Merge pull request 'Dev' (#5) from dev into main
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
@@ -9,6 +9,7 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
env/
|
env/
|
||||||
Bug/
|
Bug/
|
||||||
|
regles/
|
||||||
|
|
||||||
# Variables d'environnement / secrets locaux
|
# Variables d'environnement / secrets locaux
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
@@ -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.")
|
||||||
|
|||||||
@@ -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()))
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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 = {
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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)."""
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@echo off
|
||||||
|
cd /d "%~dp0"
|
||||||
|
python-embed\python.exe player_app.py
|
||||||
|
pause
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -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"))
|
||||||
@@ -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"))
|
||||||
@@ -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,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import publish_game
|
||||||
@@ -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")
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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é",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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 |
+242
-26
File diff suppressed because one or more lines are too long
Vendored
+7
File diff suppressed because one or more lines are too long
Vendored
+3
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.
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.
Vendored
+423
@@ -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;
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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; }
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
+68
-69
@@ -1,88 +1,87 @@
|
|||||||
{% 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>
|
||||||
|
<form method="post" action="{{ url_for('profile_update_email') }}" autocomplete="off" class="profileForm">
|
||||||
|
<div class="field"><input class="input is-small" type="email" name="email" value="{{ user.email }}" placeholder="Adresse email" required></div>
|
||||||
|
<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">Changer d'adresse email</button></div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
|
||||||
<label class="label">Nom</label>
|
|
||||||
<div class="control"><input class="input" type="text" name="nom" value="{{ user.nom }}" required></div>
|
|
||||||
</div>
|
|
||||||
<div class="field mt-4">
|
|
||||||
<button type="submit" class="button primary">Enregistrer</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</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>
|
||||||
|
</form>
|
||||||
</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>
|
|
||||||
</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>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
</div>
|
||||||
<label class="label">Tape "SUPPRIMER" pour confirmer</label>
|
|
||||||
<div class="control"><input class="input" type="text" name="confirm_text" required></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>
|
||||||
<div class="field mt-4">
|
|
||||||
<button type="submit" class="button is-danger">Supprimer définitivement mon compte</button>
|
<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>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user