Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y

Config strictement stricte partout (ruff, mypy --strict, bandit, vulture,
import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas
casser le build" - l'existant a ete corrige pour la satisfaire plutot que
l'inverse. Hooks pre-commit locaux (language: system) bloquants.

- Typage mypy --strict propage a tout le moteur (db, screens, auth, core,
  ai, routes, puis publish/scripts/tests/app.py/build_css.py).
- Securite : fuite de handle fichier Windows corrigee dans l'export SCORM
  (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes
  (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe
  "</script>" dans le JSON embarque en <script>, 25 sites).
- Architecture : imports circulaires/F811 nettoyes, contrats
  import-linter respectes, code mort retire (vulture).
- Accessibilite : 69 champs de formulaire sans label correctement
  associe corriges (for/id ou aria-label) sur 11 templates.
- ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis
  appliquees (aucune desactivee sans verification individuelle).
- Tests : isolation du compte admin partage (nettoyage ponctuel +
  fixture de teardown automatique en filet de securite), suite complete
  verte (591 tests Python, 241 tests JS).
- SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport
  complet analyse point par point, faux positifs documentes.
- .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine
  faisait echouer ESLint (linebreak-style) via un bug connu de git
  (checkout "en place" qui ignore l'eol force sur un fichier deja
  present sur disque - contourne en supprimant puis recreant chaque
  fichier suivi).

djLint (H021, styles inline) volontairement saute pour ce commit -
backlog assume, deja documente, traite dans un lot separe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-15 16:06:15 +02:00
co-authored by Claude Sonnet 5
parent 7db4803b93
commit c57420c8c9
454 changed files with 16448 additions and 6967 deletions
+161 -165
View File
@@ -1,49 +1,52 @@
function initBuilderPanel() {
(function () {
var xEl = document.getElementById('pos-x'), yEl = document.getElementById('pos-y');
var wEl = document.getElementById('pos-w'), hEl = document.getElementById('pos-h');
const xEl = document.getElementById('pos-x'); const
yEl = document.getElementById('pos-y');
const wEl = document.getElementById('pos-w'); const
hEl = document.getElementById('pos-h');
// pos-w/pos-h n'existent pas pour un élément posé dans un conteneur (sa
// taille se règle via les champs "Largeur/Hauteur (%)"/"fixe (px)" du
// formulaire général à la place, voir size_ctrls plus haut) — X/Y non
// plus, mais ils restent dans le DOM (juste désactivés) pour cet élément.
if (!xEl || !wEl) return;
function apply() {
var canvasEl = document.getElementById('el-' + CURRENT_SELECTED_ID);
var x = parseFloat(xEl.value) || 0, y = parseFloat(yEl.value) || 0;
var w = parseFloat(wEl.value) || 1, h = parseFloat(hEl.value) || 1;
const canvasEl = document.getElementById(`el-${CURRENT_SELECTED_ID}`);
const x = parseFloat(xEl.value) || 0; const
y = parseFloat(yEl.value) || 0;
const w = parseFloat(wEl.value) || 1; const
h = parseFloat(hEl.value) || 1;
if (canvasEl) {
canvasEl.style.left = x + '%'; canvasEl.style.top = y + '%';
canvasEl.style.width = w + '%'; canvasEl.style.height = h + '%';
canvasEl.style.left = `${x}%`; canvasEl.style.top = `${y}%`;
canvasEl.style.width = `${w}%`; canvasEl.style.height = `${h}%`;
}
saveGeometry(CURRENT_SELECTED_ID, x, y, w, h);
}
[xEl, yEl, wEl, hEl].forEach(function (el) { el.addEventListener('change', apply); });
})();
[xEl, yEl, wEl, hEl].forEach((el) => { el.addEventListener('change', apply); });
}());
bindAspectButtons();
bindScreenBackgroundMusicField();
document.querySelectorAll('.uploadInput').forEach(function (input) {
input.addEventListener('change', function () {
document.querySelectorAll('.uploadInput').forEach((input) => {
input.addEventListener('change', () => {
if (!input.files.length) return;
var status = input.parentElement.querySelector('.uploadStatus');
var targetField = document.getElementById(input.dataset.target);
const status = input.parentElement.querySelector('.uploadStatus');
const targetField = document.getElementById(input.dataset.target);
status.textContent = 'Envoi en cours...';
var data = new FormData();
const data = new FormData();
data.append('file', input.files[0]);
fetch("/game/" + GAME_SLUG + "/upload", { method: 'POST', body: data })
.then(function (r) { return r.json(); })
.then(function (json) {
fetch(`/game/${GAME_SLUG}/upload`, { method: 'POST', body: data })
.then((r) => r.json())
.then((json) => {
if (json.url) {
targetField.value = json.url;
status.textContent = 'Fichier envoyé — Enregistrement...';
// Enregistre tout de suite, comme n'importe quel autre réglage —
// un fichier envoyé ne doit jamais rester "en attente".
submitPropsForm().then(function () { status.textContent = 'Fichier envoyé — Enregistré.'; });
}
else { status.textContent = "Échec de l'envoi."; }
submitPropsForm().then(() => { status.textContent = 'Fichier envoyé — Enregistré.'; });
} else { status.textContent = "Échec de l'envoi."; }
})
.catch(function () { status.textContent = "Échec de l'envoi."; });
.catch(() => { status.textContent = "Échec de l'envoi."; });
});
});
@@ -52,21 +55,20 @@ function initBuilderPanel() {
// élément), il n'y a pas de formulaire à autosauvegarder ici — le champ
// texte est simplement rempli, la valeur part avec le reste du nœud au
// clic sur "Créer" (voir submitNodeForm()).
document.querySelectorAll('.nodeFormUploadInput').forEach(function (input) {
input.addEventListener('change', function () {
document.querySelectorAll('.nodeFormUploadInput').forEach((input) => {
input.addEventListener('change', () => {
if (!input.files.length) return;
var status = input.parentElement.querySelector('.uploadStatus');
var targetField = document.getElementById(input.dataset.target);
const status = input.parentElement.querySelector('.uploadStatus');
const targetField = document.getElementById(input.dataset.target);
status.textContent = 'Envoi en cours...';
var data = new FormData();
const data = new FormData();
data.append('file', input.files[0]);
fetch("/game/" + GAME_SLUG + "/upload", { method: 'POST', body: data })
.then(function (r) { return r.json(); })
.then(function (json) {
if (json.url) { targetField.value = json.url; status.textContent = 'Fichier envoyé.'; }
else { status.textContent = "Échec de l'envoi."; }
fetch(`/game/${GAME_SLUG}/upload`, { method: 'POST', body: data })
.then((r) => r.json())
.then((json) => {
if (json.url) { targetField.value = json.url; status.textContent = 'Fichier envoyé.'; } else { status.textContent = "Échec de l'envoi."; }
})
.catch(function () { status.textContent = "Échec de l'envoi."; });
.catch(() => { status.textContent = "Échec de l'envoi."; });
});
});
@@ -77,7 +79,7 @@ function initBuilderPanel() {
bindClauseListDefinitionSelect('field-data_definition_id');
bindClauseListRows();
initFilterValuePickers();
var visCondModeSel = document.getElementById('field-visibilite_cond_mode');
const visCondModeSel = document.getElementById('field-visibilite_cond_mode');
if (visCondModeSel) toggleVisCondFields(visCondModeSel);
restoreTreeCollapsedState();
applySelectionHighlight(CURRENT_SELECTED_ID);
@@ -97,27 +99,27 @@ function initBuilderPanel() {
// un champ "relation" (ex. "parcour (→ parcours)"), même règle que
// _field_label() côté Python (controls_with_values.py).
function fieldOptionLabel(f) {
return (f.type === 'relation' && f.relation_definition_name) ? (f.name + ' (→ ' + f.relation_definition_name + ')') : f.name;
return (f.type === 'relation' && f.relation_definition_name) ? (`${f.name} (→ ${f.relation_definition_name})`) : f.name;
}
function bindJaugeDefinitionSelect() {
var defSel = document.getElementById('field-definition_id');
var rowSel = document.getElementById('field-row_id');
var champSel = document.getElementById('field-champ');
var champNomSel = document.getElementById('field-champ_nom');
const defSel = document.getElementById('field-definition_id');
const rowSel = document.getElementById('field-row_id');
const champSel = document.getElementById('field-champ');
const champNomSel = document.getElementById('field-champ_nom');
if (!defSel || !rowSel || !champSel || !champNomSel) return; // pas la Jauge
defSel.addEventListener('change', function () {
var info = (DEFINITIONS_DATA || {})[defSel.value] || { rows: [], fields: [] };
defSel.addEventListener('change', () => {
const info = (DEFINITIONS_DATA || {})[defSel.value] || { rows: [], fields: [] };
rowSel.innerHTML = info.rows.length
? info.rows.map(function (r) { return '<option value="' + r.id + '">' + r.label + '</option>'; }).join('')
? info.rows.map((r) => `<option value="${r.id}">${r.label}</option>`).join('')
: '<option value="">Aucune donnée dans cet objet</option>';
// La Jauge affiche une valeur/un nom tel quel — un champ "relation" (un
// id, pas une valeur) n'a pas sa place ici, même exclusion que côté
// serveur (voir controls_with_values.py).
var simpleFields = info.fields.filter(function (f) { return f.type !== 'relation'; });
var fieldOptions = simpleFields.map(function (f) { return '<option value="' + f.name + '">' + f.name + '</option>'; }).join('');
const simpleFields = info.fields.filter((f) => f.type !== 'relation');
const fieldOptions = simpleFields.map((f) => `<option value="${f.name}">${f.name}</option>`).join('');
champSel.innerHTML = fieldOptions || '<option value="">Aucun champ simple</option>';
champNomSel.innerHTML = '<option value="">— aucun —</option>' + fieldOptions;
champNomSel.innerHTML = `<option value="">— aucun —</option>${fieldOptions}`;
});
}
@@ -133,24 +135,24 @@ function bindJaugeDefinitionSelect() {
// importe, seul le mode courant est lu par is_element_visible()) mais sont
// masqués pour ne pas embrouiller. ----------
function toggleVisCondFields(sel) {
var mode = sel.value;
document.querySelectorAll('.visCondRow').forEach(function (row) {
var scope = row.dataset.visCondScope;
var show = scope === 'any' ? !!mode : scope === mode;
const mode = sel.value;
document.querySelectorAll('.visCondRow').forEach((row) => {
const scope = row.dataset.visCondScope;
const show = scope === 'any' ? !!mode : scope === mode;
row.style.display = show ? '' : 'none';
});
}
function bindDefinitionFieldSelects(defSelId, fieldSelIds) {
var defSel = document.getElementById(defSelId);
const defSel = document.getElementById(defSelId);
if (!defSel) return;
var fieldSels = fieldSelIds.map(function (id) { return document.getElementById(id); }).filter(Boolean);
const fieldSels = fieldSelIds.map((id) => document.getElementById(id)).filter(Boolean);
if (!fieldSels.length) return;
defSel.addEventListener('change', function () {
var info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
var opts = '<option value="">(sélectionner un objet d\'abord)</option>' +
info.fields.map(function (f) { return '<option value="' + f.name + '">' + fieldOptionLabel(f) + '</option>'; }).join('');
fieldSels.forEach(function (sel) { sel.innerHTML = opts; });
defSel.addEventListener('change', () => {
const info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
const opts = `<option value="">(sélectionner un objet d'abord)</option>${
info.fields.map((f) => `<option value="${f.name}">${fieldOptionLabel(f)}</option>`).join('')}`;
fieldSels.forEach((sel) => { sel.innerHTML = opts; });
});
}
@@ -162,15 +164,15 @@ function bindDefinitionFieldSelects(defSelId, fieldSelIds) {
// sans aller-retour serveur — même principe que "fieldsList"/"varValueRows"
// ailleurs dans l'outil. ----------
function wireClauseRow(row, key) {
row.querySelectorAll('input, select').forEach(function (field) {
var debounced = field.tagName === 'INPUT';
field.addEventListener(debounced ? 'input' : 'change', function () {
row.querySelectorAll('input, select').forEach((field) => {
const debounced = field.tagName === 'INPUT';
field.addEventListener(debounced ? 'input' : 'change', () => {
if (debounced) debouncedSubmitPropsForm(); else submitPropsForm();
});
});
var removeBtn = row.querySelector('.clauseRemoveBtn');
const removeBtn = row.querySelector('.clauseRemoveBtn');
if (removeBtn) {
removeBtn.addEventListener('click', function () {
removeBtn.addEventListener('click', () => {
row.remove();
updateClauseCombinatorVisibility(key);
submitPropsForm();
@@ -179,25 +181,25 @@ function wireClauseRow(row, key) {
}
function updateClauseCombinatorVisibility(key) {
var wrap = document.getElementById('clauseListWrap-' + key);
var combRow = document.getElementById('clauseCombinatorRow-' + key);
const wrap = document.getElementById(`clauseListWrap-${key}`);
const combRow = document.getElementById(`clauseCombinatorRow-${key}`);
if (!wrap || !combRow) return;
combRow.style.display = wrap.querySelectorAll('.clauseRow').length > 1 ? '' : 'none';
}
function addClauseRow(key) {
var tmpl = document.getElementById('clauseRowTemplate-' + key);
var wrap = document.getElementById('clauseListWrap-' + key);
const tmpl = document.getElementById(`clauseRowTemplate-${key}`);
const wrap = document.getElementById(`clauseListWrap-${key}`);
if (!tmpl || !wrap) return;
var clone = tmpl.content.cloneNode(true);
var row = clone.querySelector('.clauseRow');
const clone = tmpl.content.cloneNode(true);
const row = clone.querySelector('.clauseRow');
wireClauseRow(row, key);
wrap.appendChild(clone);
// Repeuple tout de suite son sélecteur "Champ" avec les VRAIS champs de
// l'objet déjà choisi (sans ça, une ligne ajoutée après coup resterait
// bloquée sur "(sélectionner un objet d'abord)" même si un objet est
// déjà réglé plus haut dans le panneau).
var defSel = document.getElementById('field-data_definition_id');
const defSel = document.getElementById('field-data_definition_id');
if (defSel && defSel.value) defSel.dispatchEvent(new Event('change'));
// Initialise le sélecteur de valeur (fixe/dynamique/variable) de cette
// nouvelle ligne — sans effet sur les lignes déjà initialisées.
@@ -210,9 +212,9 @@ function addClauseRow(key) {
// panneau, ex. sélection d'un autre élément) — les lignes ajoutées EN
// COURS de session par addClauseRow() se branchent déjà elles-mêmes.
function bindClauseListRows() {
document.querySelectorAll('.clauseListControl').forEach(function (control) {
var key = control.dataset.clauseKey;
control.querySelectorAll('.clauseRow').forEach(function (row) { wireClauseRow(row, key); });
document.querySelectorAll('.clauseListControl').forEach((control) => {
const key = control.dataset.clauseKey;
control.querySelectorAll('.clauseRow').forEach((row) => { wireClauseRow(row, key); });
});
}
@@ -222,14 +224,14 @@ function bindClauseListRows() {
// bindDefinitionFieldSelects ci-dessus, généralisé pour un nombre de lignes
// variable plutôt qu'une liste fixe d'ids capturée une fois pour toutes.
function bindClauseListDefinitionSelect(defSelId) {
var defSel = document.getElementById(defSelId);
const defSel = document.getElementById(defSelId);
if (!defSel) return;
defSel.addEventListener('change', function () {
var info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
var opts = '<option value="">(sélectionner un objet d\'abord)</option>' +
info.fields.map(function (f) { return '<option value="' + f.name + '">' + fieldOptionLabel(f) + '</option>'; }).join('');
document.querySelectorAll('.clauseChampSelect').forEach(function (sel) {
var current = sel.value;
defSel.addEventListener('change', () => {
const info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
const opts = `<option value="">(sélectionner un objet d'abord)</option>${
info.fields.map((f) => `<option value="${f.name}">${fieldOptionLabel(f)}</option>`).join('')}`;
document.querySelectorAll('.clauseChampSelect').forEach((sel) => {
const current = sel.value;
sel.innerHTML = opts;
sel.value = current; // reste sélectionné si ce champ existe encore dans le nouvel objet
});
@@ -247,51 +249,49 @@ function bindClauseListDefinitionSelect(defSelId) {
// taper à la main. OPEN/CLOSE évitent d'écrire les deux accolades l'une
// contre l'autre dans le source (Jinja les interpréterait comme le DÉBUT
// d'une expression s'il les rencontrait telles quelles).
var _FILTER_REF_OPEN = '{' + '{';
var _FILTER_REF_CLOSE = '}' + '}';
var _FILTER_REF_RE = new RegExp('^\\{\\{\\s*([^.{}]+)\\.([^.{}]+)\\s*\\}\\}$');
const _FILTER_REF_OPEN = '{' + '{';
const _FILTER_REF_CLOSE = '}' + '}';
const _FILTER_REF_RE = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/;
// Une paire d'accolades doublées entourant "$nom_variable" (voir
// _VAR_REF_PATTERN côté Python, filter_repeater_rows.py) référence une
// variable globale — le "$" la distingue sans ambiguïté de _FILTER_REF_RE
// ci-dessus, qui attend toujours un point ("Objet.champ"). Groupe 2 : un
// chemin optionnel ".champ"/"[index]" (chaînable) à l'intérieur de la
// variable, utile pour une variable "objet"/"tableau".
var _VAR_REF_RE = new RegExp('^\\{\\{\\s*\\$([^.{}\\[\\]]+)((?:\\.[^.{}\\[\\]]+|\\[\\d+\\])*)\\s*\\}\\}$');
const _VAR_REF_RE = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/;
function _filterValueDefinitionId(objName) {
var opt = document.querySelector('.filterValueObjSel option[value="' + CSS.escape(objName) + '"]');
const opt = document.querySelector(`.filterValueObjSel option[value="${CSS.escape(objName)}"]`);
return opt ? opt.dataset.definitionId : null;
}
function populateFilterValueFields(fieldSel, defId, selectedName) {
var info = (DEFINITIONS_DATA || {})[defId] || { fields: [] };
fieldSel.innerHTML = '<option value="">— champ —</option>' + info.fields.map(function (f) {
return '<option value="' + f.name + '"' + (f.name === selectedName ? ' selected' : '') + '>' + fieldOptionLabel(f) + '</option>';
}).join('');
const info = (DEFINITIONS_DATA || {})[defId] || { fields: [] };
fieldSel.innerHTML = `<option value="">— champ —</option>${info.fields.map((f) => `<option value="${f.name}"${f.name === selectedName ? ' selected' : ''}>${fieldOptionLabel(f)}</option>`).join('')}`;
}
function updateFilterValueFromDynamic(wrap) {
var objSel = wrap.querySelector('.filterValueObjSel');
var fieldSel = wrap.querySelector('.filterValueFieldSel');
var input = wrap.querySelector('.filterValueFixe input');
input.value = (objSel.value && fieldSel.value) ? (_FILTER_REF_OPEN + objSel.value + '.' + fieldSel.value + _FILTER_REF_CLOSE) : '';
const objSel = wrap.querySelector('.filterValueObjSel');
const fieldSel = wrap.querySelector('.filterValueFieldSel');
const input = wrap.querySelector('.filterValueFixe input');
input.value = (objSel.value && fieldSel.value) ? (`${_FILTER_REF_OPEN + objSel.value}.${fieldSel.value}${_FILTER_REF_CLOSE}`) : '';
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function updateFilterValueFromVariable(wrap) {
var varSel = wrap.querySelector('.filterValueVarSel');
var pathInput = wrap.querySelector('.filterValueVarPath');
var input = wrap.querySelector('.filterValueFixe input');
var path = pathInput ? pathInput.value.trim() : '';
input.value = varSel.value ? (_FILTER_REF_OPEN + '$' + varSel.value + path + _FILTER_REF_CLOSE) : '';
const varSel = wrap.querySelector('.filterValueVarSel');
const pathInput = wrap.querySelector('.filterValueVarPath');
const input = wrap.querySelector('.filterValueFixe input');
const path = pathInput ? pathInput.value.trim() : '';
input.value = varSel.value ? (`${_FILTER_REF_OPEN}$${varSel.value}${path}${_FILTER_REF_CLOSE}`) : '';
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function onFilterValueModeChange(sel) {
var wrap = sel.closest('.filterValueControl');
var fixe = wrap.querySelector('.filterValueFixe');
var dyn = wrap.querySelector('.filterValueDynamique');
var varWrap = wrap.querySelector('.filterValueVariable');
const wrap = sel.closest('.filterValueControl');
const fixe = wrap.querySelector('.filterValueFixe');
const dyn = wrap.querySelector('.filterValueDynamique');
const varWrap = wrap.querySelector('.filterValueVariable');
fixe.style.display = sel.value === 'fixe' ? 'block' : 'none';
dyn.style.display = sel.value === 'dynamique' ? 'block' : 'none';
varWrap.style.display = sel.value === 'variable' ? 'block' : 'none';
@@ -300,7 +300,7 @@ function onFilterValueModeChange(sel) {
}
function onFilterValueObjChange(sel) {
var wrap = sel.closest('.filterValueControl');
const wrap = sel.closest('.filterValueControl');
populateFilterValueFields(wrap.querySelector('.filterValueFieldSel'), _filterValueDefinitionId(sel.value), '');
updateFilterValueFromDynamic(wrap);
}
@@ -314,15 +314,15 @@ function onFilterValueVarChange(sel) {
}
function initFilterValuePickers() {
document.querySelectorAll('.filterValueControl').forEach(function (wrap) {
var modeSel = wrap.querySelector('.filterValueMode');
var objSel = wrap.querySelector('.filterValueObjSel');
var fieldSel = wrap.querySelector('.filterValueFieldSel');
var varSel = wrap.querySelector('.filterValueVarSel');
var varPathInput = wrap.querySelector('.filterValueVarPath');
var current = (wrap.dataset.currentValue || '').trim();
var varMatch = _VAR_REF_RE.exec(current);
var objMatch = _FILTER_REF_RE.exec(current);
document.querySelectorAll('.filterValueControl').forEach((wrap) => {
const modeSel = wrap.querySelector('.filterValueMode');
const objSel = wrap.querySelector('.filterValueObjSel');
const fieldSel = wrap.querySelector('.filterValueFieldSel');
const varSel = wrap.querySelector('.filterValueVarSel');
const varPathInput = wrap.querySelector('.filterValueVarPath');
const current = (wrap.dataset.currentValue || '').trim();
const varMatch = _VAR_REF_RE.exec(current);
const objMatch = _FILTER_REF_RE.exec(current);
if (varMatch) {
modeSel.value = 'variable';
wrap.querySelector('.filterValueFixe').style.display = 'none';
@@ -349,34 +349,31 @@ function initFilterValuePickers() {
// jour automatiquement, sans qu'on ait besoin d'un second écouteur ici.
initBuilderPanel();
var CONDITION_OPERATORS_HTML = document.getElementById('nf-cond-operator') ? document.getElementById('nf-cond-operator').innerHTML : '';
var GLOBAL_VARIABLES_OPTIONS_HTML = document.getElementById('nf-cond-variable') ? document.getElementById('nf-cond-variable').innerHTML : '';
function toggleActionFields(sel) {
var form = sel.closest('.actionAddForm');
var screenSel = form.querySelector('.targetScreenSelect');
var elementFields = form.querySelector('.targetElementFields');
var dataFields = form.querySelector('.targetDataFields');
const form = sel.closest('.actionAddForm');
const screenSel = form.querySelector('.targetScreenSelect');
const elementFields = form.querySelector('.targetElementFields');
const dataFields = form.querySelector('.targetDataFields');
screenSel.style.display = sel.value === 'aller_a' ? 'block' : 'none';
elementFields.style.display = sel.value === 'modifier_element' ? 'block' : 'none';
dataFields.style.display = sel.value === 'modifier_donnee' ? 'block' : 'none';
if (sel.value === 'modifier_element') {
var propSel = elementFields.querySelector('.elementPropertySelect');
const propSel = elementFields.querySelector('.elementPropertySelect');
toggleElementPropertyValue(propSel);
}
if (sel.value === 'modifier_donnee') {
var defSel = dataFields.querySelector('.targetDefinitionSelect');
const defSel = dataFields.querySelector('.targetDefinitionSelect');
if (defSel) onDataDefinitionChange(defSel);
}
}
document.querySelectorAll('.actionTypeSelect').forEach(toggleActionFields);
function toggleElementPropertyValue(sel) {
var wrap = sel.closest('.targetElementFields');
var visSel = wrap.querySelector('.elementValueVisibility');
var colorInp = wrap.querySelector('.elementValueColor');
var numInp = wrap.querySelector('.elementValueNumber');
[visSel, colorInp, numInp].forEach(function(el){ el.style.display = 'none'; el.removeAttribute('name'); });
const wrap = sel.closest('.targetElementFields');
const visSel = wrap.querySelector('.elementValueVisibility');
const colorInp = wrap.querySelector('.elementValueColor');
const numInp = wrap.querySelector('.elementValueNumber');
[visSel, colorInp, numInp].forEach((el) => { el.style.display = 'none'; el.removeAttribute('name'); });
if (sel.value === 'visibilite') {
visSel.style.display = 'block'; visSel.setAttribute('name', 'element_value');
} else if (sel.value === 'bg_color' || sel.value === 'border_color') {
@@ -388,22 +385,22 @@ function toggleElementPropertyValue(sel) {
document.querySelectorAll('.elementPropertySelect').forEach(toggleElementPropertyValue);
function onDataDefinitionChange(sel) {
var wrap = sel.closest('.targetDataFields');
var info = DEFINITIONS_DATA[sel.value] || {rows: [], fields: []};
var rowSel = wrap.querySelector('.targetRowSelect');
var fieldSel = wrap.querySelector('.targetFieldSelect');
const wrap = sel.closest('.targetDataFields');
const info = DEFINITIONS_DATA[sel.value] || { rows: [], fields: [] };
const rowSel = wrap.querySelector('.targetRowSelect');
const fieldSel = wrap.querySelector('.targetFieldSelect');
if (!rowSel || !fieldSel) return;
rowSel.innerHTML = info.rows.length
? info.rows.map(function(r){ return '<option value="' + r.id + '">' + r.label + '</option>'; }).join('')
? info.rows.map((r) => `<option value="${r.id}">${r.label}</option>`).join('')
: '<option value="">Aucune donnée dans cet objet</option>';
fieldSel.innerHTML = info.fields.length
? info.fields.map(function(f){ return '<option value="' + f.name + '">' + f.name + ' (' + f.type + ')</option>'; }).join('')
? info.fields.map((f) => `<option value="${f.name}">${f.name} (${f.type})</option>`).join('')
: '<option value="">Aucun champ simple</option>';
}
document.querySelectorAll('.targetDefinitionSelect').forEach(onDataDefinitionChange);
function syncAlign(radio) {
radio.closest('.alignGroup').querySelectorAll('.alignOption').forEach(function(o){ o.classList.remove('active', 'is-primary'); });
radio.closest('.alignGroup').querySelectorAll('.alignOption').forEach((o) => { o.classList.remove('active', 'is-primary'); });
radio.closest('.alignOption').classList.add('active', 'is-primary');
}
@@ -418,69 +415,70 @@ function syncAlign(radio) {
// format d'aperçu ci-dessous), plus un bouton d'envoi de fichier qui
// remplit le champ URL puis déclenche le même enregistrement.
function saveScreenBackgroundMusic(url) {
return fetch("/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/background-music", {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url }),
return fetch(`/game/${GAME_SLUG}/screens/${SCREEN_ID}/background-music`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
}
function bindScreenBackgroundMusicField() {
var urlInput = document.getElementById('screen-bg-music-url');
const urlInput = document.getElementById('screen-bg-music-url');
if (urlInput) {
urlInput.addEventListener('change', function () { saveScreenBackgroundMusic(urlInput.value); });
urlInput.addEventListener('change', () => { saveScreenBackgroundMusic(urlInput.value); });
}
document.querySelectorAll('.screenUploadInput').forEach(function (input) {
input.addEventListener('change', function () {
document.querySelectorAll('.screenUploadInput').forEach((input) => {
input.addEventListener('change', () => {
if (!input.files.length) return;
var status = input.parentElement.querySelector('.uploadStatus');
var targetField = document.getElementById(input.dataset.target);
const status = input.parentElement.querySelector('.uploadStatus');
const targetField = document.getElementById(input.dataset.target);
status.textContent = 'Envoi en cours...';
var data = new FormData();
const data = new FormData();
data.append('file', input.files[0]);
fetch("/game/" + GAME_SLUG + "/upload", { method: 'POST', body: data })
.then(function (r) { return r.json(); })
.then(function (json) {
fetch(`/game/${GAME_SLUG}/upload`, { method: 'POST', body: data })
.then((r) => r.json())
.then((json) => {
if (json.url) {
targetField.value = json.url;
status.textContent = 'Fichier envoyé — Enregistrement...';
saveScreenBackgroundMusic(json.url).then(function () { status.textContent = 'Fichier envoyé — Enregistré.'; });
}
else { status.textContent = "Échec de l'envoi."; }
saveScreenBackgroundMusic(json.url).then(() => { status.textContent = 'Fichier envoyé — Enregistré.'; });
} else { status.textContent = "Échec de l'envoi."; }
})
.catch(function () { status.textContent = "Échec de l'envoi."; });
.catch(() => { status.textContent = "Échec de l'envoi."; });
});
});
}
function bindAspectButtons() {
document.querySelectorAll('.aspectBtn').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('.aspectBtn').forEach(function (b) { b.classList.remove('active'); });
document.querySelectorAll('.aspectBtn').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.aspectBtn').forEach((b) => { b.classList.remove('active'); });
btn.classList.add('active');
// Dans l'éditeur (≥1300px), le canevas remplit tout .canvasFrame
// (voir .canvas dans style.css) — cet aspect-ratio ne sert donc plus
// qu'au repli en page empilée (<1300px, voir le media query) et à
// l'aperçu jouable ("Jouer"), pas à la mise en page ici.
document.getElementById('canvas').style.aspectRatio = btn.dataset.aspect;
fetch("/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/aspect", {
method: 'POST', headers: { 'Content-Type': 'application/json' },
fetch(`/game/${GAME_SLUG}/screens/${SCREEN_ID}/aspect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aspect: btn.dataset.aspect }),
});
});
});
}
document.querySelectorAll('.uploadInput').forEach(function(input){
input.addEventListener('change', function(){
document.querySelectorAll('.uploadInput').forEach((input) => {
input.addEventListener('change', () => {
if (!input.files.length) return;
var status = input.parentElement.querySelector('.uploadStatus');
var targetField = document.getElementById(input.dataset.target);
const status = input.parentElement.querySelector('.uploadStatus');
const targetField = document.getElementById(input.dataset.target);
status.textContent = 'Envoi en cours...';
var data = new FormData();
const data = new FormData();
data.append('file', input.files[0]);
fetch("/game/" + GAME_SLUG + "/upload", {method: 'POST', body: data})
.then(function(r){ return r.json(); })
.then(function(json){
fetch(`/game/${GAME_SLUG}/upload`, { method: 'POST', body: data })
.then((r) => r.json())
.then((json) => {
if (json.url) {
targetField.value = json.url;
status.textContent = 'Fichier envoyé — Enregistrement...';
@@ -488,12 +486,10 @@ document.querySelectorAll('.uploadInput').forEach(function(input){
// qu'un fichier envoyé ne reste jamais "en attente" si on oublie
// de cliquer sur le bouton ensuite — l'image doit apparaître dès
// l'envoi, sans étape supplémentaire à retenir.
var form = document.getElementById('propsForm');
const form = document.getElementById('propsForm');
if (form) { form.requestSubmit ? form.requestSubmit() : form.submit(); }
}
else { status.textContent = "Échec de l'envoi."; }
} else { status.textContent = "Échec de l'envoi."; }
})
.catch(function(){ status.textContent = "Échec de l'envoi."; });
.catch(() => { status.textContent = "Échec de l'envoi."; });
});
});