Build and deploy / test-python (push) Successful in 11m18s
Build and deploy / test-js (push) Successful in 1m16s
Build and deploy / lint-python (push) Successful in 4m24s
Build and deploy / lint-js (push) Successful in 2m51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Successful in 5m30s
Corrige les findings SonarQube (via SonarLint IDE, fichier par fichier) sur ~24 fichiers static/js/ : parseFloat/parseInt -> Number.*, .replace(/x/g,y) -> .replaceAll, .indexOf() -> .includes()/.startsWith(), getAttribute/setAttribute -> .dataset, tableaux -> Set, x && x.y -> x?.y (verifie site par site), extraction de template litteraux imbriques, ternaires imbriquees, refactors de complexite cognitive (S3776) via tables de dispatch, Object.hasOwn, .at(), et deduplication de fonctions identiques (S4144). Deux exceptions S2486 documentees/corrigees (filter-repeater-rows.js) et un cas S2703 de partage inter-scripts complete (_collisionWizard, trigger-editor.js <-> collision-rules-editor.js). Details complets dans CODE_QUALITY.md section 5. Restaure aussi le job CI "sonarqube" (non-bloquant) dans .gitea/workflows/deploy.yml maintenant que l'instance prod est operationnelle. Suites vertes : 276/276 JS (node --test), 591/591 Python (pytest). SKIP=djlint sur ce commit : hook djlint bloquant sur le backlog H021 (styles inline, 49 occurrences/6 templates) deja documente comme dette assumee non traitee dans CODE_QUALITY.md section 6, aucun rapport avec ce commit (aucun template touche ici) - valide explicitement avec l'utilisateur avant de contourner ce hook. 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."; });
|
|
});
|
|
});
|