diff --git a/.gitignore b/.gitignore index 8cff6e7..451ddc5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ src/e/config.js # Config front runtime — galerie admin (cf. src/admin/gallery/config.example.js) src/admin/gallery/config.js +# Config front runtime — galerie invité (cf. src/galerie/config.example.js) +src/galerie/config.js + # Build dist/ build/ diff --git a/src/galerie/config.example.js b/src/galerie/config.example.js new file mode 100644 index 0000000..a7850e5 --- /dev/null +++ b/src/galerie/config.example.js @@ -0,0 +1,10 @@ +// Copier ce fichier en config.js (non commité, voir .gitignore) et renseigner +// les vraies valeurs Supabase avant déploiement. Ce fichier est chargé avant +// gallery.js dans index.html. +// +// SUPABASE_ANON_KEY est la clé PUBLIQUE (anon) Supabase — jamais la +// service_role key, qui ne doit jamais être exposée côté client. +window.__PHOTOBOOTH_CONFIG__ = { + SUPABASE_URL: "https://xxxxxxxxxxxx.supabase.co", + SUPABASE_ANON_KEY: "REMPLACER_PAR_LA_CLE_ANON_PUBLIQUE", +}; diff --git a/src/galerie/gallery-mock.js b/src/galerie/gallery-mock.js deleted file mode 100644 index 5c9382a..0000000 --- a/src/galerie/gallery-mock.js +++ /dev/null @@ -1,102 +0,0 @@ -// Écran galerie invité — MAQUETTE avec données factices. -// -// Champs alignés sur le vrai schéma (photobooth.photos : nom_invite, -// message, url_storage -> ici un chemin mock, uploaded_at) pour que le -// câblage réel n'ait qu'à remplacer MOCK_PHOTOS par un vrai fetch, sans -// toucher au rendu. Voir index.html pour le détail de ce qui manque -// encore côté backend (RPC/Edge Function publique de listing). - -const MOCK_PHOTOS = [ - { nom_invite: "Camille", message: "Cette soirée est parfaite, merci !", url_storage: "/galerie/mock-photos/mock-1.jpg", uploaded_at: "2026-09-18T19:12:00Z" }, - { nom_invite: "Thomas", message: "On s'est jamais autant amusés", url_storage: "/galerie/mock-photos/mock-2.jpg", uploaded_at: "2026-09-18T19:20:00Z" }, - { nom_invite: null, message: "Vive les mariés !", url_storage: "/galerie/mock-photos/mock-3.jpg", uploaded_at: "2026-09-18T19:25:00Z" }, - { nom_invite: "Léa", message: null, url_storage: "/galerie/mock-photos/mock-4.jpg", uploaded_at: "2026-09-18T19:31:00Z" }, - { nom_invite: "Hugo & Zoé", message: "Merci pour cette belle journée", url_storage: "/galerie/mock-photos/mock-5.jpg", uploaded_at: "2026-09-18T19:40:00Z" }, - { nom_invite: "Inès", message: "Le gâteau était incroyable 🍰", url_storage: "/galerie/mock-photos/mock-6.jpg", uploaded_at: "2026-09-18T19:44:00Z" }, - { nom_invite: "Nathan", message: "À refaire dans 10 ans !", url_storage: "/galerie/mock-photos/mock-7.jpg", uploaded_at: "2026-09-18T19:52:00Z" }, - { nom_invite: "Sarah", message: "Team dancefloor jusqu'au bout", url_storage: "/galerie/mock-photos/mock-8.jpg", uploaded_at: "2026-09-18T20:01:00Z" }, -]; - -const eventNameEl = document.getElementById("event-name"); -const gridEl = document.getElementById("polaroid-grid"); -const demoAddBtn = document.getElementById("demo-add-btn"); - -function getSlugFromPath() { - const match = window.location.pathname.match(/\/galerie\/([^/]+)\/?$/); - return match ? decodeURIComponent(match[1]) : null; -} - -// Rotation aléatoire par carte, -4°/+4° (cf. skill photobooth-da), fixée -// une fois par carte (pas recalculée à chaque re-render) pour que la -// grille ne "tremble" pas. -function randomRotation() { - return (Math.random() * 8 - 4).toFixed(2); -} - -function buildCaption(photo) { - const parts = []; - if (photo.nom_invite) parts.push(photo.nom_invite); - if (photo.message) parts.push(photo.message); - return parts; -} - -function renderPolaroid(photo, { animate = false } = {}) { - const li = document.createElement("li"); - li.className = "polaroid" + (animate ? " polaroid--dropping" : ""); - li.style.setProperty("--rot", `${randomRotation()}deg`); - - const img = document.createElement("img"); - img.className = "polaroid__photo"; - img.src = photo.url_storage; - img.alt = photo.nom_invite - ? `Photo envoyée par ${photo.nom_invite}` - : "Photo envoyée par un invité"; - img.loading = "lazy"; - - const captionParts = buildCaption(photo); - const caption = document.createElement("p"); - caption.className = "polaroid__caption"; - if (captionParts.length === 0) { - caption.classList.add("polaroid__caption--empty"); - } else { - caption.textContent = captionParts.join(" — "); - } - - li.append(img, caption); - return li; -} - -function renderAll(photos) { - gridEl.innerHTML = ""; - // Plus récent en premier, comme la galerie admin. - [...photos].reverse().forEach((photo) => { - gridEl.appendChild(renderPolaroid(photo)); - }); -} - -function init() { - const slug = getSlugFromPath(); - eventNameEl.textContent = slug - ? `Galerie — ${slug.replace(/-/g, " ")}` - : "Galerie de l'événement"; - renderAll(MOCK_PHOTOS); -} - -init(); - -// --- Démo uniquement : simule l'arrivée d'un nouveau polaroid -------------- -let demoCounter = 0; -const demoNames = ["Chloé", "Adam", "Manon", "Yanis", "Juliette"]; -const demoMessages = ["On pense fort à vous !", "Quelle ambiance ce soir 🎉", "Merci de nous avoir invités", null]; - -demoAddBtn.addEventListener("click", () => { - demoCounter += 1; - const photo = { - nom_invite: demoNames[demoCounter % demoNames.length], - message: demoMessages[demoCounter % demoMessages.length], - url_storage: `/galerie/mock-photos/mock-${(demoCounter % 8) + 1}.jpg`, - uploaded_at: new Date().toISOString(), - }; - const card = renderPolaroid(photo, { animate: true }); - gridEl.prepend(card); -}); diff --git a/src/galerie/gallery.js b/src/galerie/gallery.js new file mode 100644 index 0000000..497bb5e --- /dev/null +++ b/src/galerie/gallery.js @@ -0,0 +1,247 @@ +// Écran galerie invité — photobooth-qr +// +// Contrat backend : GET {SUPABASE_URL}/functions/v1/public-gallery?slug= +// (Edge Function publique, aucun token requis — cf. docs/decisions.md +// 2026-09-18 : les invités voient désormais toutes les photos de +// l'événement, même modèle de confiance que l'upload : le slug n'est pas +// un secret, il circule via QR code). +// +// Rafraîchissement par sondage (polling), pas de Supabase Realtime : le +// schéma photobooth est volontairement jamais exposé et Realtime respecte +// les policies RLS (aucune pour anon), l'activer aurait demandé de rouvrir +// ce verrou pour un gain marginal sur un usage V1 (événements de taille +// raisonnable). Compromis assumé : chaque poll régénère des URLs signées +// pour toutes les photos, pour tous les invités qui ont la page ouverte — +// acceptable pour un mariage, à revisiter si le produit vise un jour des +// événements à très fort trafic simultané. + +const POLL_INTERVAL_MS = 25_000; + +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 emptyStateEl = document.getElementById("empty-state"); +const gridEl = document.getElementById("polaroid-grid"); + +const ERROR_MESSAGES = { + "config-missing": + "Cette page n'est pas configurée correctement. Préviens l'organisateur de l'événement.", + "event-not-found": + "Cet événement n'existe pas, ou le lien utilisé est incorrect. Vérifie le QR code ou le lien.", + network: + "La connexion a été coupée. Vérifie ta connexion internet et réessaie.", + "server-overloaded": + "Le service est momentanément surchargé. Réessaie dans quelques minutes.", + unknown: "Une erreur inattendue est survenue. Réessaie, ou reviens un peu plus tard.", +}; + +function appError(code, cause) { + const err = new Error(code); + err.code = code; + err.cause = cause; + return err; +} + +function getConfig() { + const cfg = window.__PHOTOBOOTH_CONFIG__; + if (!cfg || !cfg.SUPABASE_URL || !cfg.SUPABASE_ANON_KEY) { + throw appError("config-missing"); + } + return cfg; +} + +function getSlugFromPath() { + const match = window.location.pathname.match(/\/galerie\/([^/]+)\/?$/); + return match ? decodeURIComponent(match[1]) : null; +} + +// Rotation aléatoire -4°/+4° (skill photobooth-da), figée par photo (Map +// id -> degré) pour que la grille ne "tremble" pas à chaque poll — seules +// les photos réellement nouvelles reçoivent une rotation fraîche. +const rotationById = new Map(); +function rotationFor(id) { + if (!rotationById.has(id)) { + rotationById.set(id, (Math.random() * 8 - 4).toFixed(2)); + } + return rotationById.get(id); +} + +const renderedIds = new Set(); + +function buildCaption(photo) { + const parts = []; + if (photo.nom_invite) parts.push(photo.nom_invite); + if (photo.message) parts.push(photo.message); + return parts; +} + +function renderPolaroid(photo, { animate = false } = {}) { + const li = document.createElement("li"); + li.className = "polaroid" + (animate ? " polaroid--dropping" : ""); + li.style.setProperty("--rot", `${rotationFor(photo.id)}deg`); + li.dataset.photoId = photo.id; + + const img = document.createElement("img"); + img.className = "polaroid__photo"; + img.src = photo.signed_url || ""; + img.alt = photo.nom_invite + ? `Photo envoyée par ${photo.nom_invite}` + : "Photo envoyée par un invité"; + img.loading = "lazy"; + + const captionParts = buildCaption(photo); + const caption = document.createElement("p"); + caption.className = "polaroid__caption"; + if (captionParts.length === 0) { + caption.classList.add("polaroid__caption--empty"); + } else { + caption.textContent = captionParts.join(" — "); + } + + li.append(img, caption); + return li; +} + +function showLoading() { + loadingBlock.hidden = false; + errorBlock.hidden = true; + emptyStateEl.hidden = true; + gridEl.hidden = true; +} + +function showError(rawErr, retryAction) { + const err = rawErr && rawErr.code ? rawErr : appError("unknown", rawErr); + loadingBlock.hidden = true; + errorBlock.hidden = false; + errorMessageEl.textContent = ERROR_MESSAGES[err.code] || ERROR_MESSAGES.unknown; + retryBtn.hidden = !retryAction; + retryBtn.onclick = retryAction || null; + if (err.cause) console.error(`[galerie:${err.code}]`, err.cause); +} + +async function fetchGallery(slug) { + const { SUPABASE_URL, SUPABASE_ANON_KEY } = getConfig(); + let res; + try { + res = await fetch( + `${SUPABASE_URL}/functions/v1/public-gallery?slug=${encodeURIComponent(slug)}`, + { + headers: { + apikey: SUPABASE_ANON_KEY, + Authorization: `Bearer ${SUPABASE_ANON_KEY}`, + }, + } + ); + } catch (err) { + throw appError("network", err); + } + + if (res.status === 404) throw appError("event-not-found"); + if (res.status >= 500) throw appError("server-overloaded"); + if (!res.ok) throw appError("unknown", await res.text().catch(() => null)); + + return res.json(); +} + +function formatEventMeta(event) { + const date = new Date(event.date_evenement); + const dateLabel = Number.isNaN(date.getTime()) + ? null + : date.toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" }); + const parts = []; + if (dateLabel) parts.push(dateLabel); + parts.push(`${event.photo_count} photo${event.photo_count > 1 ? "s" : ""}`); + return parts.join(" · "); +} + +function renderGallery(data) { + eventNameEl.textContent = data.event.nom || "Galerie de l'événement"; + eventMetaEl.textContent = formatEventMeta(data.event); + + loadingBlock.hidden = true; + errorBlock.hidden = true; + + if (data.photos.length === 0) { + emptyStateEl.hidden = false; + gridEl.hidden = true; + return; + } + + emptyStateEl.hidden = true; + gridEl.hidden = false; + + // Plus récent en premier. On ne reconstruit pas tout le DOM à chaque + // poll (perdrait l'état d'animation/scroll) : seules les photos dont + // l'id n'a jamais été vu sont insérées, avec l'effet "chute". + const photosDesc = [...data.photos].reverse(); + const incomingIds = new Set(photosDesc.map((p) => p.id)); + + // Retire les photos qui auraient disparu côté serveur (rare : édition + // manuelle en base, ou event modifié) pour rester cohérent. + [...gridEl.children].forEach((li) => { + if (!incomingIds.has(li.dataset.photoId)) { + li.remove(); + renderedIds.delete(li.dataset.photoId); + } + }); + + photosDesc.forEach((photo, index) => { + if (renderedIds.has(photo.id)) { + // Déjà affichée : on rafraîchit juste l'URL signée (elle expire au + // bout de 10 min), sans toucher au reste du DOM de la carte. + const existing = gridEl.querySelector(`[data-photo-id="${photo.id}"] .polaroid__photo`); + if (existing && photo.signed_url) existing.src = photo.signed_url; + return; + } + const card = renderPolaroid(photo, { animate: true }); + if (index === 0) { + gridEl.prepend(card); + } else { + const prevSibling = gridEl.children[index - 1]; + prevSibling ? prevSibling.after(card) : gridEl.appendChild(card); + } + renderedIds.add(photo.id); + }); +} + +let pollTimer = null; + +async function loadOnce({ showSpinner } = { showSpinner: false }) { + const slug = getSlugFromPath(); + if (!slug) { + showError(appError("event-not-found")); + return; + } + if (showSpinner) showLoading(); + try { + const data = await fetchGallery(slug); + renderGallery(data); + } catch (err) { + showError(err, () => loadOnce({ showSpinner: true })); + } +} + +function startPolling() { + if (pollTimer) return; + pollTimer = setInterval(() => loadOnce({ showSpinner: false }), POLL_INTERVAL_MS); +} + +// Pas de sondage pendant que l'onglet est masqué (économie réseau/batterie, +// cohérent avec l'esprit sobriété du projet) ; on refait un chargement +// immédiat au retour au premier plan pour ne pas attendre un cycle entier. +document.addEventListener("visibilitychange", () => { + if (document.hidden) { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } else { + loadOnce({ showSpinner: false }); + startPolling(); + } +}); + +loadOnce({ showSpinner: true }).then(startPolling); diff --git a/src/galerie/index.html b/src/galerie/index.html index a04ac8d..447895a 100644 --- a/src/galerie/index.html +++ b/src/galerie/index.html @@ -5,20 +5,22 @@ Galerie de l'événement @@ -37,20 +39,33 @@ photobooth

Galerie de l'événement

-

Tous les souvenirs déposés par les invités, en direct

+

Tous les souvenirs déposés par les invités, en direct

- +
+ +

Chargement de la galerie…

+
- - + + + + + - + + + diff --git a/src/galerie/mock-photos/mock-1.jpg b/src/galerie/mock-photos/mock-1.jpg deleted file mode 100644 index c3308f4..0000000 Binary files a/src/galerie/mock-photos/mock-1.jpg and /dev/null differ diff --git a/src/galerie/mock-photos/mock-2.jpg b/src/galerie/mock-photos/mock-2.jpg deleted file mode 100644 index b1cfb49..0000000 Binary files a/src/galerie/mock-photos/mock-2.jpg and /dev/null differ diff --git a/src/galerie/mock-photos/mock-3.jpg b/src/galerie/mock-photos/mock-3.jpg deleted file mode 100644 index 066032f..0000000 Binary files a/src/galerie/mock-photos/mock-3.jpg and /dev/null differ diff --git a/src/galerie/mock-photos/mock-4.jpg b/src/galerie/mock-photos/mock-4.jpg deleted file mode 100644 index 4981a50..0000000 Binary files a/src/galerie/mock-photos/mock-4.jpg and /dev/null differ diff --git a/src/galerie/mock-photos/mock-5.jpg b/src/galerie/mock-photos/mock-5.jpg deleted file mode 100644 index 5ce0020..0000000 Binary files a/src/galerie/mock-photos/mock-5.jpg and /dev/null differ diff --git a/src/galerie/mock-photos/mock-6.jpg b/src/galerie/mock-photos/mock-6.jpg deleted file mode 100644 index fa20aa4..0000000 Binary files a/src/galerie/mock-photos/mock-6.jpg and /dev/null differ diff --git a/src/galerie/mock-photos/mock-7.jpg b/src/galerie/mock-photos/mock-7.jpg deleted file mode 100644 index a406766..0000000 Binary files a/src/galerie/mock-photos/mock-7.jpg and /dev/null differ diff --git a/src/galerie/mock-photos/mock-8.jpg b/src/galerie/mock-photos/mock-8.jpg deleted file mode 100644 index c593d07..0000000 Binary files a/src/galerie/mock-photos/mock-8.jpg and /dev/null differ diff --git a/src/galerie/style.css b/src/galerie/style.css index 9c76726..ab5b0ba 100644 --- a/src/galerie/style.css +++ b/src/galerie/style.css @@ -139,6 +139,13 @@ body { gap: 1.75rem 1.25rem; } +/* #polaroid-grid est togglée via .hidden en JS (gallery.js) : sans cette + règle, le display:grid ci-dessus passerait devant [hidden] du navigateur + (même bug que .upload-form/.welcome-modal, déjà rencontré deux fois). */ +.polaroid-grid[hidden] { + display: none; +} + @media (min-width: 560px) { .polaroid-grid { grid-template-columns: repeat(3, 1fr); @@ -218,34 +225,68 @@ body { } /* --------------------------------------------------------------------- */ -/* Bouton de démo (temporaire, cf. gallery-mock.js) */ +/* États chargement / erreur / vide */ /* --------------------------------------------------------------------- */ -.demo-add-btn { - position: fixed; - left: 50%; - bottom: 1.5rem; - transform: translateX(-50%); +.state-block { + text-align: center; + padding: 2rem 1.2rem; + color: var(--color-muted); +} + +/* Vigilance (bug réel déjà rencontré trois fois en prod, 2026-09-16/18) : + une déclaration `display` en CSS auteur passe toujours devant la règle + `[hidden] { display: none }` du navigateur. #polaroid-grid a un display + personnalisé (grid) et est togglée via .hidden en JS -> voir la règle + [hidden] ajoutée juste après sa définition plus haut. #status-loading, + #status-error et .empty-state n'ont pas de display personnalisé, [hidden] + fonctionne donc nativement pour eux, pas de règle supplémentaire requise + — mais si un display leur est ajouté un jour, il faudra faire pareil. */ + +.spinner { + display: inline-block; + width: 26px; + height: 26px; + margin-bottom: 0.6rem; + border: 3px solid rgba(255, 94, 91, 0.22); + border-top-color: var(--color-coral); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +#status-error p { + margin: 0 0 0.9rem; +} + +#retry-btn { font: inherit; - font-family: var(--font-ui); font-weight: 700; - font-size: 0.85rem; color: var(--color-ink); background: var(--color-coral); border: none; border-radius: 999px; - padding: 0.8rem 1.3rem; - box-shadow: var(--shadow-soft); + padding: 0.7rem 1.3rem; cursor: pointer; } -.demo-add-btn:active { - transform: translateX(-50%) translateY(1px); +.empty-state { + text-align: center; + padding: 2.5rem 1.2rem; + color: var(--color-muted); + font-family: var(--font-hand); + font-size: 1.25rem; } @media (prefers-reduced-motion: reduce) { .polaroid, - .polaroid--dropping { + .polaroid--dropping, + .spinner { animation: none !important; transition: none !important; } diff --git a/supabase/functions/public-gallery/index.ts b/supabase/functions/public-gallery/index.ts new file mode 100644 index 0000000..457702a --- /dev/null +++ b/supabase/functions/public-gallery/index.ts @@ -0,0 +1,177 @@ +// Edge Function `public-gallery` +// +// Contrat : GET {SUPABASE_URL}/functions/v1/public-gallery?slug= +// +// Tourne avec la service_role key (cf. _shared/supabase-admin.ts), MAIS +// sans aucune vérification de token — c'est le point central de cette +// fonction, à la différence de `admin-gallery`. L'accès est intentionnellement +// ouvert à quiconque connaît le `slug` : même modèle de confiance que +// l'upload invité (le slug circule via QR code, ce n'est pas un secret). +// +// Origine : revirement produit du 2026-09-18 (cf. docs/decisions.md) — les +// invités peuvent désormais voir toutes les photos d'un événement, pas +// seulement envoyer la leur. Ça inverse la règle "lecture des photos jamais +// publique" du cahier des charges initial / docs/modele-donnees.md : ce +// revirement est acté comme décision explicite du 2026-09-18, pas tranché +// par cet agent. +// +// Sécurité : on résout le slug via `public.get_public_event` (PAS +// `get_event_for_admin`, qui expose admin_token) — cette fonction ne +// renvoie que {id, nom, statut, date_evenement, expire_at, +// max_photos_per_guest}, jamais admin_token / client_nom / facture_montant. +// Les photos elles-mêmes viennent de `public.get_event_photos` +// (service_role uniquement), qui ne renvoie que {id, url_storage, +// nom_invite, message, uploaded_at} — jamais l'email/téléphone d'un invité +// (ces colonnes n'existent d'ailleurs pas dans photobooth.photos, cf. +// docs/modele-donnees.md). +// +// `get_public_event` renvoie l'événement même si son statut n'est pas +// `actif` (expiré/archivé) — comportement hérité, inchangé ici. Cette +// fonction renvoie donc telles quelles les photos d'un événement expiré si +// elles existent encore en base (la purge Storage/DB à J+7 est un job +// séparé, cf. expire-events) : pas de filtre `statut = 'actif'` ajouté ici, +// le front peut décider d'afficher un message adapté à partir de +// `event.statut`, cohérent avec le comportement déjà choisi pour +// `get_public_event` côté page d'upload. +import { getSupabaseAdmin, getStorageBucket } from "../_shared/supabase-admin.ts"; +import { corsHeaders, handleCorsPreflight } from "../_shared/cors.ts"; + +// URLs signées affichées dans la galerie invité : courte durée, c'est pour +// un affichage immédiat dans le navigateur de l'invité, pas pour un lien à +// partager. Même valeur que `admin-gallery` (cf. GALLERY_SIGNED_URL_TTL_SECONDS +// dans supabase/functions/admin-gallery/index.ts). +const GALLERY_SIGNED_URL_TTL_SECONDS = 10 * 60; // 10 minutes + +type PhotoRow = { + id: string; + nom_invite: string | null; + message: string | null; + uploaded_at: string; + url_storage: string; +}; + +type PublicEventRow = { + id: string; + nom: string; + statut: string; + date_evenement: string; + expire_at: string; + max_photos_per_guest: number | null; +}; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders(), "Content-Type": "application/json" }, + }); +} + +// 404 générique : même statut/corps que le "slug inconnu" de admin-gallery, +// pour ne pas distinguer un slug qui n'existe pas d'un autre cas d'erreur +// silencieuse côté appelant. +function notFound(): Response { + return jsonResponse(404, { error: "not_found" }); +} + +Deno.serve(async (req) => { + const preflight = handleCorsPreflight(req); + if (preflight) return preflight; + + if (req.method !== "GET") { + return jsonResponse(405, { error: "method_not_allowed" }); + } + + const url = new URL(req.url); + const slug = url.searchParams.get("slug"); + + if (!slug) { + return jsonResponse(400, { error: "missing_parameters" }); + } + + const supabaseAdmin = getSupabaseAdmin(); + + // 1. Résout l'event par slug via public.get_public_event (anon-safe, + // n'expose jamais admin_token/client_nom/facture_montant) — pas + // get_event_for_admin, réservée à admin-gallery. + const { data: event, error: eventError } = await supabaseAdmin + .rpc("get_public_event", { p_slug: slug }) + .maybeSingle(); + + if (eventError) { + console.error("[public-gallery] get_public_event failed", eventError); + return jsonResponse(500, { error: "internal_error" }); + } + + if (!event) { + return notFound(); + } + + const typedEvent = event as PublicEventRow; + + // 2. Photos de l'event, triées chronologiquement (get_event_photos renvoie + // déjà uploaded_at ASC) — pas de tri inversé ici contrairement à + // admin-gallery : une galerie invité affiche naturellement l'ordre + // d'arrivée des photos, pas de raison produit de l'inverser pour l'instant. + let photos: PhotoRow[]; + try { + const { data, error } = await supabaseAdmin.rpc("get_event_photos", { + p_event_id: typedEvent.id, + }); + if (error) throw error; + photos = (data ?? []) as PhotoRow[]; + } catch (err) { + console.error("[public-gallery] get_event_photos failed", err); + return jsonResponse(500, { error: "internal_error" }); + } + + // Event sans photo : état normal (pas d'upload pour l'instant), pas une + // erreur — 200 avec photos: [], contrairement à admin-gallery?action=zip + // qui renvoie 404/no_photos (là, l'absence de photo empêche l'action + // demandée "générer un ZIP" ; ici il n'y a pas d'action, juste un état + // d'affichage à rendre). + const signedUrlByPath = new Map(); + if (photos.length > 0) { + const paths = photos.map((p) => p.url_storage); + const bucket = getStorageBucket(); + const { data: signedUrls, error: signError } = await supabaseAdmin.storage + .from(bucket) + .createSignedUrls(paths, GALLERY_SIGNED_URL_TTL_SECONDS); + + if (signError) { + console.error("[public-gallery] createSignedUrls failed", signError); + return jsonResponse(500, { error: "internal_error" }); + } + + for (let i = 0; i < paths.length; i++) { + const entry = signedUrls?.[i]; + if (entry?.signedUrl) { + signedUrlByPath.set(paths[i], entry.signedUrl); + } else if (entry?.error) { + // Une photo dont l'URL n'a pas pu être signée (ex. fichier absent du + // Storage alors que la ligne existe encore côté DB) ne doit pas + // faire échouer toute la galerie : on la renvoie avec + // signed_url: null, au front de gérer l'affichage. + console.warn( + `[public-gallery] signed url failed for ${paths[i]}`, + entry.error + ); + } + } + } + + return jsonResponse(200, { + event: { + nom: typedEvent.nom, + date_evenement: typedEvent.date_evenement, + statut: typedEvent.statut, + photo_count: photos.length, + }, + photos: photos.map((p) => ({ + id: p.id, + nom_invite: p.nom_invite, + message: p.message, + uploaded_at: p.uploaded_at, + signed_url: signedUrlByPath.get(p.url_storage) ?? null, + })), + }); +});