Files
Forge-Engine/templates/game_dashboard.html
T
williamandClaude Sonnet 5 943bf265de Panneaux flottants en plein écran par défaut, avec bouton pour en sortir
Chaque panneau (Nouvel objet, Modifier un objet, Ajouter un champ,
Ajouter une entrée) s'ouvre désormais en plein écran par défaut (marge
de 12px) plutôt qu'en petite fenêtre centrée — plus confortable dès
qu'il y a plusieurs champs/entrées à voir en même temps. Un bouton ⛶
dans l'en-tête bascule vers/depuis la taille et la position précédentes
(mémorisées le temps de la session, pas persistées), pour qui préfère
un panneau plus petit à côté du reste du tableau de bord.

makeFloatPanelFullscreenable(panel, toggleBtn) — générique, posée sur
panel._fsToggle — appelée par les 4 familles de panneaux existantes.
makeFloatPanelDraggable()/makeFloatPanelResizable() sortent d'abord
proprement du plein écran si l'utilisateur interagit manuellement
(glisser l'en-tête ou tirer le coin) : sans ça, bottom/right encore
actifs en plein écran auraient repris la main sur la position/taille
qu'on vient de poser.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:06:58 +02:00

832 lines
42 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ game.name }} — Forge Engine{% endblock %}
{% block content_class %}content-wide{% endblock %}
{% block content %}
<!-- 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></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 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 name="type">
{% for key, val in variable_types.items() %}
<option value="{{ key }}">{{ val.label }}</option>
{% endfor %}
</select>
<input type="text" name="default_value" placeholder="Valeur de départ">
<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></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><input type="text" form="varEditForm{{ v.id }}" name="value" value="{{ v.value or '' }}" aria-label="Valeur de « {{ v.name }} »"></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>
<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>
<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>
{% if definition.fields %}
<button type="button" class="button is-small primary" onclick="openAddEntryPanel({{ d.id }})">+ Ajouter</button>
{% endif %}
</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{{ r.id }}"></form>
<form method="post" action="{{ url_for('data_delete', slug=game.slug, definition_id=d.id, row_id=r.id) }}" id="dataDeleteForm{{ 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">
<thead>
<tr><th>#</th>{% for f in definition.fields %}<th>{{ f.name }}</th>{% endfor %}<th></th></tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ r.id }}</td>
{% for f in definition.fields %}
{% set col = f.name|colname %}
<td>
{% if f.type == 'texte' %}
<input class="input is-small" type="text" form="dataEditForm{{ r.id }}" name="{{ col }}" value="{{ r[col] or '' }}">
{% elif f.type == 'texte_long' %}
<textarea class="textarea is-small" form="dataEditForm{{ 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{{ 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{{ r.id }}" name="{{ col }}" {{ 'checked' if r[col] else '' }}></label>
{% elif f.type == 'date' %}
<input class="input is-small" type="date" form="dataEditForm{{ r.id }}" name="{{ col }}" value="{{ r[col] or '' }}">
{% elif f.type == 'relation' %}
<div class="select is-small">
<select form="dataEditForm{{ 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="submit" form="dataEditForm{{ r.id }}" class="button is-small">Enregistrer</button>
<button type="submit" form="dataDeleteForm{{ 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>
{% 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();
}
}
// 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');
};
})();
// 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);
});
}
</script>
{% endblock %}