all
This commit is contained in:
@@ -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 %}
|
||||
|
||||
@@ -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()">×</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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">⚡ 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 à dominante bleue sur les photos.<br>
|
||||
Ré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é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) — 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()">💡 Tester le flash</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="saveFlash()">💾 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 ★</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 %}
|
||||
Reference in New Issue
Block a user