// Page galerie admin — photobooth-qr // // Aucune dépendance framework, aucune lib tierce. Accès contrôlé par un // slug (lu dans le chemin /admin/) 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=&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 = 'Télécharger'; 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();