Init photobooth - borne photo evenementielle RPi4

Backend FastAPI complet : camera gphoto2, filtres/overlays Pillow,
chroma key OpenCV, multi-shot/collage, GIF, impression CUPS, email SMTP,
QR code. Frontend web vanilla (HTML/CSS/JS) pour Chromium kiosk.
Menu admin cache (appui long coin ecran). Toutes les fonctionnalites
activables/desactivables. Scripts install + systemd pour RPi4.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 21:46:31 +01:00
commit c8e4ec80c1
29 changed files with 2854 additions and 0 deletions

602
frontend/css/style.css Normal file
View File

@@ -0,0 +1,602 @@
/* === Variables et Reset === */
:root {
--primaire: #e91e63;
--secondaire: #ffffff;
--fond: #1a1a2e;
--fond-carte: #16213e;
--texte: #ffffff;
--texte-secondaire: #a0a0b0;
--danger: #f44336;
--succes: #4caf50;
--rayon: 16px;
--ombre: 0 8px 32px rgba(0,0,0,0.3);
--transition: 0.3s ease;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
user-select: none;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: var(--fond);
color: var(--texte);
font-size: 18px;
}
/* === Ecrans === */
.ecran {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.4s ease;
}
.ecran.actif {
opacity: 1;
pointer-events: all;
}
/* === Ecran d'accueil === */
.accueil-contenu {
text-align: center;
}
.accueil-contenu h1 {
font-size: 3.5rem;
font-weight: 300;
letter-spacing: 0.1em;
margin-bottom: 1rem;
color: var(--primaire);
}
.accueil-instruction {
font-size: 1.4rem;
color: var(--texte-secondaire);
animation: pulse-texte 2s ease-in-out infinite;
}
@keyframes pulse-texte {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.accueil-animation {
margin-top: 3rem;
}
.cercle-pulse {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--primaire);
margin: 0 auto;
animation: pulse-cercle 2s ease-in-out infinite;
}
@keyframes pulse-cercle {
0% { transform: scale(1); opacity: 0.7; }
50% { transform: scale(1.2); opacity: 1; }
100% { transform: scale(1); opacity: 0.7; }
}
/* Zone admin invisible */
.zone-admin {
position: absolute;
bottom: 0;
right: 0;
width: 80px;
height: 80px;
}
/* === Choix du mode === */
.grille-modes {
display: flex;
gap: 2rem;
margin: 2rem 0;
}
.btn-mode {
background: var(--fond-carte);
border: 2px solid transparent;
border-radius: var(--rayon);
padding: 2rem 3rem;
color: var(--texte);
font-size: 1.2rem;
cursor: pointer;
transition: var(--transition);
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
box-shadow: var(--ombre);
}
.btn-mode:active {
transform: scale(0.95);
border-color: var(--primaire);
}
.btn-mode .mode-icone {
font-size: 3rem;
}
/* === Capture / Preview live === */
.preview-live {
width: 100%;
height: 100%;
position: relative;
background: #000;
}
.preview-live img {
width: 100%;
height: 100%;
object-fit: contain;
}
.compte-a-rebours {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
z-index: 10;
}
.compte-a-rebours span {
font-size: 12rem;
font-weight: 700;
color: var(--primaire);
animation: pop 0.5s ease;
}
@keyframes pop {
0% { transform: scale(0.3); opacity: 0; }
60% { transform: scale(1.2); }
100% { transform: scale(1); opacity: 1; }
}
.flash-blanc {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
background: #fff;
z-index: 20;
animation: flash 0.3s ease-out forwards;
}
@keyframes flash {
0% { opacity: 1; }
100% { opacity: 0; }
}
.capture-info {
position: absolute;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.5);
padding: 0.5rem 1.5rem;
border-radius: 2rem;
font-size: 1rem;
z-index: 5;
}
/* === Preview photo + Filtres === */
.preview-photo-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
max-height: 65vh;
}
.preview-photo-container img {
max-width: 100%;
max-height: 100%;
border-radius: var(--rayon);
box-shadow: var(--ombre);
}
.barre-filtres, .barre-overlays {
display: flex;
gap: 0.8rem;
padding: 1rem;
overflow-x: auto;
width: 100%;
justify-content: center;
flex-wrap: wrap;
}
.btn-filtre, .btn-overlay {
background: var(--fond-carte);
border: 2px solid transparent;
border-radius: 12px;
padding: 0.6rem 1.2rem;
color: var(--texte);
font-size: 0.9rem;
cursor: pointer;
transition: var(--transition);
white-space: nowrap;
}
.btn-filtre.actif, .btn-overlay.actif {
border-color: var(--primaire);
background: rgba(233, 30, 99, 0.2);
}
.btn-filtre:active, .btn-overlay:active {
transform: scale(0.95);
}
.preview-actions {
display: flex;
gap: 1rem;
padding: 1rem;
}
/* === Partage === */
#ecran-partage h2 {
margin-top: 2rem;
font-weight: 300;
}
.photo-miniature {
margin: 1rem 0;
max-height: 30vh;
}
.photo-miniature img {
max-height: 30vh;
border-radius: var(--rayon);
box-shadow: var(--ombre);
}
.grille-partage {
display: flex;
gap: 1.5rem;
margin: 1.5rem 0;
}
.btn-partage {
background: var(--fond-carte);
border: 2px solid transparent;
border-radius: var(--rayon);
padding: 1.5rem 2rem;
color: var(--texte);
font-size: 1.1rem;
cursor: pointer;
transition: var(--transition);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.8rem;
box-shadow: var(--ombre);
}
.btn-partage:active {
transform: scale(0.95);
border-color: var(--primaire);
}
.partage-icone {
font-size: 2.5rem;
}
.form-email {
display: flex;
gap: 0.8rem;
align-items: center;
margin: 1rem 0;
}
.form-email input {
background: var(--fond-carte);
border: 2px solid var(--texte-secondaire);
border-radius: 12px;
padding: 0.8rem 1.2rem;
color: var(--texte);
font-size: 1.1rem;
width: 300px;
outline: none;
}
.form-email input:focus {
border-color: var(--primaire);
}
.zone-qr {
margin: 1rem 0;
}
.zone-qr img {
width: 200px;
height: 200px;
border-radius: 12px;
background: #fff;
padding: 8px;
}
.statut-partage {
padding: 0.8rem 1.5rem;
border-radius: 12px;
font-size: 1rem;
margin: 0.5rem 0;
}
.statut-partage.succes { background: rgba(76, 175, 80, 0.2); color: var(--succes); }
.statut-partage.erreur { background: rgba(244, 67, 54, 0.2); color: var(--danger); }
.partage-bas {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
/* === Galerie === */
.grille-galerie {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
padding: 1rem;
width: 100%;
max-height: 80vh;
overflow-y: auto;
}
.grille-galerie img {
width: 100%;
aspect-ratio: 4/3;
object-fit: cover;
border-radius: 12px;
cursor: pointer;
transition: var(--transition);
}
.grille-galerie img:active {
transform: scale(0.95);
}
/* === Boutons communs === */
.btn-action {
background: var(--primaire);
color: #fff;
border: none;
border-radius: 12px;
padding: 0.8rem 2rem;
font-size: 1.1rem;
cursor: pointer;
transition: var(--transition);
box-shadow: 0 4px 16px rgba(233, 30, 99, 0.3);
}
.btn-action:active {
transform: scale(0.95);
}
.btn-secondaire {
background: transparent;
color: var(--texte-secondaire);
border: 2px solid var(--texte-secondaire);
border-radius: 12px;
padding: 0.8rem 2rem;
font-size: 1.1rem;
cursor: pointer;
transition: var(--transition);
}
.btn-secondaire:active {
transform: scale(0.95);
}
.btn-retour {
position: absolute;
bottom: 2rem;
background: transparent;
color: var(--texte-secondaire);
border: 2px solid var(--texte-secondaire);
border-radius: 12px;
padding: 0.6rem 1.5rem;
font-size: 1rem;
cursor: pointer;
}
.btn-fermer {
background: none;
border: none;
color: var(--texte);
font-size: 2rem;
cursor: pointer;
padding: 0.5rem;
}
.btn-danger {
background: var(--danger);
color: #fff;
border: none;
border-radius: 12px;
padding: 0.8rem 2rem;
font-size: 1rem;
cursor: pointer;
margin-top: 1rem;
}
.btn-danger:active {
transform: scale(0.95);
}
/* === Admin === */
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 1rem 2rem;
}
.admin-contenu {
width: 100%;
height: calc(100% - 60px);
display: flex;
overflow: hidden;
}
.admin-onglets {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 1rem;
min-width: 160px;
background: var(--fond-carte);
overflow-y: auto;
}
.onglet {
background: transparent;
border: none;
color: var(--texte-secondaire);
padding: 0.8rem 1rem;
text-align: left;
cursor: pointer;
border-radius: 8px;
font-size: 0.95rem;
transition: var(--transition);
}
.onglet.actif {
background: var(--primaire);
color: #fff;
}
.admin-panneau {
display: none;
flex-direction: column;
gap: 1rem;
padding: 2rem;
flex: 1;
overflow-y: auto;
}
.admin-panneau.actif {
display: flex;
}
/* Toggles */
.toggle-groupe {
display: flex;
flex-direction: column;
gap: 1rem;
}
.toggle {
display: flex;
align-items: center;
gap: 1rem;
cursor: pointer;
font-size: 1rem;
}
.toggle input {
display: none;
}
.toggle-slider {
width: 50px;
height: 28px;
background: #555;
border-radius: 14px;
position: relative;
transition: var(--transition);
flex-shrink: 0;
}
.toggle-slider::after {
content: '';
position: absolute;
top: 3px; left: 3px;
width: 22px; height: 22px;
background: #fff;
border-radius: 50%;
transition: var(--transition);
}
.toggle input:checked + .toggle-slider {
background: var(--primaire);
}
.toggle input:checked + .toggle-slider::after {
left: 25px;
}
/* Champs admin */
.champ {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.champ label {
color: var(--texte-secondaire);
font-size: 0.9rem;
}
.champ input, .champ select {
background: var(--fond);
border: 2px solid #333;
border-radius: 8px;
padding: 0.6rem 1rem;
color: var(--texte);
font-size: 1rem;
outline: none;
}
.champ input:focus, .champ select:focus {
border-color: var(--primaire);
}
/* === Utilitaires === */
.cache {
display: none !important;
}
/* Scrollbar tactile */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #444;
border-radius: 3px;
}
/* Responsive tactile */
@media (max-width: 800px) {
.accueil-contenu h1 { font-size: 2.5rem; }
.grille-modes { flex-direction: column; gap: 1rem; }
.btn-mode { padding: 1.5rem 2rem; }
.grille-partage { flex-direction: column; gap: 1rem; }
.admin-contenu { flex-direction: column; }
.admin-onglets { flex-direction: row; min-width: unset; overflow-x: auto; }
}

257
frontend/index.html Normal file
View File

@@ -0,0 +1,257 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Photobooth</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div id="app">
<!-- Ecran d'accueil -->
<section id="ecran-accueil" class="ecran actif">
<div class="accueil-contenu">
<h1 id="nom-evenement">Photobooth</h1>
<p class="accueil-instruction">Touchez pour commencer</p>
<div class="accueil-animation">
<div class="cercle-pulse"></div>
</div>
</div>
<!-- Zone admin : coin bas-droit, appui long 3s -->
<div id="zone-admin" class="zone-admin"></div>
</section>
<!-- Choix du mode -->
<section id="ecran-mode" class="ecran">
<h2>Choisissez votre mode</h2>
<div class="grille-modes">
<button class="btn-mode" data-mode="simple">
<div class="mode-icone">&#128247;</div>
<span>Photo</span>
</button>
<button class="btn-mode" data-mode="multi" id="btn-multi">
<div class="mode-icone">&#127924;</div>
<span>Multi-shot</span>
</button>
<button class="btn-mode" data-mode="gif" id="btn-gif">
<div class="mode-icone">&#127910;</div>
<span>GIF</span>
</button>
</div>
<button class="btn-retour" onclick="allerA('accueil')">Retour</button>
</section>
<!-- Compte a rebours + Capture -->
<section id="ecran-capture" class="ecran">
<div id="preview-live" class="preview-live">
<img id="img-preview" src="" alt="Preview">
</div>
<div id="compte-a-rebours" class="compte-a-rebours cache">
<span id="chiffre-car">3</span>
</div>
<div id="flash-blanc" class="flash-blanc cache"></div>
<div class="capture-info">
<span id="capture-compteur"></span>
</div>
</section>
<!-- Preview photo + Filtres -->
<section id="ecran-preview" class="ecran">
<div class="preview-photo-container">
<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>
<button class="btn-secondaire" onclick="recommencer()">Reprendre</button>
</div>
</section>
<!-- Partage -->
<section id="ecran-partage" class="ecran">
<h2>Partagez votre photo !</h2>
<div class="photo-miniature">
<img id="photo-partage" src="" alt="Photo">
</div>
<div class="grille-partage">
<button class="btn-partage" id="btn-imprimer" onclick="lancerImpression()">
<div class="partage-icone">&#128424;</div>
<span>Imprimer</span>
</button>
<button class="btn-partage" id="btn-email" onclick="ouvrirEmail()">
<div class="partage-icone">&#128231;</div>
<span>Email</span>
</button>
<button class="btn-partage" id="btn-qr" onclick="afficherQR()">
<div class="partage-icone">&#128274;</div>
<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>
<button class="btn-secondaire" onclick="recommencer()">Nouvelle photo</button>
</div>
</section>
<!-- Galerie -->
<section id="ecran-galerie" class="ecran">
<h2>Galerie</h2>
<div id="grille-galerie" class="grille-galerie">
<!-- Rempli dynamiquement -->
</div>
<button class="btn-retour" onclick="allerA('accueil')">Retour</button>
</section>
<!-- Menu Admin -->
<section id="ecran-admin" class="ecran">
<div class="admin-header">
<h2>Administration</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" 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="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">
<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-gif"><span class="toggle-slider"></span> GIF anime</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>
</div>
</div>
<!-- Panneau Evenement -->
<div class="admin-panneau" id="panneau-evenement">
<div class="champ">
<label>Nom de l'evenement</label>
<input type="text" id="admin-nom-event" placeholder="Mon Evenement">
</div>
<div class="champ">
<label>Couleur primaire</label>
<input type="color" id="admin-couleur-primaire" value="#e91e63">
</div>
<div class="champ">
<label>Couleur secondaire</label>
<input type="color" id="admin-couleur-secondaire" value="#ffffff">
</div>
<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">
</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>
<button class="btn-action" onclick="sauvegarderEmail()">Sauvegarder</button>
</div>
<!-- Panneau Galerie -->
<div class="admin-panneau" id="panneau-galerie-admin">
<div class="champ">
<label>Photos prises : <span id="admin-nb-photos">0</span></label>
<label>Exports : <span id="admin-nb-exports">0</span></label>
</div>
<div class="champ">
<label>URL galerie en ligne</label>
<input type="text" id="admin-url-galerie" placeholder="https://...">
</div>
<button class="btn-action" onclick="sauvegarderGalerie()">Sauvegarder URL</button>
<button class="btn-danger" onclick="confirmerViderGalerie()">Vider toute la galerie</button>
</div>
<!-- Panneau Systeme -->
<div class="admin-panneau" id="panneau-systeme">
<button class="btn-danger" onclick="confirmerRedemarrage()">Redemarrer le systeme</button>
<button class="btn-danger" onclick="confirmerExtinction()">Eteindre le systeme</button>
</div>
</div>
</section>
</div>
<script src="/js/websocket.js"></script>
<script src="/js/app.js"></script>
<script src="/js/camera.js"></script>
<script src="/js/effects.js"></script>
<script src="/js/gallery.js"></script>
<script src="/js/share.js"></script>
<script src="/js/admin.js"></script>
</body>
</html>

161
frontend/js/admin.js Normal file
View File

@@ -0,0 +1,161 @@
/* Module administration - Menu cache */
// Mapping toggle ID -> cle config
const TOGGLES_MAP = {
'tog-photo-simple': 'photo_simple',
'tog-multi-shot': 'multi_shot',
'tog-gif': 'gif',
'tog-filtres': 'filtres',
'tog-overlays': 'overlays',
'tog-chroma-key': 'chroma_key',
'tog-impression': 'impression',
'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 || {};
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';
setValue('admin-car', cam.compte_a_rebours || 3);
// Impression
const imprimantes = await apiGet('/api/imprimantes');
const select = document.getElementById('admin-imprimante');
select.innerHTML = '';
for (const p of imprimantes) {
const opt = document.createElement('option');
opt.value = p.nom;
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);
// 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);
// Ecouter les changements de toggles
setupToggleListeners();
}
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', () => {
apiPost('/api/config', { fonctionnalites: { [cle]: nouveau.checked } });
});
}
}
async function sauvegarderEvenement() {
await apiPost('/api/config', {
evenement: {
nom: getValue('admin-nom-event'),
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');
}
async function sauvegarderEmail() {
await apiPost('/api/config', {
email: {
smtp_host: getValue('admin-smtp-host'),
smtp_port: parseInt(getValue('admin-smtp-port')) || 587,
smtp_user: getValue('admin-smtp-user'),
smtp_password: getValue('admin-smtp-pass'),
expediteur: getValue('admin-expediteur'),
},
});
afficherStatut('Email sauvegarde', 'succes');
}
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();
});
}
}
function confirmerRedemarrage() {
if (confirm('Redemarrer le systeme ?')) {
apiPost('/api/systeme/redemarrer');
}
}
function confirmerExtinction() {
if (confirm('Eteindre le systeme ?')) {
apiPost('/api/systeme/eteindre');
}
}
// Utilitaires
function setValue(id, val) {
const el = document.getElementById(id);
if (el) el.value = val || '';
}
function getValue(id) {
const el = document.getElementById(id);
return el ? el.value : '';
}

172
frontend/js/app.js Normal file
View File

@@ -0,0 +1,172 @@
/* Application principale - Routeur SPA et logique globale */
let config = {};
let modeActuel = 'simple';
let photosSession = []; // Photos de la session en cours
let photoFinale = null; // Photo finale (avec effets)
let ecranActuel = 'accueil';
// --- Initialisation ---
async function init() {
config = await apiGet('/api/config');
appliquerConfig();
setupEcranAccueil();
setupModes();
setupOnglets();
}
function appliquerConfig() {
const event = config.evenement || {};
const fonc = config.fonctionnalites || {};
// Couleurs
document.documentElement.style.setProperty('--primaire', event.couleur_primaire || '#e91e63');
document.documentElement.style.setProperty('--secondaire', event.couleur_secondaire || '#ffffff');
// Nom evenement
const h1 = document.getElementById('nom-evenement');
if (h1) h1.textContent = event.nom || 'Photobooth';
// Visibilite des modes
toggleVisible('btn-multi', fonc.multi_shot);
toggleVisible('btn-gif', fonc.gif);
toggleVisible('btn-imprimer', fonc.impression);
toggleVisible('btn-email', fonc.email);
toggleVisible('btn-qr', fonc.qr_code);
}
// --- Navigation ---
function allerA(ecran) {
const tous = document.querySelectorAll('.ecran');
tous.forEach(e => e.classList.remove('actif'));
const cible = document.getElementById('ecran-' + ecran);
if (cible) {
cible.classList.add('actif');
ecranActuel = ecran;
}
// Actions au changement d'ecran
if (ecran === 'accueil') {
photosSession = [];
photoFinale = null;
arreterPreview();
} else if (ecran === 'capture') {
lancerCapture();
} else if (ecran === 'galerie') {
chargerGalerie();
} else if (ecran === 'admin') {
chargerAdmin();
}
}
function recommencer() {
photosSession = [];
photoFinale = null;
allerA('mode');
}
// --- Ecran accueil ---
function setupEcranAccueil() {
const accueil = document.getElementById('ecran-accueil');
// Toucher pour commencer
accueil.addEventListener('click', (e) => {
// Ignorer si c'est la zone admin
if (e.target.closest('.zone-admin')) return;
allerA('mode');
});
// Zone admin : appui long 3s
const zoneAdmin = document.getElementById('zone-admin');
let timerAdmin = null;
zoneAdmin.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
timerAdmin = setTimeout(() => allerA('admin'), 3000);
});
zoneAdmin.addEventListener('touchend', () => {
if (timerAdmin) clearTimeout(timerAdmin);
});
// Support souris aussi (pour dev)
zoneAdmin.addEventListener('mousedown', (e) => {
e.stopPropagation();
timerAdmin = setTimeout(() => allerA('admin'), 3000);
});
zoneAdmin.addEventListener('mouseup', () => {
if (timerAdmin) clearTimeout(timerAdmin);
});
}
// --- Modes ---
function setupModes() {
document.querySelectorAll('.btn-mode').forEach(btn => {
btn.addEventListener('click', () => {
modeActuel = btn.dataset.mode;
allerA('capture');
});
});
}
// --- Onglets admin ---
function setupOnglets() {
document.querySelectorAll('.onglet').forEach(onglet => {
onglet.addEventListener('click', () => {
document.querySelectorAll('.onglet').forEach(o => o.classList.remove('actif'));
document.querySelectorAll('.admin-panneau').forEach(p => p.classList.remove('actif'));
onglet.classList.add('actif');
const panneau = document.getElementById('panneau-' + onglet.dataset.onglet);
if (panneau) panneau.classList.add('actif');
});
});
}
// --- Utilitaires ---
async function apiGet(url) {
const resp = await fetch(url);
return resp.json();
}
async function apiPost(url, data = {}) {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return resp.json();
}
function toggleVisible(id, visible) {
const el = document.getElementById(id);
if (el) {
if (visible === false) el.classList.add('cache');
else el.classList.remove('cache');
}
}
function afficherStatut(message, type = 'succes') {
const el = document.getElementById('statut-partage');
el.textContent = message;
el.className = 'statut-partage ' + type;
el.classList.remove('cache');
setTimeout(() => el.classList.add('cache'), 4000);
}
// WebSocket : mise a jour config en temps reel
wsOnMessage('config_maj', (msg) => {
config = msg.config;
appliquerConfig();
});
// Demarrage
document.addEventListener('DOMContentLoaded', init);

158
frontend/js/camera.js Normal file
View File

@@ -0,0 +1,158 @@
/* Module capture photo - Preview live, compte a rebours, declenchement */
let previewActif = false;
let previewInterval = null;
// --- Preview live ---
function lancerPreview() {
if (previewActif) return;
previewActif = true;
// Demander des previews via WebSocket
previewInterval = setInterval(() => {
if (previewActif) wsEnvoyer({ type: 'preview' });
}, 100); // ~10 fps
}
function arreterPreview() {
previewActif = false;
if (previewInterval) {
clearInterval(previewInterval);
previewInterval = null;
}
}
// Recevoir les previews
wsOnMessage('preview', (msg) => {
if (!previewActif) return;
const img = document.getElementById('img-preview');
if (img) img.src = msg.image;
});
// --- Capture ---
async function lancerCapture() {
photosSession = [];
lancerPreview();
const nbPhotos = getNombrePhotos();
const compteurEl = document.getElementById('capture-compteur');
for (let i = 0; i < nbPhotos; i++) {
if (nbPhotos > 1) {
compteurEl.textContent = `Photo ${i + 1} / ${nbPhotos}`;
}
// Compte a rebours
await compteARebours();
// Flash
afficherFlash();
// Capture
arreterPreview();
const resultat = await apiPost('/api/capturer');
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();
}
function getNombrePhotos() {
if (modeActuel === 'simple') return 1;
if (modeActuel === 'multi') return config.multi_shot?.nombre_photos || 3;
if (modeActuel === 'gif') return config.gif?.nombre_frames || 4;
return 1;
}
async function compteARebours() {
const conteneur = document.getElementById('compte-a-rebours');
const chiffre = document.getElementById('chiffre-car');
const duree = config.camera?.compte_a_rebours || 3;
conteneur.classList.remove('cache');
for (let i = duree; i > 0; i--) {
chiffre.textContent = i;
// Re-trigger animation
chiffre.style.animation = 'none';
chiffre.offsetHeight; // force reflow
chiffre.style.animation = 'pop 0.5s ease';
await pause(1000);
}
conteneur.classList.add('cache');
}
function afficherFlash() {
const flash = document.getElementById('flash-blanc');
flash.classList.remove('cache');
flash.style.animation = 'none';
flash.offsetHeight;
flash.style.animation = 'flash 0.3s ease-out forwards';
setTimeout(() => flash.classList.add('cache'), 400);
}
async function traiterCapture() {
if (photosSession.length === 0) {
allerA('accueil');
return;
}
if (modeActuel === 'gif') {
// Creer le GIF
const resultat = await apiPost('/api/gif', { photos: photosSession });
if (resultat.nom) {
photoFinale = resultat.nom;
afficherPreviewPhoto('/data/exports/' + resultat.nom);
}
} else if (modeActuel === 'multi') {
// Creer le strip/collage
const mode = config.multi_shot?.mode || 'strip';
let resultat;
if (mode === 'strip') {
resultat = await apiPost('/api/strip', { photos: photosSession });
} else {
resultat = await apiPost('/api/collage', { photos: photosSession });
}
if (resultat.nom) {
photoFinale = resultat.nom;
afficherPreviewPhoto('/data/exports/' + resultat.nom);
}
} else {
// Photo simple - aller au preview avec filtres
photoFinale = photosSession[0];
afficherPreviewPhoto('/data/photos/' + photosSession[0]);
}
allerA('preview');
}
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();
}
}
function pause(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

103
frontend/js/effects.js vendored Normal file
View File

@@ -0,0 +1,103 @@
/* Module effets - Filtres et overlays */
let filtreActuel = 'original';
let overlayActuel = null;
async function chargerFiltres() {
const filtres = await apiGet('/api/filtres');
const barre = document.getElementById('barre-filtres');
barre.innerHTML = '';
for (const [id, nom] of Object.entries(filtres)) {
const btn = document.createElement('button');
btn.className = 'btn-filtre' + (id === 'original' ? ' actif' : '');
btn.textContent = nom;
btn.addEventListener('click', () => appliquerFiltreUI(id, btn));
barre.appendChild(btn);
}
}
async function appliquerFiltreUI(filtre, btn) {
// Activer visuellement
document.querySelectorAll('.btn-filtre').forEach(b => b.classList.remove('actif'));
btn.classList.add('actif');
filtreActuel = filtre;
if (filtre === 'original') {
const chemin = '/data/photos/' + photosSession[0];
document.getElementById('photo-resultat').src = chemin;
document.getElementById('photo-partage').src = chemin;
photoFinale = photosSession[0];
return;
}
const resultat = await apiPost('/api/filtre', {
photo: photosSession[0],
filtre: filtre,
});
if (resultat.nom) {
photoFinale = resultat.nom;
document.getElementById('photo-resultat').src = '/data/exports/' + resultat.nom;
document.getElementById('photo-partage').src = '/data/exports/' + resultat.nom;
}
}
async function chargerOverlays() {
const overlays = await apiGet('/api/overlays');
const barre = document.getElementById('barre-overlays');
if (overlays.length === 0) {
barre.classList.add('cache');
return;
}
barre.classList.remove('cache');
barre.innerHTML = '';
// Bouton "sans overlay"
const btnAucun = document.createElement('button');
btnAucun.className = 'btn-overlay actif';
btnAucun.textContent = 'Sans cadre';
btnAucun.addEventListener('click', () => retirerOverlay(btnAucun));
barre.appendChild(btnAucun);
for (const nom of overlays) {
const btn = document.createElement('button');
btn.className = 'btn-overlay';
btn.textContent = nom.replace('.png', '');
btn.addEventListener('click', () => appliquerOverlayUI(nom, btn));
barre.appendChild(btn);
}
}
async function appliquerOverlayUI(nom, btn) {
document.querySelectorAll('.btn-overlay').forEach(b => b.classList.remove('actif'));
btn.classList.add('actif');
overlayActuel = nom;
const photo = photosSession[0];
const resultat = await apiPost('/api/overlay', { photo, overlay: nom });
if (resultat.nom) {
photoFinale = resultat.nom;
document.getElementById('photo-resultat').src = '/data/exports/' + resultat.nom;
document.getElementById('photo-partage').src = '/data/exports/' + resultat.nom;
}
}
function retirerOverlay(btn) {
document.querySelectorAll('.btn-overlay').forEach(b => b.classList.remove('actif'));
btn.classList.add('actif');
overlayActuel = null;
// Revenir a la photo avec filtre actuel (ou originale)
if (filtreActuel !== 'original') {
appliquerFiltreUI(filtreActuel, document.querySelector('.btn-filtre.actif'));
} else {
const chemin = '/data/photos/' + photosSession[0];
document.getElementById('photo-resultat').src = chemin;
document.getElementById('photo-partage').src = chemin;
photoFinale = photosSession[0];
}
}

27
frontend/js/gallery.js Normal file
View File

@@ -0,0 +1,27 @@
/* Module galerie */
async function chargerGalerie() {
const photos = await apiGet('/api/galerie');
const grille = document.getElementById('grille-galerie');
grille.innerHTML = '';
if (photos.length === 0) {
grille.innerHTML = '<p style="color:var(--texte-secondaire);grid-column:1/-1;text-align:center">Aucune photo pour le moment</p>';
return;
}
for (const photo of photos) {
const img = document.createElement('img');
img.src = '/' + photo.chemin;
img.alt = photo.nom;
img.loading = 'lazy';
img.addEventListener('click', () => voirPhoto(photo));
grille.appendChild(img);
}
}
function voirPhoto(photo) {
photoFinale = photo.nom;
document.getElementById('photo-partage').src = '/' + photo.chemin;
allerA('partage');
}

51
frontend/js/share.js Normal file
View File

@@ -0,0 +1,51 @@
/* Module partage - Impression, email, QR code */
async function lancerImpression() {
if (!photoFinale) return;
afficherStatut('Impression en cours...', 'succes');
const resultat = await apiPost('/api/imprimer', { photo: photoFinale });
if (resultat.succes) {
afficherStatut('Photo envoyee a l\'imprimante !', 'succes');
} else {
afficherStatut('Erreur d\'impression', 'erreur');
}
}
function ouvrirEmail() {
document.getElementById('form-email').classList.remove('cache');
document.getElementById('input-email').focus();
}
function fermerEmail() {
document.getElementById('form-email').classList.add('cache');
document.getElementById('input-email').value = '';
}
async function envoyerEmail() {
const email = document.getElementById('input-email').value.trim();
if (!email || !photoFinale) return;
afficherStatut('Envoi en cours...', 'succes');
const resultat = await apiPost('/api/email', { email, photo: photoFinale });
if (resultat.succes) {
afficherStatut('Email envoye !', 'succes');
fermerEmail();
} else {
afficherStatut('Erreur d\'envoi email', 'erreur');
}
}
async function afficherQR() {
const zone = document.getElementById('zone-qr');
zone.classList.toggle('cache');
if (!zone.classList.contains('cache')) {
const resultat = await apiGet('/api/qr/galerie');
if (resultat.chemin) {
document.getElementById('img-qr').src = resultat.chemin;
} else {
afficherStatut('QR Code non configure', 'erreur');
zone.classList.add('cache');
}
}
}

48
frontend/js/websocket.js Normal file
View File

@@ -0,0 +1,48 @@
/* Communication WebSocket avec le backend */
let ws = null;
let wsReconnectTimer = null;
const wsCallbacks = {};
function wsConnecter() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws`);
ws.onopen = () => {
console.log('WebSocket connecte');
if (wsReconnectTimer) {
clearInterval(wsReconnectTimer);
wsReconnectTimer = null;
}
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
const cb = wsCallbacks[msg.type];
if (cb) cb(msg);
};
ws.onclose = () => {
console.log('WebSocket deconnecte, reconnexion dans 3s...');
if (!wsReconnectTimer) {
wsReconnectTimer = setInterval(wsConnecter, 3000);
}
};
ws.onerror = () => {
ws.close();
};
}
function wsEnvoyer(msg) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}
function wsOnMessage(type, callback) {
wsCallbacks[type] = callback;
}
// Connexion au demarrage
wsConnecter();