all
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled

This commit is contained in:
2026-07-17 00:44:22 +02:00
parent 209f81aa3d
commit 9a006f1457
23 changed files with 3712 additions and 484 deletions
+52
View File
@@ -6,6 +6,7 @@
<a href="/admin/gallery">Galerie admin</a>
<a href="/admin/print">Impression</a>
<a href="/admin/actions">Actions</a>
<a href="/admin/settings">Réglages</a>
<a href="/gallery">Galerie publique</a>
<a href="/admin/logout" style="margin-left:auto;color:var(--text-dim)">Déconnexion</a>
{% endblock %}
@@ -53,6 +54,28 @@
</div>
</div>
<!-- Événement en cours -->
<div class="section">
<div class="card-title">🎫 Événement en cours</div>
<div class="card">
<div class="flex gap-2 items-center mb-2" style="flex-wrap:wrap">
<input type="text" id="event-name-input" class="form-control"
placeholder="Nom de l'événement…" style="flex:1;min-width:180px">
<button class="btn btn-primary btn-sm" onclick="saveEvent(false)">💾 Enregistrer</button>
<button class="btn btn-ghost btn-sm" onclick="saveEvent(true)"
title="Terminer cet événement et en démarrer un nouveau (les stats repartent de zéro)">
↻ Nouvel événement
</button>
</div>
<div class="flex gap-3 text-sm" id="event-stats-row">
<span>📸 <strong id="ev-photos"></strong> photos</span>
<span>⬇️ <strong id="ev-downloads"></strong> téléchargements</span>
<span>🖨 <strong id="ev-prints"></strong> impressions</span>
</div>
<div class="text-xs text-muted mt-1" id="event-slug-display"></div>
</div>
</div>
<div class="grid-2 section">
<!-- Contrôle LED -->
<div class="card">
@@ -274,8 +297,37 @@ function onWsMessage(msg) {
}
}
// ── Événement ─────────────────────────────────────────────────────────────────
async function loadEvent() {
try {
const e = await api('GET', '/api/event');
document.getElementById('event-name-input').value = e.name || '';
const s = e.stats || {};
document.getElementById('ev-photos').textContent = s.photos_taken ?? '0';
document.getElementById('ev-downloads').textContent = s.downloads ?? '0';
document.getElementById('ev-prints').textContent = s.prints_done ?? '0';
document.getElementById('event-slug-display').textContent =
`Fichiers : ${e.slug}_By_LSDW_YYYY-MM-DD.jpg · Démarré le ${
e.started_at ? new Date(e.started_at * 1000).toLocaleDateString('fr-FR') : '—'
}`;
} catch(err) { console.warn('Événement:', err); }
}
async function saveEvent(isNew) {
const name = document.getElementById('event-name-input').value.trim();
if (!name) { showToast('Entrez un nom d\'événement', 'error'); return; }
if (isNew && !confirm(`Terminer l'événement actuel et démarrer "${name}" ?\n\nLes statistiques de l'ancien événement seront archivées.`)) return;
try {
const r = await api('PUT', '/api/event', {name, new_event: isNew});
showToast(isNew ? `↻ Nouvel événement : ${r.name}` : `✅ Événement : ${r.name}`, 'success');
loadEvent();
} catch(err) { showToast('Erreur sauvegarde événement', 'error'); }
}
// ── Init ──────────────────────────────────────────────────────────────────────
loadQueue();
loadEvent();
setInterval(loadQueue, 15000);
setInterval(loadEvent, 60000);
</script>
{% endblock %}
+448 -82
View File
@@ -6,45 +6,199 @@
<a href="/admin/gallery" class="active">Galerie admin</a>
<a href="/admin/print">Impression</a>
<a href="/admin/actions">Actions</a>
<a href="/admin/settings">Réglages</a>
<a href="/gallery">Galerie publique</a>
<a href="/admin/logout" style="margin-left:auto;color:var(--text-dim)">Déconnexion</a>
{% endblock %}
{% block head %}
<style>
/* ── Photo grid ───────────────────────────────────────────────────────────── */
.gallery-wrap { max-width: 1300px; margin: 0 auto; padding: 1rem; }
.gallery-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap; }
.gallery-title { font-size: 1.2rem; font-weight: 700; }
.gallery-controls { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
.gallery-filters { display: flex; align-items: center; gap: .5rem; margin-bottom: .75rem; flex-wrap: wrap; }
.filter-btn { padding: .3rem .75rem; border-radius: 20px; border: 1px solid var(--border); background: transparent; color: var(--text-muted); cursor: pointer; font-size: .85rem; transition: all .2s; }
.filter-btn.active, .filter-btn:hover { background: var(--primary); border-color: var(--primary); color: #fff; }
.filter-badge { background: rgba(224,123,0,.2); color: #e07b00; padding: .15rem .45rem; border-radius: 10px; font-size: .75rem; font-weight: 700; }
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: .75rem; }
.photo-card {
position: relative;
aspect-ratio: 3/2;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: var(--surface);
border: 2px solid transparent;
transition: border-color .2s, transform .15s;
}
.photo-card:hover { border-color: var(--primary); transform: scale(1.02); }
.photo-card.has-print-request { border-color: #e07b00; }
.photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Badge demande d'impression */
.print-badge {
position: absolute;
top: 5px; right: 5px;
background: rgba(224,123,0,.9);
color: #fff;
border-radius: 12px;
padding: .15rem .45rem;
font-size: .72rem;
font-weight: 700;
display: flex; align-items: center; gap: .25rem;
backdrop-filter: blur(4px);
pointer-events: none;
}
.print-badge.printing { background: rgba(25,108,176,.9); }
/* Overlay actions au hover */
.card-overlay {
position: absolute; inset: 0;
background: rgba(0,0,0,.55);
display: flex; align-items: flex-end; justify-content: center;
gap: .4rem; padding: .5rem;
opacity: 0; transition: opacity .2s;
}
.photo-card:hover .card-overlay { opacity: 1; }
.ov-btn {
padding: .3rem .5rem; border: none; border-radius: 6px;
cursor: pointer; font-size: .8rem; font-weight: 700;
backdrop-filter: blur(4px);
}
.ov-print { background: rgba(224,123,0,.85); color: #fff; }
.ov-cancel { background: rgba(192,0,0,.75); color: #fff; }
.ov-delete { background: rgba(80,80,80,.75); color: #fff; }
.ov-dl { background: rgba(40,40,40,.75); color: #ccc; text-decoration: none; }
/* Pagination */
.pagination { display: flex; align-items: center; gap: .75rem; justify-content: center; margin: 1rem 0; }
.pg-btn { padding: .4rem .9rem; border: 1px solid var(--border); background: var(--surface); border-radius: 6px; color: var(--text); cursor: pointer; }
.pg-btn:disabled { opacity: .35; cursor: default; }
.pg-info { color: var(--text-muted); font-size: .9rem; }
/* ── Lightbox ─────────────────────────────────────────────────────────────── */
.lightbox {
display: none; position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,.88); backdrop-filter: blur(6px);
flex-direction: column; align-items: center; justify-content: center;
gap: 1rem; padding: 1.5rem;
}
.lightbox.open { display: flex; }
.lightbox-img-wrap { position: relative; max-width: 80vw; max-height: 70vh; }
.lightbox-img-wrap img { max-width: 80vw; max-height: 70vh; border-radius: 8px; object-fit: contain; display: block; }
.lb-close { position: absolute; top: -14px; right: -14px; width: 28px; height: 28px; border-radius: 50%; background: rgba(255,255,255,.15); border: none; color: #fff; font-size: 1.1rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.lb-info { text-align: center; }
.lb-id { font-size: .78rem; color: var(--text-muted); font-family: monospace; }
/* Bloc demandes en attente dans le lightbox */
.lb-print-status {
background: var(--surface); border-radius: 10px; padding: .85rem 1.25rem;
min-width: min(400px, 80vw); border: 1px solid var(--border);
}
.lb-print-title { font-size: .85rem; font-weight: 700; margin-bottom: .6rem; display: flex; align-items: center; gap: .5rem; }
.lb-queue-entry {
display: flex; justify-content: space-between; align-items: center;
padding: .3rem 0; border-bottom: 1px solid rgba(255,255,255,.06);
font-size: .82rem; gap: .75rem;
}
.lb-queue-status { padding: .15rem .5rem; border-radius: 10px; font-size: .75rem; font-weight: 700; }
.s-pending { background: rgba(224,123,0,.2); color: #e07b00; }
.s-printing { background: rgba(25,108,176,.2); color: var(--primary); }
.lb-actions { display: flex; gap: .6rem; flex-wrap: wrap; justify-content: center; }
.lb-btn { padding: .5rem 1.1rem; border: none; border-radius: 8px; cursor: pointer; font-size: .9rem; font-weight: 600; transition: opacity .2s; }
.lb-btn:hover { opacity: .82; }
.lb-btn-print { background: var(--primary); color: #fff; }
.lb-btn-now { background: #1a7340; color: #fff; }
.lb-btn-cancel { background: rgba(192,0,0,.25); color: #e05050; border: 1px solid rgba(192,0,0,.3); }
.lb-btn-delete { background: #3d1f1f; color: #e05050; border: 1px solid rgba(192,0,0,.2); }
.lb-btn-dl { background: rgba(255,255,255,.08); color: var(--text); text-decoration: none; display: inline-flex; align-items: center; }
/* copies input */
.copies-wrap { display: flex; align-items: center; gap: .4rem; font-size: .85rem; color: var(--text-muted); }
.copies-wrap input { width: 52px; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: .3rem .5rem; text-align: center; }
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="flex items-center justify-between mb-2">
<h1 class="page-title" style="margin:0">Galerie — Administration</h1>
<div class="flex gap-1 items-center">
<div class="gallery-wrap">
<div class="gallery-header">
<div class="gallery-title">🖼 Galerie — Administration</div>
<div class="gallery-controls">
<span class="text-sm text-muted" id="photo-count">Chargement…</span>
<div class="flex gap-1">
<input type="number" id="copies-input" min="1" max="3" value="1" class="form-control" style="width:70px" title="Copies">
<button class="btn btn-ghost btn-sm" onclick="refreshPhotos()">↻ Actualiser</button>
<div class="copies-wrap">
<label for="copies-input">Copies :</label>
<input type="number" id="copies-input" min="1" max="3" value="1">
</div>
<button class="pg-btn" onclick="refreshPhotos()"></button>
</div>
</div>
<!-- Filtres / navigation pages -->
<div class="flex items-center gap-2 mb-2">
<button class="btn btn-ghost btn-sm" id="prev-btn" onclick="changePage(-1)" disabled>← Préc.</button>
<span class="text-sm text-muted">Page <span id="page-cur">1</span> / <span id="page-total">1</span></span>
<button class="btn btn-ghost btn-sm" id="next-btn" onclick="changePage(1)">Suiv. →</button>
<!-- Filtres -->
<div class="gallery-filters">
<button class="filter-btn active" onclick="setFilter('all', this)">Toutes</button>
<button class="filter-btn" onclick="setFilter('pending', this)">
🖨 À imprimer <span class="filter-badge" id="pending-count">0</span>
</button>
</div>
<!-- Pagination -->
<div class="pagination">
<button class="pg-btn" id="prev-btn" onclick="changePage(-1)" disabled>← Préc.</button>
<span class="pg-info">Page <span id="page-cur">1</span> / <span id="page-total">1</span></span>
<button class="pg-btn" id="next-btn" onclick="changePage(1)">Suiv. →</button>
</div>
<div class="photo-grid" id="photo-grid">
<div class="empty-state"><div class="icon"></div>Chargement…</div>
<div style="color:var(--text-muted);grid-column:1/-1;text-align:center;padding:2rem">Chargement…</div>
</div>
<div class="pagination" style="margin-top:.5rem">
<button class="pg-btn" id="prev-btn2" onclick="changePage(-1)" disabled>← Préc.</button>
<span class="pg-info">Page <span id="page-cur2">1</span> / <span id="page-total2">1</span></span>
<button class="pg-btn" id="next-btn2" onclick="changePage(1)">Suiv. →</button>
</div>
</div>
<!-- Lightbox admin -->
<div class="lightbox" id="lightbox" onclick="closeLightbox(event)">
<button class="lightbox-close" onclick="closeLightbox()">&times;</button>
<img id="lightbox-img" src="" alt="">
<div class="lightbox-actions">
<button class="btn btn-success" id="lb-print-btn">🖨 Imprimer</button>
<a class="btn btn-ghost" id="lb-download-btn" download>⬇ Télécharger</a>
<button class="btn btn-danger" id="lb-delete-btn">🗑 Supprimer</button>
<!-- ── Lightbox ─────────────────────────────────────────────────────────────── -->
<div class="lightbox" id="lightbox">
<div class="lightbox-img-wrap">
<img id="lb-img" src="" alt="">
<button class="lb-close" onclick="closeLightbox()"></button>
</div>
<div class="text-sm text-muted" id="lb-filename"></div>
<div class="lb-info">
<div class="lb-id" id="lb-id"></div>
</div>
<!-- Bloc demandes d'impression en attente -->
<div class="lb-print-status" id="lb-print-status" style="display:none">
<div class="lb-print-title">🖨 Demandes d'impression en attente</div>
<div id="lb-queue-list"></div>
</div>
<!-- Boutons d'action -->
<div class="lb-actions">
<button class="lb-btn lb-btn-print" id="lb-btn-queue" onclick="lbAddToQueue()">
📋 Ajouter à la file
</button>
<button class="lb-btn lb-btn-now" id="lb-btn-now" onclick="lbPrintNow()">
🖨 Imprimer maintenant
</button>
<button class="lb-btn lb-btn-cancel" id="lb-btn-cancel-all" onclick="lbCancelAll()" style="display:none">
✕ Annuler la demande
</button>
<a class="lb-btn lb-btn-dl" id="lb-btn-dl" download>⬇ Télécharger</a>
<button class="lb-btn lb-btn-delete" onclick="lbDelete()">🗑 Supprimer</button>
</div>
</div>
{% endblock %}
@@ -53,99 +207,311 @@
let currentPage = 1;
let totalPages = 1;
let currentPhotoId = null;
let currentPhotoData = null;
let allPhotos = [];
let filterMode = 'all';
// ════════════════════════════════════════════════════════════════════════════
// Chargement photos
// ════════════════════════════════════════════════════════════════════════════
async function loadPhotos(page = 1) {
const grid = document.getElementById('photo-grid');
grid.innerHTML = '<div style="color:var(--text-muted);grid-column:1/-1;text-align:center;padding:2rem">⏳ Chargement…</div>';
try {
const data = await api('GET', `/admin/api/gallery/photos?page=${page}&limit=24`);
const { photos, total, pages } = data;
allPhotos = data.photos || [];
currentPage = page;
totalPages = pages;
totalPages = data.pages || 1;
document.getElementById('photo-count').textContent = `${total} photo(s)`;
document.getElementById('page-cur').textContent = page;
document.getElementById('page-total').textContent = pages;
document.getElementById('prev-btn').disabled = page <= 1;
document.getElementById('next-btn').disabled = page >= pages;
updatePagination();
document.getElementById('photo-count').textContent = `${data.total} photo(s)`;
const grid = document.getElementById('photo-grid');
if (!photos.length) {
grid.innerHTML = '<div class="empty-state"><div class="icon">📷</div>Aucune photo</div>';
return;
}
// Compteur de demandes en attente
const pendingTotal = allPhotos.filter(p => p.print_pending > 0).length;
document.getElementById('pending-count').textContent = pendingTotal;
grid.innerHTML = photos.map(p => {
const pid = p.id || p.filename || p.uid || '';
return `
<div class="photo-card" onclick="openLightbox('${pid}', '${p.full_url}', '${p.thumb_url}')">
<img src="${p.thumb_url}" loading="lazy" alt="">
<div class="photo-card-actions">
<button class="btn btn-success btn-sm" onclick="event.stopPropagation();printPhoto('${pid}')">🖨</button>
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deletePhoto('${pid}')">🗑</button>
<a class="btn btn-ghost btn-sm" href="${p.full_url}" download onclick="event.stopPropagation()">⬇</a>
</div>
</div>
`;
}).join('');
renderGrid();
} catch(e) {
document.getElementById('photo-grid').innerHTML = '<div class="empty-state"><div class="icon">❌</div>Erreur de chargement</div>';
grid.innerHTML = '<div style="color:#e05050;grid-column:1/-1;text-align:center;padding:2rem">❌ Erreur de chargement</div>';
}
}
function changePage(delta) {
loadPhotos(currentPage + delta);
function renderGrid() {
const grid = document.getElementById('photo-grid');
let photos = allPhotos;
if (filterMode === 'pending') {
photos = allPhotos.filter(p => p.print_pending > 0 || p.print_printing > 0);
}
if (!photos.length) {
grid.innerHTML = filterMode === 'pending'
? '<div style="color:var(--text-muted);grid-column:1/-1;text-align:center;padding:2rem">✅ Aucune demande d\'impression en attente</div>'
: '<div style="color:var(--text-muted);grid-column:1/-1;text-align:center;padding:2rem">📷 Aucune photo</div>';
return;
}
grid.innerHTML = photos.map(p => {
const pid = p.photo_id || p.id || p.filename || '';
const hasPending = p.print_pending > 0;
const hasPrinting = p.print_printing > 0;
let badge = '';
if (hasPrinting) badge = `<div class="print-badge printing">🔵 Impression…</div>`;
else if (hasPending) badge = `<div class="print-badge">🖨 ${p.print_pending} en attente</div>`;
return `
<div class="photo-card ${hasPending || hasPrinting ? 'has-print-request' : ''}"
id="card-${pid}"
onclick="openLightbox(${JSON.stringify(p)})">
<img src="${p.thumb_url}" loading="lazy" alt="">
${badge}
<div class="card-overlay">
${hasPending
? `<button class="ov-btn ov-cancel" onclick="event.stopPropagation();quickCancel('${pid}')">✕ Annuler</button>`
: `<button class="ov-btn ov-print" onclick="event.stopPropagation();quickQueue('${pid}')">🖨 File</button>`
}
<button class="ov-btn ov-delete" onclick="event.stopPropagation();deletePhoto('${pid}')">🗑</button>
<a class="ov-btn ov-dl" href="${p.full_url}" download onclick="event.stopPropagation()">⬇</a>
</div>
</div>
`;
}).join('');
}
function refreshPhotos() { loadPhotos(currentPage); }
function setFilter(mode, btn) {
filterMode = mode;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderGrid();
}
// ════════════════════════════════════════════════════════════════════════════
// Lightbox
// ════════════════════════════════════════════════════════════════════════════
function openLightbox(photo) {
currentPhotoData = photo;
currentPhotoId = photo.photo_id || photo.id || photo.filename || '';
document.getElementById('lb-img').src = photo.full_url || photo.thumb_url;
document.getElementById('lb-id').textContent = currentPhotoId;
document.getElementById('lb-btn-dl').href = photo.full_url;
// Mise à jour du bloc demandes
updateLightboxPrintStatus(photo);
// ── Lightbox ──────────────────────────────────────────────────────────────────
function openLightbox(pid, fullUrl, thumbUrl) {
currentPhotoId = pid;
document.getElementById('lightbox-img').src = fullUrl || thumbUrl;
document.getElementById('lb-download-btn').href = fullUrl;
document.getElementById('lb-filename').textContent = pid;
document.getElementById('lb-print-btn').onclick = () => printPhoto(pid);
document.getElementById('lb-delete-btn').onclick = () => deletePhoto(pid);
document.getElementById('lightbox').classList.add('open');
}
function closeLightbox(e) {
if (e && e.target !== document.getElementById('lightbox') && e.type !== 'click') return;
document.getElementById('lightbox').classList.remove('open');
currentPhotoId = null;
function updateLightboxPrintStatus(photo) {
const statusBlock = document.getElementById('lb-print-status');
const queueList = document.getElementById('lb-queue-list');
const btnCancelAll = document.getElementById('lb-btn-cancel-all');
const requests = photo.print_requests || [];
const activeRequests = requests.filter(r => r.status === 'pending' || r.status === 'printing');
if (activeRequests.length) {
statusBlock.style.display = 'block';
btnCancelAll.style.display = 'inline-flex';
queueList.innerHTML = activeRequests.map(r => `
<div class="lb-queue-entry">
<span>${r.copies} copie${r.copies > 1 ? 's' : ''}</span>
<span class="lb-queue-status ${r.status === 'printing' ? 's-printing' : 's-pending'}">
${r.status === 'printing' ? '🔵 En cours' : '⏳ En attente'}
</span>
<span style="font-size:.75rem;color:var(--text-muted)">${new Date(r.requested_at * 1000).toLocaleTimeString('fr-FR')}</span>
${r.status === 'pending'
? `<button class="ov-btn ov-cancel" style="padding:.2rem .5rem;font-size:.75rem"
onclick="cancelOneEntry('${r.id}')">✕</button>`
: ''}
</div>
`).join('');
} else {
statusBlock.style.display = 'none';
btnCancelAll.style.display = 'none';
queueList.innerHTML = '';
}
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('open');
currentPhotoId = null;
currentPhotoData = null;
}
document.getElementById('lightbox').addEventListener('click', e => {
if (e.target === document.getElementById('lightbox')) closeLightbox();
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); });
// ── Actions ───────────────────────────────────────────────────────────────────
async function printPhoto(pid) {
const copies = parseInt(document.getElementById('copies-input').value) || 1;
if (!confirm(`Imprimer ${copies} copie(s) ?`)) return;
// ════════════════════════════════════════════════════════════════════════════
// Actions impression
// ════════════════════════════════════════════════════════════════════════════
function getCopies() {
return parseInt(document.getElementById('copies-input').value) || 1;
}
async function lbAddToQueue() {
const copies = getCopies();
try {
const r = await api('POST', `/admin/api/gallery/print/${pid}?copies=${copies}`);
if (r.success) showToast('✅ Impression lancée sur ' + r.printer, 'success');
else if (r.ok) showToast('📋 Demande ajoutée à la file', 'info');
const r = await api('POST', `/admin/api/gallery/print/${currentPhotoId}?copies=${copies}&immediate=false`);
showToast(`📋 Ajouté à la file (${copies} copie${copies > 1 ? 's' : ''})`, 'info');
// Mettre à jour la photo locale et le lightbox
await refreshPhotoData(currentPhotoId);
} catch(e) { showToast('Erreur : ' + e.message, 'error'); }
}
async function lbPrintNow() {
const copies = getCopies();
if (!confirm(`Imprimer ${copies} copie(s) immédiatement ?`)) return;
try {
const r = await api('POST', `/admin/api/gallery/print/${currentPhotoId}?copies=${copies}&immediate=true`);
if (r.success) showToast(`✅ Imprimé sur ${r.printer}`, 'success');
else showToast('❌ ' + (r.error || 'Erreur'), 'error');
} catch(e) { showToast('Erreur impression', 'error'); }
await refreshPhotoData(currentPhotoId);
} catch(e) { showToast('Erreur : ' + e.message, 'error'); }
}
async function lbCancelAll() {
try {
const r = await api('DELETE', `/admin/api/gallery/print/${currentPhotoId}`);
showToast(`${r.cancelled} demande(s) annulée(s)`, 'info');
await refreshPhotoData(currentPhotoId);
} catch(e) { showToast('Erreur : ' + e.message, 'error'); }
}
async function cancelOneEntry(entryId) {
try {
await api('POST', `/api/print/cancel/${entryId}`);
showToast('Demande annulée', 'info');
await refreshPhotoData(currentPhotoId);
} catch(e) { showToast('Erreur', 'error'); }
}
async function lbDelete() {
if (!confirm('Supprimer cette photo définitivement ?')) return;
try {
const r = await api('DELETE', `/admin/api/gallery/${currentPhotoId}`);
if (r.ok) {
showToast('🗑 Photo supprimée', 'success');
closeLightbox();
loadPhotos(currentPage);
} else showToast('❌ Erreur suppression', 'error');
} catch(e) { showToast('Erreur : ' + e.message, 'error'); }
}
// Actions depuis la grille (sans ouvrir le lightbox)
async function quickQueue(pid) {
const copies = getCopies();
try {
await api('POST', `/admin/api/gallery/print/${pid}?copies=${copies}&immediate=false`);
showToast('📋 Ajouté à la file', 'info');
await refreshPhotoData(pid);
} catch(e) { showToast('Erreur', 'error'); }
}
async function quickCancel(pid) {
try {
const r = await api('DELETE', `/admin/api/gallery/print/${pid}`);
showToast(`${r.cancelled} demande(s) annulée(s)`, 'info');
await refreshPhotoData(pid);
} catch(e) { showToast('Erreur', 'error'); }
}
async function deletePhoto(pid) {
if (!confirm('Supprimer cette photo définitivement ?')) return;
if (!confirm('Supprimer cette photo ?')) return;
try {
const r = await api('DELETE', `/admin/api/gallery/${pid}`);
showToast(r.ok ? '🗑 Photo supprimée' : '❌ Erreur suppression', r.ok ? 'success' : 'error');
if (r.ok) {
document.getElementById('lightbox').classList.remove('open');
loadPhotos(currentPage);
}
} catch(e) { showToast('Erreur suppression', 'error'); }
if (r.ok) { showToast('🗑 Supprimée', 'success'); loadPhotos(currentPage); }
} catch(e) { showToast('Erreur', 'error'); }
}
function onWsMessage(msg) {
if (msg.type === 'photo_deleted') loadPhotos(currentPage);
if (msg.type === 'print_result') showToast(msg.result.success ? '✅ Impression OK' : '❌ ' + msg.result.error, msg.result.success ? 'success' : 'error');
// ════════════════════════════════════════════════════════════════════════════
// Helpers
// ════════════════════════════════════════════════════════════════════════════
async function refreshPhotoData(pid) {
// Re-fetch le statut des demandes pour cette photo et met à jour l'UI
try {
const status = await api('GET', '/admin/api/gallery/print-status');
const count = status[pid] || 0;
// Met à jour dans allPhotos
const idx = allPhotos.findIndex(p => (p.photo_id || p.id) === pid);
if (idx >= 0) {
allPhotos[idx].print_pending = count;
}
// Si le lightbox est ouvert pour cette photo, re-fetch les détails
if (currentPhotoId === pid) {
const data = await api('GET', `/admin/api/gallery/photos?page=${currentPage}&limit=24`);
const photo = (data.photos || []).find(p => (p.photo_id || p.id) === pid);
if (photo) {
currentPhotoData = photo;
updateLightboxPrintStatus(photo);
}
}
// Re-render la grille (met à jour le badge)
renderGrid();
const pendingTotal = allPhotos.filter(p => p.print_pending > 0).length;
document.getElementById('pending-count').textContent = pendingTotal;
} catch(e) {}
}
function updatePagination() {
for (const suffix of ['', '2']) {
const cur = document.getElementById(`page-cur${suffix}`);
const tot = document.getElementById(`page-total${suffix}`);
const prev = document.getElementById(`prev-btn${suffix}`);
const next = document.getElementById(`next-btn${suffix}`);
if (cur) cur.textContent = currentPage;
if (tot) tot.textContent = totalPages;
if (prev) prev.disabled = currentPage <= 1;
if (next) next.disabled = currentPage >= totalPages;
}
}
function changePage(delta) { loadPhotos(currentPage + delta); }
function refreshPhotos() { loadPhotos(currentPage); }
// ── WebSocket ─────────────────────────────────────────────────────────────────
function onWsMessage(msg) {
if (msg.type === 'photo_deleted') {
loadPhotos(currentPage);
}
if (msg.type === 'print_request' && msg.photo_id) {
refreshPhotoData(msg.photo_id);
showToast(`🖨 Demande d'impression reçue`, 'info');
}
if (msg.type === 'print_result') {
const ok = msg.result && msg.result.success;
showToast(ok ? `✅ Impression OK — ${msg.result.printer}` : `${msg.result?.error || 'Erreur'}`, ok ? 'success' : 'error');
if (msg.photo_id) refreshPhotoData(msg.photo_id);
}
if (msg.type === 'print_cancelled_for_photo' && msg.photo_id) {
refreshPhotoData(msg.photo_id);
}
}
// Auto-refresh des badges toutes les 20s
setInterval(async () => {
try {
const status = await api('GET', '/admin/api/gallery/print-status');
let changed = false;
allPhotos.forEach(p => {
const pid = p.photo_id || p.id;
const newCount = status[pid] || 0;
if (p.print_pending !== newCount) {
p.print_pending = newCount;
changed = true;
}
});
if (changed) renderGrid();
} catch(e) {}
}, 20000);
loadPhotos(1);
</script>
{% endblock %}
+309 -95
View File
@@ -6,106 +6,270 @@
<a href="/admin/gallery">Galerie admin</a>
<a href="/admin/print" class="active">Impression</a>
<a href="/admin/actions">Actions</a>
<a href="/admin/settings">Réglages</a>
<a href="/gallery">Galerie publique</a>
<a href="/admin/logout" style="margin-left:auto;color:var(--text-dim)">Déconnexion</a>
{% endblock %}
{% block content %}
<div class="container">
<h1 class="page-title">Gestion de l'impression</h1>
{% block head %}
<style>
.print-grid { max-width: 1200px; margin: 1.5rem auto; padding: 0 1rem; display: flex; flex-direction: column; gap: 1.5rem; }
<!-- Imprimantes -->
<div class="section">
<div class="card-title">Imprimantes CUPS</div>
<div class="card">
<table class="table" id="printers-table">
<thead><tr><th>Imprimante</th><th>Statut</th><th>Jobs en attente</th><th>Actions</th></tr></thead>
<tbody>
{% for p in printers %}
<tr>
<td class="font-bold">{{ p.label }}</td>
<td>
<span class="badge
{% if p.state == 'idle' %}badge-success
{% elif p.state == 'printing' %}badge-info
{% elif p.state == 'disabled' %}badge-warning
{% else %}badge-error{% endif %}
">
{% if p.state == 'idle' %}Disponible
{% elif p.state == 'printing' %}Impression
{% elif p.state == 'disabled' %}Désactivée
{% elif p.state == 'offline' %}Hors ligne
{% else %}{{ p.state }}{% endif %}
</span>
</td>
<td>{{ p.jobs }}</td>
<td>
<button class="btn btn-danger btn-sm" onclick="cancelCupsJobs('{{ p.name }}')">✕ Vider file</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
/* ── Imprimantes ──────────────────────────────────────────────────────────── */
.printers-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }
.printer-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .85rem;
}
.printer-card.state-disabled { border-color: rgba(224,123,0,.4); }
.printer-card.state-offline { border-color: rgba(192,0,0,.3); opacity: .7; }
.printer-card.state-printing { border-color: var(--primary); }
.printer-head { display: flex; align-items: center; gap: .75rem; }
.printer-icon { font-size: 2rem; }
.printer-name { font-weight: 700; font-size: 1.05rem; }
.printer-sublabel { font-size: .78rem; color: var(--text-muted); }
.printer-states { display: flex; gap: .5rem; flex-wrap: wrap; }
.badge-printer {
display: inline-flex; align-items: center; gap: .3rem;
padding: .25rem .65rem; border-radius: 20px; font-size: .8rem; font-weight: 700;
}
.bs-idle { background: rgba(26,115,64,.2); color: #4caf50; }
.bs-printing { background: rgba(25,108,176,.2); color: var(--primary); }
.bs-disabled { background: rgba(224,123,0,.2); color: #e07b00; }
.bs-offline { background: rgba(192,0,0,.2); color: #e05050; }
.bs-unknown { background: rgba(128,128,128,.2);color: #aaa; }
.bs-accept { background: rgba(26,115,64,.15); color: #66bb6a; border: 1px solid rgba(26,115,64,.3); }
.bs-reject { background: rgba(192,0,0,.12); color: #ef5350; border: 1px solid rgba(192,0,0,.2); }
.printer-actions { display: flex; gap: .5rem; flex-wrap: wrap; }
.btn-xs { padding: .3rem .65rem; font-size: .8rem; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; transition: opacity .2s; }
.btn-xs:hover { opacity: .8; }
.btn-enable { background: #1a7340; color: #fff; }
.btn-disable { background: #784700; color: #fff; }
.btn-clear { background: #7a0000; color: #fff; }
.btn-reject { background: #555; color: #fff; }
/* Jobs CUPS détaillés */
.cups-jobs { font-size: .82rem; }
.cups-job-row {
display: flex; justify-content: space-between; align-items: center;
padding: .3rem .5rem;
border-bottom: 1px solid rgba(255,255,255,.05);
gap: .5rem;
}
.cups-job-id { font-family: monospace; color: var(--primary); }
.cups-job-info { color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cups-empty { color: var(--text-muted); font-size: .82rem; font-style: italic; padding: .4rem 0; }
/* ── Mode d'impression ────────────────────────────────────────────────────── */
.mode-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1.25rem; }
.mode-options { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: .75rem; }
.mode-option { display: flex; align-items: center; gap: .5rem; cursor: pointer; }
.mode-option input[type=radio] { accent-color: var(--primary); width: 1rem; height: 1rem; }
.mode-option label { cursor: pointer; font-size: .95rem; }
.mode-hint { font-size: .8rem; color: var(--text-muted); line-height: 1.5; }
/* ── File d'attente ───────────────────────────────────────────────────────── */
.queue-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.queue-header { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); }
.queue-title { font-size: 1rem; font-weight: 700; color: var(--text); }
.queue-table { width: 100%; border-collapse: collapse; }
.queue-table th { padding: .6rem 1rem; text-align: left; font-size: .8rem; color: var(--text-muted); font-weight: 600; border-bottom: 1px solid var(--border); }
.queue-table td { padding: .65rem 1rem; border-bottom: 1px solid rgba(255,255,255,.04); font-size: .9rem; vertical-align: middle; }
.print-thumb { width: 48px; height: 32px; object-fit: cover; border-radius: 4px; background: var(--bg); }
.copies-input { width: 50px; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 4px; padding: 2px 6px; text-align: center; }
.queue-empty { text-align: center; padding: 2rem; color: var(--text-muted); font-size: .95rem; }
/* ── Badges ───────────────────────────────────────────────────────────────── */
.badge { display: inline-flex; align-items: center; padding: .2rem .55rem; border-radius: 20px; font-size: .78rem; font-weight: 700; }
.badge-success { background: rgba(26,115,64,.2); color: #4caf50; }
.badge-info { background: rgba(25,108,176,.2); color: var(--primary); }
.badge-warning { background: rgba(224,123,0,.2); color: #e07b00; }
.badge-error { background: rgba(192,0,0,.2); color: #e05050; }
/* Section title */
.section-title { font-size: 1rem; font-weight: 700; color: var(--text); margin-bottom: .75rem; display: flex; align-items: center; gap: .4rem; }
/* Inline buttons */
.btn-sm { padding: .35rem .75rem; border: none; border-radius: 6px; cursor: pointer; font-size: .85rem; font-weight: 600; transition: opacity .2s; }
.btn-sm:hover { opacity: .8; }
.btn-print { background: var(--primary); color: #fff; }
.btn-cancel { background: rgba(192,0,0,.25); color: #e05050; border: 1px solid rgba(192,0,0,.3); }
.btn-refresh { background: transparent; border: 1px solid var(--border); color: var(--text-muted); }
</style>
{% endblock %}
{% block content %}
<div class="print-grid">
<!-- ── IMPRIMANTES ─────────────────────────────────────────────────────── -->
<div>
<div class="section-title">🖨 Imprimantes CUPS</div>
<div class="printers-row" id="printers-container">
{% for p in printers %}
<div class="printer-card state-{{ p.state }}" id="pc-{{ p.name }}">
<div class="printer-head">
<span class="printer-icon">
{% if p.state == 'printing' %}🔵{% elif p.state == 'idle' %}🟢{% elif p.state == 'disabled' %}🟠{% else %}🔴{% endif %}
</span>
<div>
<div class="printer-name">{{ p.label or p.name }}</div>
<div class="printer-sublabel">{{ p.name }}</div>
</div>
</div>
<div class="printer-states">
<span class="badge-printer bs-{{ p.state }}">
{% if p.state == 'idle' %}✅ Disponible
{% elif p.state == 'printing' %}🖨 En impression
{% elif p.state == 'disabled' %}⏸ Désactivée
{% elif p.state == 'offline' %}❌ Hors ligne
{% else %}❓ {{ p.state }}{% endif %}
</span>
<span class="badge-printer {{ 'bs-accept' if p.accepting else 'bs-reject' }}">
{{ '✓ Accepte les jobs' if p.accepting else '✗ Refuse les jobs' }}
</span>
{% if p.jobs_count %}
<span class="badge-printer bs-printing">{{ p.jobs_count }} job{{ 's' if p.jobs_count > 1 else '' }}</span>
{% endif %}
</div>
<!-- Actions imprimante -->
<div class="printer-actions">
{% if p.state == 'disabled' %}
<button class="btn-xs btn-enable" onclick="printerAction('{{ p.name }}','enable')">▶ Activer</button>
{% else %}
<button class="btn-xs btn-disable" onclick="printerAction('{{ p.name }}','disable')">⏸ Désactiver</button>
{% endif %}
{% if p.accepting %}
<button class="btn-xs btn-reject" onclick="printerAction('{{ p.name }}','reject')">🚫 Refuser jobs</button>
{% else %}
<button class="btn-xs btn-enable" onclick="printerAction('{{ p.name }}','enable')">✓ Accepter jobs</button>
{% endif %}
{% if p.jobs_count %}
<button class="btn-xs btn-clear" onclick="clearJobs('{{ p.name }}')">✕ Vider file</button>
{% endif %}
</div>
<!-- Jobs CUPS en cours -->
<div class="cups-jobs" id="jobs-{{ p.name }}">
{% if p.jobs %}
{% for j in p.jobs %}
<div class="cups-job-row">
<span class="cups-job-id">{{ j.id }}</span>
<span class="cups-job-info">{{ j.user }} — {{ j.size }}</span>
<button class="btn-xs btn-clear" style="padding:.2rem .5rem;font-size:.75rem" onclick="cancelJob('{{ j.id }}')"></button>
</div>
{% endfor %}
{% else %}
<div class="cups-empty">Aucun job en cours</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<div style="margin-top:.5rem">
<button class="btn-sm btn-refresh" onclick="refreshPrinters()">↻ Actualiser imprimantes</button>
</div>
</div>
<!-- Mode impression -->
<div class="section">
<div class="card-title">Mode d'impression</div>
<div class="card">
<div class="flex gap-1 mb-1">
<label class="flex items-center gap-1" style="cursor:pointer">
<input type="radio" name="print-mode" value="direct" {% if config.print.mode=='direct' %}checked{% endif %}> Direct (impression automatique)
<!-- ── MODE D'IMPRESSION ──────────────────────────────────────────────── -->
<div>
<div class="section-title">⚙️ Mode d'impression</div>
<div class="mode-card">
<div class="mode-options">
<label class="mode-option">
<input type="radio" name="print-mode" value="direct" {% if config.print.mode=='direct' %}checked{% endif %}>
<label>🚀 Direct (auto)</label>
</label>
<label class="flex items-center gap-1" style="cursor:pointer">
<input type="radio" name="print-mode" value="validation" {% if config.print.mode=='validation' %}checked{% endif %}> Validation (admin confirme)
<label class="mode-option">
<input type="radio" name="print-mode" value="validation" {% if config.print.mode=='validation' %}checked{% endif %}>
<label>✋ Validation admin</label>
</label>
<label class="flex items-center gap-1" style="cursor:pointer">
<input type="radio" name="print-mode" value="gallery" {% if config.print.mode=='gallery' %}checked{% endif %}> Galerie (file uniquement)
<label class="mode-option">
<input type="radio" name="print-mode" value="gallery" {% if config.print.mode=='gallery' %}checked{% endif %}>
<label>🖼 File galerie</label>
</label>
</div>
<p class="text-xs text-muted">
<b>Direct</b> : impression lancée immédiatement sans confirmation. <b>Validation</b> : l'admin valide chaque impression. <b>Galerie</b> : les demandes s'accumulent, impression via la galerie admin.
</p>
<div class="mode-hint">
<b>Direct</b> : chaque demande est imprimée immédiatement (load-balancing automatique entre les Selphy).
<b>Validation</b> : l'admin doit valider chaque impression via cette page.
<b>File galerie</b> : les demandes s'accumulent sans impression automatique.
</div>
</div>
</div>
<!-- File d'attente -->
<div class="section">
<div class="flex items-center justify-between mb-1">
<div class="card-title" style="margin:0">File d'attente (<span id="pending-count">{{ queue|length }}</span>)</div>
<button class="btn btn-ghost btn-sm" onclick="refreshQueue()">↻ Actualiser</button>
</div>
<div class="card">
<!-- ── FILE D'ATTENTE ─────────────────────────────────────────────────── -->
<div>
<div class="queue-card">
<div class="queue-header">
<div class="queue-title">📋 File d'attente (<span id="pending-count">{{ queue|selectattr('status','equalto','pending')|list|length }}</span> en attente)</div>
<button class="btn-sm btn-refresh" onclick="refreshQueue()">↻ Actualiser</button>
</div>
<div id="queue-container">
{% if not queue %}
<div class="empty-state"><div class="icon"></div>Aucune impression en attente</div>
<div class="queue-empty">Aucune impression enregistrée</div>
{% else %}
<table class="table" id="queue-table">
<thead><tr><th>Aperçu</th><th>Fichier</th><th>Copies</th><th>Statut</th><th>Demandé</th><th>Actions</th></tr></thead>
<table class="queue-table">
<thead><tr>
<th>Aperçu</th>
<th>Fichier</th>
<th>Copies</th>
<th>Statut</th>
<th>Demandé le</th>
<th>Imprimante</th>
<th>Actions</th>
</tr></thead>
<tbody id="queue-tbody">
{% for q in queue %}
<tr id="row-{{ q.id }}">
<td><img src="{{ q.thumb_url }}" class="print-thumb" onerror="this.style.opacity=0"></td>
<td class="text-sm">{{ q.filename.split('/')[-1] }}</td>
<td style="font-size:.82rem;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
{{ q.filename.split('/')[-1] }}
</td>
<td>
<input type="number" value="{{ q.copies }}" min="1" max="3" id="copies-{{ q.id }}" style="width:50px;background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:4px;padding:2px 6px">
{% if q.status == 'pending' %}
<input type="number" value="{{ q.copies }}" min="1" max="3"
id="copies-{{ q.id }}" class="copies-input">
{% else %}
{{ q.copies }}
{% endif %}
</td>
<td>
<span class="badge
{% if q.status == 'pending' %}badge-warning
{% elif q.status == 'done' %}badge-success
{% elif q.status == 'printing' %}badge-info
{% else %}badge-error{% endif %}
">{{ q.status }}</span>
{% elif q.status == 'cancelled' %}badge-error
{% else %}badge-error{% endif %}">
{% if q.status == 'pending' %}En attente
{% elif q.status == 'done' %}Imprimé
{% elif q.status == 'printing' %}En cours
{% elif q.status == 'cancelled' %}Annulé
{% else %}Erreur{% endif %}
</span>
</td>
<td class="text-xs text-muted">{{ q.requested_at|int }}</td>
<td>
<td style="font-size:.78rem;color:var(--text-muted)">
{{ q.requested_at|int|timestamp_to_date if q.requested_at else '—' }}
</td>
<td style="font-size:.82rem">{{ q.printer or '—' }}</td>
<td style="display:flex;gap:.4rem;align-items:center">
{% if q.status == 'pending' %}
<button class="btn btn-success btn-sm" onclick="executePrint('{{ q.id }}')">🖨 Imprimer</button>
<button class="btn btn-danger btn-sm" onclick="cancelPrint('{{ q.id }}')"></button>
{% elif q.status == 'done' %}
<span class="text-sm text-muted">{{ q.printer or '—' }}</span>
<button class="btn-sm btn-print" onclick="executePrint('{{ q.id }}')">🖨 Imprimer</button>
<button class="btn-sm btn-cancel" onclick="cancelPrint('{{ q.id }}')"></button>
{% elif q.status == 'error' %}
<span style="font-size:.75rem;color:#e05050">{{ q.error_msg or 'Erreur' }}</span>
{% endif %}
</td>
</tr>
@@ -116,6 +280,7 @@
</div>
</div>
</div>
</div>
{% endblock %}
@@ -126,31 +291,83 @@ document.querySelectorAll('input[name="print-mode"]').forEach(r => {
r.addEventListener('change', async (e) => {
try {
await api('POST', `/api/print/mode?mode=${e.target.value}`);
showToast(`Mode: ${e.target.value}`, 'success');
} catch(e) { showToast('Erreur', 'error'); }
showToast(`Mode d'impression : ${e.target.value}`, 'success');
} catch(err) { showToast('Erreur', 'error'); }
});
});
// ── Imprimantes ────────────────────────────────────────────────────────────────
async function refreshPrinters() {
try {
const printers = await api('GET', '/api/print/printers');
printers.forEach(p => {
// Met à jour les badges et compteur de jobs
const card = document.getElementById('pc-' + p.name);
if (!card) return;
card.className = `printer-card state-${p.state}`;
// Actualise les jobs CUPS
const jobsDiv = document.getElementById('jobs-' + p.name);
if (jobsDiv) {
if (p.jobs && p.jobs.length) {
jobsDiv.innerHTML = p.jobs.map(j => `
<div class="cups-job-row">
<span class="cups-job-id">${j.id}</span>
<span class="cups-job-info">${j.user}${j.size}</span>
<button class="btn-xs btn-clear" style="padding:.2rem .5rem;font-size:.75rem" onclick="cancelJob('${j.id}')">✕</button>
</div>`).join('');
} else {
jobsDiv.innerHTML = '<div class="cups-empty">Aucun job en cours</div>';
}
}
});
} catch(err) {}
}
async function printerAction(name, action) {
try {
await api('POST', `/api/print/printers/${name}/${action}`);
showToast(`${name} : ${action}`, 'success');
setTimeout(refreshPrinters, 800);
} catch(err) { showToast('Erreur : ' + err.message, 'error'); }
}
async function clearJobs(printer) {
if (!confirm(`Vider toute la file CUPS de ${printer} ?`)) return;
try {
await api('POST', `/api/print/cups/cancel/${printer}`);
showToast('File vidée', 'success');
setTimeout(refreshPrinters, 500);
} catch(err) { showToast('Erreur', 'error'); }
}
async function cancelJob(jobId) {
try {
await api('DELETE', `/api/print/printers/jobs/${jobId}`);
showToast(`Job ${jobId} annulé`, 'info');
setTimeout(refreshPrinters, 500);
} catch(err) { showToast('Erreur', 'error'); }
}
// ── File d'attente ─────────────────────────────────────────────────────────────
async function refreshQueue() {
try {
const data = await api('GET', '/api/print/queue');
const queue = data.queue || [];
const pending = queue.filter(q => q.status === 'pending');
document.getElementById('pending-count').textContent = pending.length;
const pending = queue.filter(q => q.status === 'pending').length;
document.getElementById('pending-count').textContent = pending;
const tbody = document.getElementById('queue-tbody');
if (!tbody) return;
// Mise à jour des statuts existants
// Mise à jour des badges de statut existants
queue.forEach(q => {
const row = document.getElementById('row-' + q.id);
if (row) {
const badge = row.querySelector('.badge');
if (badge) badge.textContent = q.status;
if (badge && q.status !== badge.textContent.trim()) {
const labels = { pending:'En attente', done:'Imprimé', printing:'En cours', cancelled:'Annulé', error:'Erreur' };
badge.textContent = labels[q.status] || q.status;
}
}
});
} catch(e) {}
} catch(err) {}
}
async function executePrint(id) {
@@ -158,33 +375,30 @@ async function executePrint(id) {
const copies = copiesEl ? parseInt(copiesEl.value) : 1;
try {
const r = await api('POST', `/api/print/execute/${id}?copies=${copies}`);
showToast(r.success ? '✅ Impression lancée sur ' + r.printer : '❌ ' + r.error, r.success ? 'success' : 'error');
setTimeout(refreshQueue, 1000);
} catch(e) { showToast('Erreur impression', 'error'); }
showToast(r.success ? `✅ Impression lancée (${r.printer})` : `${r.error}`, r.success ? 'success' : 'error');
setTimeout(refreshQueue, 1200);
} catch(err) { showToast('Erreur impression', 'error'); }
}
async function cancelPrint(id) {
try {
await api('POST', `/api/print/cancel/${id}`);
showToast('Annulé', 'info');
showToast('Impression annulée', 'info');
const row = document.getElementById('row-' + id);
if (row) row.remove();
} catch(e) { showToast('Erreur', 'error'); }
}
async function cancelCupsJobs(printer) {
if (!confirm(`Vider la file de ${printer} ?`)) return;
try {
const r = await api('POST', `/api/print/cups/cancel/${printer}`);
showToast(r.ok ? 'File vidée' : 'Erreur', r.ok ? 'success' : 'error');
refreshQueue();
} catch(e) { showToast('Erreur', 'error'); }
if (row) row.style.opacity = '.4';
setTimeout(refreshQueue, 500);
} catch(err) { showToast('Erreur', 'error'); }
}
// ── WebSocket + auto-refresh ───────────────────────────────────────────────────
function onWsMessage(msg) {
if (['print_request','print_result','print_cancelled'].includes(msg.type)) refreshQueue();
if (['print_request','print_result','print_cancelled'].includes(msg.type)) {
refreshQueue();
if (msg.type === 'print_result') refreshPrinters();
}
}
setInterval(refreshQueue, 10000);
setInterval(refreshPrinters, 15000);
</script>
{% endblock %}
+823
View File
@@ -0,0 +1,823 @@
{% extends "base.html" %}
{% block title %}Réglages bouton — JH Photomaton{% endblock %}
{% block nav_links %}
<a href="/admin">Dashboard</a>
<a href="/admin/gallery">Galerie</a>
<a href="/admin/actions">Actions</a>
<a href="/admin/print">Impression</a>
<a href="/admin/settings" class="active">Réglages</a>
<a href="/admin/logout">Déconnexion</a>
{% endblock %}
{% block head %}
<style>
/* ── Layout ──────────────────────────────────────────────────────────────── */
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
max-width: 1100px;
margin: 1.5rem auto;
padding: 0 1rem;
}
@media (max-width: 768px) { .settings-grid { grid-template-columns: 1fr; } }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
}
.card-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--primary);
margin-bottom: 1.25rem;
display: flex;
align-items: center;
gap: .5rem;
}
/* ── Sliders ─────────────────────────────────────────────────────────────── */
.timing-row {
display: flex;
flex-direction: column;
gap: .4rem;
margin-bottom: 1.25rem;
}
.timing-label {
display: flex;
justify-content: space-between;
align-items: center;
}
.timing-label span:first-child { color: var(--text-muted); font-size: .9rem; }
.timing-value {
font-family: 'Courier New', monospace;
font-size: 1.05rem;
font-weight: 700;
color: var(--primary);
min-width: 5ch;
text-align: right;
}
/* Toggle switch */
.toggle-switch { position: relative; display: inline-block; width: 48px; height: 26px; flex-shrink: 0; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; inset: 0; cursor: pointer;
background: var(--border); border-radius: 26px; transition: .3s;
}
.toggle-slider::before {
content: ''; position: absolute;
width: 20px; height: 20px; left: 3px; bottom: 3px;
background: #fff; border-radius: 50%; transition: .3s;
}
.toggle-switch input:checked + .toggle-slider { background: var(--primary); }
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(22px); }
input[type=range] {
width: 100%;
accent-color: var(--primary);
height: 6px;
cursor: pointer;
}
.timing-hint { font-size: .78rem; color: var(--text-muted); }
select.field {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: .45rem .7rem;
font-size: .95rem;
}
.btn-row { display: flex; gap: .75rem; margin-top: 1.5rem; flex-wrap: wrap; }
.btn { padding: .55rem 1.2rem; border: none; border-radius: 8px; cursor: pointer; font-size: .95rem; font-weight: 600; transition: opacity .2s; }
.btn:hover { opacity: .85; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-success { background: #1a7340; color: #fff; }
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-sm { padding: .35rem .8rem; font-size: .85rem; }
/* ── Testeur ─────────────────────────────────────────────────────────────── */
.tester-area { display: flex; flex-direction: column; align-items: center; gap: 1.25rem; }
#big-btn {
width: 180px;
height: 180px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #2a6db0, #0d3d6b);
border: 4px solid var(--primary);
box-shadow: 0 0 0 0 rgba(25, 108, 176, .5);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: .4rem;
user-select: none;
touch-action: none;
transition: transform .1s, box-shadow .1s;
color: #fff;
font-size: .9rem;
font-weight: 600;
text-align: center;
}
#big-btn:active, #big-btn.pressed {
transform: scale(.94);
box-shadow: 0 0 0 16px rgba(25, 108, 176, .2);
}
#big-btn .btn-icon { font-size: 2.2rem; pointer-events: none; }
#big-btn .btn-hint { font-size: .75rem; opacity: .7; pointer-events: none; }
/* Anneau de compte pendant multi-clic */
.click-dots { display: flex; gap: .4rem; justify-content: center; }
.click-dot {
width: 12px; height: 12px;
border-radius: 50%;
background: var(--border);
transition: background .15s;
}
.click-dot.active { background: var(--primary); }
.click-dot.max { background: #1a7340; }
/* Résultat */
.result-box {
min-height: 52px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
font-weight: 800;
letter-spacing: .05em;
border-radius: 10px;
padding: .5rem 1.5rem;
width: 100%;
transition: background .3s, color .3s;
background: var(--bg);
color: var(--text-muted);
text-align: center;
}
.result-box.res-click { background: rgba(25,108,176,.2); color: var(--primary); }
.result-box.res-long { background: rgba(224,123,0,.2); color: #e07b00; }
.result-box.res-error { background: rgba(192,0,0,.2); color: #e05050; }
/* Log */
.event-log {
width: 100%;
max-height: 160px;
overflow-y: auto;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: .5rem .75rem;
font-size: .82rem;
color: var(--text-muted);
}
.log-entry {
display: flex;
justify-content: space-between;
padding: .15rem 0;
border-bottom: 1px solid rgba(255,255,255,.04);
animation: fadeIn .3s ease;
}
.log-entry .log-val { color: var(--text); font-weight: 600; }
.log-entry .log-src { font-size: .75rem; opacity: .6; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
/* ── GPIO live ───────────────────────────────────────────────────────────── */
.gpio-badge {
display: inline-flex;
align-items: center;
gap: .4rem;
padding: .3rem .7rem;
border-radius: 20px;
background: var(--bg);
border: 1px solid var(--border);
font-size: .9rem;
font-weight: 600;
}
.gpio-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--border); }
.gpio-dot.live { background: #1a7340; animation: pulse 1s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
/* ── Status banner ───────────────────────────────────────────────────────── */
.status-banner {
max-width: 1100px;
margin: 1rem auto;
padding: 0 1rem;
}
.banner { padding: .65rem 1rem; border-radius: 8px; font-size: .9rem; display: none; }
.banner.show { display: block; }
.banner-ok { background: rgba(26,115,64,.15); border: 1px solid #1a7340; color: #4caf50; }
.banner-err { background: rgba(192,0,0,.12); border: 1px solid #c00; color: #e05050; }
</style>
{% endblock %}
{% block content %}
<div class="status-banner">
<div class="banner banner-ok" id="banner-ok">✅ Réglages appliqués avec succès.</div>
<div class="banner banner-err" id="banner-err">❌ Erreur lors de l'application des réglages.</div>
</div>
<div class="settings-grid">
<!-- ── COLONNE GAUCHE : Timings ─────────────────────────────────────────── -->
<div>
<div class="card">
<div class="card-title">⏱ Timing des clics</div>
<!-- Fenêtre multi-clic -->
<div class="timing-row">
<div class="timing-label">
<span>Fenêtre multi-clic</span>
<span class="timing-value" id="val-dc">{{ button.double_click_ms }} ms</span>
</div>
<input type="range" id="double-click-ms"
min="150" max="800" step="25"
value="{{ button.double_click_ms }}"
oninput="updateSlider('dc', this.value, ' ms')">
<div class="timing-hint">
Délai d'attente entre deux clics. Trop court = double-clic raté. Trop long = lenteur.
</div>
</div>
<!-- Long press -->
<div class="timing-row">
<div class="timing-label">
<span>Durée appui long</span>
<span class="timing-value" id="val-lp">{{ button.long_press_ms }} ms</span>
</div>
<input type="range" id="long-press-ms"
min="500" max="3000" step="100"
value="{{ button.long_press_ms }}"
oninput="updateSlider('lp', this.value, ' ms')">
<div class="timing-hint">
Durée minimale pour déclencher "impression" en maintenant le bouton appuyé.
</div>
</div>
<!-- Anti-rebond -->
<div class="timing-row">
<div class="timing-label">
<span>Anti-rebond (debounce)</span>
<span class="timing-value" id="val-db">{{ button.debounce_ms }} ms</span>
</div>
<input type="range" id="debounce-ms"
min="10" max="200" step="5"
value="{{ button.debounce_ms }}"
oninput="updateSlider('db', this.value, ' ms')">
<div class="timing-hint">
Filtre les faux contacts mécaniques. Augmenter si le bouton "rebondit".
</div>
</div>
<!-- Nombre max de clics -->
<div class="timing-row">
<div class="timing-label">
<span>Nombre max de clics détectés</span>
</div>
<select class="field" id="max-clicks">
{% for n in [1,2,3,4] %}
<option value="{{ n }}" {% if n == button.max_clicks %}selected{% endif %}>
{{ n }} clic{{ 's' if n > 1 else '' }}
</option>
{% endfor %}
</select>
<div class="timing-hint">
Correspond au nombre d'actions mappées (voir page Actions).
</div>
</div>
<!-- Impression long press -->
<div class="timing-row">
<div class="timing-label">
<span>Impression via appui long</span>
<select class="field" id="print-enabled" style="width:auto">
<option value="true" {% if button.print_enabled %}selected{% endif %}>Activée</option>
<option value="false" {% if not button.print_enabled %}selected{% endif %}>Désactivée</option>
</select>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" onclick="applyTimings(false)">
▶ Appliquer (sans redémarrage)
</button>
<button class="btn btn-success" onclick="applyTimings(true)">
💾 Sauvegarder
</button>
</div>
<p style="font-size:.78rem; color:var(--text-muted); margin-top:.75rem;">
<strong>Appliquer</strong> met les réglages en ligne immédiatement.<br>
<strong>Sauvegarder</strong> les persiste dans <code>settings.yaml</code> pour les redémarrages.
</p>
</div>
<!-- Contrôles interface photobooth-app -->
<div class="card" style="margin-top:1rem">
<div class="card-title">🎛 Interface photobooth-app</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:.5rem 0; border-bottom:1px solid var(--border);">
<div>
<div style="font-size:.95rem; font-weight:600;">Bouton Supprimer (review)</div>
<div style="font-size:.78rem; color:var(--text-muted); margin-top:.2rem;">
Affiche ou cache le bouton 🗑 sur l'écran de validation après chaque capture.<br>
Effectif immédiatement (modifie <code>userdata/private.css</code>).
</div>
</div>
<label class="toggle-switch" title="Bouton Supprimer visible dans photobooth-app">
<input type="checkbox" id="toggle-delete-btn"
{% if config.photobooth.show_delete_button %}checked{% endif %}
onchange="setDeleteButton(this.checked)">
<span class="toggle-slider"></span>
</label>
</div>
<div style="display:flex; align-items:center; gap:.75rem; margin-top:.75rem; font-size:.82rem; color:var(--text-muted);" id="delete-btn-status">
{% if config.photobooth.show_delete_button %}
✅ Bouton Supprimer actuellement <strong style="color:#4caf50">visible</strong>
{% else %}
🚫 Bouton Supprimer actuellement <strong style="color:#e07b00">caché</strong>
{% endif %}
</div>
</div>
<!-- Actions système -->
<div class="card" style="margin-top:1rem">
<div class="card-title">🖥 Affichage HDMI</div>
<p style="font-size:.85rem; color:var(--text-muted); margin-bottom:1rem;">
Rafraîchit le Chromium en kiosque connecté à l'écran HDMI.
Équivalent à appuyer sur <code>F5</code> sur le Pi.
</p>
<button class="btn btn-outline" onclick="refreshScreen()" id="btn-refresh-screen">
🔄 Rafraîchir l'écran (F5)
</button>
<span id="refresh-status" style="font-size:.82rem; color:var(--text-muted); margin-left:.75rem;"></span>
</div>
<!-- Flash LED -->
<div class="card" style="margin-top:1rem">
<div class="card-title">&#x26A1; Flash photo (LED)</div>
<p style="font-size:.82rem; color:var(--text-muted); margin-bottom:1rem;">
Les LEDs WS2812b ont une diode bleue plus efficace que la rouge.<br>
<code>[255,255,255]</code> produit un rendu &agrave; dominante bleue sur les photos.<br>
R&eacute;duisez le bleu et augmentez le rouge pour un blanc plus naturel.
</p>
<div style="display:grid; gap:.75rem;">
<div>
<label style="font-size:.82rem; color:var(--text-muted)">Couleur flash (R G B)</label>
<div style="display:flex; gap:.5rem; align-items:center; margin-top:.25rem; flex-wrap:wrap;">
<input type="number" id="flash-r" min="0" max="255" value="255"
style="width:70px" class="form-control" placeholder="R">
<input type="number" id="flash-g" min="0" max="255" value="200"
style="width:70px" class="form-control" placeholder="G">
<input type="number" id="flash-b" min="0" max="255" value="80"
style="width:70px" class="form-control" placeholder="B">
<div id="flash-preview" style="width:36px;height:36px;border-radius:50%;border:2px solid var(--border);background:rgb(255,200,80)"></div>
<input type="color" id="flash-colorpicker" value="#ffc850"
title="Aide visuelle (convertit en RGB approx)"
style="width:36px;height:36px;cursor:pointer;border:none;background:none"
onchange="colorPickerToRgb(this.value)">
</div>
</div>
<div>
<label style="font-size:.82rem; color:var(--text-muted)">
Dur&eacute;e du flash : <strong id="flash-dur-val">0.30</strong> s
</label>
<input type="range" id="flash-duration" min="0.05" max="1.0" step="0.05" value="0.30"
oninput="document.getElementById('flash-dur-val').textContent=parseFloat(this.value).toFixed(2)"
style="width:100%">
<div style="font-size:.72rem;color:var(--text-muted)">0.05 s (bref) &mdash; 1.0 s (long)</div>
</div>
<div>
<label style="font-size:.82rem; color:var(--text-muted)">Nombre de flashs</label>
<select id="flash-count" class="form-control" style="width:100px; margin-top:.25rem">
<option value="1">1</option>
<option value="2" selected>2</option>
<option value="3">3</option>
</select>
</div>
<div style="display:flex; gap:.5rem; flex-wrap:wrap;">
<button class="btn btn-ghost btn-sm" onclick="previewFlash()">&#x1F4A1; Tester le flash</button>
<button class="btn btn-primary btn-sm" onclick="saveFlash()">&#x1F4BE; Sauvegarder</button>
</div>
<div style="display:flex; gap:.4rem; flex-wrap:wrap; margin-top:.25rem;">
<span style="font-size:.75rem; color:var(--text-muted); align-self:center;">Presets :</span>
<button class="btn btn-ghost btn-sm" onclick="setFlashPreset(255,255,255,0.15,2)">Blanc pur (froid)</button>
<button class="btn btn-ghost btn-sm" onclick="setFlashPreset(255,200,80,0.30,2)">Blanc chaud &#x2605;</button>
<button class="btn btn-ghost btn-sm" onclick="setFlashPreset(255,220,120,0.25,2)">Blanc neutre</button>
<button class="btn btn-ghost btn-sm" onclick="setFlashPreset(255,180,40,0.35,2)">Ambre</button>
</div>
</div>
</div>
<!-- Valeurs recommandées -->
<div class="card" style="margin-top:1rem">
<div class="card-title">📋 Préréglages</div>
<div style="display:flex; flex-wrap:wrap; gap:.5rem;">
<button class="btn btn-outline btn-sm" onclick="applyPreset(350, 1200, 30, 4)">Rapide</button>
<button class="btn btn-outline btn-sm" onclick="applyPreset(400, 1500, 50, 4)">Normal (défaut)</button>
<button class="btn btn-outline btn-sm" onclick="applyPreset(500, 2000, 80, 4)">Lent / senior</button>
<button class="btn btn-outline btn-sm" onclick="applyPreset(600, 2500, 100, 4)">Très lent</button>
</div>
<p style="font-size:.78rem; color:var(--text-muted); margin-top:.75rem;">
Les préréglages mettent à jour les sliders uniquement.
Cliquez ensuite sur Appliquer ou Sauvegarder.
</p>
</div>
</div>
<!-- ── COLONNE DROITE : Testeur ─────────────────────────────────────────── -->
<div>
<div class="card">
<div class="card-title">🎯 Testeur de timings</div>
<p style="font-size:.85rem; color:var(--text-muted); margin-bottom:1rem;">
Simulation <strong>locale</strong> — utilise les valeurs des sliders en temps réel,
même avant de sauvegarder. Aucune action GPIO déclenchée.
</p>
<div class="tester-area">
<!-- Anneau de clics -->
<div class="click-dots" id="click-dots">
<div class="click-dot" id="dot-1"></div>
<div class="click-dot" id="dot-2"></div>
<div class="click-dot" id="dot-3"></div>
<div class="click-dot" id="dot-4"></div>
</div>
<!-- Grand bouton -->
<div id="big-btn"
onmousedown="btnDown(event)"
onmouseup="btnUp(event)"
onmouseleave="btnUp(event)"
ontouchstart="btnDown(event)"
ontouchend="btnUp(event)">
<span class="btn-icon">👆</span>
<span>Appuyer ici</span>
<span class="btn-hint">clic / multi-clic / maintenir</span>
</div>
<!-- Résultat -->
<div class="result-box" id="result-box">En attente…</div>
<!-- Log local -->
<div class="event-log" id="tester-log">
<div style="opacity:.4; text-align:center; padding:.5rem 0">Les événements apparaîtront ici</div>
</div>
</div>
</div>
<!-- GPIO réel via WebSocket -->
<div class="card" style="margin-top:1rem">
<div class="card-title">
🔌 Bouton GPIO réel
<div class="gpio-badge" style="margin-left:auto">
<div class="gpio-dot" id="gpio-dot"></div>
<span id="gpio-label">En attente…</span>
</div>
</div>
<p style="font-size:.85rem; color:var(--text-muted); margin-bottom:.75rem;">
Événements reçus du bouton physique (GPIO {{ button.pin }}) via WebSocket.
</p>
<div class="event-log" id="gpio-log">
<div style="opacity:.4; text-align:center; padding:.5rem 0">Appuyer sur le bouton physique…</div>
</div>
<div style="margin-top:.75rem; display:flex; gap:.5rem; flex-wrap:wrap;">
<button class="btn btn-outline btn-sm" onclick="simulateGPIO(1)">Sim. 1 clic</button>
<button class="btn btn-outline btn-sm" onclick="simulateGPIO(2)">Sim. 2 clics</button>
<button class="btn btn-outline btn-sm" onclick="simulateGPIO(3)">Sim. 3 clics</button>
<button class="btn btn-outline btn-sm" onclick="simulateGPIO(4)">Sim. 4 clics</button>
<button class="btn btn-outline btn-sm" onclick="simulateGPIO(0)">Sim. Long press</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ════════════════════════════════════════════════════════════════════════════
// Sliders & valeurs
// ════════════════════════════════════════════════════════════════════════════
function updateSlider(key, val, unit) {
document.getElementById('val-' + key).textContent = val + unit;
}
function getTimings() {
return {
doubleClickMs: parseInt(document.getElementById('double-click-ms').value),
longPressMs: parseInt(document.getElementById('long-press-ms').value),
debounceMs: parseInt(document.getElementById('debounce-ms').value),
maxClicks: parseInt(document.getElementById('max-clicks').value),
};
}
function applyPreset(dc, lp, db, mc) {
document.getElementById('double-click-ms').value = dc;
document.getElementById('long-press-ms').value = lp;
document.getElementById('debounce-ms').value = db;
document.getElementById('max-clicks').value = mc;
updateSlider('dc', dc, ' ms');
updateSlider('lp', lp, ' ms');
updateSlider('db', db, ' ms');
showDots(0);
}
// ════════════════════════════════════════════════════════════════════════════
// Apply / Save via API
// ════════════════════════════════════════════════════════════════════════════
async function applyTimings(save) {
const t = getTimings();
const printEnabled = document.getElementById('print-enabled').value === 'true';
try {
const r = await api('PUT', '/api/system/button/config', {
double_click_ms: t.doubleClickMs,
long_press_ms: t.longPressMs,
debounce_ms: t.debounceMs,
max_clicks: t.maxClicks,
print_enabled: printEnabled,
save: save,
});
document.getElementById('banner-ok').className = 'banner banner-ok show';
document.getElementById('banner-err').className = 'banner banner-err';
setTimeout(() => {
document.getElementById('banner-ok').className = 'banner banner-ok';
}, 3000);
showToast(save ? '✅ Réglages sauvegardés dans settings.yaml' : '▶ Timings appliqués (non sauvegardés)', 'success');
} catch(e) {
document.getElementById('banner-err').className = 'banner banner-err show';
document.getElementById('banner-ok').className = 'banner banner-ok';
showToast('Erreur : ' + e.message, 'error');
}
}
// ════════════════════════════════════════════════════════════════════════════
// Testeur de timings (simulation locale — AUCUN GPIO)
// ════════════════════════════════════════════════════════════════════════════
let testerClickCount = 0;
let testerClickTimer = null;
let testerLongTimer = null;
let testerLongFired = false;
function btnDown(e) {
e.preventDefault();
document.getElementById('big-btn').classList.add('pressed');
testerLongFired = false;
if (testerLongTimer) clearTimeout(testerLongTimer);
const { longPressMs } = getTimings();
testerLongTimer = setTimeout(() => {
testerLongFired = true;
if (testerClickTimer) clearTimeout(testerClickTimer);
testerClickCount = 0;
showDots(0);
showTesterResult('LONG PRESS', 'long');
addLog('tester-log', 'Long press', 'testeur');
}, longPressMs);
}
function btnUp(e) {
e.preventDefault();
document.getElementById('big-btn').classList.remove('pressed');
if (testerLongTimer) clearTimeout(testerLongTimer);
if (testerLongFired) return;
const { doubleClickMs, maxClicks } = getTimings();
testerClickCount++;
showDots(testerClickCount);
if (testerClickTimer) clearTimeout(testerClickTimer);
const delay = testerClickCount >= maxClicks ? 50 : doubleClickMs;
testerClickTimer = setTimeout(() => {
const n = testerClickCount;
testerClickCount = 0;
testerClickTimer = null;
showDots(0);
const label = n === 1 ? '1 CLIC' : `${n} CLICS`;
showTesterResult(label, 'click');
addLog('tester-log', label, 'testeur');
}, delay);
}
function showDots(count) {
const max = parseInt(document.getElementById('max-clicks').value);
for (let i = 1; i <= 4; i++) {
const d = document.getElementById('dot-' + i);
if (i > max) {
d.className = 'click-dot';
d.style.opacity = '.2';
} else {
d.style.opacity = '1';
d.className = 'click-dot' + (i <= count ? (count >= max ? ' max' : ' active') : '');
}
}
}
function showTesterResult(label, type) {
const box = document.getElementById('result-box');
box.textContent = label;
box.className = 'result-box res-' + (type === 'long' ? 'long' : type === 'click' ? 'click' : 'error');
setTimeout(() => { box.className = 'result-box'; }, 2500);
}
// ════════════════════════════════════════════════════════════════════════════
// Simulation GPIO serveur
// ════════════════════════════════════════════════════════════════════════════
async function simulateGPIO(clicks) {
try {
await api('POST', `/api/system/button/simulate?clicks=${clicks}`);
showToast(`Simulation envoyée : ${clicks === 0 ? 'long press' : clicks + ' clic(s)'}`, 'info');
} catch(e) {
showToast('Erreur simulation : ' + e.message, 'error');
}
}
// ════════════════════════════════════════════════════════════════════════════
// WebSocket — écoute du bouton GPIO réel
// ════════════════════════════════════════════════════════════════════════════
function onWsMessage(msg) {
if (msg.type !== 'button_event') return;
const dot = document.getElementById('gpio-dot');
const label = document.getElementById('gpio-label');
dot.classList.add('live');
setTimeout(() => dot.classList.remove('live'), 1500);
let text;
if (msg.clicks === 0) {
text = 'LONG PRESS → Impression';
label.textContent = '🖨 Long press';
} else {
const clicks = msg.clicks;
text = `${clicks} CLIC${clicks > 1 ? 'S' : ''} → ${msg.action || ''}`;
label.textContent = `${clicks} clic${clicks > 1 ? 's' : ''}`;
}
addLog('gpio-log', text, 'GPIO');
}
// ════════════════════════════════════════════════════════════════════════════
// Helpers
// ════════════════════════════════════════════════════════════════════════════
function addLog(targetId, value, source) {
const log = document.getElementById(targetId);
// Vider le placeholder si présent
if (log.querySelector('[style*="opacity"]')) log.innerHTML = '';
const entry = document.createElement('div');
entry.className = 'log-entry';
const now = new Date().toLocaleTimeString('fr-FR');
entry.innerHTML = `
<span class="log-val">${value}</span>
<span class="log-src">[${source}] ${now}</span>
`;
log.insertBefore(entry, log.firstChild);
// Garder 20 lignes max
while (log.children.length > 20) log.removeChild(log.lastChild);
}
// ════════════════════════════════════════════════════════════════════════════
// Bouton Supprimer photobooth-app
// ════════════════════════════════════════════════════════════════════════════
async function setDeleteButton(visible) {
try {
const r = await api('POST', `/api/system/ui/delete-button?visible=${visible}`);
const status = document.getElementById('delete-btn-status');
if (r.ok) {
status.innerHTML = visible
? '✅ Bouton Supprimer actuellement <strong style="color:#4caf50">visible</strong>'
: '🚫 Bouton Supprimer actuellement <strong style="color:#e07b00">caché</strong>';
showToast(visible ? '✅ Bouton Supprimer activé' : '🚫 Bouton Supprimer désactivé', 'success');
} else {
showToast('❌ Erreur écriture private.css', 'error');
// Remettre le toggle dans l'état précédent
document.getElementById('toggle-delete-btn').checked = !visible;
}
} catch(e) {
showToast('Erreur : ' + e.message, 'error');
document.getElementById('toggle-delete-btn').checked = !visible;
}
}
// ════════════════════════════════════════════════════════════════════════════
// Refresh écran HDMI
// ════════════════════════════════════════════════════════════════════════════
async function refreshScreen() {
const btn = document.getElementById('btn-refresh-screen');
const status = document.getElementById('refresh-status');
btn.disabled = true;
status.textContent = 'Envoi…';
try {
const r = await api('POST', '/api/system/screen/refresh');
status.style.color = '#4caf50';
status.textContent = r.ok ? `✅ OK (${r.method})` : `⚠ ${r.error}`;
showToast('Écran rafraîchi', 'success');
} catch(e) {
status.style.color = '#e05050';
status.textContent = '❌ Erreur : ' + e.message;
showToast('Erreur refresh : ' + e.message, 'error');
} finally {
btn.disabled = false;
setTimeout(() => { status.textContent = ''; }, 4000);
}
}
// ════════════════════════════════════════════════════════════════════════════
// Flash LED
// ════════════════════════════════════════════════════════════════════════════
async function loadFlashConfig() {
try {
const effects = await api('GET', '/api/leds/effects');
const cap = effects.capture;
if (!cap) return;
document.getElementById('flash-r').value = cap.color[0] ?? 255;
document.getElementById('flash-g').value = cap.color[1] ?? 200;
document.getElementById('flash-b').value = cap.color[2] ?? 80;
document.getElementById('flash-duration').value = cap.flash_duration ?? 0.30;
document.getElementById('flash-dur-val').textContent = parseFloat(cap.flash_duration ?? 0.30).toFixed(2);
document.getElementById('flash-count').value = cap.flashes ?? 2;
updateFlashPreview();
} catch(e) { console.warn('loadFlashConfig:', e); }
}
function updateFlashPreview() {
const r = parseInt(document.getElementById('flash-r').value) || 0;
const g = parseInt(document.getElementById('flash-g').value) || 0;
const b = parseInt(document.getElementById('flash-b').value) || 0;
document.getElementById('flash-preview').style.background = `rgb(${r},${g},${b})`;
['flash-r','flash-g','flash-b'].forEach(id =>
document.getElementById(id).addEventListener('input', updateFlashPreview, {once:true})
);
}
function colorPickerToRgb(hex) {
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
document.getElementById('flash-r').value = r;
document.getElementById('flash-g').value = g;
document.getElementById('flash-b').value = b;
updateFlashPreview();
}
function setFlashPreset(r, g, b, dur, flashes) {
document.getElementById('flash-r').value = r;
document.getElementById('flash-g').value = g;
document.getElementById('flash-b').value = b;
document.getElementById('flash-duration').value = dur;
document.getElementById('flash-dur-val').textContent = dur.toFixed(2);
document.getElementById('flash-count').value = flashes;
updateFlashPreview();
}
async function previewFlash() {
await api('POST', '/api/leds/effect?effect=capture');
showToast('Flash test declenche', 'info');
}
async function saveFlash() {
const r = parseInt(document.getElementById('flash-r').value);
const g = parseInt(document.getElementById('flash-g').value);
const b = parseInt(document.getElementById('flash-b').value);
const dur = parseFloat(document.getElementById('flash-duration').value);
const flashes = parseInt(document.getElementById('flash-count').value);
try {
await api('PUT', `/api/leds/effect/capture?r=${r}&g=${g}&b=${b}&flash_duration=${dur}&flashes=${flashes}&save=true`);
showToast('Flash sauvegarde', 'success');
updateFlashPreview();
} catch(e) { showToast('Erreur : ' + e.message, 'error'); }
}
// Liaison input -> preview
['flash-r','flash-g','flash-b'].forEach(id => {
document.getElementById(id).addEventListener('input', updateFlashPreview);
});
loadFlashConfig();
// Init dots
showDots(0);
</script>
{% endblock %}