Build and deploy / test-python (push) Successful in 11m12s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / lint-python (push) Successful in 3m56s
Build and deploy / lint-js (push) Successful in 3m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m58s
Prepare la scission a venir entre l'editeur Jeu 2D et le futur editeur Support de formation (voir docs/plan/PLAN.md), sans toucher a l'architecture en couches existante : - screens/ renomme en game_engine/ (nom clair pour le moteur du jeu 2D, avant l'arrivee d'un second "moteur" cote document) : ~85 imports corriges, contrat import-linter mis a jour, meme forme de couches. - routes/, scripts/, static/, templates/, tests/ : tout ce qui est propre au jeu 2D deplace dans un sous-dossier game/ de chacun (routes/game/, static/game/, templates/game/, tests/game/, scripts/game/) ; ce qui est partage par le site (auth, onboarding, dashboard, uploads, db/) reste a la racine de chaque dossier. Un sous-dossier document/ (vide) cree dans chacun pour le futur chantier. - styles/ volontairement inchange : les 3 fichiers sources sont concatenes en un seul static/style.css charge par tout le site, scinder leur CONTENU (editeur vs partage) serait un refactor CSS distinct, pas un deplacement mecanique. - Chaine d'export SCORM (publish/build_scorm_package.py) mise a jour en profondeur : copie des assets, URLs d'icones relatives a static/style.css (qui ne bouge pas), manifeste, wrapper SCORM. - Deux regressions d'un sweep de renommage anterieur corrigees au passage (screens.js/screens/scene-objects incorrectement convertis en game_engine.js/game_engine/scene-objects dans des commentaires). - Effet de bord Windows decouvert et corrige : git mv + Path.write_text convertissent des fichiers en CRLF (core.autocrlf=true) - ~189 fichiers normalises en LF. - .eslintrc.json/package.json : uniquement les chemins de glob mis a jour (static/game/js/...) ; la preparation eslint-plugin-unicorn du lot 7 reste volontairement non committee (package-lock.json restaure a la version precedente). Verifications : ruff, mypy --strict (391 fichiers), vulture, bandit, lint-imports tous verts ; 591/591 tests Python, 276/276 tests JS ; demarrage serveur + requetes HTTP manuelles confirmant que les assets deplaces repondent en 200 au nouvel emplacement et 404 a l'ancien. SKIP=djlint : backlog H021 (styles inline) deja documente comme dette assumee dans CODE_QUALITY.md section 6, aucun template touche par ce commit au-dela d'un deplacement de fichier. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
497 lines
24 KiB
JavaScript
497 lines
24 KiB
JavaScript
function initBuilderPanel() {
|
|
(function () {
|
|
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() {
|
|
const canvasEl = document.getElementById(`el-${CURRENT_SELECTED_ID}`);
|
|
const x = Number.parseFloat(xEl.value) || 0; const
|
|
y = Number.parseFloat(yEl.value) || 0;
|
|
const w = Number.parseFloat(wEl.value) || 1; const
|
|
h = Number.parseFloat(hEl.value) || 1;
|
|
if (canvasEl) {
|
|
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((el) => { el.addEventListener('change', apply); });
|
|
}());
|
|
|
|
bindAspectButtons();
|
|
bindScreenBackgroundMusicField();
|
|
|
|
document.querySelectorAll('.uploadInput').forEach((input) => {
|
|
input.addEventListener('change', () => {
|
|
if (!input.files.length) return;
|
|
const status = input.parentElement.querySelector('.uploadStatus');
|
|
const targetField = document.getElementById(input.dataset.target);
|
|
status.textContent = 'Envoi en cours...';
|
|
const data = new FormData();
|
|
data.append('file', input.files[0]);
|
|
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(() => { status.textContent = 'Fichier envoyé — Enregistré.'; });
|
|
} else { status.textContent = "Échec de l'envoi."; }
|
|
})
|
|
.catch(() => { status.textContent = "Échec de l'envoi."; });
|
|
});
|
|
});
|
|
|
|
// Phase 6 — champ d'upload DANS le formulaire de nœud (action "Jouer un
|
|
// son") : contrairement à .uploadInput (panneau de propriétés d'un
|
|
// é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((input) => {
|
|
input.addEventListener('change', () => {
|
|
if (!input.files.length) return;
|
|
const status = input.parentElement.querySelector('.uploadStatus');
|
|
const targetField = document.getElementById(input.dataset.target);
|
|
status.textContent = 'Envoi en cours...';
|
|
const data = new FormData();
|
|
data.append('file', input.files[0]);
|
|
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(() => { status.textContent = "Échec de l'envoi."; });
|
|
});
|
|
});
|
|
|
|
bindPropsAutosave();
|
|
bindJaugeDefinitionSelect();
|
|
bindDefinitionFieldSelects('field-definition_id', ['field-filtre_champ', 'field-filtre2_champ']);
|
|
bindDefinitionFieldSelects('field-visibilite_cond_definition_id', ['field-visibilite_cond_champ']);
|
|
bindClauseListDefinitionSelect('field-data_definition_id');
|
|
bindClauseListRows();
|
|
initFilterValuePickers();
|
|
const visCondModeSel = document.getElementById('field-visibilite_cond_mode');
|
|
if (visCondModeSel) toggleVisCondFields(visCondModeSel);
|
|
restoreTreeCollapsedState();
|
|
applySelectionHighlight(CURRENT_SELECTED_ID);
|
|
restoreFloatPanelState('props', 'propsPanelFloat', 'propsPanelReopenBtn');
|
|
restoreFloatPanelState('left', 'panelLeftFloat', 'panelLeftReopenBtn');
|
|
}
|
|
|
|
// Jauge : quand on change l'objet de données à suivre SANS quitter le
|
|
// panneau (donc sans le rechargement serveur qui repeuplerait row_id/champ/
|
|
// champ_nom via controls_with_values.py), on reconstruit ces trois menus à
|
|
// la volée à partir de DEFINITIONS_DATA (même source que les formulaires de
|
|
// la Logique de la scène, voir onFlowDefinitionChange). DEFINITIONS_DATA
|
|
// est déclaré plus bas dans ce script (donc encore "undefined" au moment où
|
|
// initBuilderPanel() tourne en tout premier) — sans conséquence ici,
|
|
// puisqu'on ne le lit que dans le gestionnaire, jamais au moment du bind.
|
|
// Libellé d'un champ dans une liste déroulante — précise l'objet visé pour
|
|
// 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;
|
|
}
|
|
|
|
function bindJaugeDefinitionSelect() {
|
|
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', () => {
|
|
const info = DEFINITIONS_DATA?.[defSel.value] || { rows: [], fields: [] };
|
|
rowSel.innerHTML = info.rows.length
|
|
? 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).
|
|
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}`;
|
|
});
|
|
}
|
|
|
|
// ---------- Réglages "champ" d'un filtre (Répéteur/Donnée liée) : listes
|
|
// déroulantes peuplées avec les VRAIS champs de l'objet choisi, jamais
|
|
// tapées à la main — même principe que bindJaugeDefinitionSelect() ci-dessus,
|
|
// généralisé pour s'appliquer à un nombre quelconque de listes "champ"
|
|
// rattachées à un même sélecteur d'objet (ici : filtre_champ + filtre2_champ,
|
|
// ou data_filtre_champ + data_filtre2_champ). ----------
|
|
// ---------- Condition de visibilité (voir VISIBILITY_CONDITION_CONTROLS) :
|
|
// n'affiche que les réglages pertinents pour le mode choisi (variable
|
|
// globale, ou champ d'un objet) — les deux autres restent en base (peu
|
|
// importe, seul le mode courant est lu par is_element_visible()) mais sont
|
|
// masqués pour ne pas embrouiller. ----------
|
|
function toggleVisCondFields(sel) {
|
|
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) {
|
|
const defSel = document.getElementById(defSelId);
|
|
if (!defSel) return;
|
|
const fieldSels = fieldSelIds.map((id) => document.getElementById(id)).filter(Boolean);
|
|
if (!fieldSels.length) return;
|
|
defSel.addEventListener('change', () => {
|
|
const info = DEFINITIONS_DATA?.[defSel.value] || { fields: [] };
|
|
const fieldOptionsHtml = info.fields.map((f) => `<option value="${f.name}">${fieldOptionLabel(f)}</option>`).join('');
|
|
const opts = `<option value="">(sélectionner un objet d'abord)</option>${fieldOptionsHtml}`;
|
|
fieldSels.forEach((sel) => { sel.innerHTML = opts; });
|
|
});
|
|
}
|
|
|
|
// ---------- "Donnée liée" : liste de conditions ILLIMITÉE (champ/opérateur/
|
|
// valeur), combinées entre elles par ET/OU (voir c_clause_list.py,
|
|
// save_element_controls.py, resolve_bound_row.py). Chaque ligne existante
|
|
// est rendue côté serveur (au chargement du panneau) ; "+ Ajouter une
|
|
// condition" clone un <template> pour en ajouter de nouvelles à la volée,
|
|
// sans aller-retour serveur — même principe que "fieldsList"/"varValueRows"
|
|
// ailleurs dans l'outil. ----------
|
|
function wireClauseRow(row, key) {
|
|
row.querySelectorAll('input, select').forEach((field) => {
|
|
const debounced = field.tagName === 'INPUT';
|
|
field.addEventListener(debounced ? 'input' : 'change', () => {
|
|
if (debounced) debouncedSubmitPropsForm(); else submitPropsForm();
|
|
});
|
|
});
|
|
const removeBtn = row.querySelector('.clauseRemoveBtn');
|
|
if (removeBtn) {
|
|
removeBtn.addEventListener('click', () => {
|
|
row.remove();
|
|
updateClauseCombinatorVisibility(key);
|
|
submitPropsForm();
|
|
});
|
|
}
|
|
}
|
|
|
|
function updateClauseCombinatorVisibility(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) {
|
|
const tmpl = document.getElementById(`clauseRowTemplate-${key}`);
|
|
const wrap = document.getElementById(`clauseListWrap-${key}`);
|
|
if (!tmpl || !wrap) return;
|
|
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).
|
|
const defSel = document.getElementById('field-data_definition_id');
|
|
if (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.
|
|
initFilterValuePickers();
|
|
updateClauseCombinatorVisibility(key);
|
|
submitPropsForm();
|
|
}
|
|
|
|
// Rebranche les lignes déjà rendues par le serveur (rechargement du
|
|
// 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((control) => {
|
|
const key = control.dataset.clauseKey;
|
|
control.querySelectorAll('.clauseRow').forEach((row) => { wireClauseRow(row, key); });
|
|
});
|
|
}
|
|
|
|
// Repeuple le sélecteur "champ" de TOUTES les lignes de condition actuelles
|
|
// (interrogées à chaque changement, donc couvre aussi les lignes ajoutées
|
|
// après coup) quand on change l'objet lié — même principe que
|
|
// 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) {
|
|
const defSel = document.getElementById(defSelId);
|
|
if (!defSel) return;
|
|
defSel.addEventListener('change', () => {
|
|
const info = DEFINITIONS_DATA?.[defSel.value] || { fields: [] };
|
|
const fieldOptionsHtml = info.fields.map((f) => `<option value="${f.name}">${fieldOptionLabel(f)}</option>`).join('');
|
|
const opts = `<option value="">(sélectionner un objet d'abord)</option>${fieldOptionsHtml}`;
|
|
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
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------- Valeur de comparaison d'un filtre : fixe, ou donnée d'un autre
|
|
// objet ----------
|
|
//
|
|
// La valeur envoyée au serveur reste une simple chaîne "texte" (voir
|
|
// _resolve_filter_value côté Python) — soit une valeur fixe, soit une
|
|
// référence à un champ d'un autre objet dans un format interne précis. Le
|
|
// mode "dynamique" ci-dessous se contente de reconstruire cette chaîne à
|
|
// partir de deux listes déroulantes (objet, champ) plutôt que de la faire
|
|
// 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).
|
|
const _FILTER_REF_OPEN = '{' + '{';
|
|
const _FILTER_REF_CLOSE = '}' + '}';
|
|
const _FILTER_REF_RE = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/; // NOSONAR S8786 - classes negatives, decoupage sans ambiguite, teste jusqu'a 100k car. sans blowup (voir CODE_QUALITY.md)
|
|
// 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".
|
|
const _VAR_REF_RE = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/; // NOSONAR S8786 - idem, teste jusqu'a 20k segments sans blowup (voir CODE_QUALITY.md)
|
|
|
|
function _filterValueDefinitionId(objName) {
|
|
const opt = document.querySelector(`.filterValueObjSel option[value="${CSS.escape(objName)}"]`);
|
|
return opt ? opt.dataset.definitionId : null;
|
|
}
|
|
|
|
function populateFilterValueFields(fieldSel, defId, selectedName) {
|
|
const info = DEFINITIONS_DATA?.[defId] || { fields: [] };
|
|
const fieldOptionsHtml = info.fields.map((f) => `<option value="${f.name}"${f.name === selectedName ? ' selected' : ''}>${fieldOptionLabel(f)}</option>`).join('');
|
|
fieldSel.innerHTML = `<option value="">— champ —</option>${fieldOptionsHtml}`;
|
|
}
|
|
|
|
function updateFilterValueFromDynamic(wrap) {
|
|
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) {
|
|
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) {
|
|
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';
|
|
if (sel.value === 'dynamique') updateFilterValueFromDynamic(wrap);
|
|
else if (sel.value === 'variable') updateFilterValueFromVariable(wrap);
|
|
}
|
|
|
|
function onFilterValueObjChange(sel) {
|
|
const wrap = sel.closest('.filterValueControl');
|
|
populateFilterValueFields(wrap.querySelector('.filterValueFieldSel'), _filterValueDefinitionId(sel.value), '');
|
|
updateFilterValueFromDynamic(wrap);
|
|
}
|
|
|
|
function onFilterValueFieldChange(sel) {
|
|
updateFilterValueFromDynamic(sel.closest('.filterValueControl'));
|
|
}
|
|
|
|
function onFilterValueVarChange(sel) {
|
|
updateFilterValueFromVariable(sel.closest('.filterValueControl'));
|
|
}
|
|
|
|
function initFilterValuePickers() {
|
|
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';
|
|
wrap.querySelector('.filterValueVariable').style.display = 'block';
|
|
varSel.value = varMatch[1].trim();
|
|
if (varPathInput) varPathInput.value = (varMatch[2] || '').trim();
|
|
} else if (objMatch) {
|
|
modeSel.value = 'dynamique';
|
|
wrap.querySelector('.filterValueFixe').style.display = 'none';
|
|
wrap.querySelector('.filterValueDynamique').style.display = 'block';
|
|
objSel.value = objMatch[1].trim();
|
|
populateFilterValueFields(fieldSel, _filterValueDefinitionId(objSel.value), objMatch[2].trim());
|
|
} else {
|
|
modeSel.value = 'fixe';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Le retour/avance du navigateur (bouton "précédent") est déjà pris en
|
|
// charge par pjax.js (navigation globale sans rechargement, voir
|
|
// static/pjax.js) : il refait un rendu complet de <main> et rejoue ce
|
|
// script (voir runScriptsIn dans pjax.js), ce qui rappelle initBuilderPanel()
|
|
// ci-dessous — donc le canevas et le panneau de propriétés se retrouvent à
|
|
// jour automatiquement, sans qu'on ait besoin d'un second écouteur ici.
|
|
initBuilderPanel();
|
|
|
|
function toggleActionFields(sel) {
|
|
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') {
|
|
const propSel = elementFields.querySelector('.elementPropertySelect');
|
|
toggleElementPropertyValue(propSel);
|
|
}
|
|
if (sel.value === 'modifier_donnee') {
|
|
const defSel = dataFields.querySelector('.targetDefinitionSelect');
|
|
if (defSel) onDataDefinitionChange(defSel);
|
|
}
|
|
}
|
|
document.querySelectorAll('.actionTypeSelect').forEach(toggleActionFields);
|
|
|
|
function toggleElementPropertyValue(sel) {
|
|
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') {
|
|
colorInp.style.display = 'block'; colorInp.setAttribute('name', 'element_value');
|
|
} else {
|
|
numInp.style.display = 'block'; numInp.setAttribute('name', 'element_value');
|
|
}
|
|
}
|
|
document.querySelectorAll('.elementPropertySelect').forEach(toggleElementPropertyValue);
|
|
|
|
function onDataDefinitionChange(sel) {
|
|
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((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((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((o) => { o.classList.remove('active', 'is-primary'); });
|
|
radio.closest('.alignOption').classList.add('active', 'is-primary');
|
|
}
|
|
|
|
// Choix du format d'aperçu (Portrait/Paysage/Carré) : mémorisé côté serveur
|
|
// (voir screen_set_aspect.py) pour ne pas repartir sur Portrait à chaque
|
|
// ajout/suppression d'élément ou changement de sélection — ces actions
|
|
// redemandent ce panneau au serveur, qui doit donc connaître le choix déjà
|
|
// fait. Appelé depuis initBuilderPanel() (voir plus haut), une seule fois
|
|
// par (re)rendu de ce panneau.
|
|
// Phase 6 — musique de fond de cet écran : enregistrement en AJAX au
|
|
// blur/changement (pas d'autosave "au caractère près", même choix que le
|
|
// 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 }),
|
|
});
|
|
}
|
|
|
|
function bindScreenBackgroundMusicField() {
|
|
const urlInput = document.getElementById('screen-bg-music-url');
|
|
if (urlInput) {
|
|
urlInput.addEventListener('change', () => { saveScreenBackgroundMusic(urlInput.value); });
|
|
}
|
|
document.querySelectorAll('.screenUploadInput').forEach((input) => {
|
|
input.addEventListener('change', () => {
|
|
if (!input.files.length) return;
|
|
const status = input.parentElement.querySelector('.uploadStatus');
|
|
const targetField = document.getElementById(input.dataset.target);
|
|
status.textContent = 'Envoi en cours...';
|
|
const data = new FormData();
|
|
data.append('file', input.files[0]);
|
|
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(() => { status.textContent = 'Fichier envoyé — Enregistré.'; });
|
|
} else { status.textContent = "Échec de l'envoi."; }
|
|
})
|
|
.catch(() => { status.textContent = "Échec de l'envoi."; });
|
|
});
|
|
});
|
|
}
|
|
|
|
function bindAspectButtons() {
|
|
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' },
|
|
body: JSON.stringify({ aspect: btn.dataset.aspect }),
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
document.querySelectorAll('.uploadInput').forEach((input) => {
|
|
input.addEventListener('change', () => {
|
|
if (!input.files.length) return;
|
|
const status = input.parentElement.querySelector('.uploadStatus');
|
|
const targetField = document.getElementById(input.dataset.target);
|
|
status.textContent = 'Envoi en cours...';
|
|
const data = new FormData();
|
|
data.append('file', input.files[0]);
|
|
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 un clic sur "Enregistrer") pour
|
|
// 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.
|
|
const form = document.getElementById('propsForm');
|
|
if (form) { form.requestSubmit ? form.requestSubmit() : form.submit(); }
|
|
} else { status.textContent = "Échec de l'envoi."; }
|
|
})
|
|
.catch(() => { status.textContent = "Échec de l'envoi."; });
|
|
});
|
|
});
|