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/).
@@ -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;
|
||||
}
|
||||
|
||||