feat: gestion complète des actions (clone, delete, reorder, presets)
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled

- actions_api.py: POST /create, POST /{idx}/clone, DELETE /{idx},
  PUT /reorder, PUT /{idx} étendu (ui_trigger.*),
  GET/POST/POST-restore/DELETE /presets
- admin_api.py: route /admin/actions simplifiée (plus de pré-chargement Jinja2)
- actions.html: page full JS dynamique
  · Drag & drop pour réordonner (barre de sauvegarde explicite)
  · Clone, suppression, création par bouton
  · Edit form complet: général, cadre, fond, traitement, UI bouton
  · Sélecteurs cadres/fonds avec dropdown + saisie libre
  · Mapping bouton dynamique
  · Presets: save/restore/delete avec nom + description
This commit is contained in:
2026-07-17 17:36:32 +02:00
parent 5bc55c7591
commit 2393396948
3 changed files with 828 additions and 235 deletions
+233 -57
View File
@@ -1,110 +1,188 @@
"""API de gestion des actions photobooth-app + mapping bouton.""" """API de gestion des actions photobooth-app + mapping bouton + presets."""
import copy
import json
import logging import logging
import re
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Request, Body from fastapi import APIRouter, Request, Body
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# ── Action par défaut ────────────────────────────────────────────────────────
DEFAULT_ACTION: dict = {
"name": "Nouvelle action",
"jobcontrol": {"countdown_capture": 5.0},
"processing": {
"remove_background": False,
"fill_background_enable": False,
"fill_background_color": "#ededed",
"img_background_enable": False,
"img_background_file": None,
"image_filter": "original",
"img_frame_enable": False,
"img_frame_file": None,
"texts_enable": False,
"texts": [],
},
"trigger": {
"ui_trigger": {
"show_button": False,
"title": "Nouvelle action",
"icon": "Photo",
"use_custom_color": False,
"custom_color": "#016911",
},
"keyboard_trigger": {"keycode": ""},
"gpio_trigger": {"pin": "", "trigger_on": "pressed"},
},
}
def _presets_dir(request: Request) -> Path:
base = Path(request.app.state.config_service._path).parent.parent / "data" / "action_presets"
base.mkdir(parents=True, exist_ok=True)
return base
# ── Lecture ───────────────────────────────────────────────────────────────────
@router.get("/actions/photobooth") @router.get("/actions/photobooth")
async def get_pb_actions(request: Request): async def get_pb_actions(request: Request):
"""Retourne les actions image de photobooth-app + le mapping bouton actuel."""
pb = request.app.state.photobooth_service pb = request.app.state.photobooth_service
cfg = request.app.state.config cfg = request.app.state.config
pb_config = await pb.read_pb_config() pb_config = await pb.read_pb_config()
actions = pb_config.get("actions", {}).get("image", []) actions = pb_config.get("actions", {}).get("image", [])
# Enrichit avec l'index
for i, action in enumerate(actions): for i, action in enumerate(actions):
action["_index"] = i action["_index"] = i
return {"actions": actions, "button_mapping": cfg.button_actions}
return {
"actions": actions,
"button_mapping": cfg.button_actions,
}
@router.get("/actions/assets") @router.get("/actions/assets")
async def get_assets(request: Request): async def get_assets(request: Request):
"""Liste les cadres et fonds disponibles dans userdata."""
pb = request.app.state.photobooth_service pb = request.app.state.photobooth_service
frames = await pb.list_userdata_frames() frames = await pb.list_userdata_frames()
backgrounds = await pb.list_userdata_backgrounds() backgrounds = await pb.list_userdata_backgrounds()
return {"frames": frames, "backgrounds": backgrounds} return {"frames": frames, "backgrounds": backgrounds}
@router.put("/actions/mapping") # ── CRUD actions ──────────────────────────────────────────────────────────────
async def update_button_mapping(
request: Request,
mapping: dict = Body(...),
):
"""
Met à jour le mapping clics → actions.
Body: { "1": {"label": "...", "photobooth_index": 0}, ... }
"""
config_svc = request.app.state.config_service
# Validation basique @router.post("/actions/photobooth")
for k, v in mapping.items(): async def create_action(request: Request, body: dict = Body(default={})):
if not isinstance(v, dict) or "photobooth_index" not in v: """Crée une nouvelle action vide."""
return JSONResponse({"error": f"Format invalide pour clic {k}"}, status_code=400) pb = request.app.state.photobooth_service
pb_config = await pb.read_pb_config()
actions = pb_config.setdefault("actions", {}).setdefault("image", [])
new_action = copy.deepcopy(DEFAULT_ACTION)
if body.get("name"):
new_action["name"] = body["name"]
actions.append(new_action)
await pb.write_pb_config(pb_config)
idx = len(actions) - 1
logger.info("Nouvelle action créée: index %d, nom=%s", idx, new_action["name"])
return {"ok": True, "index": idx, "action": new_action}
config_svc.save_button_actions(mapping)
request.app.state.config.button_actions = mapping
# Met aussi à jour le button_service @router.post("/actions/photobooth/{index}/clone")
btn = request.app.state.button_service async def clone_action(request: Request, index: int):
btn._btn_actions = mapping """Clone une action (insérée juste après l'original)."""
pb = request.app.state.photobooth_service
pb_config = await pb.read_pb_config()
actions = pb_config.get("actions", {}).get("image", [])
if index < 0 or index >= len(actions):
return JSONResponse({"error": f"Index {index} invalide"}, status_code=400)
cloned = copy.deepcopy(actions[index])
cloned.pop("_index", None)
cloned["name"] = f"{cloned.get('name', 'Action')} (copie)"
actions.insert(index + 1, cloned)
await pb.write_pb_config(pb_config)
logger.info("Action %d clonée → %d: %s", index, index + 1, cloned["name"])
return {"ok": True, "index": index + 1, "action": cloned}
logger.info("Mapping bouton mis à jour: %s", mapping)
return {"ok": True, "mapping": mapping} @router.delete("/actions/photobooth/{index}")
async def delete_action(request: Request, index: int):
"""Supprime une action."""
pb = request.app.state.photobooth_service
pb_config = await pb.read_pb_config()
actions = pb_config.get("actions", {}).get("image", [])
if index < 0 or index >= len(actions):
return JSONResponse({"error": f"Index {index} invalide"}, status_code=400)
if len(actions) <= 1:
return JSONResponse({"error": "Impossible de supprimer la dernière action"}, status_code=400)
removed = actions.pop(index)
await pb.write_pb_config(pb_config)
logger.info("Action %d supprimée: %s", index, removed.get("name"))
return {"ok": True, "removed_name": removed.get("name")}
@router.put("/actions/photobooth/reorder")
async def reorder_actions(request: Request, order: list = Body(...)):
"""Réordonne les actions. Body: [ancien_idx_0, ancien_idx_1, …]"""
pb = request.app.state.photobooth_service
pb_config = await pb.read_pb_config()
actions = pb_config.get("actions", {}).get("image", [])
if sorted(order) != list(range(len(actions))):
return JSONResponse({"error": "Ordre invalide"}, status_code=400)
pb_config["actions"]["image"] = [actions[i] for i in order]
await pb.write_pb_config(pb_config)
logger.info("Actions réordonnées: %s", order)
return {"ok": True, "order": order}
@router.put("/actions/photobooth/{index}") @router.put("/actions/photobooth/{index}")
async def update_pb_action( async def update_pb_action(request: Request, index: int, updates: dict = Body(...)):
request: Request, """Met à jour une action (tous les champs sauf GPIO/keyboard trigger).
index: int,
updates: dict = Body(...), Champs plats acceptés:
): name, countdown_capture,
""" img_frame_enable, img_frame_file,
Met à jour une action image de photobooth-app (cadre, fond, countdown, etc.) img_background_enable, img_background_file,
updates peut contenir: countdown_capture, img_frame_file, img_background_file, fill_background_enable, fill_background_color,
remove_background, image_filter, name remove_background, image_filter, texts_enable,
ui_show_button, ui_title, ui_icon, ui_use_custom_color, ui_custom_color
""" """
pb = request.app.state.photobooth_service pb = request.app.state.photobooth_service
pb_config = await pb.read_pb_config() pb_config = await pb.read_pb_config()
actions = pb_config.get("actions", {}).get("image", []) actions = pb_config.get("actions", {}).get("image", [])
if index < 0 or index >= len(actions): if index < 0 or index >= len(actions):
return JSONResponse({"error": f"Index {index} invalide (max: {len(actions)-1})"}, status_code=400) return JSONResponse({"error": f"Index {index} invalide"}, status_code=400)
action = actions[index] action = actions[index]
# Mise à jour des champs autorisés if "name" in updates:
allowed_root = {"name"} action["name"] = updates["name"]
allowed_processing = {"remove_background", "img_frame_file", "img_background_file",
"image_filter", "fill_background_enable", "fill_background_color",
"img_background_enable", "texts_enable"}
allowed_jobcontrol = {"countdown_capture"}
for key, value in updates.items(): if "countdown_capture" in updates:
if key in allowed_root: action.setdefault("jobcontrol", {})["countdown_capture"] = float(updates["countdown_capture"])
action[key] = value
elif key in allowed_processing: proc = action.setdefault("processing", {})
action.setdefault("processing", {})[key] = value for key in ("img_frame_enable", "img_frame_file",
elif key in allowed_jobcontrol: "img_background_enable", "img_background_file",
action.setdefault("jobcontrol", {})[key] = value "fill_background_enable", "fill_background_color",
"remove_background", "image_filter", "texts_enable"):
if key in updates:
proc[key] = updates[key]
ui = action.setdefault("trigger", {}).setdefault("ui_trigger", {})
for key in ("show_button", "title", "icon", "use_custom_color", "custom_color"):
if f"ui_{key}" in updates:
ui[key] = updates[f"ui_{key}"]
pb_config["actions"]["image"][index] = action pb_config["actions"]["image"][index] = action
await pb.write_pb_config(pb_config) await pb.write_pb_config(pb_config)
logger.info("Action %d mise à jour: %s", index, list(updates.keys())) logger.info("Action %d mise à jour: %s", index, list(updates.keys()))
return {"ok": True, "index": index, "action": action} return {"ok": True, "index": index, "action": action}
# ── Déclenchement ─────────────────────────────────────────────────────────────
@router.post("/actions/trigger/{index}") @router.post("/actions/trigger/{index}")
async def trigger_action(request: Request, index: int): async def trigger_action(request: Request, index: int):
"""Déclenche une action directement (test).""" """Déclenche une action directement (test)."""
@@ -119,3 +197,101 @@ async def trigger_action(request: Request, index: int):
led.play("error") led.play("error")
btn.relay_on() btn.relay_on()
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"error": str(e)}, status_code=500)
# ── Mapping bouton ────────────────────────────────────────────────────────────
@router.put("/actions/mapping")
async def update_button_mapping(request: Request, mapping: dict = Body(...)):
"""Body: { "1": {"label": "", "photobooth_index": 0}, … }"""
config_svc = request.app.state.config_service
for k, v in mapping.items():
if not isinstance(v, dict) or "photobooth_index" not in v:
return JSONResponse({"error": f"Format invalide pour clic {k}"}, status_code=400)
config_svc.save_button_actions(mapping)
request.app.state.config.button_actions = mapping
request.app.state.button_service._btn_actions = mapping
logger.info("Mapping bouton mis à jour: %s", mapping)
return {"ok": True, "mapping": mapping}
# ── Presets ───────────────────────────────────────────────────────────────────
@router.get("/actions/presets")
async def list_presets(request: Request):
pdir = _presets_dir(request)
presets = []
for f in sorted(pdir.glob("*.json")):
try:
data = json.loads(f.read_text(encoding="utf-8"))
presets.append({
"name": f.stem,
"label": data.get("label", f.stem),
"description": data.get("description", ""),
"action_count": len(data.get("actions", [])),
"saved_at": data.get("saved_at", ""),
})
except Exception:
pass
return {"presets": presets}
@router.post("/actions/presets")
async def save_preset(request: Request, body: dict = Body(...)):
"""Sauvegarde les actions actuelles comme preset."""
label = body.get("name", "").strip()
description = body.get("description", "").strip()
if not label:
return JSONResponse({"error": "Nom requis"}, status_code=400)
safe_name = re.sub(r"[^a-zA-Z0-9_\-]", "_", label).strip("_")
if not safe_name:
return JSONResponse({"error": "Nom invalide"}, status_code=400)
pb = request.app.state.photobooth_service
pb_config = await pb.read_pb_config()
actions = pb_config.get("actions", {}).get("image", [])
clean = [{k: v for k, v in copy.deepcopy(a).items() if k != "_index"} for a in actions]
preset = {
"label": label,
"description": description,
"saved_at": datetime.now().isoformat(timespec="seconds"),
"actions": clean,
}
(_presets_dir(request) / f"{safe_name}.json").write_text(
json.dumps(preset, indent=2, ensure_ascii=False), encoding="utf-8"
)
logger.info("Preset sauvegardé: %s (%d actions)", safe_name, len(clean))
return {"ok": True, "name": safe_name, "action_count": len(clean)}
@router.post("/actions/presets/{name}/restore")
async def restore_preset(request: Request, name: str):
"""Restaure un preset (remplace les actions actuelles)."""
path = _presets_dir(request) / f"{name}.json"
if not path.exists():
return JSONResponse({"error": f"Preset introuvable: {name}"}, status_code=404)
try:
preset = json.loads(path.read_text(encoding="utf-8"))
except Exception as e:
return JSONResponse({"error": f"Lecture preset: {e}"}, status_code=500)
actions = preset.get("actions", [])
if not actions:
return JSONResponse({"error": "Preset vide"}, status_code=400)
pb = request.app.state.photobooth_service
pb_config = await pb.read_pb_config()
pb_config.setdefault("actions", {})["image"] = actions
await pb.write_pb_config(pb_config)
logger.info("Preset restauré: %s (%d actions)", name, len(actions))
return {"ok": True, "name": name, "action_count": len(actions)}
@router.delete("/actions/presets/{name}")
async def delete_preset(request: Request, name: str):
path = _presets_dir(request) / f"{name}.json"
if not path.exists():
return JSONResponse({"error": f"Preset introuvable: {name}"}, status_code=404)
path.unlink()
logger.info("Preset supprimé: %s", name)
return {"ok": True, "name": name}
+1 -10
View File
@@ -96,17 +96,8 @@ async def admin_actions(request: Request):
redirect = _require_auth(request) redirect = _require_auth(request)
if redirect: if redirect:
return redirect return redirect
pb = request.app.state.photobooth_service
cfg = request.app.state.config cfg = request.app.state.config
pb_config = await pb.read_pb_config() return _templates.TemplateResponse(request, "admin/actions.html", {"config": cfg})
actions = pb_config.get("actions", {}).get("image", [])
return _templates.TemplateResponse(request, "admin/actions.html", {
"config": cfg,
"pb_actions": actions,
"button_mapping": cfg.button_actions,
})
@router.get("/admin/settings", response_class=HTMLResponse) @router.get("/admin/settings", response_class=HTMLResponse)
+594 -168
View File
@@ -1,143 +1,170 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Actions — JH Photomaton{% endblock %} {% block title %}Actions — JH Photomaton{% endblock %}
{% block head %}
<style>
/* ── Cards actions ──────────────────────────────────────────────────── */
.action-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: .6rem;
transition: box-shadow .15s, opacity .15s;
cursor: default;
}
.action-card.drag-over {
border-color: var(--primary);
box-shadow: 0 0 0 2px var(--primary);
}
.action-card.dragging { opacity: .35; }
.ac-header {
display: flex;
align-items: center;
gap: .6rem;
padding: .75rem 1rem;
flex-wrap: wrap;
}
.drag-handle {
cursor: grab;
font-size: 1.2rem;
color: var(--text-muted);
user-select: none;
flex-shrink: 0;
letter-spacing: .05em;
}
.drag-handle:active { cursor: grabbing; }
.ac-idx {
font-size: .7rem;
background: var(--bg-muted);
color: var(--text-muted);
border-radius: 4px;
padding: .1rem .4rem;
flex-shrink: 0;
}
.ac-name {
font-weight: 600;
min-width: 120px;
}
.ac-chips {
display: flex;
flex-wrap: wrap;
gap: .35rem;
flex: 1;
}
.ac-chip {
font-size: .72rem;
background: var(--bg-muted);
border-radius: 10px;
padding: .1rem .5rem;
color: var(--text-muted);
white-space: nowrap;
}
.ac-chip.hl { background: var(--primary-muted, rgba(99,102,241,.15)); color: var(--primary); }
.ac-btns { display: flex; gap: .25rem; flex-shrink: 0; }
/* ── Edit form ──────────────────────────────────────────────────────── */
.ac-edit {
border-top: 1px solid var(--border);
padding: 1rem;
}
.edit-section { margin-bottom: 1rem; }
.edit-section-title {
font-size: .75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .07em;
color: var(--text-muted);
margin-bottom: .5rem;
padding-bottom: .25rem;
border-bottom: 1px solid var(--border);
}
.grid-3 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: .75rem;
}
/* ── Presets ────────────────────────────────────────────────────────── */
.preset-card {
display: flex;
align-items: center;
gap: .75rem;
padding: .6rem .9rem;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: .4rem;
}
.preset-info { flex: 1; }
.preset-name { font-weight: 600; }
.preset-meta { font-size: .75rem; color: var(--text-muted); }
/* ── Reorder bar ────────────────────────────────────────────────────── */
#reorder-bar {
display: none;
background: rgba(245,158,11,.1);
border: 1px solid rgba(245,158,11,.4);
border-radius: var(--radius);
padding: .5rem .75rem;
font-size: .85rem;
margin-bottom: .75rem;
align-items: center;
gap: .75rem;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="container"> <div class="container">
<h1 class="page-title">Gestion des actions & mapping bouton</h1>
<!-- Mapping clics → actions --> <!-- Header -->
<div class="flex items-center justify-between mb-2">
<h1 class="page-title" style="margin:0">Gestion des actions</h1>
<button class="btn btn-primary" onclick="newAction()">+ Nouvelle action</button>
</div>
<!-- ── Actions ──────────────────────────────────────────────────── -->
<div class="section"> <div class="section">
<div class="card-title">Mapping bouton → actions photobooth-app</div> <div class="card-title">Actions image photobooth-app <span class="text-muted text-sm" id="action-count"></span></div>
<!-- Barre "ordre modifié" -->
<div id="reorder-bar">
<span>↕ Ordre modifié</span>
<button class="btn btn-primary btn-sm" onclick="saveOrder()">💾 Sauvegarder l'ordre</button>
<button class="btn btn-ghost btn-sm" onclick="loadAll()">✕ Annuler</button>
</div>
<div id="actions-list">
<div class="empty-state"><div class="icon"></div>Chargement…</div>
</div>
</div>
<!-- ── Mapping bouton ───────────────────────────────────────────── -->
<div class="section">
<div class="card-title">Mapping bouton → actions</div>
<div class="card"> <div class="card">
<p class="text-sm text-muted mb-2">Associe chaque nombre de clics à une action dans photobooth-app. L'index correspond à la position dans la liste des actions (0 = première action).</p> <p class="text-sm text-muted mb-2">Associe chaque nombre de clics à une action.</p>
<table class="table"> <div id="mapping-table">
<thead> <div class="empty-state"><div class="icon"></div></div>
<tr> </div>
<th>Clics</th>
<th>Label affiché</th>
<th>Index action (photobooth)</th>
<th>Action correspondante</th>
<th></th>
</tr>
</thead>
<tbody>
{% for n in range(1, 5) %}
{% set mapping = button_mapping.get(n) or button_mapping.get(n|string) or {} %}
<tr>
<td class="font-bold">
{% if n == 1 %}1 clic{% else %}{{ n }} clics{% endif %}
</td>
<td>
<input type="text" id="label-{{ n }}" class="form-control" style="max-width:200px"
value="{{ mapping.get('label', '') }}" placeholder="Label…">
</td>
<td>
<select id="index-{{ n }}" class="form-control" style="max-width:80px">
{% for i in range(pb_actions|length) %}
<option value="{{ i }}" {% if mapping.get('photobooth_index') == i %}selected{% endif %}>{{ i }}</option>
{% endfor %}
</select>
</td>
<td class="text-sm text-muted" id="action-name-{{ n }}">
{% set idx = mapping.get('photobooth_index', 0) %}
{% if pb_actions and idx < pb_actions|length %}
{{ pb_actions[idx].name }}
{% else %}—{% endif %}
</td>
<td>
<button class="btn btn-ghost btn-sm" onclick="testAction({{ n }})">▶ Test</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<button class="btn btn-primary mt-2" onclick="saveMapping()">💾 Sauvegarder le mapping</button> <button class="btn btn-primary mt-2" onclick="saveMapping()">💾 Sauvegarder le mapping</button>
</div> </div>
</div> </div>
<!-- Actions photobooth-app --> <!-- ── Presets ──────────────────────────────────────────────────── -->
<div class="section"> <div class="section">
<div class="card-title">Actions image photobooth-app ({{ pb_actions|length }} au total)</div> <div class="card-title">Presets d'actions</div>
<div id="actions-list"> <div class="card">
{% for action in pb_actions %} <p class="text-sm text-muted mb-2">Sauvegarder la configuration actuelle pour la restaurer plus tard (ex : mariage, baptême, anniversaire…).</p>
<div class="card mb-1" id="action-{{ loop.index0 }}" style="margin-bottom:0.75rem"> <div class="flex gap-1 mb-2" style="flex-wrap:wrap">
<div class="flex items-center justify-between mb-1"> <input type="text" class="form-control" id="preset-name" placeholder="Nom du preset…" style="max-width:220px">
<div> <input type="text" class="form-control" id="preset-desc" placeholder="Description (optionnel)" style="max-width:280px">
<span class="badge badge-muted text-xs">Action {{ loop.index0 }}</span> <button class="btn btn-primary" onclick="savePreset()">💾 Sauvegarder le preset</button>
<span class="font-bold" style="margin-left:0.5rem">{{ action.name }}</span> </div>
</div> <div id="presets-list">
<div class="flex gap-1"> <div class="empty-state text-sm">Aucun preset sauvegardé</div>
<button class="btn btn-ghost btn-sm" onclick="toggleEdit({{ loop.index0 }})">✏ Modifier</button>
<button class="btn btn-ghost btn-sm" onclick="triggerAction({{ loop.index0 }})">▶ Déclencher</button>
</div>
</div>
<!-- Infos résumées -->
<div class="flex gap-2 text-sm text-muted" id="summary-{{ loop.index0 }}">
<span>⏱ {{ action.jobcontrol.get('countdown_capture', '?') }}s</span>
{% if action.processing.get('img_frame_file') %}
<span>🖼 {{ action.processing.img_frame_file.split('/')[-1] }}</span>
{% endif %}
{% if action.processing.get('remove_background') %}
<span class="badge badge-info">Remove BG</span>
{% endif %}
{% if action.processing.get('img_background_file') %}
<span>🌄 {{ action.processing.img_background_file.split('/')[-1] }}</span>
{% endif %}
</div>
<!-- Formulaire d'édition (masqué par défaut) -->
<div class="edit-form mt-2" id="edit-{{ loop.index0 }}" style="display:none;border-top:1px solid var(--border);padding-top:1rem">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Nom de l'action</label>
<input type="text" class="form-control" id="e-name-{{ loop.index0 }}" value="{{ action.name }}">
</div>
<div class="form-group">
<label class="form-label">Countdown (secondes)</label>
<input type="number" class="form-control" id="e-countdown-{{ loop.index0 }}"
value="{{ action.jobcontrol.get('countdown_capture', 5) }}" min="1" max="30" step="0.5">
</div>
<div class="form-group">
<label class="form-label">Cadre (img_frame_file)</label>
<input type="text" class="form-control" id="e-frame-{{ loop.index0 }}"
value="{{ action.processing.get('img_frame_file', '') or '' }}" placeholder="userdata/…/cadre.png">
</div>
<div class="form-group">
<label class="form-label">Fond (img_background_file)</label>
<input type="text" class="form-control" id="e-bg-{{ loop.index0 }}"
value="{{ action.processing.get('img_background_file', '') or '' }}" placeholder="userdata/…/fond.jpg">
</div>
<div class="form-group">
<label class="form-label">Filtre image</label>
<select class="form-control" id="e-filter-{{ loop.index0 }}">
<option value="original" {% if action.processing.get('image_filter','original') == 'original' %}selected{% endif %}>original</option>
<option value="FilterPilgram2.earlybird" {% if 'earlybird' in (action.processing.get('image_filter','')) %}selected{% endif %}>Earlybird</option>
<option value="FilterPilgram2.reyes" {% if 'reyes' in (action.processing.get('image_filter','')) %}selected{% endif %}>Reyes</option>
<option value="FilterPilgram2.moon" {% if 'moon' in (action.processing.get('image_filter','')) %}selected{% endif %}>Moon</option>
</select>
</div>
<div class="form-group">
<label class="form-label flex items-center gap-1">
<input type="checkbox" id="e-rmbg-{{ loop.index0 }}" {% if action.processing.get('remove_background') %}checked{% endif %}>
Remove Background (MODNet — ~400Mo RAM)
</label>
<label class="form-label flex items-center gap-1 mt-1">
<input type="checkbox" id="e-bgena-{{ loop.index0 }}" {% if action.processing.get('img_background_enable') %}checked{% endif %}>
Activer le fond
</label>
</div>
</div>
<div class="flex gap-1">
<button class="btn btn-primary btn-sm" onclick="saveAction({{ loop.index0 }})">💾 Sauvegarder</button>
<button class="btn btn-ghost btn-sm" onclick="toggleEdit({{ loop.index0 }})">Annuler</button>
</div>
</div>
</div> </div>
{% else %}
<div class="empty-state"><div class="icon">⚙️</div>Aucune action configurée dans photobooth-app</div>
{% endfor %}
</div> </div>
</div> </div>
@@ -146,72 +173,471 @@
{% block scripts %} {% block scripts %}
<script> <script>
// ── Formulaires d'édition ───────────────────────────────────────────────────── // ── État global ───────────────────────────────────────────────────────────────
function toggleEdit(idx) { let actions = [];
const el = document.getElementById('edit-' + idx); let assets = { frames: [], backgrounds: [] };
const sum = document.getElementById('summary-' + idx); let mapping = {};
const open = el.style.display === 'none'; let orderChanged = false;
el.style.display = open ? 'block' : 'none';
sum.style.display = open ? 'none' : 'flex'; const FILTERS = [
['original', 'Original'],
['FilterPilgram2.earlybird', 'Earlybird'],
['FilterPilgram2.reyes', 'Reyes'],
['FilterPilgram2.moon', 'Moon'],
['FilterPilgram2.gingham', 'Gingham'],
['FilterPilgram2.mayfair', 'Mayfair'],
['FilterPilgram2.nashville', 'Nashville'],
['FilterPilgram2.inkwell', 'Inkwell (N&B)'],
];
// ── Chargement ────────────────────────────────────────────────────────────────
async function loadAll() {
orderChanged = false;
document.getElementById('reorder-bar').style.display = 'none';
try {
const [actData, assetData] = await Promise.all([
api('GET', '/api/actions/photobooth'),
api('GET', '/api/actions/assets'),
]);
actions = actData.actions || [];
mapping = actData.button_mapping || {};
assets = assetData;
renderActions();
renderMapping();
loadPresets();
} catch(e) {
document.getElementById('actions-list').innerHTML =
`<div class="empty-state"><div class="icon">⚠️</div>Erreur chargement: ${e.message}</div>`;
}
} }
// ── Rendu des actions ─────────────────────────────────────────────────────────
function renderActions() {
const el = document.getElementById('actions-list');
document.getElementById('action-count').textContent = `(${actions.length})`;
if (!actions.length) {
el.innerHTML = '<div class="empty-state"><div class="icon">⚙️</div>Aucune action</div>';
return;
}
el.innerHTML = actions.map((a, i) => cardHTML(a, i)).join('');
attachDragListeners();
}
function cardHTML(action, idx) {
const name = esc(action.name || 'Sans nom');
const proc = action.processing || {};
const jc = action.jobcontrol || {};
const ui = (action.trigger || {}).ui_trigger || {};
const chips = [
`<span class="ac-chip">⏱ ${jc.countdown_capture ?? '?'}s</span>`,
];
if (proc.img_frame_enable && proc.img_frame_file)
chips.push(`<span class="ac-chip">🖼 ${esc(proc.img_frame_file.split('/').pop())}</span>`);
if (proc.img_background_enable && proc.img_background_file)
chips.push(`<span class="ac-chip">🌄 ${esc(proc.img_background_file.split('/').pop())}</span>`);
if (proc.fill_background_enable)
chips.push(`<span class="ac-chip" style="background:${esc(proc.fill_background_color||'#eee')};color:#000">⬛ fond couleur</span>`);
if (proc.remove_background)
chips.push('<span class="ac-chip hl">Remove BG</span>');
if (proc.image_filter && proc.image_filter !== 'original')
chips.push(`<span class="ac-chip hl">🎨 ${esc(proc.image_filter.split('.').pop())}</span>`);
if (ui.show_button)
chips.push(`<span class="ac-chip hl">🔘 ${esc(ui.title || '')}</span>`);
return `
<div class="action-card" draggable="true" data-idx="${idx}" id="ac-${idx}">
<div class="ac-header">
<span class="drag-handle" title="Glisser pour réordonner">⠿⠿</span>
<span class="ac-idx">#${idx}</span>
<span class="ac-name">${name}</span>
<div class="ac-chips">${chips.join('')}</div>
<div class="ac-btns">
<button class="btn btn-ghost btn-xs" onclick="toggleEdit(${idx})" title="Modifier"></button>
<button class="btn btn-ghost btn-xs" onclick="cloneAction(${idx})" title="Dupliquer"></button>
<button class="btn btn-ghost btn-xs" onclick="triggerAction(${idx})" title="Déclencher"></button>
<button class="btn btn-danger btn-xs" onclick="deleteAction(${idx})" title="Supprimer"></button>
</div>
</div>
<div class="ac-edit" id="edit-${idx}" style="display:none"></div>
</div>`;
}
// ── Formulaire d'édition (rendu lazy au premier toggle) ───────────────────────
function editFormHTML(action, idx) {
const proc = action.processing || {};
const jc = action.jobcontrol || {};
const ui = (action.trigger || {}).ui_trigger || {};
const frameSel = assetSelect(`ef-frame-sel-${idx}`, proc.img_frame_file || '', assets.frames, '— aucun cadre —', `ef-frame-${idx}`);
const bgSel = assetSelect(`ef-bg-sel-${idx}`, proc.img_background_file || '', assets.backgrounds, '— aucun fond —', `ef-bg-${idx}`);
const filterOpts = FILTERS.map(([v, l]) =>
`<option value="${esc(v)}" ${(proc.image_filter||'original')===v?'selected':''}>${esc(l)}</option>`).join('');
return `
<div class="edit-section">
<div class="edit-section-title">Général</div>
<div class="grid-3">
<div class="form-group">
<label class="form-label">Nom de l'action</label>
<input type="text" class="form-control" id="ef-name-${idx}" value="${esc(action.name||'')}">
</div>
<div class="form-group">
<label class="form-label">Countdown (secondes)</label>
<input type="number" class="form-control" id="ef-countdown-${idx}"
value="${jc.countdown_capture??5}" min="1" max="60" step="0.5">
</div>
</div>
</div>
<div class="edit-section">
<div class="edit-section-title">Cadre</div>
<div class="grid-3">
<div class="form-group">
<label class="form-label flex items-center gap-1">
<input type="checkbox" id="ef-frame-ena-${idx}" ${proc.img_frame_enable?'checked':''}> Activer le cadre
</label>
${frameSel}
<input type="text" class="form-control mt-1" id="ef-frame-${idx}"
value="${esc(proc.img_frame_file||'')}" placeholder="userdata/…/cadre.png" style="font-size:.8rem">
</div>
</div>
</div>
<div class="edit-section">
<div class="edit-section-title">Fond</div>
<div class="grid-3">
<div class="form-group">
<label class="form-label flex items-center gap-1">
<input type="checkbox" id="ef-bg-ena-${idx}" ${proc.img_background_enable?'checked':''}> Activer le fond image
</label>
${bgSel}
<input type="text" class="form-control mt-1" id="ef-bg-${idx}"
value="${esc(proc.img_background_file||'')}" placeholder="userdata/…/fond.jpg" style="font-size:.8rem">
</div>
<div class="form-group">
<label class="form-label flex items-center gap-1">
<input type="checkbox" id="ef-fill-ena-${idx}" ${proc.fill_background_enable?'checked':''}> Activer le fond couleur
</label>
<input type="color" class="form-control mt-1" id="ef-fill-color-${idx}"
value="${esc(proc.fill_background_color||'#ededed')}" style="height:36px;max-width:80px">
</div>
</div>
</div>
<div class="edit-section">
<div class="edit-section-title">Traitement image</div>
<div class="grid-3">
<div class="form-group">
<label class="form-label flex items-center gap-1">
<input type="checkbox" id="ef-rmbg-${idx}" ${proc.remove_background?'checked':''}>
Remove Background (MODNet)
</label>
</div>
<div class="form-group">
<label class="form-label">Filtre</label>
<select class="form-control" id="ef-filter-${idx}">${filterOpts}</select>
</div>
</div>
</div>
<div class="edit-section">
<div class="edit-section-title">Interface photobooth (bouton UI)</div>
<div class="grid-3">
<div class="form-group">
<label class="form-label flex items-center gap-1">
<input type="checkbox" id="ef-ui-show-${idx}" ${ui.show_button?'checked':''}> Afficher le bouton
</label>
<label class="form-label mt-1">Titre du bouton</label>
<input type="text" class="form-control" id="ef-ui-title-${idx}"
value="${esc(ui.title||'')}" placeholder="Photo normal…">
</div>
<div class="form-group">
<label class="form-label">Icône (nom Material)</label>
<input type="text" class="form-control" id="ef-ui-icon-${idx}"
value="${esc(ui.icon||'Photo')}" placeholder="Photo, star_shine, brick…">
<label class="form-label mt-1 flex items-center gap-1">
<input type="checkbox" id="ef-ui-usecol-${idx}" ${ui.use_custom_color?'checked':''}> Couleur personnalisée
</label>
<input type="color" class="form-control mt-1" id="ef-ui-color-${idx}"
value="${esc(ui.custom_color||'#016911')}" style="height:36px;max-width:80px">
</div>
</div>
</div>
<div class="flex gap-1">
<button class="btn btn-primary btn-sm" onclick="saveAction(${idx})">💾 Sauvegarder</button>
<button class="btn btn-ghost btn-sm" onclick="toggleEdit(${idx})">Annuler</button>
</div>`;
}
function assetSelect(selId, current, options, placeholder, textId) {
const opts = options.map(f => {
const label = f.split('/').pop();
return `<option value="${esc(f)}" ${f===current?'selected':''}>${esc(label)}</option>`;
}).join('');
return `<select class="form-control" id="${selId}"
onchange="document.getElementById('${textId}').value=this.value">
<option value="">${esc(placeholder)}</option>${opts}
</select>`;
}
// ── Toggle édition ────────────────────────────────────────────────────────────
function toggleEdit(idx) {
const el = document.getElementById(`edit-${idx}`);
if (el.style.display === 'none') {
if (!el.dataset.rendered) {
el.innerHTML = editFormHTML(actions[idx], idx);
el.dataset.rendered = '1';
}
el.style.display = 'block';
} else {
el.style.display = 'none';
}
}
// ── Sauvegarde action ─────────────────────────────────────────────────────────
async function saveAction(idx) { async function saveAction(idx) {
const realIdx = actions[idx]._index ?? idx;
const updates = { const updates = {
name: document.getElementById('e-name-' + idx).value, name: document.getElementById(`ef-name-${idx}`).value,
countdown_capture: parseFloat(document.getElementById('e-countdown-' + idx).value), countdown_capture: parseFloat(document.getElementById(`ef-countdown-${idx}`).value),
img_frame_file: document.getElementById('e-frame-' + idx).value || null, img_frame_enable: document.getElementById(`ef-frame-ena-${idx}`).checked,
img_background_file: document.getElementById('e-bg-' + idx).value || null, img_frame_file: document.getElementById(`ef-frame-${idx}`).value || null,
image_filter: document.getElementById('e-filter-' + idx).value, img_background_enable: document.getElementById(`ef-bg-ena-${idx}`).checked,
remove_background: document.getElementById('e-rmbg-' + idx).checked, img_background_file: document.getElementById(`ef-bg-${idx}`).value || null,
img_background_enable: document.getElementById('e-bgena-' + idx).checked, fill_background_enable: document.getElementById(`ef-fill-ena-${idx}`).checked,
fill_background_color: document.getElementById(`ef-fill-color-${idx}`).value,
remove_background: document.getElementById(`ef-rmbg-${idx}`).checked,
image_filter: document.getElementById(`ef-filter-${idx}`).value,
ui_show_button: document.getElementById(`ef-ui-show-${idx}`).checked,
ui_title: document.getElementById(`ef-ui-title-${idx}`).value,
ui_icon: document.getElementById(`ef-ui-icon-${idx}`).value,
ui_use_custom_color: document.getElementById(`ef-ui-usecol-${idx}`).checked,
ui_custom_color: document.getElementById(`ef-ui-color-${idx}`).value,
}; };
try { try {
await api('PUT', `/api/actions/photobooth/${idx}`, updates); await api('PUT', `/api/actions/photobooth/${realIdx}`, updates);
showToast('✅ Action sauvegardée — redémarrage photobooth-app requis', 'success', 6000); showToast('✅ Action sauvegardée', 'success');
toggleEdit(idx); await loadAll();
} catch(e) { } catch(e) {
showToast('❌ Erreur sauvegarde', 'error'); showToast(`❌ Erreur: ${e.message}`, 'error');
} }
} }
// ── Clone / Delete / Trigger ──────────────────────────────────────────────────
async function cloneAction(idx) {
const realIdx = actions[idx]._index ?? idx;
try {
await api('POST', `/api/actions/photobooth/${realIdx}/clone`);
showToast('⧉ Action dupliquée', 'success');
await loadAll();
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
}
async function deleteAction(idx) {
const name = actions[idx].name || `Action ${idx}`;
if (!confirm(`Supprimer « ${name} » ?\nCette action sera définitivement retirée de photobooth-app.`)) return;
const realIdx = actions[idx]._index ?? idx;
try {
await api('DELETE', `/api/actions/photobooth/${realIdx}`);
showToast(`🗑 « ${name} » supprimée`, 'info');
await loadAll();
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
}
async function newAction() {
const name = prompt('Nom de la nouvelle action :', 'Nouvelle action');
if (name === null) return;
try {
await api('POST', '/api/actions/photobooth', { name: name.trim() || 'Nouvelle action' });
showToast('✅ Action créée', 'success');
await loadAll();
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
}
async function triggerAction(idx) { async function triggerAction(idx) {
if (!confirm(`Déclencher l'action ${idx} (prise de photo) ?`)) return; const realIdx = actions[idx]._index ?? idx;
const name = actions[idx].name || `Action ${idx}`;
if (!confirm(`Déclencher « ${name} » (prise de photo) ?`)) return;
try { try {
await api('POST', `/api/actions/trigger/${idx}`); await api('POST', `/api/actions/trigger/${realIdx}`);
showToast(`▶ Action ${idx} déclenchée`, 'info'); showToast(`▶ « ${name} » déclenchée`, 'info');
} catch(e) { showToast('Erreur déclenchement', 'error'); } } catch(e) { showToast(`❌ ${e.message}`, 'error'); }
} }
// ── Mapping bouton ───────────────────────────────────────────────────────────── // ── Drag & Drop ───────────────────────────────────────────────────────────────
async function saveMapping() { let dragSrcIdx = null;
const mapping = {};
function attachDragListeners() {
document.querySelectorAll('.action-card').forEach(card => {
const idx = parseInt(card.dataset.idx);
card.addEventListener('dragstart', e => {
dragSrcIdx = idx;
e.dataTransfer.effectAllowed = 'move';
setTimeout(() => card.classList.add('dragging'), 0);
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
document.querySelectorAll('.action-card').forEach(c => c.classList.remove('drag-over'));
});
card.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
});
card.addEventListener('dragenter', e => {
e.preventDefault();
if (parseInt(card.dataset.idx) !== dragSrcIdx) {
document.querySelectorAll('.action-card').forEach(c => c.classList.remove('drag-over'));
card.classList.add('drag-over');
}
});
card.addEventListener('dragleave', e => {
if (!card.contains(e.relatedTarget)) card.classList.remove('drag-over');
});
card.addEventListener('drop', e => {
e.preventDefault();
const targetIdx = parseInt(card.dataset.idx);
if (dragSrcIdx === null || dragSrcIdx === targetIdx) return;
// Réordonne en mémoire
const [moved] = actions.splice(dragSrcIdx, 1);
actions.splice(targetIdx, 0, moved);
dragSrcIdx = null;
orderChanged = true;
renderActions();
document.getElementById('reorder-bar').style.display = 'flex';
});
});
}
async function saveOrder() {
// _index contient l'index original côté serveur
const order = actions.map(a => a._index);
try {
await api('PUT', '/api/actions/photobooth/reorder', order);
showToast('✅ Ordre sauvegardé — redémarrez photobooth-app pour appliquer', 'success', 7000);
await loadAll();
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
}
// ── Mapping bouton ────────────────────────────────────────────────────────────
function renderMapping() {
const el = document.getElementById('mapping-table');
const names = actions.map(a => a.name || '?');
let html = '<table class="table"><thead><tr><th>Clics</th><th>Label</th><th>Action</th><th></th></tr></thead><tbody>';
for (let n = 1; n <= 4; n++) { for (let n = 1; n <= 4; n++) {
mapping[n] = { const m = mapping[n] || mapping[String(n)] || {};
label: document.getElementById('label-' + n).value, const selectedIdx = m.photobooth_index ?? 0;
photobooth_index: parseInt(document.getElementById('index-' + n).value), const opts = actions.map((a, i) =>
`<option value="${i}" ${i===selectedIdx?'selected':''}>#${i} — ${esc(a.name||'?')}</option>`
).join('');
html += `<tr>
<td class="font-bold">${n} clic${n>1?'s':''}</td>
<td><input type="text" id="ml-${n}" class="form-control" style="max-width:180px" value="${esc(m.label||'')}" placeholder="Label…"></td>
<td><select id="mi-${n}" class="form-control" style="max-width:280px">${opts}</select></td>
<td><button class="btn btn-ghost btn-xs" onclick="testMapping(${n})">▶ Test</button></td>
</tr>`;
}
html += '</tbody></table>';
el.innerHTML = html;
}
async function saveMapping() {
const m = {};
for (let n = 1; n <= 4; n++) {
m[n] = {
label: document.getElementById(`ml-${n}`).value,
photobooth_index: parseInt(document.getElementById(`mi-${n}`).value),
}; };
} }
try { try {
await api('PUT', '/api/actions/mapping', mapping); await api('PUT', '/api/actions/mapping', m);
showToast('✅ Mapping sauvegardé', 'success'); showToast('✅ Mapping sauvegardé', 'success');
} catch(e) { showToast('❌ Erreur sauvegarde mapping', 'error'); } mapping = m;
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
} }
async function testAction(n) { async function testMapping(n) {
const idx = parseInt(document.getElementById('index-' + n).value); const idx = parseInt(document.getElementById(`mi-${n}`).value);
if (!confirm(`Déclencher l'action ${idx} pour tester le ${n} clic ?`)) return; const realIdx = actions[idx]?._index ?? idx;
if (!confirm(`Déclencher action #${realIdx} (test ${n} clic) ?`)) return;
try { try {
await api('POST', `/api/actions/trigger/${idx}`); await api('POST', `/api/actions/trigger/${realIdx}`);
showToast(`▶ Test ${n} clic → action ${idx}`, 'info'); showToast(`▶ Test ${n} clic → action #${realIdx}`, 'info');
} catch(e) { showToast('Erreur', 'error'); } } catch(e) { showToast(`❌ ${e.message}`, 'error'); }
} }
// Mise à jour du nom de l'action quand on change l'index // ── Presets ───────────────────────────────────────────────────────────────────
{% for n in range(1, 5) %} async function loadPresets() {
document.getElementById('index-{{ n }}').addEventListener('change', function() { try {
const names = {{ pb_actions | map(attribute='name') | list | tojson }}; const data = await api('GET', '/api/actions/presets');
document.getElementById('action-name-{{ n }}').textContent = names[this.value] || '—'; renderPresets(data.presets || []);
}); } catch(e) {}
{% endfor %} }
function renderPresets(presets) {
const el = document.getElementById('presets-list');
if (!presets.length) {
el.innerHTML = '<p class="text-sm text-muted">Aucun preset sauvegardé.</p>';
return;
}
el.innerHTML = presets.map(p => `
<div class="preset-card">
<div class="preset-info">
<div class="preset-name">${esc(p.label || p.name)}</div>
<div class="preset-meta">
${p.action_count} action(s)
${p.description ? ' · ' + esc(p.description) : ''}
${p.saved_at ? ' · ' + esc(p.saved_at.replace('T',' ')) : ''}
</div>
</div>
<div class="flex gap-1">
<button class="btn btn-primary btn-sm" onclick="restorePreset('${esc(p.name)}', '${esc(p.label||p.name)}')">↩ Restaurer</button>
<button class="btn btn-danger btn-xs" onclick="deletePreset('${esc(p.name)}', '${esc(p.label||p.name)}')"></button>
</div>
</div>`).join('');
}
async function savePreset() {
const name = document.getElementById('preset-name').value.trim();
const desc = document.getElementById('preset-desc').value.trim();
if (!name) { showToast('Saisissez un nom de preset', 'error'); return; }
try {
const r = await api('POST', '/api/actions/presets', { name, description: desc });
showToast(`✅ Preset « ${name} » sauvegardé (${r.action_count} actions)`, 'success');
document.getElementById('preset-name').value = '';
document.getElementById('preset-desc').value = '';
loadPresets();
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
}
async function restorePreset(name, label) {
if (!confirm(`Restaurer le preset « ${label} » ?\nLes actions actuelles seront remplacées.`)) return;
try {
const r = await api('POST', `/api/actions/presets/${encodeURIComponent(name)}/restore`);
showToast(`✅ Preset « ${label} » restauré (${r.action_count} actions) — redémarrez photobooth-app`, 'success', 8000);
await loadAll();
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
}
async function deletePreset(name, label) {
if (!confirm(`Supprimer le preset « ${label} » ?`)) return;
try {
await api('DELETE', `/api/actions/presets/${encodeURIComponent(name)}`);
showToast(`🗑 Preset « ${label} » supprimé`, 'info');
loadPresets();
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
}
// ── Utilitaires ───────────────────────────────────────────────────────────────
function esc(s) {
if (s === null || s === undefined) return '';
return String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// ── Init ──────────────────────────────────────────────────────────────────────
loadAll();
</script> </script>
{% endblock %} {% endblock %}