feat: admin gallery - lightbox with icons, filters by date/event/status; fix: event badges - only current slug shows En cours; fix: archive_all_open on new event; fix: mtime_index uses stem for event photo filter
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled

This commit is contained in:
2026-07-17 15:59:51 +02:00
parent 223d71883e
commit c89924ea18
6 changed files with 508 additions and 288 deletions
+38 -3
View File
@@ -1,6 +1,7 @@
"""API galerie admin — impression et suppression de photos.""" """API galerie admin — impression et suppression de photos."""
import logging import logging
from datetime import datetime
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Request, Query from fastapi import APIRouter, Request, Query
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -46,6 +47,9 @@ async def admin_get_photos(
request: Request, request: Request,
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
limit: int = Query(default=24, ge=1, le=100), limit: int = Query(default=24, ge=1, le=100),
event_slug: str = Query(default=None),
date_from: float = Query(default=None),
date_to: float = Query(default=None),
): ):
"""Liste des photos pour la galerie admin, annotées avec leurs demandes d'impression.""" """Liste des photos pour la galerie admin, annotées avec leurs demandes d'impression."""
if not _require_auth(request): if not _require_auth(request):
@@ -53,23 +57,54 @@ async def admin_get_photos(
pb = request.app.state.photobooth_service pb = request.app.state.photobooth_service
printer_svc = request.app.state.printer_service printer_svc = request.app.state.printer_service
cfg = request.app.state.config
event_svc = getattr(request.app.state, "event_service", None)
media_dir = Path(cfg.photobooth.media_dir)
# Récupère toutes les photos # Récupère toutes les photos
all_photos = await pb.get_media_collection(limit=500) all_photos = await pb.get_media_collection(limit=500)
photos = [p for p in all_photos if _is_image(p)] photos = [p for p in all_photos if _is_image(p)]
# Enrichit chaque photo avec mtime depuis le disque
mtime_index: dict[str, float] = {}
if media_dir.exists():
for f in media_dir.iterdir():
mtime_index[f.stem] = f.stat().st_mtime
for p in photos:
pid = _get_id(p)
p["photo_id"] = pid
mt = mtime_index.get(pid, 0.0)
p["mtime"] = mt
p["date_iso"] = datetime.fromtimestamp(mt).strftime("%Y-%m-%d") if mt else ""
p["date_label"] = datetime.fromtimestamp(mt).strftime("%d/%m/%Y %H:%M") if mt else ""
# Filtre par événement
if event_slug and event_svc:
ev = await event_svc.get_event_by_slug(event_slug)
if ev:
t0 = ev["started_at"] or 0.0
t1 = ev["ended_at"] or datetime.now().timestamp()
photos = [p for p in photos if t0 <= p["mtime"] <= t1]
# Filtre par plage de dates
if date_from is not None:
photos = [p for p in photos if p["mtime"] >= date_from]
if date_to is not None:
photos = [p for p in photos if p["mtime"] <= date_to]
total = len(photos) total = len(photos)
start = (page - 1) * limit start = (page - 1) * limit
page_photos = photos[start:start + limit] page_photos = photos[start:start + limit]
# Construit les URLs # Construit les URLs
for p in page_photos: for p in page_photos:
pid = _get_id(p) pid = p["photo_id"]
p["photo_id"] = pid
p["full_url"] = pb.media_url(pid) p["full_url"] = pb.media_url(pid)
p["thumb_url"] = pb.thumbnail_url(pid) p["thumb_url"] = pb.thumbnail_url(pid)
p["download_url"] = f"/api/gallery/download/{pid}"
# Croise avec la file d'impression en attente (une seule requête SQLite) # Croise avec la file d'impression en attente
try: try:
pending_map = await printer_svc.get_pending_by_photo_id() pending_map = await printer_svc.get_pending_by_photo_id()
for p in page_photos: for p in page_photos:
+3 -3
View File
@@ -63,9 +63,9 @@ async def update_event(request: Request, payload: EventUpdate):
old_slug = cfg.event.slug old_slug = cfg.event.slug
if payload.new_event and old_slug and old_slug != new_slug: if payload.new_event:
# Archive l'ancien événement # Archive TOUS les événements encore ouverts (ended_at IS NULL)
await event_svc.archive_event(old_slug) await event_svc.archive_all_open()
# Crée ou met à jour la ligne dans la DB # Crée ou met à jour la ligne dans la DB
await event_svc.ensure_event(new_slug, payload.name, now if payload.new_event else cfg.event.started_at or now) await event_svc.ensure_event(new_slug, payload.name, now if payload.new_event else cfg.event.started_at or now)
+17 -10
View File
@@ -44,25 +44,32 @@ async def api_gallery_photos(
# Filtre par événement si demandé # Filtre par événement si demandé
if event_slug: if event_slug:
from pathlib import Path
from datetime import datetime
event_svc = getattr(request.app.state, "event_service", None) event_svc = getattr(request.app.state, "event_service", None)
if event_svc: if event_svc:
ev = await event_svc.get_event_by_slug(event_slug) ev = await event_svc.get_event_by_slug(event_slug)
if ev: if ev:
started_at = ev["started_at"] or 0.0 started_at = ev["started_at"] or 0.0
ended_at = ev["ended_at"] or datetime.now().timestamp() ended_at = ev["ended_at"] or datetime.now().timestamp()
media_dir = Path(cfg.photobooth.media_dir) # Construire un index mtime par stem ET par nom complet
# Construire un index mtime par nom de fichier
mtime_index: dict[str, float] = {} mtime_index: dict[str, float] = {}
if media_dir.exists(): for media_dir_candidate in [
for f in media_dir.iterdir(): Path(cfg.photobooth.media_dir),
mtime_index[f.name] = f.stat().st_mtime Path("/home/pi/photobooth-data/media/processed_full"),
# Filtrer les photos par mtime Path("/home/pi/photobooth-data/media"),
]:
if media_dir_candidate.exists():
for f in media_dir_candidate.iterdir():
if f.is_file():
mt = f.stat().st_mtime
mtime_index[f.name] = mt # uuid.jpg
mtime_index[f.stem] = mt # uuid
break
def _in_event(p: dict) -> bool: def _in_event(p: dict) -> bool:
pid = _get_id(p) pid = _get_id(p)
mtime = mtime_index.get(pid, mtime_index.get(pid + ".jpg", 0.0)) stem = pid.rsplit(".", 1)[0] if "." in pid else pid
return started_at <= mtime <= ended_at mt = mtime_index.get(pid) or mtime_index.get(stem) or 0.0
return mt > 0 and started_at <= mt <= ended_at
photos = [p for p in photos if _in_event(p)] photos = [p for p in photos if _in_event(p)]
total = len(photos) total = len(photos)
+8
View File
@@ -80,6 +80,14 @@ class EventService:
) )
await self._db.commit() await self._db.commit()
async def archive_all_open(self):
"""Marque TOUS les événements ouverts comme terminés."""
await self._db.execute(
"UPDATE events SET ended_at = ? WHERE ended_at IS NULL",
(datetime.now().timestamp(),),
)
await self._db.commit()
async def increment(self, slug: str, counter: str): async def increment(self, slug: str, counter: str):
"""Incrémente un compteur de l'événement identifié par son slug. """Incrémente un compteur de l'événement identifié par son slug.
+13 -5
View File
@@ -142,8 +142,13 @@ async function loadCurrentEvent() {
} }
} }
let _currentSlug = '';
async function loadHistory() { async function loadHistory() {
try { try {
// Récupère d'abord le slug courant pour l'affichage correct des badges
try { const cur = await api('GET', '/event'); _currentSlug = cur.slug || ''; } catch(e) {}
const d = await api('GET', '/event/history'); const d = await api('GET', '/event/history');
document.getElementById('history-loading').style.display = 'none'; document.getElementById('history-loading').style.display = 'none';
const list = document.getElementById('history-list'); const list = document.getElementById('history-list');
@@ -154,13 +159,16 @@ async function loadHistory() {
return; return;
} }
list.innerHTML = d.events.map(ev => ` list.innerHTML = d.events.map(ev => {
// "En cours" uniquement si c'est le slug actif ET sans ended_at
const isActive = !ev.ended_at && ev.slug === _currentSlug;
return `
<div class="event-row"> <div class="event-row">
<div> <div>
<div class="event-name"> <div class="event-name">
${ev.name} ${ev.name}
<span class="badge ${ev.ended_at ? 'badge-done' : 'badge-active'}"> <span class="badge ${isActive ? 'badge-active' : 'badge-done'}">
${ev.ended_at ? 'Terminé' : 'En cours'} ${isActive ? 'En cours' : (ev.ended_at ? 'Terminé' : 'Archivé')}
</span> </span>
</div> </div>
<div class="event-meta"> <div class="event-meta">
@@ -179,8 +187,8 @@ async function loadHistory() {
<a href="/api/event/${ev.slug}/export" class="btn btn-primary btn-sm" download> <a href="/api/event/${ev.slug}/export" class="btn btn-primary btn-sm" download>
⬇️ ZIP ⬇️ ZIP
</a> </a>
</div> </div>`;
`).join(''); }).join('');
} catch(e) { } catch(e) {
document.getElementById('history-loading').textContent = 'Erreur chargement'; document.getElementById('history-loading').textContent = 'Erreur chargement';
} }
+406 -244
View File
@@ -13,68 +13,90 @@
{% block head %} {% block head %}
<style> <style>
/* ── Photo grid ───────────────────────────────────────────────────────────── */ /* ── Layout ───────────────────────────────────────────────────────────────── */
.gallery-wrap { max-width: 1300px; margin: 0 auto; padding: 1rem; } .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; }
/* ── 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(160px, 1fr)); gap: .75rem; } .photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: .75rem; }
.photo-card { .photo-card {
position: relative; position: relative; aspect-ratio: 3/2; border-radius: 8px; overflow: hidden;
aspect-ratio: 3/2; cursor: pointer; background: var(--surface); border: 2px solid transparent;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: var(--surface);
border: 2px solid transparent;
transition: border-color .2s, transform .15s; transition: border-color .2s, transform .15s;
} }
.photo-card:hover { border-color: var(--primary); transform: scale(1.02); } .photo-card:hover { border-color: var(--primary); transform: scale(1.02); }
.photo-card.has-print-request { border-color: #e07b00; } .photo-card.has-print { border-color: #e07b00; }
.photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Badge demande d'impression */
.print-badge { .print-badge {
position: absolute; position: absolute; top: 5px; right: 5px;
top: 5px; right: 5px; background: rgba(224,123,0,.9); color: #fff;
background: rgba(224,123,0,.9); border-radius: 12px; padding: .15rem .45rem;
color: #fff; font-size: .72rem; font-weight: 700;
border-radius: 12px;
padding: .15rem .45rem;
font-size: .72rem;
font-weight: 700;
display: flex; align-items: center; gap: .25rem; display: flex; align-items: center; gap: .25rem;
backdrop-filter: blur(4px);
pointer-events: none; pointer-events: none;
} }
.print-badge.printing { background: rgba(25,108,176,.9); } .print-badge.printing { background: rgba(25,108,176,.9); }
/* Overlay actions au hover */ .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 { .card-overlay {
position: absolute; inset: 0; position: absolute; inset: 0; background: rgba(0,0,0,.55);
background: rgba(0,0,0,.55);
display: flex; align-items: flex-end; justify-content: center; display: flex; align-items: flex-end; justify-content: center;
gap: .4rem; padding: .5rem; gap: .4rem; padding: .5rem; opacity: 0; transition: opacity .2s;
opacity: 0; transition: opacity .2s;
} }
.photo-card:hover .card-overlay { opacity: 1; } .photo-card:hover .card-overlay { opacity: 1; }
.ov-btn { .ov-btn {
padding: .3rem .5rem; border: none; border-radius: 6px; padding: .3rem .5rem; border: none; border-radius: 6px;
cursor: pointer; font-size: .8rem; font-weight: 700; cursor: pointer; font-size: .8rem; font-weight: 700;
backdrop-filter: blur(4px);
} }
.ov-print { background: rgba(224,123,0,.85); color: #fff; } .ov-print { background: rgba(224,123,0,.85); color: #fff; }
.ov-cancel { background: rgba(192,0,0,.75); color: #fff; } .ov-cancel { background: rgba(192,0,0,.75); color: #fff; }
.ov-delete { background: rgba(80,80,80,.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; } .ov-dl { background: rgba(40,40,40,.75); color: #ccc; text-decoration: none; display: inline-block; }
/* Pagination */ /* ── Pagination ───────────────────────────────────────────────────────────── */
.pagination { display: flex; align-items: center; gap: .75rem; justify-content: center; margin: 1rem 0; } .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 { 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-btn:disabled { opacity: .35; cursor: default; }
.pg-info { color: var(--text-muted); font-size: .9rem; } .pg-info { color: var(--text-muted); font-size: .9rem; }
@@ -82,45 +104,73 @@
/* ── Lightbox ─────────────────────────────────────────────────────────────── */ /* ── Lightbox ─────────────────────────────────────────────────────────────── */
.lightbox { .lightbox {
display: none; position: fixed; inset: 0; z-index: 1000; display: none; position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,.88); backdrop-filter: blur(6px); background: rgba(0,0,0,.92); backdrop-filter: blur(8px);
flex-direction: column; align-items: center; justify-content: center; align-items: center; justify-content: center; padding: 1.5rem;
gap: 1rem; padding: 1.5rem;
} }
.lightbox.open { display: flex; } .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-modal {
.lb-id { font-size: .78rem; color: var(--text-muted); font-family: monospace; } background: var(--surface); border: 1px solid var(--border);
border-radius: 16px; overflow: hidden;
/* Bloc demandes en attente dans le lightbox */ max-width: min(900px, 95vw); width: 100%;
.lb-print-status { display: flex; flex-direction: column; max-height: 95vh;
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-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: 55vh; 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 { .lb-queue-entry {
display: flex; justify-content: space-between; align-items: center; display: flex; align-items: center; gap: .75rem; padding: .25rem 0;
padding: .3rem 0; border-bottom: 1px solid rgba(255,255,255,.06); border-bottom: 1px solid rgba(255,255,255,.05); font-size: .82rem;
font-size: .82rem; gap: .75rem;
} }
.lb-queue-status { padding: .15rem .5rem; border-radius: 10px; font-size: .75rem; font-weight: 700; } .lb-queue-entry:last-child { border-bottom: none; }
.s-pending { background: rgba(224,123,0,.2); color: #e07b00; } .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); } .s-printing { background: rgba(25,108,176,.2); color: var(--primary); padding: .15rem .45rem; border-radius: 10px; font-size: .75rem; font-weight: 700; }
.lb-actions { display: flex; gap: .6rem; flex-wrap: wrap; justify-content: center; } /* Actions lightbox */
.lb-btn { padding: .5rem 1.1rem; border: none; border-radius: 8px; cursor: pointer; font-size: .9rem; font-weight: 600; transition: opacity .2s; } .lb-actions {
.lb-btn:hover { opacity: .82; } padding: .85rem 1.1rem; border-top: 1px solid var(--border);
.lb-btn-print { background: var(--primary); color: #fff; } display: flex; gap: .6rem; flex-wrap: wrap; align-items: center;
.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-copies-wrap { display: flex; align-items: center; gap: .4rem; font-size: .85rem; color: var(--text-muted); margin-right: auto; }
.lb-btn-delete { background: #3d1f1f; color: #e05050; border: 1px solid rgba(192,0,0,.2); } .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-btn-dl { background: rgba(255,255,255,.08); color: var(--text); text-decoration: none; display: inline-flex; align-items: center; }
/* copies input */ .lb-action-btn {
.copies-wrap { display: flex; align-items: center; gap: .4rem; font-size: .85rem; color: var(--text-muted); } display: flex; flex-direction: column; align-items: center; gap: .2rem;
.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; } 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> </style>
{% endblock %} {% endblock %}
@@ -131,23 +181,52 @@
<div class="gallery-title">🖼 Galerie — Administration</div> <div class="gallery-title">🖼 Galerie — Administration</div>
<div class="gallery-controls"> <div class="gallery-controls">
<span class="text-sm text-muted" id="photo-count">Chargement…</span> <span class="text-sm text-muted" id="photo-count">Chargement…</span>
<div class="copies-wrap"> <button class="pg-btn" onclick="resetFilters()">✕ Réinitialiser</button>
<label for="copies-input">Copies :</label> <button class="pg-btn" onclick="loadPhotos(1)"></button>
<input type="number" id="copies-input" min="1" max="3" value="1">
</div>
<button class="pg-btn" onclick="refreshPhotos()"></button>
</div> </div>
</div> </div>
<!-- Filtres --> <!-- ── Barre de filtres ──────────────────────────────────────────────────── -->
<div class="gallery-filters"> <div class="filter-bar">
<button class="filter-btn active" onclick="setFilter('all', this)">Toutes</button>
<button class="filter-btn" onclick="setFilter('pending', this)"> <!-- Filtre statut -->
🖨 À imprimer <span class="filter-badge" id="pending-count">0</span> <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> </button>
</div> </div>
</div>
<!-- Pagination --> <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"> <div class="pagination">
<button class="pg-btn" id="prev-btn" onclick="changePage(-1)" disabled>← Préc.</button> <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> <span class="pg-info">Page <span id="page-cur">1</span> / <span id="page-total">1</span></span>
@@ -167,38 +246,54 @@
</div> </div>
<!-- ── Lightbox ─────────────────────────────────────────────────────────────── --> <!-- ── Lightbox ─────────────────────────────────────────────────────────────── -->
<div class="lightbox" id="lightbox"> <div class="lightbox" id="lightbox" onclick="lbBackdropClose(event)">
<div class="lb-modal">
<div class="lightbox-img-wrap"> <!-- En-tête -->
<img id="lb-img" src="" alt=""> <div class="lb-header">
<button class="lb-close" onclick="closeLightbox()"></button> <div class="lb-header-info">
</div> <div class="lb-date" id="lb-date"></div>
<div class="lb-info">
<div class="lb-id" id="lb-id"></div> <div class="lb-id" id="lb-id"></div>
</div> </div>
<button class="lb-close-btn" onclick="closeLightbox()"></button>
</div>
<!-- Bloc demandes d'impression en attente --> <!-- Image -->
<div class="lb-print-status" id="lb-print-status" style="display:none"> <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 class="lb-print-title">🖨 Demandes d'impression en attente</div>
<div id="lb-queue-list"></div> <div id="lb-queue-list"></div>
</div> </div>
<!-- Boutons d'action --> <!-- Actions -->
<div class="lb-actions"> <div class="lb-actions">
<button class="lb-btn lb-btn-print" id="lb-btn-queue" onclick="lbAddToQueue()"> <div class="lb-copies-wrap">
📋 Ajouter à la file <label for="lb-copies">Copies :</label>
</button> <input type="number" id="lb-copies" min="1" max="3" value="1">
<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>
<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>
</div> </div>
{% endblock %} {% endblock %}
@@ -206,10 +301,72 @@
<script> <script>
let currentPage = 1; let currentPage = 1;
let totalPages = 1; let totalPages = 1;
let allPhotos = [];
let currentPhotoId = null; let currentPhotoId = null;
let currentPhotoData = null; let currentPhotoData = null;
let allPhotos = [];
let filterMode = 'all'; // 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 // Chargement photos
@@ -219,158 +376,154 @@ async function loadPhotos(page = 1) {
grid.innerHTML = '<div style="color:var(--text-muted);grid-column:1/-1;text-align:center;padding:2rem">⏳ Chargement…</div>'; grid.innerHTML = '<div style="color:var(--text-muted);grid-column:1/-1;text-align:center;padding:2rem">⏳ Chargement…</div>';
try { try {
const data = await api('GET', `/admin/api/gallery/photos?page=${page}&limit=24`); const data = await api('GET', `/admin/api/gallery/photos?${_buildParams(page)}`);
allPhotos = data.photos || []; allPhotos = data.photos || [];
currentPage = page; currentPage = page;
totalPages = data.pages || 1; 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(); updatePagination();
document.getElementById('photo-count').textContent = `${data.total} photo(s)`; renderGrid(photos);
updateFilterSummary(data.total);
// Compteur de demandes en attente
const pendingTotal = allPhotos.filter(p => p.print_pending > 0).length;
document.getElementById('pending-count').textContent = pendingTotal;
renderGrid();
} catch(e) { } catch(e) {
grid.innerHTML = '<div style="color:#e05050;grid-column:1/-1;text-align:center;padding:2rem">❌ Erreur de chargement</div>'; grid.innerHTML = '<div style="color:#e05050;grid-column:1/-1;text-align:center;padding:2rem">❌ Erreur de chargement</div>';
} }
} }
function renderGrid() { function updateFilterSummary(total) {
const grid = document.getElementById('photo-grid'); const parts = [];
let photos = allPhotos; 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));
if (filterMode === 'pending') { document.getElementById('filter-summary').textContent = parts.length ? `Filtre : ${parts.join(' · ')} — ${total} photo(s)` : '';
photos = allPhotos.filter(p => p.print_pending > 0 || p.print_printing > 0);
} }
if (!photos.length) { // ════════════════════════════════════════════════════════════════════════════
grid.innerHTML = filterMode === 'pending' // Rendu grille
? '<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>'; function renderGrid(photos) {
const grid = document.getElementById('photo-grid');
if (!photos || !photos.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; return;
} }
grid.innerHTML = photos.map(p => { grid.innerHTML = photos.map(p => {
const pid = p.photo_id || p.id || p.filename || ''; const pid = p.photo_id || p.id || '';
const hasPending = p.print_pending > 0; const hasPending = p.print_pending > 0;
const hasPrinting = p.print_printing > 0; const hasPrinting = p.print_printing > 0;
const badge = hasPrinting
let badge = ''; ? `<div class="print-badge printing">🔵 Impression…</div>`
if (hasPrinting) badge = `<div class="print-badge printing">🔵 Impression…</div>`; : hasPending
else if (hasPending) badge = `<div class="print-badge">🖨 ${p.print_pending} en attente</div>`; ? `<div class="print-badge">🖨 ${p.print_pending}</div>`
: '';
const dateLabel = p.date_label
? `<div class="date-badge">${p.date_label}</div>` : '';
return ` return `
<div class="photo-card ${hasPending || hasPrinting ? 'has-print-request' : ''}" <div class="photo-card ${hasPending || hasPrinting ? 'has-print' : ''}" id="card-${pid}"
id="card-${pid}" onclick="openLightbox(${JSON.stringify(p).replace(/'/g, '&#39;')})">
onclick="openLightbox(${JSON.stringify(p)})">
<img src="${p.thumb_url}" loading="lazy" alt=""> <img src="${p.thumb_url}" loading="lazy" alt="">
${badge} ${badge}${dateLabel}
<div class="card-overlay"> <div class="card-overlay">
${hasPending ${hasPending
? `<button class="ov-btn ov-cancel" onclick="event.stopPropagation();quickCancel('${pid}')"> Annuler</button>` ? `<button class="ov-btn ov-cancel" onclick="event.stopPropagation();quickCancel('${pid}')"></button>`
: `<button class="ov-btn ov-print" onclick="event.stopPropagation();quickQueue('${pid}')">🖨 File</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> <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>
</div> </div>`;
`;
}).join(''); }).join('');
} }
function setFilter(mode, btn) {
filterMode = mode;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderGrid();
}
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// Lightbox // Lightbox
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
function openLightbox(photo) { function openLightbox(photo) {
currentPhotoData = photo; currentPhotoData = photo;
currentPhotoId = photo.photo_id || photo.id || photo.filename || ''; currentPhotoId = photo.photo_id || photo.id || '';
document.getElementById('lb-img').src = photo.full_url || photo.thumb_url; 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-id').textContent = currentPhotoId;
document.getElementById('lb-btn-dl').href = photo.full_url; document.getElementById('lb-btn-dl').href = photo.download_url || photo.full_url;
// Mise à jour du bloc demandes
updateLightboxPrintStatus(photo);
_updateLbPrintPanel(photo);
document.getElementById('lightbox').classList.add('open'); document.getElementById('lightbox').classList.add('open');
} }
function updateLightboxPrintStatus(photo) { function _updateLbPrintPanel(photo) {
const statusBlock = document.getElementById('lb-print-status'); const panel = document.getElementById('lb-print-panel');
const queueList = document.getElementById('lb-queue-list'); const list = document.getElementById('lb-queue-list');
const btnCancelAll = document.getElementById('lb-btn-cancel-all'); const btnCancel = document.getElementById('lb-btn-cancel');
const active = (photo.print_requests || []).filter(r => ['pending','printing'].includes(r.status));
const requests = photo.print_requests || []; if (active.length) {
const activeRequests = requests.filter(r => r.status === 'pending' || r.status === 'printing'); panel.classList.add('visible');
btnCancel.style.display = 'flex';
if (activeRequests.length) { list.innerHTML = active.map(r => `
statusBlock.style.display = 'block';
btnCancelAll.style.display = 'inline-flex';
queueList.innerHTML = activeRequests.map(r => `
<div class="lb-queue-entry"> <div class="lb-queue-entry">
<span>${r.copies} copie${r.copies > 1 ? 's' : ''}</span> <span>${r.copies} copie${r.copies > 1 ? 's' : ''}</span>
<span class="lb-queue-status ${r.status === 'printing' ? 's-printing' : 's-pending'}"> <span class="${r.status === 'printing' ? 's-printing' : 's-pending'}">
${r.status === 'printing' ? '🔵 En cours' : '⏳ En attente'} ${r.status === 'printing' ? '🔵 En cours' : '⏳ En attente'}
</span> </span>
<span style="font-size:.75rem;color:var(--text-muted)">${new Date(r.requested_at * 1000).toLocaleTimeString('fr-FR')}</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' ${r.status === 'pending'
? `<button class="ov-btn ov-cancel" style="padding:.2rem .5rem;font-size:.75rem" ? `<button class="ov-btn ov-cancel" style="padding:.2rem .5rem;font-size:.75rem" onclick="cancelOneEntry('${r.id}')"></button>`
onclick="cancelOneEntry('${r.id}')">✕</button>`
: ''} : ''}
</div> </div>`).join('');
`).join('');
} else { } else {
statusBlock.style.display = 'none'; panel.classList.remove('visible');
btnCancelAll.style.display = 'none'; btnCancel.style.display = 'none';
queueList.innerHTML = ''; list.innerHTML = '';
} }
} }
function closeLightbox() { function closeLightbox() {
document.getElementById('lightbox').classList.remove('open'); document.getElementById('lightbox').classList.remove('open');
currentPhotoId = null; document.getElementById('lb-img').src = '';
currentPhotoData = null; currentPhotoId = currentPhotoData = null;
} }
document.getElementById('lightbox').addEventListener('click', e => { function lbBackdropClose(e) {
if (e.target === document.getElementById('lightbox')) closeLightbox(); if (e.target === document.getElementById('lightbox')) closeLightbox();
}); }
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); });
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// Actions impression // Actions impression
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
function getCopies() { function getLbCopies() { return parseInt(document.getElementById('lb-copies').value) || 1; }
return parseInt(document.getElementById('copies-input').value) || 1;
}
async function lbAddToQueue() { async function lbAddToQueue() {
const copies = getCopies(); const copies = getLbCopies();
try { try {
const r = await api('POST', `/admin/api/gallery/print/${currentPhotoId}?copies=${copies}&immediate=false`); await api('POST', `/admin/api/gallery/print/${currentPhotoId}?copies=${copies}&immediate=false`);
showToast(`📋 Ajouté à la file (${copies} copie${copies > 1 ? 's' : ''})`, 'info'); showToast(`📋 Ajouté à la file (${copies} copie${copies > 1 ? 's' : ''})`, 'info');
// Mettre à jour la photo locale et le lightbox await _refreshPhotoData(currentPhotoId);
await refreshPhotoData(currentPhotoId);
} catch(e) { showToast('Erreur : ' + e.message, 'error'); } } catch(e) { showToast('Erreur : ' + e.message, 'error'); }
} }
async function lbPrintNow() { async function lbPrintNow() {
const copies = getCopies(); const copies = getLbCopies();
if (!confirm(`Imprimer ${copies} copie(s) immédiatement ?`)) return; if (!confirm(`Imprimer ${copies} copie(s) maintenant ?`)) return;
try { try {
const r = await api('POST', `/admin/api/gallery/print/${currentPhotoId}?copies=${copies}&immediate=true`); const r = await api('POST', `/admin/api/gallery/print/${currentPhotoId}?copies=${copies}&immediate=true`);
if (r.success) showToast(`✅ Imprimé sur ${r.printer}`, 'success'); showToast(r.success ? `✅ Imprimé sur ${r.printer}` : `❌ ${r.error || 'Erreur'}`, r.success ? 'success' : 'error');
else showToast('❌ ' + (r.error || 'Erreur'), 'error'); await _refreshPhotoData(currentPhotoId);
await refreshPhotoData(currentPhotoId);
} catch(e) { showToast('Erreur : ' + e.message, 'error'); } } catch(e) { showToast('Erreur : ' + e.message, 'error'); }
} }
@@ -378,15 +531,15 @@ async function lbCancelAll() {
try { try {
const r = await api('DELETE', `/admin/api/gallery/print/${currentPhotoId}`); const r = await api('DELETE', `/admin/api/gallery/print/${currentPhotoId}`);
showToast(`✕ ${r.cancelled} demande(s) annulée(s)`, 'info'); showToast(`✕ ${r.cancelled} demande(s) annulée(s)`, 'info');
await refreshPhotoData(currentPhotoId); await _refreshPhotoData(currentPhotoId);
} catch(e) { showToast('Erreur : ' + e.message, 'error'); } } catch(e) { showToast('Erreur', 'error'); }
} }
async function cancelOneEntry(entryId) { async function cancelOneEntry(entryId) {
try { try {
await api('POST', `/api/print/cancel/${entryId}`); await api('POST', `/api/print/cancel/${entryId}`);
showToast('Demande annulée', 'info'); showToast('Demande annulée', 'info');
await refreshPhotoData(currentPhotoId); await _refreshPhotoData(currentPhotoId);
} catch(e) { showToast('Erreur', 'error'); } } catch(e) { showToast('Erreur', 'error'); }
} }
@@ -399,24 +552,22 @@ async function lbDelete() {
closeLightbox(); closeLightbox();
loadPhotos(currentPage); loadPhotos(currentPage);
} else showToast('❌ Erreur suppression', 'error'); } else showToast('❌ Erreur suppression', 'error');
} catch(e) { showToast('Erreur : ' + e.message, 'error'); } } catch(e) { showToast('Erreur', 'error'); }
} }
// Actions depuis la grille (sans ouvrir le lightbox)
async function quickQueue(pid) { async function quickQueue(pid) {
const copies = getCopies();
try { try {
await api('POST', `/admin/api/gallery/print/${pid}?copies=${copies}&immediate=false`); await api('POST', `/admin/api/gallery/print/${pid}?copies=1&immediate=false`);
showToast('📋 Ajouté à la file', 'info'); showToast('📋 Ajouté à la file', 'info');
await refreshPhotoData(pid); await _refreshPhotoData(pid);
} catch(e) { showToast('Erreur', 'error'); } } catch(e) { showToast('Erreur', 'error'); }
} }
async function quickCancel(pid) { async function quickCancel(pid) {
try { try {
const r = await api('DELETE', `/admin/api/gallery/print/${pid}`); const r = await api('DELETE', `/admin/api/gallery/print/${pid}`);
showToast(`✕ ${r.cancelled} demande(s) annulée(s)`, 'info'); showToast(`✕ ${r.cancelled} annulée(s)`, 'info');
await refreshPhotoData(pid); await _refreshPhotoData(pid);
} catch(e) { showToast('Erreur', 'error'); } } catch(e) { showToast('Erreur', 'error'); }
} }
@@ -431,88 +582,99 @@ async function deletePhoto(pid) {
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// Helpers // Helpers
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
async function refreshPhotoData(pid) { async function _refreshPhotoData(pid) {
// Re-fetch le statut des demandes pour cette photo et met à jour l'UI
try { try {
const status = await api('GET', '/admin/api/gallery/print-status'); const data = await api('GET', `/admin/api/gallery/photos?${_buildParams(currentPage)}`);
const count = status[pid] || 0; allPhotos = data.photos || [];
const photo = allPhotos.find(p => (p.photo_id || p.id) === pid);
// Met à jour dans allPhotos if (photo && currentPhotoId === pid) {
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; currentPhotoData = photo;
updateLightboxPrintStatus(photo); _updateLbPrintPanel(photo);
} }
} let photos = allPhotos;
if (statusFilter === 'pending') photos = allPhotos.filter(p => p.print_pending > 0 || p.print_printing > 0);
// Re-render la grille (met à jour le badge) renderGrid(photos);
renderGrid(); document.getElementById('pending-count').textContent = allPhotos.filter(p => p.print_pending > 0).length;
const pendingTotal = allPhotos.filter(p => p.print_pending > 0).length;
document.getElementById('pending-count').textContent = pendingTotal;
} catch(e) {} } catch(e) {}
} }
function updatePagination() { function updatePagination() {
for (const suffix of ['', '2']) { for (const s of ['', '2']) {
const cur = document.getElementById(`page-cur${suffix}`); const c = document.getElementById(`page-cur${s}`);
const tot = document.getElementById(`page-total${suffix}`); const t = document.getElementById(`page-total${s}`);
const prev = document.getElementById(`prev-btn${suffix}`); const p = document.getElementById(`prev-btn${s}`);
const next = document.getElementById(`next-btn${suffix}`); const n = document.getElementById(`next-btn${s}`);
if (cur) cur.textContent = currentPage; if (c) c.textContent = currentPage;
if (tot) tot.textContent = totalPages; if (t) t.textContent = totalPages;
if (prev) prev.disabled = currentPage <= 1; if (p) p.disabled = currentPage <= 1;
if (next) next.disabled = currentPage >= totalPages; if (n) n.disabled = currentPage >= totalPages;
} }
} }
function changePage(delta) { loadPhotos(currentPage + delta); } function changePage(delta) { loadPhotos(currentPage + delta); }
function refreshPhotos() { loadPhotos(currentPage); }
// ════════════════════════════════════════════════════════════════════════════
// 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 ───────────────────────────────────────────────────────────────── // ── WebSocket ─────────────────────────────────────────────────────────────────
function onWsMessage(msg) { function onWsMessage(msg) {
if (msg.type === 'photo_deleted') { if (msg.type === 'photo_deleted') loadPhotos(currentPage);
loadPhotos(currentPage);
}
if (msg.type === 'print_request' && msg.photo_id) { if (msg.type === 'print_request' && msg.photo_id) {
refreshPhotoData(msg.photo_id); _refreshPhotoData(msg.photo_id);
showToast(`🖨 Demande d'impression reçue`, 'info'); showToast('🖨 Demande d\'impression reçue', 'info');
} }
if (msg.type === 'print_result') { if (msg.type === 'print_result') {
const ok = msg.result && msg.result.success; const ok = msg.result?.success;
showToast(ok ? `✅ Impression OK — ${msg.result.printer}` : `❌ ${msg.result?.error || 'Erreur'}`, ok ? 'success' : 'error'); showToast(ok ? `✅ Impression OK — ${msg.result.printer}` : `❌ ${msg.result?.error || 'Erreur'}`, ok ? 'success' : 'error');
if (msg.photo_id) refreshPhotoData(msg.photo_id); 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 // Auto-refresh badges toutes les 30s
setInterval(async () => { setInterval(async () => {
try { try {
const status = await api('GET', '/admin/api/gallery/print-status'); const status = await api('GET', '/admin/api/gallery/print-status');
let changed = false; let changed = false;
allPhotos.forEach(p => { allPhotos.forEach(p => {
const pid = p.photo_id || p.id; const pid = p.photo_id || p.id;
const newCount = status[pid] || 0; const n = status[pid] || 0;
if (p.print_pending !== newCount) { if (p.print_pending !== n) { p.print_pending = n; changed = true; }
p.print_pending = newCount;
changed = true;
}
}); });
if (changed) renderGrid(); 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) {} } catch(e) {}
}, 20000); }, 30000);
loadEventOptions();
loadPhotos(1); loadPhotos(1);
</script> </script>
{% endblock %} {% endblock %}