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
This commit is contained in:
1 parent
223d71883e
commit
c89924ea18
6 files changed
+508
-288
No files matched your search
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- 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,49 +246,127 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Lightbox ─────────────────────────────────────────────────────────────── -->
|
<!-- ── Lightbox ─────────────────────────────────────────────────────────────── -->
|
||||||
<div class="lightbox" id="lightbox">
|
<div class="lightbox" id="lightbox" onclick="lbBackdropClose(event)">
|
||||||
|
<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>
|
||||||
|
<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 class="lightbox-img-wrap">
|
|
||||||
<img id="lb-img" src="" alt="">
|
|
||||||
<button class="lb-close" onclick="closeLightbox()">✕</button>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let totalPages = 1;
|
let totalPages = 1;
|
||||||
let currentPhotoId = null;
|
let allPhotos = [];
|
||||||
|
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 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) {
|
||||||
const grid = document.getElementById('photo-grid');
|
const grid = document.getElementById('photo-grid');
|
||||||
let photos = allPhotos;
|
|
||||||
|
|
||||||
if (filterMode === 'pending') {
|
if (!photos || !photos.length) {
|
||||||
photos = allPhotos.filter(p => p.print_pending > 0 || p.print_printing > 0);
|
grid.innerHTML = `<div style="color:var(--text-muted);grid-column:1/-1;text-align:center;padding:2rem">
|
||||||
}
|
📷 Aucune photo pour ces filtres</div>`;
|
||||||
|
|
||||||
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;
|
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, ''')})">
|
||||||
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-id').textContent = currentPhotoId;
|
document.getElementById('lb-date').textContent = photo.date_label || '—';
|
||||||
document.getElementById('lb-btn-dl').href = photo.full_url;
|
document.getElementById('lb-id').textContent = currentPhotoId;
|
||||||
|
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);
|
currentPhotoData = photo;
|
||||||
if (idx >= 0) {
|
_updateLbPrintPanel(photo);
|
||||||
allPhotos[idx].print_pending = count;
|
|
||||||
}
|
}
|
||||||
|
let photos = allPhotos;
|
||||||
// Si le lightbox est ouvert pour cette photo, re-fetch les détails
|
if (statusFilter === 'pending') photos = allPhotos.filter(p => p.print_pending > 0 || p.print_printing > 0);
|
||||||
if (currentPhotoId === pid) {
|
renderGrid(photos);
|
||||||
const data = await api('GET', `/admin/api/gallery/photos?page=${currentPage}&limit=24`);
|
document.getElementById('pending-count').textContent = allPhotos.filter(p => p.print_pending > 0).length;
|
||||||
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) {}
|
} 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 %}
|
||||||
| |||||||