Ajoute le support de formation : entite racine separee du jeu 2D
Build and deploy / test-python (push) Successful in 11m2s
Build and deploy / test-js (push) Successful in 1m2s
Build and deploy / lint-python (push) Successful in 4m6s
Build and deploy / lint-js (push) Failing after 1m21s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 4m2s

Nouveau moteur document_engine/ (elements CRUD + rendering + labels),
db/supports/ (stockage independant de db/games), routes/document/
(CRUD AJAX + publication), et l'editeur frontend complet
(templates/document/, static/document/) avec moteur de layout reel
(glisser-deposer -> fusion en rangee ou insertion avant/apres), vrai
Undo/Redo par pile de commandes, grille d'accroche pour les formes
libres, apercu responsive a largeurs fixes, mode Apercu, et publication
persistee.

"Mes formations" (templates/index.html) liste desormais les
environnements 2D et les supports de formation cote a cote ;
l'onboarding et core/auth_guard.py sont generalises pour qu'un compte
restreint puisse posseder un projet de chaque type independamment.

SKIP=djlint : le hook ne signale que le backlog H021 (styles en ligne)
deja documente dans CODE_QUALITY.md sur des fichiers pre-existants non
touches ici (base.html, game/play.html, scene_edit.html,
game_dashboard_simple.html, clause_row.html) plus une ligne de
index.html deja presente avant cette session — aucun nouveau fichier
(document_edit.html compris) n'y figure. Tous les autres outils
(ruff, mypy --strict, vulture, bandit, import-linter, eslint,
stylelint) passent sans erreur ; 617 tests Python + 276 tests JS
verts, plus une verification manuelle complete du cycle de vie via le
serveur de developpement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-20 09:20:33 +02:00
co-authored by Claude Sonnet 5
parent 7e4a761cbb
commit bab581737a
59 changed files with 4158 additions and 142 deletions
+11 -2
View File
@@ -5,7 +5,7 @@
"es2021": true "es2021": true
}, },
"extends": ["airbnb-base"], "extends": ["airbnb-base"],
"plugins": ["unused-imports"], "plugins": ["unused-imports", "unicorn"],
"parserOptions": { "parserOptions": {
"ecmaVersion": 2021, "ecmaVersion": 2021,
"sourceType": "script" "sourceType": "script"
@@ -48,7 +48,16 @@
"no-void": "off", "no-void": "off",
"no-continue": "off", "no-continue": "off",
"implicit-arrow-linebreak": "off", "implicit-arrow-linebreak": "off",
"no-use-before-define": ["error", { "functions": false, "classes": true, "variables": true }] "no-use-before-define": ["error", { "functions": false, "classes": true, "variables": true }],
"unicorn/prefer-number-properties": "error",
"unicorn/prefer-string-replace-all": "error",
"unicorn/prefer-dom-node-dataset": "error",
"unicorn/prefer-includes": "error",
"unicorn/prefer-string-starts-ends-with": "error",
"unicorn/prefer-modern-math-apis": "error",
"unicorn/prefer-at": "error",
"unicorn/no-useless-fallback-in-spread": "error",
"unicorn/no-for-loop": "error"
}, },
"overrides": [ "overrides": [
{ {
+1 -1
View File
@@ -114,7 +114,7 @@ jobs:
RUN ruff format --check . RUN ruff format --check .
RUN mypy . RUN mypy .
RUN vulture RUN vulture
RUN bandit -c pyproject.toml -r ai auth core db filters publish routes game_engine scripts app.py build_css.py RUN bandit -c pyproject.toml -r ai auth core db filters publish routes game_engine document_engine scripts app.py build_css.py
RUN lint-imports RUN lint-imports
DOCKERFILE DOCKERFILE
+1 -1
View File
@@ -51,7 +51,7 @@ repos:
- id: bandit - id: bandit
name: Bandit (securite Python) name: Bandit (securite Python)
entry: bandit -c pyproject.toml -r ai auth core db filters publish routes screens scripts app.py build_css.py entry: bandit -c pyproject.toml -r ai auth core db filters publish routes game_engine document_engine scripts app.py build_css.py
language: system language: system
pass_filenames: false pass_filenames: false
+6 -5
View File
@@ -19,7 +19,7 @@ ignore de fichier entier ou de règle globale — voir section 4.
| **Ruff** (lint) | Erreurs Python, imports inutilisés, style | `*.py` | Obligatoire (bloquant CI + pre-commit) | | **Ruff** (lint) | Erreurs Python, imports inutilisés, style | `*.py` | Obligatoire (bloquant CI + pre-commit) |
| **Ruff** (format) | Formatage Python | `*.py` | Obligatoire | | **Ruff** (format) | Formatage Python | `*.py` | Obligatoire |
| **Mypy** `--strict` | Typage statique Python | `*.py` | Obligatoire | | **Mypy** `--strict` | Typage statique Python | `*.py` | Obligatoire |
| **Bandit** | Sécurité Python (injections, primitives faibles) | `ai`, `auth`, `core`, `db`, `filters`, `publish`, `routes`, `game_engine`, `scripts`, `app.py`, `build_css.py` | Obligatoire | | **Bandit** | Sécurité Python (injections, primitives faibles) | `ai`, `auth`, `core`, `db`, `filters`, `publish`, `routes`, `game_engine`, `document_engine`, `scripts`, `app.py`, `build_css.py` | Obligatoire |
| **Vulture** | Code mort Python | mêmes dossiers que Bandit | Obligatoire | | **Vulture** | Code mort Python | mêmes dossiers que Bandit | Obligatoire |
| **import-linter** | Contrat de couches applicatives | tout le code Python | Obligatoire | | **import-linter** | Contrat de couches applicatives | tout le code Python | Obligatoire |
| **ESLint** (`airbnb-base`) | Lint JavaScript | `static/js/**/*.js` | Obligatoire | | **ESLint** (`airbnb-base`) | Lint JavaScript | `static/js/**/*.js` | Obligatoire |
@@ -42,7 +42,7 @@ ignore de fichier entier ou de règle globale — voir section 4.
### Bandit (`pyproject.toml`, `[tool.bandit]`) ### Bandit (`pyproject.toml`, `[tool.bandit]`)
- `exclude_dirs` : `projects`, `user_assets`, `data`, `Bug`, `regles`, `tests` (les tests contiennent des mots de passe/tokens de test en dur, jamais un vrai risque). - `exclude_dirs` : `projects`, `user_assets`, `data`, `Bug`, `regles`, `tests` (les tests contiennent des mots de passe/tokens de test en dur, jamais un vrai risque).
- Portée explicite (`-r ai auth core db filters publish routes game_engine scripts app.py build_css.py`) plutôt que tout le dépôt. - Portée explicite (`-r ai auth core db filters publish routes game_engine document_engine scripts app.py build_css.py`) plutôt que tout le dépôt.
### Vulture (`pyproject.toml`, `[tool.vulture]`) ### Vulture (`pyproject.toml`, `[tool.vulture]`)
- `min_confidence = 80`, `paths` = mêmes dossiers que Bandit + `vulture_whitelist.py`. - `min_confidence = 80`, `paths` = mêmes dossiers que Bandit + `vulture_whitelist.py`.
@@ -50,7 +50,7 @@ ignore de fichier entier ou de règle globale — voir section 4.
- Faux positifs structurels (routes Flask enregistrées par décorateur, hooks appelés par convention de nom) : whitelist dédiée dans `vulture_whitelist.py`, **jamais** un `min_confidence` abaissé globalement. - Faux positifs structurels (routes Flask enregistrées par décorateur, hooks appelés par convention de nom) : whitelist dédiée dans `vulture_whitelist.py`, **jamais** un `min_confidence` abaissé globalement.
### import-linter (`pyproject.toml`, `[tool.importlinter]`) ### import-linter (`pyproject.toml`, `[tool.importlinter]`)
- Un seul contrat de type `layers`, du haut vers le bas : `routes` → `ai | publish | core` → `game_engine` → `auth | filters` → `db`. - Un seul contrat de type `layers`, du haut vers le bas : `routes` → `ai | publish | core` → `game_engine | document_engine` → `auth | filters` → `db`.
- Une couche ne peut importer qu'une couche strictement en dessous d'elle, jamais au-dessus, jamais une couche sœur du même niveau. - Une couche ne peut importer qu'une couche strictement en dessous d'elle, jamais au-dessus, jamais une couche sœur du même niveau.
- `app.py` (point d'entrée, pas un paquet) reste hors contrat — c'est lui qui importe `routes`/`core`, jamais l'inverse. - `app.py` (point d'entrée, pas un paquet) reste hors contrat — c'est lui qui importe `routes`/`core`, jamais l'inverse.
@@ -94,7 +94,7 @@ ruff check .
ruff format --check . ruff format --check .
mypy . mypy .
vulture vulture
bandit -c pyproject.toml -r ai auth core db filters publish routes game_engine scripts app.py build_css.py bandit -c pyproject.toml -r ai auth core db filters publish routes game_engine document_engine scripts app.py build_css.py
lint-imports lint-imports
djlint templates djlint templates
npx eslint "static/js/**/*.js" npx eslint "static/js/**/*.js"
@@ -141,7 +141,7 @@ SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloq
| `static/game/js/scenes/scene-editor.js:214` (`CURRENT_SELECTED_ID = null`), `static/game/js/screen_edit/tree-panels.js:392` (`CURRENT_SELECTED_ID = selectedId \|\| null`) | `javascript:S2703` | Déclarée en **`var`** (pas `let`/`const`) dans `templates/game/scene_edit.html:833` (`var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};`), chargé avant `tree-panels.js`/`scene-editor.js`/`trigger-editor.js`/`collision-rules-editor.js` (ordre vérifié, lignes 833/858/880/885/887/888) — un `var` de script classique attache directement à `window`, réassignable sans aucune restriction depuis n'importe quel autre `<script>` de la page (contrairement au cas `SCENE_OBJECT_NAMES` ci-dessous, qui lui était en `const`). Déjà documenté dans `.eslintrc.json` (`"CURRENT_SELECTED_ID": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 | | `static/game/js/scenes/scene-editor.js:214` (`CURRENT_SELECTED_ID = null`), `static/game/js/screen_edit/tree-panels.js:392` (`CURRENT_SELECTED_ID = selectedId \|\| null`) | `javascript:S2703` | Déclarée en **`var`** (pas `let`/`const`) dans `templates/game/scene_edit.html:833` (`var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};`), chargé avant `tree-panels.js`/`scene-editor.js`/`trigger-editor.js`/`collision-rules-editor.js` (ordre vérifié, lignes 833/858/880/885/887/888) — un `var` de script classique attache directement à `window`, réassignable sans aucune restriction depuis n'importe quel autre `<script>` de la page (contrairement au cas `SCENE_OBJECT_NAMES` ci-dessous, qui lui était en `const`). Déjà documenté dans `.eslintrc.json` (`"CURRENT_SELECTED_ID": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 |
| `static/game/js/scenes/collision-rules-editor.js:78` (`let _collisionWizard = null;`) et ses réassignations dans ce fichier, **et** `static/game/js/triggers/trigger-editor.js:787,937` (`triggerOpenAppendActionModal`/`screenTriggerOpenAppendActionModal`, `_collisionWizard = { bodyEl: body };` sans mot-clé) | `javascript:S2703` | Même pattern de partage inter-scripts que `SCENE_OBJECT_NAMES` : `trigger-editor.js` réutilise TELLES QUELLES les étapes de l'assistant de `collision-rules-editor.js` (`renderCollisionWizardChainStep`/`collisionWizardBuildLeafAction`, qui ne lisent que `.bodyEl` — voir commentaire ligne 784-786 de `trigger-editor.js`) pour poser "+ Ajouter une action" sur un déclencheur déjà existant, plutôt que de dupliquer ces étapes. `trigger-editor.js` est chargé AVANT `collision-rules-editor.js` (`templates/game/scene_edit.html:887-888`), mais sans risque de TDZ : les deux réassignations de `trigger-editor.js` sont à l'intérieur de fonctions déclenchées par un clic utilisateur, jamais exécutées avant que `collision-rules-editor.js` (et son `let _collisionWizard = null;`) n'ait fini de se charger. Déjà documenté dans `.eslintrc.json` (`"_collisionWizard": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 ; complété lot 4, 18/09/2026 | | `static/game/js/scenes/collision-rules-editor.js:78` (`let _collisionWizard = null;`) et ses réassignations dans ce fichier, **et** `static/game/js/triggers/trigger-editor.js:787,937` (`triggerOpenAppendActionModal`/`screenTriggerOpenAppendActionModal`, `_collisionWizard = { bodyEl: body };` sans mot-clé) | `javascript:S2703` | Même pattern de partage inter-scripts que `SCENE_OBJECT_NAMES` : `trigger-editor.js` réutilise TELLES QUELLES les étapes de l'assistant de `collision-rules-editor.js` (`renderCollisionWizardChainStep`/`collisionWizardBuildLeafAction`, qui ne lisent que `.bodyEl` — voir commentaire ligne 784-786 de `trigger-editor.js`) pour poser "+ Ajouter une action" sur un déclencheur déjà existant, plutôt que de dupliquer ces étapes. `trigger-editor.js` est chargé AVANT `collision-rules-editor.js` (`templates/game/scene_edit.html:887-888`), mais sans risque de TDZ : les deux réassignations de `trigger-editor.js` sont à l'intérieur de fonctions déclenchées par un clic utilisateur, jamais exécutées avant que `collision-rules-editor.js` (et son `let _collisionWizard = null;`) n'ait fini de se charger. Déjà documenté dans `.eslintrc.json` (`"_collisionWizard": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 ; complété lot 4, 18/09/2026 |
| `static/game/js/triggers/trigger-editor.js:28` (`let SCENE_OBJECT_NAMES = ...`) | `javascript:S2703` | **Bug réel trouvé et corrigé** (pas un faux positif comme les 4 sites ci-dessus) : était déclarée en `const`, alors que `refreshSceneObjectNames()` (`static/game/js/scenes/scene-editor.js:754-759`) la réassigne après un fetch — deux `<script>` classiques sur la même page partagent un même environnement lexical global, mais une liaison `const` posée dans l'un ne peut pas être réassignée depuis l'autre (`TypeError: Assignment to constant variable.`, reproduit empiriquement via `node:vm`). Symptôme : renommer un personnage puis ouvrir un dialogue de déclencheur sans recharger la page ne montrait jamais le nouveau nom dans "qui parle". Corrigé en `let`, couvert par un test de non-régression (`static/game/js/scenes/__tests__/collision-rules-editor.test.js`, test `refreshSceneObjectNames`) qui échoue avec `TypeError` sur l'ancien code et passe avec le nouveau. | Lot 2 "modernisation JS", 16/09/2026 | | `static/game/js/triggers/trigger-editor.js:28` (`let SCENE_OBJECT_NAMES = ...`) | `javascript:S2703` | **Bug réel trouvé et corrigé** (pas un faux positif comme les 4 sites ci-dessus) : était déclarée en `const`, alors que `refreshSceneObjectNames()` (`static/game/js/scenes/scene-editor.js:754-759`) la réassigne après un fetch — deux `<script>` classiques sur la même page partagent un même environnement lexical global, mais une liaison `const` posée dans l'un ne peut pas être réassignée depuis l'autre (`TypeError: Assignment to constant variable.`, reproduit empiriquement via `node:vm`). Symptôme : renommer un personnage puis ouvrir un dialogue de déclencheur sans recharger la page ne montrait jamais le nouveau nom dans "qui parle". Corrigé en `let`, couvert par un test de non-régression (`static/game/js/scenes/__tests__/collision-rules-editor.test.js`, test `refreshSceneObjectNames`) qui échoue avec `TypeError` sur l'ancien code et passe avec le nouveau. | Lot 2 "modernisation JS", 16/09/2026 |
| 31 sites `\|safe` (`templates/game/scene_edit.html`, `templates/game/play.html`, `templates/auth/register_2fa.html`, `templates/onboarding/onboarding_new.html`, `templates/game_dashboard_simple.html`) | `Web:S5247` (Sonar) | **Faux positif confirmé sur le fond, mais non-supprimable techniquement pour l'instant.** 3 sous-groupes : (1) `rendered_html`/`qr_svg`/`description` — HTML déjà échappé côté Python (`html.escape()`) ou généré sans texte libre utilisateur ; (2) 25 sites `*_json` — `db.json_for_script()` échappe déjà `</script>` (voir Phase 3). Plusieurs syntaxes de suppression testées (commentaire Jinja `{# #}`, commentaire JS natif dans un `<script>`, bonne position de ligne) : **aucune ne fonctionne** avec l'analyseur Web de cette version de SonarQube. La résolution "Faux positif" via l'API est bloquée par le système de permissions de session. `sonar.issue.ignore.multicriteria` existe mais sans sélecteur de ligne (exclusion fichier entier uniquement) — écarté pour `scene_edit.html`/`play.html` (masquerait un futur `\|safe` réellement dangereux). Ces 31 sites restent visibles dans le rapport Sonar en l'état ; traités et compris, pas un point ouvert côté code. | Phase 3 + investigation du 16/09/2026 | | 32 sites `\|safe` (`templates/game/scene_edit.html`, `templates/game/play.html`, `templates/auth/register_2fa.html`, `templates/onboarding/onboarding_new.html`, `templates/game_dashboard_simple.html`, `templates/document/document_edit.html`) | `Web:S5247` (Sonar) | **Faux positif confirmé sur le fond, mais non-supprimable techniquement pour l'instant.** 3 sous-groupes : (1) `rendered_html`/`qr_svg`/`description` — HTML déjà échappé côté Python (`html.escape()`) ou généré sans texte libre utilisateur ; (2) 25 sites `*_json` — `db.json_for_script()` échappe déjà `</script>` (voir Phase 3) ; (3) `rendered_document` (`document_edit.html`, ajouté le 20/09/2026) — même sous-groupe (1) : produit par `document_engine.render_document`, qui échappe (`html.escape()`) tout contenu utilisateur avant interpolation (voir `document_engine/rendering/render_document_element.py`). Plusieurs syntaxes de suppression testées (commentaire Jinja `{# #}`, commentaire JS natif dans un `<script>`, bonne position de ligne) : **aucune ne fonctionne** avec l'analyseur Web de cette version de SonarQube. La résolution "Faux positif" via l'API est bloquée par le système de permissions de session. `sonar.issue.ignore.multicriteria` existe mais sans sélecteur de ligne (exclusion fichier entier uniquement) — écarté pour `scene_edit.html`/`play.html` (masquerait un futur `\|safe` réellement dangereux). Ces 32 sites restent visibles dans le rapport Sonar en l'état ; traités et compris, pas un point ouvert côté code. | Phase 3 + investigation du 16/09/2026 ; complété le 20/09/2026 |
| `static/game/js/play/offline/filter-repeater-rows.js:10,11`, `static/game/js/screen_edit/panel-init.js:254,261`, `static/game/js/play/offline/xapi-client.js:132` | `javascript:S8786` (ReDoS) | 3 regex distinctes (2 dupliquées dans 2 fichiers) testées empiriquement, aucune ne montre de backtracking super-linéaire réel — voir le détail complet juste en dessous du tableau (méthode reproductible). | Lot 1 "modernisation JS", 16/09/2026 | | `static/game/js/play/offline/filter-repeater-rows.js:10,11`, `static/game/js/screen_edit/panel-init.js:254,261`, `static/game/js/play/offline/xapi-client.js:132` | `javascript:S8786` (ReDoS) | 3 regex distinctes (2 dupliquées dans 2 fichiers) testées empiriquement, aucune ne montre de backtracking super-linéaire réel — voir le détail complet juste en dessous du tableau (méthode reproductible). | Lot 1 "modernisation JS", 16/09/2026 |
| `static/game/js/play/dialogue-box-controller.js` (`forgeShowQuizBox`, ligne du `void widget.offsetWidth;`) | `javascript:S3735` | Force une lecture de mise en page (reflow) AVANT de reposer la classe `is-active`, pour que l'animation CSS d'entrée du quiz rejoue même si la boîte était déjà active juste avant (2 questions à la suite) — idiome JS standard, `void` marque explicitement une expression dont seul l'EFFET DE LECTURE compte, jamais la valeur. 3 formes essayées dans l'ordre, chacune rejetée par une règle Sonar différente : `void widget.offsetWidth;` (S3735, "retirer void") → `widget.offsetWidth;` seule (S905, "expression sans effet — accepté par ESLint ici, `no-unused-expressions` est désactivé dans ce projet, mais pas par Sonar") → `const _ = widget.offsetWidth;` (S1481, "variable jamais lue — accepté par ESLint via `varsIgnorePattern: ^_$`, pas par Sonar"). Aucune forme ne satisfait Sonar sans en recréer une autre : `void` restauré (la plus lisible/idiomatique des 3, et la seule aussi acceptée par ESLint) et documenté ici plutôt que de continuer à faire tourner ce carrousel. | Lot 4 "modernisation JS", 18/09/2026 | | `static/game/js/play/dialogue-box-controller.js` (`forgeShowQuizBox`, ligne du `void widget.offsetWidth;`) | `javascript:S3735` | Force une lecture de mise en page (reflow) AVANT de reposer la classe `is-active`, pour que l'animation CSS d'entrée du quiz rejoue même si la boîte était déjà active juste avant (2 questions à la suite) — idiome JS standard, `void` marque explicitement une expression dont seul l'EFFET DE LECTURE compte, jamais la valeur. 3 formes essayées dans l'ordre, chacune rejetée par une règle Sonar différente : `void widget.offsetWidth;` (S3735, "retirer void") → `widget.offsetWidth;` seule (S905, "expression sans effet — accepté par ESLint ici, `no-unused-expressions` est désactivé dans ce projet, mais pas par Sonar") → `const _ = widget.offsetWidth;` (S1481, "variable jamais lue — accepté par ESLint via `varsIgnorePattern: ^_$`, pas par Sonar"). Aucune forme ne satisfait Sonar sans en recréer une autre : `void` restauré (la plus lisible/idiomatique des 3, et la seule aussi acceptée par ESLint) et documenté ici plutôt que de continuer à faire tourner ce carrousel. | Lot 4 "modernisation JS", 18/09/2026 |
| `static/game/js/screen_edit/tree-panels.js:32` (`restoreTreeCollapsedState`), `:57` (`saveFloatPanelState`), `:230` (sauvegarde état replié/déplié au clic) | `javascript:S2486` | Lecture/écriture `localStorage` purement cosmétique (éditeur seulement, jamais le jeu) : un échec (quota, storage désactivé) laisse au pire un panneau à sa position par défaut ou un nœud d'arborescence dans son état précédent — aucune donnée de jeu en jeu, aucun état perdu de façon irréversible. | Lot 3 "modernisation JS", 16/09/2026 | | `static/game/js/screen_edit/tree-panels.js:32` (`restoreTreeCollapsedState`), `:57` (`saveFloatPanelState`), `:230` (sauvegarde état replié/déplié au clic) | `javascript:S2486` | Lecture/écriture `localStorage` purement cosmétique (éditeur seulement, jamais le jeu) : un échec (quota, storage désactivé) laisse au pire un panneau à sa position par défaut ou un nœud d'arborescence dans son état précédent — aucune donnée de jeu en jeu, aucun état perdu de façon irréversible. | Lot 3 "modernisation JS", 16/09/2026 |
@@ -151,6 +151,7 @@ SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloq
| `static/game/js/play/screens.js:192` (`applyAnimationClip`, clip `sprite`), `static/game/js/play/actions.js:340` (action `jouer_animation_sprite`) | `javascript:S2486` | **Corrigé, pas seulement documenté** : `JSON.parse(custom_keyframes / data_value)` invalide laissait `spriteData` retomber silencieusement sur `{}` (aucune animation jouée) sans aucun signal — `custom_keyframes`/`data_value` sont produits par l'éditeur, jamais tapés à la main, donc un JSON invalide ici trahit presque toujours un bug côté éditeur plutôt qu'une simple erreur de saisie. Un `console.warn('configuration sprite invalide', e)` a été ajouté dans les deux `catch` : signal devtools pour le créateur en test, **comportement joueur inchangé** (l'animation reste silencieusement absente). Couvert par un nouveau test (`static/game/js/play/__tests__/actions.test.js`, `runActionNode — jouer_animation_sprite avec data_value JSON invalide`) qui vérifie à la fois l'absence de crash/rendu cassé ET l'appel du `console.warn`. | Lot 3 "modernisation JS", 16/09/2026 | | `static/game/js/play/screens.js:192` (`applyAnimationClip`, clip `sprite`), `static/game/js/play/actions.js:340` (action `jouer_animation_sprite`) | `javascript:S2486` | **Corrigé, pas seulement documenté** : `JSON.parse(custom_keyframes / data_value)` invalide laissait `spriteData` retomber silencieusement sur `{}` (aucune animation jouée) sans aucun signal — `custom_keyframes`/`data_value` sont produits par l'éditeur, jamais tapés à la main, donc un JSON invalide ici trahit presque toujours un bug côté éditeur plutôt qu'une simple erreur de saisie. Un `console.warn('configuration sprite invalide', e)` a été ajouté dans les deux `catch` : signal devtools pour le créateur en test, **comportement joueur inchangé** (l'animation reste silencieusement absente). Couvert par un nouveau test (`static/game/js/play/__tests__/actions.test.js`, `runActionNode — jouer_animation_sprite avec data_value JSON invalide`) qui vérifie à la fois l'absence de crash/rendu cassé ET l'appel du `console.warn`. | Lot 3 "modernisation JS", 16/09/2026 |
| `static/game/js/play/offline/filter-repeater-rows.js:24` (`forgeDecodeClauses`) | `javascript:S2486` | **Corrigé, même patron que ci-dessus** : `_filtres_json` est un attribut rendu par le serveur, jamais tapé à la main — un JSON invalide y trahit presque toujours un bug côté éditeur/serveur. `console.warn('_filtres_json invalide, filtre ignoré', e)` ajouté, comportement inchangé (repli sur l'ancien format à 2 filtres fixes ou aucun filtre). Couvert par un nouveau test (`filter-repeater-rows.test.js`, `forgeDecodeClauses — _filtres_json invalide`). | Lot 4 "modernisation JS", 18/09/2026 | | `static/game/js/play/offline/filter-repeater-rows.js:24` (`forgeDecodeClauses`) | `javascript:S2486` | **Corrigé, même patron que ci-dessus** : `_filtres_json` est un attribut rendu par le serveur, jamais tapé à la main — un JSON invalide y trahit presque toujours un bug côté éditeur/serveur. `console.warn('_filtres_json invalide, filtre ignoré', e)` ajouté, comportement inchangé (repli sur l'ancien format à 2 filtres fixes ou aucun filtre). Couvert par un nouveau test (`filter-repeater-rows.test.js`, `forgeDecodeClauses — _filtres_json invalide`). | Lot 4 "modernisation JS", 18/09/2026 |
| `static/game/js/play/offline/filter-repeater-rows.js:59`, `:70` (`forgeResolveVariablePath`, JSON.parse + navigation `.champ`/`[index]`) | `javascript:S2486` | **Documenté, pas corrigé — nature différente du cas ci-dessus** : ici `rawValue` est la VALEUR ACTUELLE d'une variable de jeu (modifiable librement par n'importe quelle action "Modifier une variable"), pas une config interne à l'éditeur — un chemin qui ne correspond pas à sa forme actuelle est un cas normal et attendu (ex. variable encore à sa valeur par défaut non-JSON), déjà explicitement documenté par le commentaire de la fonction ("Ne lève jamais... même convention que côté serveur"). Un `console.warn` ici bruiterait la console à chaque usage légitime. | Lot 4 "modernisation JS", 18/09/2026 | | `static/game/js/play/offline/filter-repeater-rows.js:59`, `:70` (`forgeResolveVariablePath`, JSON.parse + navigation `.champ`/`[index]`) | `javascript:S2486` | **Documenté, pas corrigé — nature différente du cas ci-dessus** : ici `rawValue` est la VALEUR ACTUELLE d'une variable de jeu (modifiable librement par n'importe quelle action "Modifier une variable"), pas une config interne à l'éditeur — un chemin qui ne correspond pas à sa forme actuelle est un cas normal et attendu (ex. variable encore à sa valeur par défaut non-JSON), déjà explicitement documenté par le commentaire de la fonction ("Ne lève jamais... même convention que côté serveur"). Un `console.warn` ici bruiterait la console à chaque usage légitime. | Lot 4 "modernisation JS", 18/09/2026 |
| `static/document/js/document-editor.js` (`FORGE_DOC_STYLE_PRESETS`, `FORGE_DOC_SHAPE_KINDS`, `FORGE_DOC_SNAP_SIZE` — 3 sites) | `eslint:no-var`, `eslint:vars-on-top` | Constantes de premier niveau déclarées en `var` plutôt que `const` : un `<script src>` de page est rejoué TEL QUEL par `pjax.js` (`runScriptsIn`) à chaque navigation interne — une redéclaration `let`/`const` au premier niveau lèverait `SyntaxError: already declared` à la 2e exécution (voir l'en-tête de `static/pjax.js`, et le commentaire d'en-tête de ce fichier). `var` est le seul mot-clé sûr à ce niveau ; tout le reste du fichier (état mutable, y compris à l'intérieur des fonctions) est bien en `let`/`const`, porté par `window.forgeDocState` plutôt que par des variables de module (même convention que `static/game/js/scenes/scene-editor.js` et les autres scripts de page existants, qui n'ont eux aucune constante de ce genre à déclarer). | Session du 20/09/2026 |
### Détail — `javascript:S8786` (ReDoS), lot 1 "modernisation JS" ### Détail — `javascript:S8786` (ReDoS), lot 1 "modernisation JS"
+2
View File
@@ -34,6 +34,7 @@ from .save_user_asset import save_user_asset
from .send_email import EmailNotConfiguredError, send_password_reset_email from .send_email import EmailNotConfiguredError, send_password_reset_email
from .set_password import set_password from .set_password import set_password
from .set_project_slug import set_project_slug from .set_project_slug import set_project_slug
from .set_support_slug import set_support_slug
from .totp_qrcode_svg import totp_provisioning_uri, totp_qrcode_svg from .totp_qrcode_svg import totp_provisioning_uri, totp_qrcode_svg
from .update_email import EmailUpdateError, update_email from .update_email import EmailUpdateError, update_email
from .update_profile import update_profile from .update_profile import update_profile
@@ -57,6 +58,7 @@ __all__ = [
"confirm_totp", "confirm_totp",
"verify_totp", "verify_totp",
"set_project_slug", "set_project_slug",
"set_support_slug",
"password_strength", "password_strength",
"MIN_SCORE_REQUIRED", "MIN_SCORE_REQUIRED",
"totp_provisioning_uri", "totp_provisioning_uri",
+8
View File
@@ -29,6 +29,14 @@ def ensure_users_schema() -> None:
conn.execute("ALTER TABLE _users ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0") conn.execute("ALTER TABLE _users ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0")
if "locked_until" not in cols: if "locked_until" not in cols:
conn.execute("ALTER TABLE _users ADD COLUMN locked_until TEXT") conn.execute("ALTER TABLE _users ADD COLUMN locked_until TEXT")
if "support_slug" not in cols:
# Le SEUL support de formation que ce compte possède (voir
# set_support_slug.py) — mirroir exact de project_slug ci-dessus,
# mais pour l'autre type de projet (entité racine séparée, voir
# docs/plan/PLAN.md). Un compte peut avoir project_slug ET
# support_slug simultanément (un de chaque), jamais deux du même
# type pour un compte "user" restreint.
conn.execute("ALTER TABLE _users ADD COLUMN support_slug TEXT")
# Codes de récupération 2FA (voir recovery_codes.py) : table séparée # Codes de récupération 2FA (voir recovery_codes.py) : table séparée
# plutôt qu'une colonne JSON sur _users, pour pouvoir marquer/consommer # plutôt qu'une colonne JSON sur _users, pour pouvoir marquer/consommer
# chaque code UN PAR UN (used_at) sans avoir à relire/réécrire toute # chaque code UN PAR UN (used_at) sans avoir à relire/réécrire toute
+13
View File
@@ -0,0 +1,13 @@
from .connection import connect
def set_support_slug(user_id: int, slug: str) -> None:
"""Enregistre le SEUL support de formation que ce compte possède —
mirroir exact de set_project_slug.py pour l'autre type de projet
(entité racine séparée, voir docs/plan/PLAN.md). Un compte "user"
restreint peut avoir project_slug ET support_slug en même temps (un
de chaque), jamais deux support_slug."""
conn = connect()
conn.execute("UPDATE _users SET support_slug = ? WHERE id = ?", (slug, user_id))
conn.commit()
conn.close()
+57 -25
View File
@@ -12,6 +12,8 @@ module tente de résoudre request.endpoint — en pratique sans importance
ici (la résolution se fait à la requête, pas à l'import), mais gardé pour ici (la résolution se fait à la requête, pas à l'import), mais gardé pour
rester cohérent avec l'ordre d'import du reste du moteur.""" rester cohérent avec l'ordre d'import du reste du moteur."""
from typing import Any
from flask import abort, g, redirect, request, session, url_for from flask import abort, g, redirect, request, session, url_for
from werkzeug.wrappers import Response from werkzeug.wrappers import Response
@@ -49,6 +51,18 @@ _REACHABLE_WITHOUT_PROJECT = {
"profile_delete", "profile_delete",
} }
# Un support de formation est une entité racine séparée du jeu 2D, jamais
# nichée dedans (voir docs/plan/PLAN.md) : "index" liste désormais LES
# DEUX types de projet côte à côte ("Mes formations", voir templates/
# index.html), reste donc utile même une fois qu'un des deux (ou les deux)
# slots sont remplis. Un compte "user" restreint ne peut en revanche
# reposséder qu'UN SEUL projet de chaque type — bloqué ici, mirroir exact
# de la logique déjà en place pour game_delete.
_RESTRICTED_SLOT_ALREADY_FULL_ENDPOINTS = {
"games_new": "project_slug",
"documents_new": "support_slug",
}
@app.before_request @app.before_request
def _require_login_and_enforce_project_isolation() -> Response | None: def _require_login_and_enforce_project_isolation() -> Response | None:
@@ -95,35 +109,53 @@ def _require_login_and_enforce_project_isolation() -> Response | None:
elif owner_folder != str(user["id"]): elif owner_folder != str(user["id"]):
abort(403) abort(403)
# Un compte "user" n'a accès qu'à SON SEUL projet (project_slug,
# simple raccourci de confort ici — la sécurité elle-même est déjà
# assurée ci-dessus) — un "admin" peut en avoir plusieurs, exactement
# comme avant l'authentification. Les routes de gestion multi-jeux
# (page d'accueil, "+ Nouveau jeu") n'ont pas leur place pour un
# compte à projet unique : redirigées directement vers son propre
# tableau de bord plutôt qu'un 403 sec.
if user["role"] != "admin": if user["role"] != "admin":
if endpoint in ("index", "games_new") and user.get("project_slug"): return _enforce_restricted_user_project_slots(user, endpoint)
return redirect(url_for("game_dashboard", slug=user["project_slug"])) return None
# Un compte "user" restreint a AU PLUS un projet de chaque type
# (project_slug = son seul environnement 2D, support_slug = son seul
# support de formation — voir docs/plan/PLAN.md, deux entités racines
# séparées, deux slots indépendants). "index" ("Mes formations") reste
# atteignable dans tous les cas : il liste les deux types de projet côte à
# côte, jamais remplacé par un tableau de bord unique comme avant
# l'introduction du support.
_DELETE_ENDPOINTS_BLOCKED_FOR_RESTRICTED_USERS = {"game_delete", "document_delete"}
def _enforce_restricted_user_project_slots(user: dict[str, Any], endpoint: str) -> Response | None:
# Redirigé directement vers son propre tableau de bord plutôt qu'un
# 403 sec — seulement si LE SLOT CORRESPONDANT à la route de création
# visée est déjà rempli (les deux slots sont indépendants : avoir déjà
# un environnement 2D ne doit pas empêcher /documents/new, et
# inversement).
already_full_slot_column = _RESTRICTED_SLOT_ALREADY_FULL_ENDPOINTS.get(endpoint)
if already_full_slot_column and user.get(already_full_slot_column):
dashboard_endpoint = "game_dashboard" if already_full_slot_column == "project_slug" else "document_edit"
return redirect(url_for(dashboard_endpoint, slug=user[already_full_slot_column]))
# Onboarding guidé (voir routes/onboarding/onboarding_new.py) : un # Onboarding guidé (voir routes/onboarding/onboarding_new.py) : un
# compte "user" fraîchement inscrit n'a plus de projet créé # compte "user" fraîchement inscrit n'a plus aucun projet créé d'office
# d'office (voir routes/auth/register_2fa.py) — tant qu'il n'en a # (voir routes/auth/register_2fa.py) — tant qu'AUCUN des deux slots
# pas, seules ces routes restent atteignables (toutes exigent déjà # n'est rempli, seules ces routes restent atteignables (toutes exigent
# une connexion, absentes de _PUBLIC_ENDPOINTS). # déjà une connexion, absentes de _PUBLIC_ENDPOINTS).
if not user.get("project_slug") and endpoint not in _REACHABLE_WITHOUT_PROJECT: has_any_project = bool(user.get("project_slug")) or bool(user.get("support_slug"))
if not has_any_project and endpoint not in _REACHABLE_WITHOUT_PROJECT:
return redirect(url_for("onboarding_new")) return redirect(url_for("onboarding_new"))
# Type d'onboarding "restreint" (quiz/embranchement/rpg, voir # Type d'onboarding "restreint" (quiz/embranchement/rpg, voir
# db/games/game_type_catalog.py) : game_dashboard reste atteignable # db/games/game_type_catalog.py) : game_dashboard reste atteignable
# comme pour "custom", mais routes/games/game_dashboard.py y rend # comme pour "custom", mais routes/games/game_dashboard.py y rend une
# une version SIMPLIFIÉE (juste les écrans, pas d'onglets Objets/ # version SIMPLIFIÉE (juste les écrans, pas d'onglets Objets/Éléments
# Éléments de jeu/Variables) — rien à bloquer ici, la restriction # de jeu/Variables) — rien à bloquer ici, la restriction se fait au
# se fait au niveau du rendu, pas de l'accès à la route. # niveau du rendu, pas de l'accès à la route.
if endpoint == "game_delete": if endpoint in _DELETE_ENDPOINTS_BLOCKED_FOR_RESTRICTED_USERS:
# Un compte "user" n'a qu'UN SEUL projet, et aucun moyen d'en # Un compte "user" n'a qu'UN SEUL projet de ce type, et aucun moyen
# recréer un une fois supprimé (games_new le renvoie toujours # d'en recréer un une fois supprimé (games_new/documents_new le
# vers project_slug, même si le dossier n'existe plus) — # renvoient toujours vers son slot déjà posé, même si le dossier
# bloqué plutôt que de risquer de le laisser sans projet du # n'existe plus) — bloqué plutôt que de risquer de le laisser sans
# tout. Un admin, lui, peut toujours supprimer ses jeux comme # projet du tout. Un admin, lui, peut toujours supprimer ses
# avant (aucune restriction ci-dessus pour ce rôle). # projets comme avant (aucune restriction ci-dessus pour ce rôle).
abort(403) abort(403)
return None return None
+9 -4
View File
@@ -1,11 +1,16 @@
"""Enregistre le filet de sécurité de fermeture des connexions SQLite """Enregistre le filet de sécurité de fermeture des connexions SQLite
fuitées (voir db/connection.py::install_teardown_safety_net) sur l'app fuitées (voir db/connection.py::install_teardown_safety_net et son
Flask — vit dans core/ (couche de câblage qui branche les autres paquets équivalent totalement indépendant pour le système support,
sur l'app), jamais l'inverse : db/ ne doit importer aucun paquet au-dessus db/supports/support_connection.py::install_support_teardown_safety_net)
de lui dans la hiérarchie (voir contrat import-linter, pyproject.toml).""" sur l'app Flask — vit dans core/ (couche de câblage qui branche les
autres paquets sur l'app), jamais l'inverse : db/ ne doit importer aucun
paquet au-dessus de lui dans la hiérarchie (voir contrat import-linter,
pyproject.toml)."""
from db.connection import install_teardown_safety_net from db.connection import install_teardown_safety_net
from db.supports.support_connection import install_support_teardown_safety_net
from .flask_app import app from .flask_app import app
install_teardown_safety_net(app) install_teardown_safety_net(app)
install_support_teardown_safety_net(app)
+10
View File
@@ -76,6 +76,11 @@ from .scoring.get_score import get_score
from .scoring.set_score_value import set_score_value from .scoring.set_score_value import set_score_value
from .scoring.set_status import set_status from .scoring.set_status import set_status
from .slugify import slugify from .slugify import slugify
from .supports.create_support import create_support
from .supports.delete_support import delete_support
from .supports.list_supports import list_supports
from .supports.mark_support_published import mark_support_published
from .supports.support_meta import support_meta
from .table_name_for import table_name_for from .table_name_for import table_name_for
__all__ = [ __all__ = [
@@ -109,6 +114,11 @@ __all__ = [
"set_success_threshold", "set_success_threshold",
"get_scorm_version", "get_scorm_version",
"set_scorm_version", "set_scorm_version",
"create_support",
"list_supports",
"support_meta",
"delete_support",
"mark_support_published",
"ONBOARDING_TYPES", "ONBOARDING_TYPES",
"DEFAULT_ONBOARDING_TYPE", "DEFAULT_ONBOARDING_TYPE",
"get_onboarding_type", "get_onboarding_type",
+7 -1
View File
@@ -22,7 +22,13 @@ DEFAULT_ONBOARDING_TYPE = "rpg"
ONBOARDING_TYPES: dict[str, dict[str, Any]] = { ONBOARDING_TYPES: dict[str, dict[str, Any]] = {
"rpg": { "rpg": {
"label": "Créer un jeu 2D ludique", # Renommé "Environnement de formation 2D" (voir docs/plan/PLAN.md) :
# "jeu" est une appellation obsolète depuis l'introduction du
# support de formation comme second type de projet (voir
# routes/onboarding/onboarding_new.py — ce label/tagline est la
# SEULE chose renommée ici, l'argumentaire ci-dessous reste
# inchangé, déjà validé).
"label": "Environnement de formation 2D",
"tagline": "Ton monde, ton héros, ton scénario.", "tagline": "Ton monde, ton héros, ton scénario.",
"icon": "🗺️", "icon": "🗺️",
# Argumentaire pour un public RH non technique (voir .claude/ # Argumentaire pour un public RH non technique (voir .claude/
+29
View File
@@ -0,0 +1,29 @@
"""Couche données du support de formation — entité racine séparée du jeu
(voir docs/plan/PLAN.md). Aucun import croisé avec db/games/ ou tout autre
module lié au jeu 2D : même les réglages de connexion SQLite bas niveau
sont dupliqués (voir support_connection.py) pour garantir cette
indépendance."""
from .create_support import create_support
from .delete_support import delete_support
from .list_supports import list_supports
from .mark_support_published import mark_support_published
from .support_connection import connect_support, install_support_teardown_safety_net
from .support_dir import support_dir
from .support_meta import support_meta
from .support_path import support_path
from .support_slug import build_slug, split_slug
__all__ = [
"build_slug",
"connect_support",
"create_support",
"delete_support",
"install_support_teardown_safety_net",
"list_supports",
"mark_support_published",
"split_slug",
"support_dir",
"support_meta",
"support_path",
]
+46
View File
@@ -0,0 +1,46 @@
import os
from ..slugify import slugify
from .support_connection import connect_support
from .support_dir import support_dir
from .support_slug import build_slug
def create_support(name: str, owner_folder: str) -> str:
"""Crée le dossier du support (projects/<owner_folder>/_supports/
<project_part>/) et sa base support.db dédiée — mirroir de
db/games/create_game.py pour la mécanique de dossier/unicité de nom,
mais un schéma entièrement différent (voir document_engine/ : un seul
document par support, pas d'écrans/objets de scène). owner_folder
n'est jamais optionnel ici (contrairement à create_game) : un support
n'existe pas sans compte propriétaire."""
project_part = slugify(name)
base = project_part
n = 2
while os.path.isdir(support_dir(build_slug(owner_folder, base))):
base = f"{project_part}-{n}"
n += 1
project_part = base
slug = build_slug(owner_folder, project_part)
os.makedirs(support_dir(slug), exist_ok=True)
conn = connect_support(slug)
conn.executescript(
"""
CREATE TABLE _meta (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE _document_elements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER REFERENCES _document_elements(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
order_index INTEGER NOT NULL DEFAULT 0,
attributes TEXT NOT NULL DEFAULT '{}',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
"""
)
conn.execute("INSERT INTO _meta (key, value) VALUES ('name', ?)", (name,))
conn.commit()
conn.close()
return slug
+19
View File
@@ -0,0 +1,19 @@
import os
import shutil
from ..constants import PROJECTS_DIR
from .support_dir import support_dir
from .support_slug import split_slug
def delete_support(slug: str) -> None:
shutil.rmtree(support_dir(slug))
# Nettoie aussi _supports/ (et le dossier propriétaire) s'ils sont
# vides ensuite — purement cosmétique, mirroir de
# db/games/delete_game.py.
owner_folder, project_part = split_slug(slug)
if project_part is None:
return
supports_path = os.path.join(PROJECTS_DIR, owner_folder, "_supports")
if os.path.isdir(supports_path) and not os.listdir(supports_path):
os.rmdir(supports_path)
+29
View File
@@ -0,0 +1,29 @@
import os
from typing import Any
from ..constants import PROJECTS_DIR
from .support_connection import connect_support
from .support_path import support_path
from .support_slug import build_slug
def list_supports(owner_folder: str) -> list[dict[str, Any]]:
"""Scanne UNIQUEMENT projects/<owner_folder>/_supports/ — jamais les
autres comptes, jamais les jeux du même compte (mirroir de
db/games/list_games.py, sous-dossier différent)."""
supports: list[dict[str, Any]] = []
entry_path = os.path.join(PROJECTS_DIR, owner_folder, "_supports")
if not os.path.isdir(entry_path):
return supports
for project_part in sorted(os.listdir(entry_path)):
slug = build_slug(owner_folder, project_part)
if os.path.isfile(support_path(slug)):
_append_support(supports, slug)
return supports
def _append_support(supports: list[dict[str, Any]], slug: str) -> None:
conn = connect_support(slug)
row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone()
conn.close()
supports.append({"slug": slug, "name": row["value"] if row else slug})
+19
View File
@@ -0,0 +1,19 @@
from datetime import UTC, datetime
from .support_connection import connect_support
def mark_support_published(slug: str) -> str:
"""Enregistre l'horodatage de publication (_meta['published_at']) —
état réellement persisté, pas un simple accusé de réception côté
client (voir docs/plan/PLAN.md, "Publier" doit être réel)."""
published_at = datetime.now(UTC).isoformat()
conn = connect_support(slug)
conn.execute(
"INSERT INTO _meta (key, value) VALUES ('published_at', ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(published_at,),
)
conn.commit()
conn.close()
return published_at
+52
View File
@@ -0,0 +1,52 @@
import contextlib
import sqlite3
from typing import TYPE_CHECKING
from .support_path import support_path
if TYPE_CHECKING:
from flask import Flask
def connect_support(slug: str) -> sqlite3.Connection:
"""Connexion SQLite dédiée à un support.db — copie volontairement
indépendante de db/connection.py::connect (mêmes réglages WAL/verrou/
row_factory, système différent, "aucun import croisé avec le système
jeu", voir docs/plan/PLAN.md)."""
conn = sqlite3.connect(support_path(slug), timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA busy_timeout = 8000")
_track_for_teardown(conn)
return conn
def _track_for_teardown(conn: sqlite3.Connection) -> None:
"""Filet de sécurité identique à db/connection.py::_track_for_teardown
(voir son commentaire pour le raisonnement complet) — dupliqué plutôt
qu'importé pour la même raison d'indépendance totale entre les deux
systèmes."""
try:
from flask import g, has_app_context
except ImportError:
return
if not has_app_context():
return
if not hasattr(g, "_forge_support_db_connections"):
g._forge_support_db_connections = []
g._forge_support_db_connections.append(conn)
def install_support_teardown_safety_net(app: "Flask") -> None:
"""Mirroir de db/connection.py::install_teardown_safety_net pour les
connexions support.db — appelé séparément par
core/db_teardown_guard.py."""
@app.teardown_request
def _close_leaked_support_connections(exception: BaseException | None = None) -> None: # noqa: ARG001 - signature imposée par Flask
from flask import g
for conn in getattr(g, "_forge_support_db_connections", ()):
with contextlib.suppress(sqlite3.Error):
conn.close()
+19
View File
@@ -0,0 +1,19 @@
import os
from ..constants import PROJECTS_DIR
from .support_slug import split_slug
def support_dir(slug: str) -> str:
"""projects/<owner_folder>/_supports/<project_part>/ — même principe
que db/games/game_dir.py (owner_folder encodé une seule fois, via le
chemin parent), `_supports/` sépare physiquement les supports des
jeux d'un même propriétaire (voir support_slug.py) : `db.list_games()`
scanne uniquement les enfants DIRECTS de projects/<owner_folder>/,
jamais ce sous-dossier, donc un support n'y apparaît jamais et
réciproquement."""
owner_folder, project_part = split_slug(slug)
if project_part is None:
msg = f"slug de support invalide (non composé) : {slug!r}"
raise ValueError(msg)
return os.path.join(PROJECTS_DIR, owner_folder, "_supports", project_part)
+15
View File
@@ -0,0 +1,15 @@
from typing import Any
from .support_connection import connect_support
def support_meta(slug: str) -> dict[str, Any]:
conn = connect_support(slug)
row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone()
published_row = conn.execute("SELECT value FROM _meta WHERE key = 'published_at'").fetchone()
conn.close()
return {
"slug": slug,
"name": row["value"] if row else slug,
"published_at": published_row["value"] if published_row else None,
}
+7
View File
@@ -0,0 +1,7 @@
import os
from .support_dir import support_dir
def support_path(slug: str) -> str:
return os.path.join(support_dir(slug), "support.db")
+22
View File
@@ -0,0 +1,22 @@
"""Convention de slug propre aux supports de formation — mirroir du
principe de db/games/project_slug.py (slug composé "propriétaire_projet"),
mais résolu vers un sous-espace totalement séparé (voir support_dir.py)
pour qu'un support et un jeu du même propriétaire ne puissent jamais se
collisionner sur un même nom de dossier, et pour qu'un support n'apparaisse
jamais dans db.list_games() (qui ne scanne que le dossier propriétaire
direct, jamais son sous-dossier _supports/)."""
def build_slug(owner_folder: str, project_part: str) -> str:
return f"{owner_folder}_{project_part}"
def split_slug(slug: str) -> tuple[str, str | None]:
"""(owner_folder, project_part) si `slug` est bien composé, sinon
(slug, None) — même repli que db/games/project_slug.py, un support
n'a toutefois jamais vocation à exister sous forme "plate" (toujours
créé avec un owner_folder)."""
owner_folder, sep, project_part = slug.partition("_")
if not sep:
return slug, None
return owner_folder, project_part
+55
View File
@@ -0,0 +1,55 @@
"""
document_engine — support de formation : entité racine séparée du jeu 2D
(voir docs/plan/PLAN.md). Un support = un seul document, structuré en deux
couches sur le même canevas :
- le flux de contenu (titre/paragraphe/image/bouton/mini-jeux), organisé
en rangées ("row") par le moteur d'inférence de layout (voir
document_engine/rendering/render_document_element.py) ;
- la couche de formes libres (rectangle/cercle/triangle/trait), position-
nées en absolu (x/y/width/height/rotation/z_index).
Tout est stocké dans support.db (voir db/supports/) : une seule table,
_document_elements, portant les deux couches via parent_id (NULL = top-
niveau ou forme libre, sinon = enfant d'une rangée).
Aucun import croisé avec game_engine ou tout module lié au jeu 2D — voir
le contrat import-linter dans pyproject.toml (game_engine | document_engine
sont un même palier, mutuellement isolés).
Ce module se contente de ré-exporter l'intégralité de l'API publique du
paquet (pattern "un fichier = une fonction, un dossier = une responsabi-
lité", voir game_engine/__init__.py).
"""
from .elements.add_document_element import add_document_element
from .elements.delete_document_element import delete_document_element
from .elements.get_document_element import get_document_element
from .elements.list_document_elements import list_document_elements
from .elements.move_document_element import move_document_element
from .elements.update_document_element_attributes import update_document_element_attributes
from .labels.element_kind_labels import (
CONTENT_KINDS,
ELEMENT_KIND_LABELS,
ELEMENT_LIBRARY,
MINIGAME_KINDS,
SHAPE_KINDS,
element_default_attributes,
)
from .rendering.render_document_element import render_document, render_document_element
__all__ = [
"CONTENT_KINDS",
"ELEMENT_KIND_LABELS",
"ELEMENT_LIBRARY",
"MINIGAME_KINDS",
"SHAPE_KINDS",
"add_document_element",
"delete_document_element",
"element_default_attributes",
"get_document_element",
"list_document_elements",
"move_document_element",
"render_document",
"render_document_element",
"update_document_element_attributes",
]
+19
View File
@@ -0,0 +1,19 @@
# document_engine/
Moteur du support de formation — entité racine séparée du jeu 2D (voir
`docs/plan/PLAN.md`). Package composé de trois sous-dossiers, chacun
documenté séparément :
- [`elements/`](elements/elements.md) — CRUD des éléments du document
(`_document_elements`).
- [`labels/`](labels/labels.md) — catalogue statique des types d'éléments
(bibliothèque, libellés, attributs par défaut).
- [`rendering/`](rendering/rendering.md) — rendu HTML du document (canevas
d'édition et Mode Aperçu, même fonction).
`document_engine/__init__.py` ré-exporte l'intégralité de l'API publique
du paquet (voir son `__all__`), pattern identique à `game_engine/__init__.py`.
Aucun import croisé avec `game_engine` ou tout module lié au jeu 2D — voir
le contrat import-linter dans `pyproject.toml` (`game_engine | document_engine`
sont un même palier, mutuellement isolés).
@@ -0,0 +1,25 @@
import json
from db.supports import connect_support
from ..labels.element_kind_labels import element_default_attributes
def add_document_element(slug: str, kind: str, parent_id: int | None = None) -> int:
"""Ajoute un élément en fin de son groupe de frères (même parent_id —
NULL pour un élément top-niveau, l'id d'une rangée pour un enfant de
cette rangée, voir docs/plan/PLAN.md). Attributs de départ posés via
element_default_attributes(kind)."""
conn = connect_support(slug)
max_row = conn.execute(
"SELECT MAX(order_index) AS m FROM _document_elements WHERE parent_id IS ?", (parent_id,)
).fetchone()
order_index = (max_row["m"] or 0) + 1 if max_row and max_row["m"] is not None else 0
conn.execute(
"INSERT INTO _document_elements (parent_id, kind, order_index, attributes) VALUES (?, ?, ?, ?)",
(parent_id, kind, order_index, json.dumps(element_default_attributes(kind))),
)
element_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
conn.commit()
conn.close()
return element_id
@@ -0,0 +1,11 @@
from db.supports import connect_support
def delete_document_element(slug: str, element_id: int) -> None:
"""Supprime un élément — CASCADE (contrainte FK, voir
db/supports/create_support.py) retire aussi ses enfants si c'était une
rangée."""
conn = connect_support(slug)
conn.execute("DELETE FROM _document_elements WHERE id = ?", (element_id,))
conn.commit()
conn.close()
+57
View File
@@ -0,0 +1,57 @@
# document_engine/elements/
CRUD des éléments d'un support de formation (`_document_elements`, voir
`db/supports/create_support.py`). Un élément est soit un élément de
contenu/rangée du flux (`parent_id` = groupe de frères), soit une forme
libre superposée en position absolue (toujours `parent_id = NULL`).
## `add_document_element(slug: str, kind: str, parent_id: int | None = None) -> int`
Insère un nouvel élément en fin de son groupe de frères (même `parent_id`).
Pose les attributs de départ via `element_default_attributes(kind)`
(voir `document_engine/labels/element_kind_labels.py`).
- **Retour** : l'`id` du nouvel élément.
- **Exceptions** : aucune levée explicitement ; une connexion invalide
(support inexistant) lève l'erreur SQLite sous-jacente.
## `list_document_elements(slug: str) -> list[dict[str, Any]]`
Renvoie tous les éléments du support, à plat, triés par `(parent_id,
order_index)` — les éléments top-niveau (`parent_id` NULL) groupés en
premier (tri SQLite : NULL avant toute valeur), puis chaque rangée
groupant ses propres enfants. `attributes` est décodé en dict.
- **Retour** : liste de dicts (une ligne de table chacun, `attributes`
déjà en JSON décodé).
- **Exceptions** : aucune.
## `get_document_element(slug: str, element_id: int) -> dict[str, Any] | None`
Récupère un seul élément par id.
- **Retour** : le dict de l'élément, ou `None` si l'id n'existe pas.
- **Exceptions** : aucune.
## `update_document_element_attributes(slug: str, element_id: int, attributes: dict[str, Any]) -> None`
Remplace intégralement le JSON `attributes` d'un élément — chaque
formulaire du panneau Propriétés envoie l'état complet de ses champs,
jamais un patch partiel.
- **Retour** : aucun.
- **Exceptions** : aucune levée explicitement ; un `element_id`
inexistant ne modifie silencieusement aucune ligne (`UPDATE` sans
correspondance).
## `move_document_element(slug: str, element_id: int, new_parent_id: int | None, new_index: int) -> None`
Réinsertion réelle appelée par l'algorithme de glisser-déposer du moteur
de layout : dépose l'élément dans un groupe de frères (nouvelle rangée,
un groupe existant, ou le top-niveau) à une position précise, puis
renumérote intégralement le ou les groupes concernés (ancien et nouveau
si le parent change, un seul sinon) pour rester correct même en cas de
réordonnancement dans le même groupe.
- **Retour** : aucun.
- **Exceptions** : aucune levée explicitement ; un `element_id`
inexistant ne fait rien (retour silencieux après vérification de son
existence).
## `delete_document_element(slug: str, element_id: int) -> None`
Supprime un élément. La contrainte `FOREIGN KEY ... ON DELETE CASCADE`
(voir `db/supports/create_support.py`) retire automatiquement ses
enfants si l'élément supprimé était une rangée.
- **Retour** : aucun.
- **Exceptions** : aucune levée explicitement ; un `element_id`
inexistant ne modifie silencieusement aucune ligne.
@@ -0,0 +1,15 @@
import json
from typing import Any
from db.supports import connect_support
def get_document_element(slug: str, element_id: int) -> dict[str, Any] | None:
conn = connect_support(slug)
row = conn.execute("SELECT * FROM _document_elements WHERE id = ?", (element_id,)).fetchone()
conn.close()
if not row:
return None
d = dict(row)
d["attributes"] = json.loads(d["attributes"] or "{}")
return d
@@ -0,0 +1,25 @@
import json
from typing import Any
from db.supports import connect_support
def list_document_elements(slug: str) -> list[dict[str, Any]]:
"""Tous les éléments d'un support, à PLAT — chaque élément porte son
propre parent_id (NULL = top-niveau, sinon l'id d'une rangée) ; la
reconstitution de l'arbre (rangées + leurs enfants dans l'ordre) se
fait côté rendu (voir document_engine.render_document_element) et
côté JS pour l'affichage du canevas."""
conn = connect_support(slug)
# SQLite trie NULL avant toute valeur : les éléments top-niveau
# (parent_id NULL) arrivent groupés en premier, puis chaque rangée
# groupe ses propres enfants — chacun trié par order_index à
# l'intérieur de son groupe.
rows = conn.execute("SELECT * FROM _document_elements ORDER BY parent_id, order_index").fetchall()
conn.close()
elements = []
for row in rows:
d = dict(row)
d["attributes"] = json.loads(d["attributes"] or "{}")
elements.append(d)
return elements
@@ -0,0 +1,54 @@
from db.supports import connect_support
def move_document_element(slug: str, element_id: int, new_parent_id: int | None, new_index: int) -> None:
"""Réinsertion réelle d'un élément — appelée par l'algorithme de
glisser-déposer du moteur de layout (voir docs/plan/PLAN.md) : dépose
dans un nouveau groupe de frères (nouvelle rangée, ou top-niveau) à
une position précise, pas juste "monter/descendre" d'un cran. Renumérote
intégralement les deux groupes concernés (ancien et nouveau, ou un seul
si inchangé) plutôt que de décaler un par un, pour rester correct même
en cas de réordonnancement DANS le même groupe."""
conn = connect_support(slug)
row = conn.execute("SELECT parent_id FROM _document_elements WHERE id = ?", (element_id,)).fetchone()
if not row:
conn.close()
return
old_parent_id = row["parent_id"]
if old_parent_id == new_parent_id:
siblings = [
r["id"]
for r in conn.execute(
"SELECT id FROM _document_elements WHERE parent_id IS ? AND id != ? ORDER BY order_index",
(old_parent_id, element_id),
).fetchall()
]
siblings.insert(max(0, min(new_index, len(siblings))), element_id)
for index, sibling_id in enumerate(siblings):
conn.execute("UPDATE _document_elements SET order_index = ? WHERE id = ?", (index, sibling_id))
else:
old_siblings = [
r["id"]
for r in conn.execute(
"SELECT id FROM _document_elements WHERE parent_id IS ? AND id != ? ORDER BY order_index",
(old_parent_id, element_id),
).fetchall()
]
for index, sibling_id in enumerate(old_siblings):
conn.execute("UPDATE _document_elements SET order_index = ? WHERE id = ?", (index, sibling_id))
new_siblings = [
r["id"]
for r in conn.execute(
"SELECT id FROM _document_elements WHERE parent_id IS ? ORDER BY order_index",
(new_parent_id,),
).fetchall()
]
new_siblings.insert(max(0, min(new_index, len(new_siblings))), element_id)
conn.execute("UPDATE _document_elements SET parent_id = ? WHERE id = ?", (new_parent_id, element_id))
for index, sibling_id in enumerate(new_siblings):
conn.execute("UPDATE _document_elements SET order_index = ? WHERE id = ?", (index, sibling_id))
conn.commit()
conn.close()
@@ -0,0 +1,18 @@
import json
from typing import Any
from db.supports import connect_support
def update_document_element_attributes(slug: str, element_id: int, attributes: dict[str, Any]) -> None:
"""Remplacement COMPLET du JSON attributes — un formulaire du panneau
Propriétés envoie systématiquement l'état entier de ses champs, jamais
un patch partiel (voir docs/plan/PLAN.md §1.2.D : "chaque formulaire
est autonome")."""
conn = connect_support(slug)
conn.execute(
"UPDATE _document_elements SET attributes = ? WHERE id = ?",
(json.dumps(attributes), element_id),
)
conn.commit()
conn.close()
@@ -0,0 +1,83 @@
"""Catalogue des types d'éléments du support de formation (voir
docs/plan/PLAN.md §1.2/§3.2) — bibliothèque groupée par catégorie visuelle
(panneau gauche de l'éditeur), libellés d'affichage, et attributs par
défaut posés à la création de chaque type."""
from typing import Any
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait")
CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton")
MINIGAME_KINDS = ("quiz", "association", "memory", "mots", "scenario", "zones")
# "row" n'apparaît jamais dans la bibliothèque (créé implicitement par le
# moteur de layout au dépôt d'un élément à côté d'un autre) — absent de
# ELEMENT_LIBRARY, présent dans ELEMENT_KIND_LABELS pour l'affichage/debug.
ELEMENT_LIBRARY: dict[str, dict[str, Any]] = {
"mise_en_page": {"label": "Mise en page", "kinds": list(SHAPE_KINDS)},
"contenu": {"label": "Contenu", "kinds": list(CONTENT_KINDS)},
"minigames": {"label": "Mini-jeux", "kinds": list(MINIGAME_KINDS)},
}
ELEMENT_KIND_LABELS: dict[str, str] = {
"row": "Rangée",
"rectangle": "Rectangle",
"cercle": "Cercle",
"triangle": "Triangle",
"trait": "Trait",
"titre": "Titre",
"paragraphe": "Paragraphe",
"image": "Image",
"bouton": "Bouton",
"quiz": "Quiz",
"association": "Association",
"memory": "Memory",
"mots": "Mots mêlés",
"scenario": "Scénario",
"zones": "Zones à risque",
}
_SHAPE_DEFAULTS = {
"x": 40,
"y": 40,
"width": 160,
"height": 100,
"rotation": 0,
"z_index": 1,
"fill": "#ff5f2e",
"stroke": "#232a38",
"stroke_width": 2,
"label": "",
}
_TEXT_DEFAULTS = {
"bold": False,
"italic": False,
"underline": False,
"align": "left",
"color": "var(--forge-text)",
}
def element_default_attributes(kind: str) -> dict[str, Any]:
"""Attributs posés à la création d'un élément de ce type — cf.
docs/plan/PLAN.md §3.3/§3.4/§3.5 pour la liste des propriétés
éditables par panneau, ici juste leur valeur de départ."""
if kind in SHAPE_KINDS:
return dict(_SHAPE_DEFAULTS)
if kind == "titre":
return {"content": "Nouveau titre", "style": "titre1", **_TEXT_DEFAULTS}
if kind == "paragraphe":
return {"content": "Nouveau paragraphe de texte.", "style": "paragraphe", **_TEXT_DEFAULTS}
if kind == "image":
return {"src": "", "alt": ""}
if kind == "bouton":
return {"label": "Bouton", "target": ""}
if kind == "row":
return {"gap": 16, "align": "stretch", "justify": "flex-start"}
if kind in MINIGAME_KINDS:
# Panneau Propriétés minimal (voir PLAN.md §3.5, dernier
# paragraphe : "état par défaut en attendant sa spécification") —
# décision actée : cœur complet + mini-jeux en emplacement
# réservé, formulaires de contenu dédiés = chantier séparé.
return {"theme_color": "#ff5f2e"}
return {}
+39
View File
@@ -0,0 +1,39 @@
# document_engine/labels/
Catalogue statique des types d'éléments du support de formation : bibliothèque
groupée par catégorie (panneau gauche de l'éditeur), libellés d'affichage, et
attributs par défaut posés à la création de chaque type.
## `SHAPE_KINDS: tuple[str, ...]`
`("rectangle", "cercle", "triangle", "trait")` — couche de formes libres,
toujours en position absolue (`parent_id = NULL`).
## `CONTENT_KINDS: tuple[str, ...]`
`("titre", "paragraphe", "image", "bouton")` — éléments du flux, peuvent
être top-niveau ou enfants d'une rangée.
## `MINIGAME_KINDS: tuple[str, ...]`
`("quiz", "association", "memory", "mots", "scenario", "zones")` —
panneau Propriétés minimal aujourd'hui (emplacement réservé), formulaires
de contenu dédiés hors périmètre de cette passe.
## `ELEMENT_LIBRARY: dict[str, dict[str, Any]]`
Bibliothèque affichée dans le panneau gauche, groupée par catégorie
(`mise_en_page` / `contenu` / `minigames`), chaque entrée portant un
`label` et sa liste de `kinds`. `"row"` n'y apparaît jamais — créé
implicitement par le moteur de layout, jamais choisi directement dans la
bibliothèque.
## `ELEMENT_KIND_LABELS: dict[str, str]`
Libellé d'affichage pour chaque `kind`, y compris `"row"` (pour
l'affichage/debug hors bibliothèque).
## `element_default_attributes(kind: str) -> dict[str, Any]`
Attributs posés à la création d'un élément de ce type (voir
`document_engine/elements/add_document_element.py`).
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` :
formes (`x/y/width/height/rotation/z_index/fill/stroke/stroke_width/label`),
texte (`content/style` + `bold/italic/underline/align/color`),
image (`src/alt`), bouton (`label/target`), rangée (`gap/align/justify`),
mini-jeu (`theme_color`), ou `{}` pour un `kind` inconnu.
- **Exceptions** : aucune.
@@ -0,0 +1,159 @@
import html as html_lib
from typing import Any
_SHAPE_TAGS = {"rectangle": "rect", "cercle": "circle", "trait": "line"}
def render_document(elements: list[dict[str, Any]]) -> str:
"""Assemble le document ENTIER à partir de la liste à plat renvoyée
par document_engine.list_document_elements : les éléments top-niveau
(parent_id NULL) dans l'ordre, chaque rangée ("row") rendant
récursivement ses propres enfants côte à côte. Point d'entrée unique
du rendu, utilisé à la fois par le canevas d'édition et par le Mode
Aperçu (même fonction, voir docs/plan/PLAN.md — "Aperçu réel")."""
children_by_parent: dict[int | None, list[dict[str, Any]]] = {}
for el in elements:
children_by_parent.setdefault(el["parent_id"], []).append(el)
top_level = children_by_parent.get(None, [])
return "".join(render_document_element(el, children_by_parent) for el in top_level)
def render_document_element(el: dict[str, Any], children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
"""HTML d'UN élément — dispatch par dict plutôt qu'un enchaînement de
`if` (mirroir de l'esprit de game_engine/scenes/render_scene_object.py,
mais un vrai registre ici puisque le nombre de kinds est plus élevé)."""
renderer = _RENDERERS.get(el["kind"], _render_unknown)
return renderer(el, children_by_parent)
def _render_row(el: dict[str, Any], children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
attrs = el["attributes"]
gap = attrs.get("gap", 16)
align = html_lib.escape(str(attrs.get("align", "stretch")))
justify = html_lib.escape(str(attrs.get("justify", "flex-start")))
style = f"display:flex; flex-wrap:wrap; gap:{gap}px; align-items:{align}; justify-content:{justify};"
children = children_by_parent.get(el["id"], [])
inner = "".join(render_document_element(child, children_by_parent) for child in children)
return f'<div class="docRow" data-element-id="{el["id"]}" data-kind="row" style="{style}">{inner}</div>'
def _render_shape(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
a = el["attributes"]
x, y, width, height = a["x"], a["y"], a["width"], a["height"]
rotation, z_index = a.get("rotation", 0), a.get("z_index", 1)
fill = html_lib.escape(str(a.get("fill", "#ff5f2e")))
stroke = html_lib.escape(str(a.get("stroke", "#232a38")))
stroke_width = a.get("stroke_width", 2)
label = html_lib.escape(str(a.get("label", "")))
wrap_style = (
f"position:absolute; left:{x}px; top:{y}px; width:{width}px; height:{height}px; "
f"transform:rotate({rotation}deg); z-index:{z_index};"
)
kind = el["kind"]
stroke_attrs = f'stroke="{stroke}" stroke-width="{stroke_width}"'
if kind == "triangle":
points = f"{width / 2},0 {width},{height} 0,{height}"
shape_svg = f'<polygon points="{points}" fill="{fill}" {stroke_attrs}></polygon>'
else:
tag = _SHAPE_TAGS.get(kind, "rect")
if tag == "circle":
cx, cy, r = width / 2, height / 2, min(width, height) / 2
shape_svg = f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="{fill}" {stroke_attrs}></circle>'
elif tag == "line":
shape_svg = f'<line x1="0" y1="{height / 2}" x2="{width}" y2="{height / 2}" {stroke_attrs}></line>'
else:
shape_svg = f'<rect width="{width}" height="{height}" fill="{fill}" {stroke_attrs}></rect>'
label_attr = f' aria-label="{label}"' if label else ' aria-hidden="true"'
return (
f'<div class="docShape" data-element-id="{el["id"]}" data-kind="{kind}" style="{wrap_style}"{label_attr}>'
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}">{shape_svg}</svg>'
f"</div>"
)
_STYLE_PRESETS = {
"titre1": ("clamp(1.6rem,4vw,2rem)", 800, 1.15),
"titre2": ("1.3rem", 800, 1.25),
"paragraphe": ("15px", 400, 1.5),
"legende": ("12.5px", 600, 1.4),
}
def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
a = el["attributes"]
content = html_lib.escape(str(a.get("content", "")))
style_name = a.get("style", "paragraphe")
font_size, base_weight, line_height = _STYLE_PRESETS.get(style_name, _STYLE_PRESETS["paragraphe"])
weight = 800 if a.get("bold") else base_weight
font_style = "italic" if a.get("italic") else "normal"
text_decoration = "underline" if a.get("underline") else "none"
align = html_lib.escape(str(a.get("align", "left")))
color = html_lib.escape(str(a.get("color", "var(--forge-text)")))
style = (
f"font-size:{font_size}; font-weight:{weight}; line-height:{line_height}; "
f"font-style:{font_style}; text-decoration:{text_decoration}; text-align:{align}; color:{color};"
)
return f'<div class="docText" data-element-id="{el["id"]}" data-kind="{el["kind"]}" style="{style}">{content}</div>'
def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
a = el["attributes"]
src = html_lib.escape(str(a.get("src", "")))
alt = html_lib.escape(str(a.get("alt", "")))
if not src:
return (
f'<div class="docImage docImagePlaceholder" data-element-id="{el["id"]}" data-kind="image">'
f"Image — aucun fichier choisi</div>"
)
return f'<img class="docImage" data-element-id="{el["id"]}" data-kind="image" src="{src}" alt="{alt}">'
def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
a = el["attributes"]
label = html_lib.escape(str(a.get("label", "Bouton")))
target = html_lib.escape(str(a.get("target", "")))
target_attr = f' data-target="{target}"' if target else ""
return (
f'<button type="button" class="docButton" data-element-id="{el["id"]}" data-kind="bouton"{target_attr}>'
f"{label}</button>"
)
def _render_minigame_placeholder(
el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]
) -> str:
from ..labels.element_kind_labels import ELEMENT_KIND_LABELS
a = el["attributes"]
theme_color = html_lib.escape(str(a.get("theme_color", "#ff5f2e")))
label = html_lib.escape(ELEMENT_KIND_LABELS.get(el["kind"], el["kind"]))
return (
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="{el["kind"]}" '
f'style="border-color:{theme_color};">'
f'<span class="docMinigameLabel">{label}</span>'
f'<span class="docMinigamePlaceholder">Formulaire de contenu à venir</span>'
f"</div>"
)
def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
return f'<div class="docUnknown" data-element-id="{el["id"]}">Type inconnu : {html_lib.escape(el["kind"])}</div>'
_RENDERERS = {
"row": _render_row,
"rectangle": _render_shape,
"cercle": _render_shape,
"triangle": _render_shape,
"trait": _render_shape,
"titre": _render_text,
"paragraphe": _render_text,
"image": _render_image,
"bouton": _render_button,
"quiz": _render_minigame_placeholder,
"association": _render_minigame_placeholder,
"memory": _render_minigame_placeholder,
"mots": _render_minigame_placeholder,
"scenario": _render_minigame_placeholder,
"zones": _render_minigame_placeholder,
}
+41
View File
@@ -0,0 +1,41 @@
# document_engine/rendering/
Rendu HTML du document — point d'entrée unique utilisé à la fois par le
canevas d'édition et par le Mode Aperçu (même fonction, voir
`docs/plan/PLAN.md` — "Aperçu réel").
## `render_document(elements: list[dict[str, Any]]) -> str`
Assemble le document entier à partir de la liste à plat renvoyée par
`document_engine.list_document_elements` : regroupe les éléments par
`parent_id`, puis rend récursivement les éléments top-niveau dans
l'ordre (une rangée rend elle-même ses propres enfants côte à côte).
- **Retour** : le HTML complet du document.
- **Exceptions** : aucune.
## `render_document_element(el: dict[str, Any], children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str`
HTML d'un seul élément — dispatch par dict (table `_RENDERERS`) selon
`el["kind"]`, plutôt qu'un enchaînement de `if` (mirroir de l'esprit de
`game_engine/scenes/render_scene_object.py`). `children_by_parent` est le
regroupement précalculé par `render_document`, transmis pour que les
rangées puissent rendre récursivement leurs enfants sans refaire le
regroupement à chaque appel.
- **Retour** : le HTML de cet élément (et de ses enfants s'il s'agit
d'une rangée).
- **Exceptions** : aucune ; un `kind` inconnu produit un bloc
`docUnknown` visible plutôt qu'une levée d'exception.
### Détail des rendus par catégorie (fonctions privées, table de dispatch)
- **Rangée** (`row`) : conteneur flex (`gap`/`align-items`/
`justify-content` réels depuis `attributes`), enfants rendus
récursivement.
- **Formes** (`rectangle`/`cercle`/`triangle`/`trait`) : `<div>` positionné
en absolu (`x`/`y`/`width`/`height`/`rotation`/`z_index` réels) contenant
un SVG (`rect`/`circle`/`polygon`/`line` selon le type).
- **Texte** (`titre`/`paragraphe`) : `<div>` stylé selon `style` (préréglage
taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`.
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide.
- **Bouton** : `<button>` avec son `label` et un `data-target` optionnel.
- **Mini-jeux** (`quiz`/`association`/`memory`/`mots`/`scenario`/`zones`) :
carte placeholder portant le libellé du type (voir
`document_engine/labels/element_kind_labels.py`) — emplacement réservé,
formulaire de contenu dédié hors périmètre de cette passe.
+586
View File
@@ -11,6 +11,7 @@
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-unicorn": "^55.0.0",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^4.1.4",
"stylelint": "^16.9.0", "stylelint": "^16.9.0",
"stylelint-config-standard": "^36.0.1" "stylelint-config-standard": "^36.0.1"
@@ -295,6 +296,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/normalize-package-data": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
"integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==",
"dev": true,
"license": "MIT"
},
"node_modules/@ungap/structured-clone": { "node_modules/@ungap/structured-clone": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
@@ -550,6 +558,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": {
"version": "2.11.24",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz",
"integrity": "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.18", "version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
@@ -574,6 +595,53 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/browserslist": {
"version": "4.29.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz",
"integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.11.23",
"caniuse-lite": "^1.0.30001810",
"electron-to-chromium": "^1.5.427",
"node-releases": "^2.0.55",
"update-browserslist-db": "^1.3.3"
},
"bin": {
"browserslist": "cli.js"
},
"engines": {
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/builtin-modules": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
"integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/call-bind": { "node_modules/call-bind": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -634,6 +702,27 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/caniuse-lite": {
"version": "1.0.30001810",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -651,6 +740,45 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/ci-info": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
"integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/clean-regexp": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz",
"integrity": "sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==",
"dev": true,
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^1.0.5"
},
"engines": {
"node": ">=4"
}
},
"node_modules/clean-regexp/node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -692,6 +820,23 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-js-compat": {
"version": "3.50.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz",
"integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"browserslist": "^4.28.7"
},
"engines": {
"node": ">=6.4.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cosmiconfig": { "node_modules/cosmiconfig": {
"version": "9.0.2", "version": "9.0.2",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz",
@@ -927,6 +1072,13 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/electron-to-chromium": {
"version": "1.5.430",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.430.tgz",
"integrity": "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==",
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -1125,6 +1277,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/escape-string-regexp": { "node_modules/escape-string-regexp": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -1322,6 +1484,66 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/eslint-plugin-unicorn": {
"version": "55.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-55.0.0.tgz",
"integrity": "sha512-n3AKiVpY2/uDcGrS3+QsYDkjPfaOrNrsfQxU9nt5nitd9KuvVXrfAvgCO9DYPSfap+Gqjw9EOrXIsBp5tlHZjA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.24.5",
"@eslint-community/eslint-utils": "^4.4.0",
"ci-info": "^4.0.0",
"clean-regexp": "^1.0.0",
"core-js-compat": "^3.37.0",
"esquery": "^1.5.0",
"globals": "^15.7.0",
"indent-string": "^4.0.0",
"is-builtin-module": "^3.2.1",
"jsesc": "^3.0.2",
"pluralize": "^8.0.0",
"read-pkg-up": "^7.0.1",
"regexp-tree": "^0.1.27",
"regjsparser": "^0.10.0",
"semver": "^7.6.1",
"strip-indent": "^3.0.0"
},
"engines": {
"node": ">=18.18"
},
"funding": {
"url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1"
},
"peerDependencies": {
"eslint": ">=8.56.0"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/globals": {
"version": "15.15.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/eslint-plugin-unused-imports": { "node_modules/eslint-plugin-unused-imports": {
"version": "4.1.4", "version": "4.1.4",
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.1.4.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.1.4.tgz",
@@ -1970,6 +2192,13 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/hosted-git-info": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
"integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
"dev": true,
"license": "ISC"
},
"node_modules/html-tags": { "node_modules/html-tags": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz",
@@ -2020,6 +2249,16 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/inflight": { "node_modules/inflight": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -2139,6 +2378,22 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-builtin-module": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz",
"integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==",
"dev": true,
"license": "MIT",
"dependencies": {
"builtin-modules": "^3.3.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-callable": { "node_modules/is-callable": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
@@ -2550,6 +2805,19 @@
"js-yaml": "bin/js-yaml.js" "js-yaml": "bin/js-yaml.js"
} }
}, },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=6"
}
},
"node_modules/json-buffer": { "node_modules/json-buffer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -2734,6 +3002,16 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -2809,6 +3087,61 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/node-releases": {
"version": "2.0.55",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz",
"integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/normalize-package-data": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
"integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"hosted-git-info": "^2.1.4",
"resolve": "^1.10.0",
"semver": "2 || 3 || 4 || 5",
"validate-npm-package-license": "^3.0.1"
}
},
"node_modules/normalize-package-data/node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/normalize-package-data/node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -3011,6 +3344,16 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -3110,6 +3453,16 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -3245,6 +3598,116 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/read-pkg": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
"integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/normalize-package-data": "^2.4.0",
"normalize-package-data": "^2.5.0",
"parse-json": "^5.0.0",
"type-fest": "^0.6.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/read-pkg-up": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz",
"integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==",
"dev": true,
"license": "MIT",
"dependencies": {
"find-up": "^4.1.0",
"read-pkg": "^5.2.0",
"type-fest": "^0.8.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/read-pkg-up/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/read-pkg-up/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/read-pkg-up/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/read-pkg-up/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/read-pkg-up/node_modules/type-fest": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
"integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=8"
}
},
"node_modules/read-pkg/node_modules/type-fest": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz",
"integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=8"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -3268,6 +3731,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/regexp-tree": {
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
"integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==",
"dev": true,
"license": "MIT",
"bin": {
"regexp-tree": "bin/regexp-tree"
}
},
"node_modules/regexp.prototype.flags": { "node_modules/regexp.prototype.flags": {
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -3289,6 +3762,28 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/regjsparser": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.10.0.tgz",
"integrity": "sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"jsesc": "~0.5.0"
},
"bin": {
"regjsparser": "bin/parser"
}
},
"node_modules/regjsparser/node_modules/jsesc": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz",
"integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==",
"dev": true,
"bin": {
"jsesc": "bin/jsesc"
}
},
"node_modules/require-from-string": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -3649,6 +4144,42 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/spdx-correct": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz",
"integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"spdx-expression-parse": "^3.0.0",
"spdx-license-ids": "^3.0.0"
}
},
"node_modules/spdx-exceptions": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz",
"integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==",
"dev": true,
"license": "CC-BY-3.0"
},
"node_modules/spdx-expression-parse": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
"integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"spdx-exceptions": "^2.1.0",
"spdx-license-ids": "^3.0.0"
}
},
"node_modules/spdx-license-ids": {
"version": "3.0.23",
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz",
"integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/stop-iteration-iterator": { "node_modules/stop-iteration-iterator": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -3761,6 +4292,19 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"min-indent": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-json-comments": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -4206,6 +4750,37 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/update-browserslist-db": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
"integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
},
"bin": {
"update-browserslist-db": "cli.js"
},
"peerDependencies": {
"browserslist": ">= 4.21.0"
}
},
"node_modules/uri-js": { "node_modules/uri-js": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -4223,6 +4798,17 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/validate-npm-package-license": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
"integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"spdx-correct": "^3.0.0",
"spdx-expression-parse": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+1
View File
@@ -12,6 +12,7 @@
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-unicorn": "^55.0.0",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^4.1.4",
"stylelint": "^16.9.0", "stylelint": "^16.9.0",
"stylelint-config-standard": "^36.0.1" "stylelint-config-standard": "^36.0.1"
+3 -3
View File
@@ -91,7 +91,7 @@ ignore_missing_imports = true
min_confidence = 80 min_confidence = 80
paths = [ paths = [
"ai", "auth", "core", "db", "filters", "publish", "routes", "game_engine", "ai", "auth", "core", "db", "filters", "publish", "routes", "game_engine",
"scripts", "app.py", "build_css.py", "vulture_whitelist.py", "document_engine", "scripts", "app.py", "build_css.py", "vulture_whitelist.py",
] ]
exclude = ["*/tests/*"] exclude = ["*/tests/*"]
# Faux positifs structurels (routes Flask enregistrees par decorateur, # Faux positifs structurels (routes Flask enregistrees par decorateur,
@@ -119,7 +119,7 @@ exclude_dirs = ["projects", "user_assets", "data", "Bug", "regles", "tests"]
# (db -> core, voir db/connection.py) est un signal reel, jamais masque. # (db -> core, voir db/connection.py) est un signal reel, jamais masque.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
[tool.importlinter] [tool.importlinter]
root_packages = ["routes", "ai", "publish", "core", "game_engine", "auth", "filters", "db"] root_packages = ["routes", "ai", "publish", "core", "game_engine", "document_engine", "auth", "filters", "db"]
[[tool.importlinter.contracts]] [[tool.importlinter.contracts]]
name = "Couches applicatives Forge Engine" name = "Couches applicatives Forge Engine"
@@ -127,7 +127,7 @@ type = "layers"
layers = [ layers = [
"routes", "routes",
"ai | publish | core", "ai | publish | core",
"game_engine", "game_engine | document_engine",
"auth | filters", "auth | filters",
"db", "db",
] ]
+16 -5
View File
@@ -1,5 +1,16 @@
"""Routes HTTP du futur éditeur Support de formation (document unique, """Routes HTTP du support de formation (document unique, mise en page
mise en page automatique, mini-jeux — voir docs/plan/PLAN.md). Vide pour automatique, mini-jeux — voir docs/plan/PLAN.md). Entité racine séparée du
l'instant : aucun code métier n'existe encore côté document. Ne doit jeu 2D : ne reprend rien de routes/game/ (pas de notion d'écran, pas de
jamais recevoir de logique de type Flow (déclencheurs événement->action) Flow déclencheur événement->action, c'est exclusif à routes/game/)."""
— c'est une notion exclusive à routes/game/, voir PLAN.md."""
from . import ( # noqa: F401 - enregistre les routes definies dans chaque module
document_delete,
document_edit,
document_element_add,
document_element_delete,
document_element_move,
document_element_update,
document_new,
document_publish,
document_render,
)
+15
View File
@@ -0,0 +1,15 @@
from flask import redirect, url_for
from werkzeug.wrappers import Response
import db
from core.flask_app import app
@app.route("/document/<slug>/delete", methods=["POST"])
def document_delete(slug: str) -> Response:
"""Supprime un support — mirroir de routes/games/game_delete.py.
Blocage pour un compte "user" restreint géré par core/auth_guard.py
(même raisonnement que game_delete : aucun moyen d'en recréer un une
fois support_slug posé et le dossier supprimé)."""
db.delete_support(slug)
return redirect(url_for("index"))
+24
View File
@@ -0,0 +1,24 @@
from flask import render_template
import db
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/edit")
def document_edit(slug: str) -> str:
"""Éditeur du support — route directe, sans page intermédiaire (un
support = un projet = un document, voir docs/plan/PLAN.md). Contraste
avec l'environnement 2D (clic sur une carte -> game_dashboard qui liste
ses écrans -> éditeur de scène) : ici la carte "Mes formations" mène
directement ici."""
support = db.support_meta(slug)
elements = document_engine.list_document_elements(slug)
return render_template(
"document/document_edit.html",
support=support,
elements=elements,
rendered_document=document_engine.render_document(elements),
element_library=document_engine.ELEMENT_LIBRARY,
element_kind_labels=document_engine.ELEMENT_KIND_LABELS,
)
+41
View File
@@ -0,0 +1,41 @@
from typing import Any
from flask import jsonify, request
from werkzeug.wrappers import Response
import db
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/elements/add", methods=["POST"])
def document_element_add(slug: str) -> Response | tuple[Response, int]:
"""Ajoute un élément — appelée en AJAX depuis la bibliothèque du
panneau gauche (clic ou glisser-déposer initial), OU par le moteur de
layout lui-même pour créer une rangée à la volée au moment d'un dépôt
latéral (kind="row" — jamais choisi directement dans la bibliothèque,
absent de document_engine.ELEMENT_LIBRARY, mais un kind valide comme
un autre pour cette route ; voir static/document/js/document-editor.js).
parent_id, quand fourni, place l'élément directement dans une rangée
existante."""
kind = request.form.get("kind", "")
if kind not in document_engine.ELEMENT_KIND_LABELS:
return jsonify({"error": "type d'élément inconnu"}), 400
parent_id = request.form.get("parent_id", type=int)
element_id = document_engine.add_document_element(slug, kind, parent_id=parent_id)
element = db.assert_not_none(
document_engine.get_document_element(slug, element_id),
"element_id vient d'etre cree par add_document_element juste au-dessus",
)
elements_by_parent: dict[int | None, list[dict[str, Any]]] = {}
for el in document_engine.list_document_elements(slug):
elements_by_parent.setdefault(el["parent_id"], []).append(el)
return jsonify(
{
"id": element_id,
"kind": kind,
"parent_id": parent_id,
"attributes": element["attributes"],
"rendered_html": document_engine.render_document_element(element, elements_by_parent),
}
)
@@ -0,0 +1,11 @@
from flask import jsonify
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/elements/<int:element_id>/delete", methods=["POST"])
def document_element_delete(slug: str, element_id: int) -> Response:
document_engine.delete_document_element(slug, element_id)
return jsonify({"ok": True})
+19
View File
@@ -0,0 +1,19 @@
from flask import jsonify, request
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/elements/<int:element_id>/move", methods=["POST"])
def document_element_move(slug: str, element_id: int) -> Response:
"""Réinsertion réelle d'un élément — new_parent_id/new_index calculés
côté client par l'algorithme de glisser-déposer (boîte englobante la
plus proche sous le curseur, moitié gauche/droite -> rejoint sa
rangée, moitié haute/basse -> nouvelle rangée avant/après ; voir
static/document/js/document-editor.js)."""
payload = request.get_json(force=True) or {}
new_parent_id = payload.get("new_parent_id")
new_index = int(payload.get("new_index", 0))
document_engine.move_document_element(slug, element_id, new_parent_id, new_index)
return jsonify({"ok": True})
@@ -0,0 +1,32 @@
from typing import Any
from flask import jsonify, request
from werkzeug.wrappers import Response
import db
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/elements/<int:element_id>/update", methods=["POST"])
def document_element_update(slug: str, element_id: int) -> Response | tuple[Response, int]:
"""Remplace les attributs d'un élément — un formulaire du panneau
Propriétés (voir static/document/js/document-editor.js) envoie
systématiquement l'état complet de ses champs."""
if document_engine.get_document_element(slug, element_id) is None:
return jsonify({"error": "élément introuvable"}), 404
attributes: dict[str, Any] = request.get_json(force=True) or {}
document_engine.update_document_element_attributes(slug, element_id, attributes)
element = db.assert_not_none(
document_engine.get_document_element(slug, element_id),
"element_id verifie present juste au-dessus, aucune suppression concurrente possible entre-temps ici",
)
elements_by_parent: dict[int | None, list[dict[str, Any]]] = {}
for el in document_engine.list_document_elements(slug):
elements_by_parent.setdefault(el["parent_id"], []).append(el)
return jsonify(
{
"ok": True,
"rendered_html": document_engine.render_document_element(element, elements_by_parent),
}
)
+23
View File
@@ -0,0 +1,23 @@
from flask import g, redirect, request, url_for
from werkzeug.wrappers import Response
import auth
import db
from core.flask_app import app
@app.route("/documents/new", methods=["POST"])
def documents_new() -> Response:
"""Crée un support de formation — mirroir de routes/games/games_new.py
pour l'autre type de projet (entité racine séparée, voir
docs/plan/PLAN.md). Un compte "user" restreint pose support_slug (voir
auth.set_support_slug) en plus de project_slug si un environnement 2D
existe déjà : les deux slots sont indépendants, un compte peut remplir
les deux."""
name = request.form.get("name", "").strip()
if not name:
return redirect(url_for("index"))
slug = db.create_support(name, owner_folder=str(g.current_user["id"]))
if g.current_user["role"] != "admin":
auth.set_support_slug(g.current_user["id"], slug)
return redirect(url_for("document_edit", slug=slug))
+15
View File
@@ -0,0 +1,15 @@
from flask import jsonify
from werkzeug.wrappers import Response
import db
from core.flask_app import app
@app.route("/document/<slug>/publish", methods=["POST"])
def document_publish(slug: str) -> Response:
"""Publie le support — vrai changement d'état persisté
(_meta['published_at'], voir db/supports/mark_support_published.py),
pas juste un toast côté client (voir docs/plan/PLAN.md, "Publier
réel")."""
published_at = db.mark_support_published(slug)
return jsonify({"ok": True, "published_at": published_at})
+16
View File
@@ -0,0 +1,16 @@
from flask import jsonify
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/render")
def document_render(slug: str) -> Response:
"""Rendu HTML complet et à jour du document — appelé par le moteur de
layout côté client après toute mutation structurelle (ajout/déplacement/
suppression) pour reconstruire le canevas depuis la vérité serveur
(recalcul réel des rangées, jamais une simple retouche DOM locale, voir
docs/plan/PLAN.md — "recalcul au déplacement")."""
elements = document_engine.list_document_elements(slug)
return jsonify({"html": document_engine.render_document(elements), "elements": elements})
+10 -2
View File
@@ -6,7 +6,15 @@ from core.flask_app import app
@app.route("/") @app.route("/")
def index() -> str: def index() -> str:
# Toujours SES PROPRES projets (voir db.list_games, corrigé pour ne # "Mes formations" (voir docs/plan/PLAN.md) : les deux types de projet
# sont listés côte à côte, jamais mélangés dans les mêmes données
# (db.list_games/db.list_supports, deux systèmes de fichiers
# entièrement séparés). Toujours SES PROPRES projets (corrigé pour ne
# plus jamais scanner les autres comptes) — même pour un admin, qui # plus jamais scanner les autres comptes) — même pour un admin, qui
# n'a donc plus aucune visibilité sur les projets des autres comptes. # n'a donc plus aucune visibilité sur les projets des autres comptes.
return render_template("index.html", games=db.list_games(str(g.current_user["id"]))) owner_folder = str(g.current_user["id"])
return render_template(
"index.html",
games=db.list_games(owner_folder),
supports=db.list_supports(owner_folder),
)
+60 -30
View File
@@ -8,61 +8,91 @@ import db
import game_engine import game_engine
from core.flask_app import app from core.flask_app import app
# Choix de PREMIER niveau, avant le catalogue des types de jeu (voir
# db.ONBOARDING_TYPES) : "support" n'y figure jamais (schéma/notions
# entièrement différents, voir document_engine/), posé ici comme une
# carte de plus dans la même grille visuelle (templates/onboarding/
# onboarding_new.html réutilise sa mécanique de sélection existante sans
# aucune modification JS, voir static/js/onboarding/onboarding_new.js).
_SUPPORT_ONBOARDING_CARD: dict[str, Any] = {
"label": "Support de formation",
"tagline": "Un contenu structuré, prêt à publier.",
"icon": "📄",
"description": (
'<span class="onboardingPitchTitle">Un support de formation clair,'
" sans une ligne de code</span>"
'<ul class="onboardingPitchList">'
'<li><span class="onboardingPitchCheck">✓</span>'
"<span><strong>Mise en page automatique</strong> — dépose tes blocs,"
" l'alignement se fait tout seul</span></li>"
'<li><span class="onboardingPitchCheck">✓</span>'
"<span><strong>Formes & mini-jeux</strong> — habille et anime ton"
" contenu à volonté</span></li>"
'<li><span class="onboardingPitchCheck">✓</span>'
"<span><strong>Aperçu & publication</strong> — vois exactement ce que"
" verra l'apprenant</span></li>"
"</ul>"
),
}
@app.route("/onboarding", methods=["GET", "POST"]) @app.route("/onboarding", methods=["GET", "POST"])
def onboarding_new() -> str | Response: def onboarding_new() -> str | Response:
"""Point d'entrée UNIQUE pour créer un jeu — comportement SYSTÉMATIQUE """Point d'entrée UNIQUE pour créer un projet — jeu (environnement 2D)
à chaque création, pas une formalité réservée à la toute première OU support de formation (voir docs/plan/PLAN.md), deux entités racines
inscription : chaque projet (celui d'un compte "user" comme n'importe séparées. Un compte peut avoir un projet DE CHAQUE type (project_slug
lequel des projets d'un admin) choisit son propre type ici. Prépare ET support_slug, voir core/auth_guard.py) : chaque carte du catalogue
aussi le futur multi-projet payant (plusieurs jeux de types n'est proposée que si son slot correspondant n'est pas déjà rempli
différents pour un même compte "user"). (compte "user" restreint — un admin, lui, n'a aucun slot dédié, illimité
des deux côtés)."""
Un compte "user" fraîchement inscrit n'a plus de projet créé d'office
(voir routes/auth/register_2fa.py) : core/auth_guard.py le redirige
ici tant qu'il n'a pas encore de project_slug — et n'en aura jamais
qu'UN SEUL (voir core/auth_guard.py). Un admin, lui, n'a PAS de
project_slug dédié (illimité, voir create_user.py) : repasse ici à
volonté pour créer un projet de PLUS, jamais redirigé ailleurs.
Choisit un type (voir db.ONBOARDING_TYPES) puis nomme son jeu — crée
le projet ET son premier écran (du "kind" correspondant), puis envoie
vers son tableau de bord (routes/games/game_dashboard.py y rend une
version SIMPLIFIÉE pour un type restreint — quiz/embranchement/rpg —
ou la version complète pour "custom", même route pour tous)."""
user = g.current_user user = g.current_user
if user["role"] != "admin" and user.get("project_slug"): restricted = user["role"] != "admin"
# Retour en arrière après coup (l'onboarding a déjà été fait) — has_game = restricted and bool(user.get("project_slug"))
# ne recrée rien, renvoie directement vers son tableau de bord. Ne has_support = restricted and bool(user.get("support_slug"))
# s'applique PAS à un admin (illimité, jamais "déjà fait" une fois if has_game and has_support:
# pour toutes). # Les deux slots sont déjà remplis — plus rien à créer ici.
return redirect(url_for("game_dashboard", slug=user["project_slug"])) return redirect(url_for("index"))
onboarding_types: dict[str, dict[str, Any]] = {}
if not has_support:
onboarding_types["support"] = _SUPPORT_ONBOARDING_CARD
if not has_game:
onboarding_types.update(db.ONBOARDING_TYPES)
error = None error = None
if request.method == "POST": if request.method == "POST":
onboarding_type = request.form.get("onboarding_type", "") onboarding_type = request.form.get("onboarding_type", "")
name = request.form.get("name", "").strip() name = request.form.get("name", "").strip()
if onboarding_type not in db.ONBOARDING_TYPES: if onboarding_type not in onboarding_types:
error = "Choisis un type de jeu." error = "Choisis un type de projet."
elif not name: elif not name:
error = "Donne un nom à ton jeu." error = "Donne un nom à ton projet."
elif onboarding_type == "support":
return redirect(_create_support_for_user(user, name))
else: else:
return redirect(_create_project_for_user(user, onboarding_type, name)) return redirect(_create_project_for_user(user, onboarding_type, name))
return render_template( return render_template(
"onboarding/onboarding_new.html", "onboarding/onboarding_new.html",
onboarding_types=db.ONBOARDING_TYPES, onboarding_types=onboarding_types,
error=error, error=error,
) )
def _create_support_for_user(user: dict[str, Any], name: str) -> str:
slug = db.create_support(name, owner_folder=str(user["id"]))
if user["role"] != "admin":
auth.set_support_slug(user["id"], slug)
return url_for("document_edit", slug=slug)
def _create_project_for_user(user: dict[str, Any], onboarding_type: str, name: str) -> str: def _create_project_for_user(user: dict[str, Any], onboarding_type: str, name: str) -> str:
# owner_folder = l'id du compte, jamais son email (voir # owner_folder = l'id du compte, jamais son email (voir
# routes/games/games_new.py, même convention). # routes/games/games_new.py, même convention).
slug = db.create_game(name, owner_folder=str(user["id"])) slug = db.create_game(name, owner_folder=str(user["id"]))
db.set_onboarding_type(slug, onboarding_type) db.set_onboarding_type(slug, onboarding_type)
if user["role"] != "admin": if user["role"] != "admin":
# project_slug = LE seul projet d'un compte "user" (voir # project_slug = LE seul environnement 2D d'un compte "user" (voir
# core/auth_guard.py) — un admin n'utilise pas cette colonne, ses # core/auth_guard.py) — un admin n'utilise pas cette colonne, ses
# projets se retrouvent via db.list_games() comme avant. # projets se retrouvent via db.list_games() comme avant.
auth.set_project_slug(user["id"], slug) auth.set_project_slug(user["id"], slug)
+795
View File
@@ -0,0 +1,795 @@
/* document-editor.css — éditeur de support de formation (voir
docs/plan/PLAN.md). Tout est scopé sous .docEditor3 : aucune règle ici
ne touche jamais html[data-theme] (figé "dark" pour le reste du site,
voir templates/base.html) — le thème clair/sombre de CET éditeur est un
attribut data-theme posé sur .docEditor3 lui-même (voir
static/document/js/document-editor.js), avec ses propres tokens
--doc-* ci-dessous plutôt que les --forge-* globaux. */
.docEditor3 {
--doc-bg: var(--forge-bg);
--doc-bg-2: var(--forge-bg-secondary);
--doc-card: var(--forge-card);
--doc-border: var(--forge-border);
--doc-text: var(--forge-text);
--doc-muted: var(--forge-text-muted);
--doc-accent: var(--accent);
--doc-accent-2: var(--accent-2);
--doc-glow: 0 0 0 1px rgb(255 95 46 / 25%), 0 8px 24px -6px rgb(255 95 46 / 25%);
position: fixed;
inset: 0;
z-index: 20;
display: flex;
flex-direction: column;
background: var(--doc-bg);
color: var(--doc-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.docEditor3[data-theme="light"] {
--doc-bg: #f6f5f2;
--doc-bg-2: #fff;
--doc-card: #fff;
--doc-border: #e3e1db;
--doc-text: #14161c;
--doc-muted: #6b7280;
--doc-glow: 0 0 0 1px rgb(255 95 46 / 30%), 0 8px 24px -6px rgb(255 95 46 / 30%);
}
.docEditor3 * {
box-sizing: border-box;
}
.docEditor3 button {
font-family: inherit;
}
/* ===== TOPBAR ===== */
.docTopbar {
height: 56px;
flex-shrink: 0;
background: var(--doc-bg-2);
border-bottom: 1px solid var(--doc-border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
gap: 12px;
}
.docCrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
min-width: 0;
overflow: hidden;
}
.docCrumbDim {
color: var(--doc-muted);
font-weight: 400;
white-space: nowrap;
text-decoration: none;
}
.docCrumbCur {
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--doc-text);
}
.docTopbarActions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.docIconBtn {
width: 34px;
height: 34px;
border-radius: 10px;
border: 1px solid var(--doc-border);
background: transparent;
color: var(--doc-text);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.docIconBtn:hover:not(:disabled) {
border-color: var(--doc-muted);
}
.docIconBtn:disabled {
opacity: 0.35;
cursor: default;
}
.docDividerV {
width: 1px;
height: 22px;
background: var(--doc-border);
margin: 0 4px;
}
.docBtnSecondary {
height: 34px;
padding: 0 14px;
border-radius: 10px;
border: 1px solid var(--doc-border);
background: transparent;
color: var(--doc-text);
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 7px;
cursor: pointer;
}
.docBtnSecondary:hover {
border-color: var(--doc-muted);
}
.docBtnPrimary {
border-color: var(--doc-accent);
color: var(--doc-accent);
}
.docBtnPrimary:hover {
background: rgb(255 95 46 / 8%);
}
.docBtnPrimary.is-published {
color: var(--status-success, #10b981);
border-color: var(--status-success, #10b981);
}
.docWidthPicker {
display: flex;
gap: 4px;
background: var(--doc-card);
border: 1px solid var(--doc-border);
border-radius: 10px;
padding: 3px;
}
.docWidthBtn {
border: none;
background: transparent;
color: var(--doc-muted);
font-size: 12.5px;
font-weight: 600;
padding: 5px 10px;
border-radius: 7px;
cursor: pointer;
}
.docWidthBtn.is-active {
background: rgb(255 95 46 / 12%);
color: var(--doc-accent);
}
/* ===== BODY LAYOUT ===== */
.docBodyWrap {
flex: 1;
display: flex;
min-height: 0;
position: relative;
}
.docSidebar {
flex-shrink: 0;
background: var(--doc-bg-2);
padding: 20px 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 22px;
}
.docSidebarLeft {
width: 248px;
border-right: 1px solid var(--doc-border);
}
.docSidebarRight {
width: 300px;
border-left: 1px solid var(--doc-border);
}
.docSectionLabel {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--doc-muted);
margin-bottom: 10px;
}
.docShapesGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.docLibItem {
border: 1px solid var(--doc-border);
border-radius: 10px;
cursor: grab;
background: var(--doc-card);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.docLibItem:hover {
border-color: var(--doc-accent);
box-shadow: var(--doc-glow);
}
.docLibItem--shape {
padding: 10px 8px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.docLibItem--row {
padding: 9px 10px;
display: flex;
align-items: center;
gap: 10px;
}
.docLibItem span:last-child {
font-size: 12.5px;
font-weight: 600;
}
.docLibList {
display: flex;
flex-direction: column;
gap: 6px;
}
.docLibIcon {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.docLibIcon--rectangle::before { content: "▭"; }
.docLibIcon--cercle::before { content: "○"; }
.docLibIcon--triangle::before { content: "△"; }
.docLibIcon--trait::before { content: "―"; }
.docLibIcon--paragraphe::before { content: "¶"; }
.docLibIcon--image::before { content: "🖼"; }
.docLibIcon--bouton::before { content: "⬚"; }
.docLibIcon--titre::before {
content: "T";
font-weight: 800;
}
.docLibIcon--quiz::before {
content: "?";
color: var(--doc-accent);
font-weight: 800;
}
.docLibIcon--association::before {
content: "⇄";
color: var(--doc-accent);
}
.docLibIcon--memory::before {
content: "▤";
color: var(--doc-accent);
}
.docLibIcon--mots::before {
content: "▦";
color: var(--doc-accent);
}
.docLibIcon--scenario::before {
content: "◈";
color: var(--doc-accent);
}
.docLibIcon--zones::before {
content: "⚠";
color: var(--doc-accent);
}
/* ---- CANVAS ---- */
.docCanvasArea {
flex: 1;
background: var(--doc-bg);
padding: 36px 40px;
overflow: auto;
display: flex;
justify-content: center;
position: relative;
}
.docPage {
width: 760px;
max-width: 100%;
height: fit-content;
background: var(--doc-card);
border: 1px solid var(--doc-border);
border-radius: 16px;
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
padding: 48px 52px;
position: relative;
transform-origin: top center;
transition: width 0.2s ease;
}
/* Enfant qui reçoit le HTML rendu (voir #docPage vs #docPageContent dans
document-editor.js — le rafraîchissement du canevas ne remplace QUE ce
contenu, jamais .docPage elle-même, pour que .docSnapGridOverlay,
sa voisine, survive à chaque rafraîchissement). */
.docPageContent {
display: flex;
flex-direction: column;
gap: 18px;
}
.docPage.docPage--fixedWidth {
width: var(--doc-preview-width, 760px);
padding: 28px 20px;
}
.docRow {
position: relative;
}
.docRow > [data-element-id] {
flex: 1 1 220px;
min-width: 0;
}
[data-element-id] {
position: relative;
border-radius: 8px;
padding: 6px;
margin: -6px;
cursor: pointer;
transition: background 0.1s ease;
}
[data-kind="row"] > [data-element-id] {
margin: 0;
}
[data-element-id]:hover {
background: rgb(255 95 46 / 6%);
}
[data-element-id].docSelected {
background: rgb(255 95 46 / 10%);
outline: 1px dashed var(--doc-accent);
}
[data-element-id].docDropTarget--before {
box-shadow: inset 0 3px 0 0 var(--doc-accent);
}
[data-element-id].docDropTarget--after {
box-shadow: inset 0 -3px 0 0 var(--doc-accent);
}
[data-element-id].docDropTarget--mergeLeft {
box-shadow: inset 3px 0 0 0 var(--doc-accent);
}
[data-element-id].docDropTarget--mergeRight {
box-shadow: inset -3px 0 0 0 var(--doc-accent);
}
.docShape[data-element-id] {
padding: 0;
margin: 0;
border-radius: 0;
}
.docShape[data-element-id].docSelected {
outline: 2px solid var(--doc-accent);
background: transparent;
}
.docText {
outline: none;
}
.docImage.docImagePlaceholder {
width: 100%;
height: 190px;
border-radius: 10px;
background: var(--doc-bg-2);
border: 1px dashed var(--doc-border);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--doc-muted);
font-size: 13px;
font-weight: 600;
}
img.docImage {
max-width: 100%;
border-radius: 10px;
display: block;
}
.docButton {
border: none;
border-radius: 999px;
padding: 10px 22px;
font-weight: 700;
font-size: 14px;
background: var(--doc-accent);
color: #14100c;
cursor: pointer;
}
.docMinigame {
border: 1px solid var(--doc-accent);
border-radius: 12px;
padding: 18px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: rgb(255 95 46 / 8%);
}
.docMinigameLabel {
font-size: 15px;
font-weight: 700;
}
.docMinigamePlaceholder {
font-size: 12.5px;
color: var(--doc-muted);
}
.docUnknown {
font-size: 12.5px;
color: var(--doc-muted);
border: 1px dashed var(--doc-border);
border-radius: 8px;
padding: 10px;
}
/* ---- grille d'accroche (formes libres) — voir docs/plan/PLAN.md,
"grille d'accroche réelle" : visible en overlay pendant le déplacement
d'une forme (voir .docPage--dragging-shape ci-dessous), jamais
décorative. */
.docSnapGridOverlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 1;
opacity: 0;
transition: opacity 0.1s ease;
background-image:
linear-gradient(to right, rgb(255 95 46 / 12%) 1px, transparent 1px),
linear-gradient(to bottom, rgb(255 95 46 / 12%) 1px, transparent 1px);
background-size: var(--doc-snap-size, 8px) var(--doc-snap-size, 8px);
}
.docPage.docPage--dragging-shape .docSnapGridOverlay {
opacity: 1;
}
.docZoomPill {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--doc-card);
border: 1px solid var(--doc-border);
border-radius: 999px;
height: 36px;
padding: 0 6px;
display: flex;
align-items: center;
gap: 6px;
box-shadow: 0 2px 6px rgb(0 0 0 / 30%);
}
.docZoomPill button {
width: 26px;
height: 26px;
border-radius: 999px;
border: none;
background: transparent;
color: var(--doc-text);
cursor: pointer;
font-size: 15px;
font-weight: 700;
}
.docZoomPill button:hover {
background: var(--doc-bg-2);
}
.docZoomPill span {
font-size: 12.5px;
font-weight: 700;
color: var(--doc-muted);
width: 40px;
text-align: center;
}
/* ---- RIGHT SIDEBAR : Propriétés ---- */
.docField {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.docFieldRow {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.docField label,
.docFieldRow .docFieldLabel {
font-size: 13px;
color: var(--doc-muted);
}
.docField input,
.docField textarea,
.docField select {
border: 1px solid var(--doc-border);
border-radius: 8px;
padding: 8px 10px;
font-size: 13px;
color: var(--doc-text);
background: var(--doc-card);
font-family: inherit;
width: 100%;
resize: vertical;
}
.docSwatches {
display: flex;
gap: 8px;
}
.docSwatch {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
}
.docSwatch.is-active {
border-color: var(--doc-text);
}
.docSegmented {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.docSegBtn {
padding: 6px 10px;
border-radius: 8px;
border: 1px solid var(--doc-border);
background: var(--doc-card);
font-size: 12.5px;
font-weight: 600;
color: var(--doc-text);
cursor: pointer;
}
.docSegBtn.is-active {
background: rgb(255 95 46 / 12%);
color: var(--doc-accent);
border-color: var(--doc-accent);
}
.docToolbarRow {
display: flex;
gap: 6px;
margin-bottom: 14px;
}
.docFmtBtn {
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid var(--doc-border);
background: var(--doc-card);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 14px;
font-weight: 800;
color: var(--doc-text);
}
.docFmtBtn.is-active {
background: rgb(255 95 46 / 12%);
border-color: var(--doc-accent);
color: var(--doc-accent);
}
.docFmtBtn.docFmtBtn--italic {
font-style: italic;
font-weight: 400;
}
.docFmtBtn.docFmtBtn--underline {
text-decoration: underline;
font-weight: 400;
}
.docDeleteBtn {
width: 100%;
height: 38px;
border-radius: 10px;
border: 1px solid var(--status-danger, #f04438);
background: transparent;
color: var(--status-danger, #f04438);
font-weight: 700;
cursor: pointer;
margin-top: 6px;
}
.docDeleteBtn:hover {
background: rgb(240 68 56 / 10%);
}
.docEmptyState {
font-size: 13px;
color: var(--doc-muted);
text-align: center;
padding: 30px 10px;
}
/* ===== BOTTOM NAV (mobile) ===== */
.docBottomNav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 58px;
background: var(--doc-bg-2);
border-top: 1px solid var(--doc-border);
z-index: 40;
align-items: stretch;
justify-content: space-around;
}
.docBottomNav button {
flex: 1;
background: transparent;
border: none;
color: var(--doc-muted);
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.docBottomNav button.is-active {
color: var(--doc-accent);
}
.docSidebarClose {
display: none;
}
/* ===== MODE APERÇU : masque tout le chrome d'édition ===== */
.docEditor3.docEditor3--preview .docSidebarLeft,
.docEditor3.docEditor3--preview .docSidebarRight,
.docEditor3.docEditor3--preview .docBottomNav,
.docEditor3.docEditor3--preview .docZoomPill {
display: none;
}
.docEditor3.docEditor3--preview [data-element-id] {
cursor: default;
pointer-events: none;
}
.docEditor3.docEditor3--preview [data-element-id]:hover,
.docEditor3.docEditor3--preview [data-element-id].docSelected {
background: transparent;
outline: none;
}
.docEditor3.docEditor3--preview .docCanvasArea {
padding: 40px;
}
/* ===== RESPONSIVE (chrome de l'éditeur lui-même, <900px) ===== */
@media (width <= 900px) {
.docCrumbDim {
display: none;
}
.docTopbar {
padding: 0 14px;
}
.docBtnLabel {
display: none;
}
.docBtnSecondary {
padding: 0 10px;
}
.docWidthPicker {
display: none;
}
.docCanvasArea {
padding: 18px 14px 84px;
}
.docPage {
padding: 28px 20px;
}
.docSidebar {
position: fixed;
inset: 56px 0 58px;
width: 100%;
z-index: 30;
transition: transform 0.25s ease;
}
.docSidebarLeft {
transform: translateX(-100%);
}
.docSidebarRight {
transform: translateX(100%);
}
.docSidebar.is-open {
transform: translateX(0);
}
.docSidebarClose {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: var(--doc-muted);
margin-bottom: 16px;
cursor: pointer;
padding: 0 16px;
}
.docBottomNav {
display: flex;
}
}
+835
View File
@@ -0,0 +1,835 @@
// document-editor.js — éditeur de support de formation (voir
// docs/plan/PLAN.md). État MUTABLE partagé sur window.forgeDocState
// (jamais let/const au premier niveau de ce fichier, voir static/pjax.js —
// un <script src> de page est rejoué tel quel à chaque navigation pjax,
// une redéclaration lèverait "already declared" ; seul `var` est sûr à
// rejouer). Toute mutation passe par le serveur (AJAX) puis rafraîchit le
// canevas depuis SA réponse (jamais un état simulé côté client) —
// Undo/Redo est une PILE DE COMMANDES qui rejoue ces mêmes appels AJAX en
// sens inverse, pas un simple instantané visuel.
// Constante immuable au premier niveau : `let`/`const` lèverait "already
// declared" à la 2e exécution de ce script par pjax.js (voir l'en-tête
// ci-dessus), `var` est le seul choix sûr ici.
// eslint-disable-next-line no-var, vars-on-top
var FORGE_DOC_STYLE_PRESETS = {
titre1: 'Titre 1',
titre2: 'Titre 2',
paragraphe: 'Paragraphe',
legende: 'Légende',
};
// Même raison que FORGE_DOC_STYLE_PRESETS ci-dessus.
// eslint-disable-next-line no-var, vars-on-top
var FORGE_DOC_SHAPE_KINDS = ['rectangle', 'cercle', 'triangle', 'trait'];
function forgeDocIsShapeKind(kind) {
return FORGE_DOC_SHAPE_KINDS.includes(kind);
}
function forgeDocChildrenOf(parentId) {
return window.forgeDocState.elements
.filter((el) => el.parent_id === parentId)
.sort((a, b) => a.order_index - b.order_index);
}
function forgeDocTopLevel() {
return forgeDocChildrenOf(null);
}
function forgeDocRebuildIndex() {
const byId = {};
window.forgeDocState.elements.forEach((el) => { byId[el.id] = el; });
window.forgeDocState.elementsById = byId;
}
/* ---------------------------------------------------------------------
* Appels serveur — un seul point de vérité par opération (voir
* routes/document/), jamais dupliqué côté client.
* ------------------------------------------------------------------- */
function forgeDocApiAdd(kind, parentId) {
const body = new URLSearchParams();
body.set('kind', kind);
if (parentId !== null && parentId !== undefined) body.set('parent_id', String(parentId));
return fetch(`/document/${window.forgeDocState.slug}/elements/add`, { method: 'POST', body })
.then((r) => r.json());
}
function forgeDocApiUpdate(id, attributes) {
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attributes),
}).then((r) => r.json());
}
function forgeDocApiMove(id, newParentId, newIndex) {
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_parent_id: newParentId, new_index: newIndex }),
}).then((r) => r.json());
}
function forgeDocApiDelete(id) {
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/delete`, { method: 'POST' })
.then((r) => r.json());
}
function forgeDocApiRender() {
return fetch(`/document/${window.forgeDocState.slug}/render`).then((r) => r.json());
}
function forgeDocApiPublish() {
return fetch(`/document/${window.forgeDocState.slug}/publish`, { method: 'POST' }).then((r) => r.json());
}
/* ---------------------------------------------------------------------
* Canevas — rafraîchi depuis la VÉRITÉ SERVEUR après chaque mutation
* structurelle (jamais une simple retouche DOM locale, voir
* routes/document/document_render.py).
* ------------------------------------------------------------------- */
function forgeDocApplySelectionClass() {
document.querySelectorAll('#docPage .docSelected').forEach((d) => d.classList.remove('docSelected'));
const { selectedId } = window.forgeDocState;
if (selectedId === null) return;
const dom = document.querySelector(`[data-element-id="${selectedId}"]`);
if (dom) dom.classList.add('docSelected');
}
function forgeDocRefreshCanvas() {
return forgeDocApiRender().then((data) => {
document.getElementById('docPageContent').innerHTML = data.html;
window.forgeDocState.elements = data.elements;
forgeDocRebuildIndex();
forgeDocBindCanvasInteractions();
const { selectedId } = window.forgeDocState;
if (selectedId !== null && window.forgeDocState.elementsById[selectedId]) {
forgeDocApplySelectionClass();
} else {
window.forgeDocState.selectedId = null;
forgeDocRenderProps(null);
}
});
}
/* ---------------------------------------------------------------------
* Undo/Redo — pile de COMMANDES {undo, redo}, chacune rejouant les mêmes
* appels AJAX que l'action d'origine (voir docs/plan/PLAN.md).
* ------------------------------------------------------------------- */
function forgeDocUpdateHistoryButtons() {
const undoBtn = document.getElementById('docUndoBtn');
const redoBtn = document.getElementById('docRedoBtn');
if (undoBtn) undoBtn.disabled = window.forgeDocState.undoStack.length === 0;
if (redoBtn) redoBtn.disabled = window.forgeDocState.redoStack.length === 0;
}
function forgeDocPushCommand(command) {
window.forgeDocState.undoStack.push(command);
window.forgeDocState.redoStack = [];
forgeDocUpdateHistoryButtons();
}
function forgeDocUndo() {
const command = window.forgeDocState.undoStack.pop();
if (!command) return;
command.undo().then(forgeDocRefreshCanvas).then(() => {
window.forgeDocState.redoStack.push(command);
forgeDocUpdateHistoryButtons();
});
}
function forgeDocRedo() {
const command = window.forgeDocState.redoStack.pop();
if (!command) return;
command.redo().then(forgeDocRefreshCanvas).then(() => {
window.forgeDocState.undoStack.push(command);
forgeDocUpdateHistoryButtons();
});
}
/* ---------------------------------------------------------------------
* Mutations de haut niveau — chacune exécute son action puis pousse la
* commande d'annulation correspondante.
* ------------------------------------------------------------------- */
function forgeDocAddElement(kind, parentId) {
const state = { parentId: parentId === undefined ? null : parentId };
function perform() {
return forgeDocApiAdd(kind, state.parentId).then((result) => { state.id = result.id; });
}
return perform().then(() => {
forgeDocPushCommand({
undo: () => forgeDocApiDelete(state.id),
redo: perform,
});
return forgeDocRefreshCanvas().then(() => forgeDocSelectElement(state.id));
});
}
function forgeDocDeleteElement(id) {
const el = window.forgeDocState.elementsById[id];
if (!el) return Promise.resolve();
const state = {
id, parentId: el.parent_id, index: el.order_index, kind: el.kind, attributes: el.attributes,
};
return forgeDocApiDelete(state.id).then(() => {
forgeDocPushCommand({
undo: () => forgeDocApiAdd(state.kind, state.parentId).then((r) => {
state.id = r.id;
return forgeDocApiUpdate(state.id, state.attributes)
.then(() => forgeDocApiMove(state.id, state.parentId, state.index));
}),
redo: () => forgeDocApiDelete(state.id),
});
window.forgeDocState.selectedId = null;
return forgeDocRefreshCanvas();
});
}
function forgeDocUpdateAttributes(id, newAttributes) {
const el = window.forgeDocState.elementsById[id];
if (!el) return Promise.resolve();
const oldAttributes = { ...el.attributes };
return forgeDocApiUpdate(id, newAttributes).then((result) => {
forgeDocPushCommand({
undo: () => forgeDocApiUpdate(id, oldAttributes),
redo: () => forgeDocApiUpdate(id, newAttributes),
});
el.attributes = newAttributes;
const dom = document.querySelector(`[data-element-id="${id}"]`);
if (dom && result.rendered_html) {
const wrapper = document.createElement('div');
wrapper.innerHTML = result.rendered_html;
dom.replaceWith(wrapper.firstElementChild);
forgeDocBindCanvasInteractions();
forgeDocApplySelectionClass();
}
});
}
/* ---------------------------------------------------------------------
* Moteur de layout — glisser-déposer réel (voir docs/plan/PLAN.md §1.2.B) :
* dépose sur la moitié gauche/droite de la boîte englobante d'un élément
* -> rejoint sa rangée (créée à la volée si besoin) ; moitié haute/basse
* -> nouvelle position au top-niveau, avant/après.
* ------------------------------------------------------------------- */
function forgeDocFindDropTarget(clientX, clientY) {
const candidates = Array.from(document.querySelectorAll('#docPage [data-element-id]'))
.filter((dom) => !forgeDocIsShapeKind(dom.dataset.kind));
let best = null;
let bestDist = Infinity;
candidates.forEach((dom) => {
const rect = dom.getBoundingClientRect();
const dist = Math.hypot(clientX - (rect.left + rect.width / 2), clientY - (rect.top + rect.height / 2));
if (dist < bestDist) { bestDist = dist; best = dom; }
});
return best;
}
function forgeDocComputeDropPlacement(targetDom, clientX, clientY) {
const rect = targetDom.getBoundingClientRect();
const relX = (clientX - rect.left) / rect.width;
const relY = (clientY - rect.top) / rect.height;
const targetId = Number.parseInt(targetDom.dataset.elementId, 10);
const targetEl = window.forgeDocState.elementsById[targetId];
if (relY < 0.25) return { mode: 'before', targetEl };
if (relY > 0.75) return { mode: 'after', targetEl };
return { mode: relX < 0.5 ? 'mergeLeft' : 'mergeRight', targetEl };
}
function forgeDocShowDropHighlight(targetDom, mode) {
document.querySelectorAll('.docDropTarget').forEach((d) => {
d.classList.remove('docDropTarget', 'docDropTarget--before', 'docDropTarget--after', 'docDropTarget--mergeLeft', 'docDropTarget--mergeRight');
});
targetDom.classList.add('docDropTarget', `docDropTarget--${mode}`);
}
function forgeDocClearDropHighlight() {
document.querySelectorAll('.docDropTarget').forEach((d) => {
d.classList.remove('docDropTarget', 'docDropTarget--before', 'docDropTarget--after', 'docDropTarget--mergeLeft', 'docDropTarget--mergeRight');
});
}
function forgeDocDropLibraryItem(kind, placement) {
const { targetEl } = placement;
const state = {};
function performForward() {
if (placement.mode === 'mergeLeft' || placement.mode === 'mergeRight') {
if (targetEl.parent_id !== null) {
const siblings = forgeDocChildrenOf(targetEl.parent_id);
const pos = siblings.findIndex((s) => s.id === targetEl.id);
state.index = placement.mode === 'mergeLeft' ? pos : pos + 1;
state.createdRow = false;
return forgeDocApiAdd(kind, targetEl.parent_id).then((r) => {
state.newId = r.id;
return forgeDocApiMove(state.newId, targetEl.parent_id, state.index);
});
}
state.createdRow = true;
state.topPos = forgeDocTopLevel().findIndex((s) => s.id === targetEl.id);
return forgeDocApiAdd('row', null).then((rowResult) => {
state.rowId = rowResult.id;
return forgeDocApiMove(state.rowId, null, state.topPos)
.then(() => forgeDocApiMove(targetEl.id, state.rowId, 0))
.then(() => forgeDocApiAdd(kind, state.rowId))
.then((r) => {
state.newId = r.id;
state.index = placement.mode === 'mergeLeft' ? 0 : 1;
return forgeDocApiMove(state.newId, state.rowId, state.index);
});
});
}
const anchorId = targetEl.parent_id !== null ? targetEl.parent_id : targetEl.id;
const anchorPos = forgeDocTopLevel().findIndex((s) => s.id === anchorId);
state.index = placement.mode === 'before' ? anchorPos : anchorPos + 1;
state.createdRow = false;
return forgeDocApiAdd(kind, null).then((r) => {
state.newId = r.id;
return forgeDocApiMove(state.newId, null, state.index);
});
}
function performUndo() {
const deleteNew = forgeDocApiDelete(state.newId);
if (!state.createdRow) return deleteNew;
return deleteNew
.then(() => forgeDocApiMove(targetEl.id, null, state.topPos))
.then(() => forgeDocApiDelete(state.rowId));
}
return performForward().then(() => {
forgeDocPushCommand({ undo: performUndo, redo: performForward });
return forgeDocRefreshCanvas().then(() => forgeDocSelectElement(state.newId));
});
}
function forgeDocDropExistingElement(draggedId, placement) {
const draggedEl = window.forgeDocState.elementsById[draggedId];
const { targetEl } = placement;
if (!draggedEl || draggedEl.id === targetEl.id || targetEl.parent_id === draggedId) return Promise.resolve();
const isMerge = placement.mode === 'mergeLeft' || placement.mode === 'mergeRight';
if (draggedEl.kind === 'row' && isMerge) return Promise.resolve();
const fromParentId = draggedEl.parent_id;
const fromIndex = draggedEl.order_index;
const state = {};
function performForward() {
if (isMerge) {
if (targetEl.parent_id !== null) {
const siblings = forgeDocChildrenOf(targetEl.parent_id).filter((s) => s.id !== draggedId);
const pos = siblings.findIndex((s) => s.id === targetEl.id);
state.rowId = targetEl.parent_id;
state.index = placement.mode === 'mergeLeft' ? pos : pos + 1;
state.createdRow = false;
return forgeDocApiMove(draggedId, state.rowId, state.index);
}
state.createdRow = true;
state.topPos = forgeDocTopLevel().filter((s) => s.id !== draggedId).findIndex((s) => s.id === targetEl.id);
return forgeDocApiAdd('row', null).then((rowResult) => {
state.rowId = rowResult.id;
return forgeDocApiMove(state.rowId, null, state.topPos)
.then(() => forgeDocApiMove(targetEl.id, state.rowId, 0))
.then(() => {
state.index = placement.mode === 'mergeLeft' ? 0 : 1;
return forgeDocApiMove(draggedId, state.rowId, state.index);
});
});
}
const anchorId = targetEl.parent_id !== null ? targetEl.parent_id : targetEl.id;
const anchorPos = forgeDocTopLevel().filter((s) => s.id !== draggedId).findIndex((s) => s.id === anchorId);
state.index = placement.mode === 'before' ? anchorPos : anchorPos + 1;
state.createdRow = false;
return forgeDocApiMove(draggedId, null, state.index);
}
function performUndo() {
const restore = forgeDocApiMove(draggedId, fromParentId, fromIndex);
if (!state.createdRow) return restore;
return restore
.then(() => forgeDocApiMove(targetEl.id, null, state.topPos))
.then(() => forgeDocApiDelete(state.rowId));
}
return performForward().then(() => {
forgeDocPushCommand({ undo: performUndo, redo: performForward });
return forgeDocRefreshCanvas();
});
}
function forgeDocBindLibraryDragHandlers() {
document.querySelectorAll('.docLibItem[data-add]').forEach((item) => {
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/forge-doc-add', item.dataset.add);
e.dataTransfer.effectAllowed = 'copy';
});
item.addEventListener('click', () => { forgeDocAddElement(item.dataset.add, null); });
});
}
function forgeDocBindCanvasDropZone() {
const docPage = document.getElementById('docPage');
docPage.addEventListener('dragover', (e) => {
const isAdd = e.dataTransfer.types.includes('text/forge-doc-add');
const isMove = e.dataTransfer.types.includes('text/forge-doc-move');
if (!isAdd && !isMove) return;
e.preventDefault();
const target = forgeDocFindDropTarget(e.clientX, e.clientY);
if (!target) { forgeDocClearDropHighlight(); return; }
const placement = forgeDocComputeDropPlacement(target, e.clientX, e.clientY);
forgeDocShowDropHighlight(target, placement.mode);
});
docPage.addEventListener('dragleave', (e) => {
if (e.target === docPage) forgeDocClearDropHighlight();
});
docPage.addEventListener('drop', (e) => {
e.preventDefault();
forgeDocClearDropHighlight();
const target = forgeDocFindDropTarget(e.clientX, e.clientY);
if (!target) return;
const placement = forgeDocComputeDropPlacement(target, e.clientX, e.clientY);
const addKind = e.dataTransfer.getData('text/forge-doc-add');
const moveId = e.dataTransfer.getData('text/forge-doc-move');
if (addKind) { forgeDocDropLibraryItem(addKind, placement); return; }
if (moveId) forgeDocDropExistingElement(Number.parseInt(moveId, 10), placement);
});
}
/* ---------------------------------------------------------------------
* Formes libres — position absolue, glissées à la souris (pas de HTML5
* DnD, pas de logique de rangée) avec grille d'accroche réelle.
* ------------------------------------------------------------------- */
// Même raison que FORGE_DOC_STYLE_PRESETS en tête de fichier.
// eslint-disable-next-line no-var, vars-on-top
var FORGE_DOC_SNAP_SIZE = 8;
function forgeDocBindShapeDrag(dom, elementId) {
dom.addEventListener('mousedown', (e) => {
if (window.forgeDocState.previewMode) return;
e.preventDefault();
e.stopPropagation();
forgeDocSelectElement(elementId);
const el = window.forgeDocState.elementsById[elementId];
window.forgeDocState.shapeDrag = {
id: elementId,
startX: e.clientX,
startY: e.clientY,
origX: el.attributes.x,
origY: el.attributes.y,
};
document.getElementById('docPage').classList.add('docPage--dragging-shape');
});
}
function forgeDocBindShapeDragGlobalHandlers() {
document.addEventListener('mousemove', (e) => {
const drag = window.forgeDocState.shapeDrag;
if (!drag) return;
const zoomFactor = window.forgeDocState.zoom / 100;
const dx = (e.clientX - drag.startX) / zoomFactor;
const dy = (e.clientY - drag.startY) / zoomFactor;
const newX = Math.round((drag.origX + dx) / FORGE_DOC_SNAP_SIZE) * FORGE_DOC_SNAP_SIZE;
const newY = Math.round((drag.origY + dy) / FORGE_DOC_SNAP_SIZE) * FORGE_DOC_SNAP_SIZE;
const dom = document.querySelector(`[data-element-id="${drag.id}"]`);
if (dom) { dom.style.left = `${newX}px`; dom.style.top = `${newY}px`; }
drag.lastX = newX;
drag.lastY = newY;
});
document.addEventListener('mouseup', () => {
const drag = window.forgeDocState.shapeDrag;
if (!drag) return;
window.forgeDocState.shapeDrag = null;
document.getElementById('docPage').classList.remove('docPage--dragging-shape');
if (typeof drag.lastX === 'number' && (drag.lastX !== drag.origX || drag.lastY !== drag.origY)) {
const el = window.forgeDocState.elementsById[drag.id];
forgeDocUpdateAttributes(drag.id, { ...el.attributes, x: drag.lastX, y: drag.lastY });
}
});
}
/* ---------------------------------------------------------------------
* Sélection & panneau Propriétés.
* ------------------------------------------------------------------- */
function forgeDocBindCanvasInteractions() {
document.querySelectorAll('#docPage [data-element-id]').forEach((dom) => {
const elementId = Number.parseInt(dom.dataset.elementId, 10);
dom.addEventListener('click', (e) => {
e.stopPropagation();
forgeDocSelectElement(elementId);
});
if (forgeDocIsShapeKind(dom.dataset.kind)) {
forgeDocBindShapeDrag(dom, elementId);
} else {
dom.setAttribute('draggable', 'true');
dom.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/forge-doc-move', String(elementId));
e.dataTransfer.effectAllowed = 'move';
});
}
});
}
function forgeDocSetActiveBottomNav(target) {
document.querySelectorAll('#docBottomNav button').forEach((b) => {
b.classList.toggle('is-active', b.dataset.target === target);
});
}
function forgeDocSelectElement(id) {
window.forgeDocState.selectedId = id;
forgeDocApplySelectionClass();
forgeDocRenderProps(window.forgeDocState.elementsById[id]);
if (window.innerWidth <= 900) {
document.getElementById('docSidebarLeft').classList.remove('is-open');
document.getElementById('docSidebarRight').classList.add('is-open');
forgeDocSetActiveBottomNav('right');
}
}
function forgeDocPropsHeader(label) {
return `<div class="docSectionLabel">Propriétés — ${label}</div>`;
}
function forgeDocDeleteButtonHtml() {
return '<button type="button" class="docDeleteBtn" id="docDeletePropBtn">🗑 Supprimer cet élément</button>';
}
function forgeDocRenderTextProps(panel, el) {
const a = el.attributes;
panel.innerHTML = `
${forgeDocPropsHeader('texte')}
<div class="docField">
<span class="docFieldLabel">Style</span>
<div class="docSegmented" id="docStyleSeg">
${Object.keys(FORGE_DOC_STYLE_PRESETS).map((key) => `<button type="button" class="docSegBtn ${a.style === key ? 'is-active' : ''}" data-style="${key}">${FORGE_DOC_STYLE_PRESETS[key]}</button>`).join('')}
</div>
</div>
<div class="docField"><label for="docContentInput">Contenu</label><textarea id="docContentInput" rows="3">${a.content}</textarea></div>
<div class="docField">
<span class="docFieldLabel">Mise en forme</span>
<div class="docToolbarRow">
<button type="button" class="docFmtBtn ${a.bold ? 'is-active' : ''}" id="docBoldBtn" title="Gras">B</button>
<button type="button" class="docFmtBtn docFmtBtn--italic ${a.italic ? 'is-active' : ''}" id="docItalicBtn" title="Italique">I</button>
<button type="button" class="docFmtBtn docFmtBtn--underline ${a.underline ? 'is-active' : ''}" id="docUnderlineBtn" title="Souligné">U</button>
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Alignement</span>
<div class="docToolbarRow" id="docAlignRow">
<button type="button" class="docFmtBtn ${a.align === 'left' ? 'is-active' : ''}" data-align="left">◧</button>
<button type="button" class="docFmtBtn ${a.align === 'center' ? 'is-active' : ''}" data-align="center">▣</button>
<button type="button" class="docFmtBtn ${a.align === 'right' ? 'is-active' : ''}" data-align="right">◨</button>
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Couleur du texte</span>
<div class="docSwatches" id="docColorSwatches">
${['var(--doc-text)', 'var(--doc-muted)', '#ff5f2e', '#ffb020'].map((c) => `<div class="docSwatch ${a.color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c.replace('doc-', 'forge-').replace('var(--forge-text)', 'var(--forge-text)')};"></div>`).join('')}
</div>
</div>
${forgeDocDeleteButtonHtml()}
`;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docContentInput').addEventListener('change', (e) => patch({ content: e.target.value }));
panel.querySelectorAll('#docStyleSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ style: btn.dataset.style }));
});
document.getElementById('docBoldBtn').addEventListener('click', () => patch({ bold: !a.bold }));
document.getElementById('docItalicBtn').addEventListener('click', () => patch({ italic: !a.italic }));
document.getElementById('docUnderlineBtn').addEventListener('click', () => patch({ underline: !a.underline }));
panel.querySelectorAll('#docAlignRow [data-align]').forEach((btn) => {
btn.addEventListener('click', () => patch({ align: btn.dataset.align }));
});
panel.querySelectorAll('#docColorSwatches .docSwatch').forEach((sw) => {
sw.addEventListener('click', () => patch({ color: sw.dataset.color }));
});
}
function forgeDocRenderShapeProps(panel, el) {
const a = el.attributes;
panel.innerHTML = `
${forgeDocPropsHeader('forme')}
<div class="docField"><label for="docShapeLabel">Libellé</label><input type="text" id="docShapeLabel" value="${a.label || ''}"></div>
<div class="docFieldRow"><label for="docShapeX">Position X</label><input type="number" id="docShapeX" value="${a.x}" style="width:90px;"></div>
<div class="docFieldRow"><label for="docShapeY">Position Y</label><input type="number" id="docShapeY" value="${a.y}" style="width:90px;"></div>
<div class="docFieldRow"><label for="docShapeWidth">Largeur</label><input type="number" id="docShapeWidth" value="${a.width}" style="width:90px;"></div>
<div class="docFieldRow"><label for="docShapeHeight">Hauteur</label><input type="number" id="docShapeHeight" value="${a.height}" style="width:90px;"></div>
<div class="docFieldRow"><label for="docShapeRotation">Rotation (°)</label><input type="number" id="docShapeRotation" value="${a.rotation}" style="width:90px;"></div>
<div class="docFieldRow"><label for="docShapeZ">Ordre (z-index)</label><input type="number" id="docShapeZ" value="${a.z_index}" style="width:90px;"></div>
<div class="docFieldRow"><label for="docShapeFill">Couleur de remplissage</label><input type="color" id="docShapeFill" value="${a.fill}"></div>
<div class="docFieldRow"><label for="docShapeStroke">Couleur de bordure</label><input type="color" id="docShapeStroke" value="${a.stroke}"></div>
<div class="docFieldRow"><label for="docShapeStrokeWidth">Épaisseur de bordure</label><input type="number" id="docShapeStrokeWidth" value="${a.stroke_width}" style="width:90px;"></div>
${forgeDocDeleteButtonHtml()}
`;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docShapeLabel').addEventListener('change', (e) => patch({ label: e.target.value }));
document.getElementById('docShapeX').addEventListener('change', (e) => patch({ x: Number(e.target.value) }));
document.getElementById('docShapeY').addEventListener('change', (e) => patch({ y: Number(e.target.value) }));
document.getElementById('docShapeWidth').addEventListener('change', (e) => patch({ width: Number(e.target.value) }));
document.getElementById('docShapeHeight').addEventListener('change', (e) => patch({ height: Number(e.target.value) }));
document.getElementById('docShapeRotation').addEventListener('change', (e) => patch({ rotation: Number(e.target.value) }));
document.getElementById('docShapeZ').addEventListener('change', (e) => patch({ z_index: Number(e.target.value) }));
document.getElementById('docShapeFill').addEventListener('input', (e) => patch({ fill: e.target.value }));
document.getElementById('docShapeStroke').addEventListener('input', (e) => patch({ stroke: e.target.value }));
document.getElementById('docShapeStrokeWidth').addEventListener('change', (e) => patch({ stroke_width: Number(e.target.value) }));
}
function forgeDocRenderImageProps(panel, el) {
const a = el.attributes;
panel.innerHTML = `
${forgeDocPropsHeader('image')}
<div class="docField"><label for="docImageSrc">URL de l'image</label><input type="text" id="docImageSrc" value="${a.src || ''}" placeholder="https://..."></div>
<div class="docField"><label for="docImageAlt">Texte alternatif</label><input type="text" id="docImageAlt" value="${a.alt || ''}"></div>
${forgeDocDeleteButtonHtml()}
`;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docImageSrc').addEventListener('change', (e) => patch({ src: e.target.value.trim() }));
document.getElementById('docImageAlt').addEventListener('change', (e) => patch({ alt: e.target.value }));
}
function forgeDocRenderButtonProps(panel, el) {
const a = el.attributes;
panel.innerHTML = `
${forgeDocPropsHeader('bouton')}
<div class="docField"><label for="docButtonLabel">Texte du bouton</label><input type="text" id="docButtonLabel" value="${a.label || ''}"></div>
<div class="docField"><label for="docButtonTarget">Cible (URL ou ancre)</label><input type="text" id="docButtonTarget" value="${a.target || ''}"></div>
${forgeDocDeleteButtonHtml()}
`;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docButtonLabel').addEventListener('change', (e) => patch({ label: e.target.value }));
document.getElementById('docButtonTarget').addEventListener('change', (e) => patch({ target: e.target.value.trim() }));
}
function forgeDocRenderRowProps(panel, el) {
const a = el.attributes;
panel.innerHTML = `
${forgeDocPropsHeader('rangée')}
<div class="docFieldRow"><label for="docRowGap">Espacement (px)</label><input type="number" id="docRowGap" value="${a.gap}" style="width:90px;"></div>
<div class="docField">
<span class="docFieldLabel">Alignement</span>
<div class="docSegmented" id="docRowAlign">
${['stretch', 'flex-start', 'center', 'flex-end'].map((v) => `<button type="button" class="docSegBtn ${a.align === v ? 'is-active' : ''}" data-align="${v}">${v}</button>`).join('')}
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Justification</span>
<div class="docSegmented" id="docRowJustify">
${['flex-start', 'center', 'flex-end', 'space-between'].map((v) => `<button type="button" class="docSegBtn ${a.justify === v ? 'is-active' : ''}" data-justify="${v}">${v}</button>`).join('')}
</div>
</div>
${forgeDocDeleteButtonHtml()}
`;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docRowGap').addEventListener('change', (e) => patch({ gap: Number(e.target.value) }));
panel.querySelectorAll('#docRowAlign .docSegBtn').forEach((btn) => btn.addEventListener('click', () => patch({ align: btn.dataset.align })));
panel.querySelectorAll('#docRowJustify .docSegBtn').forEach((btn) => btn.addEventListener('click', () => patch({ justify: btn.dataset.justify })));
}
function forgeDocRenderMinigameProps(panel, el) {
const a = el.attributes;
const label = window.FORGE_DOCUMENT.elementKindLabels[el.kind] || el.kind;
panel.innerHTML = `
${forgeDocPropsHeader(label)}
<div class="docFieldRow"><span class="docFieldLabel">Type</span><span style="font-size:13px;font-weight:700;">${label}</span></div>
<div class="docField">
<span class="docFieldLabel">Couleur du thème</span>
<div class="docSwatches" id="docMinigameColor">
${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `<div class="docSwatch ${a.theme_color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c};"></div>`).join('')}
</div>
</div>
<div style="font-size:12.5px;color:var(--doc-muted);">Formulaire de contenu détaillé à venir pour ce type d'élément.</div>
${forgeDocDeleteButtonHtml()}
`;
panel.querySelectorAll('#docMinigameColor .docSwatch').forEach((sw) => {
sw.addEventListener('click', () => forgeDocUpdateAttributes(el.id, { ...el.attributes, theme_color: sw.dataset.color }));
});
}
function forgeDocRenderProps(el) {
const panel = document.getElementById('docPropsPanel');
if (!el) {
panel.innerHTML = '<div class="docSectionLabel">Propriétés</div><div class="docEmptyState">Sélectionnez un élément du document pour voir ses propriétés.</div>';
return;
}
if (el.kind === 'titre' || el.kind === 'paragraphe') forgeDocRenderTextProps(panel, el);
else if (forgeDocIsShapeKind(el.kind)) forgeDocRenderShapeProps(panel, el);
else if (el.kind === 'image') forgeDocRenderImageProps(panel, el);
else if (el.kind === 'bouton') forgeDocRenderButtonProps(panel, el);
else if (el.kind === 'row') forgeDocRenderRowProps(panel, el);
else forgeDocRenderMinigameProps(panel, el);
const deleteBtn = document.getElementById('docDeletePropBtn');
if (deleteBtn) deleteBtn.addEventListener('click', () => forgeDocDeleteElement(el.id));
}
/* ---------------------------------------------------------------------
* Chrome de l'éditeur : zoom, largeur d'aperçu responsive, Aperçu,
* Publier, thème (scopé à .docEditor3), navigation mobile.
* ------------------------------------------------------------------- */
function forgeDocBindZoom() {
const docPage = document.getElementById('docPage');
document.getElementById('docZoomOut').addEventListener('click', () => {
window.forgeDocState.zoom = Math.max(50, window.forgeDocState.zoom - 10);
document.getElementById('docZoomVal').textContent = `${window.forgeDocState.zoom}%`;
docPage.style.transform = `scale(${window.forgeDocState.zoom / 100})`;
});
document.getElementById('docZoomIn').addEventListener('click', () => {
window.forgeDocState.zoom = Math.min(150, window.forgeDocState.zoom + 10);
document.getElementById('docZoomVal').textContent = `${window.forgeDocState.zoom}%`;
docPage.style.transform = `scale(${window.forgeDocState.zoom / 100})`;
});
}
function forgeDocBindWidthPicker() {
document.querySelectorAll('.docWidthBtn').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.docWidthBtn').forEach((b) => b.classList.remove('is-active'));
btn.classList.add('is-active');
const docPage = document.getElementById('docPage');
const width = btn.dataset.width;
if (!width) {
docPage.classList.remove('docPage--fixedWidth');
docPage.style.removeProperty('--doc-preview-width');
} else {
docPage.classList.add('docPage--fixedWidth');
docPage.style.setProperty('--doc-preview-width', `${width}px`);
}
});
});
}
function forgeDocBindPreviewToggle() {
document.getElementById('docPreviewBtn').addEventListener('click', () => {
window.forgeDocState.previewMode = !window.forgeDocState.previewMode;
document.getElementById('docEditor3').classList.toggle('docEditor3--preview', window.forgeDocState.previewMode);
});
}
function forgeDocBindPublish() {
document.getElementById('docPublishBtn').addEventListener('click', () => {
forgeDocApiPublish().then((result) => {
if (!result.ok) return;
const label = document.getElementById('docPublishLabel');
const btn = document.getElementById('docPublishBtn');
label.textContent = 'Publié';
btn.classList.add('is-published');
});
});
}
function forgeDocApplyTheme(theme) {
const editor = document.getElementById('docEditor3');
editor.dataset.theme = theme;
const toggle = document.getElementById('docThemeToggle');
toggle.innerHTML = theme === 'light'
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"/></svg>'
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M2 12h2M20 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/></svg>';
}
function forgeDocBindThemeToggle() {
let stored = 'dark';
try {
const saved = window.localStorage.getItem('forge-doc-editor-theme');
if (saved === 'light' || saved === 'dark') stored = saved;
} catch (e) { /* stockage indisponible, on reste sur le thème par défaut */ }
forgeDocApplyTheme(stored);
document.getElementById('docThemeToggle').addEventListener('click', () => {
const next = document.getElementById('docEditor3').dataset.theme === 'light' ? 'dark' : 'light';
forgeDocApplyTheme(next);
try { window.localStorage.setItem('forge-doc-editor-theme', next); } catch (e) { /* ignore */ }
});
}
function forgeDocBindMobileNav() {
const left = document.getElementById('docSidebarLeft');
const right = document.getElementById('docSidebarRight');
document.querySelectorAll('#docBottomNav button').forEach((btn) => {
btn.addEventListener('click', () => {
forgeDocSetActiveBottomNav(btn.dataset.target);
left.classList.toggle('is-open', btn.dataset.target === 'left');
right.classList.toggle('is-open', btn.dataset.target === 'right');
});
});
function closePanels() {
left.classList.remove('is-open');
right.classList.remove('is-open');
forgeDocSetActiveBottomNav('doc');
}
document.getElementById('docCloseLeft').addEventListener('click', closePanels);
document.getElementById('docCloseRight').addEventListener('click', closePanels);
}
function forgeDocBindDeselectOnBackgroundClick() {
document.getElementById('docCanvasArea').addEventListener('click', (e) => {
if (e.target.closest('[data-element-id]')) return;
window.forgeDocState.selectedId = null;
forgeDocApplySelectionClass();
forgeDocRenderProps(null);
});
}
function forgeDocBindKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if (!document.getElementById('docEditor3')) return;
const isUndo = (e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === 'z';
const isRedo = ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'z')
|| ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y');
if (isUndo) { e.preventDefault(); forgeDocUndo(); }
if (isRedo) { e.preventDefault(); forgeDocRedo(); }
});
}
function forgeDocInit() {
const root = document.getElementById('docEditor3');
if (!root || !window.FORGE_DOCUMENT) return;
window.forgeDocState = {
slug: window.FORGE_DOCUMENT.slug,
elements: window.FORGE_DOCUMENT.elements,
elementsById: {},
selectedId: null,
undoStack: [],
redoStack: [],
previewMode: false,
zoom: 100,
shapeDrag: null,
};
forgeDocRebuildIndex();
forgeDocBindCanvasInteractions();
forgeDocBindLibraryDragHandlers();
forgeDocBindCanvasDropZone();
forgeDocBindShapeDragGlobalHandlers();
forgeDocBindDeselectOnBackgroundClick();
forgeDocBindZoom();
forgeDocBindWidthPicker();
forgeDocBindPreviewToggle();
forgeDocBindPublish();
forgeDocBindThemeToggle();
forgeDocBindMobileNav();
forgeDocBindKeyboardShortcuts();
forgeDocUpdateHistoryButtons();
document.getElementById('docUndoBtn').addEventListener('click', forgeDocUndo);
document.getElementById('docRedoBtn').addEventListener('click', forgeDocRedo);
if (window.innerWidth <= 900) {
document.getElementById('docSidebarLeft').classList.remove('is-open');
document.getElementById('docSidebarRight').classList.remove('is-open');
}
}
forgeDocInit();
+174
View File
@@ -0,0 +1,174 @@
{% extends "base.html" %}
{% block title %}Forge Engine — {{ support.name }}{% endblock %}
{% block body_class %}objectEditBody{% endblock %}
{% block content_class %}content-wide{% endblock %}
{% block extra_head %}
<link rel="stylesheet"
href="{{ url_for('static', filename='document/document-editor.css') }}">
{% endblock %}
{% block content %}
<div class="docEditor3" id="docEditor3" data-slug="{{ support.slug }}">
<div class="docTopbar">
<div class="docCrumb">
<a href="{{ url_for('index') }}" class="docCrumbDim">Mes formations</a>
<svg width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path d="M9 6l6 6-6 6" />
</svg>
<span class="docCrumbCur">{{ support.name }}</span>
</div>
<div class="docTopbarActions">
<button type="button"
class="docIconBtn"
id="docUndoBtn"
aria-label="Annuler"
disabled
title="Annuler (Ctrl+Z)">
<svg width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round">
<path d="M8 6L4 10l4 4" />
<path d="M4 10h9a6 6 0 1 1-4.2 10.2" />
</svg>
</button>
<button type="button"
class="docIconBtn"
id="docRedoBtn"
aria-label="Rétablir"
disabled
title="Rétablir (Ctrl+Y)">
<svg width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round">
<path d="M16 6l4 4-4 4" />
<path d="M20 10h-9a6 6 0 1 0 4.2 10.2" />
</svg>
</button>
<div class="docDividerV"></div>
<button type="button" class="docBtnSecondary" id="docPreviewBtn">
<svg width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round">
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z" />
<circle cx="12" cy="12" r="3" />
</svg><span class="docBtnLabel">Aperçu</span>
</button>
<button type="button"
class="docBtnSecondary docBtnPrimary"
id="docPublishBtn">
<svg width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round">
<circle cx="12" cy="12" r="9" />
<path d="M12 16V8M8.5 11.5L12 8l3.5 3.5" />
</svg><span class="docBtnLabel" id="docPublishLabel">{{ 'Publié' if support.published_at else 'Publier' }}</span>
</button>
<div class="docDividerV"></div>
<div class="docWidthPicker"
id="docWidthPicker"
role="group"
aria-label="Largeur d'aperçu">
<button type="button" class="docWidthBtn is-active" data-width="">Bureau</button>
<button type="button" class="docWidthBtn" data-width="768">Tablette</button>
<button type="button" class="docWidthBtn" data-width="375">Mobile</button>
</div>
<div class="docDividerV"></div>
<button type="button"
class="docIconBtn docThemeToggle"
id="docThemeToggle"
aria-label="Changer de thème"></button>
</div>
</div>
<div class="docBodyWrap">
<div class="docSidebarClose" id="docCloseLeft">← Retour au document</div>
<div class="docSidebar docSidebarLeft" id="docSidebarLeft">
{% for category_key, category in element_library.items() %}
<div>
<div class="docSectionLabel">{{ category.label }}</div>
{% if category_key == "mise_en_page" %}
<div class="docShapesGrid">
{% for kind in category.kinds %}
<div class="docLibItem docLibItem--shape"
draggable="true"
data-add="{{ kind }}">
<span class="docLibIcon docLibIcon--{{ kind }}"></span>
<span>{{ element_kind_labels[kind] }}</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="docLibList">
{% for kind in category.kinds %}
<div class="docLibItem docLibItem--row"
draggable="true"
data-add="{{ kind }}">
<span class="docLibIcon docLibIcon--{{ kind }}"></span>
<span>{{ element_kind_labels[kind] }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
<div class="docCanvasArea" id="docCanvasArea">
<div class="docPage" id="docPage">
<div class="docPageContent" id="docPageContent">{{ rendered_document|safe }}</div>
{# //NOSONAR S5247 - rendered_document vient de document_engine.render_document, qui échappe (html.escape) tout contenu utilisateur avant interpolation (voir document_engine/rendering/render_document_element.py) ; jamais de HTML brut non échappé ici #}
<div class="docSnapGridOverlay" aria-hidden="true"></div>
</div>
<div class="docZoomPill">
<button type="button" id="docZoomOut" aria-label="Zoom -">−</button>
<span id="docZoomVal">100%</span>
<button type="button" id="docZoomIn" aria-label="Zoom +">+</button>
</div>
</div>
<div class="docSidebarClose" id="docCloseRight">← Retour au document</div>
<div class="docSidebar docSidebarRight" id="docSidebarRight">
<div id="docPropsPanel">
<div class="docSectionLabel">Propriétés</div>
<div class="docEmptyState">Sélectionnez un élément du document pour voir ses propriétés.</div>
</div>
</div>
</div>
<div class="docBottomNav" id="docBottomNav">
<button type="button" data-target="left">Éléments</button>
<button type="button" data-target="doc" class="is-active">Document</button>
<button type="button" data-target="right">Propriétés</button>
</div>
</div>
<script>
window.FORGE_DOCUMENT = {
slug: {{ support.slug|tojson }},
elements: {{ elements|tojson }},
elementKindLabels: {{ element_kind_labels|tojson }}
};
</script>
<script src="{{ url_for('static', filename='document/js/document-editor.js') }}"></script>
{% endblock %}
+60 -19
View File
@@ -1,44 +1,52 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Forge Engine — Mes jeux{% endblock %} {% block title %}Forge Engine — Mes formations{% endblock %}
{% 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 %}
<div class="dashPanelHeader"> <div class="dashPanelHeader">
<h2 class="title is-4 mb-0">Mes jeux</h2> <h2 class="title is-4 mb-0">Mes formations</h2>
<span class="forge-badge">{{ games|length }} jeu{{ 's' if games|length != 1 else '' }}</span> <span class="forge-badge">{{ games|length + supports|length }} projet{{ 's' if (games|length + supports|length) != 1 else '' }}</span>
</div> </div>
<div class="twoCol"> <div class="twoCol">
<div class="colLeft"> <div class="colLeft">
<h2>Créer un jeu</h2> <h2>Créer un projet</h2>
<!-- Onboarding guidé (voir routes/onboarding/onboarding_new.py) : <!-- Onboarding guidé (voir routes/onboarding/onboarding_new.py) :
comportement SYSTÉMATIQUE à chaque création de jeu, pas une comportement SYSTÉMATIQUE à chaque création de projet, pas une
formalité réservée à l'inscription — un admin y repasse à formalité réservée à l'inscription — un admin y repasse à
volonté (illimité, pas de project_slug dédié qui bloquerait le volonté (illimité, aucun slot dédié qui bloquerait le retour).
retour). Choisir un type ici, ce n'est pas juste pour un compte Un compte "user" restreint peut posséder AU PLUS un projet de
"user" : ça prépare aussi le jour où un admin (ou un compte chaque type (environnement 2D + support de formation, voir
payant multi-projet) gère plusieurs jeux de types différents. --> docs/plan/PLAN.md — deux entités racines séparées, jamais
mélangées). -->
<div class="card"> <div class="card">
<p class="hint" style="margin-top:0;">Choisis un type de jeu, nomme-le : Forge Engine crée le projet et t'envoie directement sur l'éditeur adapté.</p> <p class="hint" style="margin-top:0;">Choisis un type de projet, nomme-le : Forge Engine le crée et t'envoie directement sur l'éditeur adapté.</p>
<div class="actionsRow"><a href="{{ url_for('onboarding_new') }}" class="button primary">+ Créer un jeu</a></div> <div class="actionsRow"><a href="{{ url_for('onboarding_new') }}" class="button primary">+ Créer un projet</a></div>
</div> </div>
</div> </div>
<div class="colRight"> <div class="colRight">
<h2>Jeux existants</h2> <h2>Environnements 2D</h2>
{% if games %} {% if games %}
<div class="fieldsTableWrap"> <div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable"> <table class="table is-fullwidth is-hoverable fieldsTable">
<thead><tr><th>Nom</th><th>Dossier</th><th></th></tr></thead> <thead>
<tr>
<th>Nom</th>
<th>Dossier</th>
<th></th>
</tr>
</thead>
<tbody> <tbody>
{% for g in games %} {% for g in games %}
<tr> <tr>
<td><a href="{{ url_for('game_dashboard', slug=g.slug) }}">{{ g.name }}</a></td> <td><a href="{{ url_for('game_dashboard', slug=g.slug) }}">{{ g.name }}</a></td>
<td class="has-text-grey">projects/{{ g.slug }}/</td> <td class="has-text-grey">projects/{{ g.slug }}/</td>
<td class="fieldsTableActions"> <td class="fieldsTableActions">
<form method="post" action="{{ url_for('game_delete', slug=g.slug) }}" <form method="post"
onsubmit="return confirm('Supprimer définitivement le jeu « {{ g.name }} » et tout son contenu (dossier + base de données) ?');"> action="{{ url_for('game_delete', slug=g.slug) }}"
<button type="submit" class="button is-small is-danger" title="Supprimer ce jeu">🗑️</button> onsubmit="return confirm('Supprimer définitivement l\'environnement 2D « {{ g.name }} » et tout son contenu (dossier + base de données) ?');">
<button type="submit"
class="button is-small is-danger"
title="Supprimer cet environnement 2D">🗑️</button>
</form> </form>
</td> </td>
</tr> </tr>
@@ -47,7 +55,40 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p class="hint">Aucun jeu pour l'instant — crée le premier à gauche.</p> <p class="hint">Aucun environnement 2D pour l'instant — crée-en un à gauche.</p>
{% endif %}
<h2 class="mt-5">Supports de formation</h2>
{% if supports %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable">
<thead>
<tr>
<th>Nom</th>
<th>Dossier</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in supports %}
<tr>
<td><a href="{{ url_for('document_edit', slug=s.slug) }}">{{ s.name }}</a></td>
<td class="has-text-grey">projects/{{ s.slug }}/</td>
<td class="fieldsTableActions">
<form method="post"
action="{{ url_for('document_delete', slug=s.slug) }}"
onsubmit="return confirm('Supprimer définitivement le support « {{ s.name }} » et tout son contenu (dossier + base de données) ?');">
<button type="submit"
class="button is-small is-danger"
title="Supprimer ce support">🗑️</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="hint">Aucun support de formation pour l'instant — crée-en un à gauche.</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
+4 -4
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Forge Engine — Créer mon jeu{% endblock %} {% block title %}Forge Engine — Créer mon projet{% endblock %}
{% block body_class %}objectEditBody{% endblock %} {% block body_class %}objectEditBody{% endblock %}
{% block content_class %}content-wide onboardingPage{% endblock %} {% block content_class %}content-wide onboardingPage{% endblock %}
{% block content %} {% block content %}
@@ -12,7 +12,7 @@
<div class="onboardingTrack" id="onboardingTrack"> <div class="onboardingTrack" id="onboardingTrack">
<section class="onboardingStep onboardingStep--pick"> <section class="onboardingStep onboardingStep--pick">
<h2 class="onboardingTitle">Quel jeu veux-tu créer ?</h2> <h2 class="onboardingTitle">Que veux-tu créer ?</h2>
<div class="onboardingGrid"> <div class="onboardingGrid">
{% for key, info in onboarding_types.items() %} {% for key, info in onboarding_types.items() %}
<button type="button" class="onboardingCard" data-type="{{ key }}" data-label="{{ info.label }}"> <button type="button" class="onboardingCard" data-type="{{ key }}" data-label="{{ info.label }}">
@@ -33,9 +33,9 @@
<section class="onboardingStep onboardingStep--name"> <section class="onboardingStep onboardingStep--name">
<button type="button" class="onboardingBackBtn" id="onboardingBackBtn">← Changer de type</button> <button type="button" class="onboardingBackBtn" id="onboardingBackBtn">← Changer de type</button>
<div class="onboardingNameBox"> <div class="onboardingNameBox">
<label class="onboardingTitle" for="onboardingNameInput">Quel est le nom de votre jeu ?</label> <label class="onboardingTitle" for="onboardingNameInput">Quel est son nom ?</label>
<input type="text" name="name" id="onboardingNameInput" class="onboardingNameInput" placeholder="ex. CyberQuest RGPD" autocomplete="off"> <input type="text" name="name" id="onboardingNameInput" class="onboardingNameInput" placeholder="ex. CyberQuest RGPD" autocomplete="off">
<button type="submit" id="onboardingSubmitBtn" class="primary onboardingSubmitBtn" disabled>Créer mon jeu</button> <button type="submit" id="onboardingSubmitBtn" class="primary onboardingSubmitBtn" disabled>Créer</button>
</div> </div>
</section> </section>
+37
View File
@@ -99,6 +99,27 @@ def tmp_game_slug_cleanup() -> Generator[Callable[[str], str], None, None]:
shutil.rmtree(game_dir) shutil.rmtree(game_dir)
@pytest.fixture
def tmp_support_slug_cleanup() -> Generator[Callable[[str], str], None, None]:
"""Mirroir de tmp_game_slug_cleanup pour un support de formation
(db.create_support(...)) — système de fichiers séparé
(db.supports.support_dir), jamais db.game_dir (qui résoudrait vers le
mauvais dossier pour un slug de support)."""
import db.supports
slugs = []
def register(slug: str) -> str:
slugs.append(slug)
return slug
yield register
for slug in slugs:
support_dir = db.supports.support_dir(slug)
if os.path.isdir(support_dir):
shutil.rmtree(support_dir)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _cleanup_admin_assets() -> Generator[None, None, None]: def _cleanup_admin_assets() -> Generator[None, None, None]:
"""Filet de sécurité : le compte admin (`_TEST_ADMIN_ID`) est PARTAGÉ """Filet de sécurité : le compte admin (`_TEST_ADMIN_ID`) est PARTAGÉ
@@ -179,6 +200,22 @@ def game(client: FlaskClient) -> Generator[str, None, None]:
db.delete_game(slug) db.delete_game(slug)
@pytest.fixture
def support(client: FlaskClient) -> Generator[str, None, None]:
"""Crée un support de formation de test frais via /documents/new
(mirroir de la fixture `game` ci-dessus, pour l'autre type de projet —
entité racine séparée, voir docs/plan/PLAN.md) et le supprime après le
test, quel que soit son résultat."""
import db.supports
resp = client.post("/documents/new", data={"name": "pytest_test_support"}, follow_redirects=False)
assert resp.status_code == 302
slug = resp.headers["Location"].rstrip("/").split("/")[-2]
yield slug
if os.path.isdir(db.supports.support_dir(slug)):
db.delete_support(slug)
_STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static", "game", "js") _STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static", "game", "js")
_PLAY_JS_DIR = os.path.join(_STATIC_DIR, "play") _PLAY_JS_DIR = os.path.join(_STATIC_DIR, "play")
_SCREEN_EDIT_JS_DIR = os.path.join(_STATIC_DIR, "screen_edit") _SCREEN_EDIT_JS_DIR = os.path.join(_STATIC_DIR, "screen_edit")
+140
View File
@@ -0,0 +1,140 @@
"""CRUD des éléments d'un support de formation (document_engine/elements/)
— sans Flask, directement contre le support.db créé par db.create_support.
Couvre : CRUD complet, rangées (parent_id), réinsertion (move_document_element
vers une nouvelle rangée/position), suppression en cascade d'une rangée avec
enfants, et les attributs de position/taille/rotation d'une forme."""
from typing import Any
import db
import document_engine as doc_engine
def _new_support(tmp_support_slug_cleanup: Any, name: str = "Support de test") -> str:
slug = db.create_support(name, owner_folder="90")
tmp_support_slug_cleanup(slug)
return slug
def test_add_and_get_document_element_uses_default_attributes(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "titre")
element = doc_engine.get_document_element(slug, element_id)
assert element is not None
assert element["kind"] == "titre"
assert element["parent_id"] is None
assert element["attributes"]["content"] == "Nouveau titre"
def test_get_document_element_returns_none_for_missing_id(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
assert doc_engine.get_document_element(slug, 999) is None
def test_list_document_elements_groups_row_children_together(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
row_id = doc_engine.add_document_element(slug, "row")
top_titre_id = doc_engine.add_document_element(slug, "titre")
child_a_id = doc_engine.add_document_element(slug, "paragraphe", parent_id=row_id)
child_b_id = doc_engine.add_document_element(slug, "image", parent_id=row_id)
elements = doc_engine.list_document_elements(slug)
ids_in_order = [el["id"] for el in elements]
# Les deux top-niveau (row_id, top_titre_id) groupés avant les deux
# enfants de la rangée (child_a_id, child_b_id) — tri (parent_id, order_index).
assert ids_in_order.index(row_id) < ids_in_order.index(child_a_id)
assert ids_in_order.index(top_titre_id) < ids_in_order.index(child_a_id)
assert ids_in_order.index(child_a_id) < ids_in_order.index(child_b_id)
def test_update_document_element_attributes_replaces_whole_json(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "paragraphe")
doc_engine.update_document_element_attributes(slug, element_id, {"content": "Texte modifié", "style": "legende"})
element = doc_engine.get_document_element(slug, element_id)
assert element is not None
assert element["attributes"] == {"content": "Texte modifié", "style": "legende"}
def test_shape_default_attributes_carry_real_position_and_size(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "rectangle")
element = doc_engine.get_document_element(slug, element_id)
assert element is not None
attrs = element["attributes"]
assert attrs["x"] == 40
assert attrs["y"] == 40
assert attrs["width"] == 160
assert attrs["height"] == 100
assert attrs["rotation"] == 0
assert attrs["z_index"] == 1
doc_engine.update_document_element_attributes(
slug,
element_id,
{**attrs, "x": 120, "y": 80, "width": 200, "height": 150, "rotation": 45, "z_index": 3},
)
moved = doc_engine.get_document_element(slug, element_id)
assert moved is not None
assert moved["attributes"]["x"] == 120
assert moved["attributes"]["rotation"] == 45
assert moved["attributes"]["z_index"] == 3
def test_move_document_element_reorders_within_same_parent(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
first_id = doc_engine.add_document_element(slug, "titre")
second_id = doc_engine.add_document_element(slug, "paragraphe")
third_id = doc_engine.add_document_element(slug, "image")
doc_engine.move_document_element(slug, third_id, None, 0)
elements = doc_engine.list_document_elements(slug)
top_level = [el for el in elements if el["parent_id"] is None]
ordered_ids = [el["id"] for el in sorted(top_level, key=lambda el: el["order_index"])]
assert ordered_ids == [third_id, first_id, second_id]
def test_move_document_element_reparents_into_a_row(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
row_id = doc_engine.add_document_element(slug, "row")
lone_titre_id = doc_engine.add_document_element(slug, "titre")
doc_engine.move_document_element(slug, lone_titre_id, row_id, 0)
moved = doc_engine.get_document_element(slug, lone_titre_id)
assert moved is not None
assert moved["parent_id"] == row_id
assert moved["order_index"] == 0
def test_delete_document_element_cascades_to_row_children(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
row_id = doc_engine.add_document_element(slug, "row")
child_id = doc_engine.add_document_element(slug, "titre", parent_id=row_id)
doc_engine.delete_document_element(slug, row_id)
assert doc_engine.get_document_element(slug, row_id) is None
assert doc_engine.get_document_element(slug, child_id) is None
def test_delete_document_element_on_missing_id_is_a_noop(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
doc_engine.delete_document_element(slug, 999)
def test_render_document_includes_row_children_and_shape_position(tmp_support_slug_cleanup: Any) -> None:
slug = _new_support(tmp_support_slug_cleanup)
row_id = doc_engine.add_document_element(slug, "row")
doc_engine.add_document_element(slug, "titre", parent_id=row_id)
rect_id = doc_engine.add_document_element(slug, "rectangle")
html = doc_engine.render_document(doc_engine.list_document_elements(slug))
assert 'data-kind="row"' in html
assert 'data-kind="titre"' in html
assert f'data-element-id="{rect_id}"' in html
assert "left:40px" in html
assert "top:40px" in html
+124
View File
@@ -0,0 +1,124 @@
"""Routes HTTP du support de formation (routes/document/) — création,
édition directe (sans page intermédiaire), CRUD des éléments en AJAX,
publication, et isolation par propriétaire (core/auth_guard.py)."""
from typing import Any
from flask.testing import FlaskClient
import auth
import db
def test_documents_new_creates_a_support_not_a_game(client: FlaskClient) -> None:
resp = client.post("/documents/new", data={"name": "Sécurité incendie"}, follow_redirects=False)
assert resp.status_code == 302
assert "/document/" in resp.headers["Location"]
assert "/edit" in resp.headers["Location"]
slug = resp.headers["Location"].rstrip("/").split("/")[-2]
try:
assert db.support_meta(slug)["name"] == "Sécurité incendie"
finally:
db.delete_support(slug)
def test_document_edit_renders_directly_no_intermediate_page(client: FlaskClient, support: str) -> None:
resp = client.get(f"/document/{support}/edit")
assert resp.status_code == 200
def test_document_element_add_returns_rendered_html(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre"})
assert resp.status_code == 200
payload = resp.get_json()
assert payload["kind"] == "titre"
assert "docText" in payload["rendered_html"]
def test_document_element_add_rejects_unknown_kind(client: FlaskClient, support: str) -> None:
assert client.post(f"/document/{support}/elements/add", data={"kind": "n-importe-quoi"}).status_code == 400
def test_document_element_add_accepts_row_for_the_layout_engine(client: FlaskClient, support: str) -> None:
""" "row" n'apparaît jamais dans la bibliothèque (voir
document_engine.ELEMENT_LIBRARY) mais reste un kind valide pour cette
route — c'est le moteur de layout côté client qui en crée une à la
volée au moment d'un dépôt latéral (voir
static/document/js/document-editor.js)."""
resp = client.post(f"/document/{support}/elements/add", data={"kind": "row"})
assert resp.status_code == 200
assert resp.get_json()["kind"] == "row"
def test_document_element_update_persists_attributes(client: FlaskClient, support: str) -> None:
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe"})
element_id = add_resp.get_json()["id"]
resp = client.post(
f"/document/{support}/elements/{element_id}/update",
json={"content": "Texte mis à jour", "style": "legende"},
)
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
reread = client.post(f"/document/{support}/elements/add", data={"kind": "titre"})
assert reread.status_code == 200
def test_document_element_move_and_delete(client: FlaskClient, support: str) -> None:
first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre"}).get_json()["id"]
second_id = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe"}).get_json()["id"]
move_resp = client.post(
f"/document/{support}/elements/{second_id}/move",
json={"new_parent_id": None, "new_index": 0},
)
assert move_resp.status_code == 200
assert move_resp.get_json()["ok"] is True
delete_resp = client.post(f"/document/{support}/elements/{first_id}/delete")
assert delete_resp.status_code == 200
assert delete_resp.get_json()["ok"] is True
def test_document_publish_persists_published_at(client: FlaskClient, support: str) -> None:
assert db.support_meta(support)["published_at"] is None
resp = client.post(f"/document/{support}/publish")
assert resp.status_code == 200
published_at = resp.get_json()["published_at"]
assert published_at is not None
assert db.support_meta(support)["published_at"] == published_at
def test_restricted_user_can_have_one_game_and_one_support_at_once(
user_client: FlaskClient, tmp_game_slug_cleanup: Any, tmp_support_slug_cleanup: Any
) -> None:
with user_client.session_transaction() as sess:
user_id = sess["user_id"]
game_slug = db.create_game("Un environnement 2D", owner_folder=str(user_id))
tmp_game_slug_cleanup(game_slug)
auth.set_project_slug(user_id, game_slug)
resp = user_client.post("/documents/new", data={"name": "Un support"}, follow_redirects=False)
assert resp.status_code == 302
support_slug = resp.headers["Location"].rstrip("/").split("/")[-2]
tmp_support_slug_cleanup(support_slug)
user = auth.get_user_by_id(user_id)
assert user is not None
assert user["project_slug"] == game_slug
assert user["support_slug"] == support_slug
def test_cannot_open_another_owners_support(client: FlaskClient, user_client: FlaskClient) -> None:
"""Isolation par propriétaire (core/auth_guard.py, split_slug déjà
générique quel que soit le type de projet) — le compte admin
(`client`) ne doit pas pouvoir ouvrir le support d'un autre compte."""
with user_client.session_transaction() as sess:
victim_user_id = sess["user_id"]
victim_slug = db.create_support("Support de la victime", owner_folder=str(victim_user_id))
try:
assert client.get(f"/document/{victim_slug}/edit").status_code == 403
assert client.post(f"/document/{victim_slug}/delete").status_code == 403
finally:
db.delete_support(victim_slug)
+70
View File
@@ -0,0 +1,70 @@
"""Cycle de vie d'un support de formation (db/supports/) — mirroir des
tests de db/games/, mais vérifie en plus l'ISOLATION stricte entre les
deux systèmes : un support ne doit jamais apparaître dans db.list_games()
et un jeu ne doit jamais apparaître dans db.list_supports(), même pour le
même propriétaire."""
from typing import Any
import db
def test_create_support_creates_its_own_db_file_and_schema(tmp_support_slug_cleanup: Any) -> None:
slug = db.create_support("Sécurité incendie", owner_folder="42")
tmp_support_slug_cleanup(slug)
meta = db.support_meta(slug)
assert meta["name"] == "Sécurité incendie"
assert meta["published_at"] is None
def test_list_supports_scopes_to_owner_and_excludes_games(
tmp_support_slug_cleanup: Any, tmp_game_slug_cleanup: Any
) -> None:
owner = "43"
support_slug = db.create_support("Onboarding RH", owner_folder=owner)
tmp_support_slug_cleanup(support_slug)
game_slug = db.create_game("Un jeu du même compte", owner_folder=owner)
tmp_game_slug_cleanup(game_slug)
supports = db.list_supports(owner)
games = db.list_games(owner)
assert [s["slug"] for s in supports] == [support_slug]
assert [g["slug"] for g in games] == [game_slug]
assert support_slug not in [g["slug"] for g in games]
assert game_slug not in [s["slug"] for s in supports]
def test_list_supports_never_sees_another_owners_supports(tmp_support_slug_cleanup: Any) -> None:
slug_a = db.create_support("Support A", owner_folder="44")
tmp_support_slug_cleanup(slug_a)
slug_b = db.create_support("Support B", owner_folder="45")
tmp_support_slug_cleanup(slug_b)
assert [s["slug"] for s in db.list_supports("44")] == [slug_a]
assert [s["slug"] for s in db.list_supports("45")] == [slug_b]
def test_create_support_avoids_name_collision_for_the_same_owner(tmp_support_slug_cleanup: Any) -> None:
owner = "46"
slug_1 = db.create_support("Module 1", owner_folder=owner)
tmp_support_slug_cleanup(slug_1)
slug_2 = db.create_support("Module 1", owner_folder=owner)
tmp_support_slug_cleanup(slug_2)
assert slug_1 != slug_2
def test_mark_support_published_persists_a_timestamp(tmp_support_slug_cleanup: Any) -> None:
slug = db.create_support("À publier", owner_folder="47")
tmp_support_slug_cleanup(slug)
assert db.support_meta(slug)["published_at"] is None
published_at = db.mark_support_published(slug)
assert db.support_meta(slug)["published_at"] == published_at
def test_delete_support_removes_it_from_the_listing(tmp_support_slug_cleanup: Any) -> None:
slug = db.create_support("Temporaire", owner_folder="48")
tmp_support_slug_cleanup(slug)
assert [s["slug"] for s in db.list_supports("48")] == [slug]
db.delete_support(slug)
assert db.list_supports("48") == []
+32 -8
View File
@@ -26,7 +26,11 @@ def test_account_without_a_project_is_redirected_to_onboarding(anon_client: Flas
resp = anon_client.get("/onboarding") resp = anon_client.get("/onboarding")
assert resp.status_code == 200 assert resp.status_code == 200
assert "Créer un jeu 2D ludique" in resp.get_data(as_text=True) # Aucun des deux slots n'est encore rempli — les deux cartes sont
# proposées (voir docs/plan/PLAN.md, projet_kind de premier niveau).
html = resp.get_data(as_text=True)
assert "Environnement de formation 2D" in html
assert "Support de formation" in html
finally: finally:
_cleanup_project("needsonboarding@example.com") _cleanup_project("needsonboarding@example.com")
@@ -90,24 +94,44 @@ def test_onboarding_rejects_removed_types(anon_client: FlaskClient) -> None: #
_cleanup_project("removedtype@example.com") _cleanup_project("removedtype@example.com")
def test_revisiting_onboarding_after_completion_redirects_without_recreating(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre def test_revisiting_onboarding_after_completing_one_slot_offers_only_the_other(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
"""Les deux slots (project_slug/support_slug) sont indépendants (voir
docs/plan/PLAN.md) : avoir déjà un environnement 2D ne bloque plus
/onboarding, mais n'y propose plus que la carte encore libre — tenter
de recréer un environnement 2D via un POST direct doit rester rejeté."""
_register(anon_client, "revisit@example.com") _register(anon_client, "revisit@example.com")
_confirm_2fa(anon_client) _confirm_2fa(anon_client)
support_slug = None
try: try:
first = _complete_onboarding(anon_client, name="Premier jeu", onboarding_type="rpg") _complete_onboarding(anon_client, name="Premier jeu", onboarding_type="rpg")
first_slug = not_none(auth.get_user_by_email("revisit@example.com"))["project_slug"] first_slug = not_none(auth.get_user_by_email("revisit@example.com"))["project_slug"]
resp = anon_client.get("/onboarding", follow_redirects=False) resp = anon_client.get("/onboarding")
assert resp.status_code == 302 assert resp.status_code == 200
assert resp.headers["Location"] == first.headers["Location"] html = resp.get_data(as_text=True)
assert "Environnement de formation 2D" not in html
assert "Support de formation" in html
again = anon_client.post( again = anon_client.post(
"/onboarding", data={"onboarding_type": "rpg", "name": "Second jeu"}, follow_redirects=False "/onboarding", data={"onboarding_type": "rpg", "name": "Second jeu"}, follow_redirects=False
) )
assert again.status_code == 200
assert not_none(auth.get_user_by_email("revisit@example.com"))["project_slug"] == first_slug assert not_none(auth.get_user_by_email("revisit@example.com"))["project_slug"] == first_slug
assert again.headers["Location"] == first.headers["Location"]
second = anon_client.post(
"/onboarding", data={"onboarding_type": "support", "name": "Un support"}, follow_redirects=False
)
assert second.status_code == 302
support_slug = not_none(auth.get_user_by_email("revisit@example.com"))["support_slug"]
assert second.headers["Location"] == f"/document/{support_slug}/edit"
both_full = anon_client.get("/onboarding", follow_redirects=False)
assert both_full.status_code == 302
assert both_full.headers["Location"] == "/"
finally: finally:
_cleanup_project("revisit@example.com") _cleanup_project("revisit@example.com")
if support_slug:
db.delete_support(support_slug)
def test_admin_is_never_redirected_to_onboarding(client: FlaskClient, game: str) -> None: def test_admin_is_never_redirected_to_onboarding(client: FlaskClient, game: str) -> None:
@@ -132,7 +156,7 @@ def test_admin_can_create_several_games_via_onboarding(client: FlaskClient) -> N
# le premier projet créé, la page de choix s'affiche à nouveau. # le premier projet créé, la page de choix s'affiche à nouveau.
resp = client.get("/onboarding") resp = client.get("/onboarding")
assert resp.status_code == 200 assert resp.status_code == 200
assert "Créer un jeu 2D ludique" in resp.get_data(as_text=True) assert "Environnement de formation 2D" in resp.get_data(as_text=True)
resp2 = _complete_onboarding(client, name="Jeu RPG Admin 2", onboarding_type="rpg") resp2 = _complete_onboarding(client, name="Jeu RPG Admin 2", onboarding_type="rpg")
assert resp2.status_code == 302 assert resp2.status_code == 302