Merge pull request 'Dev' (#5) from dev into main
Build and deploy / build-and-push (push) Successful in 1m2s
Build and deploy / deploy (push) Successful in 12s

Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
2026-08-30 09:29:41 +00:00
96 changed files with 3199 additions and 199 deletions
+1
View File
@@ -9,6 +9,7 @@ __pycache__/
venv/ venv/
env/ env/
Bug/ Bug/
regles/
# Variables d'environnement / secrets locaux # Variables d'environnement / secrets locaux
.env .env
+8
View File
@@ -0,0 +1,8 @@
<svg width="32" height="32" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Forge favicon">
<rect width="120" height="120" rx="24" fill="#161b25"/>
<polygon points="60,15 30,75 60,105" fill="#ff5f2e"/>
<polygon points="60,15 90,75 60,105" fill="#ffb020"/>
<polyline points="46,43 60,35 74,43" fill="none" stroke="#161b25" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="38,59 60,51 82,59" fill="none" stroke="#161b25" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="30,75 60,67 90,75" fill="none" stroke="#161b25" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 703 B

+8
View File
@@ -0,0 +1,8 @@
<svg width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Forge (sur fond blanc)">
<rect x="1" y="1" width="118" height="118" rx="16" fill="#ffffff" stroke="#e2e5eb" stroke-width="1"/>
<polygon points="60,15 30,75 60,105" fill="#ff5f2e"/>
<polygon points="60,15 90,75 60,105" fill="#ffb020"/>
<polyline points="46,43 60,35 74,43" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="38,59 60,51 82,59" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="30,75 60,67 90,75" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 760 B

+12
View File
@@ -0,0 +1,12 @@
<svg width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Forge">
<!-- Carte de fond — le mark n'est jamais utilisé sans sa carte -->
<rect width="120" height="120" rx="16" fill="#161b25"/>
<!-- Facette gauche — orange, accent 1 -->
<polygon points="60,15 30,75 60,105" fill="#ff5f2e"/>
<!-- Facette droite — ambre, accent 2 -->
<polygon points="60,15 90,75 60,105" fill="#ffb020"/>
<!-- Trois chevrons — entailles de la couleur de la carte -->
<polyline points="46,43 60,35 74,43" fill="none" stroke="#161b25" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="38,59 60,51 82,59" fill="none" stroke="#161b25" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="30,75 60,67 90,75" fill="none" stroke="#161b25" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 929 B

+2
View File
@@ -29,6 +29,7 @@ from .send_email import send_password_reset_email, EmailNotConfiguredError
from .update_profile import update_profile from .update_profile import update_profile
from .count_admins import count_admins from .count_admins import count_admins
from .delete_user import delete_user from .delete_user import delete_user
from .update_email import update_email, EmailUpdateError
__all__ = [ __all__ = [
"users_db_path", "ensure_users_schema", "is_first_user", "users_db_path", "ensure_users_schema", "is_first_user",
@@ -44,4 +45,5 @@ __all__ = [
"consume_password_reset_token", "TOKEN_TTL_MINUTES", "consume_password_reset_token", "TOKEN_TTL_MINUTES",
"send_password_reset_email", "EmailNotConfiguredError", "send_password_reset_email", "EmailNotConfiguredError",
"update_profile", "count_admins", "delete_user", "update_profile", "count_admins", "delete_user",
"update_email", "EmailUpdateError",
] ]
+2 -5
View File
@@ -1,16 +1,13 @@
import re
import pyotp import pyotp
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from .ensure_schema import ensure_users_schema from .ensure_schema import ensure_users_schema
from .connection import connect from .connection import connect
from .email_validation import is_valid_email
from .get_user_by_email import get_user_by_email from .get_user_by_email import get_user_by_email
from .is_first_user import is_first_user from .is_first_user import is_first_user
from .password_strength import password_strength from .password_strength import password_strength
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
class UserCreationError(Exception): class UserCreationError(Exception):
"""Erreur de validation lisible par un humain (affichée telle quelle """Erreur de validation lisible par un humain (affichée telle quelle
@@ -29,7 +26,7 @@ def create_user(email, password, nom, prenom):
email = (email or "").strip().lower() email = (email or "").strip().lower()
nom = (nom or "").strip() nom = (nom or "").strip()
prenom = (prenom or "").strip() prenom = (prenom or "").strip()
if not _EMAIL_RE.match(email): if not is_valid_email(email):
raise UserCreationError("Adresse email invalide.") raise UserCreationError("Adresse email invalide.")
if not nom or not prenom: if not nom or not prenom:
raise UserCreationError("Nom et prénom sont requis.") raise UserCreationError("Nom et prénom sont requis.")
+7
View File
@@ -0,0 +1,7 @@
import re
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def is_valid_email(email):
return bool(EMAIL_RE.match((email or "").strip()))
+23
View File
@@ -0,0 +1,23 @@
from .connection import connect
from .email_validation import is_valid_email
from .get_user_by_email import get_user_by_email
class EmailUpdateError(Exception):
"""Erreur de validation lisible par un humain (affichée telle quelle
sur la page de profil) — mêmes règles qu'à l'inscription (format
valide, unicité), voir create_user.py."""
def update_email(user_id, new_email):
new_email = (new_email or "").strip().lower()
if not is_valid_email(new_email):
raise EmailUpdateError("Adresse email invalide.")
existing = get_user_by_email(new_email)
if existing and existing["id"] != user_id:
raise EmailUpdateError("Un compte existe déjà avec cette adresse email.")
conn = connect()
conn.execute("UPDATE _users SET email = ? WHERE id = ?", (new_email, user_id))
conn.commit()
conn.close()
return new_email
+42
View File
@@ -0,0 +1,42 @@
"""Assemble static/style.css à partir de styles/ — un simple concaténage,
PAS une compilation Sass (voir styles/bulma-override.css pour l'incident
qui a motivé ce choix : Bulma 1.x expose déjà tout son thème via des
variables CSS standard, aucun compilateur n'est nécessaire pour le
recolorer). Ordre important — chaque fichier ne fait que redéfinir des
variables/règles posées par le précédent :
1. static/vendor/bulma.min.css — Bulma 1.0.2 vendoré tel quel, non
modifié (voir son en-tête pour la provenance).
2. styles/bulma-override.css — variables --bulma-* de la charte Forge.
3. styles/forge-tokens.css — tokens --forge-*/--accent... de l'app.
4. styles/forge-custom.css — CSS custom de l'app (floatPanel, canvas,
éditeurs...), consomme les tokens ci-dessus.
Lancer `python build_css.py` après toute modification sous styles/ ou
static/vendor/bulma.min.css."""
import os
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
_STYLES_DIR = os.path.join(_BASE_DIR, "styles")
_OUTPUT = os.path.join(_BASE_DIR, "static", "style.css")
_SOURCES = [
os.path.join(_BASE_DIR, "static", "vendor", "bulma.min.css"),
os.path.join(_STYLES_DIR, "bulma-override.css"),
os.path.join(_STYLES_DIR, "forge-tokens.css"),
os.path.join(_STYLES_DIR, "forge-custom.css"),
]
def build():
chunks = []
for path in _SOURCES:
with open(path, encoding="utf-8") as f:
chunks.append(f"/* ---- {os.path.relpath(path, _BASE_DIR)} ---- */\n" + f.read())
css = "\n\n".join(chunks)
with open(_OUTPUT, "w", encoding="utf-8") as f:
f.write(css)
print(f"Assemblé : {len(_SOURCES)} fichiers -> {_OUTPUT} ({len(css)} octets)")
if __name__ == "__main__":
build()
+8 -1
View File
@@ -32,6 +32,7 @@ from .games.game_meta import game_meta
from .games.create_game import create_game from .games.create_game import create_game
from .games.update_game_name import update_game_name from .games.update_game_name import update_game_name
from .games.delete_game import delete_game from .games.delete_game import delete_game
from .games.move_game import move_game
from .definitions.list_definitions import list_definitions from .definitions.list_definitions import list_definitions
from .definitions.get_definition import get_definition from .definitions.get_definition import get_definition
@@ -60,10 +61,15 @@ from .global_vars.update_global_variable import update_global_variable
from .global_vars.delete_global_variable import delete_global_variable from .global_vars.delete_global_variable import delete_global_variable
from .global_vars.delete_global_variable_by_id import delete_global_variable_by_id from .global_vars.delete_global_variable_by_id import delete_global_variable_by_id
from .custom_events.list_custom_events import list_custom_events
from .custom_events.get_custom_event import get_custom_event
from .custom_events.create_custom_event import create_custom_event
from .custom_events.update_custom_event import update_custom_event
__all__ = [ __all__ = [
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES",
"slugify", "table_name_for", "game_dir", "db_path", "connect", "slugify", "table_name_for", "game_dir", "db_path", "connect",
"list_games", "game_meta", "create_game", "update_game_name", "delete_game", "list_games", "game_meta", "create_game", "update_game_name", "delete_game", "move_game",
"list_definitions", "get_definition", "create_definition", "rename_definition", "list_definitions", "get_definition", "create_definition", "rename_definition",
"definitions_referencing", "add_field_to_definition", "update_field", "delete_field", "definitions_referencing", "add_field_to_definition", "update_field", "delete_field",
"delete_definition", "delete_definition",
@@ -72,4 +78,5 @@ __all__ = [
"list_global_variables", "get_global_variable", "create_global_variable", "list_global_variables", "get_global_variable", "create_global_variable",
"update_global_variable_value", "update_global_variable", "delete_global_variable", "update_global_variable_value", "update_global_variable", "delete_global_variable",
"delete_global_variable_by_id", "delete_global_variable_by_id",
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
] ]
+11 -1
View File
@@ -2,7 +2,17 @@
import os import os
PROJECTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects") # Surchargeable via FORGE_PROJECTS_DIR (même schéma que
# auth/connection.py::FORGE_USERS_DB_PATH) — utilisé par le serveur
# joueur autonome (publish/player_app_template.py), qui pose cette
# variable AVANT d'importer db/ pour pointer vers son propre dossier
# "projects/" embarqué plutôt que celui, réel, du poste de développement.
# Résolu ici, à l'IMPORT (pas par un appel de fonction) : la variable
# d'environnement doit donc déjà être posée avant le tout premier
# `import db`.
PROJECTS_DIR = os.environ.get("FORGE_PROJECTS_DIR") or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects"
)
# Types de champ exposés dans l'interface -> type de colonne SQLite réel. # Types de champ exposés dans l'interface -> type de colonne SQLite réel.
FIELD_TYPES = { FIELD_TYPES = {
View File
+26
View File
@@ -0,0 +1,26 @@
from ..connection import connect
from .ensure_custom_events_schema import ensure_custom_events_schema
def create_custom_event(slug, name, description=""):
"""Idempotent par nom (même convention que create_global_variable.py) :
si le nom existe déjà, ne touche à rien et renvoie simplement son id
existant plutôt que de lever une erreur — sans risque en cas de
double clic/soumission depuis l'onglet Événements (screen_edit.html)."""
name = (name or "").strip()
if not name:
return None
ensure_custom_events_schema(slug)
conn = connect(slug)
existing = conn.execute("SELECT id FROM _custom_events WHERE name = ?", (name,)).fetchone()
if existing:
conn.close()
return existing["id"]
conn.execute(
"INSERT INTO _custom_events (name, description) VALUES (?, ?)",
(name, (description or "").strip()),
)
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return new_id
@@ -0,0 +1,27 @@
from ..connection import connect
def ensure_custom_events_schema(slug):
"""Migration légère (même principe que ensure_global_vars_schema.py) :
crée _custom_events si absente. Un événement personnalisé vit pour
TOUT le jeu (pas par écran, pas par modèle) : "name" est donc UNIQUE —
c'est par ce nom qu'on le désigne dans l'interface, même si les nœuds
du graphe le référencent par id technique (voir
screens/flow/ensure_flow_schema.py, trigger_custom_event_id/
target_custom_event_id). Un événement est une pure NOTIFICATION,
sans paramètre : "déclencher" ne fait que signaler, jamais choisir
un élément — c'est à l'ÉCOUTEUR (déclencheur → condition → action)
de décider quoi faire, avec ses propres réglages habituels."""
conn = connect(slug)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS _custom_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
"""
)
conn.commit()
conn.close()
+12
View File
@@ -0,0 +1,12 @@
from ..connection import connect
from .ensure_custom_events_schema import ensure_custom_events_schema
def get_custom_event(slug, event_id):
if not event_id:
return None
ensure_custom_events_schema(slug)
conn = connect(slug)
row = conn.execute("SELECT * FROM _custom_events WHERE id = ?", (event_id,)).fetchone()
conn.close()
return dict(row) if row else None
+10
View File
@@ -0,0 +1,10 @@
from ..connection import connect
from .ensure_custom_events_schema import ensure_custom_events_schema
def list_custom_events(slug):
ensure_custom_events_schema(slug)
conn = connect(slug)
rows = conn.execute("SELECT * FROM _custom_events ORDER BY name").fetchall()
conn.close()
return [dict(r) for r in rows]
+16
View File
@@ -0,0 +1,16 @@
from ..connection import connect
from .ensure_custom_events_schema import ensure_custom_events_schema
def update_custom_event(slug, event_id, description=""):
"""Le NOM reste volontairement immuable après création — comme une
variable globale (voir update_global_variable.py) : c'est par ce nom
qu'on désigne l'événement dans l'interface, mais surtout par son ID
TECHNIQUE que les nœuds du graphe le référencent
(trigger_custom_event_id/target_custom_event_id) — le renommer ne
casserait donc rien niveau logique."""
ensure_custom_events_schema(slug)
conn = connect(slug)
conn.execute("UPDATE _custom_events SET description = ? WHERE id = ?", ((description or "").strip(), event_id))
conn.commit()
conn.close()
+23
View File
@@ -0,0 +1,23 @@
import os
from ..game_dir import game_dir
def move_game(old_slug, new_slug):
"""Renomme le dossier d'un jeu vers un nouveau slug — utilisé quand un
utilisateur change son adresse email (voir routes/auth/profile.py,
project_slug = slugify(email) pour un compte "user") : le dossier
physique du jeu doit suivre. Rend le nouveau slug unique de la même
façon que create_game() si, par un hasard extrême, il correspond déjà
à un dossier existant. Renvoie le slug FINAL (potentiellement suffixé),
à enregistrer comme nouveau project_slug."""
if old_slug == new_slug:
return old_slug
base = new_slug
n = 2
while os.path.isdir(game_dir(base)):
base = f"{new_slug}-{n}"
n += 1
new_slug = base
os.rename(game_dir(old_slug), game_dir(new_slug))
return new_slug
+3
View File
@@ -0,0 +1,3 @@
"""publish — empaquette un jeu en exécutable Windows autonome (zip avec
serveur interne, voir build_package.py) : le bouton "Publier" de la barre
de navigation d'un jeu (templates/base.html)."""
+113
View File
@@ -0,0 +1,113 @@
"""Assemble le zip exporté d'un jeu (bouton "Publier", voir
routes/publish/publish_game.py) : un dossier autonome contenant Python
portable + Flask (vendorés une fois, voir vendor_runtime.py), une copie
figée du jeu (screens/db/filters/core minimal + templates/play.html +
le sous-ensemble utile de static/), sa base SQLite et ses fichiers
envoyés (uploads/), et les scripts de lancement (player_app.py/run.bat).
Ne modifie JAMAIS les fichiers réels de l'app (tout se passe dans un
dossier de travail temporaire, supprimé après envoi de la réponse) ni la
vraie base de données du jeu (copiée, jamais déplacée)."""
import os
import shutil
import tempfile
import zipfile
import db
from . import vendor_runtime
_PUBLISH_DIR = os.path.dirname(os.path.abspath(__file__))
_BASE_DIR = os.path.dirname(_PUBLISH_DIR)
# Sous-ensemble de static/ réellement nécessaire au mode jouable (voir
# templates/play.html) — jamais les assets propres à l'éditeur seul
# (icônes de l'interface de l'éditeur mises à part, déjà dans icons/).
_STATIC_ITEMS = ["style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css"]
_SLUG_PLACEHOLDER = "_SLUG_PLACEHOLDER"
def _copy_engine_sources(staging_dir):
"""Copie la partie du moteur nécessaire au mode jouable SEUL (jamais
l'éditeur ni l'authentification — voir le constat d'exploration dans
le plan : screens/ et db/ sont déjà totalement découplés de auth//
routes/, core/flask_app.py et core/jinja_filters.py sont les 2 SEULS
fichiers de core/ dont ils dépendent réellement)."""
for pkg in ("screens", "db", "filters"):
shutil.copytree(os.path.join(_BASE_DIR, pkg), os.path.join(staging_dir, pkg),
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
core_dir = os.path.join(staging_dir, "core")
os.makedirs(core_dir, exist_ok=True)
for fname in ("__init__.py", "flask_app.py", "jinja_filters.py"):
shutil.copy2(os.path.join(_BASE_DIR, "core", fname), os.path.join(core_dir, fname))
templates_dir = os.path.join(staging_dir, "templates")
os.makedirs(templates_dir, exist_ok=True)
shutil.copy2(os.path.join(_BASE_DIR, "templates", "play.html"), os.path.join(templates_dir, "play.html"))
static_dir = os.path.join(staging_dir, "static")
for item in _STATIC_ITEMS:
src = os.path.join(_BASE_DIR, "static", item)
dst = os.path.join(static_dir, item)
os.makedirs(os.path.dirname(dst), exist_ok=True)
if os.path.isdir(src):
shutil.copytree(src, dst)
else:
shutil.copy2(src, dst)
def _copy_game_data(staging_dir, slug):
"""Copie le dossier RÉEL du jeu (game.db + uploads/, voir
db.game_dir) tel quel — jamais déplacé, jamais modifié."""
src = db.game_dir(slug)
dst = os.path.join(staging_dir, "projects", slug)
shutil.copytree(src, dst, ignore=shutil.ignore_patterns("index.html", "index.css", "index.js"))
def _write_player_app(staging_dir, slug):
template_path = os.path.join(_PUBLISH_DIR, "player_app_template.py")
with open(template_path, encoding="utf-8") as f:
content = f.read()
content = content.replace(_SLUG_PLACEHOLDER, slug)
with open(os.path.join(staging_dir, "player_app.py"), "w", encoding="utf-8") as f:
f.write(content)
def _write_run_bat(staging_dir):
src = os.path.join(_PUBLISH_DIR, "run_bat_template.bat")
shutil.copy2(src, os.path.join(staging_dir, "run.bat"))
def _zip_dir(staging_dir, zip_path):
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for root, _dirs, files in os.walk(staging_dir):
for name in files:
full = os.path.join(root, name)
rel = os.path.relpath(full, staging_dir)
zf.write(full, rel)
def build_game_zip(slug):
"""Construit le zip et renvoie son chemin (dans un dossier temporaire
— à l'appelant de le supprimer une fois la réponse HTTP envoyée, voir
routes/publish/publish_game.py)."""
game = db.game_meta(slug)
python_dir, pylibs_dir = vendor_runtime.ensure_vendor_ready()
staging_dir = tempfile.mkdtemp(prefix="forge_publish_")
try:
shutil.copytree(python_dir, os.path.join(staging_dir, "python-embed"))
shutil.copytree(pylibs_dir, os.path.join(staging_dir, "python-embed", "pylibs"))
_copy_engine_sources(staging_dir)
_copy_game_data(staging_dir, slug)
_write_player_app(staging_dir, slug)
_write_run_bat(staging_dir)
zip_fd, zip_path = tempfile.mkstemp(prefix="forge_publish_", suffix=".zip")
os.close(zip_fd)
_zip_dir(staging_dir, zip_path)
finally:
shutil.rmtree(staging_dir, ignore_errors=True)
return zip_path, game["name"]
+107
View File
@@ -0,0 +1,107 @@
"""player_app.py — serveur autonome généré par Forge Engine pour UN SEUL
jeu (voir publish/build_package.py) : aucune installation externe requise,
lancé par double-clic sur run.bat (Python portable + Flask embarqués à
côté de ce fichier). Reprend telles quelles les routes utilisées par le
mode jouable (voir templates/play.html) — jamais l'éditeur ni
l'authentification, absents de cet export.
SLUG est remplacé littéralement par build_package.py au moment de la
publication (voir _SLUG_PLACEHOLDER) — ce fichier n'est jamais exécuté
tel quel depuis le dépôt source."""
import json
import os
import socket
import sys
import threading
import webbrowser
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# Le python311._pth de Python embeddable (voir publish/vendor_runtime.py)
# ne met sur sys.path QUE le dossier de python.exe lui-même (python-embed/)
# et "pylibs" — jamais le dossier de CE script, qui vit un cran au-dessus
# (sibling de python-embed/). Sans cette ligne, "import core"/"db"/
# "screens" échoue avec ModuleNotFoundError bien qu'ils soient juste à
# côté de ce fichier.
sys.path.insert(0, _BASE_DIR)
# Doit être posé AVANT tout import de db/ : db/constants.py lit
# FORGE_PROJECTS_DIR à l'IMPORT, pas à l'appel — pointe ici vers le
# dossier "projects/" embarqué à côté de ce fichier, jamais un chemin du
# poste où le jeu a été publié.
os.environ["FORGE_PROJECTS_DIR"] = os.path.join(_BASE_DIR, "projects")
from flask import jsonify, redirect, render_template, request, send_from_directory, url_for # noqa: E402
from core.flask_app import app # noqa: E402
from core import jinja_filters # noqa: E402,F401 - enregistre les filtres Jinja (elstyle/eltransform/elabel/colname)
import db # noqa: E402
import screens # noqa: E402
# Pas de CSRF/auth dans cet export (un seul jeu, aucune connexion) —
# play.html appelle quand même csrf_token() en Jinja (voir base.html/
# play.html) : un no-op le laisse fonctionner tel quel, sans le forker.
app.jinja_env.globals["csrf_token"] = lambda: ""
SLUG = "_SLUG_PLACEHOLDER"
@app.route("/")
def index():
return redirect(url_for("game_play", slug=SLUG))
@app.route("/game/<slug>/play")
def game_play(slug):
game = db.game_meta(SLUG)
payload = screens.full_game_payload(SLUG)
return render_template(
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
)
@app.route("/game/<slug>/runtime-payload")
def runtime_payload(slug):
return jsonify(screens.full_game_payload(SLUG))
@app.route("/game/<slug>/flow/nodes/<int:node_id>/run-data", methods=["POST"])
def flow_node_run_data(slug, node_id):
node = screens.get_flow_node(SLUG, node_id)
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_donnee":
return jsonify({"ok": False}), 400
if node.get("target_row_id") == screens.CLICKED_ROW_ID:
body = request.get_json(silent=True) or {}
node = {**node, "target_row_id": body.get("clicked_row_id")}
ok = screens.apply_data_action(SLUG, node)
return jsonify({"ok": ok})
@app.route("/game/<slug>/flow/nodes/<int:node_id>/run-variable", methods=["POST"])
def flow_node_run_variable(slug, node_id):
node = screens.get_flow_node(SLUG, node_id)
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_variable":
return jsonify({"ok": False}), 400
ok = screens.apply_variable_action(SLUG, node)
return jsonify({"ok": ok})
@app.route("/game/<slug>/uploads/<path:filename>")
def uploaded_file(slug, filename):
return send_from_directory(os.path.join(db.game_dir(SLUG), "uploads"), filename)
def _open_browser(port):
webbrowser.open(f"http://127.0.0.1:{port}/")
if __name__ == "__main__":
# Port libre choisi au hasard par l'OS (jamais le même serveur à
# relancer sur un port déjà pris par une partie précédente encore
# ouverte) — le navigateur est ouvert automatiquement, l'utilisateur
# n'a jamais besoin de connaître ni taper le port.
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
threading.Timer(1.0, _open_browser, args=(port,)).start()
app.run(host="127.0.0.1", port=port, debug=False, use_reloader=False)
+4
View File
@@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0"
python-embed\python.exe player_app.py
pause
+91
View File
@@ -0,0 +1,91 @@
"""Prépare, une seule fois par poste, tout ce qu'il faut pour empaqueter
un jeu en exécutable autonome : Python portable (embeddable, python.org)
et les paquets pip nécessaires (Flask + ses dépendances), mis en cache
localement sous data/publish_vendor/ (non commité, voir .gitignore —
même dossier que data/secret_key/data/users.db, déjà runtime-only).
La toute PREMIÈRE publication d'un jeu sur un poste donné télécharge ces
~15 Mo (connexion internet nécessaire, une seule fois) ; toutes les
publications suivantes réutilisent le cache, hors-ligne. Le JOUEUR qui
reçoit le zip final, lui, n'a jamais besoin d'internet — ce cache ne
concerne que la machine qui PUBLIE.
Fonctions séparées et mockables (download_python_embeddable/
vendor_flask) pour que les tests ne déclenchent jamais un vrai
téléchargement."""
import os
import subprocess
import sys
import urllib.request
import zipfile
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
VENDOR_DIR = os.environ.get("FORGE_PUBLISH_VENDOR_DIR") or os.path.join(_BASE_DIR, "data", "publish_vendor")
PYTHON_VERSION = "3.11.9"
PYTHON_EMBED_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip"
PYTHON_EMBED_DIR = os.path.join(VENDOR_DIR, "python-embed-amd64")
PYLIBS_DIR = os.path.join(VENDOR_DIR, "pylibs")
# Mêmes versions que requirements.txt — seul Flask est listé explicitement,
# ses dépendances (Werkzeug/Jinja2/MarkupSafe/click/itsdangerous/blinker)
# suivent automatiquement via pip.
FLASK_SPEC = "Flask==3.0.3"
def download_python_embeddable():
"""Télécharge et extrait le ZIP Python embeddable officiel si absent
du cache — no-op si déjà présent (idempotent, sûr à rappeler à
chaque publication)."""
marker = os.path.join(PYTHON_EMBED_DIR, "python.exe")
if os.path.isfile(marker):
return PYTHON_EMBED_DIR
os.makedirs(PYTHON_EMBED_DIR, exist_ok=True)
zip_path = os.path.join(VENDOR_DIR, "python-embed-amd64.zip")
urllib.request.urlretrieve(PYTHON_EMBED_URL, zip_path)
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(PYTHON_EMBED_DIR)
os.remove(zip_path)
_enable_pylibs_path(PYTHON_EMBED_DIR)
return PYTHON_EMBED_DIR
def _enable_pylibs_path(python_embed_dir):
"""Le ZIP embeddable désactive site-packages par défaut
(python311._pth ne contient que "python311.zip" et "."). On ajoute une
ligne "pylibs" (chemin relatif à python.exe) plutôt que de réactiver
tout le mécanisme "site" — les paquets vendorés (Flask...) sont de
simples dossiers purement Python, aucun besoin d'aller plus loin."""
pth_files = [f for f in os.listdir(python_embed_dir) if f.endswith("._pth")]
if not pth_files:
return
pth_path = os.path.join(python_embed_dir, pth_files[0])
with open(pth_path, encoding="utf-8") as f:
content = f.read()
if "pylibs" not in content:
with open(pth_path, "a", encoding="utf-8") as f:
f.write("\npylibs\n")
def vendor_flask():
"""pip install --target=... : installe Flask (paquets purs Python,
indépendants de la version exacte de l'interpréteur embeddable) dans
le cache, réutilisé par tous les exports suivants — no-op si déjà
fait."""
if os.path.isdir(PYLIBS_DIR) and os.listdir(PYLIBS_DIR):
return PYLIBS_DIR
os.makedirs(PYLIBS_DIR, exist_ok=True)
subprocess.run(
[sys.executable, "-m", "pip", "install", "--target", PYLIBS_DIR, "--no-compile", FLASK_SPEC],
check=True,
)
return PYLIBS_DIR
def ensure_vendor_ready():
"""Point d'entrée unique appelé par build_package.py — télécharge/
installe ce qui manque, puis renvoie (dossier_python_embed,
dossier_pylibs)."""
python_dir = download_python_embeddable()
pylibs_dir = vendor_flask()
return python_dir, pylibs_dir
+1 -1
View File
@@ -1 +1 @@
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events
+38
View File
@@ -32,6 +32,44 @@ def profile_update_name():
return _render(success="Informations mises à jour.") return _render(success="Informations mises à jour.")
@app.route("/profile/update-email", methods=["POST"])
def profile_update_email():
user = g.current_user
current_password = request.form.get("current_password", "")
new_email = request.form.get("email", "").strip().lower()
if not auth.verify_password(user, current_password):
return _render(error="Mot de passe incorrect.")
try:
auth.update_email(user["id"], new_email)
except auth.EmailUpdateError as exc:
return _render(error=str(exc))
# Le dossier d'un compte "user" porte le nom de son adresse email (voir
# create_user.py/register_2fa.py, project_slug = slugify(email)) — il
# doit suivre le changement, sans quoi project_slug ne correspondrait
# plus à aucun dossier réel. Un "admin" n'a pas de project_slug dédié :
# rien à renommer pour ce rôle.
if user["role"] != "admin" and user.get("project_slug"):
new_slug = db.move_game(user["project_slug"], db.slugify(new_email))
auth.set_project_slug(user["id"], new_slug)
return _render(success="Adresse email mise à jour.")
@app.route("/profile/regenerate-recovery-codes", methods=["POST"])
def profile_regenerate_recovery_codes():
user = g.current_user
current_password = request.form.get("current_password", "")
if not auth.verify_password(user, current_password):
return _render(error="Mot de passe incorrect.")
# Posés en session pour être affichés une seule fois par le modal déjà
# utilisé à l'inscription (voir core/recovery_codes_flash.py et
# templates/base.html) — les 10 anciens codes sont déjà invalidés à ce
# stade (auth.generate_recovery_codes supprime tout jeu précédent).
session["recovery_codes_to_show"] = auth.generate_recovery_codes(user["id"])
return _render()
@app.route("/profile/change-password", methods=["POST"]) @app.route("/profile/change-password", methods=["POST"])
def profile_change_password(): def profile_change_password():
user = g.current_user user = g.current_user
+1
View File
@@ -0,0 +1 @@
from . import custom_event_new, custom_event_edit, custom_event_delete
@@ -0,0 +1,18 @@
from flask import request, redirect, url_for
import screens
from core.flask_app import app
@app.route("/game/<slug>/events/<int:event_id>/delete", methods=["POST"])
def custom_event_delete(slug, event_id):
"""Supprime aussi TOUS les nœuds Déclencheur/Action qui référencent cet
événement, sur toutes les scènes et tous les modèles (voir
screens/custom_events/delete_custom_event.py) — seule suppression
d'entité game-wide du moteur à vraiment cascader, sur demande
explicite (une variable globale ou une définition d'objet supprimée,
elles, laissent des références pendantes tolérées ailleurs)."""
screen_id = request.form.get("screen_id", type=int)
screens.delete_custom_event(slug, event_id)
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="events"))
+13
View File
@@ -0,0 +1,13 @@
from flask import request, redirect, url_for
import db
from core.flask_app import app
@app.route("/game/<slug>/events/<int:event_id>/edit", methods=["POST"])
def custom_event_edit(slug, event_id):
description = request.form.get("description") or ""
screen_id = request.form.get("screen_id", type=int)
db.update_custom_event(slug, event_id, description)
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="events"))
+20
View File
@@ -0,0 +1,20 @@
from flask import request, redirect, url_for
import db
from core.flask_app import app
@app.route("/game/<slug>/events/new", methods=["POST"])
def custom_event_new(slug):
"""Depuis l'onglet Événements de l'éditeur de scène/modèle
(screen_edit.html) — idempotent par nom (voir db.create_custom_event),
sans risque en cas de double soumission. screen_id (l'écran/modèle
depuis lequel on a créé l'événement) sert uniquement à revenir sur le
bon écran ensuite, jamais stocké sur l'événement lui-même (game-wide)."""
name = (request.form.get("name") or "").strip()
description = request.form.get("description") or ""
screen_id = request.form.get("screen_id", type=int)
if name:
db.create_custom_event(slug, name, description)
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="events"))
-1
View File
@@ -14,5 +14,4 @@ def game_play(slug):
payload = screens.full_game_payload(slug) payload = screens.full_game_payload(slug)
return render_template( return render_template(
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload), "play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
google_fonts_link=screens.GOOGLE_FONTS_LINK,
) )
+1
View File
@@ -0,0 +1 @@
from . import publish_game
+28
View File
@@ -0,0 +1,28 @@
import os
from flask import after_this_request, send_file
import db
from publish.build_package import build_game_zip
from core.flask_app import app
@app.route("/game/<slug>/publish", methods=["POST"])
def publish_game(slug):
"""Construit et renvoie le zip exécutable autonome du jeu (voir
publish/build_package.py) — protégée par la même garde d'accès que
toutes les autres routes /game/<slug>/... (core/auth_guard.py),
aucune vérification supplémentaire nécessaire ici."""
zip_path, game_name = build_game_zip(slug)
@after_this_request
def _cleanup(response):
try:
os.remove(zip_path)
except OSError:
pass
return response
download_name = f"{db.slugify(game_name)}.zip"
return send_file(zip_path, as_attachment=True, download_name=download_name, mimetype="application/zip")
+5 -1
View File
@@ -44,6 +44,9 @@ def screen_edit(slug, screen_id):
screens_map = {s["id"]: s["name"] for s in all_screens} screens_map = {s["id"]: s["name"] for s in all_screens}
animation_clips = screens.list_animation_clips(slug, screen_id) animation_clips = screens.list_animation_clips(slug, screen_id)
global_variables = db.list_global_variables(slug) global_variables = db.list_global_variables(slug)
custom_events = db.list_custom_events(slug)
custom_event_usages = screens.list_custom_event_usages(slug)
custom_events_map = {e["id"]: {"name": e["name"]} for e in custom_events}
return render_template( return render_template(
"screen_edit.html", "screen_edit.html",
@@ -63,7 +66,6 @@ def screen_edit(slug, screen_id):
data_operation_labels=screens.DATA_OPERATION_LABELS, data_operation_labels=screens.DATA_OPERATION_LABELS,
definitions=definitions, definitions_json=json.dumps(definitions_data), definitions=definitions, definitions_json=json.dumps(definitions_data),
selected_id=selected_id, selected_controls=selected_controls, selected_id=selected_id, selected_controls=selected_controls,
google_fonts_link=screens.GOOGLE_FONTS_LINK,
flow_nodes=flow_nodes, flow_edges=flow_edges, flow_nodes=flow_nodes, flow_edges=flow_edges,
flow_nodes_json=json.dumps(flow_nodes), flow_edges_json=json.dumps(flow_edges), flow_nodes_json=json.dumps(flow_nodes), flow_edges_json=json.dumps(flow_edges),
trigger_events=screens.TRIGGER_EVENTS, condition_operators=screens.CONDITION_OPERATORS, trigger_events=screens.TRIGGER_EVENTS, condition_operators=screens.CONDITION_OPERATORS,
@@ -81,4 +83,6 @@ def screen_edit(slug, screen_id):
animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS), animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS),
fontawesome_icons=screens.FONTAWESOME_ICONS, fontawesome_icons=screens.FONTAWESOME_ICONS,
global_variables=global_variables, global_variables=global_variables,
custom_events=custom_events, custom_event_usages=custom_event_usages,
custom_events_map_json=json.dumps(custom_events_map),
) )
+6 -2
View File
@@ -40,7 +40,6 @@ de fonctionner à l'identique.
from .widgets.registry import WIDGETS from .widgets.registry import WIDGETS
from .widgets.container_widgets import CONTAINER_WIDGETS from .widgets.container_widgets import CONTAINER_WIDGETS
from .widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS from .widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
from .widgets.font_options import GOOGLE_FONTS_LINK
from .widgets.fontawesome_icons import FONTAWESOME_ICONS from .widgets.fontawesome_icons import FONTAWESOME_ICONS
from .widgets.widget_meta import widget_meta from .widgets.widget_meta import widget_meta
from .widgets.controls_with_values import controls_with_values from .widgets.controls_with_values import controls_with_values
@@ -56,7 +55,9 @@ from .labels.element_action_properties import (
from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBILITY_LABELS from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBILITY_LABELS
from .labels.data_operations import DATA_OPERATIONS, DATA_OPERATION_LABELS from .labels.data_operations import DATA_OPERATIONS, DATA_OPERATION_LABELS
from .flow.constants import TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID from .flow.constants import (
TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID,
)
from .rendering.render_element_html import render_element_html from .rendering.render_element_html import render_element_html
from .rendering.trigger_for import _trigger_for from .rendering.trigger_for import _trigger_for
@@ -118,4 +119,7 @@ from .animations.update_animation_clip import update_animation_clip
from .animations.delete_animation_clip import delete_animation_clip from .animations.delete_animation_clip import delete_animation_clip
from .animations.animate_css_catalog import ANIMATE_CSS_CATALOG, ANIMATE_CSS_LABELS from .animations.animate_css_catalog import ANIMATE_CSS_CATALOG, ANIMATE_CSS_LABELS
from .custom_events.list_custom_event_usages import list_custom_event_usages
from .custom_events.delete_custom_event import delete_custom_event
from .payload.full_game_payload import full_game_payload from .payload.full_game_payload import full_game_payload
+9
View File
@@ -0,0 +1,9 @@
"""Tout ce qui touche à la fois _custom_events (db/custom_events/) ET
_flow_nodes/_flow_edges (screens/flow/) — jamais dans db/custom_events/,
pour la même raison que screens/elements/delete_element.py n'est pas
dans db/ : ce sont deux domaines différents (une entité game-wide, des
nœuds de graphe), le nettoyage croisé vit dans screens/, pas db/."""
from .list_custom_event_usages import list_custom_event_usages
from .delete_custom_event import delete_custom_event
__all__ = ["list_custom_event_usages", "delete_custom_event"]
@@ -0,0 +1,30 @@
import db
from ..flow.ensure_flow_schema import ensure_flow_schema
def delete_custom_event(slug, event_id):
"""Seule suppression d'entité game-wide du moteur qui cascade
RÉELLEMENT (demande explicite) : une variable globale ou une
définition d'objet supprimée laisse des références pendantes
tolérées ailleurs (voir db/global_vars/, db/definitions/) — un
événement, lui, doit emporter avec lui TOUS ses écouteurs (nœuds
Déclencheur, trigger_custom_event_id) et déclencheurs (nœuds Action,
target_custom_event_id), sur TOUS les écrans ET modèles à la fois
(aucun filtre screen_id, comme screens/elements/delete_element.py —
un modèle est un écran caché, même table _flow_nodes)."""
ensure_flow_schema(slug)
conn = db.connect(slug)
conn.execute(
"""DELETE FROM _flow_edges WHERE from_node_id IN
(SELECT id FROM _flow_nodes WHERE trigger_custom_event_id = ? OR target_custom_event_id = ?)
OR to_node_id IN
(SELECT id FROM _flow_nodes WHERE trigger_custom_event_id = ? OR target_custom_event_id = ?)""",
(event_id, event_id, event_id, event_id),
)
conn.execute(
"DELETE FROM _flow_nodes WHERE trigger_custom_event_id = ? OR target_custom_event_id = ?",
(event_id, event_id),
)
conn.execute("DELETE FROM _custom_events WHERE id = ?", (event_id,))
conn.commit()
conn.close()
@@ -0,0 +1,37 @@
import db
from ..flow.ensure_flow_schema import ensure_flow_schema
def list_custom_event_usages(slug):
"""Pour CHAQUE événement, où est-il écouté ("ecoute", nœud Déclencheur)
et déclenché ("declenche", nœud Action) — une seule requête plutôt
qu'une par événement, pour l'onglet Événements (screen_edit.html) qui
les affiche tous à la fois. Aucun filtre screen_id : couvre aussi bien
les écrans que les modèles (un modèle est un écran caché, voir
screens/element_types/create_element_type.py), exactement comme la
suppression en cascade (delete_custom_event.py).
Renvoie {event_id: [{"screen_id", "screen_name", "is_template", "role"}, ...]}."""
ensure_flow_schema(slug)
conn = db.connect(slug)
rows = conn.execute(
"""
SELECT DISTINCT n.id AS node_id, s.id AS screen_id, s.name AS screen_name, s.is_template AS is_template,
CASE WHEN n.trigger_custom_event_id IS NOT NULL THEN n.trigger_custom_event_id
ELSE n.target_custom_event_id END AS event_id,
CASE WHEN n.trigger_custom_event_id IS NOT NULL THEN 'ecoute'
ELSE 'declenche' END AS role
FROM _flow_nodes n
JOIN _screens s ON s.id = n.screen_id
WHERE n.trigger_custom_event_id IS NOT NULL OR n.target_custom_event_id IS NOT NULL
ORDER BY s.name
"""
).fetchall()
conn.close()
usages = {}
for row in rows:
usages.setdefault(row["event_id"], []).append({
"screen_id": row["screen_id"], "screen_name": row["screen_name"],
"is_template": bool(row["is_template"]), "role": row["role"],
})
return usages
+17
View File
@@ -42,6 +42,14 @@ TRIGGER_EVENTS = [
# cacher/montrer un élément selon l'état de la partie SANS qu'un clic # cacher/montrer un élément selon l'état de la partie SANS qu'un clic
# explicite soit nécessaire pour le réévaluer. # explicite soit nécessaire pour le réévaluer.
("affichage", "À l'affichage de l'écran"), ("affichage", "À l'affichage de l'écran"),
# Écoute un événement personnalisé (voir screens/custom_events/),
# déclenché depuis N'IMPORTE QUEL autre graphe (une autre scène, un
# autre modèle) via l'action "declencher_evenement" — ni élément ni
# écran précis : trigger_custom_event_id (quel événement) est le seul
# réglage propre à ce nœud, retrouvé par un scan global de
# gameData.flows côté client (voir dispatchGameEvent() dans
# templates/play.html), au même titre que findTriggerNode().
("evenement", "Sur un événement personnalisé"),
] ]
CONDITION_OPERATORS = [ CONDITION_OPERATORS = [
@@ -69,4 +77,13 @@ FLOW_NODE_FIELDS = {
"action_type", "target_screen_id", "target_element_id", "element_property", "element_value", "action_type", "target_screen_id", "target_element_id", "element_property", "element_value",
"target_definition_id", "target_row_id", "target_field", "data_operation", "data_value", "target_definition_id", "target_row_id", "target_field", "data_operation", "data_value",
"target_variable", "target_variable",
# Événements personnalisés (voir screens/custom_events/) : quel
# événement un nœud Déclencheur écoute (trigger_event="evenement") ou
# un nœud Action déclenche (action_type="declencher_evenement") — un
# événement est une pure NOTIFICATION, sans paramètre : "déclencher"
# ne fait que signaler, jamais choisir un élément. C'est à l'ÉCOUTEUR
# (déclencheur → condition → action) de décider quoi faire ensuite,
# avec ses propres réglages habituels (target_element_id normal, pas
# de mécanisme dynamique dédié).
"trigger_custom_event_id", "target_custom_event_id",
} }
+11
View File
@@ -72,5 +72,16 @@ def ensure_flow_schema(slug):
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_variable TEXT") conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_variable TEXT")
if "cond_variable_chemin" not in cols: if "cond_variable_chemin" not in cols:
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_variable_chemin TEXT") conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_variable_chemin TEXT")
if "trigger_custom_event_id" not in cols:
# Événements personnalisés (voir screens/custom_events/) : quel
# événement un nœud Déclencheur écoute (trigger_event="evenement")
# ou un nœud Action déclenche (action_type="declencher_evenement").
# Pas de FK vers _custom_events : une suppression d'événement
# nettoie déjà ces colonnes elle-même (voir
# screens/custom_events/delete_custom_event.py), une contrainte
# ferait double emploi sans rien apporter de plus.
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN trigger_custom_event_id INTEGER")
if "target_custom_event_id" not in cols:
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN target_custom_event_id INTEGER")
conn.commit() conn.commit()
conn.close() conn.close()
+1
View File
@@ -8,5 +8,6 @@ ACTION_TYPE_LABELS = {
"ouvrir_ligne": "Ouvrir la ligne de Répéteur cliquée", "ouvrir_ligne": "Ouvrir la ligne de Répéteur cliquée",
"activer_onglet": "Afficher cet élément, masquer tous ses frères", "activer_onglet": "Afficher cet élément, masquer tous ses frères",
"attendre": "Attendre quelques secondes avant de continuer", "attendre": "Attendre quelques secondes avant de continuer",
"declencher_evenement": "Déclencher un événement",
"rien": "Ne rien faire", "rien": "Ne rien faire",
} }
+1
View File
@@ -4,4 +4,5 @@ TRIGGER_LABELS = {
"affichage": "À l'affichage de l'écran", "affichage": "À l'affichage de l'écran",
"survol": "Au survol", "survol": "Au survol",
"fin_survol": "Fin du survol", "fin_survol": "Fin du survol",
"evenement": "Sur un événement personnalisé",
} }
-5
View File
@@ -8,8 +8,3 @@ FONT_OPTIONS = [
("'Montserrat', sans-serif", "Montserrat (Google Fonts, affirmée)"), ("'Montserrat', sans-serif", "Montserrat (Google Fonts, affirmée)"),
("'Playfair Display', serif", "Playfair Display (Google Fonts, élégante)"), ("'Playfair Display', serif", "Playfair Display (Google Fonts, élégante)"),
] ]
GOOGLE_FONTS_LINK = (
"https://fonts.googleapis.com/css2?"
"family=Poppins:wght@400;600;700&family=Roboto:wght@400;700&"
"family=Montserrat:wght@400;700&family=Playfair+Display:wght@400;700&display=swap"
)
+8
View File
@@ -0,0 +1,8 @@
<svg width="32" height="32" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Forge favicon">
<rect width="120" height="120" rx="24" fill="#161b25"/>
<polygon points="60,15 30,75 60,105" fill="#ff5f2e"/>
<polygon points="60,15 90,75 60,105" fill="#ffb020"/>
<polyline points="46,43 60,35 74,43" fill="none" stroke="#161b25" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="38,59 60,51 82,59" fill="none" stroke="#161b25" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="30,75 60,67 90,75" fill="none" stroke="#161b25" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 703 B

+8
View File
@@ -0,0 +1,8 @@
<svg width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Forge (sur fond blanc)">
<rect x="1" y="1" width="118" height="118" rx="16" fill="#ffffff" stroke="#e2e5eb" stroke-width="1"/>
<polygon points="60,15 30,75 60,105" fill="#ff5f2e"/>
<polygon points="60,15 90,75 60,105" fill="#ffb020"/>
<polyline points="46,43 60,35 74,43" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="38,59 60,51 82,59" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="30,75 60,67 90,75" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 760 B

+12
View File
@@ -0,0 +1,12 @@
<svg width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Forge">
<!-- Carte de fond — le mark n'est jamais utilisé sans sa carte -->
<rect width="120" height="120" rx="16" fill="#161b25"/>
<!-- Facette gauche — orange, accent 1 -->
<polygon points="60,15 30,75 60,105" fill="#ff5f2e"/>
<!-- Facette droite — ambre, accent 2 -->
<polygon points="60,15 90,75 60,105" fill="#ffb020"/>
<!-- Trois chevrons — entailles de la couleur de la carte -->
<polyline points="46,43 60,35 74,43" fill="none" stroke="#161b25" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="38,59 60,51 82,59" fill="none" stroke="#161b25" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="30,75 60,67 90,75" fill="none" stroke="#161b25" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 929 B

+241 -25
View File
File diff suppressed because one or more lines are too long
+7
View File
File diff suppressed because one or more lines are too long
+3
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+423
View File
@@ -0,0 +1,423 @@
/* cyrillic-ext */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459WRhyzbi.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459W1hyzbi.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459WZhyzbi.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459Wdhyzbi.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459Wlhyw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459WRhyzbi.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459W1hyzbi.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459WZhyzbi.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459Wdhyzbi.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459Wlhyw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/nuFiD-vYSZviVYUb_rj3ij__anPXDTjYgFE_.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/nuFiD-vYSZviVYUb_rj3ij__anPXDTPYgFE_.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/nuFiD-vYSZviVYUb_rj3ij__anPXDTLYgFE_.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/nuFiD-vYSZviVYUb_rj3ij__anPXDTzYgA.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/nuFiD-vYSZviVYUb_rj3ij__anPXDTjYgFE_.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/nuFiD-vYSZviVYUb_rj3ij__anPXDTPYgFE_.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/nuFiD-vYSZviVYUb_rj3ij__anPXDTLYgFE_.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/nuFiD-vYSZviVYUb_rj3ij__anPXDTzYgA.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* devanagari */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/pxiEyp8kv8JHgFVrJJbecmNE.woff2) format('woff2');
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/pxiEyp8kv8JHgFVrJJnecmNE.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/pxiEyp8kv8JHgFVrJJfecg.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* devanagari */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(files/pxiByp8kv8JHgFVrLEj6Z11lFc-K.woff2) format('woff2');
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(files/pxiByp8kv8JHgFVrLEj6Z1JlFc-K.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(files/pxiByp8kv8JHgFVrLEj6Z1xlFQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* devanagari */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/pxiByp8kv8JHgFVrLCz7Z11lFc-K.woff2) format('woff2');
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/pxiByp8kv8JHgFVrLCz7Z1JlFc-K.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/pxiByp8kv8JHgFVrLCz7Z1xlFQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* math */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* math */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+142
View File
@@ -0,0 +1,142 @@
/* bulma-override.css — palette Forge appliquée à Bulma 1.0.2 (vendoré tel
quel, non modifié, dans static/vendor/bulma.min.css) SANS recompilation
Sass : Bulma 1.x expose tout son thème via de vraies variables CSS
(--bulma-primary-h/-s/-l, --bulma-radius...), conçues justement pour être
surchargées après coup — voir regles/FORGE_ENGINE_TEMPLATE_BULMA.md, §6.
Chargé APRÈS bulma.min.css (voir build_css.py) : ce fichier ne fait que
redéfinir ses variables, jamais les siennes propres.
ATTENTION (leçon de la première version de ce chantier) : du code Python
de rendu du jeu (screens/rendering/render_jauge.py) fixe en ligne
--bulma-progress-value-background-color pour piloter le remplissage
d'une jauge — Bulma 1.x est donc une DÉPENDANCE FONCTIONNELLE réelle de
l'app, pas seulement un habillage visuel. Ne plus jamais changer de
version de Bulma sans vérifier `grep -rn "\-\-bulma-" screens/ templates/`
d'abord. */
:root {
--bulma-family-primary: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
--bulma-radius: 10px;
--bulma-radius-small: 8px;
--bulma-radius-medium: 12px;
--bulma-radius-large: 16px;
--bulma-radius-rounded: 999px;
--bulma-primary-h: 14deg;
--bulma-primary-s: 100%;
--bulma-primary-l: 59%;
--bulma-link-h: 14deg;
--bulma-link-s: 100%;
--bulma-link-l: 59%;
--bulma-info-h: 217deg;
--bulma-info-s: 91%;
--bulma-info-l: 60%;
--bulma-success-h: 160deg;
--bulma-success-s: 84%;
--bulma-success-l: 39%;
--bulma-warning-h: 39deg;
--bulma-warning-s: 100%;
--bulma-warning-l: 56%;
--bulma-danger-h: 4deg;
--bulma-danger-s: 86%;
--bulma-danger-l: 58%;
/* Un seul teinte partagée par tous les fonds (main/bis/ter/bordures),
seule la LUMINOSITÉ varie — c'est le modèle natif de Bulma 1.x, calé
ici sur les valeurs de la charte Forge (--forge-bg/-bg-secondary/
-card/-border, voir bulma-tokens.css). */
--bulma-scheme-h: 220deg;
--bulma-scheme-s: 26%;
--bulma-scheme-main-l: 6%;
--bulma-scheme-main-bis-l: 9%;
--bulma-scheme-main-ter-l: 12%;
--bulma-background-l: 12%;
--bulma-border-weak-l: 15%;
--bulma-border-l: 18%;
--bulma-text-weak-l: 60%;
--bulma-text-l: 93%;
--bulma-text-strong-l: 97%;
--bulma-text-title-l: 100%;
--bulma-box-radius: var(--bulma-radius-large);
--bulma-card-radius: var(--bulma-radius-large);
--bulma-navbar-height: 3.5rem;
}
/* Composants dont Bulma ne prévoit pas de variable dédiée (mise en page
propre au navbar/tableaux/onglets de l'app, badges maison) — voir §6 du
document de règles. */
.navbar {
background: var(--forge-bg-secondary);
border-bottom: 1px solid var(--forge-border);
}
.navbar-item.brand { gap: 8px; font-size: 1rem; }
.navbar-item, .navbar-link { color: var(--forge-text); }
.navbar-item.is-active, .navbar-item:hover, .navbar-link:hover {
color: var(--accent);
background: var(--forge-card);
}
.table thead th {
position: sticky;
top: 0;
z-index: 2;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.tabs.is-toggle li.is-active a { color: #14100c; }
.button.is-primary, button.primary {
background: var(--gradient);
border: none;
color: #14100c;
font-weight: 700;
}
.button.is-primary:hover, button.primary:hover { box-shadow: 0 6px 16px -6px var(--glow); }
.box, .card {
background: linear-gradient(180deg, var(--forge-card), var(--forge-bg-secondary));
box-shadow: none;
transition: box-shadow .15s ease;
}
.box:hover, .card:hover { box-shadow: 0 10px 25px -8px var(--glow); }
.forge-badge {
display: inline-block;
padding: 0.4rem 1rem;
border-radius: 999px;
background: rgba(255, 95, 46, 0.1);
border: 1px solid rgba(255, 95, 46, 0.35);
color: var(--accent);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.forge-badge.is-success { background: rgba(16, 185, 129, 0.12); border-color: rgba(16, 185, 129, 0.35); color: var(--status-success); }
.forge-badge.is-danger { background: rgba(240, 68, 56, 0.12); border-color: rgba(240, 68, 56, 0.35); color: var(--status-danger); }
/* Titre H1 en dégradé signature — réservé aux pages d'accueil/marketing et
à l'écran de connexion (§3/§5). */
.forge-gradient-title {
background: var(--gradient);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* Pages d'authentification (login/register/2FA/mot de passe oublié) :
seul endroit du site, avec l'accueil marketing, où le H1 en dégradé et
la grille de fond fine sont autorisés — absents de tous les écrans de
travail (tableau de bord, éditeur...). */
body.authBody {
background:
radial-gradient(circle at 1px 1px, rgba(232, 236, 244, 0.06) 1px, transparent 0) 0 0/26px 26px,
var(--forge-bg);
}
.authScreen { display: flex; align-items: center; justify-content: center; min-height: calc(100vh - 3.5rem); padding: 2rem 1rem; }
.authCard { width: 100%; max-width: 440px; }
.authCard.authCard-wide { max-width: 500px; }
.authTitle { font-size: clamp(1.75rem, 3.5vw, 2.5rem); margin-bottom: 0.5rem; }
+617
View File
@@ -0,0 +1,617 @@
/* Tokens (--bg, --panel, --accent...) définis une seule fois dans
forge-theme.scss, qui @importe ce fichier APRÈS Bulma — ce fichier
continue de référencer les mêmes noms de variable qu'avant la charte
Forge (voir les alias --bg/--panel/--panel2/--border/--text/--text-dim/
--danger dans forge-theme.scss), pour n'avoir à toucher qu'aux VALEURS
des tokens, jamais aux ~600 lignes de règles ci-dessous qui les
consomment déjà. */
*{ box-sizing:border-box; }
body{ margin:0; font-family:system-ui,-apple-system,"Segoe UI",sans-serif; background:var(--bg); color:var(--text); }
h1{ font-size:20px; margin:0 0 6px; }
h2{ font-size:13px; text-transform:uppercase; letter-spacing:.05em; color:var(--text-dim); margin:24px 0 8px; }
h3{ font-size:13px; color:var(--accent); margin:14px 0 6px; }
label{ display:block; font-size:12px; color:var(--text-dim); margin:8px 0 4px; }
input[type=text], input[type=number], input[type=date], textarea, select{
width:100%; background:var(--panel2); border:1px solid var(--border); border-radius:6px;
color:var(--text); padding:8px 10px; font-size:13px; font-family:inherit;
}
input[type=checkbox]{ width:auto; margin-top:6px; }
/* .topbar : Bulma fournit déjà la mise en page (navbar) — cette classe ne
sert plus qu'à la repérer dans les layouts plein-écran
(body.builderBody/objectEditBody, voir plus bas) et à la rattacher au
thème sombre. */
.topbar{ border-bottom:1px solid var(--border); }
/* Barre de navigation d'UN JEU (voir base.html) — toujours visible entre
les différents éditeurs d'un même jeu, en ligne (contrairement à
.navList/.navRow ci-dessous, empilés verticalement dans le panneau
"Créer" du tableau de bord). */
.gameNavBar{ display:flex; flex-wrap:wrap; justify-content:center; gap:6px; padding:8px 20px; background:transparent; border-bottom:1px solid var(--border); }
.gameNavLink{ display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border:1px solid var(--border); border-radius:6px; background:var(--panel); text-decoration:none; color:var(--text); font-size:12.5px; }
.gameNavLink:hover{ background:var(--panel2); border-color:var(--accent); }
.gameNavLink.active{ background:var(--panel2); border-color:var(--accent); color:var(--accent); }
.content{ max-width:760px; margin:0 auto; padding:28px 20px; }
.card{ border:1px solid var(--border); border-radius:10px; padding:16px; background:var(--panel); margin:12px 0; }
.inline-form .fieldRow{ display:flex; gap:8px; align-items:flex-end; }
.inline-form .fieldRow input{ flex:1; }
.fieldRow{ display:flex; gap:10px; }
.fieldRow > div{ flex:1; }
.formField{ margin-bottom:6px; }
button{ background:var(--panel2); border:1px solid var(--border); color:var(--text); border-radius:10px; padding:9px 14px; cursor:pointer; font-size:13px; }
/* Un <button> qui doit se comporter comme un <a> visuellement (ex. ouvrir
un panneau plutôt que suivre un href, mais garder l'apparence d'un lien
dans un tableau) — annule le style bouton par défaut ci-dessus. */
.linkLikeButton{ background:none; border:none; padding:0; color:var(--accent); text-decoration:none; font-size:inherit; }
.linkLikeButton:hover{ text-decoration:underline; }
/* button.primary : couleur/dégradé posés une seule fois, centralement,
dans forge-theme.scss (".button.is-primary, button.primary") — pas ici,
pour ne pas entrer en conflit de cascade avec cette règle centrale. */
button.smallBtn{ padding:5px 10px; font-size:11.5px; }
button.danger{ color:var(--danger); border-color:rgba(240,68,56,.4); }
.actionsRow{ margin-top:16px; }
/* Nav compacte à une seule ligne (icône + libellé) — pour une liste
d'actions fixe (ex. colonne "Créer" du tableau de bord d'un jeu) : sobre
et dense plutôt qu'une carte à deux lignes. */
.navList{ display:flex; flex-direction:column; gap:3px; }
.navRow{ display:flex; align-items:center; gap:8px; padding:7px 10px; border:1px solid var(--border); border-radius:6px; background:var(--panel); text-decoration:none; color:var(--text); font-size:12.5px; }
.navRow:hover{ background:var(--panel2); border-color:var(--accent); }
.hint{ font-size:11.5px; color:var(--text-dim); line-height:1.5; }
.autosaveStatus{ display:inline-flex; align-items:center; gap:6px; transition:color .2s ease; }
.autosaveStatus.saving{ color:var(--text-dim); }
.autosaveStatus.saved{ color:var(--status-success); }
.warningBanner{ border:1px solid var(--danger); background:rgba(240,68,56,.1); color:var(--text);
border-radius:8px; padding:10px 14px; font-size:12.5px; margin:12px 0; }
.dangerZone{ border:1px solid rgba(240,68,56,.4); border-radius:8px; padding:10px 12px; margin-top:14px; }
.dangerZone h3{ color:var(--danger); margin-top:0; }
.fieldsExistingRow{ display:flex; align-items:center; justify-content:space-between;
padding:8px 10px; border:1px solid var(--border); border-radius:6px; margin-bottom:6px; font-size:12.5px; }
.fieldsExistingRow form{ margin:0; }
.fieldRowCard{ margin-bottom:10px; }
.relationTarget{ margin-top:6px; }
/* Liste de conditions de "Donnée liée" (voir c_clause_list.py) : un nombre
ILLIMITÉ de lignes peut s'y accumuler — sans limite de hauteur, le
panneau de propriétés entier deviendrait interminable à faire défiler
dès qu'on en pose plus de 3-4. max-height + overflow-y borne cette
LISTE elle-même à une hauteur confortable, le reste du panneau (et le
bouton "+ Ajouter une condition" juste en dessous) reste toujours à
portée sans avoir à chercher le bas de la liste. */
.clauseListWrap{ max-height:260px; overflow-y:auto; padding-right:2px; }
.clauseRow{ margin-bottom:8px; }
.clauseRow:last-child{ margin-bottom:0; }
/* .dataTable : Bulma fournit déjà la mise en page du tableau (classes
"table is-fullwidth is-hoverable", voir data_list.html) — ceci ne fait
plus que tronquer les cellules trop longues avec "...", pour garder une
vue d'ensemble compacte (le détail complet s'ouvre dans la modale Bulma
ci-dessous via le bouton 👁️). */
.dataTable td{ max-width:220px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* ---------- Détail d'une entrée (bouton 👁️) ---------- */
/* Contenu du panneau de détail (voir openRowDetailPanel() dans
game_dashboard.html) — affiche chaque champ en entier, sans troncature,
contrairement au tableau dense qui les tronque. */
.rowDetailField{ padding:10px 0; border-bottom:1px solid var(--border); }
.rowDetailField:last-child{ border-bottom:none; }
.rowDetailLabel{ font-size:11px; color:var(--text-dim); text-transform:uppercase; letter-spacing:.05em; margin-bottom:4px; }
.rowDetailValue{ font-size:13.5px; white-space:pre-wrap; word-break:break-word; }
.content-wide{ max-width:1600px; }
.twoCol{ display:flex; gap:24px; align-items:flex-start; margin-top:10px; }
.twoCol .colLeft{ flex:0 0 340px; }
.twoCol .colRight{ flex:1; min-width:0; }
@media (max-width: 820px){
.twoCol{ flex-direction:column; }
.twoCol .colLeft{ flex:none; width:100%; }
/* En dessous de 820px, les deux colonnes s'empilent : le défilement
interne séparé par colonne n'a plus de sens (il n'y a plus qu'une
colonne) — on revient à une page classique qui défile normalement,
comme pour l'éditeur d'écran ci-dessous. */
body.objectEditBody{ height:auto; overflow:visible; }
body.objectEditBody > main.content{ overflow:visible; }
.content-objectEdit .colLeft, .content-objectEdit .colRight{ height:auto; overflow:visible; }
.fieldsTableWrap{ max-height:60vh; }
}
/* ---------- Modifier un objet : tableau des champs existants ---------- */
/* Comme l'éditeur d'écran (body.builderBody, plus bas) : la page ne défile
jamais elle-même, seules les zones internes (colonne de gauche, tableau
des champs) défilent chacune de leur côté — indispensable dès qu'un objet
a beaucoup de champs, pour garder une vue d'ensemble sans perdre le
formulaire "Ajouter un champ" hors de l'écran. */
body.objectEditBody{ display:flex; flex-direction:column; height:100vh; margin:0; overflow:hidden; }
body.objectEditBody > #pageChrome{ flex:0 0 auto; }
body.objectEditBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; display:flex; flex-direction:column; }
.content-objectEdit{ width:100%; display:flex; flex-direction:column; min-height:0; }
.content-objectEdit > h1, .content-objectEdit > .dashPanelHeader, .content-objectEdit > .hint, .content-objectEdit > form, .content-objectEdit > .warningBanner{ flex:0 0 auto; }
/* Un <form> qui doit envelopper les DEUX colonnes à la fois (un seul
formulaire soumis ensemble, ex. object_form.html : nom de l'objet à
gauche, liste de champs à droite) ne doit pas devenir lui-même une boîte
dans la mise en page flex — sinon .twoCol à l'intérieur perd la hauteur
disponible qu'il doit occuper. "display:contents" le rend transparent :
.twoCol se comporte comme s'il était directement enfant de
.content-objectEdit. */
.formPassthrough{ display:contents; }
.content-objectEdit .twoCol{ flex:1 1 auto; min-height:0; align-items:stretch; }
.content-objectEdit .colLeft{ overflow-y:auto; height:100%; }
.content-objectEdit .colRight{ display:flex; flex-direction:column; min-height:0; height:100%; }
.content-objectEdit .colRight h2{ flex:0 0 auto; }
.content-objectEdit .colRight > .hint, .content-objectEdit .colRight > button{ flex:0 0 auto; }
.fieldsTableWrap{ flex:1 1 auto; min-height:0; overflow:auto; border:1px solid var(--border); border-radius:8px; }
.fieldsTable{ width:100%; border-collapse:collapse; font-size:12.5px; }
.fieldsTable th, .fieldsTable td{ border-bottom:1px solid var(--border); padding:8px 10px; text-align:left; white-space:nowrap; }
.fieldsTable th{ position:sticky; top:0; background:var(--panel2); color:var(--text-dim); z-index:1; }
.fieldsTable tbody tr:hover{ background:rgba(255,255,255,.02); }
.fieldsTable tbody tr:last-child td{ border-bottom:none; }
.fieldsTable td input, .fieldsTable td select{ min-width:120px; width:auto; }
.fieldsTableActions{ display:flex; gap:6px; }
/* ---------- Éditeur d'écrans (façon Elementor/Bricks) ---------- */
/* La page de l'éditeur d'écran ne doit jamais défiler elle-même : elle
occupe exactement la hauteur de la fenêtre (topbar + zone de travail), et
ce sont les zones internes (panneaux, écran, panneau de logique) qui
défilent chacune de leur côté si besoin. */
body.builderBody{ display:flex; flex-direction:column; height:100vh; margin:0; overflow:hidden; }
body.builderBody > #pageChrome{ flex:0 0 auto; }
body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; display:flex; flex-direction:column; }
.content-builder{
/* .content (ci-dessus) pose "margin:0 auto" pour centrer une page normale
— mais sur cette page, le body est un flex-container en colonne
(body.builderBody), et une marge horizontale "auto" y désactive le
stretch d'un flex-item : sa largeur est alors recalculée en
"shrink-to-fit" à partir de son contenu, un calcul qui traverse toute
la chaîne flex/%/aspect-ratio jusqu'au canvas et redonne un résultat
légèrement différent à chaque changement de contenu du panneau (élément
sélectionné, grille affichée...) — d'où le canvas qui grandit/rétrécit
sans raison apparente à chaque interaction. "width:100%" donne une
taille définie à la place : la marge "auto" ne fait alors que centrer
l'espace restant une fois "max-width" atteint, comme voulu à l'origine. */
width:100%;
max-width:1600px;
/* Sans fil d'Ariane sur cette page (voir breadcrumb_wrap dans
screen_edit.html), .content garde quand même son padding par défaut
(28px en haut) — réduit ici pour que le canevas commence le plus haut
possible sur la page. */
padding:10px 20px 8px;
}
/* 3 colonnes : ajout d'éléments (gauche) / écran en grand (centre) / propriétés (droite) —
toutes les trois occupent exactement la même hauteur (celle qui reste
disponible), avec leur propre défilement interne si leur contenu dépasse. */
.builder3{ display:flex; gap:20px; align-items:stretch; margin-top:8px; flex:1 1 auto; min-height:0; }
/* overflow:hidden (pas auto) : le défilement doit se faire DANS le
canevas lui-même (voir .canvasFrame plus bas) ou dans l'onglet actif
(.flowCanvasWrap/.animTimelineWrap ont déjà leur propre défilement),
jamais sur cette zone entière — sans quoi la barre d'onglets et la barre
d'outils défileraient avec le contenu au lieu de rester fixes en haut. */
.builderCanvasArea{ flex:1 1 auto; min-width:0; height:100%; overflow:hidden; display:flex; flex-direction:column; }
/* ---------- Panneaux gauche/droite flottants ---------- */
/* Les deux panneaux (éléments de l'écran / propriétés) sont sortis du flux
flex de .builder3 (position:fixed) : .builderCanvasArea (flex:1 1 auto)
récupère automatiquement tout l'espace libéré, sans réglage
supplémentaire — les fermer donne donc une prévisualisation aussi large
que possible, aussi fidèle que possible à ce que "Jouer" affiche
réellement. Position et état ouvert/fermé mémorisés par écran (voir
floatPanel*() dans screen_edit.html) pour survivre à un changement de
sélection (qui remplace tout #builder3) ou un rechargement. */
.floatPanel{
position:fixed; top:90px; max-height:calc(100vh - 120px);
min-width:0; display:flex; flex-direction:column; z-index:60;
background:var(--panel); border:1px solid var(--border); border-radius:10px;
box-shadow:0 12px 40px rgba(0,0,0,.55);
}
.floatPanel.hidden{ display:none; }
/* Coin de redimensionnement (voir wireFloatPanelResize() dans
game_dashboard.html) — un panneau devient redimensionnable simplement en
lui ajoutant ce coin, JS mis à part rien d'autre à faire côté CSS. */
.floatPanelResizeHandle{
position:absolute; right:0; bottom:0; width:16px; height:16px; cursor:nwse-resize;
background:linear-gradient(135deg, transparent 50%, var(--border) 50%, var(--border) 60%, transparent 60%, transparent 70%, var(--border) 70%, var(--border) 80%, transparent 80%);
border-radius:0 0 10px 0;
}
.floatPanel--left{ left:24px; width:280px; }
.floatPanel--right{ right:24px; width:340px; }
.floatPanel--center{ left:50%; width:560px; max-width:calc(100vw - 48px); transform:translateX(-50%); }
/* Plein écran (voir makeFloatPanelFullscreenable() dans game_dashboard.html)
— juste un repère visuel (bordure accent) sur le panneau agrandi, tout le
positionnement réel est posé directement en style inline par le JS (pour
pouvoir revenir exactement à la position/taille d'avant en sortant). */
.floatPanel--fullscreen{ border-color:var(--accent); }
.floatPanelHeader{
display:flex; align-items:center; justify-content:space-between; gap:8px;
padding:8px 10px; border-bottom:1px solid var(--border); background:var(--panel2);
border-radius:10px 10px 0 0; cursor:move; flex:0 0 auto; user-select:none;
}
.floatPanelHeader .floatPanelTitle{ font-size:11px; color:var(--text-dim); text-transform:uppercase; letter-spacing:.05em; }
.floatPanelBody{ padding:12px; overflow-y:auto; min-height:0; flex:1 1 auto; }
.floatPanelReopenBtn{ position:fixed; top:90px; z-index:60; display:none; }
.floatPanelReopenBtn.visible{ display:inline-flex; }
#panelLeftReopenBtn{ left:24px; }
#propsPanelReopenBtn{ right:24px; }
.widgetGrid{ display:grid; grid-template-columns:1fr 1fr; gap:8px; }
/* Icônes (widget Icône + galerie du panneau de propriétés) — SVG posé en
silhouette via "mask-image" plutôt qu'une police d'icônes (@font-face) :
se colore avec "color"/"background-color" comme du texte, MAIS ne dépend
d'aucun fichier de police externe — certains navigateurs/extensions
bloquent le chargement de polices web tierces (protection anti-
fingerprinting), ce qui rendait toutes les icônes Font Awesome invisibles
quelle que soit leur source (CDN ou même hébergées ici). Un simple SVG
affiché en masque n'est jamais concerné par ce blocage. Voir
render_icone.py (widget) et fontawesome_icons.py (liste + chemin des
fichiers, statiques dans static/icons/). */
.icon-svg{
display:inline-block; width:1em; height:1em; flex:0 0 auto;
background-color:currentColor;
-webkit-mask-image:var(--icon-url); mask-image:var(--icon-url);
-webkit-mask-size:contain; mask-size:contain;
-webkit-mask-repeat:no-repeat; mask-repeat:no-repeat;
-webkit-mask-position:center; mask-position:center;
}
/* Galerie d'icônes (panneau de propriétés) — voir
fontawesome_icons.py et addIconElement()/filterIconGallery() plus bas. */
.iconGallery{ display:grid; grid-template-columns:repeat(6, 1fr); gap:6px; max-height:220px; overflow-y:auto; margin-top:8px; }
.iconGalleryTile{
aspect-ratio:1; display:flex; align-items:center; justify-content:center;
background:var(--panel2); border:1px solid var(--border); border-radius:6px;
color:var(--text); cursor:grab; padding:0;
}
.iconGalleryTile .icon-svg{ width:16px; height:16px; }
.iconGalleryTile:hover{ border-color:var(--accent); color:var(--accent); }
.iconGalleryTile.dragging{ opacity:0.4; }
.iconGalleryTile.iconHidden{ display:none; }
.widgetBtnForm{ margin:0; }
.widgetBtn{
width:100%; display:flex; flex-direction:column; align-items:center; gap:4px;
padding:12px 6px; border:1px solid var(--border); border-radius:10px; background:var(--panel);
color:var(--text); cursor:pointer;
}
.widgetBtn:hover{ border-color:var(--accent); background:var(--panel2); }
.widgetIcon{ font-size:22px; }
.widgetLabel{ font-size:11px; text-align:center; line-height:1.2; color:var(--text-dim); }
.canvasToolbar{ display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:10px; }
.aspectBtn.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.canvasFrame{
/* flex:1 1 auto + min-height:0 : occupe tout l'espace restant sous la
barre d'outils dans .builderTabPanel (voir screen_edit.html). Le
canevas (voir .canvas ci-dessous) est réglé pour remplir EXACTEMENT
ce cadre (100% x 100%, sans respecter l'aspect-ratio Portrait/Paysage/
Carré dans l'éditeur — ce réglage ne sert qu'à l'aperçu jouable/"Jouer",
voir screen_set_aspect.py) : "prendre toute la place disponible" et
"garder l'aspect-ratio" sont deux exigences contradictoires dès que la
fenêtre n'a pas exactement ce ratio, la première l'emporte ici. C'est
LUI qui défile (overflow:auto) si jamais le contenu déborde malgré
tout — jamais .builderCanvasArea (voir plus haut), pour garder la
barre d'onglets/d'outils toujours visible. */
flex:1 1 auto; min-height:0; width:100%; padding:4px; border:1px solid var(--border); border-radius:16px; background:var(--panel);
display:flex; overflow:auto;
}
.canvas{
position:relative; width:100%; height:100%; flex:1 1 auto; margin:0; background:#0b0d12; border-radius:8px;
overflow:hidden; border:1px solid var(--border);
}
.canvasElement{
/* Pas d'overflow:hidden ici : une échelle (transform:scale) ou une
animation posée sur cet élément (voir la Timeline d'animation) vit sur
CE cadre (voir filters/element_style_filter.py) — le rogner clippait
l'effet à la taille d'origine de la boîte au lieu de le laisser
grossir/bouger avec elle. La zone de l'écran elle-même (.canvas,
ci-dessus) reste rognée, donc rien ne peut déborder de l'écran final. */
box-sizing:border-box; cursor:move; user-select:none;
border:1px dashed transparent;
}
.canvasElement:hover{ border-color:rgba(91,140,255,.5); }
.canvasElement.selected{ border:2px solid var(--accent); }
.canvasElementInner{ width:100%; height:100%; pointer-events:none; }
/* Surbrillance de l'élément sélectionné dans l'arborescence, posée en JS sur
sa propre balise (voir applySelectionHighlight) — seul moyen de la voir
sur le canevas quand cet élément est imbriqué dans un conteneur/répéteur/
groupe de champs, qui n'a pas de cadre ".canvasElement" séparé comme un
élément posé directement sur l'écran. Un "outline" qui dépasse la boîte
(offset positif) se fait couper par le premier ancêtre en overflow:hidden
(le canevas lui-même, ou un conteneur aux coins arrondis) dès que
l'élément touche un de ses bords — très fréquent pour une rangée de
jauges collée en haut d'un conteneur. Un box-shadow EN CREUX (inset)
reste toujours à l'intérieur de la boîte, jamais rogné, et épouse
naturellement les coins arrondis de l'élément lui-même. */
.elementHighlighted{
box-shadow: inset 0 0 0 2px var(--accent), inset 0 0 0 6px rgba(91,140,255,.2);
}
.resizeHandle{
position:absolute; right:0; bottom:0; width:14px; height:14px; cursor:nwse-resize;
background:var(--accent); border-radius:3px 0 6px 0; opacity:.85;
}
.listRow.selected{ border-color:var(--accent); background:var(--panel2); }
.childList{ display:flex; flex-direction:column; gap:6px; margin:2px 0 10px 18px; padding-left:10px; border-left:2px dashed var(--border); }
.childRow{ padding:7px 10px; font-size:12px; }
.childRow .rowTitle{ font-size:12px; }
/* Arborescence compacte des éléments de l'écran (panneau gauche) — une
ligne par élément, indentation par imbrication réelle (profondeur
illimitée) plutôt qu'un seul niveau d'enfants comme l'ancienne .childList,
pour rester lisible même avec beaucoup d'éléments. */
.elementTree{ display:flex; flex-direction:column; gap:1px; max-height:55vh; overflow-y:auto; margin-bottom:14px; }
.treeNode{ display:flex; flex-direction:column; }
.treeRow{ display:flex; align-items:center; gap:2px; }
.treeToggle{
flex:0 0 auto; width:16px; height:16px; padding:0; border:none; background:none; cursor:pointer;
color:var(--text-dim); font-size:10px; display:flex; align-items:center; justify-content:center;
transition:transform .12s ease;
}
.treeToggle:hover{ color:var(--text); }
.treeToggleSpacer{ flex:0 0 auto; width:16px; height:16px; }
.treeNode.collapsed > .treeRow > .treeToggle{ transform:rotate(-90deg); }
.treeNode.collapsed > .treeChildren{ display:none; }
.treeRowLink{
flex:1; display:flex; align-items:center; gap:6px; padding:4px 6px; border-radius:5px;
text-decoration:none; color:var(--text); font-size:12.5px; line-height:1.5;
white-space:nowrap; overflow:hidden; min-width:0;
}
.treeRowLink:hover{ background:var(--panel2); }
.treeRowLink.selected{ background:var(--accent); color:#0b0e14; font-weight:600; }
.treeIcon{ flex:0 0 auto; font-size:12px; }
.treeLabel{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.treeChildren{ display:flex; flex-direction:column; margin-left:10px; padding-left:8px; border-left:1px dashed var(--border); }
.treeRow.dragging{ opacity:0.4; }
.treeRow.dropBefore{ box-shadow: inset 0 2px 0 0 var(--accent); }
.treeRow.dropAfter{ box-shadow: inset 0 -2px 0 0 var(--accent); }
.treeRow.dropInside{ background:rgba(91,140,255,.25); box-shadow: inset 0 0 0 1px var(--accent); border-radius:4px; }
.contextMenu{
position:fixed; z-index:500; background:var(--panel); border:1px solid var(--border);
border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.4); padding:4px; min-width:160px;
}
.contextMenu button{
display:block; width:100%; text-align:left; background:none; border:none; color:var(--text);
padding:7px 10px; border-radius:5px; font-size:13px; cursor:pointer;
}
.contextMenu button:hover{ background:var(--panel2); }
/* Élément à onglets (voir render_onglets.py) : la barre d'onglets elle-même
est un vrai composant Bulma ("tabs" > "ul" > "li", voir render_onglets.py)
— il ne reste ici que les panneaux, que Bulma n'a pas de composant pour. */
.forgeTabPanels{ min-width:0; }
.forgeTabPanel{ padding-top:10px; }
.propGroup{ border:1px solid var(--border); border-radius:8px; margin-bottom:8px; background:var(--panel); }
.propGroup > summary{
list-style:none; cursor:pointer; padding:9px 12px; font-size:13px; color:var(--accent);
font-weight:600; user-select:none; display:flex; align-items:center; justify-content:space-between;
}
.propGroup > summary::-webkit-details-marker{ display:none; }
.propGroup > summary::after{ content:"▸"; color:var(--text-dim); font-size:11px; transition:transform .15s; }
.propGroup[open] > summary::after{ transform:rotate(90deg); }
.propGroup > summary:hover{ background:var(--panel2); }
.propGroupBody{ padding:2px 12px 12px; }
.propGroupBody h3{ margin-top:0; }
/* .posGrid/.controlRow/.sizeField/.sliderRow/.alignGroup : Bulma fournit
déjà la mise en page (columns/field/control/buttons has-addons, voir
screen_edit.html) — ce qui reste ici n'est que ce que Bulma ne fournit
pas nativement : l'espacement propre à un curseur (slider) et son
pastille de valeur affichée à côté. */
.posGrid input:disabled{ opacity:0.4; cursor:not-allowed; }
.sliderRow{ display:flex; align-items:center; gap:10px; }
.sliderRow input[type=range]{ flex:1; }
.sliderValue{ min-width:44px; text-align:right; }
input[type=color]{ width:52px; height:34px; padding:2px; }
.propsPanel .sectionDivider, .floatPanel .sectionDivider{ border:none; border-top:1px solid var(--border); margin:14px 0; }
.actionAddForm{ margin-top:10px; }
.uploadRow{ margin-top:6px; }
.uploadInput{ font-size:11px; color:var(--text-dim); }
.uploadStatus{ display:block; margin-top:4px; }
code{ background:var(--panel2); border:1px solid var(--border); border-radius:4px; padding:1px 6px; font-size:11.5px; }
@media (max-width: 1300px){
/* Sous cette largeur, les 3 colonnes s'empilent : le principe "tout tient
dans la fenêtre, chaque zone défile pour elle-même" n'a plus de sens
(il n'y a plus qu'une seule colonne) — on revient à une page classique
qui défile normalement. */
body.builderBody{ height:auto; overflow:visible; }
body.builderBody > main.content{ overflow:visible; }
.builder3{ flex-direction:column; flex:none; }
.builderCanvasArea{ flex:none; width:100%; height:auto; max-height:none; overflow:visible; }
/* Le "letterboxing" par hauteur (voir .canvas plus haut) suppose une
chaîne de hauteurs définies jusqu'ici — qui n'existe plus une fois
.builderCanvasArea repassée en "height:auto" ci-dessus. Revient donc à
un dimensionnement par LARGEUR, comme une page qui défile normalement. */
.canvasFrame{ flex:none; overflow:visible; }
.canvas{ height:auto; width:100%; }
/* Trop peu de largeur pour qu'un panneau flottant ait un sens (il
couvrirait tout l'écran) : les deux rejoignent la pile normale et
restent toujours ouverts/visibles, quel que soit l'état mémorisé —
.hidden est ignoré grâce au "!important" sur "display" ci-dessous. */
.floatPanel{ position:static; flex:none; width:100%; height:auto; max-height:none; box-shadow:none; top:auto; left:auto; right:auto; display:flex !important; }
.floatPanelReopenBtn{ display:none !important; }
}
/* ---------- Onglets du centre : Écran / Logique de la scène / Timeline
d'animation ---------- */
/* Un seul onglet visible à la fois (voir switchBuilderTab() dans
screen_edit.html), chacun occupant tout l'espace disponible dans
.builderCanvasArea — remplace l'ancien panneau du bas rétractable/
redimensionnable à la souris : un onglet "prend toute la place" par
nature, pas besoin d'une poignée de redimensionnement séparée, et en
changer d'onglet équivaut à "fermer" celui qu'on quitte. */
.builderTabs{ display:flex; gap:2px; flex:0 0 auto; border-bottom:1px solid var(--border); margin-bottom:10px; }
.builderTabBtn{
background:transparent; border:none; border-bottom:2px solid transparent; border-radius:0;
padding:8px 12px; color:var(--text-dim); font-size:12.5px; cursor:pointer;
}
.builderTabBtn:hover{ color:var(--text); }
.builderTabBtn.active{ color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
.builderTabPanel{ flex:1 1 auto; min-height:0; display:none; flex-direction:column; }
.builderTabPanel.active{ display:flex; }
/* Sous-onglets Champs/Données À L'INTÉRIEUR d'un panneau "Modifier un
objet" (voir game_dashboard.html) — classes distinctes de .builderTabs/
.builderTabBtn/.builderTabPanel (utilisées par le style, réutilisé tel
quel) pour que switchDashTab() (onglets du tableau de bord lui-même,
ciblage global) n'aille jamais les toucher par erreur. */
.objectPanelTabs{ margin:14px 0 10px; }
.objectPanelTabPanel{ display:none; flex-direction:column; }
.objectPanelTabPanel.active{ display:flex; }
.flowToolbar{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:10px; flex:0 0 auto; }
.nodeForm{ border:1px solid var(--border); border-radius:8px; padding:12px; margin-bottom:12px; background:var(--panel2); max-width:420px; flex:0 0 auto; }
.nodeForm label{ margin-top:8px; }
.flowCanvasWrap{ position:relative; width:100%; flex:1 1 auto; min-height:120px; overflow:auto; border:1px dashed var(--border); border-radius:8px; background:var(--bg); }
.flowCanvas{ position:relative; width:2200px; height:1400px; }
.flowEdgesSvg{ position:absolute; top:0; left:0; width:2200px; height:1400px; pointer-events:none; z-index:0; }
.flowEdgeLine{ stroke:var(--accent); stroke-width:2; pointer-events:stroke; cursor:pointer; }
.flowEdgeLine.flowEdgeTrue{ stroke:var(--status-success); }
.flowEdgeLine.flowEdgeFalse{ stroke:var(--danger); }
.flowNode{
position:absolute; width:220px; min-height:52px; border-radius:8px; border:1px solid var(--border);
background:var(--panel2); padding:10px 12px; font-size:12.5px; cursor:grab; z-index:1; user-select:none;
}
.flowNode-trigger{ border-color:var(--status-info); }
.flowNode-condition{ border-color:var(--status-warning); }
.flowNode-action{ border-color:var(--status-success); }
.flowNodeBody{ padding-right:16px; line-height:1.4; word-break:break-word; }
.flowNodeDelete{
position:absolute; top:4px; right:4px; width:18px; height:18px; padding:0; line-height:1;
font-size:11px; border-radius:4px; background:transparent; border:none; color:var(--text-dim);
}
.flowNodeDelete:hover{ color:var(--danger); }
.flowPort{
position:absolute; width:14px; height:14px; border-radius:50%; background:var(--panel);
border:2px solid var(--accent); top:50%; transform:translateY(-50%); cursor:crosshair; z-index:2;
}
.flowPortIn{ left:-8px; }
.flowPortOut{ right:-8px; }
.flowPortTrue{ right:-8px; top:35%; border-color:var(--status-success); }
.flowPortFalse{ right:-8px; top:65%; border-color:var(--danger); }
/* ---------- Timeline d'animation : panneau sous "Logique de la scène" ---------- */
.animTimelineWrap{
position:relative; width:100%; flex:1 1 auto; min-height:120px; overflow:auto;
border:1px dashed var(--border); border-radius:8px; background:var(--bg);
}
.animRuler{ display:flex; height:22px; border-bottom:1px solid var(--border); position:sticky; top:0; background:var(--bg); z-index:3; }
.animRulerSpacer{ position:sticky; left:0; width:140px; flex:0 0 140px; background:var(--panel); z-index:2; border-right:1px solid var(--border); }
.animRulerTicksWrap{ position:relative; flex:0 0 auto; }
.animRulerTick{
position:absolute; top:0; bottom:0; font-size:10px; color:var(--text-dim);
border-left:1px solid var(--border); padding-left:3px;
}
.animTracks{ position:relative; }
.animTrack{ position:relative; height:38px; border-bottom:1px solid var(--border); display:flex; align-items:stretch; }
.animTrackLabel{
position:sticky; left:0; width:140px; flex:0 0 140px; background:var(--panel); z-index:2;
padding:0 8px; font-size:12px; display:flex; align-items:center; border-right:1px solid var(--border);
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.animTrackLane{ position:relative; flex:0 0 auto; }
.animClip{
position:absolute; top:5px; height:28px; border-radius:6px; background:var(--accent); color:#fff;
font-size:11px; padding:5px 8px; cursor:grab; overflow:hidden; white-space:nowrap; user-select:none;
box-sizing:border-box;
}
.animClip.selected{ outline:2px solid #fff; }
.animClipResizeHandle{ position:absolute; right:0; top:0; width:8px; height:100%; cursor:ew-resize; }
.animKeyframeRow{ align-items:flex-start; }
.animKeyframeRow textarea{ font-family:monospace; font-size:11px; }
/* ---------- Tableau de bord d'un jeu (game_dashboard.html) ---------- */
/* Une seule fenêtre, un onglet par catégorie (écrans/objets/éléments de
jeu/variables) — réutilise .builderTabs/.builderTabBtn/.builderTabPanel
(voir plus bas, éditeur d'écran) pour la même sensation de "fenêtre à
onglets" qu'ailleurs dans l'outil, plutôt que d'inventer un 2e système.
Contrairement à leur usage dans l'éditeur d'écran, ici la page défile
normalement (pas de hauteur figée) : une liste peut être longue, pas
besoin d'un défilement interne séparé par panneau. */
.dashTabCount{ font-size:10px; color:var(--text-dim); background:var(--panel2); border-radius:999px; padding:1px 6px; margin-left:4px; }
.dashPanelHeader{ display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px; }
.dashPanelHeader h2{ margin:0; }
.dashCreateBar{ display:flex; gap:8px; align-items:center; margin-bottom:12px; padding:10px; border:1px solid var(--border); border-radius:8px; background:var(--panel); }
.dashCreateBar input, .dashCreateBar select{ width:auto; flex:1 1 160px; }
.dashDangerZone{ margin-top:28px; }
/* ---------- Filtre de colonnes (onglet Données, game_dashboard.html) ---------- */
/* Menu déroulant compact sous le bouton "🔧 Colonnes" — un objet avec
beaucoup de champs rend le tableau illisible, ce menu permet d'en
masquer certains (voir toggleDataColumn() : agit directement sur
[data-col] plutôt que de reconstruire le tableau). */
.columnFilterWrap{ position:relative; display:inline-block; }
.columnFilterMenu{
position:absolute; top:100%; left:0; margin-top:4px; z-index:70;
background:var(--panel); border:1px solid var(--border); border-radius:8px;
padding:8px; box-shadow:0 12px 40px rgba(0,0,0,.55);
display:flex; flex-direction:column; gap:2px; min-width:180px; max-height:280px; overflow-y:auto;
}
.columnFilterMenu.hidden{ display:none; }
.columnFilterOption{ display:flex; align-items:center; gap:6px; padding:4px 6px; border-radius:4px; font-size:12.5px; white-space:nowrap; }
.columnFilterOption:hover{ background:var(--panel2); }
/* ---------- Panneau "Configurer la valeur" (variable Objet/Tableau) ---------- */
/* La colonne "Clé" n'a de sens que pour un Objet (paires clé/valeur) — un
Tableau n'est qu'une liste de valeurs, voir openVarValuePanel() dans
game_dashboard.html. */
#varValuePanel[data-mode="tableau"] .varValueKeyField{ display:none; }
/* ---------- Inscription : schéma visuel du mot de passe ---------- */
/* Jauge + liste de critères mise à jour en direct (voir register.html) —
les mêmes 5 critères que auth/password_strength.py côté serveur. */
.passwordStrengthBar{ height:6px; border-radius:3px; background:var(--panel2); overflow:hidden; margin-top:8px; }
.passwordStrengthFill{ height:100%; width:0; border-radius:3px; background:var(--status-danger); transition:width .15s ease, background-color .15s ease; }
.passwordStrengthFill.weak{ background:var(--status-danger); }
.passwordStrengthFill.medium{ background:var(--status-warning); }
.passwordStrengthFill.good{ background:var(--accent-2); }
.passwordStrengthFill.strong{ background:var(--status-success); }
.passwordChecklist{ list-style:none; margin:8px 0 0; padding:0; display:flex; flex-direction:column; gap:2px; }
.passwordChecklist li{ font-size:12.5px; color:var(--text-dim); padding-left:18px; position:relative; }
.passwordChecklist li::before{ content:"○"; position:absolute; left:0; }
.passwordChecklist li.ok{ color:var(--status-success); }
.passwordChecklist li.ok::before{ content:"✓"; }
.passwordChecklist-compact{ flex-direction:row; flex-wrap:wrap; column-gap:10px; row-gap:2px; }
.passwordChecklist-compact li{ font-size:11px; }
/* ---------- Page de profil : compacte, sur 2 colonnes ---------- */
.profileSection{ padding-top:1.5rem; padding-bottom:1.5rem; }
.profileContainer{ max-width:980px; }
.profileNotif{ padding:0.6rem 0.9rem; margin-bottom:0.75rem; }
.profileGrid{ display:grid; grid-template-columns:1fr 1fr; gap:14px; align-items:start; }
.profileCol{ display:flex; flex-direction:column; gap:14px; min-width:0; }
.profileBox{ padding:0.85rem 1rem; }
.profileBox-danger{ border:1px solid var(--status-danger); }
.profileForm .field{ margin-bottom:8px; }
.profileForm .field:last-child{ margin-bottom:0; }
.profileForm-row{ display:flex; flex-wrap:wrap; gap:8px; }
.profileForm-row .field{ flex:1 1 120px; margin-bottom:0; }
.profileForm-row .field.profileForm-full{ flex-basis:100%; }
@media (max-width:720px){
.profileGrid{ grid-template-columns:1fr; }
}
/* ---------- Inscription : QR code de la double authentification ---------- */
.totpQrWrap{ background:#fff; padding:16px; border-radius:8px; display:inline-block; max-width:220px; }
.totpQrWrap svg{ display:block; width:100%; height:auto; }
.recoveryCodesGrid{ display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.recoveryCode{ display:block; text-align:center; padding:8px; background:var(--panel2); border-radius:10px; letter-spacing:1px; }
+38
View File
@@ -0,0 +1,38 @@
/* forge-tokens.css — tokens de la charte Forge (voir
regles/FORGE_ENGINE_TEMPLATE_BULMA.md, §1), exposés en :root pour rester
inspectables/utilisables tels quels par du JS ou du style inline.
Chargé APRÈS bulma-override.css (voir build_css.py) : indépendant de
Bulma, consommé par tout le CSS custom de l'app (forge-custom.css). */
:root {
--forge-bg: #0b0e14;
--forge-bg-secondary: #11151d;
--forge-card: #161b25;
--forge-border: #232a38;
--forge-text: #e8ecf4;
--forge-text-muted: #8b95a7;
--accent: #ff5f2e;
--accent-2: #ffb020;
--gradient: linear-gradient(120deg, var(--accent), var(--accent-2));
--glow: rgba(255, 95, 46, 0.25);
--status-success: #10b981;
--status-warning: #ffb020;
--status-danger: #f04438;
--status-info: #3b82f6;
/* Alias historiques : noms de variable déjà utilisés par tout le CSS
custom de l'app avant la charte Forge (styles/forge-custom.css) — pas
besoin de renommer les ~600 lignes de règles déjà écrites, seules les
VALEURS changent. */
--bg: var(--forge-bg);
--panel: var(--forge-card);
--panel2: var(--forge-bg-secondary);
--border: var(--forge-border);
--text: var(--forge-text);
--text-dim: var(--forge-text-muted);
--danger: var(--status-danger);
}
html, body {
background: var(--forge-bg);
color: var(--forge-text);
}
+4 -3
View File
@@ -1,9 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Mot de passe oublié — Forge Engine{% endblock %} {% block title %}Mot de passe oublié — Forge Engine{% endblock %}
{% block body_class %}authBody{% endblock %}
{% block content %} {% block content %}
<section class="section"> <section class="section authScreen">
<div class="container" style="max-width:420px;"> <div class="container authCard">
<h1 class="title">Mot de passe oublié</h1> <h1 class="title authTitle forge-gradient-title">Mot de passe oublié</h1>
{% if sent %} {% if sent %}
<div class="notification is-success">Si un compte existe avec cette adresse, un email vient de t'être envoyé avec un lien pour choisir un nouveau mot de passe.</div> <div class="notification is-success">Si un compte existe avec cette adresse, un email vient de t'être envoyé avec un lien pour choisir un nouveau mot de passe.</div>
+4 -3
View File
@@ -1,9 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Connexion — Forge Engine{% endblock %} {% block title %}Connexion — Forge Engine{% endblock %}
{% block body_class %}authBody{% endblock %}
{% block content %} {% block content %}
<section class="section"> <section class="section authScreen">
<div class="container" style="max-width:420px;"> <div class="container authCard">
<h1 class="title">Connexion</h1> <h1 class="title authTitle forge-gradient-title">Connexion</h1>
{% if error %} {% if error %}
<div class="notification is-danger">{{ error }}</div> <div class="notification is-danger">{{ error }}</div>
+4 -3
View File
@@ -1,9 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Code de vérification — Forge Engine{% endblock %} {% block title %}Code de vérification — Forge Engine{% endblock %}
{% block body_class %}authBody{% endblock %}
{% block content %} {% block content %}
<section class="section"> <section class="section authScreen">
<div class="container" style="max-width:420px;"> <div class="container authCard">
<h1 class="title">Code de vérification</h1> <h1 class="title authTitle forge-gradient-title">Code de vérification</h1>
<p class="hint mb-4">Ouvre ton application d'authentification et saisis le code à 6 chiffres affiché, ou un de tes codes de récupération si tu n'y as plus accès.</p> <p class="hint mb-4">Ouvre ton application d'authentification et saisis le code à 6 chiffres affiché, ou un de tes codes de récupération si tu n'y as plus accès.</p>
{% if error %} {% if error %}
+64 -65
View File
@@ -1,90 +1,89 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Mon profil — Forge Engine{% endblock %} {% block title %}Mon profil — Forge Engine{% endblock %}
{% block content %} {% block content %}
<section class="section"> <section class="section profileSection">
<div class="container" style="max-width:560px;"> <div class="container profileContainer">
<h1 class="title">Mon profil</h1> <h1 class="title is-4 mb-3">Mon profil</h1>
{% if error %} {% if error %}
<div class="notification is-danger">{{ error }}</div> <div class="notification is-danger profileNotif">{{ error }}</div>
{% endif %} {% endif %}
{% if success %} {% if success %}
<div class="notification is-success">{{ success }}</div> <div class="notification is-success profileNotif">{{ success }}</div>
{% endif %} {% endif %}
<div class="box"> <div class="profileGrid">
<h2 class="title is-5">Informations</h2> <div class="profileCol">
<p class="hint mb-4">Adresse email : <strong>{{ user.email }}</strong> ({{ 'compte administrateur' if user.role == 'admin' else 'compte utilisateur' }})</p> <div class="box profileBox">
<form method="post" action="{{ url_for('profile_update_name') }}"> <h2 class="title is-6 mb-2">Adresse email</h2>
<div class="field"> <p class="hint mb-2">
<label class="label">Prénom</label> {{ 'Compte administrateur' if user.role == 'admin' else 'Compte utilisateur' }}{% if user.role != 'admin' %} — le dossier de ton jeu ({{ user.project_slug }}) suit automatiquement{% endif %}.
<div class="control"><input class="input" type="text" name="prenom" value="{{ user.prenom }}" required></div> </p>
</div> <form method="post" action="{{ url_for('profile_update_email') }}" autocomplete="off" class="profileForm">
<div class="field"> <div class="field"><input class="input is-small" type="email" name="email" value="{{ user.email }}" placeholder="Adresse email" required></div>
<label class="label">Nom</label> <div class="field"><input class="input is-small" type="password" name="current_password" placeholder="Mot de passe actuel" required autocomplete="current-password"></div>
<div class="control"><input class="input" type="text" name="nom" value="{{ user.nom }}" required></div> <div class="field"><button type="submit" class="button primary is-small is-fullwidth">Changer d'adresse email</button></div>
</div>
<div class="field mt-4">
<button type="submit" class="button primary">Enregistrer</button>
</div>
</form> </form>
</div> </div>
<div class="box"> <div class="box profileBox">
<h2 class="title is-5">Changer de mot de passe</h2> <h2 class="title is-6 mb-2">Informations</h2>
<form method="post" action="{{ url_for('profile_change_password') }}" autocomplete="off"> <form method="post" action="{{ url_for('profile_update_name') }}" class="profileForm profileForm-row">
<div class="field"> <div class="field"><input class="input is-small" type="text" name="prenom" value="{{ user.prenom }}" placeholder="Prénom" required></div>
<label class="label">Mot de passe actuel</label> <div class="field"><input class="input is-small" type="text" name="nom" value="{{ user.nom }}" placeholder="Nom" required></div>
<div class="control"><input class="input" type="password" name="current_password" required autocomplete="current-password"></div> <div class="field profileForm-full"><button type="submit" class="button primary is-small is-fullwidth">Enregistrer</button></div>
</div>
<div class="field">
<label class="label">Nouveau mot de passe</label>
<div class="control"><input class="input" type="password" id="password" name="new_password" required autocomplete="new-password"></div>
</div>
<div class="field">
<div class="passwordStrengthBar"><div class="passwordStrengthFill" id="pwFill"></div></div>
<ul class="passwordChecklist" id="pwChecklist">
<li data-check="longueur">Au moins 8 caractères</li>
<li data-check="majuscule">Une majuscule</li>
<li data-check="minuscule">Une minuscule</li>
<li data-check="chiffre">Un chiffre</li>
<li data-check="special">Un caractère spécial (ex. ! ? # -)</li>
</ul>
</div>
<div class="field">
<label class="label">Confirmer le nouveau mot de passe</label>
<div class="control"><input class="input" type="password" name="new_password_confirm" required autocomplete="new-password"></div>
</div>
<div class="field mt-4">
<button type="submit" class="button primary">Changer le mot de passe</button>
</div>
</form> </form>
</div> </div>
<div class="box" style="border:1px solid #b33;"> <div class="box profileBox profileBox-danger">
<h2 class="title is-5">Supprimer mon compte</h2> <h2 class="title is-6 mb-2">Supprimer mon compte</h2>
<p class="hint mb-4"> <p class="hint mb-2">
{% if user.role != 'admin' %} {% if user.role != 'admin' %}
Action définitive et irréversible : ton compte ET ton jeu ({{ user.project_slug }}) seront supprimés, sans possibilité de récupération. Action définitive : ton compte ET ton jeu ({{ user.project_slug }}) seront supprimés.
{% else %} {% else %}
Action définitive et irréversible : ton compte sera supprimé (tes jeux existants ne sont pas touchés). Action définitive : ton compte sera supprimé (tes jeux existants ne sont pas touchés).
{% endif %} {% endif %}
</p> </p>
<form method="post" action="{{ url_for('profile_delete') }}" autocomplete="off"> <form method="post" action="{{ url_for('profile_delete') }}" autocomplete="off" class="profileForm">
<div class="field"> <div class="field"><input class="input is-small" type="password" name="current_password" placeholder="Mot de passe actuel" required autocomplete="current-password"></div>
<label class="label">Mot de passe actuel</label> <div class="field"><input class="input is-small" type="text" name="confirm_text" placeholder='Tape "SUPPRIMER" pour confirmer' required></div>
<div class="control"><input class="input" type="password" name="current_password" required autocomplete="current-password"></div> <div class="field"><button type="submit" class="button is-danger is-small is-fullwidth">Supprimer définitivement mon compte</button></div>
</div>
<div class="field">
<label class="label">Tape "SUPPRIMER" pour confirmer</label>
<div class="control"><input class="input" type="text" name="confirm_text" required></div>
</div>
<div class="field mt-4">
<button type="submit" class="button is-danger">Supprimer définitivement mon compte</button>
</div>
</form> </form>
</div> </div>
</div> </div>
<div class="profileCol">
<div class="box profileBox">
<h2 class="title is-6 mb-2">Changer de mot de passe</h2>
<form method="post" action="{{ url_for('profile_change_password') }}" autocomplete="off" class="profileForm">
<div class="field"><input class="input is-small" type="password" name="current_password" placeholder="Mot de passe actuel" required autocomplete="current-password"></div>
<div class="field"><input class="input is-small" id="password" type="password" name="new_password" placeholder="Nouveau mot de passe" required autocomplete="new-password"></div>
<div class="field">
<div class="passwordStrengthBar"><div class="passwordStrengthFill" id="pwFill"></div></div>
<ul class="passwordChecklist passwordChecklist-compact" id="pwChecklist">
<li data-check="longueur">8+ car.</li>
<li data-check="majuscule">Majuscule</li>
<li data-check="minuscule">Minuscule</li>
<li data-check="chiffre">Chiffre</li>
<li data-check="special">Spécial</li>
</ul>
</div>
<div class="field"><input class="input is-small" type="password" name="new_password_confirm" placeholder="Confirmer le nouveau mot de passe" required autocomplete="new-password"></div>
<div class="field"><button type="submit" class="button primary is-small is-fullwidth">Changer le mot de passe</button></div>
</form>
</div>
<div class="box profileBox">
<h2 class="title is-6 mb-2">Codes de récupération 2FA</h2>
<p class="hint mb-2">Remplace immédiatement les 10 codes précédents. Les nouveaux ne s'affichent qu'une fois, juste après.</p>
<form method="post" action="{{ url_for('profile_regenerate_recovery_codes') }}" autocomplete="off" class="profileForm">
<div class="field"><input class="input is-small" type="password" name="current_password" placeholder="Mot de passe actuel" required autocomplete="current-password"></div>
<div class="field"><button type="submit" class="button primary is-small is-fullwidth">Régénérer mes 10 codes</button></div>
</form>
</div>
</div>
</div>
</div>
</section> </section>
<script> <script>
+4 -3
View File
@@ -1,9 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Inscription — Forge Engine{% endblock %} {% block title %}Inscription — Forge Engine{% endblock %}
{% block body_class %}authBody{% endblock %}
{% block content %} {% block content %}
<section class="section"> <section class="section authScreen">
<div class="container" style="max-width:480px;"> <div class="container authCard authCard-wide">
<h1 class="title">Créer un compte</h1> <h1 class="title authTitle forge-gradient-title">Créer un compte</h1>
<p class="hint mb-4">Un compte = un seul projet de jeu (illimité pour un compte administrateur). La double authentification (2FA) est obligatoire — l'étape suivante te fera scanner un QR code avec une application comme Google Authenticator.</p> <p class="hint mb-4">Un compte = un seul projet de jeu (illimité pour un compte administrateur). La double authentification (2FA) est obligatoire — l'étape suivante te fera scanner un QR code avec une application comme Google Authenticator.</p>
{% if error %} {% if error %}
+4 -3
View File
@@ -1,9 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Activer la double authentification — Forge Engine{% endblock %} {% block title %}Activer la double authentification — Forge Engine{% endblock %}
{% block body_class %}authBody{% endblock %}
{% block content %} {% block content %}
<section class="section"> <section class="section authScreen">
<div class="container" style="max-width:480px;"> <div class="container authCard authCard-wide">
<h1 class="title">Active la double authentification</h1> <h1 class="title authTitle forge-gradient-title">Active la double authentification</h1>
<p class="hint mb-4">Scanne ce QR code avec une application d'authentification (Google Authenticator, Authy...), puis tape le code à 6 chiffres qu'elle affiche pour confirmer.</p> <p class="hint mb-4">Scanne ce QR code avec une application d'authentification (Google Authenticator, Authy...), puis tape le code à 6 chiffres qu'elle affiche pour confirmer.</p>
{% if error %} {% if error %}
+4 -3
View File
@@ -1,9 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Nouveau mot de passe — Forge Engine{% endblock %} {% block title %}Nouveau mot de passe — Forge Engine{% endblock %}
{% block body_class %}authBody{% endblock %}
{% block content %} {% block content %}
<section class="section"> <section class="section authScreen">
<div class="container" style="max-width:480px;"> <div class="container authCard authCard-wide">
<h1 class="title">Choisir un nouveau mot de passe</h1> <h1 class="title authTitle forge-gradient-title">Choisir un nouveau mot de passe</h1>
{% if invalid %} {% if invalid %}
<div class="notification is-danger">Ce lien de réinitialisation est invalide ou a expiré.</div> <div class="notification is-danger">Ce lien de réinitialisation est invalide ou a expiré.</div>
+119 -20
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Forge Engine{% endblock %}</title> <title>{% block title %}Forge Engine{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"> <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') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script>window.CSRF_TOKEN = {{ csrf_token()|tojson }};</script> <script>window.CSRF_TOKEN = {{ csrf_token()|tojson }};</script>
<script src="{{ url_for('static', filename='csrf_fetch.js') }}"></script> <script src="{{ url_for('static', filename='csrf_fetch.js') }}"></script>
@@ -22,28 +22,13 @@
ne rechargeait pas la page en entier (Ctrl+F5), et inversement 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 laissée en place en revenant sur l'accueil (liste des jeux) où elle
n'a pourtant rien à faire. --> n'a pourtant rien à faire. -->
{% set recovery_codes = pop_recovery_codes() %}
{% if recovery_codes %}
<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 %}
<div id="pageChrome"> <div id="pageChrome">
<nav class="navbar topbar" role="navigation" aria-label="main navigation"> <nav class="navbar topbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand"> <div class="navbar-brand">
<a href="{{ url_for('index') }}" class="navbar-item brand"><strong>🛠️ Forge Engine</strong></a> <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> </div>
{% if g.get('current_user') %} {% if g.get('current_user') %}
<div class="navbar-end" style="margin-left:auto; display:flex; align-items:center; gap:10px; padding-right:12px;"> <div class="navbar-end" style="margin-left:auto; display:flex; align-items:center; gap:10px; padding-right:12px;">
@@ -65,10 +50,124 @@
<nav class="gameNavBar" aria-label="Navigation du jeu"> <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_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> <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> </nav>
{% endif %} {% endif %}
</div> </div>
<main class="content {% block content_class %}{% endblock %}"> <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 %} {% block content %}{% endblock %}
</main> </main>
<script src="{{ url_for('static', filename='pjax.js') }}"></script> <script src="{{ url_for('static', filename='pjax.js') }}"></script>
+5
View File
@@ -3,6 +3,11 @@
{% block content_class %}content-wide{% endblock %} {% block content_class %}content-wide{% endblock %}
{% block content %} {% block content %}
<div class="dashPanelHeader">
<h2 class="title is-4 mb-0">{{ game.name }}</h2>
<span class="hint">projects/{{ game.slug }}/</span>
</div>
<!-- Une seule fenêtre, un onglet par catégorie (même principe que les <!-- Une seule fenêtre, un onglet par catégorie (même principe que les
onglets "Écran / Logique / Timeline" de l'éditeur d'écran, voir onglets "Écran / Logique / Timeline" de l'éditeur d'écran, voir
.builderTabs/.builderTabBtn/.builderTabPanel dans style.css) — pour .builderTabs/.builderTabBtn/.builderTabPanel dans style.css) — pour
+4 -1
View File
@@ -3,7 +3,10 @@
{% block content_class %}content-wide content-objectEdit{% endblock %} {% block content_class %}content-wide content-objectEdit{% endblock %}
{% block body_class %}objectEditBody{% endblock %} {% block body_class %}objectEditBody{% endblock %}
{% block content %} {% block content %}
<h1 class="title">Mes jeux</h1> <div class="dashPanelHeader">
<h2 class="title is-4 mb-0">Mes jeux</h2>
<span class="forge-badge">{{ games|length }} jeu{{ 's' if games|length != 1 else '' }}</span>
</div>
<div class="twoCol"> <div class="twoCol">
<div class="colLeft"> <div class="colLeft">
+89 -35
View File
@@ -4,12 +4,12 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ game.name }} — Jouer</title> <title>{{ game.name }} — Jouer</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') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script>window.CSRF_TOKEN = {{ csrf_token()|tojson }};</script> <script>window.CSRF_TOKEN = {{ csrf_token()|tojson }};</script>
<script src="{{ url_for('static', filename='csrf_fetch.js') }}"></script> <script src="{{ url_for('static', filename='csrf_fetch.js') }}"></script>
<link rel="stylesheet" href="{{ google_fonts_link }}"> <link rel="stylesheet" href="{{ url_for('static', filename='vendor/fonts/fonts.css') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css"> <link rel="stylesheet" href="{{ url_for('static', filename='vendor/animate.min.css') }}"><style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"><style>
body{ margin:0; background:#000; } body{ margin:0; background:#000; }
/* ---------- Zone de jeu à proportion fixe (voir #playFrame) ---------- /* ---------- Zone de jeu à proportion fixe (voir #playFrame) ----------
Chaque écran est conçu avec un format précis (Portrait/Paysage/Carré, Chaque écran est conçu avec un format précis (Portrait/Paysage/Carré,
@@ -40,7 +40,7 @@
display:flex; align-items:center; justify-content:center; display:flex; align-items:center; justify-content:center;
} }
.playElement input{ width:90%; height:70%; box-sizing:border-box; border:1px solid #2b3140; border-radius:4px; padding:0 8px; } .playElement input{ width:90%; height:70%; box-sizing:border-box; border:1px solid #2b3140; border-radius:4px; padding:0 8px; }
#emptyState{ color:#8b93a7; font-family:system-ui,sans-serif; text-align:center; padding-top:40vh; } #emptyState{ color:var(--forge-text-muted, #8b95a7); font-family:system-ui,sans-serif; text-align:center; padding-top:40vh; }
/* 3.3 (Confort) — surbrillance générique dynamique : liseré clignotant, /* 3.3 (Confort) — surbrillance générique dynamique : liseré clignotant,
posé/enlevé par une action "Modifier un élément → Surbrillance", posé/enlevé par une action "Modifier un élément → Surbrillance",
applicable à n'importe quel élément (pas seulement les boutons). */ applicable à n'importe quel élément (pas seulement les boutons). */
@@ -265,28 +265,39 @@
} }
// Trouve, pour un élément et un type de déclencheur ("clic"/"soumission"), // Trouve, pour un élément et un type de déclencheur ("clic"/"soumission"),
// le nœud Déclencheur qui lui est rattaché — c'est le point d'entrée du // TOUS les nœuds Déclencheur qui lui sont rattachés — ce sont les points
// graphe à exécuter. On cherche dans TOUS les écrans (pas seulement // d'entrée des graphes à exécuter. Renvoie un TABLEAU (jamais un seul
// l'écran courant) : bindClicks() attache les gestionnaires une seule // nœud) : rien n'empêche de poser plusieurs déclencheurs indépendants
// fois, sur tous les éléments de tous les écrans (déjà tous présents dans // "Au clic" sur le MÊME élément (ex. l'un ouvre une ligne de Répéteur,
// le DOM, seuls les écrans non actifs sont masqués) — chaque identifiant // l'autre éteint une surbrillance) — un seul clic doit alors exécuter
// d'élément est unique dans tout le jeu, donc pas d'ambiguïté possible. // CHACUN de ces graphes, pas seulement le premier trouvé (régression
// Cette recherche globale couvre aussi les écrans-MODÈLES (un élément de // vécue : un second déclencheur posé sur un élément qui en avait déjà un
// jeu réutilisable, ex. "mail content", posé sur une scène) : un // restait silencieusement inerte, aucune erreur, juste jamais exécuté).
// déclencheur "Au clic"/"Au survol" posé dans l'éditeur du modèle, sur un //
// de SES PROPRES enfants, est donc retrouvé même quand ce modèle est // On cherche dans TOUS les écrans (pas seulement l'écran courant) :
// utilisé ailleurs — d'où le besoin de renvoyer aussi l'écran d'origine du // bindClicks() attache les gestionnaires une seule fois, sur tous les
// nœud (screenIdKey), pas seulement le nœud : runFlowFrom() doit exécuter // éléments de tous les écrans (déjà tous présents dans le DOM, seuls les
// le graphe dans CET écran-là (celui du modèle), jamais dans celui // écrans non actifs sont masqués) — chaque identifiant d'élément est
// réellement affiché, sans quoi le nœud trouvé n'existerait pas dans le // unique dans tout le jeu, donc pas d'ambiguïté possible. Cette recherche
// graphe utilisé et l'action ne se déclencherait jamais. // globale couvre aussi les écrans-MODÈLES (un élément de jeu réutilisable,
function findTriggerNode(elementId, event) { // ex. "mail content", posé sur une scène) : un déclencheur "Au clic"/"Au
// survol" posé dans l'éditeur du modèle, sur un de SES PROPRES enfants,
// est donc retrouvé même quand ce modèle est utilisé ailleurs — d'où le
// besoin de renvoyer aussi l'écran d'origine de CHAQUE nœud (screenIdKey),
// pas seulement le nœud : runFlowFrom() doit exécuter le graphe dans CET
// écran-là (celui du modèle), jamais dans celui réellement affiché, sans
// quoi le nœud trouvé n'existerait pas dans le graphe utilisé et l'action
// ne se déclencherait jamais.
function findTriggerNodess(elementId, event) {
const found = [];
for (const screenIdKey in gameData.flows) { for (const screenIdKey in gameData.flows) {
const flow = gameData.flows[screenIdKey]; gameData.flows[screenIdKey].nodes.forEach(function(n) {
const found = flow.nodes.find(n => n.node_type === 'trigger' && n.trigger_element_id === elementId && (n.trigger_event || 'clic') === event); if (n.node_type === 'trigger' && n.trigger_element_id === elementId && (n.trigger_event || 'clic') === event) {
if (found) return { node: found, screenId: screenIdKey }; found.push({ node: n, screenId: screenIdKey });
} }
return null; });
}
return found;
} }
// Ré-attache les gestionnaires de clic sur chaque élément — appelé au // Ré-attache les gestionnaires de clic sur chaque élément — appelé au
@@ -313,8 +324,8 @@
function bindClicks() { function bindClicks() {
document.querySelectorAll('[data-element-id]').forEach(elDiv => { document.querySelectorAll('[data-element-id]').forEach(elDiv => {
const elementId = parseInt(elDiv.dataset.elementId, 10); const elementId = parseInt(elDiv.dataset.elementId, 10);
const trigger = findTriggerNode(elementId, 'clic'); const triggers = findTriggerNodess(elementId, 'clic');
if (trigger) { if (triggers.length) {
// Garde-fou anti-doublon : refreshRuntimeData() rappelle bindClicks() // Garde-fou anti-doublon : refreshRuntimeData() rappelle bindClicks()
// après chaque changement de donnée, mais un élément qui n'est ni un // après chaque changement de donnée, mais un élément qui n'est ni un
// Répéteur ni une Jauge garde le MÊME nœud DOM d'un rafraîchissement // Répéteur ni une Jauge garde le MÊME nœud DOM d'un rafraîchissement
@@ -337,7 +348,9 @@
const rowEl = ev.target.closest('.repeaterItem'); const rowEl = ev.target.closest('.repeaterItem');
window.lastClickedRowId = rowEl ? parseInt(rowEl.dataset.rowId, 10) : null; window.lastClickedRowId = rowEl ? parseInt(rowEl.dataset.rowId, 10) : null;
window.lastClickedDefinitionId = elDiv.dataset.definitionId ? parseInt(elDiv.dataset.definitionId, 10) : null; window.lastClickedDefinitionId = elDiv.dataset.definitionId ? parseInt(elDiv.dataset.definitionId, 10) : null;
runFlowFrom(trigger.node.id, trigger.screenId); // Exécute CHAQUE déclencheur "Au clic" posé sur cet élément, pas
// seulement le premier (voir le commentaire de findTriggerNodess).
triggers.forEach(trigger => runFlowFrom(trigger.node.id, trigger.screenId));
}); });
} }
}); });
@@ -354,12 +367,15 @@
document.querySelectorAll('[data-element-id]').forEach(elDiv => { document.querySelectorAll('[data-element-id]').forEach(elDiv => {
if (elDiv.dataset.hoverTriggerBound) return; if (elDiv.dataset.hoverTriggerBound) return;
const elementId = parseInt(elDiv.dataset.elementId, 10); const elementId = parseInt(elDiv.dataset.elementId, 10);
const enterTrigger = findTriggerNode(elementId, 'survol'); const enterTriggers = findTriggerNodess(elementId, 'survol');
const leaveTrigger = findTriggerNode(elementId, 'fin_survol'); const leaveTriggers = findTriggerNodess(elementId, 'fin_survol');
if (!enterTrigger && !leaveTrigger) return; if (!enterTriggers.length && !leaveTriggers.length) return;
elDiv.dataset.hoverTriggerBound = '1'; elDiv.dataset.hoverTriggerBound = '1';
if (enterTrigger) elDiv.addEventListener('mouseenter', () => runFlowFrom(enterTrigger.node.id, enterTrigger.screenId)); // Exécute CHAQUE déclencheur trouvé, pas seulement le premier — voir
if (leaveTrigger) elDiv.addEventListener('mouseleave', () => runFlowFrom(leaveTrigger.node.id, leaveTrigger.screenId)); // le commentaire de findTriggerNodess (plusieurs déclencheurs
// indépendants sur le même élément/événement doivent tous s'exécuter).
if (enterTriggers.length) elDiv.addEventListener('mouseenter', () => enterTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId)));
if (leaveTriggers.length) elDiv.addEventListener('mouseleave', () => leaveTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId)));
}); });
} }
@@ -605,7 +621,20 @@
const found = []; const found = [];
root.querySelectorAll('[data-element-id]').forEach(function(node) { root.querySelectorAll('[data-element-id]').forEach(function(node) {
const sib = node.nextSibling; const sib = node.nextSibling;
if (sib && sib.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.indexOf(sib.data) !== -1) { const hasCommentMarker = !!(sib && sib.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.indexOf(sib.data) !== -1);
// Un élément actuellement CACHÉ par une condition de
// visibilité (voir render_element_html.py) n'a pas de
// commentaire marqueur à sa suite — sa balise porte
// directement la classe "visibilityGated" à la place. Sans
// ce second cas, un élément qui passe de VISIBLE à CACHÉ
// n'était jamais retrouvé ici (seul le sens caché->visible
// l'était, via le commentaire) : son ancien nœud visible
// restait donc affiché indéfiniment à côté du nouveau,
// maintenant visible, qui le remplace normalement (ex. une
// icône "enveloppe fermée" jamais masquée en même temps que
// "enveloppe ouverte" qui apparaît après avoir cliqué).
const isHiddenPlaceholder = node.classList.contains('visibilityGated');
if (hasCommentMarker || isHiddenPlaceholder) {
found.push(node); found.push(node);
} }
}); });
@@ -779,6 +808,13 @@
const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]'); const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]');
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value); if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
return Promise.resolve(); return Promise.resolve();
} else if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) {
// Notification pure (voir screens/custom_events/) : ne fait que
// signaler l'événement, jamais choisir un élément — c'est à
// l'écouteur (déclencheur → condition → action) de décider quoi
// faire, avec ses propres réglages habituels.
window.dispatchGameEvent(node.target_custom_event_id);
return Promise.resolve();
} else if (node.action_type === 'activer_onglet' && node.target_element_id) { } else if (node.action_type === 'activer_onglet' && node.target_element_id) {
// 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre // 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre
// l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id, // l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id,
@@ -962,10 +998,10 @@
// continue sur son (ou ses) fil de sortie ; chaque nœud Condition évalue // continue sur son (ou ses) fil de sortie ; chaque nœud Condition évalue
// une donnée du jeu et suit le(s) fil(s) "Vrai" ou "Faux" en conséquence. // une donnée du jeu et suit le(s) fil(s) "Vrai" ou "Faux" en conséquence.
// flowScreenId : l'écran PROPRIÉTAIRE du graphe à exécuter — par défaut // flowScreenId : l'écran PROPRIÉTAIRE du graphe à exécuter — par défaut
// l'écran affiché, mais peut être un écran-MODÈLE (voir findTriggerNode) // l'écran affiché, mais peut être un écran-MODÈLE (voir findTriggerNodes)
// quand le nœud de départ vient d'un déclencheur posé dans l'éditeur d'un // quand le nœud de départ vient d'un déclencheur posé dans l'éditeur d'un
// élément de jeu réutilisable, sur un de ses propres enfants. Sans ce // élément de jeu réutilisable, sur un de ses propres enfants. Sans ce
// paramètre, un tel déclencheur était bien retrouvé (findTriggerNode // paramètre, un tel déclencheur était bien retrouvé (findTriggerNodes
// cherche dans tous les écrans) mais son graphe n'existait jamais dans // cherche dans tous les écrans) mais son graphe n'existait jamais dans
// l'écran réellement affiché : rien ne se déclenchait, silencieusement. // l'écran réellement affiché : rien ne se déclenchait, silencieusement.
function runFlowFrom(nodeId, flowScreenId) { function runFlowFrom(nodeId, flowScreenId) {
@@ -995,6 +1031,24 @@
step(nodeId); step(nodeId);
} }
// Déclenche un événement personnalisé (voir screens/custom_events/,
// action "declencher_evenement") : une pure NOTIFICATION, sans aucun
// paramètre — scan GLOBAL de gameData.flows, même principe que
// findTriggerNodes()/runScreenShowTriggers() ci-dessus, puisqu'un
// écouteur (nœud Déclencheur trigger_event="evenement") peut vivre sur
// N'IMPORTE QUELLE autre scène ou modèle, pas seulement l'écran affiché.
// C'est à CHAQUE écouteur de décider quoi faire ensuite (via sa propre
// suite condition/action, avec ses réglages habituels — cible fixe,
// "Ligne cliquée"...), jamais à l'événement lui-même de le dicter.
window.dispatchGameEvent = function(eventId) {
for (const screenIdKey in gameData.flows) {
const flow = gameData.flows[screenIdKey];
flow.nodes
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'evenement' && n.trigger_custom_event_id === eventId; })
.forEach(function(n) { runFlowFrom(n.id, screenIdKey); });
}
};
// L'écran affiché au démarrage doit être choisi AVANT le premier // L'écran affiché au démarrage doit être choisi AVANT le premier
// bindClicks() : la recherche du nœud Déclencheur associé à un élément se // bindClicks() : la recherche du nœud Déclencheur associé à un élément se
// fait sur l'écran courant (window.currentScreenId), qui doit donc déjà // fait sur l'écran courant (window.currentScreenId), qui doit donc déjà
+141 -9
View File
@@ -3,12 +3,12 @@
{% block content_class %}content-builder{% endblock %} {% block content_class %}content-builder{% endblock %}
{% block body_class %}builderBody{% endblock %} {% block body_class %}builderBody{% endblock %}
{% block extra_head %} {% block extra_head %}
<link rel="stylesheet" href="{{ google_fonts_link }}"> <link rel="stylesheet" href="{{ url_for('static', filename='vendor/fonts/fonts.css') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css"> <link rel="stylesheet" href="{{ url_for('static', filename='vendor/animate.min.css') }}">
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if screen.is_template %} {% if screen.is_template %}
<p class="hint" style="background:#1c2130; border:1px solid #2b3140; border-radius:8px; padding:10px 14px; margin:10px 0;"> <p class="hint" style="background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:10px 14px; margin:10px 0;">
🧩 Contenu d'un élément de jeu réutilisable. 🧩 Contenu d'un élément de jeu réutilisable.
</p> </p>
{% endif %} {% endif %}
@@ -172,6 +172,7 @@
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">🖥️ Écran</button> <button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">🖥️ Écran</button>
<button type="button" class="builderTabBtn" data-tab="flow" onclick="switchBuilderTab('flow')">🔀 Logique de la scène</button> <button type="button" class="builderTabBtn" data-tab="flow" onclick="switchBuilderTab('flow')">🔀 Logique de la scène</button>
<button type="button" class="builderTabBtn" data-tab="anim" onclick="switchBuilderTab('anim')">🎬 Timeline d'animation</button> <button type="button" class="builderTabBtn" data-tab="anim" onclick="switchBuilderTab('anim')">🎬 Timeline d'animation</button>
<button type="button" class="builderTabBtn" data-tab="events" onclick="switchBuilderTab('events')">📣 Événements</button>
</div> </div>
<div class="builderTabPanel active" data-tab-panel="screen"> <div class="builderTabPanel active" data-tab-panel="screen">
@@ -216,6 +217,16 @@
</select></label> </select></label>
<p class="hint" id="nf-trigger-repeater-hint" style="display:none;"></p> <p class="hint" id="nf-trigger-repeater-hint" style="display:none;"></p>
<p class="hint" id="nf-trigger-affichage-hint" style="display:none;"></p> <p class="hint" id="nf-trigger-affichage-hint" style="display:none;"></p>
<div id="nf-trigger-event-row" style="display:none;">
<label>Événement personnalisé<select id="nf-trigger-custom-event">
{% for ev in custom_events %}
<option value="{{ ev.id }}">{{ ev.name }}</option>
{% endfor %}
</select></label>
{% if not custom_events %}
<p class="hint">Aucun événement pour l'instant — <a href="javascript:void(0)" onclick="switchBuilderTab('events'); return false;">crées-en un dans l'onglet Événements</a>.</p>
{% endif %}
</div>
</div> </div>
<div id="nodeFormCondition" style="display:none;"> <div id="nodeFormCondition" style="display:none;">
@@ -371,6 +382,23 @@
<input type="text" id="nf-action-variable-value" style="margin-top:6px;" placeholder="Valeur / montant"> <input type="text" id="nf-action-variable-value" style="margin-top:6px;" placeholder="Valeur / montant">
{% endif %} {% endif %}
</div> </div>
<div id="nf-action-event-fields" style="display:none;">
{% if not custom_events %}
<p class="hint">Aucun événement pour l'instant — <a href="javascript:void(0)" onclick="switchBuilderTab('events'); return false;">crées-en un dans l'onglet Événements</a>.</p>
{% else %}
<!-- Déclencher un événement = notifier, rien d'autre : aucun
élément/ligne à choisir ici, c'est à l'ÉCOUTEUR (nœud
Déclencheur "Sur un événement personnalisé", plus bas dans
son propre graphe) de décider quoi faire, avec ses propres
réglages habituels — voir screens/custom_events/. -->
<select id="nf-action-custom-event">
{% for ev in custom_events %}
<option value="{{ ev.id }}">{{ ev.name }}</option>
{% endfor %}
</select>
{% endif %}
</div>
</div> </div>
<div class="actionsRow"><button type="button" class="smallBtn button is-small is-primary" onclick="submitNodeForm()">+ Ajouter ce nœud</button></div> <div class="actionsRow"><button type="button" class="smallBtn button is-small is-primary" onclick="submitNodeForm()">+ Ajouter ce nœud</button></div>
@@ -470,6 +498,66 @@
<div id="animTracks" class="animTracks"></div> <div id="animTracks" class="animTracks"></div>
</div> </div>
</div> </div>
<!-- Onglet Événements : entité GAME-WIDE (pas propre à cet écran, voir
db/custom_events/) mais gérée depuis ici plutôt que le tableau de
bord — pour rester au plus près de l'endroit où on câble des
écouteurs/déclencheurs (nœuds Déclencheur/Action de la Logique de
la scène ci-dessus). Présent aussi sur un écran-modèle, comme les
autres onglets. -->
<div class="builderTabPanel" id="eventsTabPanel" data-tab-panel="events">
<div class="dashPanelHeader">
<h2>Événements personnalisés</h2>
<button type="button" class="button is-small primary" onclick="toggleDashCreate('events')">+ Créer un événement</button>
</div>
<form method="post" action="{{ url_for('custom_event_new', slug=game.slug) }}" class="dashCreateBar" id="dashCreate-events" style="display:none;">
<input type="hidden" name="screen_id" value="{{ screen.id }}">
<input type="text" name="name" placeholder="NOM (ex. NEED_ACTION)" required autocomplete="off">
<input type="text" name="description" placeholder="Description (optionnel)">
<button type="submit" class="primary">Créer</button>
</form>
{% if custom_events %}
{% for ev in custom_events %}
<form method="post" action="{{ url_for('custom_event_edit', slug=game.slug, event_id=ev.id) }}" id="eventEditForm{{ ev.id }}">
<input type="hidden" name="screen_id" value="{{ screen.id }}">
</form>
{% endfor %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable">
<thead><tr><th>Nom</th><th>Description</th><th>Utilisé par</th><th></th></tr></thead>
<tbody>
{% for ev in custom_events %}
<tr>
<td><code>{{ ev.name }}</code></td>
<td><input type="text" form="eventEditForm{{ ev.id }}" name="description" value="{{ ev.description or '' }}" placeholder="Description" aria-label="Description de « {{ ev.name }} »"></td>
<td>
{% set usages = custom_event_usages.get(ev.id, []) %}
{% if usages %}
{% for u in usages %}
<a href="{{ url_for('screen_edit', slug=game.slug, screen_id=u.screen_id) }}" class="linkLikeButton" title="{{ 'Écouté ici' if u.role == 'ecoute' else 'Déclenché ici' }}">{{ '🔔' if u.role == 'ecoute' else '📣' }} {{ u.screen_name }}{{ ' (modèle)' if u.is_template else '' }}</a>{{ ', ' if not loop.last else '' }}
{% endfor %}
{% else %}
<span class="hint">Inutilisé</span>
{% endif %}
</td>
<td class="fieldsTableActions">
<button type="submit" form="eventEditForm{{ ev.id }}" class="button is-small" title="Enregistrer">💾</button>
<form method="post" action="{{ url_for('custom_event_delete', slug=game.slug, event_id=ev.id) }}"
onsubmit="return confirm('Supprimer l\'événement « {{ ev.name }} » ? Cela supprime aussi tous ses écouteurs et déclencheurs, sur toutes les scènes et tous les modèles.');">
<input type="hidden" name="screen_id" value="{{ screen.id }}">
<button type="submit" class="button is-small is-danger" title="Supprimer">🗑️</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="hint">Aucun événement pour l'instant — crée le premier ci-dessus, puis pose un nœud "Sur un événement personnalisé" (Déclencheur) ou "Déclencher un événement" (Action) dans la Logique de la scène.</p>
{% endif %}
</div>
</div> </div>
<!-- Panneau droit : propriétés de l'élément sélectionné — flottant, <!-- Panneau droit : propriétés de l'élément sélectionné — flottant,
@@ -522,7 +610,7 @@
</header> </header>
<section class="modal-card-body"> <section class="modal-card-body">
<p class="hint">Cette action est irréversible.</p> <p class="hint">Cette action est irréversible.</p>
<p class="hint" id="deleteConfirmFlowWarning" style="display:none; margin-top:10px; color:var(--danger, #e2685f);"></p> <p class="hint" id="deleteConfirmFlowWarning" style="display:none; margin-top:10px; color:var(--danger, #f04438);"></p>
</section> </section>
<footer class="modal-card-foot" style="justify-content:flex-end; gap:8px;"> <footer class="modal-card-foot" style="justify-content:flex-end; gap:8px;">
<button type="button" class="button is-small" onclick="closeDeleteConfirmModal()">Annuler</button> <button type="button" class="button is-small" onclick="closeDeleteConfirmModal()">Annuler</button>
@@ -2087,6 +2175,7 @@ var ELEMENT_DESACTIVE_LABELS_MAP = {{ element_desactive_labels_json|safe }};
var DATA_OPERATION_LABELS_MAP = {{ data_operation_labels_json|safe }}; var DATA_OPERATION_LABELS_MAP = {{ data_operation_labels_json|safe }};
var CONDITION_OPERATOR_LABELS_MAP = {{ condition_operator_labels_json|safe }}; var CONDITION_OPERATOR_LABELS_MAP = {{ condition_operator_labels_json|safe }};
var TRIGGER_LABELS_MAP = {{ trigger_labels_json|safe }}; var TRIGGER_LABELS_MAP = {{ trigger_labels_json|safe }};
var CUSTOM_EVENTS_MAP = {{ custom_events_map_json|safe }};
var flowPendingPort = null; // {nodeId, port} en attente de connexion var flowPendingPort = null; // {nodeId, port} en attente de connexion
@@ -2095,6 +2184,10 @@ function flowUrl(path) { return "/game/{{ game.slug }}" + path; }
function nodeLabel(node) { function nodeLabel(node) {
if (node.node_type === 'trigger') { if (node.node_type === 'trigger') {
if (node.trigger_event === 'affichage') return '🖥️ ' + TRIGGER_LABELS_MAP['affichage']; if (node.trigger_event === 'affichage') return '🖥️ ' + TRIGGER_LABELS_MAP['affichage'];
if (node.trigger_event === 'evenement') {
var listenedEvent = CUSTOM_EVENTS_MAP[node.trigger_custom_event_id];
return '📣 Sur « ' + (listenedEvent ? listenedEvent.name : '?') + ' »';
}
return '🖱️ ' + (TRIGGER_LABELS_MAP[node.trigger_event] || 'Au clic') + ' sur ' + return '🖱️ ' + (TRIGGER_LABELS_MAP[node.trigger_event] || 'Au clic') + ' sur ' +
(ELEMENTS_LABELS[node.trigger_element_id] || '?'); (ELEMENTS_LABELS[node.trigger_element_id] || '?');
} }
@@ -2147,6 +2240,12 @@ function nodeLabel(node) {
if (node.data_value) base += ' ' + node.data_value; if (node.data_value) base += ' ' + node.data_value;
} else if (node.action_type === 'attendre') { } else if (node.action_type === 'attendre') {
base += ' (' + (node.data_value || '0') + ' s)'; base += ' (' + (node.data_value || '0') + ' s)';
} else if (node.action_type === 'declencher_evenement') {
// Notification pure : rien d'autre à afficher que le nom de
// l'événement (voir screens/custom_events/) — aucun élément/ligne
// n'est jamais transmis au déclenchement.
var dispatchedEvent = CUSTOM_EVENTS_MAP[node.target_custom_event_id];
base += ' « ' + (dispatchedEvent ? dispatchedEvent.name : '?') + ' »';
} }
return base; return base;
} }
@@ -2324,10 +2423,16 @@ function openNodeForm(type) {
} }
function toggleFlowTriggerFields() { function toggleFlowTriggerFields() {
var isAffichage = document.getElementById('nf-trigger-event').value === 'affichage'; var eventVal = document.getElementById('nf-trigger-event').value;
document.getElementById('nf-trigger-element-row').style.display = isAffichage ? 'none' : 'block'; var isAffichage = eventVal === 'affichage';
document.getElementById('nf-trigger-repeater-hint').style.display = isAffichage ? 'none' : 'block'; // "Sur un événement personnalisé" ne concerne ni un élément précis ni
// l'écran entier : c'est un scan global (voir dispatchGameEvent() dans
// play.html), le seul réglage propre à ce nœud est QUEL événement.
var isCustomEvent = eventVal === 'evenement';
document.getElementById('nf-trigger-element-row').style.display = (isAffichage || isCustomEvent) ? 'none' : 'block';
document.getElementById('nf-trigger-repeater-hint').style.display = (isAffichage || isCustomEvent) ? 'none' : 'block';
document.getElementById('nf-trigger-affichage-hint').style.display = isAffichage ? 'block' : 'none'; document.getElementById('nf-trigger-affichage-hint').style.display = isAffichage ? 'block' : 'none';
document.getElementById('nf-trigger-event-row').style.display = isCustomEvent ? 'block' : 'none';
} }
function closeNodeForm() { function closeNodeForm() {
@@ -2427,7 +2532,7 @@ function renderFlowConditionClauses() {
wrap.innerHTML = flowConditionExtraClauses.map(function(clause, idx) { wrap.innerHTML = flowConditionExtraClauses.map(function(clause, idx) {
var isVariable = clause.source === 'variable'; var isVariable = clause.source === 'variable';
return '' + return '' +
'<div class="flowConditionClauseRow" style="border-top:1px dashed #ccc; margin-top:8px; padding-top:8px;">' + '<div class="flowConditionClauseRow" style="border-top:1px dashed var(--border); margin-top:8px; padding-top:8px;">' +
'<label>Source<select id="nf-cond-extra-source-' + idx + '" onchange="onFlowClauseSourceChange(' + idx + ')">' + '<label>Source<select id="nf-cond-extra-source-' + idx + '" onchange="onFlowClauseSourceChange(' + idx + ')">' +
'<option value="objet"' + (isVariable ? '' : ' selected') + '>Champ d\'un objet</option>' + '<option value="objet"' + (isVariable ? '' : ' selected') + '>Champ d\'un objet</option>' +
'<option value="variable"' + (isVariable ? ' selected' : '') + '>Variable globale</option>' + '<option value="variable"' + (isVariable ? ' selected' : '') + '>Variable globale</option>' +
@@ -2491,12 +2596,14 @@ function toggleFlowActionFields() {
var dataFields = document.getElementById('nf-action-data-fields'); var dataFields = document.getElementById('nf-action-data-fields');
var variableFields = document.getElementById('nf-action-variable-fields'); var variableFields = document.getElementById('nf-action-variable-fields');
var waitFields = document.getElementById('nf-action-wait-fields'); var waitFields = document.getElementById('nf-action-wait-fields');
var eventFields = document.getElementById('nf-action-event-fields');
var isOnglet = typeSel.value === 'activer_onglet'; var isOnglet = typeSel.value === 'activer_onglet';
screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none'; screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none';
elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none'; elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none';
dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none'; dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none';
variableFields.style.display = typeSel.value === 'modifier_variable' ? 'block' : 'none'; variableFields.style.display = typeSel.value === 'modifier_variable' ? 'block' : 'none';
waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none'; waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none';
if (eventFields) eventFields.style.display = typeSel.value === 'declencher_evenement' ? 'block' : 'none';
document.getElementById('nf-action-onglet-hint').style.display = isOnglet ? 'block' : 'none'; document.getElementById('nf-action-onglet-hint').style.display = isOnglet ? 'block' : 'none';
document.getElementById('nf-action-element-property-wrap').style.display = isOnglet ? 'none' : 'block'; document.getElementById('nf-action-element-property-wrap').style.display = isOnglet ? 'none' : 'block';
if (typeSel.value === 'modifier_element') toggleFlowElementPropertyValue(); if (typeSel.value === 'modifier_element') toggleFlowElementPropertyValue();
@@ -2556,7 +2663,9 @@ function submitNodeForm() {
if (currentNodeFormType === 'trigger') { if (currentNodeFormType === 'trigger') {
payload.trigger_event = document.getElementById('nf-trigger-event').value; payload.trigger_event = document.getElementById('nf-trigger-event').value;
if (payload.trigger_event !== 'affichage') { if (payload.trigger_event === 'evenement') {
payload.trigger_custom_event_id = parseInt(document.getElementById('nf-trigger-custom-event').value, 10);
} else if (payload.trigger_event !== 'affichage') {
payload.trigger_element_id = parseInt(document.getElementById('nf-trigger-element').value, 10); payload.trigger_element_id = parseInt(document.getElementById('nf-trigger-element').value, 10);
} }
} else if (currentNodeFormType === 'condition') { } else if (currentNodeFormType === 'condition') {
@@ -2639,6 +2748,11 @@ function submitNodeForm() {
payload.data_value = document.getElementById('nf-action-variable-value').value; payload.data_value = document.getElementById('nf-action-variable-value').value;
} else if (actionType === 'attendre') { } else if (actionType === 'attendre') {
payload.data_value = document.getElementById('nf-action-wait-seconds').value; payload.data_value = document.getElementById('nf-action-wait-seconds').value;
} else if (actionType === 'declencher_evenement') {
// Notification pure : aucun élément/ligne à transmettre (voir
// screens/custom_events/) — c'est à l'écouteur de décider quoi
// faire, avec ses propres réglages habituels.
payload.target_custom_event_id = parseInt(document.getElementById('nf-action-custom-event').value, 10);
} }
} }
@@ -2671,6 +2785,24 @@ function switchBuilderTab(tab) {
if (tab === 'flow') requestAnimationFrame(drawFlowEdges); if (tab === 'flow') requestAnimationFrame(drawFlowEdges);
} }
function toggleDashCreate(tab) {
var bar = document.getElementById('dashCreate-' + tab);
bar.style.display = bar.style.display === 'none' ? 'flex' : 'none';
if (bar.style.display === 'flex') {
var firstInput = bar.querySelector('input, select');
if (firstInput) firstInput.focus();
}
}
// Onglet initial : ?tab=... (utilisé par les routes de CRUD des
// événements, routes/custom_events/, pour revenir directement sur
// l'onglet "Événements" après création/modification/suppression plutôt
// que de laisser deviner où cliquer — même patron que game_dashboard.html).
(function () {
var requestedTab = {{ (request.args.get('tab') or '')|tojson }};
if (requestedTab) switchBuilderTab(requestedTab);
})();
// ---------- Timeline d'animation : piste par élément, clips Animate.css ou // ---------- Timeline d'animation : piste par élément, clips Animate.css ou
// personnalisés (images-clés), glissés/redimensionnés à la souris sur un // personnalisés (images-clés), glissés/redimensionnés à la souris sur un
// axe du temps en secondes depuis l'affichage de l'écran. Même principe de // axe du temps en secondes depuis l'affichage de l'écran. Même principe de
+4 -4
View File
@@ -86,18 +86,18 @@ def test_clip_delete_removes_it(client, game):
assert f'"id": {clip["id"]}' not in html assert f'"id": {clip["id"]}' not in html
def test_screen_edit_exposes_timeline_panel_and_animate_css_cdn(client, game): def test_screen_edit_exposes_timeline_panel_and_animate_css(client, game):
screen_id = _create_screen(client, game) screen_id = _create_screen(client, game)
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert "animTabPanel" in html assert "animTabPanel" in html
assert "Timeline d'animation" in html assert "Timeline d'animation" in html
assert "animate.css" in html assert "animate.min.css" in html
def test_play_page_exposes_animation_runtime_and_cdn(client, game): def test_play_page_exposes_animation_runtime_and_animate_css(client, game):
resp = client.get(f"/game/{game}/play") resp = client.get(f"/game/{game}/play")
html = resp.data.decode() html = resp.data.decode()
assert "animate.css" in html assert "animate.min.css" in html
assert "runAnimationTimeline" in html assert "runAnimationTimeline" in html
assert "injectCustomKeyframes" in html assert "injectCustomKeyframes" in html
+143
View File
@@ -0,0 +1,143 @@
"""Événements personnalisés (db/custom_events/, screens/custom_events/) :
un événement game-wide (ex. NEED_ACTION) écouté par un nœud Déclencheur
("trigger_event"="evenement") sur UNE scène/UN modèle, et déclenché par
un nœud Action ("action_type"="declencher_evenement") sur UNE AUTRE
scène/UN AUTRE modèle — la seule entité game-wide du moteur dont la
suppression cascade RÉELLEMENT vers les nœuds qui la référencent,
partout à la fois (voir screens/custom_events/delete_custom_event.py).
Un événement est une pure NOTIFICATION, sans aucun paramètre : le
déclencher ne fait que signaler, jamais choisir un élément — c'est à
l'ÉCOUTEUR (déclencheur → condition → action) de décider quoi faire
ensuite, avec ses propres réglages habituels (cible fixe, "Ligne
cliquée"...)."""
import json
import re
import db
import screens
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
def _add_flow_node(client, slug, screen_id, **fields):
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(fields),
content_type="application/json",
)
assert resp.status_code == 200
return resp.get_json()["id"]
def test_create_event_is_idempotent_by_name(client, game):
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/events/new", data={
"name": "NEED_ACTION", "description": "Besoin d'action", "screen_id": screen_id,
})
assert resp.status_code == 302
events = db.list_custom_events(game)
assert len(events) == 1
assert events[0]["name"] == "NEED_ACTION"
# Double soumission : ne crée pas un deuxième événement du même nom.
client.post(f"/game/{game}/events/new", data={"name": "NEED_ACTION", "screen_id": screen_id})
assert len(db.list_custom_events(game)) == 1
def test_edit_event_updates_description(client, game):
screen_id = _create_screen(client, game)
event_id = db.create_custom_event(game, "NEED_ACTION", "old")
resp = client.post(f"/game/{game}/events/{event_id}/edit", data={
"description": "new", "screen_id": screen_id,
})
assert resp.status_code == 302
event = db.get_custom_event(game, event_id)
assert event["description"] == "new"
def test_usages_reports_listener_and_dispatcher_across_different_screens(client, game):
event_id = db.create_custom_event(game, "NEED_ACTION")
listener_screen = _create_screen(client, game, "Écran écouteur")
_add_flow_node(client, game, listener_screen, node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id)
dispatcher_screen = _create_screen(client, game, "Écran déclencheur")
_add_flow_node(
client, game, dispatcher_screen,
node_type="action", action_type="declencher_evenement", target_custom_event_id=event_id,
)
usages = screens.list_custom_event_usages(game)
roles = {u["role"] for u in usages[event_id]}
screen_ids = {u["screen_id"] for u in usages[event_id]}
assert roles == {"ecoute", "declenche"}
assert screen_ids == {listener_screen, dispatcher_screen}
def test_deleting_an_event_cascades_to_every_referencing_node_everywhere(client, game):
event_id = db.create_custom_event(game, "NEED_ACTION")
listener_screen = _create_screen(client, game, "Écran écouteur")
listener_node_id = _add_flow_node(
client, game, listener_screen,
node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id,
)
# Le modèle (élément de jeu réutilisable) est un écran caché — voir
# screens.create_element_type — le déclencheur y est posé exactement
# comme sur une vraie scène.
screens.create_element_type(game, "Carte email", "📧")
template = next(t for t in screens.list_element_types(game) if t["name"] == "Carte email")
template_screen_id = template["template_screen_id"]
dispatcher_node_id = _add_flow_node(
client, game, template_screen_id,
node_type="action", action_type="declencher_evenement", target_custom_event_id=event_id,
)
resp = client.post(f"/game/{game}/events/{event_id}/delete", data={"screen_id": listener_screen}, follow_redirects=False)
assert resp.status_code == 302
assert db.get_custom_event(game, event_id) is None
assert screens.get_flow_node(game, listener_node_id) is None
assert screens.get_flow_node(game, dispatcher_node_id) is None
# Les écrans/modèles eux-mêmes ne sont pas touchés, seuls les nœuds.
assert screens.get_screen(game, listener_screen) is not None
assert screens.get_screen(game, template_screen_id) is not None
def test_runtime_payload_exposes_both_nodes_for_the_client_js(client, game):
"""templates/play.html::dispatchGameEvent()/findTriggerNode() scannent
gameData.flows (toutes les scènes ET tous les modèles à la fois) pour
retrouver un nœud par trigger_custom_event_id/target_custom_event_id —
ce test verrouille que full_game_payload() expose bien ces deux champs
intacts pour chaque écran, avec le bon type (entier, pas une chaîne),
faute de quoi une comparaison stricte "===" côté JS échouerait
silencieusement."""
event_id = db.create_custom_event(game, "NEED_ACTION")
listener_screen = _create_screen(client, game, "Écran écouteur")
listener_node_id = _add_flow_node(
client, game, listener_screen,
node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id,
)
dispatcher_screen = _create_screen(client, game, "Écran déclencheur")
dispatcher_node_id = _add_flow_node(
client, game, dispatcher_screen,
node_type="action", action_type="declencher_evenement", target_custom_event_id=event_id,
)
payload = screens.full_game_payload(game)
flows = payload["flows"]
listener_node = next(n for n in flows[str(listener_screen)]["nodes"] if n["id"] == listener_node_id)
assert listener_node["trigger_event"] == "evenement"
assert listener_node["trigger_custom_event_id"] == event_id
dispatcher_node = next(n for n in flows[str(dispatcher_screen)]["nodes"] if n["id"] == dispatcher_node_id)
assert dispatcher_node["action_type"] == "declencher_evenement"
assert dispatcher_node["target_custom_event_id"] == event_id
+120
View File
@@ -141,3 +141,123 @@ def test_the_only_admin_account_cannot_be_deleted(client):
assert "administrateur" in resp.get_data(as_text=True) assert "administrateur" in resp.get_data(as_text=True)
import tests.conftest as conftest_module import tests.conftest as conftest_module
assert auth.get_user_by_id(conftest_module._TEST_ADMIN_ID) is not None assert auth.get_user_by_id(conftest_module._TEST_ADMIN_ID) is not None
def test_regenerate_recovery_codes_requires_correct_password(anon_client):
_register(anon_client, "regencodeswrong@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/regenerate-recovery-codes", data={"current_password": "wrong"})
assert "incorrect" in resp.get_data(as_text=True)
finally:
_cleanup_project("regencodeswrong@example.com")
def test_regenerate_recovery_codes_replaces_the_old_ones_and_shows_them_once(anon_client):
_register(anon_client, "regencodesok@example.com")
_confirm_2fa(anon_client)
user = auth.get_user_by_email("regencodesok@example.com")
try:
old_codes = auth.generate_recovery_codes(user["id"])
resp = anon_client.post("/profile/regenerate-recovery-codes", data={"current_password": "Sup3r$ecret!"})
html = resp.get_data(as_text=True)
assert "recoveryCodesModal" in html
# Jamais réaffichés sur un chargement suivant.
html_again = anon_client.get("/profile").get_data(as_text=True)
assert "recoveryCodesModal" not in html_again
# Les anciens codes ne fonctionnent plus, mais le nouveau lot,
# visible dans le modal ci-dessus, doit être réellement stocké en
# base — vérifié indirectement via verify_recovery_code : aucun des
# anciens codes ne doit plus matcher.
for old_code in old_codes:
assert not auth.verify_recovery_code(user["id"], old_code)
finally:
_cleanup_project("regencodesok@example.com")
def test_update_email_requires_correct_password(anon_client):
_register(anon_client, "emailwrongpw@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/update-email", data={"email": "new@example.com", "current_password": "wrong"})
assert "incorrect" in resp.get_data(as_text=True)
assert auth.get_user_by_email("emailwrongpw@example.com") is not None
finally:
_cleanup_project("emailwrongpw@example.com")
def test_update_email_rejects_invalid_format(anon_client):
_register(anon_client, "emailinvalid@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/update-email", data={"email": "not-an-email", "current_password": "Sup3r$ecret!"})
assert "invalide" in resp.get_data(as_text=True)
finally:
_cleanup_project("emailinvalid@example.com")
def test_update_email_rejects_an_email_already_used(anon_client):
_register(anon_client, "emailtaken1@example.com")
_confirm_2fa(anon_client)
anon_client.post("/logout")
_register(anon_client, "emailtaken2@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/update-email", data={"email": "emailtaken1@example.com", "current_password": "Sup3r$ecret!"})
assert "existe déjà" in resp.get_data(as_text=True)
finally:
_cleanup_project("emailtaken1@example.com")
_cleanup_project("emailtaken2@example.com")
def test_update_email_renames_the_user_project_folder(anon_client):
import os
_register(anon_client, "oldmail@example.com")
_confirm_2fa(anon_client)
old_user = auth.get_user_by_email("oldmail@example.com")
old_slug = old_user["project_slug"]
assert os.path.isdir(db.game_dir(old_slug))
try:
resp = anon_client.post("/profile/update-email", data={"email": "newmail@example.com", "current_password": "Sup3r$ecret!"})
assert "mise à jour" in resp.get_data(as_text=True)
assert auth.get_user_by_email("oldmail@example.com") is None
new_user = auth.get_user_by_email("newmail@example.com")
assert new_user is not None
new_slug = new_user["project_slug"]
assert new_slug == db.slugify("newmail@example.com")
assert not os.path.isdir(db.game_dir(old_slug))
assert os.path.isdir(db.game_dir(new_slug))
# La connexion se fait désormais avec la nouvelle adresse.
anon_client.post("/logout")
resp = anon_client.post("/login", data={"email": "newmail@example.com", "password": "Sup3r$ecret!"}, follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["Location"] == "/login/2fa"
finally:
_cleanup_project("oldmail@example.com")
_cleanup_project("newmail@example.com")
def test_update_email_for_an_admin_does_not_touch_any_project_folder(client, game):
"""`client`/`game` (conftest.py) : compte admin, pas de project_slug
dédié — changer son adresse ne doit renommer aucun dossier. L'admin de
test est PARTAGÉ par toute la session de tests (voir conftest.py) :
son adresse est restaurée après coup pour ne pas perturber les autres
tests qui s'appuient dessus."""
import tests.conftest as conftest_module
try:
resp = client.post("/profile/update-email", data={"email": "newadminmail@example.com", "current_password": "Test1234!"})
assert "mise à jour" in resp.get_data(as_text=True)
user = auth.get_user_by_id(conftest_module._TEST_ADMIN_ID)
assert user["email"] == "newadminmail@example.com"
assert user["project_slug"] is None
assert db.game_meta(game) is not None
finally:
auth.update_email(conftest_module._TEST_ADMIN_ID, "admin@pytest.local")
+91
View File
@@ -0,0 +1,91 @@
"""Publier un jeu en exécutable Windows autonome (bouton "Publier", voir
routes/publish/publish_game.py et publish/build_package.py). Le vrai
téléchargement du Python portable + Flask (publish/vendor_runtime.py)
n'a JAMAIS lieu ici : `ensure_vendor_ready` est monkeypatché vers un
dossier factice minuscule — ces tests vérifient l'ASSEMBLAGE du zip
(fichiers présents, structure), jamais un vrai lancement du serveur
embarqué (nécessite un environnement Windows réel, voir le plan)."""
import os
import zipfile
import pytest
import publish.build_package as build_package
from tests.test_auth import anon_client # noqa: F401
@pytest.fixture
def fake_vendor(tmp_path, monkeypatch):
"""Un python-embed/pylibs factice, juste assez pour que l'assemblage
du zip (copie de fichiers) fonctionne sans jamais toucher au réseau."""
python_dir = tmp_path / "python-embed-amd64"
python_dir.mkdir()
(python_dir / "python.exe").write_bytes(b"fake-exe")
pylibs_dir = tmp_path / "pylibs"
pylibs_dir.mkdir()
(pylibs_dir / "flask").mkdir()
(pylibs_dir / "flask" / "__init__.py").write_text("# fake", encoding="utf-8")
def _fake_ensure_vendor_ready():
return str(python_dir), str(pylibs_dir)
monkeypatch.setattr(build_package.vendor_runtime, "ensure_vendor_ready", _fake_ensure_vendor_ready)
return python_dir, pylibs_dir
def test_build_game_zip_contains_the_expected_files(fake_vendor, game):
zip_path, game_name = build_package.build_game_zip(game)
try:
assert game_name # nom du jeu (pytest_test_game par défaut, voir conftest.py)
with zipfile.ZipFile(zip_path) as zf:
names = set(zf.namelist())
for expected in [
"run.bat", "player_app.py",
f"projects/{game}/game.db",
"screens/__init__.py", "db/__init__.py", "filters/__init__.py",
"core/__init__.py", "core/flask_app.py", "core/jinja_filters.py",
"templates/play.html",
"static/style.css", "static/csrf_fetch.js",
"python-embed/python.exe", "python-embed/pylibs/flask/__init__.py",
]:
assert expected in names, f"{expected} manquant du zip"
finally:
if os.path.isfile(zip_path):
os.remove(zip_path)
def test_build_game_zip_bakes_the_slug_into_player_app(fake_vendor, game):
zip_path, _ = build_package.build_game_zip(game)
try:
with zipfile.ZipFile(zip_path) as zf:
player_app_src = zf.read("player_app.py").decode("utf-8")
assert f'SLUG = "{game}"' in player_app_src
assert "_SLUG_PLACEHOLDER" not in player_app_src
finally:
if os.path.isfile(zip_path):
os.remove(zip_path)
def test_publish_route_returns_a_zip_attachment(fake_vendor, client, game):
resp = client.post(f"/game/{game}/publish")
assert resp.status_code == 200
assert resp.headers["Content-Type"] == "application/zip"
assert "attachment" in resp.headers["Content-Disposition"]
assert resp.data[:2] == b"PK" # signature d'un fichier zip
def test_publish_route_is_isolated_like_other_game_routes(fake_vendor, anon_client):
"""Réutilise la garde d'accès existante (core/auth_guard.py) sans
code supplémentaire — un compte non-admin ne doit pas pouvoir publier
le projet d'un autre (même vérification que /game/<slug> lui-même,
voir tests/test_auth.py::test_non_admin_user_is_isolated_to_their_own_project)."""
from tests.test_auth import _register, _confirm_2fa, _cleanup_project
_register(anon_client, "publishisolation@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/publish")
assert resp.status_code == 403
finally:
_cleanup_project("publishisolation@example.com")
+98
View File
@@ -0,0 +1,98 @@
"""Régression : après une action "Modifier une donnée" qui fait BASCULER
une condition de visibilité (ex. une icône "enveloppe fermée" visible
quand is_opened=faux, une "enveloppe ouverte" visible quand is_opened=vrai),
templates/play.html::refreshRuntimeData() ne retrouvait, dans le HTML
fraîchement régénéré, que l'élément qui REDEVIENT visible (repéré via le
commentaire "<!--visibilityGated-->" posé après son contenu réel par
_mark(), voir render_element_html.py) — jamais celui qui REDEVIENT
CACHÉ, dont le placeholder ("<div class=\"visibilityGated\" ...
style=\"display:none;\"></div>") n'a pas de commentaire à sa suite, juste
une classe CSS sur lui-même. Résultat : l'ancien élément visible restait
affiché indéfiniment à côté du nouveau qui apparaît en plus (les deux
enveloppes en même temps).
Ces tests-ci ne peuvent pas simuler le DOM d'un navigateur (refreshRuntimeData
est du JS) — ils verrouillent seulement le CONTRAT côté serveur dont
dépend le correctif JS (findCommentMarkedDescendants, voir play.html) :
un élément CACHÉ par condition de visibilité doit porter la classe
"visibilityGated" directement sur sa balise, un élément VISIBLE sous
condition doit porter le commentaire juste après. Voir aussi
tests/test_data_binding_refresh_marker.py pour le marqueur "dataBound"."""
import re
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
def _add_icon_with_visibility(client, slug, screen_id, def_id, field_name, expected):
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": "icone"}, follow_redirects=False)
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(f"/game/{slug}/elements/{el_id}/save", data={
"ctrl_visibilite_cond_mode": "objet",
"ctrl_visibilite_cond_definition_id": str(def_id),
"ctrl_visibilite_cond_champ": field_name,
"ctrl_visibilite_cond_operateur": "egal",
"ctrl_visibilite_cond_valeur": expected,
})
return el_id
def test_hidden_element_carries_the_class_marker_not_a_comment(client, game):
resp = client.post(f"/game/{game}/objects/new", data={
"object_name": "Email", "field_name[]": ["is_opened"],
"field_type[]": ["booleen"], "field_relation[]": [""], "field_required[]": ["0"],
"field_min[]": [""], "field_max[]": [""],
}, follow_redirects=False)
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"is_opened": "0"})
screen_id = _create_screen(client, game)
closed_id = _add_icon_with_visibility(client, game, screen_id, def_id, "is_opened", "Non")
open_id = _add_icon_with_visibility(client, game, screen_id, def_id, "is_opened", "Oui")
payload = client.get(f"/game/{game}/runtime-payload").get_json()
elements = {e["id"]: e for e in payload["screens"][0]["elements"]}
closed_html = elements[closed_id]["rendered_html"]
open_html = elements[open_id]["rendered_html"]
# is_opened=faux : l'enveloppe "fermée" est visible (marqueur en
# commentaire, après son vrai contenu) ; l'enveloppe "ouverte" est
# cachée (marqueur en CLASSE, directement sur le placeholder).
assert "<!--visibilityGated-->" in closed_html
assert f'data-element-id="{closed_id}"' in closed_html
assert "display:none" not in closed_html
assert 'class="visibilityGated"' in open_html
assert "display:none" in open_html
assert f'data-element-id="{open_id}"' in open_html
assert "<!--visibilityGated-->" not in open_html
def test_toggling_the_field_swaps_which_element_carries_which_marker(client, game):
resp = client.post(f"/game/{game}/objects/new", data={
"object_name": "Email2", "field_name[]": ["is_opened"],
"field_type[]": ["booleen"], "field_relation[]": [""], "field_required[]": ["0"],
"field_min[]": [""], "field_max[]": [""],
}, follow_redirects=False)
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
resp = client.post(f"/game/{game}/objects/{def_id}/data/new", data={"is_opened": "0"}, follow_redirects=False)
screen_id = _create_screen(client, game)
closed_id = _add_icon_with_visibility(client, game, screen_id, def_id, "is_opened", "Non")
open_id = _add_icon_with_visibility(client, game, screen_id, def_id, "is_opened", "Oui")
import db
rows = db.list_rows(game, db.get_definition(game, def_id))
row_id = rows[0]["id"]
db.update_row(game, db.get_definition(game, def_id), row_id, {"is_opened": "on"})
payload = client.get(f"/game/{game}/runtime-payload").get_json()
elements = {e["id"]: e for e in payload["screens"][0]["elements"]}
# is_opened=vrai maintenant : les rôles s'inversent complètement, aucun
# des deux ne doit garder son ancien marqueur.
assert 'class="visibilityGated"' in elements[closed_id]["rendered_html"]
assert "<!--visibilityGated-->" in elements[open_id]["rendered_html"]