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…
+
-
-
+
+
+
+ Aucune photo pour l'instant — sois le·la premier·ère à en déposer une !
+
+
+
-
+
+
+