Galerie live booth.copydev.fr + icones agrandies + pellicule
- booth/: serveur Node.js galerie photo live avec auth par code 4 chiffres - Backend: envoi auto des photos vers booth, endpoint QR dynamique - Frontend: code + QR booth sur ecran accueil, icone pellicule pour multi-shot - Icones mode agrandies (5rem) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
import ftplib
|
import ftplib
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
from urllib.error import URLError
|
||||||
|
|
||||||
from backend.config import charger_config, mettre_a_jour_config
|
from backend.config import charger_config, mettre_a_jour_config
|
||||||
|
|
||||||
@@ -26,6 +29,15 @@ def distribuer_photo(chemin_photo: Path, imprimee: bool = False):
|
|||||||
if dest.get("ftp", False):
|
if dest.get("ftp", False):
|
||||||
envoyer_ftp(chemin_photo, dest)
|
envoyer_ftp(chemin_photo, dest)
|
||||||
|
|
||||||
|
# Galerie live (booth)
|
||||||
|
booth = config.get("booth", {})
|
||||||
|
if booth.get("actif", False):
|
||||||
|
threading.Thread(
|
||||||
|
target=envoyer_booth,
|
||||||
|
args=(chemin_photo, booth),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
# Incrementer le compteur
|
# Incrementer le compteur
|
||||||
compteur = config.get("compteur", {})
|
compteur = config.get("compteur", {})
|
||||||
if compteur.get("actif", False):
|
if compteur.get("actif", False):
|
||||||
@@ -83,6 +95,73 @@ def envoyer_ftp(chemin_photo: Path, config_dest: dict):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def envoyer_booth(chemin_photo: Path, config_booth: dict):
|
||||||
|
"""Envoie une photo vers la galerie live booth."""
|
||||||
|
url = config_booth.get("url", "").rstrip("/")
|
||||||
|
api_key = config_booth.get("api_key", "")
|
||||||
|
event_id = config_booth.get("event_id", "default")
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
log.warning("Booth non configure (URL manquante)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
import mimetypes
|
||||||
|
boundary = "----BoothUpload"
|
||||||
|
filename = chemin_photo.name
|
||||||
|
|
||||||
|
with open(chemin_photo, "rb") as f:
|
||||||
|
file_data = f.read()
|
||||||
|
|
||||||
|
body = (
|
||||||
|
f"--{boundary}\r\n"
|
||||||
|
f'Content-Disposition: form-data; name="photo"; filename="{filename}"\r\n'
|
||||||
|
f"Content-Type: image/jpeg\r\n\r\n"
|
||||||
|
).encode() + file_data + f"\r\n--{boundary}--\r\n".encode()
|
||||||
|
|
||||||
|
req = Request(
|
||||||
|
f"{url}/api/{event_id}/upload",
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||||
|
req.add_header("X-Api-Key", api_key)
|
||||||
|
|
||||||
|
with urlopen(req, timeout=10) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
log.info(f"Photo envoyee au booth : {filename}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
log.error(f"Booth erreur HTTP {resp.status}")
|
||||||
|
return False
|
||||||
|
except (URLError, OSError) as e:
|
||||||
|
log.error(f"Erreur envoi booth : {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def recuperer_booth_password() -> dict:
|
||||||
|
"""Recupere le mot de passe et l'info de la session booth."""
|
||||||
|
config = charger_config()
|
||||||
|
booth = config.get("booth", {})
|
||||||
|
url = booth.get("url", "").rstrip("/")
|
||||||
|
api_key = booth.get("api_key", "")
|
||||||
|
event_id = booth.get("event_id", "default")
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
return {"password": None}
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = Request(f"{url}/api/{event_id}/info")
|
||||||
|
req.add_header("X-Api-Key", api_key)
|
||||||
|
with urlopen(req, timeout=5) as resp:
|
||||||
|
import json
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
return data
|
||||||
|
except (URLError, OSError) as e:
|
||||||
|
log.error(f"Erreur recuperation booth info : {e}")
|
||||||
|
return {"password": None}
|
||||||
|
|
||||||
|
|
||||||
def detecter_usb() -> list[str]:
|
def detecter_usb() -> list[str]:
|
||||||
"""Detecte les cles USB montees."""
|
"""Detecte les cles USB montees."""
|
||||||
chemins_possibles = [Path("/media"), Path("/mnt")]
|
chemins_possibles = [Path("/media"), Path("/mnt")]
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from backend.camera import camera
|
|||||||
from backend.gallery import lister_photos, compter_photos, supprimer_photo, vider_galerie
|
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.effects import appliquer_filtre, appliquer_overlay, chroma_key, lister_overlays, lister_fonds, FILTRES
|
||||||
from backend.collage import creer_strip, creer_collage, creer_impression_strip
|
from backend.collage import creer_strip, creer_collage, creer_impression_strip
|
||||||
from backend.destinations import distribuer_photo, detecter_usb, compteur_restant, reset_compteur
|
from backend.destinations import distribuer_photo, detecter_usb, compteur_restant, reset_compteur, recuperer_booth_password
|
||||||
from backend.printer import lister_imprimantes, imprimer
|
from backend.printer import lister_imprimantes, imprimer
|
||||||
from backend.mailer import envoyer_photo
|
from backend.mailer import envoyer_photo
|
||||||
from backend.qrcode_gen import generer_qr, qr_galerie
|
from backend.qrcode_gen import generer_qr, qr_galerie
|
||||||
@@ -243,6 +243,13 @@ async def api_imprimer(donnees: dict):
|
|||||||
return {"succes": ok}
|
return {"succes": ok}
|
||||||
|
|
||||||
|
|
||||||
|
# --- API Booth (galerie live) ---
|
||||||
|
|
||||||
|
@app.get("/api/booth/info")
|
||||||
|
async def api_booth_info():
|
||||||
|
return recuperer_booth_password()
|
||||||
|
|
||||||
|
|
||||||
# --- API Email ---
|
# --- API Email ---
|
||||||
|
|
||||||
@app.post("/api/email")
|
@app.post("/api/email")
|
||||||
@@ -262,6 +269,21 @@ async def api_email(donnees: dict):
|
|||||||
|
|
||||||
# --- API QR Code ---
|
# --- API QR Code ---
|
||||||
|
|
||||||
|
@app.get("/api/qr")
|
||||||
|
async def api_qr(url: str = ""):
|
||||||
|
"""Genere un QR code pour une URL arbitraire."""
|
||||||
|
if not url:
|
||||||
|
return JSONResponse({"erreur": "URL requise"}, status_code=400)
|
||||||
|
import qrcode
|
||||||
|
import io
|
||||||
|
qr = qrcode.make(url, box_size=6, border=2)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
qr.save(buf, format="PNG")
|
||||||
|
buf.seek(0)
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
return StreamingResponse(buf, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/qr/galerie")
|
@app.get("/api/qr/galerie")
|
||||||
async def api_qr_galerie():
|
async def api_qr_galerie():
|
||||||
chemin = qr_galerie()
|
chemin = qr_galerie()
|
||||||
|
|||||||
16
booth/package.json
Normal file
16
booth/package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "booth-gallery",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Galerie photo live pour photobooth",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node --watch server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"sharp": "^0.33.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
432
booth/public/index.html
Normal file
432
booth/public/index.html
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Galerie Photo</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0a0a0f;
|
||||||
|
--card: #14141f;
|
||||||
|
--accent: #e91e63;
|
||||||
|
--text: #ffffff;
|
||||||
|
--text2: #8888aa;
|
||||||
|
--radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Ecran de login --- */
|
||||||
|
#login-screen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-screen.hidden { display: none; }
|
||||||
|
#gallery-screen.hidden { display: none; }
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 3rem;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box p {
|
||||||
|
color: var(--text2);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box .icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-input input {
|
||||||
|
width: 60px;
|
||||||
|
height: 70px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
border: 2px solid #333;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-input input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
color: #f44336;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error.visible { display: block; }
|
||||||
|
|
||||||
|
/* --- Galerie --- */
|
||||||
|
#gallery-screen {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-count {
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(233, 30, 99, 0.2);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card {
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
background: var(--card);
|
||||||
|
animation: fadeIn 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: scale(0.95); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card:active img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-time {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
color: white;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Lightbox --- */
|
||||||
|
.lightbox {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: rgba(0,0,0,0.95);
|
||||||
|
z-index: 100;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox.active { display: flex; }
|
||||||
|
|
||||||
|
.lightbox img {
|
||||||
|
max-width: 95%;
|
||||||
|
max-height: 85vh;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-download {
|
||||||
|
margin-top: 1rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Responsive --- */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.photo-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.login-box { padding: 2rem; }
|
||||||
|
.code-input input { width: 50px; height: 60px; font-size: 1.5rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Empty state --- */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
.empty-state .icon { font-size: 4rem; margin-bottom: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Ecran de login -->
|
||||||
|
<div id="login-screen">
|
||||||
|
<div class="login-box">
|
||||||
|
<div class="icon">📷</div>
|
||||||
|
<h1>Galerie Photo</h1>
|
||||||
|
<p>Saisissez le code affiche sur la borne</p>
|
||||||
|
<div class="code-input">
|
||||||
|
<input type="tel" maxlength="1" data-index="0" inputmode="numeric" autocomplete="off">
|
||||||
|
<input type="tel" maxlength="1" data-index="1" inputmode="numeric" autocomplete="off">
|
||||||
|
<input type="tel" maxlength="1" data-index="2" inputmode="numeric" autocomplete="off">
|
||||||
|
<input type="tel" maxlength="1" data-index="3" inputmode="numeric" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div id="login-error" class="login-error">Code incorrect</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ecran galerie -->
|
||||||
|
<div id="gallery-screen" class="hidden">
|
||||||
|
<div class="gallery-header">
|
||||||
|
<div>
|
||||||
|
<h1>Galerie Photo</h1>
|
||||||
|
<span class="photo-count" id="photo-count">0 photos</span>
|
||||||
|
</div>
|
||||||
|
<span class="live-badge"><span class="live-dot"></span> LIVE</span>
|
||||||
|
</div>
|
||||||
|
<div class="photo-grid" id="photo-grid"></div>
|
||||||
|
<div class="empty-state" id="empty-state">
|
||||||
|
<div class="icon">📸</div>
|
||||||
|
<p>Les photos apparaitront ici en temps reel</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lightbox -->
|
||||||
|
<div class="lightbox" id="lightbox">
|
||||||
|
<button class="lightbox-close" onclick="closeLightbox()">×</button>
|
||||||
|
<img id="lightbox-img" src="">
|
||||||
|
<button class="lightbox-download" id="lightbox-dl" onclick="downloadPhoto()">Telecharger</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const eventId = window.location.pathname.replace(/^\//, '') || 'default';
|
||||||
|
let token = null;
|
||||||
|
let ws = null;
|
||||||
|
let photos = [];
|
||||||
|
|
||||||
|
// --- Login ---
|
||||||
|
const codeInputs = document.querySelectorAll('.code-input input');
|
||||||
|
|
||||||
|
codeInputs.forEach((input, i) => {
|
||||||
|
input.addEventListener('input', (e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val && i < 3) codeInputs[i + 1].focus();
|
||||||
|
if (val && i === 3) tryLogin();
|
||||||
|
});
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Backspace' && !e.target.value && i > 0) {
|
||||||
|
codeInputs[i - 1].focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
codeInputs[0].focus();
|
||||||
|
|
||||||
|
async function tryLogin() {
|
||||||
|
const code = Array.from(codeInputs).map(i => i.value).join('');
|
||||||
|
if (code.length !== 4) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/${eventId}/auth`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password: code }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
token = data.token;
|
||||||
|
document.getElementById('login-error').classList.remove('visible');
|
||||||
|
showGallery();
|
||||||
|
} else {
|
||||||
|
document.getElementById('login-error').classList.add('visible');
|
||||||
|
codeInputs.forEach(i => { i.value = ''; });
|
||||||
|
codeInputs[0].focus();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Galerie ---
|
||||||
|
async function showGallery() {
|
||||||
|
document.getElementById('login-screen').classList.add('hidden');
|
||||||
|
document.getElementById('gallery-screen').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Charger les photos existantes
|
||||||
|
const res = await fetch(`/api/${eventId}/photos`, {
|
||||||
|
headers: { 'X-Token': token },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
photos = data.photos || [];
|
||||||
|
renderPhotos();
|
||||||
|
|
||||||
|
// Connexion WebSocket pour les nouvelles photos
|
||||||
|
connectWS();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPhotos() {
|
||||||
|
const grid = document.getElementById('photo-grid');
|
||||||
|
const empty = document.getElementById('empty-state');
|
||||||
|
const count = document.getElementById('photo-count');
|
||||||
|
|
||||||
|
count.textContent = `${photos.length} photo${photos.length > 1 ? 's' : ''}`;
|
||||||
|
|
||||||
|
if (photos.length === 0) {
|
||||||
|
empty.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
empty.style.display = 'none';
|
||||||
|
|
||||||
|
// Afficher les photos les plus recentes en premier
|
||||||
|
const sorted = [...photos].reverse();
|
||||||
|
grid.innerHTML = sorted.map(p => `
|
||||||
|
<div class="photo-card" onclick="openLightbox('${p.name}')">
|
||||||
|
<img src="/thumbs/${eventId}/${p.name}?token=${token}" loading="lazy" alt="">
|
||||||
|
<span class="photo-time">${formatTime(p.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WebSocket live ---
|
||||||
|
function connectWS() {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
ws = new WebSocket(`${proto}//${location.host}?event=${eventId}&token=${token}`);
|
||||||
|
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.type === 'new_photo') {
|
||||||
|
photos.push(msg.photo);
|
||||||
|
renderPhotos();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setTimeout(connectWS, 3000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lightbox ---
|
||||||
|
let currentPhoto = null;
|
||||||
|
|
||||||
|
function openLightbox(name) {
|
||||||
|
currentPhoto = name;
|
||||||
|
document.getElementById('lightbox-img').src = `/photos/${eventId}/${name}?token=${token}`;
|
||||||
|
document.getElementById('lightbox').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLightbox() {
|
||||||
|
document.getElementById('lightbox').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('lightbox').addEventListener('click', (e) => {
|
||||||
|
if (e.target === e.currentTarget) closeLightbox();
|
||||||
|
});
|
||||||
|
|
||||||
|
function downloadPhoto() {
|
||||||
|
if (!currentPhoto) return;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = `/photos/${eventId}/${currentPhoto}?token=${token}`;
|
||||||
|
a.download = currentPhoto;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
224
booth/server.js
Normal file
224
booth/server.js
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const http = require('http');
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
const multer = require('multer');
|
||||||
|
const sharp = require('sharp');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const wss = new WebSocket.Server({ server });
|
||||||
|
|
||||||
|
// --- Configuration ---
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const PHOTOS_DIR = path.join(__dirname, 'data', 'photos');
|
||||||
|
const THUMBS_DIR = path.join(__dirname, 'data', 'thumbs');
|
||||||
|
|
||||||
|
// Creer les dossiers
|
||||||
|
fs.mkdirSync(PHOTOS_DIR, { recursive: true });
|
||||||
|
fs.mkdirSync(THUMBS_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// --- Sessions evenement ---
|
||||||
|
// { eventId: { password, photos: [{name, timestamp}], createdAt } }
|
||||||
|
const sessions = new Map();
|
||||||
|
|
||||||
|
function genererPassword() {
|
||||||
|
return crypto.randomInt(1000, 9999).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSession(eventId) {
|
||||||
|
if (!sessions.has(eventId)) {
|
||||||
|
sessions.set(eventId, {
|
||||||
|
password: genererPassword(),
|
||||||
|
photos: [],
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
// Creer le dossier
|
||||||
|
fs.mkdirSync(path.join(PHOTOS_DIR, eventId), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(THUMBS_DIR, eventId), { recursive: true });
|
||||||
|
}
|
||||||
|
return sessions.get(eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Upload photos depuis la borne ---
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 20 * 1024 * 1024 }, // 20 Mo max
|
||||||
|
});
|
||||||
|
|
||||||
|
// API Key pour l'upload (simple)
|
||||||
|
const API_KEY = process.env.API_KEY || 'booth-secret-key';
|
||||||
|
|
||||||
|
function verifierApiKey(req, res, next) {
|
||||||
|
const key = req.headers['x-api-key'] || req.query.key;
|
||||||
|
if (key !== API_KEY) {
|
||||||
|
return res.status(401).json({ error: 'Cle API invalide' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Routes API ---
|
||||||
|
|
||||||
|
// Upload photo depuis la borne
|
||||||
|
app.post('/api/:eventId/upload', verifierApiKey, upload.single('photo'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { eventId } = req.params;
|
||||||
|
const session = getSession(eventId);
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const filename = `${timestamp}.jpg`;
|
||||||
|
|
||||||
|
// Sauvegarder la photo originale
|
||||||
|
const photoPath = path.join(PHOTOS_DIR, eventId, filename);
|
||||||
|
await sharp(req.file.buffer)
|
||||||
|
.jpeg({ quality: 90 })
|
||||||
|
.toFile(photoPath);
|
||||||
|
|
||||||
|
// Creer la miniature
|
||||||
|
const thumbPath = path.join(THUMBS_DIR, eventId, filename);
|
||||||
|
await sharp(req.file.buffer)
|
||||||
|
.resize(400, 400, { fit: 'cover' })
|
||||||
|
.jpeg({ quality: 75 })
|
||||||
|
.toFile(thumbPath);
|
||||||
|
|
||||||
|
// Ajouter a la session
|
||||||
|
const photoInfo = { name: filename, timestamp };
|
||||||
|
session.photos.push(photoInfo);
|
||||||
|
|
||||||
|
// Notifier les clients WebSocket
|
||||||
|
broadcast(eventId, {
|
||||||
|
type: 'new_photo',
|
||||||
|
photo: photoInfo,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true, filename });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur upload:', err);
|
||||||
|
res.status(500).json({ error: 'Erreur upload' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Info session (mot de passe pour la borne)
|
||||||
|
app.get('/api/:eventId/info', verifierApiKey, (req, res) => {
|
||||||
|
const session = getSession(req.params.eventId);
|
||||||
|
res.json({
|
||||||
|
password: session.password,
|
||||||
|
photoCount: session.photos.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regenerer le mot de passe
|
||||||
|
app.post('/api/:eventId/reset-password', verifierApiKey, (req, res) => {
|
||||||
|
const session = getSession(req.params.eventId);
|
||||||
|
session.password = genererPassword();
|
||||||
|
res.json({ password: session.password });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Routes publiques (invites) ---
|
||||||
|
|
||||||
|
// Verifier le mot de passe
|
||||||
|
app.post('/api/:eventId/auth', express.json(), (req, res) => {
|
||||||
|
const session = sessions.get(req.params.eventId);
|
||||||
|
if (!session) {
|
||||||
|
return res.status(404).json({ error: 'Evenement introuvable' });
|
||||||
|
}
|
||||||
|
if (req.body.password !== session.password) {
|
||||||
|
return res.status(403).json({ error: 'Mot de passe incorrect' });
|
||||||
|
}
|
||||||
|
// Generer un token simple
|
||||||
|
const token = crypto.randomBytes(16).toString('hex');
|
||||||
|
session.token = session.token || new Set();
|
||||||
|
session.token.add(token);
|
||||||
|
res.json({ ok: true, token, photoCount: session.photos.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Middleware pour verifier le token invite
|
||||||
|
function verifierToken(req, res, next) {
|
||||||
|
const token = req.headers['x-token'] || req.query.token;
|
||||||
|
const session = sessions.get(req.params.eventId);
|
||||||
|
if (!session || !session.token || !session.token.has(token)) {
|
||||||
|
return res.status(403).json({ error: 'Acces refuse' });
|
||||||
|
}
|
||||||
|
req.session = session;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liste des photos
|
||||||
|
app.get('/api/:eventId/photos', verifierToken, (req, res) => {
|
||||||
|
res.json({ photos: req.session.photos });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Servir les photos
|
||||||
|
app.get('/photos/:eventId/:filename', (req, res) => {
|
||||||
|
const token = req.query.token;
|
||||||
|
const session = sessions.get(req.params.eventId);
|
||||||
|
if (!session || !session.token || !session.token.has(token)) {
|
||||||
|
return res.status(403).send('Acces refuse');
|
||||||
|
}
|
||||||
|
const filePath = path.join(PHOTOS_DIR, req.params.eventId, req.params.filename);
|
||||||
|
if (!fs.existsSync(filePath)) return res.status(404).send('Not found');
|
||||||
|
res.sendFile(filePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Servir les miniatures
|
||||||
|
app.get('/thumbs/:eventId/:filename', (req, res) => {
|
||||||
|
const token = req.query.token;
|
||||||
|
const session = sessions.get(req.params.eventId);
|
||||||
|
if (!session || !session.token || !session.token.has(token)) {
|
||||||
|
return res.status(403).send('Acces refuse');
|
||||||
|
}
|
||||||
|
const filePath = path.join(THUMBS_DIR, req.params.eventId, req.params.filename);
|
||||||
|
if (!fs.existsSync(filePath)) return res.status(404).send('Not found');
|
||||||
|
res.sendFile(filePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- WebSocket ---
|
||||||
|
wss.on('connection', (ws, req) => {
|
||||||
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||||
|
const eventId = url.searchParams.get('event');
|
||||||
|
const token = url.searchParams.get('token');
|
||||||
|
|
||||||
|
const session = sessions.get(eventId);
|
||||||
|
if (!session || !session.token || !session.token.has(token)) {
|
||||||
|
ws.close(4001, 'Acces refuse');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.eventId = eventId;
|
||||||
|
ws.isAlive = true;
|
||||||
|
|
||||||
|
ws.on('pong', () => { ws.isAlive = true; });
|
||||||
|
ws.on('error', () => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
function broadcast(eventId, data) {
|
||||||
|
const msg = JSON.stringify(data);
|
||||||
|
wss.clients.forEach(client => {
|
||||||
|
if (client.eventId === eventId && client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping pour garder les connexions
|
||||||
|
setInterval(() => {
|
||||||
|
wss.clients.forEach(ws => {
|
||||||
|
if (!ws.isAlive) return ws.terminate();
|
||||||
|
ws.isAlive = false;
|
||||||
|
ws.ping();
|
||||||
|
});
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// --- Frontend statique ---
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// SPA fallback
|
||||||
|
app.get('/:eventId', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Demarrage ---
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Booth gallery running on port ${PORT}`);
|
||||||
|
});
|
||||||
@@ -249,6 +249,36 @@ html, body {
|
|||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-mode .mode-icone-large {
|
||||||
|
font-size: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Booth info (code + QR sur accueil) === */
|
||||||
|
.booth-info {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booth-qr {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booth-code {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
letter-spacing: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* === Capture / Preview live === */
|
/* === Capture / Preview live === */
|
||||||
.preview-live {
|
.preview-live {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -24,6 +24,11 @@
|
|||||||
<div id="compteur-accueil" class="compteur-accueil cache">
|
<div id="compteur-accueil" class="compteur-accueil cache">
|
||||||
<span id="compteur-restant">400</span> / <span id="compteur-limite">400</span>
|
<span id="compteur-restant">400</span> / <span id="compteur-limite">400</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Booth : code + QR en bas a gauche -->
|
||||||
|
<div id="booth-info" class="booth-info cache">
|
||||||
|
<img id="booth-qr" class="booth-qr" src="">
|
||||||
|
<span id="booth-code" class="booth-code"></span>
|
||||||
|
</div>
|
||||||
<!-- Icone admin coin bas-droit -->
|
<!-- Icone admin coin bas-droit -->
|
||||||
<div id="btn-admin" class="btn-admin" onclick="ouvrirAdmin()">⚙</div>
|
<div id="btn-admin" class="btn-admin" onclick="ouvrirAdmin()">⚙</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -33,12 +38,12 @@
|
|||||||
<h2>Choisissez votre mode</h2>
|
<h2>Choisissez votre mode</h2>
|
||||||
<div class="grille-modes">
|
<div class="grille-modes">
|
||||||
<button class="btn-mode" data-mode="simple">
|
<button class="btn-mode" data-mode="simple">
|
||||||
<div class="mode-icone">📷</div>
|
<div class="mode-icone mode-icone-large">📷</div>
|
||||||
<span>Photo</span>
|
<span>Photo</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-mode" data-mode="multi" id="btn-multi">
|
<button class="btn-mode" data-mode="multi" id="btn-multi">
|
||||||
<div class="mode-icone">🎴</div>
|
<div class="mode-icone mode-icone-large">🎞</div>
|
||||||
<span>Multi-shot</span>
|
<span>Pellicule</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-retour" onclick="allerA('accueil')">Retour</button>
|
<button class="btn-retour" onclick="allerA('accueil')">Retour</button>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ async function init() {
|
|||||||
setupEcranAccueil();
|
setupEcranAccueil();
|
||||||
setupModes();
|
setupModes();
|
||||||
setupOnglets();
|
setupOnglets();
|
||||||
|
chargerBoothInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
function appliquerConfig() {
|
function appliquerConfig() {
|
||||||
@@ -209,6 +210,28 @@ function toggleVisible(id, visible) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function chargerBoothInfo() {
|
||||||
|
const booth = config.booth || {};
|
||||||
|
const el = document.getElementById('booth-info');
|
||||||
|
if (!booth.actif || !booth.url) {
|
||||||
|
el.classList.add('cache');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const info = await apiGet('/api/booth/info');
|
||||||
|
if (info.password) {
|
||||||
|
document.getElementById('booth-code').textContent = info.password;
|
||||||
|
// QR code vers le site booth
|
||||||
|
const eventId = booth.event_id || 'default';
|
||||||
|
const qrUrl = `${booth.url}/${eventId}`;
|
||||||
|
document.getElementById('booth-qr').src = `/api/qr?url=${encodeURIComponent(qrUrl)}`;
|
||||||
|
el.classList.remove('cache');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
el.classList.add('cache');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function afficherStatut(message, type = 'succes') {
|
function afficherStatut(message, type = 'succes') {
|
||||||
const el = document.getElementById('statut-partage');
|
const el = document.getElementById('statut-partage');
|
||||||
el.textContent = message;
|
el.textContent = message;
|
||||||
|
|||||||
Reference in New Issue
Block a user