- 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>
433 lines
11 KiB
HTML
433 lines
11 KiB
HTML
<!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>
|