Files
Forge-Engine/templates/game_dashboard.html
T
williamandClaude Sonnet 5 4585a72588
Build and deploy / test (push) Failing after 8s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Phase 1 (3/3) : bascule "par joueur" dans le tableau de bord
Dernier morceau du plan d'état par joueur : le créateur choisit, à la
création d'une variable globale ou d'un objet, si chaque joueur aura sa
propre valeur/ses propres lignes (coché par défaut) ou si elle est
explicitement PARTAGÉE par tous les joueurs (ex. un compteur de visiteurs
global, un catalogue commun) — voir db/global_vars/create_global_variable.py
et db/definitions/create_definition.py (Phase 1, 1er commit).

routes/global_vars/create_global_var.py, routes/objects/object_new.py :
lisent la case à cocher "per_player" du formulaire (absente => reste
per_player=1, comportement par défaut). templates/game_dashboard.html :
case à cocher sur les deux panneaux de création + colonne "Par joueur"
dans les deux tableaux existants, pour que ce réglage (immuable après
création, comme le nom d'une variable) reste visible.

db/definitions/list_definitions.py appelait _definitions directement
sans jamais migrer son schéma — un tableau de bord ouvert avant la toute
première création/modification d'objet aurait affiché "Non — partagé"
pour un objet en réalité per_player=1 (colonne absente => Undefined,
donc faux en Jinja) : corrigé en appelant ensure_field_bounds_schema()
ici aussi, comme le fait déjà create_definition.py/get_definition.py.

Vérifié : 239 tests passent (2 nouveaux, dont un qui aurait détecté le
bug ci-dessus). Phase 1 (état par joueur) est maintenant complète :
couche db/, route publique /jouer/<slug>, et ce réglage créateur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 15:54:37 +02:00

1143 lines
58 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ game.name }} — Forge Engine{% endblock %}
{% block content_class %}content-wide{% endblock %}
{% block content %}
<div class="dashPanelHeader">
<h2 class="title is-4 mb-0">{{ game.name }}</h2>
<span class="hint">projects/{{ game.slug }}/</span>
</div>
<!-- Une seule fenêtre, un onglet par catégorie (même principe que les
onglets "Écran / Logique / Timeline" de l'éditeur d'écran, voir
.builderTabs/.builderTabBtn/.builderTabPanel dans style.css) — pour
naviguer entre écrans/objets/éléments de jeu/variables sans jamais
changer de page, avec à chaque fois un vrai tableau (dense, colonnes
nettes) plutôt que des cartes compactes. -->
<div class="builderTabs" id="dashTabs">
<button type="button" class="builderTabBtn active" data-tab="screens" onclick="switchDashTab('screens')">🖥️ Écrants <span class="dashTabCount">{{ screen_list|length }}</span></button>
<button type="button" class="builderTabBtn" data-tab="objects" onclick="switchDashTab('objects')">🗂️ Objets <span class="dashTabCount">{{ definitions|length }}</span></button>
<button type="button" class="builderTabBtn" data-tab="element_types" onclick="switchDashTab('element_types')">🧩 Templates <span class="dashTabCount">{{ element_types|length }}</span></button>
<button type="button" class="builderTabBtn" data-tab="variables" onclick="switchDashTab('variables')">🔧 Variables <span class="dashTabCount">{{ variables|length }}</span></button>
</div>
<!-- ---------- Écrans ---------- -->
<div class="builderTabPanel active" data-tab-panel="screens">
{% if request.args.get('blocked_screen') %}
<div class="warningBanner">
⚠️ Suppression impossible : au moins un autre écran a un bouton qui cible précisément celui-ci. Retire d'abord cette action.
</div>
{% endif %}
<div class="dashPanelHeader">
<h2>Écrants</h2>
<button type="button" class="button is-small primary" onclick="toggleDashCreate('screens')">+ Créer un écran</button>
</div>
<form method="post" action="{{ url_for('screens_new', slug=game.slug) }}" class="dashCreateBar" id="dashCreate-screens" style="display:none;">
<input type="text" name="name" placeholder="Nom du nouvel écran (ex. Accueil, Scène 1...)" required autocomplete="off">
<button type="submit" class="primary">Créer</button>
</form>
{% if screen_list %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable">
<thead><tr><th>#</th><th>Nom</th><th></th></tr></thead>
<tbody>
{% for s in screen_list %}
<tr>
<td>{{ loop.index }}</td>
<td>
<a href="{{ url_for('screen_edit', slug=game.slug, screen_id=s.id) }}">{{ s.name }}</a>
{% if loop.first %} <span class="tag is-info is-light">★ Départ</span>{% endif %}
</td>
<td class="fieldsTableActions">
<form method="post" action="{{ url_for('screen_move', slug=game.slug, screen_id=s.id) }}"><input type="hidden" name="direction" value="up"><button type="submit" class="button is-small" {{ 'disabled' if loop.first else '' }}>↑</button></form>
<form method="post" action="{{ url_for('screen_move', slug=game.slug, screen_id=s.id) }}"><input type="hidden" name="direction" value="down"><button type="submit" class="button is-small" {{ 'disabled' if loop.last else '' }}>↓</button></form>
<a class="button is-small" href="{{ url_for('screen_edit', slug=game.slug, screen_id=s.id) }}">✏️</a>
<form method="post" action="{{ url_for('screen_delete', slug=game.slug, screen_id=s.id) }}"
onsubmit="return confirm('Supprimer l\'écran « {{ s.name }} » et tous ses éléments ?');">
<button type="submit" class="button is-small is-danger">🗑️</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="hint">Aucun écran pour l'instant.</p>
{% endif %}
</div>
<!-- ---------- Objets ---------- -->
<div class="builderTabPanel" data-tab-panel="objects">
<div class="dashPanelHeader">
<h2>Objets</h2>
<button type="button" class="button is-small primary" onclick="openNewObjectPanel()">+ Nouvel objet</button>
</div>
{% if definitions %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable">
<thead><tr><th>Nom</th><th>Table SQL</th><th>Par joueur</th><th></th></tr></thead>
<tbody>
{% for d in definitions %}
<tr>
<td><button type="button" class="linkLikeButton" onclick="openObjectEditPanel({{ d.id }})">{{ d.name }}</button></td>
<td class="has-text-grey">{{ d.table_name }}</td>
<td><span class="hint">{{ 'Oui' if d.per_player else 'Non — partagé' }}</span></td>
<td class="fieldsTableActions">
<button type="button" class="button is-small" onclick="openObjectEditPanel({{ d.id }})" title="Modifier cet objet">✏️</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="hint">Aucun objet pour l'instant.</p>
{% endif %}
</div>
<!-- ---------- Templates ---------- -->
<div class="builderTabPanel" data-tab-panel="element_types">
<div class="dashPanelHeader">
<h2>Templates</h2>
<button type="button" class="button is-small primary" onclick="toggleDashCreate('element_types')">+ Créer un template</button>
</div>
<form method="post" action="{{ url_for('element_types', slug=game.slug) }}" class="dashCreateBar" id="dashCreate-element_types" style="display:none;">
<input type="text" name="icon" value="🧩" maxlength="4" style="max-width:60px;" title="Icône">
<input type="text" name="name" placeholder="Nom (ex. Personnage, Carte mail...)" required autocomplete="off">
<select name="bound_definition_id">
<option value="">(aucun objet lié)</option>
{% for d in definitions %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
<button type="submit" class="primary">Créer</button>
</form>
{% if element_types %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable">
<thead><tr><th>Nom</th><th>Usage</th><th></th></tr></thead>
<tbody>
{% for t in element_types %}
<tr>
<td>{{ t.icon or '🧩' }} {{ t.name }}</td>
<td class="has-text-grey">
{{ element_type_usage.get(t.id, 0) }} instance(s)
{% if t.bound_definition_id %}
{% for d in definitions %}{% if d.id == t.bound_definition_id %} · « {{ d.name }} »{% endif %}{% endfor %}
{% endif %}
</td>
<td class="fieldsTableActions">
<a class="button is-small" href="{{ url_for('screen_edit', slug=game.slug, screen_id=t.template_screen_id) }}" title="Modifier le contenu de ce template">✏️</a>
<form method="post" action="{{ url_for('element_type_delete', slug=game.slug, element_type_id=t.id) }}"
onsubmit="return confirm('Supprimer le template « {{ t.name }} » du catalogue (et tout son contenu) ?');">
<button type="submit" class="button is-small is-danger" {{ 'disabled title="Utilisé sur un écran — retire-le des écrans d\'abord"' if element_type_usage.get(t.id, 0) > 0 else '' }}>🗑️</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="hint">Aucun template pour l'instant.</p>
{% endif %}
</div>
<!-- ---------- Variables ---------- -->
<div class="builderTabPanel" data-tab-panel="variables">
<div class="dashPanelHeader">
<h2>Variables</h2>
<button type="button" class="button is-small primary" onclick="toggleDashCreate('variables')">+ Créer une variable</button>
</div>
<form method="post" action="{{ url_for('create_global_var', slug=game.slug) }}" class="dashCreateBar" id="dashCreate-variables" style="display:none;">
<input type="text" name="name" placeholder="Nom (ex. porte_ouverte)" required autocomplete="off">
<select id="newVarType" name="type" onchange="onVarTypeSelectChange(this)">
{% for key, val in variable_types.items() %}
<option value="{{ key }}">{{ val.label }}</option>
{% endfor %}
</select>
<input type="text" id="newVarValue" name="default_value" placeholder="Valeur de départ">
<button type="button" id="newVarConfigureBtn" class="button is-small" style="display:none;" onclick="openVarValuePanel('newVarValue', document.getElementById('newVarType').value)">🔧 Configurer la valeur</button>
<!-- État par joueur (Phase 1) : réglé une seule fois, à la création
(voir db/global_vars/create_global_variable.py) — coché par
défaut (chaque joueur a sa propre valeur), décoché pour une
variable explicitement PARTAGÉE par tous les joueurs (ex. un
compteur de visiteurs global). -->
<label class="checkbox" style="display:flex; align-items:center; gap:6px; font-size:12px; white-space:nowrap;" title="Décoché : une seule valeur, partagée par tous les joueurs.">
<input type="checkbox" name="per_player" value="1" checked style="width:auto;">
Par joueur
</label>
<button type="submit" class="primary">Créer</button>
</form>
{% if variables %}
{% for v in variables %}
<form method="post" action="{{ url_for('global_var_edit', slug=game.slug, var_id=v.id) }}" id="varEditForm{{ v.id }}"></form>
{% endfor %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable">
<thead><tr><th>Nom</th><th>Type</th><th>Valeur</th><th>Par joueur</th><th></th></tr></thead>
<tbody>
{% for v in variables %}
<tr>
<td>{{ v.name }}</td>
<td>
<select form="varEditForm{{ v.id }}" name="type" aria-label="Type de « {{ v.name }} »">
{% for key, val in variable_types.items() %}
<option value="{{ key }}" {{ 'selected' if v.type == key else '' }}>{{ val.label }}</option>
{% endfor %}
</select>
</td>
<td>
{% if v.type in ('objet', 'tableau') %}
<div class="fieldRow" style="align-items:center;">
<input type="text" form="varEditForm{{ v.id }}" name="value" id="varValue-{{ v.id }}" value="{{ v.value or '' }}" readonly aria-label="Valeur de « {{ v.name }} »">
<button type="button" class="button is-small" onclick="openVarValuePanel('varValue-{{ v.id }}', '{{ v.type }}')">🔧 Configurer</button>
</div>
{% else %}
<input type="text" form="varEditForm{{ v.id }}" name="value" value="{{ v.value or '' }}" aria-label="Valeur de « {{ v.name }} »">
{% endif %}
</td>
<td><span class="hint">{{ 'Oui' if v.per_player else 'Non — partagée' }}</span></td>
<td class="fieldsTableActions">
<button type="submit" form="varEditForm{{ v.id }}" class="button is-small">Enregistrer</button>
<form method="post" action="{{ url_for('global_var_delete', slug=game.slug, var_id=v.id) }}"
onsubmit="return confirm('Supprimer la variable « {{ v.name }} » ? Toute condition de visibilité ou action qui la référence cessera de fonctionner.');">
<button type="submit" class="button is-small is-danger">🗑️</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="hint">Aucune variable pour l'instant.</p>
{% endif %}
</div>
<!-- ---------- Panneau "Configurer la valeur" (variable Objet/Tableau) ---------- -->
<!-- Un seul panneau partagé (pas un par variable) : une seule valeur à la
fois peut être en cours d'édition. openVarValuePanel(targetInputId,
mode) mémorise vers quel <input> écrire le JSON reconstruit (celui de
la barre de création, ou celui, en lecture seule, de la ligne d'une
variable existante) — pas de JSON à taper à la main : une ligne par
clé (Objet) ou par élément (Tableau), avec un type (Texte/Nombre/
Oui-Non) qui pilote comment la valeur est encodée en JSON. Ne gère
que des valeurs SIMPLES (pas d'objet/tableau imbriqué) — largement
suffisant pour une configuration ou une liste, et ça reste lisible
pour qui ne connaît pas JSON. -->
<div class="floatPanel floatPanel--center hidden" id="varValuePanel">
<div class="floatPanelHeader" id="varValuePanelHeader">
<span class="floatPanelTitle" id="varValuePanelTitle">⋮⋮ Configurer la valeur</span>
<button type="button" class="button is-small floatPanelFullscreenBtn" title="Plein écran">⛶</button>
<button type="button" class="button is-small" onclick="closeVarValuePanel()">✕</button>
</div>
<div class="floatPanelBody">
<p class="hint mb-2" id="varValuePanelHint"></p>
<div id="varValueRows"></div>
<button type="button" class="button primary is-fullwidth mt-2" onclick="addVarValueRow()">+ Ajouter une ligne</button>
</div>
<div class="floatPanelResizeHandle" id="varValuePanelResizeHandle"></div>
</div>
<template id="varValueRowTemplate">
<div class="fieldRowCard box varValueRow">
<div class="fieldRow">
<div class="formField varValueKeyField">
<label>Clé</label>
<input type="text" class="varValueKey" placeholder="ex. nom, degats...">
</div>
<div class="formField">
<label>Type</label>
<select class="varValueType">
<option value="texte">Texte</option>
<option value="nombre">Nombre</option>
<option value="booleen">Oui / Non</option>
</select>
</div>
<div class="formField" style="flex:2;">
<label>Valeur</label>
<input type="text" class="varValueValue" placeholder="ex. Alice, 12, Oui...">
</div>
</div>
<button type="button" class="button is-small is-danger varValueRemoveBtn">🗑️ Retirer</button>
</div>
</template>
<div class="dangerZone dashDangerZone">
<h3>Paramètres du jeu</h3>
<form method="post" action="{{ url_for('game_rename', slug=game.slug) }}">
<label for="rename">Renommer le jeu</label>
<div class="fieldRow">
<input type="text" id="rename" name="game_name" value="{{ game.name }}" required autocomplete="off">
<button type="submit">Renommer</button>
</div>
</form>
<form method="post" action="{{ url_for('game_delete', slug=game.slug) }}" class="actionsRow"
onsubmit="return confirm('Supprimer définitivement le jeu « {{ game.name }} », son dossier et sa base de données ? Cette action est irréversible.');">
<button type="submit" class="danger">🗑️ Supprimer ce jeu</button>
</form>
</div>
<!-- ---------- Panneau déplaçable : nouvel objet ---------- -->
<!-- Réutilise .floatPanel/.floatPanelHeader/.floatPanelBody (éditeur
d'écran, voir style.css) pour la même sensation de fenêtre flottante
qu'ailleurs dans l'outil, avec sa propre variante centrée
(.floatPanel--center) et son propre glisser-déposer minimal (pas de
position persistée en localStorage ici, contrairement aux panneaux de
l'éditeur d'écran — inutile pour un panneau ouvert ponctuellement).
Contenu repris de l'ancienne page "Définir un nouvel objet"
(object_form.html, retirée), mais avec un gabarit de ligne dense —
une vraie ligne de tableau (comme "Champs existants" dans
object_edit.html) plutôt qu'une grosse carte par champ — pensé pour
ajouter beaucoup de champs d'affilée sans que le panneau explose en
hauteur. -->
<div class="floatPanel floatPanel--center hidden" id="newObjectPanel">
<div class="floatPanelHeader" id="newObjectPanelHeader">
<span class="floatPanelTitle">⋮⋮ Nouvel objet</span>
<button type="button" class="button is-small floatPanelFullscreenBtn" title="Plein écran">⛶</button>
<button type="button" class="button is-small" onclick="closeNewObjectPanel()">✕</button>
</div>
<div class="floatPanelBody">
<form method="post" action="{{ url_for('object_new', slug=game.slug) }}" id="objectForm" class="formPassthrough">
<div class="formField">
<label for="object_name">Nom de l'objet (ex. Personnage, Niveau, Question...)</label>
<input type="text" id="object_name" name="object_name" required autocomplete="off">
</div>
<!-- État par joueur (Phase 1) : réglé une seule fois, à la création
(voir db/definitions/create_definition.py) — coché par défaut
(chaque joueur a ses propres lignes, ex. un inventaire),
décoché pour un objet explicitement PARTAGÉ par tous les
joueurs (ex. un catalogue, une liste de questions). -->
<label class="checkbox" style="display:flex; align-items:center; gap:6px; margin-top:10px;" title="Décoché : les lignes de cet objet sont partagées par tous les joueurs.">
<input type="checkbox" name="per_player" value="1" checked style="width:auto;">
Chaque joueur a ses propres lignes ("par joueur")
</label>
<div class="dashPanelHeader" style="margin-top:14px;">
<h2>Champs</h2>
<button type="button" class="button is-small" id="btnAddField">+ Ajouter un champ</button>
</div>
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable">
<thead>
<tr><th>Nom</th><th>Type</th><th>Objet lié</th><th>Mini</th><th>Maxi</th><th>Obligatoire</th><th></th></tr>
</thead>
<tbody id="fieldsList"></tbody>
</table>
</div>
<div class="actionsRow"><button type="submit" class="primary">Créer l'objet</button></div>
</form>
</div>
<div class="floatPanelResizeHandle" id="newObjectPanelResizeHandle"></div>
</div>
<!-- ---------- Panneaux déplaçables : modifier un objet (un par objet) ---------- -->
<!-- Un panneau par objet, pré-rendu ici et caché par défaut (voir
openObjectEditPanel()) — évite un aller-retour serveur à l'ouverture.
Remplace l'ancienne page object_edit.html (retirée) : même renommage,
même formulaire "Ajouter un champ" (une ligne compacte, pas la grosse
carte d'origine), et le même tableau "Champs existants" repris tel
quel (déjà dense et sobre). -->
{% for d in definitions %}
{% set definition = definitions_full[d.id] %}
{% set existing_definitions = definitions|rejectattr('id', 'equalto', d.id)|list %}
<div class="floatPanel floatPanel--center hidden objectEditPanel" data-definition-id="{{ d.id }}" id="objectEditPanel-{{ d.id }}">
<div class="floatPanelHeader">
<span class="floatPanelTitle">⋮⋮ {{ definition.name }}</span>
<button type="button" class="button is-small floatPanelFullscreenBtn" title="Plein écran">⛶</button>
<button type="button" class="button is-small" onclick="closeObjectEditPanel({{ d.id }})">✕</button>
</div>
<div class="floatPanelBody">
<form method="post" action="{{ url_for('object_edit', slug=game.slug, definition_id=d.id) }}" class="field has-addons">
<div class="control is-expanded">
<label class="label" for="object_name-{{ d.id }}">Nom de l'objet</label>
<input class="input" type="text" id="object_name-{{ d.id }}" name="object_name" value="{{ definition.name }}" required>
</div>
<div class="control" style="align-self:flex-end;">
<button type="submit" class="button">Renommer</button>
</div>
</form>
<p class="help mb-3">Table SQL : {{ definition.table_name }}</p>
{% if referenced_by_definition.get(d.id) %}
<div class="warningBanner">
⚠️ D'autres objets ont une relation vers « {{ definition.name }} » :
{{ referenced_by_definition[d.id]|map(attribute='name')|join(', ') }}.
La suppression de cet objet est bloquée tant que ces relations existent.
</div>
{% endif %}
<!-- Sous-onglets Champs/Données de CE panneau — classes distinctes de
.builderTabs/.builderTabBtn/.builderTabPanel (onglets du tableau de
bord lui-même) pour ne jamais interférer avec eux : switchDashTab()
cible ces classes globalement, alors que switchObjectPanelTab()
reste scopé au panneau de CET objet (plusieurs objets = plusieurs
jeux de sous-onglets indépendants sur la même page). -->
<div class="builderTabs objectPanelTabs">
<button type="button" class="builderTabBtn active" data-optab="fields" onclick="switchObjectPanelTab(this, 'fields')">Champs <span class="dashTabCount">{{ definition.fields|length }}</span></button>
<button type="button" class="builderTabBtn" data-optab="data" onclick="switchObjectPanelTab(this, 'data')">Données <span class="dashTabCount">{{ rows_by_definition[d.id]|length }}</span></button>
</div>
<div class="objectPanelTabPanel active" data-optab-panel="fields">
<div class="dashPanelHeader">
<h2>Champs existants</h2>
<button type="button" class="button is-small primary" onclick="openAddFieldPanel({{ d.id }})">+ Ajouter</button>
</div>
{% if definition.fields %}
{% for f in definition.fields %}
<form method="post" action="{{ url_for('object_field_edit', slug=game.slug, definition_id=d.id, field_id=f.id) }}" id="fieldEditForm{{ f.id }}"></form>
<form method="post" action="{{ url_for('object_field_delete', slug=game.slug, definition_id=d.id, field_id=f.id) }}" id="fieldDeleteForm{{ f.id }}"
onsubmit="return confirm('Retirer le champ « {{ f.name }} » ? Les données déjà enregistrées dans ce champ seront perdues.');"></form>
{% endfor %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable">
<thead>
<tr><th>Nom</th><th>Type</th><th>Objet lié</th><th>Mini</th><th>Maxi</th><th>Obligatoire</th><th></th></tr>
</thead>
<tbody>
{% for f in definition.fields %}
<tr>
<td><input class="input is-small" type="text" form="fieldEditForm{{ f.id }}" name="field_name" value="{{ f.name }}" aria-label="Nom du champ « {{ f.name }} »" required></td>
<td>{{ field_types.get(f.type, {}).get('label', f.type) }}</td>
<td>
{% if f.type == 'relation' %}
<div class="select is-small">
<select form="fieldEditForm{{ f.id }}" name="field_relation" aria-label="Objet lié pour « {{ f.name }} »">
{% for od in existing_definitions %}
<option value="{{ od.id }}" {{ 'selected' if od.id == f.relation_definition_id else '' }}>{{ od.name }}</option>
{% endfor %}
</select>
</div>
{% else %}
<span class="has-text-grey">—</span>
{% endif %}
</td>
<td>
{% if f.type in ('nombre_entier', 'nombre_decimal') %}
<input class="input is-small" type="number" step="any" form="fieldEditForm{{ f.id }}" name="field_min" value="{{ f.min_value if f.min_value is not none else '' }}" placeholder="ex. 0" aria-label="Valeur mini pour « {{ f.name }} »">
{% else %}
<span class="has-text-grey">—</span>
{% endif %}
</td>
<td>
{% if f.type in ('nombre_entier', 'nombre_decimal') %}
<input class="input is-small" type="number" step="any" form="fieldEditForm{{ f.id }}" name="field_max" value="{{ f.max_value if f.max_value is not none else '' }}" placeholder="ex. 100" aria-label="Valeur maxi pour « {{ f.name }} »">
{% else %}
<span class="has-text-grey">—</span>
{% endif %}
</td>
<td>
<div class="select is-small">
<select form="fieldEditForm{{ f.id }}" name="field_required" aria-label="« {{ f.name }} » obligatoire ?">
<option value="0" {{ 'selected' if not f.required else '' }}>Non</option>
<option value="1" {{ 'selected' if f.required else '' }}>Oui</option>
</select>
</div>
</td>
<td class="fieldsTableActions">
<button type="submit" form="fieldEditForm{{ f.id }}" class="button is-small">Enregistrer</button>
<button type="submit" form="fieldDeleteForm{{ f.id }}" class="button is-small is-danger">🗑️</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="help">Mini/Maxi (bornage automatique) : laisser vide = pas de limite.</p>
{% else %}
<p class="help">Aucun champ pour l'instant.</p>
{% endif %}
</div>
<div class="objectPanelTabPanel" data-optab-panel="data">
{% set blocked_row_id = request.args.get('blocked_row') if request.args.get('edit') == d.id|string else none %}
{% if blocked_row_id %}
<div class="warningBanner">
⚠️ Impossible de supprimer l'entrée #{{ blocked_row_id }} : elle est référencée ailleurs. Modifie ou supprime d'abord ces entrées.
</div>
{% endif %}
<div class="dashPanelHeader">
<h2>Données enregistrées</h2>
<div style="display:flex; gap:6px;">
{% if definition.fields %}
<div class="columnFilterWrap">
<button type="button" class="button is-small" onclick="toggleColumnFilterMenu({{ d.id }})">🔧 Colonnes</button>
<div class="columnFilterMenu hidden" id="columnFilterMenu-{{ d.id }}">
{% for f in definition.fields %}
<label class="checkbox columnFilterOption"><input type="checkbox" checked data-col="field-{{ f.id }}" onchange="toggleDataColumn({{ d.id }}, 'field-{{ f.id }}', this.checked)"> {{ f.name }}</label>
{% endfor %}
</div>
</div>
<button type="button" class="button is-small primary" onclick="openAddEntryPanel({{ d.id }})">+ Ajouter</button>
{% endif %}
</div>
</div>
{% if definition.fields %}
{% set rows = rows_by_definition[d.id] %}
{% if rows %}
{% for r in rows %}
<form method="post" action="{{ url_for('data_edit', slug=game.slug, definition_id=d.id, row_id=r.id) }}" id="dataEditForm{{ d.id }}-{{ r.id }}"></form>
<form method="post" action="{{ url_for('data_delete', slug=game.slug, definition_id=d.id, row_id=r.id) }}" id="dataDeleteForm{{ d.id }}-{{ r.id }}"
onsubmit="return confirm('Supprimer définitivement l\'entrée #{{ r.id }} ?');"></form>
{% endfor %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable dataTable fieldsTable" id="dataTable-{{ d.id }}">
<thead>
<tr><th>#</th>{% for f in definition.fields %}<th data-col="field-{{ f.id }}">{{ f.name }}</th>{% endfor %}<th></th></tr>
</thead>
<tbody>
{% for r in rows %}
<tr data-row-id="{{ r.id }}">
<td>{{ r.id }}</td>
{% for f in definition.fields %}
{% set col = f.name|colname %}
<td data-col="field-{{ f.id }}" data-field-label="{{ f.name }}">
{% if f.type == 'texte' %}
<input class="input is-small" type="text" form="dataEditForm{{ d.id }}-{{ r.id }}" name="{{ col }}" value="{{ r[col] or '' }}">
{% elif f.type == 'texte_long' %}
<textarea class="textarea is-small" form="dataEditForm{{ d.id }}-{{ r.id }}" name="{{ col }}" rows="1">{{ r[col] or '' }}</textarea>
{% elif f.type in ('nombre_entier', 'nombre_decimal') %}
<input class="input is-small" type="number" step="{{ '1' if f.type == 'nombre_entier' else 'any' }}" form="dataEditForm{{ d.id }}-{{ r.id }}" name="{{ col }}" value="{{ r[col] if r[col] is not none else '' }}">
{% elif f.type == 'booleen' %}
<label class="checkbox"><input type="checkbox" form="dataEditForm{{ d.id }}-{{ r.id }}" name="{{ col }}" {{ 'checked' if r[col] else '' }}></label>
{% elif f.type == 'date' %}
<input class="input is-small" type="date" form="dataEditForm{{ d.id }}-{{ r.id }}" name="{{ col }}" value="{{ r[col] or '' }}">
{% elif f.type == 'relation' %}
<div class="select is-small">
<select form="dataEditForm{{ d.id }}-{{ r.id }}" name="{{ col }}_id">
<option value="">—</option>
{% for opt in relation_options_by_definition[d.id].get(f.id, []) %}
<option value="{{ opt.id }}" {{ 'selected' if r[col ~ '_id'] == opt.id else '' }}>{{ opt.label }}</option>
{% endfor %}
</select>
</div>
{% endif %}
</td>
{% endfor %}
<td class="fieldsTableActions">
<button type="button" class="button is-small" title="Voir cette entrée en détail" onclick="openRowDetailPanel({{ d.id }}, {{ r.id }})">👁️</button>
<button type="submit" form="dataEditForm{{ d.id }}-{{ r.id }}" class="button is-small">Enregistrer</button>
<button type="submit" form="dataDeleteForm{{ d.id }}-{{ r.id }}" class="button is-small is-danger">🗑️</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="hint">Aucune donnée enregistrée pour l'instant.</p>
{% endif %}
{% else %}
<p class="hint">Ajoute d'abord au moins un champ pour pouvoir enregistrer des données.</p>
{% endif %}
</div>
<div class="dangerZone">
<h3>Supprimer cet objet</h3>
<form method="post" action="{{ url_for('object_delete', slug=game.slug, definition_id=d.id) }}"
onsubmit="return confirm('Supprimer définitivement l\'objet « {{ definition.name }} », sa table et toutes ses données ? Cette action est irréversible.');">
<button type="submit" class="danger">🗑️ Supprimer l'objet « {{ definition.name }} »</button>
</form>
</div>
</div>
<div class="floatPanelResizeHandle"></div>
</div>
<!-- Panneaux "Ajouter" (champ / entrée) de CET objet — déplaçables et
redimensionnables comme les autres, ouverts par le bouton "+ Ajouter"
du panneau ci-dessus. Formulaire vertical (un champ par ligne,
étiquette au-dessus) plutôt que la barre en ligne d'origine : au-delà
d'une poignée de champs, une seule ligne devenait illisible (voir
capture d'écran fournie par l'utilisateur — 10 champs alignés sur une
largeur d'écran normale). -->
<div class="floatPanel floatPanel--center hidden addFieldPanel" data-definition-id="{{ d.id }}" id="addFieldPanel-{{ d.id }}">
<div class="floatPanelHeader">
<span class="floatPanelTitle">⋮⋮ Ajouter un champ — {{ definition.name }}</span>
<button type="button" class="button is-small floatPanelFullscreenBtn" title="Plein écran">⛶</button>
<button type="button" class="button is-small" onclick="closeAddFieldPanel({{ d.id }})">✕</button>
</div>
<div class="floatPanelBody">
<form method="post" action="{{ url_for('object_field_add', slug=game.slug, definition_id=d.id) }}">
<div class="formField">
<label for="new_field_name-{{ d.id }}">Nom du champ</label>
<input type="text" id="new_field_name-{{ d.id }}" name="field_name" placeholder="ex. nom, pv, description..." required autocomplete="off">
</div>
<div class="formField">
<label for="new_field_type-{{ d.id }}">Type</label>
<select id="new_field_type-{{ d.id }}" name="field_type" class="fieldTypeSelect">
{% for key, val in field_types.items() %}
<option value="{{ key }}">{{ val.label }}</option>
{% endfor %}
</select>
</div>
<div class="formField relationTarget" style="display:none;">
<label for="new_field_relation-{{ d.id }}">Objet lié</label>
<select id="new_field_relation-{{ d.id }}" name="field_relation">
{% for od in existing_definitions %}
<option value="{{ od.id }}">{{ od.name }}</option>
{% endfor %}
</select>
</div>
<div class="fieldRow boundsTarget" style="display:none;">
<div class="formField">
<label for="new_field_min-{{ d.id }}">Valeur mini</label>
<input type="number" step="any" id="new_field_min-{{ d.id }}" name="field_min" placeholder="ex. 0">
</div>
<div class="formField">
<label for="new_field_max-{{ d.id }}">Valeur maxi</label>
<input type="number" step="any" id="new_field_max-{{ d.id }}" name="field_max" placeholder="ex. 100">
</div>
</div>
<div class="formField">
<label for="new_field_required-{{ d.id }}">Obligatoire ?</label>
<select id="new_field_required-{{ d.id }}" name="field_required">
<option value="0">Non</option>
<option value="1">Oui</option>
</select>
</div>
<div class="actionsRow"><button type="submit" class="primary">+ Ajouter le champ</button></div>
</form>
</div>
<div class="floatPanelResizeHandle"></div>
</div>
{% if definition.fields %}
<div class="floatPanel floatPanel--center hidden addEntryPanel" data-definition-id="{{ d.id }}" id="addEntryPanel-{{ d.id }}">
<div class="floatPanelHeader">
<span class="floatPanelTitle">⋮⋮ Ajouter une entrée — {{ definition.name }}</span>
<button type="button" class="button is-small floatPanelFullscreenBtn" title="Plein écran">⛶</button>
<button type="button" class="button is-small" onclick="closeAddEntryPanel({{ d.id }})">✕</button>
</div>
<div class="floatPanelBody">
<form method="post" action="{{ url_for('data_new', slug=game.slug, definition_id=d.id) }}">
{% for f in definition.fields %}
{% set col = f.name|colname %}
<div class="formField">
<label for="new_entry_{{ col }}-{{ d.id }}">{{ f.name }}{% if f.required %} *{% endif %}</label>
{% if f.type == 'texte' %}
<input type="text" id="new_entry_{{ col }}-{{ d.id }}" name="{{ col }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'texte_long' %}
<textarea id="new_entry_{{ col }}-{{ d.id }}" name="{{ col }}" rows="4" {{ 'required' if f.required else '' }}></textarea>
{% elif f.type == 'nombre_entier' %}
<input type="number" step="1" id="new_entry_{{ col }}-{{ d.id }}" name="{{ col }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'nombre_decimal' %}
<input type="number" step="any" id="new_entry_{{ col }}-{{ d.id }}" name="{{ col }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'booleen' %}
<label class="checkbox"><input type="checkbox" id="new_entry_{{ col }}-{{ d.id }}" name="{{ col }}"> Oui</label>
{% elif f.type == 'date' %}
<input type="date" id="new_entry_{{ col }}-{{ d.id }}" name="{{ col }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'relation' %}
<select id="new_entry_{{ col }}-{{ d.id }}" name="{{ col }}_id" {{ 'required' if f.required else '' }}>
<option value="">—</option>
{% for opt in relation_options_by_definition[d.id].get(f.id, []) %}
<option value="{{ opt.id }}">{{ opt.label }}</option>
{% endfor %}
</select>
{% if not relation_options_by_definition[d.id].get(f.id) %}
<p class="help">Aucune entrée n'existe encore pour l'objet lié.</p>
{% endif %}
{% endif %}
</div>
{% endfor %}
<div class="actionsRow"><button type="submit" class="primary">+ Ajouter l'entrée</button></div>
</form>
</div>
<div class="floatPanelResizeHandle"></div>
</div>
<!-- Panneau de détail d'UNE entrée (voir openRowDetailPanel()) — un seul
panneau PAR OBJET (pas un par ligne, pour ne jamais alourdir la page
même avec beaucoup d'entrées) : son contenu est reconstruit à
l'ouverture en lisant directement la ligne correspondante du tableau
"Données enregistrées" (valeurs à jour, y compris une modification
pas encore enregistrée). Champ par ligne, étiquette bien visible —
pensé pour un objet avec beaucoup de colonnes, illisible en tableau
serré (voir la barre "🔧 Colonnes" pour l'inverse : cacher des
colonnes plutôt que tout regarder en détail). -->
<div class="floatPanel floatPanel--center hidden rowDetailPanel" data-definition-id="{{ d.id }}" id="rowDetailPanel-{{ d.id }}">
<div class="floatPanelHeader">
<span class="floatPanelTitle rowDetailPanelTitle">⋮⋮ Entrée — {{ definition.name }}</span>
<button type="button" class="button is-small floatPanelFullscreenBtn" title="Plein écran">⛶</button>
<button type="button" class="button is-small" onclick="closeRowDetailPanel({{ d.id }})">✕</button>
</div>
<div class="floatPanelBody rowDetailPanelBody"></div>
<div class="floatPanelResizeHandle"></div>
</div>
{% endif %}
{% endfor %}
<template id="fieldRowTemplate">
<tr>
<td><input class="input is-small" type="text" name="field_name[]" placeholder="ex. nom, pv..." required></td>
<td>
<div class="select is-small">
<select name="field_type[]" class="fieldTypeSelect">
{% for key, val in field_types.items() %}
<option value="{{ key }}">{{ val.label }}</option>
{% endfor %}
</select>
</div>
</td>
<td>
<div class="select is-small relationTarget" style="display:none;">
<select name="field_relation[]">
{% for d in definitions %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
</div>
<span class="has-text-grey relationPlaceholder">—</span>
</td>
<td>
<input class="input is-small boundsTarget" type="number" step="any" name="field_min[]" placeholder="0" style="display:none;">
<span class="has-text-grey boundsPlaceholder">—</span>
</td>
<td>
<input class="input is-small boundsTarget" type="number" step="any" name="field_max[]" placeholder="100" style="display:none;">
<span class="has-text-grey boundsPlaceholder">—</span>
</td>
<td>
<div class="select is-small">
<select name="field_required[]">
<option value="0">Non</option>
<option value="1">Oui</option>
</select>
</div>
</td>
<td class="fieldsTableActions"><button type="button" class="button is-small is-danger btnRemoveField">🗑️</button></td>
</tr>
</template>
<script src="{{ url_for('static', filename='object_form.js') }}"></script>
<script>
function switchDashTab(tab) {
document.querySelectorAll('#dashTabs .builderTabBtn').forEach(function (btn) {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
document.querySelectorAll('.builderTabPanel').forEach(function (panel) {
panel.classList.toggle('active', panel.dataset.tabPanel === tab);
});
}
function toggleDashCreate(tab) {
var bar = document.getElementById('dashCreate-' + tab);
bar.style.display = bar.style.display === 'none' ? 'flex' : 'none';
if (bar.style.display === 'flex') {
var firstInput = bar.querySelector('input, select');
if (firstInput) firstInput.focus();
}
}
// Barre "+ Créer une variable" : pour un type Objet/Tableau, la valeur de
// départ se configure via le panneau à champs (voir openVarValuePanel) —
// la saisie libre n'a alors plus de sens, remplacée par un aperçu en
// lecture seule + le bouton "🔧 Configurer".
function onVarTypeSelectChange(sel) {
var valueInput = document.getElementById('newVarValue');
var configureBtn = document.getElementById('newVarConfigureBtn');
var structured = sel.value === 'objet' || sel.value === 'tableau';
valueInput.readOnly = structured;
configureBtn.style.display = structured ? '' : 'none';
if (structured && !valueInput.value) valueInput.value = sel.value === 'objet' ? '{}' : '[]';
}
// Onglet initial : ?tab=... (utilisé par les liens "crée-en un" ailleurs
// dans l'outil, ex. l'éditeur d'écran, pour arriver directement sur le bon
// onglet plutôt que de laisser deviner où cliquer).
(function () {
var requestedTab = {{ (request.args.get('tab') or '')|tojson }};
if (requestedTab) switchDashTab(requestedTab);
})();
// ---------- Panneaux flottants : glisser-déposer + redimensionnement ----------
// Générique — utilisé aussi bien par "Nouvel objet" que par un panneau
// d'édition d'objet (un par objet, voir plus bas) : mêmes gestes partout
// dans l'outil plutôt qu'une variante par panneau.
function makeFloatPanelDraggable(panel, header) {
var dragging = false, offsetX = 0, offsetY = 0;
header.addEventListener('mousedown', function (e) {
if (e.target.closest('button')) return;
dragging = true;
// Glisser un panneau en plein écran doit d'abord l'en faire sortir —
// sinon left/top posés juste en dessous seraient aussitôt repris en
// main par bottom/right encore actifs (voir makeFloatPanelFullscreenable).
if (panel.classList.contains('floatPanel--fullscreen')) panel._fsToggle.exit();
var rect = panel.getBoundingClientRect();
panel.style.left = rect.left + 'px';
panel.style.top = rect.top + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
panel.style.transform = 'none';
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', function (e) {
if (!dragging) return;
panel.style.left = (e.clientX - offsetX) + 'px';
panel.style.top = (e.clientY - offsetY) + 'px';
});
document.addEventListener('mouseup', function () {
dragging = false;
document.body.style.userSelect = '';
});
}
function makeFloatPanelResizable(panel, handle) {
var resizing = false, startX = 0, startY = 0, startW = 0, startH = 0;
handle.addEventListener('mousedown', function (e) {
resizing = true;
// Même remarque que pour le glisser-déposer : sortir du plein écran
// d'abord, sinon bottom/right encore actifs reprendraient la main sur
// la largeur/hauteur posées ci-dessous.
if (panel.classList.contains('floatPanel--fullscreen')) panel._fsToggle.exit();
var rect = panel.getBoundingClientRect();
startX = e.clientX; startY = e.clientY;
startW = rect.width; startH = rect.height;
// Un coin qui redimensionne doit d'abord "détacher" le panneau de sa
// largeur/hauteur fixées par la variante CSS (--center...) — sinon la
// largeur en % ou le max-width continueraient à l'emporter sur le
// redimensionnement manuel.
panel.style.width = startW + 'px';
panel.style.maxWidth = 'none';
document.body.style.userSelect = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', function (e) {
if (!resizing) return;
panel.style.width = Math.max(320, startW + (e.clientX - startX)) + 'px';
panel.style.height = Math.max(200, startH + (e.clientY - startY)) + 'px';
});
document.addEventListener('mouseup', function () {
resizing = false;
document.body.style.userSelect = '';
});
}
// Plein écran (voir switchDashTab pour l'esprit général : chaque panneau
// s'ouvre en plein écran par défaut — plus pratique dès qu'il y a plus de
// quelques champs — avec un bouton pour revenir à sa taille/position
// précédente, mémorisée le temps de la session (pas persistée). Renvoie un
// objet {enter, exit} et pose panel._fsToggle dessus, pour que le code qui
// OUVRE le panneau (openXxxPanel ci-dessous) puisse appeler enter() sans
// avoir à re-chercher le bouton.
function makeFloatPanelFullscreenable(panel, toggleBtn) {
var saved = null;
function enter() {
saved = {
left: panel.style.left, top: panel.style.top, right: panel.style.right,
width: panel.style.width, height: panel.style.height,
maxWidth: panel.style.maxWidth, transform: panel.style.transform,
};
panel.classList.add('floatPanel--fullscreen');
panel.style.left = '12px'; panel.style.top = '12px'; panel.style.right = '12px'; panel.style.bottom = '12px';
panel.style.width = 'auto'; panel.style.height = 'auto'; panel.style.maxWidth = 'none'; panel.style.transform = 'none';
toggleBtn.title = 'Quitter le plein écran';
}
function exit() {
panel.classList.remove('floatPanel--fullscreen');
panel.style.bottom = '';
if (saved) {
panel.style.left = saved.left; panel.style.top = saved.top; panel.style.right = saved.right;
panel.style.width = saved.width; panel.style.height = saved.height;
panel.style.maxWidth = saved.maxWidth; panel.style.transform = saved.transform;
}
toggleBtn.title = 'Plein écran';
}
toggleBtn.addEventListener('click', function () {
if (panel.classList.contains('floatPanel--fullscreen')) exit(); else enter();
});
panel._fsToggle = { enter: enter, exit: exit };
return panel._fsToggle;
}
// ---------- Panneau déplaçable "Nouvel objet" ----------
(function () {
var panel = document.getElementById('newObjectPanel');
makeFloatPanelDraggable(panel, document.getElementById('newObjectPanelHeader'));
makeFloatPanelResizable(panel, document.getElementById('newObjectPanelResizeHandle'));
makeFloatPanelFullscreenable(panel, panel.querySelector('.floatPanelFullscreenBtn'));
window.openNewObjectPanel = function () { panel.classList.remove('hidden'); panel._fsToggle.enter(); };
window.closeNewObjectPanel = function () { panel.classList.add('hidden'); };
})();
// ---------- Panneaux déplaçables "Modifier un objet" (un par objet) ----------
(function () {
document.querySelectorAll('.objectEditPanel').forEach(function (panel) {
makeFloatPanelDraggable(panel, panel.querySelector('.floatPanelHeader'));
makeFloatPanelResizable(panel, panel.querySelector('.floatPanelResizeHandle'));
makeFloatPanelFullscreenable(panel, panel.querySelector('.floatPanelFullscreenBtn'));
});
window.openObjectEditPanel = function (definitionId, subtab) {
document.querySelectorAll('.objectEditPanel').forEach(function (panel) {
var isTarget = panel.dataset.definitionId === String(definitionId);
panel.classList.toggle('hidden', !isTarget);
if (isTarget) panel._fsToggle.enter();
});
if (subtab) {
var panel = document.querySelector('.objectEditPanel[data-definition-id="' + definitionId + '"]');
var btn = panel && panel.querySelector('.objectPanelTabs [data-optab="' + subtab + '"]');
if (btn) switchObjectPanelTab(btn, subtab);
}
};
window.closeObjectEditPanel = function (definitionId) {
var panel = document.querySelector('.objectEditPanel[data-definition-id="' + definitionId + '"]');
if (panel) panel.classList.add('hidden');
};
// Réouverture automatique après un aller-retour serveur (ajout/édition/
// suppression d'un champ ou d'une entrée, renommage...) : ?edit=<id>
// (et ?subtab=data pour rouvrir sur le bon sous-onglet), posés par les
// routes concernées (voir routes/objects/) sur leur redirection vers ce
// tableau de bord — sans ça, chaque action fermait le panneau et
// obligeait à recliquer pour voir le résultat.
var requestedEdit = {{ (request.args.get('edit') or '')|tojson }};
var requestedSubtab = {{ (request.args.get('subtab') or '')|tojson }};
if (requestedEdit) openObjectEditPanel(requestedEdit, requestedSubtab);
})();
// ---------- Panneaux déplaçables "Ajouter un champ"/"Ajouter une entrée" ----------
// Un panneau par objet et par action (voir game_dashboard.html, ouverts
// par le bouton "+ Ajouter" du panneau "Modifier un objet") — formulaire
// vertical, un champ par ligne : au-delà d'une poignée de champs, une
// seule ligne (l'ancienne barre en ligne) devenait illisible.
(function () {
var NUMERIC_FIELD_TYPES = ['nombre_entier', 'nombre_decimal'];
document.querySelectorAll('.addFieldPanel').forEach(function (panel) {
makeFloatPanelDraggable(panel, panel.querySelector('.floatPanelHeader'));
makeFloatPanelResizable(panel, panel.querySelector('.floatPanelResizeHandle'));
makeFloatPanelFullscreenable(panel, panel.querySelector('.floatPanelFullscreenBtn'));
var typeSelect = panel.querySelector('.fieldTypeSelect');
var relationTarget = panel.querySelector('.relationTarget');
var boundsTarget = panel.querySelector('.boundsTarget');
function syncAddFieldVisibility() {
relationTarget.style.display = typeSelect.value === 'relation' ? '' : 'none';
boundsTarget.style.display = NUMERIC_FIELD_TYPES.indexOf(typeSelect.value) !== -1 ? '' : 'none';
}
typeSelect.addEventListener('change', syncAddFieldVisibility);
syncAddFieldVisibility();
});
document.querySelectorAll('.addEntryPanel').forEach(function (panel) {
makeFloatPanelDraggable(panel, panel.querySelector('.floatPanelHeader'));
makeFloatPanelResizable(panel, panel.querySelector('.floatPanelResizeHandle'));
makeFloatPanelFullscreenable(panel, panel.querySelector('.floatPanelFullscreenBtn'));
});
window.openAddFieldPanel = function (definitionId) {
var panel = document.querySelector('.addFieldPanel[data-definition-id="' + definitionId + '"]');
if (panel) { panel.classList.remove('hidden'); panel._fsToggle.enter(); }
};
window.closeAddFieldPanel = function (definitionId) {
var panel = document.querySelector('.addFieldPanel[data-definition-id="' + definitionId + '"]');
if (panel) panel.classList.add('hidden');
};
window.openAddEntryPanel = function (definitionId) {
var panel = document.querySelector('.addEntryPanel[data-definition-id="' + definitionId + '"]');
if (panel) { panel.classList.remove('hidden'); panel._fsToggle.enter(); }
};
window.closeAddEntryPanel = function (definitionId) {
var panel = document.querySelector('.addEntryPanel[data-definition-id="' + definitionId + '"]');
if (panel) panel.classList.add('hidden');
};
})();
// ---------- Filtre de colonnes (onglet Données) ----------
function toggleColumnFilterMenu(definitionId) {
var menu = document.getElementById('columnFilterMenu-' + definitionId);
if (!menu) return;
var opening = menu.classList.contains('hidden');
// Un seul menu de filtre ouvert à la fois, tous objets confondus — évite
// d'en laisser traîner un ouvert par-dessus le panneau d'un autre objet.
document.querySelectorAll('.columnFilterMenu').forEach(function (m) { m.classList.add('hidden'); });
if (opening) menu.classList.remove('hidden');
}
document.addEventListener('click', function (e) {
if (e.target.closest('.columnFilterWrap')) return;
document.querySelectorAll('.columnFilterMenu').forEach(function (m) { m.classList.add('hidden'); });
});
function toggleDataColumn(definitionId, colKey, visible) {
var table = document.getElementById('dataTable-' + definitionId);
if (!table) return;
table.querySelectorAll('[data-col="' + colKey + '"]').forEach(function (cell) {
cell.style.display = visible ? '' : 'none';
});
}
// ---------- Panneau de détail d'une entrée (un par objet) ----------
(function () {
document.querySelectorAll('.rowDetailPanel').forEach(function (panel) {
makeFloatPanelDraggable(panel, panel.querySelector('.floatPanelHeader'));
makeFloatPanelResizable(panel, panel.querySelector('.floatPanelResizeHandle'));
makeFloatPanelFullscreenable(panel, panel.querySelector('.floatPanelFullscreenBtn'));
});
// Reconstruit le contenu du panneau à chaque ouverture, en lisant
// directement la ligne correspondante du tableau "Données enregistrées"
// déjà affiché — pas d'aller-retour serveur, et ça reflète une
// modification tout juste saisie mais pas encore "Enregistrer"ée.
window.openRowDetailPanel = function (definitionId, rowId) {
var panel = document.querySelector('.rowDetailPanel[data-definition-id="' + definitionId + '"]');
var table = document.getElementById('dataTable-' + definitionId);
if (!panel || !table) return;
var row = table.querySelector('tr[data-row-id="' + rowId + '"]');
if (!row) return;
var body = panel.querySelector('.rowDetailPanelBody');
body.innerHTML = '';
var idField = document.createElement('div');
idField.className = 'rowDetailField';
idField.innerHTML = '<div class="rowDetailLabel">#</div><div class="rowDetailValue">' + rowId + '</div>';
body.appendChild(idField);
row.querySelectorAll('td[data-field-label]').forEach(function (cell) {
var label = cell.dataset.fieldLabel;
var control = cell.querySelector('input, textarea, select');
var value = '—';
if (control) {
if (control.type === 'checkbox') value = control.checked ? 'Oui' : 'Non';
else if (control.tagName === 'SELECT') value = control.options[control.selectedIndex] ? control.options[control.selectedIndex].text : '—';
else value = control.value || '—';
}
var field = document.createElement('div');
field.className = 'rowDetailField';
var labelEl = document.createElement('div');
labelEl.className = 'rowDetailLabel';
labelEl.textContent = label;
var valueEl = document.createElement('div');
valueEl.className = 'rowDetailValue';
valueEl.textContent = value;
field.appendChild(labelEl);
field.appendChild(valueEl);
body.appendChild(field);
});
panel.classList.remove('hidden');
panel._fsToggle.enter();
};
window.closeRowDetailPanel = function (definitionId) {
var panel = document.querySelector('.rowDetailPanel[data-definition-id="' + definitionId + '"]');
if (panel) panel.classList.add('hidden');
};
})();
// Sous-onglets Champs/Données d'UN panneau précis (voir plus haut) — scopé
// au panneau ancêtre du bouton cliqué, jamais global (plusieurs objets
// peuvent chacun avoir leurs sous-onglets sur leur propre panneau, en même
// temps, sur la même page).
function switchObjectPanelTab(btn, tab) {
var panel = btn.closest('.objectEditPanel');
panel.querySelectorAll('.objectPanelTabs .builderTabBtn').forEach(function (b) {
b.classList.toggle('active', b.dataset.optab === tab);
});
panel.querySelectorAll('.objectPanelTabPanel').forEach(function (p) {
p.classList.toggle('active', p.dataset.optabPanel === tab);
});
}
// ---------- Panneau "Configurer la valeur" (variable Objet/Tableau) ----------
// Un seul panneau partagé (voir le commentaire sur son HTML) : à
// l'ouverture, mémorise vers quel <input> (targetId) le JSON reconstruit
// devra être réécrit, construit une ligne par clé/élément déjà présent
// dans le JSON actuel de cet <input>. Pas de bouton "OK" : chaque
// changement de ligne réécrit tout de suite le JSON (saveVarValuePanel),
// exactement comme si on tapait directement dans le champ cible.
(function () {
var panel = document.getElementById('varValuePanel');
var rowsWrap = document.getElementById('varValueRows');
var rowTemplate = document.getElementById('varValueRowTemplate');
var title = document.getElementById('varValuePanelTitle');
var hint = document.getElementById('varValuePanelHint');
makeFloatPanelDraggable(panel, document.getElementById('varValuePanelHeader'));
makeFloatPanelResizable(panel, document.getElementById('varValuePanelResizeHandle'));
makeFloatPanelFullscreenable(panel, panel.querySelector('.floatPanelFullscreenBtn'));
function valueTypeOf(v) {
if (typeof v === 'boolean') return 'booleen';
if (typeof v === 'number') return 'nombre';
return 'texte'; // texte, et repli pour un objet/tableau imbriqué (non éditable ici, voir addRowFrom)
}
function valueTextOf(v, type) {
if (type === 'booleen') return v ? 'Oui' : 'Non';
if (typeof v === 'object' && v !== null) return JSON.stringify(v); // imbriqué : préservé tel quel, en texte opaque
return v == null ? '' : String(v);
}
function coerceRowValue(type, text) {
if (type === 'nombre') { var n = parseFloat(text); return isNaN(n) ? 0 : n; }
if (type === 'booleen') return ['oui', 'vrai', '1', 'true'].indexOf(text.trim().toLowerCase()) !== -1;
return text;
}
function addRowFrom(key, value) {
var clone = rowTemplate.content.cloneNode(true);
var row = clone.querySelector('.varValueRow');
var type = valueTypeOf(value);
if (key !== null) row.querySelector('.varValueKey').value = key;
row.querySelector('.varValueType').value = type;
row.querySelector('.varValueValue').value = valueTextOf(value, type);
row.querySelector('.varValueRemoveBtn').addEventListener('click', function () { row.remove(); saveVarValuePanel(); });
rowsWrap.appendChild(clone);
}
window.addVarValueRow = function () {
addRowFrom(panel.dataset.mode === 'tableau' ? null : '', '');
saveVarValuePanel();
};
// Enregistrement automatique : pas de bouton "OK" — chaque changement
// (clé, type, valeur, ajout/retrait de ligne) réécrit tout de suite le
// JSON dans le champ cible, comme si on tapait directement dedans.
rowsWrap.addEventListener('input', saveVarValuePanel);
rowsWrap.addEventListener('change', saveVarValuePanel);
window.openVarValuePanel = function (targetInputId, mode) {
var input = document.getElementById(targetInputId);
if (!input) return;
panel.dataset.target = targetInputId;
panel.dataset.mode = mode;
rowsWrap.innerHTML = '';
var parsed;
try { parsed = JSON.parse(input.value || (mode === 'objet' ? '{}' : '[]')); } catch (e) { parsed = mode === 'objet' ? {} : []; }
if (mode === 'tableau') {
title.textContent = '⋮⋮ Configurer le tableau';
hint.textContent = 'Une ligne par élément — valeurs simples uniquement (pas de tableau/objet imbriqué).';
(Array.isArray(parsed) ? parsed : []).forEach(function (v) { addRowFrom(null, v); });
} else {
title.textContent = '⋮⋮ Configurer l\'objet';
hint.textContent = 'Une ligne par clé — valeurs simples uniquement (pas de tableau/objet imbriqué).';
Object.keys(parsed || {}).forEach(function (k) { addRowFrom(k, parsed[k]); });
}
if (!rowsWrap.children.length) window.addVarValueRow();
panel.classList.remove('hidden');
panel._fsToggle.enter();
};
window.closeVarValuePanel = function () {
panel.classList.add('hidden');
};
function saveVarValuePanel() {
if (panel.classList.contains('hidden')) return;
var mode = panel.dataset.mode;
var result = mode === 'tableau' ? [] : {};
rowsWrap.querySelectorAll('.varValueRow').forEach(function (row) {
var type = row.querySelector('.varValueType').value;
var value = coerceRowValue(type, row.querySelector('.varValueValue').value);
if (mode === 'tableau') {
result.push(value);
} else {
var key = row.querySelector('.varValueKey').value.trim();
if (key) result[key] = value;
}
});
var input = document.getElementById(panel.dataset.target);
if (input) {
input.value = JSON.stringify(result);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
}
})();
</script>
{% endblock %}