Files
photobooth-qr/src/admin/gallery/gallery.js
T
KevinLecou 8568b48461 Ajoute la création d'événement via n8n et refond la DA en cartes papier
- Nouvelle Edge Function create-event + RPC public.create_event : génère
  slug/admin_token/expire_at et insère l'event, réservée à service_role,
  appelée par le workflow n8n "Créer un événement" (Form Trigger, publié).
- Passe DA "polaroid poussé à fond" : les cartes d'état (chargement/erreur/
  succès), la modale de bienvenue et le formulaire passent en papier clair
  avec ruban washi tape et léger tilt, plutôt qu'en carte sombre sur fond
  sombre (ombre invisible dans l'ancienne version). Admin resté sobre/sombre
  mais cartes plus lisibles (liseré coloré, contraste renforcé).
- Générateur QR et carton imprimable : lisent maintenant ?slug= et
  ?baseUrl= pour se pré-remplir depuis le lien renvoyé par create-event.
- Correctifs : icône manquante sur "Télécharger" (admin), bug [hidden]
  anticipé sur l'état vide de la galerie invité.
2026-09-18 12:16:25 +02:00

473 lines
16 KiB
JavaScript

// Page galerie admin — photobooth-qr
//
// Aucune dépendance framework, aucune lib tierce. Accès contrôlé par un
// slug (lu dans le chemin /admin/<slug>) et un token secret (lu dans le
// query param ?token=..., voir docs/decisions.md : "accès admin via token
// secret dans l'URL, pas de login"). L'un ou l'autre invalide -> message
// générique, on ne distingue jamais "slug inconnu" de "token invalide"
// côté client (cf. contrat de l'Edge Function).
//
// Contrat backend attendu (Edge Function "admin-gallery", implémentée en
// parallèle par un autre agent — voir rapport de tâche pour le détail des
// points d'ambiguïté identifiés) :
// GET {SUPABASE_URL}/functions/v1/admin-gallery?slug=<slug>&token=<token>
// -> 200 { event: { nom, date_evenement, statut, photo_count },
// photos: [ { id, nom_invite, message, uploaded_at,
// signed_url, expires_in } ] }
// -> 403/404 générique si slug/token invalide (pas de distinction)
// GET {...}&action=zip
// -> 200 application/zip (binaire, toutes les photos de l'événement)
// ---------------------------------------------------------------------------
// Éléments DOM
// ---------------------------------------------------------------------------
const eventNameEl = document.getElementById("event-name");
const eventMetaEl = document.getElementById("event-meta");
const loadingBlock = document.getElementById("status-loading");
const errorBlock = document.getElementById("status-error");
const errorMessageEl = document.getElementById("status-error-message");
const retryBtn = document.getElementById("retry-btn");
const contentBlock = document.getElementById("gallery-content");
const statusBanner = document.getElementById("status-banner");
const refreshBtn = document.getElementById("refresh-btn");
const zipBtn = document.getElementById("download-zip-btn");
const zipStatusEl = document.getElementById("zip-status");
const emptyStateEl = document.getElementById("empty-state");
const photoGridEl = document.getElementById("photo-grid");
// ---------------------------------------------------------------------------
// État
// ---------------------------------------------------------------------------
const state = {
slug: null,
token: null,
event: null,
photos: [],
loadedAt: 0, // Date.now() au moment de la dernière réponse reçue
lastRetryAction: null,
isZipping: false,
};
// ---------------------------------------------------------------------------
// Erreurs applicatives : un code technique -> un message clair pour l'admin
// ---------------------------------------------------------------------------
const ERROR_MESSAGES = {
"config-missing":
"Cette page n'est pas configurée correctement. Contacte le développeur du service.",
"link-invalid":
"Ce lien est invalide ou a expiré. Vérifie l'URL utilisée, ou redemande un lien d'accès.",
network:
"La connexion a été coupée. Vérifie ta connexion internet et réessaie.",
"server-error":
"Le service est momentanément indisponible. Réessaie dans quelques instants.",
unknown: "Une erreur inattendue est survenue. Réessaie, ou reviens un peu plus tard.",
};
const RETRYABLE_CODES = new Set(["network", "server-error", "unknown"]);
function appError(code, cause) {
const err = new Error(code);
err.code = code;
err.cause = cause;
return err;
}
function normalizeError(err) {
if (err && err.code && ERROR_MESSAGES[err.code]) return err;
if (err instanceof TypeError) return appError("network", err); // fetch() qui échoue = coupure réseau
return appError("unknown", err);
}
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
function getConfig() {
const cfg = window.__PHOTOBOOTH_CONFIG__;
if (!cfg || !cfg.SUPABASE_URL) {
throw appError("config-missing");
}
return cfg;
}
// ---------------------------------------------------------------------------
// Lecture slug (chemin) + token (query param)
// ---------------------------------------------------------------------------
function getSlugFromPath() {
const match = window.location.pathname.match(/\/admin\/([^/]+)\/?$/);
return match ? decodeURIComponent(match[1]) : null;
}
function getTokenFromQuery() {
return new URLSearchParams(window.location.search).get("token");
}
// ---------------------------------------------------------------------------
// Affichage des états
// ---------------------------------------------------------------------------
function showLoading() {
loadingBlock.hidden = false;
errorBlock.hidden = true;
contentBlock.hidden = true;
}
function showError(rawErr, retryAction) {
const err = normalizeError(rawErr);
loadingBlock.hidden = true;
contentBlock.hidden = true;
errorBlock.hidden = false;
errorMessageEl.textContent = ERROR_MESSAGES[err.code] || ERROR_MESSAGES.unknown;
state.lastRetryAction = retryAction || null;
const canRetry = Boolean(retryAction) && RETRYABLE_CODES.has(err.code);
retryBtn.hidden = !canRetry;
if (err.cause) {
console.error(`[gallery:${err.code}]`, err.cause);
}
}
const STATUT_LABELS = {
actif: "Actif",
expire: "Expiré",
archive: "Archivé",
};
function formatDateFr(dateStr) {
if (!dateStr) return null;
// "date" Postgres arrive en "YYYY-MM-DD" : on force un parsing en heure
// locale pour éviter un décalage de jour selon le fuseau du navigateur.
const d = new Date(`${dateStr}T00:00:00`);
if (Number.isNaN(d.getTime())) return null;
return d.toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
}
function formatDateTimeFr(dateStr) {
if (!dateStr) return null;
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return null;
return d.toLocaleString("fr-FR", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
}
function renderHeader(event) {
eventNameEl.textContent = event.nom || "Galerie événement";
document.title = `${event.nom || "Galerie événement"} — admin`;
const parts = [];
const dateFr = formatDateFr(event.date_evenement);
if (dateFr) parts.push(dateFr);
const count = Number.isFinite(event.photo_count) ? event.photo_count : state.photos.length;
parts.push(`${count} photo${count > 1 ? "s" : ""}`);
const statutLabel = STATUT_LABELS[event.statut] || event.statut || "";
if (statutLabel) parts.push(statutLabel);
eventMetaEl.textContent = parts.join(" · ");
eventMetaEl.hidden = parts.length === 0;
}
function renderStatusBanner(event) {
if (event.statut === "expire") {
statusBanner.textContent =
"Cet événement est expiré : les photos ne sont plus disponibles (suppression automatique après la période de conservation).";
statusBanner.hidden = false;
} else if (event.statut === "archive") {
statusBanner.textContent = "Cet événement est archivé. Les photos ne sont plus disponibles.";
statusBanner.hidden = false;
} else {
statusBanner.hidden = true;
}
}
function renderEmptyState(event) {
if (state.photos.length > 0) {
emptyStateEl.hidden = true;
return;
}
emptyStateEl.hidden = false;
emptyStateEl.textContent =
event.statut === "actif"
? "Aucune photo n'a encore été déposée pour cet événement."
: "Il n'y a plus de photo disponible pour cet événement.";
}
function photoExpiryTimestamp(photo) {
if (!Number.isFinite(photo.expires_in)) return null;
return state.loadedAt + photo.expires_in * 1000;
}
function isPhotoLinkLikelyExpired(photo) {
const expiresAt = photoExpiryTimestamp(photo);
return expiresAt !== null && Date.now() >= expiresAt;
}
function renderPhotoGrid() {
photoGridEl.textContent = "";
for (const photo of state.photos) {
const li = document.createElement("li");
li.className = "photo-card";
const thumbWrap = document.createElement("div");
thumbWrap.className = "photo-card__thumb-wrap";
const img = document.createElement("img");
img.className = "photo-card__thumb";
img.loading = "lazy";
img.alt = photo.nom_invite
? `Photo envoyée par ${photo.nom_invite}`
: "Photo envoyée par un invité";
img.src = photo.signed_url;
img.onerror = () => {
li.classList.add("photo-card--broken");
};
const fallback = document.createElement("div");
fallback.className = "photo-card__thumb-fallback";
fallback.textContent = "Aperçu indisponible (lien peut-être expiré — clique sur Actualiser)";
thumbWrap.append(img, fallback);
const meta = document.createElement("div");
meta.className = "photo-card__meta";
if (photo.nom_invite) {
const nameEl = document.createElement("p");
nameEl.className = "photo-card__name";
nameEl.textContent = photo.nom_invite;
meta.appendChild(nameEl);
}
if (photo.message) {
const msgEl = document.createElement("p");
msgEl.className = "photo-card__message";
msgEl.textContent = photo.message;
meta.appendChild(msgEl);
}
const dateFr = formatDateTimeFr(photo.uploaded_at);
if (dateFr) {
const dateEl = document.createElement("p");
dateEl.className = "photo-card__date";
dateEl.textContent = dateFr;
meta.appendChild(dateEl);
}
const downloadLink = document.createElement("a");
downloadLink.className = "photo-card__download";
downloadLink.href = photo.signed_url;
downloadLink.target = "_blank";
downloadLink.rel = "noopener";
// Indication pour les navigateurs same-origin ; pour une URL signée
// cross-origin (cas normal ici), l'attribut "download" est ignoré par
// la plupart des navigateurs, qui ouvrent l'image dans un nouvel onglet
// à la place (limitation connue, acceptée pour éviter un fetch+blob par
// photo qui multiplierait les requêtes réseau).
downloadLink.download = "";
// Icône + texte plutôt qu'un lien texte nu : cohérent avec le langage
// iconographique du reste de l'écran (Actualiser, Télécharger tout).
downloadLink.innerHTML =
'<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 4v11"/><path d="M7 10.5 12 15.5 17 10.5"/><path d="M5 19.5h14"/></svg><span>Télécharger</span>';
downloadLink.addEventListener("click", (evt) => {
if (isPhotoLinkLikelyExpired(photo)) {
evt.preventDefault();
zipStatusEl.hidden = false;
zipStatusEl.className = "zip-status zip-status--error";
zipStatusEl.textContent =
"Ce lien a probablement expiré. Clique sur \"Actualiser\" puis réessaie.";
}
});
li.append(thumbWrap, meta, downloadLink);
photoGridEl.appendChild(li);
}
}
function renderContent(event, photos) {
state.event = event;
state.photos = Array.isArray(photos) ? photos : [];
loadingBlock.hidden = true;
errorBlock.hidden = true;
contentBlock.hidden = false;
renderHeader(event);
renderStatusBanner(event);
renderEmptyState(event);
renderPhotoGrid();
const hasPhotos = state.photos.length > 0;
zipBtn.disabled = !hasPhotos;
zipBtn.title = hasPhotos ? "" : "Aucune photo à télécharger";
}
// ---------------------------------------------------------------------------
// Appels réseau
// ---------------------------------------------------------------------------
function buildFunctionUrl(action) {
const { SUPABASE_URL } = getConfig();
const url = new URL(`${SUPABASE_URL.replace(/\/+$/, "")}/functions/v1/admin-gallery`);
url.searchParams.set("slug", state.slug);
url.searchParams.set("token", state.token);
if (action) url.searchParams.set("action", action);
return url.toString();
}
function buildAuthHeaders() {
// SUPABASE_ANON_KEY est optionnelle (voir config.example.js) : ne
// renseignée que si l'Edge Function nécessite un header apikey/Authorization
// en plus du token applicatif. Non requis dans le cas nominal attendu
// (accès contrôlé uniquement par slug+token, verify_jwt=false côté
// Supabase) — cf. rapport de tâche pour le détail de cette hypothèse.
const cfg = window.__PHOTOBOOTH_CONFIG__ || {};
if (!cfg.SUPABASE_ANON_KEY) return {};
return {
apikey: cfg.SUPABASE_ANON_KEY,
Authorization: `Bearer ${cfg.SUPABASE_ANON_KEY}`,
};
}
async function safeReadText(res) {
try {
return await res.text();
} catch {
return null;
}
}
async function fetchGallery() {
let res;
try {
res = await fetch(buildFunctionUrl(), { headers: buildAuthHeaders() });
} catch (err) {
throw appError("network", err);
}
if (res.status === 403 || res.status === 404) throw appError("link-invalid");
if (res.status >= 500) throw appError("server-error", await safeReadText(res));
if (!res.ok) throw appError("unknown", await safeReadText(res));
const data = await res.json().catch((err) => {
throw appError("unknown", err);
});
if (!data || !data.event) throw appError("unknown", data);
return data;
}
async function loadGallery() {
showLoading();
try {
const data = await fetchGallery();
state.loadedAt = Date.now();
renderContent(data.event, data.photos);
} catch (err) {
showError(err, () => loadGallery());
}
}
function parseZipFilename(res, fallback) {
const header = res.headers.get("Content-Disposition") || "";
const match = header.match(/filename\*?=(?:UTF-8'')?"?([^";\n]+)"?/i);
return match ? decodeURIComponent(match[1]) : fallback;
}
function triggerBlobDownload(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
async function downloadZip() {
if (state.isZipping) return;
state.isZipping = true;
zipBtn.disabled = true;
zipStatusEl.hidden = false;
zipStatusEl.className = "zip-status";
zipStatusEl.textContent = "Préparation du ZIP…";
try {
let res;
try {
res = await fetch(buildFunctionUrl("zip"), { headers: buildAuthHeaders() });
} catch (err) {
throw appError("network", err);
}
if (res.status === 403 || res.status === 404) throw appError("link-invalid");
if (res.status >= 500) throw appError("server-error", await safeReadText(res));
if (!res.ok) throw appError("unknown", await safeReadText(res));
const blob = await res.blob();
const filename = parseZipFilename(res, `photos-${state.slug}.zip`);
triggerBlobDownload(blob, filename);
zipStatusEl.textContent = "Téléchargement lancé.";
} catch (err) {
const normalized = normalizeError(err);
zipStatusEl.className = "zip-status zip-status--error";
zipStatusEl.textContent = ERROR_MESSAGES[normalized.code] || ERROR_MESSAGES.unknown;
if (normalized.cause) console.error(`[gallery:zip:${normalized.code}]`, normalized.cause);
} finally {
state.isZipping = false;
zipBtn.disabled = state.photos.length === 0;
}
}
// ---------------------------------------------------------------------------
// Événements
// ---------------------------------------------------------------------------
retryBtn.addEventListener("click", () => {
if (state.lastRetryAction) state.lastRetryAction();
});
refreshBtn.addEventListener("click", () => {
zipStatusEl.hidden = true;
loadGallery();
});
zipBtn.addEventListener("click", () => {
downloadZip();
});
// ---------------------------------------------------------------------------
// Démarrage
// ---------------------------------------------------------------------------
function init() {
try {
getConfig();
} catch (err) {
showError(err);
return;
}
state.slug = getSlugFromPath();
state.token = getTokenFromQuery();
if (!state.slug || !state.token) {
showError(appError("link-invalid"));
return;
}
loadGallery();
}
init();