Files
photoBooth/frontend/templates/admin/gallery.html
T
admin 9a006f1457
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
all
2026-07-17 00:44:22 +02:00

518 lines
22 KiB
HTML

{% extends "base.html" %}
{% block title %}Galerie admin — JH Photomaton{% endblock %}
{% block nav_links %}
<a href="/admin">Dashboard</a>
<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="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="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 -->
<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 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 ─────────────────────────────────────────────────────────────── -->
<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="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 %}
{% block scripts %}
<script>
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`);
allPhotos = data.photos || [];
currentPage = page;
totalPages = data.pages || 1;
updatePagination();
document.getElementById('photo-count').textContent = `${data.total} photo(s)`;
// Compteur de demandes en attente
const pendingTotal = allPhotos.filter(p => p.print_pending > 0).length;
document.getElementById('pending-count').textContent = pendingTotal;
renderGrid();
} catch(e) {
grid.innerHTML = '<div style="color:#e05050;grid-column:1/-1;text-align:center;padding:2rem">❌ Erreur de chargement</div>';
}
}
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 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);
document.getElementById('lightbox').classList.add('open');
}
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 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/${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');
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 ?')) return;
try {
const r = await api('DELETE', `/admin/api/gallery/${pid}`);
if (r.ok) { showToast('🗑 Supprimée', 'success'); loadPhotos(currentPage); }
} catch(e) { showToast('Erreur', '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 %}