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
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:
co-authored by
Claude Sonnet 5
parent
7e4a761cbb
commit
bab581737a
+11
-2
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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})
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -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 {}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Generated
+586
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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})
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -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))
|
||||||
@@ -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})
|
||||||
@@ -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
@@ -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),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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") == []
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user