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/).
@@ -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/
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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 !
|
||||
</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>
|
||||
|
||||
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 8.4 KiB |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Edge Function `public-gallery`
|
||||
//
|
||||
// Contrat : GET {SUPABASE_URL}/functions/v1/public-gallery?slug=<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<string, string>();
|
||||
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,
|
||||
})),
|
||||
});
|
||||
});
|
||||