Backoffice complet : materiel, compteur, destinations, cadres, animations

- Onglet Materiel : choix appareil photo + imprimante
- Compteur avec limite configurable (399/400) affiche sur l'accueil
- Destinations multiples : memoire interne, cle USB, FTP, site web, email
- Choix sauvegarder toutes les photos ou seulement les imprimees
- Gestion des cadres : activation/desactivation + import
- 7 animations de compte a rebours : classique, explosion, rebond,
  rotation 3D, fondu, emoji fun, tremblement
- Preview des animations dans le backoffice
- Module destinations.py (copie USB, envoi FTP, compteur)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 23:03:30 +01:00
parent 3b3271a32f
commit d9de999d74
8 changed files with 1009 additions and 148 deletions

114
backend/destinations.py Normal file
View File

@@ -0,0 +1,114 @@
import ftplib
import logging
import shutil
from pathlib import Path
from backend.config import charger_config, mettre_a_jour_config
log = logging.getLogger("photobooth.destinations")
def distribuer_photo(chemin_photo: Path, imprimee: bool = False):
"""Copie la photo vers toutes les destinations activees."""
config = charger_config()
dest = config.get("destinations", {})
# Verifier si on sauvegarde tout ou seulement les imprimees
if not dest.get("sauvegarder_tout", True) and not imprimee:
log.info(f"Photo non imprimee, pas de distribution : {chemin_photo.name}")
return
# Cle USB
if dest.get("cle_usb", False):
copier_usb(chemin_photo, dest.get("chemin_usb", "/media/usb"))
# FTP
if dest.get("ftp", False):
envoyer_ftp(chemin_photo, dest)
# Incrementer le compteur
compteur = config.get("compteur", {})
if compteur.get("actif", False):
compteur["photos_prises"] = compteur.get("photos_prises", 0) + 1
mettre_a_jour_config({"compteur": compteur})
def copier_usb(chemin_photo: Path, chemin_usb: str):
"""Copie une photo sur la cle USB."""
dossier_usb = Path(chemin_usb)
if not dossier_usb.exists():
log.warning(f"Cle USB non trouvee : {chemin_usb}")
return False
dossier_dest = dossier_usb / "photobooth"
dossier_dest.mkdir(exist_ok=True)
try:
shutil.copy2(chemin_photo, dossier_dest / chemin_photo.name)
log.info(f"Photo copiee sur USB : {chemin_photo.name}")
return True
except OSError as e:
log.error(f"Erreur copie USB : {e}")
return False
def envoyer_ftp(chemin_photo: Path, config_dest: dict):
"""Envoie une photo par FTP."""
host = config_dest.get("ftp_host", "")
port = config_dest.get("ftp_port", 21)
user = config_dest.get("ftp_user", "")
password = config_dest.get("ftp_password", "")
chemin_distant = config_dest.get("ftp_chemin", "/photobooth")
if not host:
log.warning("FTP non configure")
return False
try:
with ftplib.FTP() as ftp:
ftp.connect(host, port, timeout=10)
ftp.login(user, password)
# Creer le dossier si necessaire
try:
ftp.mkd(chemin_distant)
except ftplib.error_perm:
pass
ftp.cwd(chemin_distant)
with open(chemin_photo, "rb") as f:
ftp.storbinary(f"STOR {chemin_photo.name}", f)
log.info(f"Photo envoyee par FTP : {chemin_photo.name}")
return True
except (ftplib.all_errors, OSError) as e:
log.error(f"Erreur FTP : {e}")
return False
def detecter_usb() -> list[str]:
"""Detecte les cles USB montees."""
chemins_possibles = [Path("/media"), Path("/mnt")]
usb_trouvees = []
for racine in chemins_possibles:
if racine.exists():
for sous_dossier in racine.iterdir():
if sous_dossier.is_mount() or (sous_dossier.is_dir() and any(sous_dossier.iterdir())):
usb_trouvees.append(str(sous_dossier))
return usb_trouvees
def compteur_restant() -> dict:
"""Retourne l'etat du compteur."""
config = charger_config()
compteur = config.get("compteur", {})
limite = compteur.get("limite", 400)
prises = compteur.get("photos_prises", 0)
return {
"actif": compteur.get("actif", False),
"limite": limite,
"photos_prises": prises,
"restantes": max(0, limite - prises),
}
def reset_compteur():
"""Remet le compteur a zero."""
mettre_a_jour_config({"compteur": {"photos_prises": 0}})

View File

@@ -17,7 +17,7 @@ from backend.camera import camera
from backend.gallery import lister_photos, compter_photos, supprimer_photo, vider_galerie
from backend.effects import appliquer_filtre, appliquer_overlay, chroma_key, lister_overlays, lister_fonds, FILTRES
from backend.collage import creer_strip, creer_collage
from backend.destinations import distribuer_photo, detecter_usb, compteur_restant, reset_compteur
from backend.printer import lister_imprimantes, imprimer
from backend.mailer import envoyer_photo
from backend.qrcode_gen import generer_qr, qr_galerie
@@ -73,10 +73,19 @@ async def api_config_update(modifications: dict):
@app.post("/api/capturer")
async def api_capturer():
# Verifier le compteur
etat = compteur_restant()
if etat["actif"] and etat["restantes"] <= 0:
return JSONResponse({"erreur": "Limite de photos atteinte"}, status_code=403)
chemin = camera.capturer()
if chemin is None:
return JSONResponse({"erreur": "Echec capture"}, status_code=500)
nom = chemin.name
# Distribuer vers les destinations
distribuer_photo(chemin, imprimee=False)
await diffuser_ws({"type": "photo_capturee", "nom": nom})
return {"nom": nom, "chemin": f"/data/photos/{nom}"}
@@ -206,6 +215,8 @@ async def api_imprimer(donnees: dict):
if not chemin.exists():
return JSONResponse({"erreur": "Photo introuvable"}, status_code=404)
ok = imprimer(chemin)
if ok:
distribuer_photo(chemin, imprimee=True)
return {"succes": ok}
@@ -278,6 +289,60 @@ async def api_upload_fond(fichier: UploadFile = File(...)):
return {"nom": fichier.filename}
# --- API Compteur ---
@app.get("/api/compteur")
async def api_compteur_etat():
return compteur_restant()
@app.post("/api/compteur/reset")
async def api_compteur_reset():
reset_compteur()
return compteur_restant()
# --- API Destinations ---
@app.get("/api/usb/detecter")
async def api_detecter_usb():
return detecter_usb()
# --- API Cadres actifs ---
@app.get("/api/cadres")
async def api_cadres():
config = charger_config()
tous = lister_overlays()
actifs = config.get("cadres", {}).get("actifs", [])
return {"tous": tous, "actifs": actifs}
@app.post("/api/cadres")
async def api_cadres_update(donnees: dict):
actifs = donnees.get("actifs", [])
mettre_a_jour_config({"cadres": {"actifs": actifs}})
return {"actifs": actifs}
# --- API Animations compte a rebours ---
ANIMATIONS_CAR = {
"classique": "Classique (chiffres simples)",
"explosion": "Explosion (chiffres qui eclatent)",
"rebond": "Rebond (chiffres qui rebondissent)",
"rotation": "Rotation 3D",
"fondu": "Fondu enchaine",
"emoji": "Emoji fun",
"shake": "Tremblement",
}
@app.get("/api/animations")
async def api_animations():
return ANIMATIONS_CAR
# --- API Systeme ---
@app.post("/api/systeme/redemarrer")

View File

@@ -2,7 +2,6 @@
"evenement": {
"nom": "Mon Evenement",
"logo": null,
"overlay": null,
"couleur_primaire": "#e91e63",
"couleur_secondaire": "#ffffff"
},
@@ -15,19 +14,43 @@
"impression": true,
"email": true,
"qr_code": true,
"galerie": true,
"compteur": true
"galerie": true
},
"camera": {
"appareil": null,
"iso": "auto",
"balance_blancs": "auto",
"compte_a_rebours": 3
"compte_a_rebours": 3,
"animation_compte_a_rebours": "classique"
},
"compteur": {
"actif": true,
"limite": 400,
"photos_prises": 0
},
"destinations": {
"memoire_interne": true,
"cle_usb": false,
"chemin_usb": "/media/usb",
"ftp": false,
"ftp_host": "",
"ftp_port": 21,
"ftp_user": "",
"ftp_password": "",
"ftp_chemin": "/photobooth",
"site_web": false,
"site_web_url": "",
"email_auto": false,
"sauvegarder_tout": true
},
"impression": {
"imprimante": null,
"copies": 1,
"format": "10x15"
},
"cadres": {
"actifs": []
},
"email": {
"smtp_host": "",
"smtp_port": 587,

View File

@@ -572,11 +572,291 @@ html, body {
border-color: var(--primaire);
}
/* === Compteur accueil === */
.compteur-accueil {
position: absolute;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.4);
padding: 0.5rem 1.5rem;
border-radius: 2rem;
font-size: 1.1rem;
color: var(--texte-secondaire);
}
/* === Admin extras === */
.btn-petit {
padding: 0.4rem 1rem !important;
font-size: 0.85rem !important;
}
.champ-row {
display: flex;
gap: 1rem;
align-items: center;
}
.sous-config {
padding-left: 1.5rem;
border-left: 3px solid var(--primaire);
margin: 0.5rem 0;
}
h3 {
font-size: 1.1rem;
font-weight: 600;
color: var(--primaire);
margin-top: 0.5rem;
}
.aide {
color: var(--texte-secondaire);
font-size: 0.85rem;
font-style: italic;
}
.statut-badge {
display: inline-block;
padding: 0.2rem 0.8rem;
border-radius: 1rem;
font-size: 0.85rem;
background: rgba(76, 175, 80, 0.2);
color: var(--succes);
}
/* Compteur display */
.compteur-display {
text-align: center;
padding: 1.5rem;
background: var(--fond);
border-radius: var(--rayon);
}
.compteur-gros {
display: flex;
align-items: baseline;
justify-content: center;
gap: 0.3rem;
}
.compteur-nombre {
font-size: 4rem;
font-weight: 700;
color: var(--primaire);
}
.compteur-nombre.petit {
font-size: 2rem;
color: var(--texte-secondaire);
}
.compteur-sep {
font-size: 2rem;
color: var(--texte-secondaire);
}
.compteur-label {
color: var(--texte-secondaire);
font-size: 0.9rem;
}
/* Radio buttons */
.radio-groupe {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.radio {
display: flex;
align-items: center;
gap: 0.8rem;
cursor: pointer;
font-size: 1rem;
}
.radio input { display: none; }
.radio-mark {
width: 22px;
height: 22px;
border: 2px solid #555;
border-radius: 50%;
position: relative;
flex-shrink: 0;
transition: var(--transition);
}
.radio input:checked + .radio-mark {
border-color: var(--primaire);
}
.radio input:checked + .radio-mark::after {
content: '';
position: absolute;
top: 4px; left: 4px;
width: 10px; height: 10px;
background: var(--primaire);
border-radius: 50%;
}
/* Liste cadres */
.liste-cadres {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.cadre-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
background: var(--fond);
border-radius: 8px;
}
.cadre-item img {
width: 60px;
height: 40px;
object-fit: contain;
border-radius: 4px;
background: #fff;
}
.input-fichier {
background: var(--fond);
border: 2px dashed #444;
border-radius: 8px;
padding: 0.8rem;
color: var(--texte);
width: 100%;
}
/* Liste animations */
.liste-animations {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.anim-option {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.8rem 1rem;
background: var(--fond);
border: 2px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: var(--transition);
}
.anim-option.actif {
border-color: var(--primaire);
background: rgba(233, 30, 99, 0.1);
}
.anim-option:active {
transform: scale(0.98);
}
.preview-animation-box {
width: 200px;
height: 200px;
background: rgba(0,0,0,0.6);
border-radius: var(--rayon);
display: flex;
align-items: center;
justify-content: center;
margin: 1rem 0;
}
.anim-chiffre {
font-size: 8rem;
font-weight: 700;
color: var(--primaire);
}
/* === Animations compte a rebours === */
/* Classique */
.anim-classique { animation: anim-pop 0.5s ease; }
/* Explosion */
.anim-explosion { animation: anim-explosion 0.6s ease; }
@keyframes anim-explosion {
0% { transform: scale(0); opacity: 0; }
50% { transform: scale(1.8); opacity: 1; }
70% { transform: scale(0.9); }
100% { transform: scale(1); opacity: 1; }
}
/* Rebond */
.anim-rebond { animation: anim-rebond 0.7s ease; }
@keyframes anim-rebond {
0% { transform: translateY(-200px) scale(0.5); opacity: 0; }
40% { transform: translateY(20px) scale(1.1); opacity: 1; }
60% { transform: translateY(-10px) scale(0.95); }
80% { transform: translateY(5px) scale(1.02); }
100% { transform: translateY(0) scale(1); }
}
/* Rotation 3D */
.anim-rotation { animation: anim-rotation 0.6s ease; perspective: 500px; }
@keyframes anim-rotation {
0% { transform: rotateY(90deg) scale(0.5); opacity: 0; }
60% { transform: rotateY(-10deg) scale(1.1); opacity: 1; }
100% { transform: rotateY(0deg) scale(1); }
}
/* Fondu */
.anim-fondu { animation: anim-fondu 0.8s ease; }
@keyframes anim-fondu {
0% { opacity: 0; transform: scale(2); filter: blur(20px); }
100% { opacity: 1; transform: scale(1); filter: blur(0); }
}
/* Emoji fun */
.anim-emoji { animation: anim-emoji 0.6s ease; }
@keyframes anim-emoji {
0% { transform: scale(0) rotate(-180deg); }
60% { transform: scale(1.3) rotate(10deg); }
100% { transform: scale(1) rotate(0); }
}
/* Shake */
.anim-shake { animation: anim-shake 0.6s ease; }
@keyframes anim-shake {
0% { transform: scale(0.3); opacity: 0; }
20% { transform: scale(1.1) rotate(-5deg); opacity: 1; }
40% { transform: scale(1) rotate(5deg); }
60% { transform: scale(1.05) rotate(-3deg); }
80% { transform: scale(1) rotate(2deg); }
100% { transform: scale(1) rotate(0); }
}
/* Pop (classique redefini) */
@keyframes anim-pop {
0% { transform: scale(0.3); opacity: 0; }
60% { transform: scale(1.2); }
100% { transform: scale(1); opacity: 1; }
}
/* Textes emoji pour le mode emoji */
.emoji-3::before { content: ""; }
.emoji-2::before { content: ""; }
.emoji-1::before { content: ""; }
/* === Utilitaires === */
.cache {
display: none !important;
}
.texte-secondaire {
color: var(--texte-secondaire);
}
/* Scrollbar tactile */
::-webkit-scrollbar {
width: 6px;

View File

@@ -17,6 +17,9 @@
<div class="accueil-animation">
<div class="cercle-pulse"></div>
</div>
<div id="compteur-accueil" class="compteur-accueil cache">
<span id="compteur-restant">400</span> / <span id="compteur-limite">400</span>
</div>
</div>
<!-- Zone admin : coin bas-droit, appui long 3s -->
<div id="zone-admin" class="zone-admin"></div>
@@ -58,10 +61,8 @@
<img id="photo-resultat" src="" alt="Photo">
</div>
<div id="barre-filtres" class="barre-filtres">
<!-- Rempli dynamiquement -->
</div>
<div id="barre-overlays" class="barre-overlays cache">
<!-- Rempli dynamiquement -->
</div>
<div class="preview-actions">
<button class="btn-action" onclick="allerA('partage')">Valider</button>
@@ -89,17 +90,14 @@
<span>QR Code</span>
</button>
</div>
<!-- Formulaire email -->
<div id="form-email" class="form-email cache">
<input type="email" id="input-email" placeholder="votre@email.com" autocomplete="off">
<button class="btn-action" onclick="envoyerEmail()">Envoyer</button>
<button class="btn-secondaire" onclick="fermerEmail()">Annuler</button>
</div>
<!-- QR Code -->
<div id="zone-qr" class="zone-qr cache">
<img id="img-qr" src="" alt="QR Code">
</div>
<!-- Message statut -->
<div id="statut-partage" class="statut-partage cache"></div>
<div class="partage-bas">
<button class="btn-action" onclick="allerA('accueil')">Terminer</button>
@@ -110,43 +108,154 @@
<!-- Galerie -->
<section id="ecran-galerie" class="ecran">
<h2>Galerie</h2>
<div id="grille-galerie" class="grille-galerie">
<!-- Rempli dynamiquement -->
</div>
<div id="grille-galerie" class="grille-galerie"></div>
<button class="btn-retour" onclick="allerA('accueil')">Retour</button>
</section>
<!-- Menu Admin -->
<!-- Menu Admin / Backoffice -->
<section id="ecran-admin" class="ecran">
<div class="admin-header">
<h2>Administration</h2>
<h2>Parametres</h2>
<button class="btn-fermer" onclick="allerA('accueil')">&times;</button>
</div>
<div class="admin-contenu">
<!-- Onglets -->
<div class="admin-onglets">
<button class="onglet actif" data-onglet="fonctionnalites">Fonctions</button>
<button class="onglet actif" data-onglet="materiel">Materiel</button>
<button class="onglet" data-onglet="compteur-admin">Compteur</button>
<button class="onglet" data-onglet="destinations-admin">Destinations</button>
<button class="onglet" data-onglet="cadres-admin">Cadres</button>
<button class="onglet" data-onglet="animations-admin">Animation 3-2-1</button>
<button class="onglet" data-onglet="evenement">Evenement</button>
<button class="onglet" data-onglet="camera-admin">Camera</button>
<button class="onglet" data-onglet="impression-admin">Impression</button>
<button class="onglet" data-onglet="fonctionnalites">Fonctions</button>
<button class="onglet" data-onglet="email-admin">Email</button>
<button class="onglet" data-onglet="galerie-admin">Galerie</button>
<button class="onglet" data-onglet="systeme">Systeme</button>
</div>
<!-- Panneau Fonctionnalites -->
<div class="admin-panneau actif" id="panneau-fonctionnalites">
<!-- Panneau Materiel (Camera + Imprimante) -->
<div class="admin-panneau actif" id="panneau-materiel">
<h3>Appareil photo</h3>
<div class="champ">
<label>Statut</label>
<span id="admin-camera-statut" class="statut-badge">--</span>
<button class="btn-secondaire btn-petit" onclick="reconnecterCamera()">Reconnecter</button>
</div>
<div class="champ">
<label>Appareil detecte</label>
<select id="admin-appareil"><option value="">Detection...</option></select>
</div>
<div class="champ">
<label>Compte a rebours (secondes)</label>
<input type="number" id="admin-car" min="1" max="10" value="3">
</div>
<h3>Imprimante</h3>
<div class="champ">
<label>Imprimante</label>
<select id="admin-imprimante"><option value="">Detection...</option></select>
<button class="btn-secondaire btn-petit" onclick="rafraichirImprimantes()">Rafraichir</button>
</div>
<div class="champ">
<label>Copies par impression</label>
<input type="number" id="admin-copies" min="1" max="5" value="1">
</div>
<button class="btn-action" onclick="sauvegarderMateriel()">Sauvegarder</button>
</div>
<!-- Panneau Compteur -->
<div class="admin-panneau" id="panneau-compteur-admin">
<h3>Compteur de photos</h3>
<div class="champ">
<label class="toggle"><input type="checkbox" id="tog-compteur-actif"><span class="toggle-slider"></span> Compteur actif</label>
</div>
<div class="compteur-display">
<div class="compteur-gros">
<span id="admin-compteur-restant" class="compteur-nombre">400</span>
<span class="compteur-sep">/</span>
<span id="admin-compteur-limite-display" class="compteur-nombre petit">400</span>
</div>
<span class="compteur-label">photos restantes</span>
</div>
<div class="champ">
<label>Limite totale</label>
<input type="number" id="admin-compteur-limite" min="1" max="9999" value="400">
</div>
<div class="champ-row">
<button class="btn-action" onclick="sauvegarderCompteur()">Sauvegarder</button>
<button class="btn-danger" onclick="resetCompteur()">Remettre a zero</button>
</div>
</div>
<!-- Panneau Destinations -->
<div class="admin-panneau" id="panneau-destinations-admin">
<h3>Ou sauvegarder les photos ?</h3>
<div class="toggle-groupe">
<label class="toggle"><input type="checkbox" id="tog-photo-simple" checked><span class="toggle-slider"></span> Photo simple</label>
<label class="toggle"><input type="checkbox" id="tog-multi-shot"><span class="toggle-slider"></span> Multi-shot</label>
<label class="toggle"><input type="checkbox" id="tog-filtres"><span class="toggle-slider"></span> Filtres</label>
<label class="toggle"><input type="checkbox" id="tog-overlays"><span class="toggle-slider"></span> Overlays / Cadres</label>
<label class="toggle"><input type="checkbox" id="tog-chroma-key"><span class="toggle-slider"></span> Chroma key (fond vert)</label>
<label class="toggle"><input type="checkbox" id="tog-impression"><span class="toggle-slider"></span> Impression</label>
<label class="toggle"><input type="checkbox" id="tog-email"><span class="toggle-slider"></span> Email</label>
<label class="toggle"><input type="checkbox" id="tog-qr-code"><span class="toggle-slider"></span> QR Code</label>
<label class="toggle"><input type="checkbox" id="tog-galerie"><span class="toggle-slider"></span> Galerie</label>
<label class="toggle"><input type="checkbox" id="tog-compteur"><span class="toggle-slider"></span> Compteur</label>
<label class="toggle"><input type="checkbox" id="tog-dest-memoire" checked><span class="toggle-slider"></span> Memoire interne</label>
<label class="toggle"><input type="checkbox" id="tog-dest-usb"><span class="toggle-slider"></span> Cle USB</label>
</div>
<div id="dest-usb-details" class="sous-config cache">
<div class="champ">
<label>Chemin USB</label>
<div class="champ-row">
<select id="admin-chemin-usb"><option value="/media/usb">/media/usb</option></select>
<button class="btn-secondaire btn-petit" onclick="detecterUSB()">Detecter</button>
</div>
</div>
</div>
<div class="toggle-groupe">
<label class="toggle"><input type="checkbox" id="tog-dest-ftp"><span class="toggle-slider"></span> Serveur FTP</label>
</div>
<div id="dest-ftp-details" class="sous-config cache">
<div class="champ"><label>Hote FTP</label><input type="text" id="admin-ftp-host" placeholder="ftp.exemple.com"></div>
<div class="champ"><label>Port</label><input type="number" id="admin-ftp-port" value="21"></div>
<div class="champ"><label>Utilisateur</label><input type="text" id="admin-ftp-user"></div>
<div class="champ"><label>Mot de passe</label><input type="password" id="admin-ftp-pass"></div>
<div class="champ"><label>Chemin distant</label><input type="text" id="admin-ftp-chemin" value="/photobooth"></div>
</div>
<div class="toggle-groupe">
<label class="toggle"><input type="checkbox" id="tog-dest-web"><span class="toggle-slider"></span> Site internet</label>
</div>
<div id="dest-web-details" class="sous-config cache">
<div class="champ"><label>URL d'upload</label><input type="text" id="admin-web-url" placeholder="https://..."></div>
</div>
<div class="toggle-groupe">
<label class="toggle"><input type="checkbox" id="tog-dest-email-auto"><span class="toggle-slider"></span> Email automatique</label>
</div>
<h3>Quelles photos sauvegarder ?</h3>
<div class="radio-groupe">
<label class="radio"><input type="radio" name="sauv-mode" id="sauv-tout" value="tout" checked><span class="radio-mark"></span> Toutes les photos</label>
<label class="radio"><input type="radio" name="sauv-mode" id="sauv-imprimees" value="imprimees"><span class="radio-mark"></span> Uniquement les photos imprimees</label>
</div>
<button class="btn-action" onclick="sauvegarderDestinations()">Sauvegarder</button>
</div>
<!-- Panneau Cadres -->
<div class="admin-panneau" id="panneau-cadres-admin">
<h3>Cadres disponibles</h3>
<p class="aide">Cochez les cadres a proposer aux utilisateurs.</p>
<div id="liste-cadres" class="liste-cadres">
<p class="texte-secondaire">Aucun cadre importe</p>
</div>
<h3>Importer un cadre</h3>
<div class="champ">
<input type="file" id="input-cadre" accept=".png" class="input-fichier">
<button class="btn-action" onclick="uploaderCadre()">Importer</button>
</div>
</div>
<!-- Panneau Animations -->
<div class="admin-panneau" id="panneau-animations-admin">
<h3>Animation du compte a rebours</h3>
<p class="aide">Choisissez le style d'animation pour le 3... 2... 1...</p>
<div id="liste-animations" class="liste-animations">
</div>
<div class="animation-preview">
<h4>Apercu</h4>
<div id="preview-animation" class="preview-animation-box">
<span id="preview-anim-chiffre" class="anim-chiffre">3</span>
</div>
<button class="btn-secondaire" onclick="testerAnimation()">Tester</button>
</div>
</div>
@@ -167,53 +276,28 @@
<button class="btn-action" onclick="sauvegarderEvenement()">Sauvegarder</button>
</div>
<!-- Panneau Camera -->
<div class="admin-panneau" id="panneau-camera-admin">
<div class="champ">
<label>Statut</label>
<span id="admin-camera-statut">--</span>
<button class="btn-secondaire" onclick="reconnecterCamera()">Reconnecter</button>
</div>
<div class="champ">
<label>Compte a rebours (secondes)</label>
<input type="number" id="admin-car" min="1" max="10" value="3">
</div>
</div>
<!-- Panneau Impression -->
<div class="admin-panneau" id="panneau-impression-admin">
<div class="champ">
<label>Imprimante</label>
<select id="admin-imprimante"><option value="">Chargement...</option></select>
</div>
<div class="champ">
<label>Copies</label>
<input type="number" id="admin-copies" min="1" max="5" value="1">
<!-- Panneau Fonctionnalites -->
<div class="admin-panneau" id="panneau-fonctionnalites">
<div class="toggle-groupe">
<label class="toggle"><input type="checkbox" id="tog-photo-simple" checked><span class="toggle-slider"></span> Photo simple</label>
<label class="toggle"><input type="checkbox" id="tog-multi-shot"><span class="toggle-slider"></span> Multi-shot</label>
<label class="toggle"><input type="checkbox" id="tog-filtres"><span class="toggle-slider"></span> Filtres</label>
<label class="toggle"><input type="checkbox" id="tog-overlays"><span class="toggle-slider"></span> Overlays / Cadres</label>
<label class="toggle"><input type="checkbox" id="tog-chroma-key"><span class="toggle-slider"></span> Chroma key (fond vert)</label>
<label class="toggle"><input type="checkbox" id="tog-impression"><span class="toggle-slider"></span> Impression</label>
<label class="toggle"><input type="checkbox" id="tog-email"><span class="toggle-slider"></span> Email</label>
<label class="toggle"><input type="checkbox" id="tog-qr-code"><span class="toggle-slider"></span> QR Code</label>
<label class="toggle"><input type="checkbox" id="tog-galerie"><span class="toggle-slider"></span> Galerie</label>
</div>
</div>
<!-- Panneau Email -->
<div class="admin-panneau" id="panneau-email-admin">
<div class="champ">
<label>Serveur SMTP</label>
<input type="text" id="admin-smtp-host" placeholder="smtp.gmail.com">
</div>
<div class="champ">
<label>Port</label>
<input type="number" id="admin-smtp-port" value="587">
</div>
<div class="champ">
<label>Utilisateur</label>
<input type="text" id="admin-smtp-user" placeholder="user@gmail.com">
</div>
<div class="champ">
<label>Mot de passe</label>
<input type="password" id="admin-smtp-pass">
</div>
<div class="champ">
<label>Expediteur</label>
<input type="text" id="admin-expediteur" placeholder="photobooth@event.com">
</div>
<div class="champ"><label>Serveur SMTP</label><input type="text" id="admin-smtp-host" placeholder="smtp.gmail.com"></div>
<div class="champ"><label>Port</label><input type="number" id="admin-smtp-port" value="587"></div>
<div class="champ"><label>Utilisateur</label><input type="text" id="admin-smtp-user" placeholder="user@gmail.com"></div>
<div class="champ"><label>Mot de passe</label><input type="password" id="admin-smtp-pass"></div>
<div class="champ"><label>Expediteur</label><input type="text" id="admin-expediteur" placeholder="photobooth@event.com"></div>
<button class="btn-action" onclick="sauvegarderEmail()">Sauvegarder</button>
</div>

View File

@@ -1,6 +1,6 @@
/* Module administration - Menu cache */
/* Module administration - Backoffice complet */
// Mapping toggle ID -> cle config
// Mapping toggle ID -> cle config fonctionnalites
const TOGGLES_MAP = {
'tog-photo-simple': 'photo_simple',
'tog-multi-shot': 'multi_shot',
@@ -11,36 +11,53 @@ const TOGGLES_MAP = {
'tog-email': 'email',
'tog-qr-code': 'qr_code',
'tog-galerie': 'galerie',
'tog-compteur': 'compteur',
};
async function chargerAdmin() {
config = await apiGet('/api/config');
const fonc = config.fonctionnalites || {};
const event = config.evenement || {};
chargerMateriel();
chargerCompteur();
chargerDestinations();
chargerCadres();
chargerAnimations();
chargerFonctionnalites();
chargerEmailAdmin();
chargerGalerieAdmin();
}
// === MATERIEL ===
async function chargerMateriel() {
const cam = config.camera || {};
const imp = config.impression || {};
const email = config.email || {};
const qr = config.qr_code || {};
// Toggles fonctionnalites
for (const [id, cle] of Object.entries(TOGGLES_MAP)) {
const el = document.getElementById(id);
if (el) el.checked = fonc[cle] !== false;
}
// Evenement
setValue('admin-nom-event', event.nom);
setValue('admin-couleur-primaire', event.couleur_primaire || '#e91e63');
setValue('admin-couleur-secondaire', event.couleur_secondaire || '#ffffff');
// Camera
const statut = await apiGet('/api/camera/statut');
document.getElementById('admin-camera-statut').textContent =
statut.connectee ? 'Connectee' : 'Deconnectee';
const badge = document.getElementById('admin-camera-statut');
badge.textContent = statut.connectee ? 'Connectee' : 'Deconnectee';
badge.style.background = statut.connectee ? 'rgba(76,175,80,0.2)' : 'rgba(244,67,54,0.2)';
badge.style.color = statut.connectee ? 'var(--succes)' : 'var(--danger)';
// Liste appareils
const selectApp = document.getElementById('admin-appareil');
selectApp.innerHTML = '';
for (const a of statut.appareils) {
const opt = document.createElement('option');
opt.value = a;
opt.textContent = a;
selectApp.appendChild(opt);
}
if (cam.appareil) selectApp.value = cam.appareil;
setValue('admin-car', cam.compte_a_rebours || 3);
// Impression
// Imprimantes
await rafraichirImprimantes();
if (imp.imprimante) document.getElementById('admin-imprimante').value = imp.imprimante;
setValue('admin-copies', imp.copies || 1);
}
async function rafraichirImprimantes() {
const imprimantes = await apiGet('/api/imprimantes');
const select = document.getElementById('admin-imprimante');
select.innerHTML = '';
@@ -50,23 +67,266 @@ async function chargerAdmin() {
opt.textContent = `${p.nom} (${p.statut})`;
select.appendChild(opt);
}
if (imp.imprimante) select.value = imp.imprimante;
setValue('admin-copies', imp.copies || 1);
}
// Email
setValue('admin-smtp-host', email.smtp_host);
setValue('admin-smtp-port', email.smtp_port || 587);
setValue('admin-smtp-user', email.smtp_user);
setValue('admin-smtp-pass', email.smtp_password);
setValue('admin-expediteur', email.expediteur);
async function reconnecterCamera() {
const resultat = await apiPost('/api/camera/reconnecter');
chargerMateriel();
}
// Galerie
const compteur = await apiGet('/api/galerie/compteur');
document.getElementById('admin-nb-photos').textContent = compteur.photos_prises || 0;
document.getElementById('admin-nb-exports').textContent = compteur.exports || 0;
setValue('admin-url-galerie', qr.url_galerie);
async function sauvegarderMateriel() {
await apiPost('/api/config', {
camera: {
appareil: getValue('admin-appareil'),
compte_a_rebours: parseInt(getValue('admin-car')) || 3,
},
impression: {
imprimante: getValue('admin-imprimante'),
copies: parseInt(getValue('admin-copies')) || 1,
},
});
afficherStatut('Materiel sauvegarde', 'succes');
}
// Ecouter les changements de toggles
// === COMPTEUR ===
async function chargerCompteur() {
const etat = await apiGet('/api/compteur');
document.getElementById('tog-compteur-actif').checked = etat.actif;
document.getElementById('admin-compteur-restant').textContent = etat.restantes;
document.getElementById('admin-compteur-limite-display').textContent = etat.limite;
setValue('admin-compteur-limite', etat.limite);
}
async function sauvegarderCompteur() {
await apiPost('/api/config', {
compteur: {
actif: document.getElementById('tog-compteur-actif').checked,
limite: parseInt(getValue('admin-compteur-limite')) || 400,
},
});
chargerCompteur();
afficherStatut('Compteur sauvegarde', 'succes');
}
async function resetCompteur() {
if (confirm('Remettre le compteur a zero ?')) {
await apiPost('/api/compteur/reset');
chargerCompteur();
afficherStatut('Compteur remis a zero', 'succes');
}
}
// === DESTINATIONS ===
function chargerDestinations() {
const dest = config.destinations || {};
document.getElementById('tog-dest-memoire').checked = dest.memoire_interne !== false;
document.getElementById('tog-dest-usb').checked = dest.cle_usb || false;
document.getElementById('tog-dest-ftp').checked = dest.ftp || false;
document.getElementById('tog-dest-web').checked = dest.site_web || false;
document.getElementById('tog-dest-email-auto').checked = dest.email_auto || false;
// Sous-configs visibles si actives
toggleSousConfig('tog-dest-usb', 'dest-usb-details');
toggleSousConfig('tog-dest-ftp', 'dest-ftp-details');
toggleSousConfig('tog-dest-web', 'dest-web-details');
setValue('admin-ftp-host', dest.ftp_host);
setValue('admin-ftp-port', dest.ftp_port || 21);
setValue('admin-ftp-user', dest.ftp_user);
setValue('admin-ftp-pass', dest.ftp_password);
setValue('admin-ftp-chemin', dest.ftp_chemin || '/photobooth');
setValue('admin-web-url', dest.site_web_url);
// Radio sauvegarder tout / imprimees
if (dest.sauvegarder_tout === false) {
document.getElementById('sauv-imprimees').checked = true;
} else {
document.getElementById('sauv-tout').checked = true;
}
// Listeners pour afficher/cacher sous-configs
['tog-dest-usb', 'tog-dest-ftp', 'tog-dest-web'].forEach(id => {
const el = document.getElementById(id);
el.onchange = () => {
const map = { 'tog-dest-usb': 'dest-usb-details', 'tog-dest-ftp': 'dest-ftp-details', 'tog-dest-web': 'dest-web-details' };
toggleSousConfig(id, map[id]);
};
});
}
function toggleSousConfig(toggleId, detailsId) {
const checked = document.getElementById(toggleId).checked;
const el = document.getElementById(detailsId);
if (checked) el.classList.remove('cache');
else el.classList.add('cache');
}
async function detecterUSB() {
const usbs = await apiGet('/api/usb/detecter');
const select = document.getElementById('admin-chemin-usb');
select.innerHTML = '';
if (usbs.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'Aucune cle detectee';
select.appendChild(opt);
} else {
for (const u of usbs) {
const opt = document.createElement('option');
opt.value = u;
opt.textContent = u;
select.appendChild(opt);
}
}
}
async function sauvegarderDestinations() {
await apiPost('/api/config', {
destinations: {
memoire_interne: document.getElementById('tog-dest-memoire').checked,
cle_usb: document.getElementById('tog-dest-usb').checked,
chemin_usb: getValue('admin-chemin-usb') || '/media/usb',
ftp: document.getElementById('tog-dest-ftp').checked,
ftp_host: getValue('admin-ftp-host'),
ftp_port: parseInt(getValue('admin-ftp-port')) || 21,
ftp_user: getValue('admin-ftp-user'),
ftp_password: getValue('admin-ftp-pass'),
ftp_chemin: getValue('admin-ftp-chemin') || '/photobooth',
site_web: document.getElementById('tog-dest-web').checked,
site_web_url: getValue('admin-web-url'),
email_auto: document.getElementById('tog-dest-email-auto').checked,
sauvegarder_tout: document.getElementById('sauv-tout').checked,
},
});
afficherStatut('Destinations sauvegardees', 'succes');
}
// === CADRES ===
async function chargerCadres() {
const data = await apiGet('/api/cadres');
const liste = document.getElementById('liste-cadres');
liste.innerHTML = '';
if (data.tous.length === 0) {
liste.innerHTML = '<p class="texte-secondaire">Aucun cadre importe</p>';
return;
}
for (const nom of data.tous) {
const div = document.createElement('div');
div.className = 'cadre-item';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = data.actifs.includes(nom);
cb.onchange = () => majCadresActifs();
const img = document.createElement('img');
img.src = `/assets/overlays/${nom}`;
img.alt = nom;
const span = document.createElement('span');
span.textContent = nom.replace('.png', '');
div.appendChild(cb);
div.appendChild(img);
div.appendChild(span);
liste.appendChild(div);
}
}
async function majCadresActifs() {
const items = document.querySelectorAll('.cadre-item input[type="checkbox"]');
const noms = document.querySelectorAll('.cadre-item span');
const actifs = [];
items.forEach((cb, i) => {
if (cb.checked) actifs.push(noms[i].textContent + '.png');
});
await apiPost('/api/cadres', { actifs });
}
async function uploaderCadre() {
const input = document.getElementById('input-cadre');
if (!input.files.length) return;
const formData = new FormData();
formData.append('fichier', input.files[0]);
await fetch('/api/upload/overlay', { method: 'POST', body: formData });
input.value = '';
chargerCadres();
afficherStatut('Cadre importe', 'succes');
}
// === ANIMATIONS ===
let animationChoisie = 'classique';
async function chargerAnimations() {
const anims = await apiGet('/api/animations');
animationChoisie = config.camera?.animation_compte_a_rebours || 'classique';
const liste = document.getElementById('liste-animations');
liste.innerHTML = '';
for (const [id, nom] of Object.entries(anims)) {
const div = document.createElement('div');
div.className = 'anim-option' + (id === animationChoisie ? ' actif' : '');
div.textContent = nom;
div.onclick = () => choisirAnimation(id, div);
liste.appendChild(div);
}
}
async function choisirAnimation(id, el) {
animationChoisie = id;
document.querySelectorAll('.anim-option').forEach(e => e.classList.remove('actif'));
el.classList.add('actif');
await apiPost('/api/config', {
camera: { animation_compte_a_rebours: id },
});
testerAnimation();
}
const EMOJI_MAP = { 3: '🤪', 2: '😱', 1: '🔥' };
function testerAnimation() {
const chiffre = document.getElementById('preview-anim-chiffre');
let count = 3;
function afficher() {
if (animationChoisie === 'emoji') {
chiffre.textContent = EMOJI_MAP[count] || count;
} else {
chiffre.textContent = count;
}
chiffre.className = 'anim-chiffre anim-' + animationChoisie;
// Force reflow pour relancer l'animation
chiffre.style.animation = 'none';
chiffre.offsetHeight;
chiffre.className = 'anim-chiffre anim-' + animationChoisie;
count--;
if (count > 0) setTimeout(afficher, 800);
}
afficher();
}
// === FONCTIONNALITES ===
function chargerFonctionnalites() {
const fonc = config.fonctionnalites || {};
for (const [id, cle] of Object.entries(TOGGLES_MAP)) {
const el = document.getElementById(id);
if (el) el.checked = fonc[cle] !== false;
}
setupToggleListeners();
}
@@ -74,7 +334,6 @@ function setupToggleListeners() {
for (const [id, cle] of Object.entries(TOGGLES_MAP)) {
const el = document.getElementById(id);
if (!el) continue;
// Retirer les anciens listeners en clonant
const nouveau = el.cloneNode(true);
el.parentNode.replaceChild(nouveau, el);
nouveau.addEventListener('change', () => {
@@ -83,6 +342,15 @@ function setupToggleListeners() {
}
}
// === EVENEMENT ===
function chargerEvenementAdmin() {
const event = config.evenement || {};
setValue('admin-nom-event', event.nom);
setValue('admin-couleur-primaire', event.couleur_primaire || '#e91e63');
setValue('admin-couleur-secondaire', event.couleur_secondaire || '#ffffff');
}
async function sauvegarderEvenement() {
await apiPost('/api/config', {
evenement: {
@@ -90,13 +358,21 @@ async function sauvegarderEvenement() {
couleur_primaire: getValue('admin-couleur-primaire'),
couleur_secondaire: getValue('admin-couleur-secondaire'),
},
camera: {
compte_a_rebours: parseInt(getValue('admin-car')) || 3,
},
});
afficherStatut('Evenement sauvegarde', 'succes');
}
// === EMAIL ===
function chargerEmailAdmin() {
const email = config.email || {};
setValue('admin-smtp-host', email.smtp_host);
setValue('admin-smtp-port', email.smtp_port || 587);
setValue('admin-smtp-user', email.smtp_user);
setValue('admin-smtp-pass', email.smtp_password);
setValue('admin-expediteur', email.expediteur);
}
async function sauvegarderEmail() {
await apiPost('/api/config', {
email: {
@@ -110,45 +386,43 @@ async function sauvegarderEmail() {
afficherStatut('Email sauvegarde', 'succes');
}
// === GALERIE ===
async function chargerGalerieAdmin() {
const compteur = await apiGet('/api/galerie/compteur');
document.getElementById('admin-nb-photos').textContent = compteur.photos_prises || 0;
document.getElementById('admin-nb-exports').textContent = compteur.exports || 0;
setValue('admin-url-galerie', (config.qr_code || {}).url_galerie);
}
async function sauvegarderGalerie() {
await apiPost('/api/config', {
qr_code: { url_galerie: getValue('admin-url-galerie') },
impression: {
imprimante: getValue('admin-imprimante'),
copies: parseInt(getValue('admin-copies')) || 1,
},
});
afficherStatut('Configuration sauvegardee', 'succes');
}
async function reconnecterCamera() {
const resultat = await apiPost('/api/camera/reconnecter');
document.getElementById('admin-camera-statut').textContent =
resultat.connectee ? 'Connectee' : 'Deconnectee';
}
function confirmerViderGalerie() {
if (confirm('Supprimer TOUTES les photos ? Cette action est irreversible.')) {
apiPost('/api/galerie/vider').then(() => {
afficherStatut('Galerie videe', 'succes');
chargerAdmin();
chargerGalerieAdmin();
});
}
}
// === SYSTEME ===
function confirmerRedemarrage() {
if (confirm('Redemarrer le systeme ?')) {
apiPost('/api/systeme/redemarrer');
}
if (confirm('Redemarrer le systeme ?')) apiPost('/api/systeme/redemarrer');
}
function confirmerExtinction() {
if (confirm('Eteindre le systeme ?')) {
apiPost('/api/systeme/eteindre');
}
if (confirm('Eteindre le systeme ?')) apiPost('/api/systeme/eteindre');
}
// Utilitaires
// === Utilitaires ===
function setValue(id, val) {
const el = document.getElementById(id);
if (el) el.value = val || '';

View File

@@ -52,6 +52,7 @@ function allerA(ecran) {
photosSession = [];
photoFinale = null;
arreterPreview();
majCompteurAccueil();
} else if (ecran === 'capture') {
lancerCapture();
} else if (ecran === 'galerie') {

View File

@@ -3,16 +3,17 @@
let previewActif = false;
let previewInterval = null;
const EMOJI_CAR = { 3: '🤪', 2: '😱', 1: '🔥' };
// --- Preview live ---
function lancerPreview() {
if (previewActif) return;
previewActif = true;
// Demander des previews via WebSocket
previewInterval = setInterval(() => {
if (previewActif) wsEnvoyer({ type: 'preview' });
}, 100); // ~10 fps
}, 100);
}
function arreterPreview() {
@@ -23,7 +24,6 @@ function arreterPreview() {
}
}
// Recevoir les previews
wsOnMessage('preview', (msg) => {
if (!previewActif) return;
const img = document.getElementById('img-preview');
@@ -35,6 +35,7 @@ wsOnMessage('preview', (msg) => {
async function lancerCapture() {
photosSession = [];
lancerPreview();
majCompteurAccueil();
const nbPhotos = getNombrePhotos();
const compteurEl = document.getElementById('capture-compteur');
@@ -44,27 +45,28 @@ async function lancerCapture() {
compteurEl.textContent = `Photo ${i + 1} / ${nbPhotos}`;
}
// Compte a rebours
await compteARebours();
// Flash
afficherFlash();
// Capture
arreterPreview();
const resultat = await apiPost('/api/capturer');
if (resultat.erreur) {
// Limite atteinte ou erreur
allerA('accueil');
return;
}
if (resultat.nom) {
photosSession.push(resultat.nom);
}
// Reprendre le preview si encore des photos a prendre
if (i < nbPhotos - 1) {
lancerPreview();
await pause(500);
}
}
// Traitement selon le mode
await traiterCapture();
}
@@ -78,15 +80,24 @@ async function compteARebours() {
const conteneur = document.getElementById('compte-a-rebours');
const chiffre = document.getElementById('chiffre-car');
const duree = config.camera?.compte_a_rebours || 3;
const anim = config.camera?.animation_compte_a_rebours || 'classique';
conteneur.classList.remove('cache');
for (let i = duree; i > 0; i--) {
// Contenu selon le type d'animation
if (anim === 'emoji') {
chiffre.textContent = EMOJI_CAR[i] || i;
} else {
chiffre.textContent = i;
// Re-trigger animation
}
// Appliquer l'animation
chiffre.className = 'anim-chiffre anim-' + anim;
chiffre.style.animation = 'none';
chiffre.offsetHeight; // force reflow
chiffre.style.animation = 'pop 0.5s ease';
chiffre.offsetHeight;
chiffre.className = 'anim-chiffre anim-' + anim;
await pause(1000);
}
@@ -109,7 +120,6 @@ async function traiterCapture() {
}
if (modeActuel === 'multi') {
// Creer le strip/collage
const mode = config.multi_shot?.mode || 'strip';
let resultat;
if (mode === 'strip') {
@@ -122,7 +132,6 @@ async function traiterCapture() {
afficherPreviewPhoto('/data/exports/' + resultat.nom);
}
} else {
// Photo simple - aller au preview avec filtres
photoFinale = photosSession[0];
afficherPreviewPhoto('/data/photos/' + photosSession[0]);
}
@@ -134,17 +143,28 @@ function afficherPreviewPhoto(chemin) {
document.getElementById('photo-resultat').src = chemin;
document.getElementById('photo-partage').src = chemin;
// Charger les filtres si photo simple
if (modeActuel === 'simple' && config.fonctionnalites?.filtres) {
chargerFiltres();
}
// Charger les overlays
if (config.fonctionnalites?.overlays) {
chargerOverlays();
}
}
// --- Compteur accueil ---
async function majCompteurAccueil() {
const etat = await apiGet('/api/compteur');
const el = document.getElementById('compteur-accueil');
if (etat.actif) {
el.classList.remove('cache');
document.getElementById('compteur-restant').textContent = etat.restantes;
document.getElementById('compteur-limite').textContent = etat.limite;
} else {
el.classList.add('cache');
}
}
function pause(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}