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:
2026-04-07 17:27:05 +02:00
parent e583126f85
commit f0584e8fdf
8 changed files with 835 additions and 4 deletions

16
booth/package.json Normal file
View 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
View 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">&#128247;</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">&#128248;</div>
<p>Les photos apparaitront ici en temps reel</p>
</div>
</div>
<!-- Lightbox -->
<div class="lightbox" id="lightbox">
<button class="lightbox-close" onclick="closeLightbox()">&times;</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
View 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}`);
});