feat: gestion complète des actions (clone, delete, reorder, presets)
- 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:
+233
-57
@@ -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 re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request, Body
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
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")
|
||||
async def get_pb_actions(request: Request):
|
||||
"""Retourne les actions image de photobooth-app + le mapping bouton actuel."""
|
||||
pb = request.app.state.photobooth_service
|
||||
cfg = request.app.state.config
|
||||
|
||||
pb_config = await pb.read_pb_config()
|
||||
actions = pb_config.get("actions", {}).get("image", [])
|
||||
|
||||
# Enrichit avec l'index
|
||||
for i, action in enumerate(actions):
|
||||
action["_index"] = i
|
||||
|
||||
return {
|
||||
"actions": actions,
|
||||
"button_mapping": cfg.button_actions,
|
||||
}
|
||||
return {"actions": actions, "button_mapping": cfg.button_actions}
|
||||
|
||||
|
||||
@router.get("/actions/assets")
|
||||
async def get_assets(request: Request):
|
||||
"""Liste les cadres et fonds disponibles dans userdata."""
|
||||
pb = request.app.state.photobooth_service
|
||||
frames = await pb.list_userdata_frames()
|
||||
backgrounds = await pb.list_userdata_backgrounds()
|
||||
return {"frames": frames, "backgrounds": backgrounds}
|
||||
|
||||
|
||||
@router.put("/actions/mapping")
|
||||
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
|
||||
# ── CRUD actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Validation basique
|
||||
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)
|
||||
@router.post("/actions/photobooth")
|
||||
async def create_action(request: Request, body: dict = Body(default={})):
|
||||
"""Crée une nouvelle action vide."""
|
||||
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
|
||||
btn = request.app.state.button_service
|
||||
btn._btn_actions = mapping
|
||||
@router.post("/actions/photobooth/{index}/clone")
|
||||
async def clone_action(request: Request, index: int):
|
||||
"""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}")
|
||||
async def update_pb_action(
|
||||
request: Request,
|
||||
index: int,
|
||||
updates: dict = Body(...),
|
||||
):
|
||||
"""
|
||||
Met à jour une action image de photobooth-app (cadre, fond, countdown, etc.)
|
||||
updates peut contenir: countdown_capture, img_frame_file, img_background_file,
|
||||
remove_background, image_filter, name
|
||||
async def update_pb_action(request: Request, index: int, updates: dict = Body(...)):
|
||||
"""Met à jour une action (tous les champs sauf GPIO/keyboard trigger).
|
||||
|
||||
Champs plats acceptés:
|
||||
name, countdown_capture,
|
||||
img_frame_enable, img_frame_file,
|
||||
img_background_enable, img_background_file,
|
||||
fill_background_enable, fill_background_color,
|
||||
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_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 (max: {len(actions)-1})"}, status_code=400)
|
||||
return JSONResponse({"error": f"Index {index} invalide"}, status_code=400)
|
||||
|
||||
action = actions[index]
|
||||
|
||||
# Mise à jour des champs autorisés
|
||||
allowed_root = {"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"}
|
||||
if "name" in updates:
|
||||
action["name"] = updates["name"]
|
||||
|
||||
for key, value in updates.items():
|
||||
if key in allowed_root:
|
||||
action[key] = value
|
||||
elif key in allowed_processing:
|
||||
action.setdefault("processing", {})[key] = value
|
||||
elif key in allowed_jobcontrol:
|
||||
action.setdefault("jobcontrol", {})[key] = value
|
||||
if "countdown_capture" in updates:
|
||||
action.setdefault("jobcontrol", {})["countdown_capture"] = float(updates["countdown_capture"])
|
||||
|
||||
proc = action.setdefault("processing", {})
|
||||
for key in ("img_frame_enable", "img_frame_file",
|
||||
"img_background_enable", "img_background_file",
|
||||
"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
|
||||
await pb.write_pb_config(pb_config)
|
||||
|
||||
logger.info("Action %d mise à jour: %s", index, list(updates.keys()))
|
||||
return {"ok": True, "index": index, "action": action}
|
||||
|
||||
|
||||
# ── Déclenchement ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/actions/trigger/{index}")
|
||||
async def trigger_action(request: Request, index: int):
|
||||
"""Déclenche une action directement (test)."""
|
||||
@@ -119,3 +197,101 @@ async def trigger_action(request: Request, index: int):
|
||||
led.play("error")
|
||||
btn.relay_on()
|
||||
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}
|
||||
|
||||
@@ -96,17 +96,8 @@ async def admin_actions(request: Request):
|
||||
redirect = _require_auth(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
pb = request.app.state.photobooth_service
|
||||
cfg = request.app.state.config
|
||||
pb_config = await pb.read_pb_config()
|
||||
actions = pb_config.get("actions", {}).get("image", [])
|
||||
|
||||
return _templates.TemplateResponse(request, "admin/actions.html", {
|
||||
"config": cfg,
|
||||
"pb_actions": actions,
|
||||
"button_mapping": cfg.button_actions,
|
||||
})
|
||||
return _templates.TemplateResponse(request, "admin/actions.html", {"config": cfg})
|
||||
|
||||
|
||||
@router.get("/admin/settings", response_class=HTMLResponse)
|
||||
|
||||
@@ -1,143 +1,170 @@
|
||||
{% extends "base.html" %}
|
||||
{% 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 %}
|
||||
<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="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">
|
||||
<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>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
<p class="text-sm text-muted mb-2">Associe chaque nombre de clics à une action.</p>
|
||||
<div id="mapping-table">
|
||||
<div class="empty-state"><div class="icon">⏳</div></div>
|
||||
</div>
|
||||
<button class="btn btn-primary mt-2" onclick="saveMapping()">💾 Sauvegarder le mapping</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions photobooth-app -->
|
||||
<!-- ── Presets ──────────────────────────────────────────────────── -->
|
||||
<div class="section">
|
||||
<div class="card-title">Actions image photobooth-app ({{ pb_actions|length }} au total)</div>
|
||||
<div id="actions-list">
|
||||
{% for action in pb_actions %}
|
||||
<div class="card mb-1" id="action-{{ loop.index0 }}" style="margin-bottom:0.75rem">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div>
|
||||
<span class="badge badge-muted text-xs">Action {{ loop.index0 }}</span>
|
||||
<span class="font-bold" style="margin-left:0.5rem">{{ action.name }}</span>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<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 class="card-title">Presets d'actions</div>
|
||||
<div class="card">
|
||||
<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="flex gap-1 mb-2" style="flex-wrap:wrap">
|
||||
<input type="text" class="form-control" id="preset-name" placeholder="Nom du preset…" style="max-width:220px">
|
||||
<input type="text" class="form-control" id="preset-desc" placeholder="Description (optionnel)" style="max-width:280px">
|
||||
<button class="btn btn-primary" onclick="savePreset()">💾 Sauvegarder le preset</button>
|
||||
</div>
|
||||
<div id="presets-list">
|
||||
<div class="empty-state text-sm">Aucun preset sauvegardé</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="icon">⚙️</div>Aucune action configurée dans photobooth-app</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -146,72 +173,471 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// ── Formulaires d'édition ─────────────────────────────────────────────────────
|
||||
function toggleEdit(idx) {
|
||||
const el = document.getElementById('edit-' + idx);
|
||||
const sum = document.getElementById('summary-' + idx);
|
||||
const open = el.style.display === 'none';
|
||||
el.style.display = open ? 'block' : 'none';
|
||||
sum.style.display = open ? 'none' : 'flex';
|
||||
// ── État global ───────────────────────────────────────────────────────────────
|
||||
let actions = [];
|
||||
let assets = { frames: [], backgrounds: [] };
|
||||
let mapping = {};
|
||||
let orderChanged = false;
|
||||
|
||||
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) {
|
||||
const realIdx = actions[idx]._index ?? idx;
|
||||
const updates = {
|
||||
name: document.getElementById('e-name-' + idx).value,
|
||||
countdown_capture: parseFloat(document.getElementById('e-countdown-' + idx).value),
|
||||
img_frame_file: document.getElementById('e-frame-' + idx).value || null,
|
||||
img_background_file: document.getElementById('e-bg-' + idx).value || null,
|
||||
image_filter: document.getElementById('e-filter-' + idx).value,
|
||||
remove_background: document.getElementById('e-rmbg-' + idx).checked,
|
||||
img_background_enable: document.getElementById('e-bgena-' + idx).checked,
|
||||
name: document.getElementById(`ef-name-${idx}`).value,
|
||||
countdown_capture: parseFloat(document.getElementById(`ef-countdown-${idx}`).value),
|
||||
img_frame_enable: document.getElementById(`ef-frame-ena-${idx}`).checked,
|
||||
img_frame_file: document.getElementById(`ef-frame-${idx}`).value || null,
|
||||
img_background_enable: document.getElementById(`ef-bg-ena-${idx}`).checked,
|
||||
img_background_file: document.getElementById(`ef-bg-${idx}`).value || null,
|
||||
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 {
|
||||
await api('PUT', `/api/actions/photobooth/${idx}`, updates);
|
||||
showToast('✅ Action sauvegardée — redémarrage photobooth-app requis', 'success', 6000);
|
||||
toggleEdit(idx);
|
||||
await api('PUT', `/api/actions/photobooth/${realIdx}`, updates);
|
||||
showToast('✅ Action sauvegardée', 'success');
|
||||
await loadAll();
|
||||
} 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) {
|
||||
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 {
|
||||
await api('POST', `/api/actions/trigger/${idx}`);
|
||||
showToast(`▶ Action ${idx} déclenchée`, 'info');
|
||||
} catch(e) { showToast('Erreur déclenchement', 'error'); }
|
||||
await api('POST', `/api/actions/trigger/${realIdx}`);
|
||||
showToast(`▶ « ${name} » déclenchée`, 'info');
|
||||
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
|
||||
}
|
||||
|
||||
// ── Mapping bouton ─────────────────────────────────────────────────────────────
|
||||
async function saveMapping() {
|
||||
const mapping = {};
|
||||
// ── Drag & Drop ───────────────────────────────────────────────────────────────
|
||||
let dragSrcIdx = null;
|
||||
|
||||
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++) {
|
||||
mapping[n] = {
|
||||
label: document.getElementById('label-' + n).value,
|
||||
photobooth_index: parseInt(document.getElementById('index-' + n).value),
|
||||
const m = mapping[n] || mapping[String(n)] || {};
|
||||
const selectedIdx = m.photobooth_index ?? 0;
|
||||
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 {
|
||||
await api('PUT', '/api/actions/mapping', mapping);
|
||||
await api('PUT', '/api/actions/mapping', m);
|
||||
showToast('✅ Mapping sauvegardé', 'success');
|
||||
} catch(e) { showToast('❌ Erreur sauvegarde mapping', 'error'); }
|
||||
mapping = m;
|
||||
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
|
||||
}
|
||||
|
||||
async function testAction(n) {
|
||||
const idx = parseInt(document.getElementById('index-' + n).value);
|
||||
if (!confirm(`Déclencher l'action ${idx} pour tester le ${n} clic ?`)) return;
|
||||
async function testMapping(n) {
|
||||
const idx = parseInt(document.getElementById(`mi-${n}`).value);
|
||||
const realIdx = actions[idx]?._index ?? idx;
|
||||
if (!confirm(`Déclencher action #${realIdx} (test ${n} clic) ?`)) return;
|
||||
try {
|
||||
await api('POST', `/api/actions/trigger/${idx}`);
|
||||
showToast(`▶ Test ${n} clic → action ${idx}`, 'info');
|
||||
} catch(e) { showToast('Erreur', 'error'); }
|
||||
await api('POST', `/api/actions/trigger/${realIdx}`);
|
||||
showToast(`▶ Test ${n} clic → action #${realIdx}`, 'info');
|
||||
} catch(e) { showToast(`❌ ${e.message}`, 'error'); }
|
||||
}
|
||||
|
||||
// Mise à jour du nom de l'action quand on change l'index
|
||||
{% for n in range(1, 5) %}
|
||||
document.getElementById('index-{{ n }}').addEventListener('change', function() {
|
||||
const names = {{ pb_actions | map(attribute='name') | list | tojson }};
|
||||
document.getElementById('action-name-{{ n }}').textContent = names[this.value] || '—';
|
||||
});
|
||||
{% endfor %}
|
||||
// ── Presets ───────────────────────────────────────────────────────────────────
|
||||
async function loadPresets() {
|
||||
try {
|
||||
const data = await api('GET', '/api/actions/presets');
|
||||
renderPresets(data.presets || []);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
loadAll();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user