68b0a36031
modified: Opencode/scripts/script_print.sh modified: backend/services/printer_service.py modified: frontend/templates/admin/actions.html modified: frontend/templates/admin/events.html modified: frontend/templates/admin/gallery.html modified: frontend/templates/admin/settings.html modified: frontend/templates/base.html modified: frontend/templates/public/gallery.html Untracked files: photobooth-app/script/ photobooth-app/userdata/
754 lines
33 KiB
HTML
754 lines
33 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Galerie admin — JH Photomaton{% endblock %}
|
||
|
||
|
||
{% block head %}
|
||
<style>
|
||
/* ── Layout ───────────────────────────────────────────────────────────────── */
|
||
.gallery-wrap { max-width: 100%; margin: 0; padding: 1rem 1.5rem; }
|
||
|
||
/* ── Barre de filtres ─────────────────────────────────────────────────────── */
|
||
.filter-bar {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: .85rem 1.1rem;
|
||
margin-bottom: 1rem;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: .75rem;
|
||
align-items: flex-end;
|
||
}
|
||
.filter-group { display: flex; flex-direction: column; gap: .3rem; }
|
||
.filter-label { font-size: .74rem; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
|
||
.filter-chips { display: flex; gap: .35rem; flex-wrap: wrap; }
|
||
.chip {
|
||
padding: .28rem .7rem; border-radius: 20px; border: 1px solid var(--border);
|
||
background: transparent; color: var(--text-muted); cursor: pointer;
|
||
font-size: .82rem; transition: all .15s;
|
||
}
|
||
.chip.active, .chip:hover { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||
.chip.danger.active { background: #7a0000; border-color: #7a0000; }
|
||
.filter-select {
|
||
background: var(--bg); border: 1px solid var(--border); color: var(--text);
|
||
border-radius: 8px; padding: .3rem .6rem; font-size: .85rem;
|
||
}
|
||
.filter-divider { width: 1px; background: var(--border); align-self: stretch; margin: 0 .2rem; }
|
||
.filter-summary { font-size: .82rem; color: var(--text-muted); margin-left: auto; align-self: center; }
|
||
|
||
/* ── Header ───────────────────────────────────────────────────────────────── */
|
||
.gallery-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: .75rem; flex-wrap: wrap; }
|
||
.gallery-title { font-size: 1.15rem; font-weight: 700; }
|
||
.gallery-controls { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
|
||
.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; }
|
||
|
||
/* ── Grille photos ────────────────────────────────────────────────────────── */
|
||
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: .85rem; }
|
||
.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 { border-color: #e07b00; }
|
||
|
||
.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;
|
||
pointer-events: none;
|
||
}
|
||
.print-badge.printing { background: rgba(25,108,176,.9); }
|
||
.print-badge.done { background: rgba(30,160,70,.9); }
|
||
.print-badge.done-secondary {
|
||
position: absolute; top: 5px; left: 5px;
|
||
background: rgba(30,160,70,.85); color: #fff;
|
||
border-radius: 12px; padding: .15rem .45rem;
|
||
font-size: .72rem; font-weight: 700;
|
||
display: flex; align-items: center; gap: .2rem;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.date-badge {
|
||
position: absolute; bottom: 0; left: 0; right: 0;
|
||
background: linear-gradient(transparent, rgba(0,0,0,.65));
|
||
color: rgba(255,255,255,.85);
|
||
font-size: .68rem; padding: .4rem .4rem .3rem;
|
||
pointer-events: none; text-align: right;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
.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; display: inline-block; }
|
||
|
||
/* ── Pagination ───────────────────────────────────────────────────────────── */
|
||
.pagination { display: flex; align-items: center; gap: .75rem; justify-content: center; margin: .75rem 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,.92); backdrop-filter: blur(8px);
|
||
align-items: center; justify-content: center; padding: 1.5rem;
|
||
gap: 1rem;
|
||
}
|
||
.lightbox.open { display: flex; }
|
||
|
||
.lb-nav-btn {
|
||
flex-shrink: 0; width: 44px; height: 44px; border-radius: 50%;
|
||
border: 1px solid rgba(255,255,255,.2); background: rgba(255,255,255,.08);
|
||
color: #fff; font-size: 1.3rem; cursor: pointer; display: flex;
|
||
align-items: center; justify-content: center; transition: background .15s;
|
||
user-select: none;
|
||
}
|
||
.lb-nav-btn:hover:not(:disabled) { background: rgba(255,255,255,.18); }
|
||
.lb-nav-btn:disabled { opacity: .2; cursor: default; }
|
||
|
||
.lb-modal {
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
border-radius: 16px; overflow: hidden;
|
||
max-width: min(1100px, calc(95vw - 110px)); width: 100%;
|
||
display: flex; flex-direction: column; max-height: 95vh;
|
||
}
|
||
|
||
.lb-counter {
|
||
font-size: .75rem; color: var(--text-muted); white-space: nowrap;
|
||
}
|
||
|
||
.lb-header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: .75rem 1.1rem; border-bottom: 1px solid var(--border);
|
||
}
|
||
.lb-header-info { display: flex; flex-direction: column; gap: .1rem; }
|
||
.lb-date { font-weight: 600; font-size: .95rem; }
|
||
.lb-id { font-size: .72rem; color: var(--text-muted); font-family: monospace; }
|
||
.lb-close-btn {
|
||
width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,.08);
|
||
border: 1px solid var(--border); color: var(--text); cursor: pointer;
|
||
font-size: 1.1rem; display: flex; align-items: center; justify-content: center;
|
||
}
|
||
|
||
.lb-img-wrap {
|
||
flex: 1; overflow: hidden; display: flex; align-items: center; justify-content: center;
|
||
background: #111; min-height: 200px;
|
||
}
|
||
.lb-img-wrap img { max-width: 100%; max-height: 72vh; object-fit: contain; display: block; }
|
||
|
||
/* Bloc demandes impression */
|
||
.lb-print-panel {
|
||
padding: .6rem 1.1rem; border-top: 1px solid var(--border);
|
||
background: rgba(224,123,0,.06); display: none;
|
||
}
|
||
.lb-print-panel.visible { display: block; }
|
||
.lb-print-title { font-size: .82rem; font-weight: 700; color: #e07b00; margin-bottom: .4rem; }
|
||
.lb-queue-entry {
|
||
display: flex; align-items: center; gap: .75rem; padding: .25rem 0;
|
||
border-bottom: 1px solid rgba(255,255,255,.05); font-size: .82rem;
|
||
}
|
||
.lb-queue-entry:last-child { border-bottom: none; }
|
||
.s-pending { background: rgba(224,123,0,.2); color: #e07b00; padding: .15rem .45rem; border-radius: 10px; font-size: .75rem; font-weight: 700; }
|
||
.s-printing { background: rgba(25,108,176,.2); color: var(--primary); padding: .15rem .45rem; border-radius: 10px; font-size: .75rem; font-weight: 700; }
|
||
|
||
/* Actions lightbox */
|
||
.lb-actions {
|
||
padding: .85rem 1.1rem; border-top: 1px solid var(--border);
|
||
display: flex; gap: .6rem; flex-wrap: wrap; align-items: center;
|
||
}
|
||
.lb-copies-wrap { display: flex; align-items: center; gap: .4rem; font-size: .85rem; color: var(--text-muted); margin-right: auto; }
|
||
.lb-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; }
|
||
|
||
.lb-action-btn {
|
||
display: flex; flex-direction: column; align-items: center; gap: .2rem;
|
||
padding: .55rem .85rem; border: none; border-radius: 10px;
|
||
cursor: pointer; font-size: .78rem; font-weight: 600; transition: opacity .2s;
|
||
text-decoration: none; min-width: 60px;
|
||
}
|
||
.lb-action-btn:hover { opacity: .82; }
|
||
.lb-action-btn .icon { font-size: 1.3rem; line-height: 1; }
|
||
.lba-queue { background: rgba(224,123,0,.18); color: #e07b00; border: 1px solid rgba(224,123,0,.3); }
|
||
.lba-print { background: rgba(25,108,176,.2); color: var(--primary); border: 1px solid rgba(25,108,176,.3); }
|
||
.lba-dl { background: rgba(255,255,255,.07); color: var(--text); border: 1px solid var(--border); }
|
||
.lba-cancel { background: rgba(192,0,0,.15); color: #e05050; border: 1px solid rgba(192,0,0,.25); display: none; }
|
||
.lba-delete { background: rgba(80,20,20,.4); color: #e05050; border: 1px solid rgba(192,0,0,.2); }
|
||
</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>
|
||
<button class="pg-btn" onclick="resetFilters()">✕ Réinitialiser</button>
|
||
<button class="pg-btn" onclick="loadPhotos(1)">↻</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Barre de filtres ──────────────────────────────────────────────────── -->
|
||
<div class="filter-bar">
|
||
|
||
<!-- Filtre statut -->
|
||
<div class="filter-group">
|
||
<div class="filter-label">Statut</div>
|
||
<div class="filter-chips">
|
||
<button class="chip active" onclick="setStatusFilter('all', this)">Toutes</button>
|
||
<button class="chip danger" onclick="setStatusFilter('pending', this)">
|
||
🖨 À imprimer <span id="pending-count" style="font-weight:700">0</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filter-divider"></div>
|
||
|
||
<!-- Filtre date -->
|
||
<div class="filter-group">
|
||
<div class="filter-label">Date</div>
|
||
<div class="filter-chips">
|
||
<button class="chip" onclick="setDateFilter('all', this)">Toutes</button>
|
||
<button class="chip" onclick="setDateFilter('today', this)">Aujourd'hui</button>
|
||
<button class="chip" onclick="setDateFilter('week', this)">Cette semaine</button>
|
||
<button class="chip" onclick="setDateFilter('month', this)">Ce mois</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filter-divider"></div>
|
||
|
||
<!-- Filtre événement -->
|
||
<div class="filter-group">
|
||
<div class="filter-label">Événement</div>
|
||
<select class="filter-select" id="event-filter" onchange="setEventFilter(this.value)">
|
||
<option value="">Tous les événements</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="filter-summary" id="filter-summary"></div>
|
||
</div>
|
||
|
||
<!-- Pagination haut -->
|
||
<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" onclick="lbBackdropClose(event)">
|
||
<button class="lb-nav-btn" id="lb-prev" onclick="lbNavigate(-1);event.stopPropagation()">‹</button>
|
||
<div class="lb-modal">
|
||
|
||
<!-- En-tête -->
|
||
<div class="lb-header">
|
||
<div class="lb-header-info">
|
||
<div class="lb-date" id="lb-date">—</div>
|
||
<div class="lb-id" id="lb-id"></div>
|
||
</div>
|
||
<span class="lb-counter" id="lb-counter"></span>
|
||
<button class="lb-close-btn" onclick="closeLightbox()">✕</button>
|
||
</div>
|
||
|
||
<!-- Image -->
|
||
<div class="lb-img-wrap">
|
||
<img id="lb-img" src="" alt="">
|
||
</div>
|
||
|
||
<!-- Demandes impression en attente -->
|
||
<div class="lb-print-panel" id="lb-print-panel">
|
||
<div class="lb-print-title">🖨 Demandes d'impression en attente</div>
|
||
<div id="lb-queue-list"></div>
|
||
</div>
|
||
|
||
<!-- Actions -->
|
||
<div class="lb-actions">
|
||
<div class="lb-copies-wrap">
|
||
<label for="lb-copies">Copies :</label>
|
||
<input type="number" id="lb-copies" min="1" max="3" value="1">
|
||
</div>
|
||
|
||
<button class="lb-action-btn lba-queue" onclick="lbAddToQueue()">
|
||
<span class="icon">📋</span>File d'attente
|
||
</button>
|
||
<button class="lb-action-btn lba-print" onclick="lbPrintNow()">
|
||
<span class="icon">🖨</span>Imprimer
|
||
</button>
|
||
<a class="lb-action-btn lba-dl" id="lb-btn-dl" href="#">
|
||
<span class="icon">⬇</span>Télécharger
|
||
</a>
|
||
<button class="lb-action-btn lba-cancel" id="lb-btn-cancel" onclick="lbCancelAll()">
|
||
<span class="icon">✕</span>Annuler
|
||
</button>
|
||
<button class="lb-action-btn lba-delete" onclick="lbDelete()">
|
||
<span class="icon">🗑</span>Supprimer
|
||
</button>
|
||
</div>
|
||
|
||
</div>
|
||
<button class="lb-nav-btn" id="lb-next" onclick="lbNavigate(1);event.stopPropagation()">›</button>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
let currentPage = 1;
|
||
let totalPages = 1;
|
||
let allPhotos = [];
|
||
let displayedPhotos = []; // photos actuellement dans la grille (après filtre statut)
|
||
let currentIndex = -1; // index dans displayedPhotos
|
||
let currentPhotoId = null;
|
||
let currentPhotoData = null;
|
||
|
||
// Filtres actifs
|
||
let statusFilter = 'all';
|
||
let dateFilter = 'all';
|
||
let eventSlug = '';
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Filtres
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
function setStatusFilter(mode, btn) {
|
||
statusFilter = mode;
|
||
document.querySelectorAll('.filter-chips .chip').forEach(b => {
|
||
if (b.closest('.filter-group') === btn.closest('.filter-group')) b.classList.remove('active');
|
||
});
|
||
btn.classList.add('active');
|
||
loadPhotos(1);
|
||
}
|
||
|
||
function setDateFilter(mode, btn) {
|
||
dateFilter = mode;
|
||
document.querySelectorAll('.filter-chips .chip').forEach(b => {
|
||
if (b.closest('.filter-group') === btn.closest('.filter-group')) b.classList.remove('active');
|
||
});
|
||
btn.classList.add('active');
|
||
loadPhotos(1);
|
||
}
|
||
|
||
function setEventFilter(slug) {
|
||
eventSlug = slug;
|
||
loadPhotos(1);
|
||
}
|
||
|
||
function resetFilters() {
|
||
statusFilter = 'all';
|
||
dateFilter = 'all';
|
||
eventSlug = '';
|
||
document.getElementById('event-filter').value = '';
|
||
document.querySelectorAll('.chip').forEach(b => b.classList.remove('active'));
|
||
document.querySelector('.chip[onclick*="all"]').classList.add('active');
|
||
loadPhotos(1);
|
||
}
|
||
|
||
function _buildParams(page) {
|
||
const params = new URLSearchParams({ page, limit: 24 });
|
||
if (eventSlug) params.set('event_slug', eventSlug);
|
||
|
||
if (dateFilter !== 'all') {
|
||
const now = Date.now() / 1000;
|
||
const sod = new Date(); sod.setHours(0,0,0,0);
|
||
const sodTs = sod.getTime() / 1000;
|
||
if (dateFilter === 'today') {
|
||
params.set('date_from', sodTs);
|
||
params.set('date_to', now);
|
||
} else if (dateFilter === 'week') {
|
||
params.set('date_from', now - 7 * 86400);
|
||
params.set('date_to', now);
|
||
} else if (dateFilter === 'month') {
|
||
params.set('date_from', now - 30 * 86400);
|
||
params.set('date_to', now);
|
||
}
|
||
}
|
||
return params.toString();
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 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?${_buildParams(page)}`);
|
||
allPhotos = data.photos || [];
|
||
currentPage = page;
|
||
totalPages = data.pages || 1;
|
||
|
||
// Filtre statut côté client
|
||
let photos = allPhotos;
|
||
if (statusFilter === 'pending') {
|
||
photos = allPhotos.filter(p => p.print_pending > 0 || p.print_printing > 0);
|
||
}
|
||
|
||
document.getElementById('photo-count').textContent =
|
||
`${photos.length} / ${data.total} photo(s)`;
|
||
document.getElementById('pending-count').textContent =
|
||
allPhotos.filter(p => p.print_pending > 0).length;
|
||
|
||
updatePagination();
|
||
renderGrid(photos);
|
||
updateFilterSummary(data.total);
|
||
} catch(e) {
|
||
grid.innerHTML = '<div style="color:#e05050;grid-column:1/-1;text-align:center;padding:2rem">❌ Erreur de chargement</div>';
|
||
}
|
||
}
|
||
|
||
function updateFilterSummary(total) {
|
||
const parts = [];
|
||
if (dateFilter !== 'all') parts.push({ today: "Aujourd'hui", week: "Cette semaine", month: "Ce mois" }[dateFilter]);
|
||
if (eventSlug) parts.push('Événement : ' + (document.getElementById('event-filter').options[document.getElementById('event-filter').selectedIndex]?.text || eventSlug));
|
||
document.getElementById('filter-summary').textContent = parts.length ? `Filtre : ${parts.join(' · ')} — ${total} photo(s)` : '';
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Rendu grille
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
function renderGrid(photos) {
|
||
displayedPhotos = photos || [];
|
||
const grid = document.getElementById('photo-grid');
|
||
|
||
if (!displayedPhotos.length) {
|
||
grid.innerHTML = `<div style="color:var(--text-muted);grid-column:1/-1;text-align:center;padding:2rem">
|
||
📷 Aucune photo pour ces filtres</div>`;
|
||
return;
|
||
}
|
||
|
||
// On utilise l'index (pas le JSON sérialisé) pour éviter les problèmes de quotes
|
||
grid.innerHTML = displayedPhotos.map((p, idx) => {
|
||
const pid = p.photo_id || p.id || '';
|
||
const hasPending = p.print_pending > 0;
|
||
const hasPrinting = p.print_printing > 0;
|
||
const hasDone = (p.print_done || 0) > 0;
|
||
const copies = p.print_done_copies || 0;
|
||
|
||
// Badge principal (coin haut droite) — priorité : impression en cours > attente > terminé
|
||
const badge = hasPrinting
|
||
? `<div class="print-badge printing">🔵 Impression…</div>`
|
||
: hasPending
|
||
? `<div class="print-badge">🖨 ${p.print_pending} att.</div>`
|
||
: hasDone
|
||
? `<div class="print-badge done">✅ ${copies} cop.</div>`
|
||
: '';
|
||
|
||
// Si en attente ET déjà imprimé → badge "imprimé" coin haut gauche aussi
|
||
const doneBadgeLeft = (hasPending || hasPrinting) && hasDone
|
||
? `<div class="print-badge done-secondary">✅ ${copies}</div>`
|
||
: '';
|
||
|
||
const dateLabel = p.date_label
|
||
? `<div class="date-badge">${p.date_label}</div>` : '';
|
||
|
||
return `
|
||
<div class="photo-card ${hasPending || hasPrinting ? 'has-print' : ''}" id="card-${pid}"
|
||
onclick="openLightboxIdx(${idx})">
|
||
<img src="${p.thumb_url}" loading="lazy" alt="">
|
||
${badge}${doneBadgeLeft}${dateLabel}
|
||
<div class="card-overlay">
|
||
${hasPending
|
||
? `<button class="ov-btn ov-cancel" onclick="event.stopPropagation();quickCancel('${pid}')">✕</button>`
|
||
: `<button class="ov-btn ov-print" onclick="event.stopPropagation();quickQueue('${pid}')">🖨</button>`}
|
||
<a class="ov-btn ov-dl" href="${p.download_url || p.full_url}" onclick="event.stopPropagation()">⬇</a>
|
||
<button class="ov-btn ov-delete" onclick="event.stopPropagation();deletePhoto('${pid}')">🗑</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Lightbox
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
function openLightboxIdx(idx) {
|
||
if (idx < 0 || idx >= displayedPhotos.length) return;
|
||
currentIndex = idx;
|
||
const photo = displayedPhotos[idx];
|
||
currentPhotoData = photo;
|
||
currentPhotoId = photo.photo_id || photo.id || '';
|
||
|
||
document.getElementById('lb-img').src = photo.full_url || photo.thumb_url;
|
||
document.getElementById('lb-date').textContent = photo.date_label || '—';
|
||
document.getElementById('lb-id').textContent = currentPhotoId;
|
||
document.getElementById('lb-btn-dl').href = photo.download_url || photo.full_url;
|
||
document.getElementById('lb-counter').textContent = `${idx + 1} / ${displayedPhotos.length}`;
|
||
|
||
// Boutons nav
|
||
document.getElementById('lb-prev').disabled = idx <= 0;
|
||
document.getElementById('lb-next').disabled = idx >= displayedPhotos.length - 1;
|
||
|
||
_updateLbPrintPanel(photo);
|
||
document.getElementById('lightbox').classList.add('open');
|
||
}
|
||
|
||
function lbNavigate(dir) {
|
||
openLightboxIdx(currentIndex + dir);
|
||
}
|
||
|
||
function _updateLbPrintPanel(photo) {
|
||
const panel = document.getElementById('lb-print-panel');
|
||
const list = document.getElementById('lb-queue-list');
|
||
const btnCancel = document.getElementById('lb-btn-cancel');
|
||
const active = (photo.print_requests || []).filter(r => ['pending','printing'].includes(r.status));
|
||
|
||
if (active.length) {
|
||
panel.classList.add('visible');
|
||
btnCancel.style.display = 'flex';
|
||
list.innerHTML = active.map(r => `
|
||
<div class="lb-queue-entry">
|
||
<span>${r.copies} copie${r.copies > 1 ? 's' : ''}</span>
|
||
<span class="${r.status === 'printing' ? 's-printing' : 's-pending'}">
|
||
${r.status === 'printing' ? '🔵 En cours' : '⏳ En attente'}
|
||
</span>
|
||
<span style="font-size:.75rem;color:var(--text-muted);margin-left:auto">
|
||
${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 {
|
||
panel.classList.remove('visible');
|
||
btnCancel.style.display = 'none';
|
||
list.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
function closeLightbox() {
|
||
document.getElementById('lightbox').classList.remove('open');
|
||
document.getElementById('lb-img').src = '';
|
||
currentPhotoId = currentPhotoData = null;
|
||
currentIndex = -1;
|
||
}
|
||
|
||
function lbBackdropClose(e) {
|
||
if (e.target === document.getElementById('lightbox')) closeLightbox();
|
||
}
|
||
|
||
// Clavier : Échap + flèches
|
||
document.addEventListener('keydown', e => {
|
||
const lb = document.getElementById('lightbox');
|
||
if (!lb.classList.contains('open')) return;
|
||
if (e.key === 'Escape') { closeLightbox(); }
|
||
else if (e.key === 'ArrowLeft') { lbNavigate(-1); }
|
||
else if (e.key === 'ArrowRight') { lbNavigate(1); }
|
||
});
|
||
|
||
// Swipe tactile
|
||
let _touchX = 0;
|
||
document.getElementById('lightbox').addEventListener('touchstart', e => {
|
||
_touchX = e.touches[0].clientX;
|
||
}, { passive: true });
|
||
document.getElementById('lightbox').addEventListener('touchend', e => {
|
||
const dx = e.changedTouches[0].clientX - _touchX;
|
||
if (Math.abs(dx) > 50) lbNavigate(dx < 0 ? 1 : -1);
|
||
}, { passive: true });
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Actions impression
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
function getLbCopies() { return parseInt(document.getElementById('lb-copies').value) || 1; }
|
||
|
||
async function lbAddToQueue() {
|
||
const copies = getLbCopies();
|
||
try {
|
||
await api('POST', `/admin/api/gallery/print/${currentPhotoId}?copies=${copies}&immediate=false`);
|
||
showToast(`📋 Ajouté à la file (${copies} copie${copies > 1 ? 's' : ''})`, 'info');
|
||
await _refreshPhotoData(currentPhotoId);
|
||
} catch(e) { showToast('Erreur : ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function lbPrintNow() {
|
||
const copies = getLbCopies();
|
||
if (!confirm(`Imprimer ${copies} copie(s) maintenant ?`)) return;
|
||
try {
|
||
const r = await api('POST', `/admin/api/gallery/print/${currentPhotoId}?copies=${copies}&immediate=true`);
|
||
showToast(r.success ? `✅ Imprimé sur ${r.printer}` : `❌ ${r.error || 'Erreur'}`, r.success ? 'success' : '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', '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', 'error'); }
|
||
}
|
||
|
||
async function quickQueue(pid) {
|
||
try {
|
||
await api('POST', `/admin/api/gallery/print/${pid}?copies=1&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} 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) {
|
||
try {
|
||
const data = await api('GET', `/admin/api/gallery/photos?${_buildParams(currentPage)}`);
|
||
allPhotos = data.photos || [];
|
||
let photos = allPhotos;
|
||
if (statusFilter === 'pending') photos = allPhotos.filter(p => p.print_pending > 0 || p.print_printing > 0);
|
||
renderGrid(photos);
|
||
document.getElementById('pending-count').textContent = allPhotos.filter(p => p.print_pending > 0).length;
|
||
|
||
// Si lightbox ouverte sur cette photo, mettre à jour le panel impression
|
||
if (currentPhotoId === pid) {
|
||
const newIdx = displayedPhotos.findIndex(p => (p.photo_id || p.id) === pid);
|
||
if (newIdx >= 0) {
|
||
currentIndex = newIdx;
|
||
currentPhotoData = displayedPhotos[newIdx];
|
||
_updateLbPrintPanel(currentPhotoData);
|
||
document.getElementById('lb-prev').disabled = newIdx <= 0;
|
||
document.getElementById('lb-next').disabled = newIdx >= displayedPhotos.length - 1;
|
||
document.getElementById('lb-counter').textContent = `${newIdx + 1} / ${displayedPhotos.length}`;
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
|
||
function updatePagination() {
|
||
for (const s of ['', '2']) {
|
||
const c = document.getElementById(`page-cur${s}`);
|
||
const t = document.getElementById(`page-total${s}`);
|
||
const p = document.getElementById(`prev-btn${s}`);
|
||
const n = document.getElementById(`next-btn${s}`);
|
||
if (c) c.textContent = currentPage;
|
||
if (t) t.textContent = totalPages;
|
||
if (p) p.disabled = currentPage <= 1;
|
||
if (n) n.disabled = currentPage >= totalPages;
|
||
}
|
||
}
|
||
|
||
function changePage(delta) { loadPhotos(currentPage + delta); }
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Init — charge les événements pour le filtre
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
async function loadEventOptions() {
|
||
try {
|
||
const data = await api('GET', '/api/event/history');
|
||
const events = data.events || data || [];
|
||
const sel = document.getElementById('event-filter');
|
||
events.forEach(ev => {
|
||
const opt = document.createElement('option');
|
||
opt.value = ev.slug;
|
||
const date = ev.started_at ? new Date(ev.started_at * 1000).toLocaleDateString('fr-FR') : '';
|
||
opt.textContent = `${ev.name}${date ? ' — ' + date : ''}`;
|
||
sel.appendChild(opt);
|
||
});
|
||
// Ajouter aussi l'événement en cours s'il n'est pas dans l'historique
|
||
try {
|
||
const cur = await api('GET', '/api/event');
|
||
if (cur.slug && !events.find(e => e.slug === cur.slug)) {
|
||
const opt = document.createElement('option');
|
||
opt.value = cur.slug;
|
||
opt.textContent = `${cur.name} (en cours)`;
|
||
sel.insertBefore(opt, sel.children[1]);
|
||
}
|
||
} catch(e) {}
|
||
} catch(e) { console.warn('Impossible de charger les événements:', e); }
|
||
}
|
||
|
||
// ── 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?.success;
|
||
showToast(ok ? `✅ Impression OK — ${msg.result.printer}` : `❌ ${msg.result?.error || 'Erreur'}`, ok ? 'success' : 'error');
|
||
if (msg.photo_id) _refreshPhotoData(msg.photo_id);
|
||
}
|
||
}
|
||
|
||
// Auto-refresh badges toutes les 30s
|
||
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 n = status[pid] || 0;
|
||
if (p.print_pending !== n) { p.print_pending = n; changed = true; }
|
||
});
|
||
if (changed) {
|
||
let photos = allPhotos;
|
||
if (statusFilter === 'pending') photos = allPhotos.filter(p => p.print_pending > 0 || p.print_printing > 0);
|
||
renderGrid(photos);
|
||
document.getElementById('pending-count').textContent = allPhotos.filter(p => p.print_pending > 0).length;
|
||
}
|
||
} catch(e) {}
|
||
}, 30000);
|
||
|
||
loadEventOptions();
|
||
loadPhotos(1);
|
||
</script>
|
||
{% endblock %}
|