Branche la galerie invité au vrai backend (Edge Function public-gallery)

Nouvelle Edge Function publique (aucun token requis — même modèle de
confiance que l'upload, le slug n'est pas un secret) qui résout un slug
via get_public_event puis renvoie les photos de get_event_photos avec
des URLs signées. Déployée et testée en conditions réelles sur
kevin-lecou-hub (slug inconnu -> 404, event sans photo -> 200 vide,
event avec photo -> signed_url fonctionnelle), CORS vérifié.

Front (src/galerie/) : remplace les données factices par un vrai fetch +
sondage (polling 25s, pas de Supabase Realtime — le schéma photobooth
reste volontairement jamais exposé, l'activer aurait demandé de rouvrir
ce verrou RLS). Rotation des cartes figée par photo (pas de "tremblement"
au rafraîchissement), seules les nouvelles photos jouent l'animation de
chute. États chargement/erreur/vide ajoutés (absents de la maquette).
Testé en réel : une photo uploadée apparaît bien sur le mur.

Suppression des données de maquette (gallery-mock.js, mock-photos/).
This commit is contained in:
2026-09-18 09:21:31 +02:00
parent 107e5c619a
commit f4322850d6
15 changed files with 527 additions and 136 deletions
+10
View File
@@ -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",
};
-102
View File
@@ -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);
});
+247
View File
@@ -0,0 +1,247 @@
// Écran galerie invité — photobooth-qr
//
// Contrat backend : GET {SUPABASE_URL}/functions/v1/public-gallery?slug=<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);
+36 -21
View File
@@ -5,20 +5,22 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Galerie de l'événement</title>
<!--
Écran "galerie invité" — nouvelle direction artistique "Polaroid Néon"
Écran "galerie invité" — direction artistique "Polaroid Néon"
(.claude/skills/photobooth-da/SKILL.md). Décision actée le 2026-09-18 :
les invités voient désormais toutes les photos de l'événement (revirement
par rapport au cahier des charges initial "upload only"), cf.
les invités voient toutes les photos de l'événement (revirement par
rapport au cahier des charges initial "upload only"), cf.
docs/decisions.md.
⚠️ MAQUETTE VISUELLE, PAS ENCORE CÂBLÉE AU BACKEND : les 8 photos
affichées viennent de src/galerie/mock-photos/ et gallery-mock.js, pas de
Supabase. Il manque encore côté backend une RPC/Edge Function publique de
listing (les fonctions actuelles get_event_photos/admin-gallery sont
réservées à service_role + admin_token — aucune ne convient à un accès
invité anonyme). Priorité donnée au rendu visuel par consigne explicite
("le plus abouti visuellement"). Route prévue : /galerie/<slug>, cf.
deploy/nginx-photobooth.conf à mettre à jour lors du câblage réel.
Câblé au vrai backend depuis le 2026-09-18 : Edge Function publique
`public-gallery?slug=<slug>` (aucun token requis, même modèle de
confiance que l'upload — le slug 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 sur photobooth.photos), l'activer aurait demandé de
rouvrir ce verrou. Le slug est lu côté client via
window.location.pathname, cf. gallery.js — suppose une réécriture
serveur qui renvoie ce fichier pour toute requête sous /galerie/*
(voir deploy/nginx-photobooth.conf).
-->
<meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#12100E">
@@ -37,20 +39,33 @@
<span class="brand__name">photobooth</span>
</p>
<h1 id="event-name">Galerie de l'événement</h1>
<p class="page__kicker">Tous les souvenirs déposés par les invités, en direct</p>
<p id="event-meta" class="page__kicker">Tous les souvenirs déposés par les invités, en direct</p>
</header>
<ul id="polaroid-grid" class="polaroid-grid" aria-live="polite"></ul>
<section id="status-loading" class="state-block state-block--loading" role="status">
<span class="spinner" aria-hidden="true"></span>
<p>Chargement de la galerie…</p>
</section>
<!-- Bouton de démo temporaire : rejoue l'effet "chute" d'un nouveau
polaroid dans la pile, pour évaluer l'animation sans attendre un
vrai envoi. À retirer une fois le câblage Supabase réel en place
(le déclenchement normal sera un nouvel insert détecté en base). -->
<button type="button" id="demo-add-btn" class="demo-add-btn">
+ Simuler un nouvel envoi (démo)
</button>
<section id="status-error" class="state-block state-block--error" hidden role="alert">
<p id="status-error-message"></p>
<button type="button" id="retry-btn" hidden>Réessayer</button>
</section>
<p id="empty-state" class="empty-state" hidden>
Aucune photo pour l'instant — sois le·la premier·ère à en déposer une&nbsp;!
</p>
<ul id="polaroid-grid" class="polaroid-grid" aria-live="polite" hidden></ul>
</main>
<script type="module" src="/galerie/gallery-mock.js"></script>
<!--
config.js n'est PAS commité (voir .gitignore) : copier config.example.js
en config.js et renseigner les vraies valeurs Supabase avant déploiement.
Chargé en script classique (donc synchrone, avant le module) pour garantir
que window.__PHOTOBOOTH_CONFIG__ existe avant l'exécution de gallery.js.
-->
<script src="/galerie/config.js"></script>
<script type="module" src="/galerie/gallery.js"></script>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

+54 -13
View File
@@ -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;
}