Ajoute la V1 fonctionnelle : upload invité, galerie admin, QR code, backend Supabase
Schéma dédié `photobooth` (jamais exposé via PostgREST, accès exclusivement via fonctions SECURITY DEFINER dans public) avec RLS, policies Storage sur bucket privé event-photos, Edge Functions admin-gallery et expire-events, et job pg_cron d'expiration J+7. Le tout déployé et testé en conditions réelles sur le projet Supabase kevin-lecou-hub avant relecture sécurité (faille d'abus sur insert_public_photo corrigée en cours de route). Côté front : page upload invité (compression + HEIC + RGPD), galerie admin avec export ZIP, générateur de QR code autonome. Workflow n8n de purge Storage J+7 fourni (à activer manuellement côté n8n).
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
// Copier ce fichier en config.js (non commité, voir .gitignore) et renseigner
|
||||
// les vraies valeurs. Ce fichier est chargé avant gallery.js dans index.html.
|
||||
//
|
||||
// SUPABASE_URL sert uniquement à construire l'URL de l'Edge Function
|
||||
// "admin-gallery" (https://<SUPABASE_URL>/functions/v1/admin-gallery).
|
||||
// Le contrôle d'accès à la galerie repose sur le slug + le token secret
|
||||
// transmis dans l'URL de la page (/admin/<slug>?token=...), PAS sur une clé
|
||||
// Supabase : aucune clé n'est donc requise en théorie.
|
||||
window.__PHOTOBOOTH_CONFIG__ = {
|
||||
SUPABASE_URL: "https://xxxxxxxxxxxx.supabase.co",
|
||||
|
||||
// Requis. La fonction "admin-gallery" est déployée avec verify_jwt=true
|
||||
// (vérifié en conditions réelles le 2026-09-16) : la gateway Supabase
|
||||
// exige un header apikey/Authorization valide en plus du slug+token.
|
||||
// La clé publique (anon/publishable) suffit ici — le contrôle d'accès
|
||||
// réel à la galerie reste entièrement basé sur le slug + admin_token,
|
||||
// cette clé ne fait que satisfaire la vérification JWT de la gateway.
|
||||
SUPABASE_ANON_KEY: "sb_publishable_xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
};
|
||||
@@ -0,0 +1,469 @@
|
||||
// Page galerie admin — photobooth-qr
|
||||
//
|
||||
// Aucune dépendance framework, aucune lib tierce. Accès contrôlé par un
|
||||
// slug (lu dans le chemin /admin/<slug>) et un token secret (lu dans le
|
||||
// query param ?token=..., voir docs/decisions.md : "accès admin via token
|
||||
// secret dans l'URL, pas de login"). L'un ou l'autre invalide -> message
|
||||
// générique, on ne distingue jamais "slug inconnu" de "token invalide"
|
||||
// côté client (cf. contrat de l'Edge Function).
|
||||
//
|
||||
// Contrat backend attendu (Edge Function "admin-gallery", implémentée en
|
||||
// parallèle par un autre agent — voir rapport de tâche pour le détail des
|
||||
// points d'ambiguïté identifiés) :
|
||||
// GET {SUPABASE_URL}/functions/v1/admin-gallery?slug=<slug>&token=<token>
|
||||
// -> 200 { event: { nom, date_evenement, statut, photo_count },
|
||||
// photos: [ { id, nom_invite, message, uploaded_at,
|
||||
// signed_url, expires_in } ] }
|
||||
// -> 403/404 générique si slug/token invalide (pas de distinction)
|
||||
// GET {...}&action=zip
|
||||
// -> 200 application/zip (binaire, toutes les photos de l'événement)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Éléments DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 contentBlock = document.getElementById("gallery-content");
|
||||
const statusBanner = document.getElementById("status-banner");
|
||||
const refreshBtn = document.getElementById("refresh-btn");
|
||||
const zipBtn = document.getElementById("download-zip-btn");
|
||||
const zipStatusEl = document.getElementById("zip-status");
|
||||
const emptyStateEl = document.getElementById("empty-state");
|
||||
const photoGridEl = document.getElementById("photo-grid");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// État
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const state = {
|
||||
slug: null,
|
||||
token: null,
|
||||
event: null,
|
||||
photos: [],
|
||||
loadedAt: 0, // Date.now() au moment de la dernière réponse reçue
|
||||
lastRetryAction: null,
|
||||
isZipping: false,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Erreurs applicatives : un code technique -> un message clair pour l'admin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
"config-missing":
|
||||
"Cette page n'est pas configurée correctement. Contacte le développeur du service.",
|
||||
"link-invalid":
|
||||
"Ce lien est invalide ou a expiré. Vérifie l'URL utilisée, ou redemande un lien d'accès.",
|
||||
network:
|
||||
"La connexion a été coupée. Vérifie ta connexion internet et réessaie.",
|
||||
"server-error":
|
||||
"Le service est momentanément indisponible. Réessaie dans quelques instants.",
|
||||
unknown: "Une erreur inattendue est survenue. Réessaie, ou reviens un peu plus tard.",
|
||||
};
|
||||
|
||||
const RETRYABLE_CODES = new Set(["network", "server-error", "unknown"]);
|
||||
|
||||
function appError(code, cause) {
|
||||
const err = new Error(code);
|
||||
err.code = code;
|
||||
err.cause = cause;
|
||||
return err;
|
||||
}
|
||||
|
||||
function normalizeError(err) {
|
||||
if (err && err.code && ERROR_MESSAGES[err.code]) return err;
|
||||
if (err instanceof TypeError) return appError("network", err); // fetch() qui échoue = coupure réseau
|
||||
return appError("unknown", err);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getConfig() {
|
||||
const cfg = window.__PHOTOBOOTH_CONFIG__;
|
||||
if (!cfg || !cfg.SUPABASE_URL) {
|
||||
throw appError("config-missing");
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lecture slug (chemin) + token (query param)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getSlugFromPath() {
|
||||
const match = window.location.pathname.match(/\/admin\/([^/]+)\/?$/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function getTokenFromQuery() {
|
||||
return new URLSearchParams(window.location.search).get("token");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Affichage des états
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function showLoading() {
|
||||
loadingBlock.hidden = false;
|
||||
errorBlock.hidden = true;
|
||||
contentBlock.hidden = true;
|
||||
}
|
||||
|
||||
function showError(rawErr, retryAction) {
|
||||
const err = normalizeError(rawErr);
|
||||
loadingBlock.hidden = true;
|
||||
contentBlock.hidden = true;
|
||||
errorBlock.hidden = false;
|
||||
errorMessageEl.textContent = ERROR_MESSAGES[err.code] || ERROR_MESSAGES.unknown;
|
||||
|
||||
state.lastRetryAction = retryAction || null;
|
||||
const canRetry = Boolean(retryAction) && RETRYABLE_CODES.has(err.code);
|
||||
retryBtn.hidden = !canRetry;
|
||||
|
||||
if (err.cause) {
|
||||
console.error(`[gallery:${err.code}]`, err.cause);
|
||||
}
|
||||
}
|
||||
|
||||
const STATUT_LABELS = {
|
||||
actif: "Actif",
|
||||
expire: "Expiré",
|
||||
archive: "Archivé",
|
||||
};
|
||||
|
||||
function formatDateFr(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
// "date" Postgres arrive en "YYYY-MM-DD" : on force un parsing en heure
|
||||
// locale pour éviter un décalage de jour selon le fuseau du navigateur.
|
||||
const d = new Date(`${dateStr}T00:00:00`);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d.toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
function formatDateTimeFr(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
const d = new Date(dateStr);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d.toLocaleString("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function renderHeader(event) {
|
||||
eventNameEl.textContent = event.nom || "Galerie événement";
|
||||
document.title = `${event.nom || "Galerie événement"} — admin`;
|
||||
|
||||
const parts = [];
|
||||
const dateFr = formatDateFr(event.date_evenement);
|
||||
if (dateFr) parts.push(dateFr);
|
||||
const count = Number.isFinite(event.photo_count) ? event.photo_count : state.photos.length;
|
||||
parts.push(`${count} photo${count > 1 ? "s" : ""}`);
|
||||
const statutLabel = STATUT_LABELS[event.statut] || event.statut || "";
|
||||
if (statutLabel) parts.push(statutLabel);
|
||||
|
||||
eventMetaEl.textContent = parts.join(" · ");
|
||||
eventMetaEl.hidden = parts.length === 0;
|
||||
}
|
||||
|
||||
function renderStatusBanner(event) {
|
||||
if (event.statut === "expire") {
|
||||
statusBanner.textContent =
|
||||
"Cet événement est expiré : les photos ne sont plus disponibles (suppression automatique après la période de conservation).";
|
||||
statusBanner.hidden = false;
|
||||
} else if (event.statut === "archive") {
|
||||
statusBanner.textContent = "Cet événement est archivé. Les photos ne sont plus disponibles.";
|
||||
statusBanner.hidden = false;
|
||||
} else {
|
||||
statusBanner.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function renderEmptyState(event) {
|
||||
if (state.photos.length > 0) {
|
||||
emptyStateEl.hidden = true;
|
||||
return;
|
||||
}
|
||||
emptyStateEl.hidden = false;
|
||||
emptyStateEl.textContent =
|
||||
event.statut === "actif"
|
||||
? "Aucune photo n'a encore été déposée pour cet événement."
|
||||
: "Il n'y a plus de photo disponible pour cet événement.";
|
||||
}
|
||||
|
||||
function photoExpiryTimestamp(photo) {
|
||||
if (!Number.isFinite(photo.expires_in)) return null;
|
||||
return state.loadedAt + photo.expires_in * 1000;
|
||||
}
|
||||
|
||||
function isPhotoLinkLikelyExpired(photo) {
|
||||
const expiresAt = photoExpiryTimestamp(photo);
|
||||
return expiresAt !== null && Date.now() >= expiresAt;
|
||||
}
|
||||
|
||||
function renderPhotoGrid() {
|
||||
photoGridEl.textContent = "";
|
||||
|
||||
for (const photo of state.photos) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "photo-card";
|
||||
|
||||
const thumbWrap = document.createElement("div");
|
||||
thumbWrap.className = "photo-card__thumb-wrap";
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.className = "photo-card__thumb";
|
||||
img.loading = "lazy";
|
||||
img.alt = photo.nom_invite
|
||||
? `Photo envoyée par ${photo.nom_invite}`
|
||||
: "Photo envoyée par un invité";
|
||||
img.src = photo.signed_url;
|
||||
img.onerror = () => {
|
||||
li.classList.add("photo-card--broken");
|
||||
};
|
||||
|
||||
const fallback = document.createElement("div");
|
||||
fallback.className = "photo-card__thumb-fallback";
|
||||
fallback.textContent = "Aperçu indisponible (lien peut-être expiré — clique sur Actualiser)";
|
||||
|
||||
thumbWrap.append(img, fallback);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "photo-card__meta";
|
||||
|
||||
if (photo.nom_invite) {
|
||||
const nameEl = document.createElement("p");
|
||||
nameEl.className = "photo-card__name";
|
||||
nameEl.textContent = photo.nom_invite;
|
||||
meta.appendChild(nameEl);
|
||||
}
|
||||
|
||||
if (photo.message) {
|
||||
const msgEl = document.createElement("p");
|
||||
msgEl.className = "photo-card__message";
|
||||
msgEl.textContent = photo.message;
|
||||
meta.appendChild(msgEl);
|
||||
}
|
||||
|
||||
const dateFr = formatDateTimeFr(photo.uploaded_at);
|
||||
if (dateFr) {
|
||||
const dateEl = document.createElement("p");
|
||||
dateEl.className = "photo-card__date";
|
||||
dateEl.textContent = dateFr;
|
||||
meta.appendChild(dateEl);
|
||||
}
|
||||
|
||||
const downloadLink = document.createElement("a");
|
||||
downloadLink.className = "photo-card__download";
|
||||
downloadLink.href = photo.signed_url;
|
||||
downloadLink.target = "_blank";
|
||||
downloadLink.rel = "noopener";
|
||||
// Indication pour les navigateurs same-origin ; pour une URL signée
|
||||
// cross-origin (cas normal ici), l'attribut "download" est ignoré par
|
||||
// la plupart des navigateurs, qui ouvrent l'image dans un nouvel onglet
|
||||
// à la place (limitation connue, acceptée pour éviter un fetch+blob par
|
||||
// photo qui multiplierait les requêtes réseau).
|
||||
downloadLink.download = "";
|
||||
downloadLink.textContent = "Télécharger";
|
||||
downloadLink.addEventListener("click", (evt) => {
|
||||
if (isPhotoLinkLikelyExpired(photo)) {
|
||||
evt.preventDefault();
|
||||
zipStatusEl.hidden = false;
|
||||
zipStatusEl.className = "zip-status zip-status--error";
|
||||
zipStatusEl.textContent =
|
||||
"Ce lien a probablement expiré. Clique sur \"Actualiser\" puis réessaie.";
|
||||
}
|
||||
});
|
||||
|
||||
li.append(thumbWrap, meta, downloadLink);
|
||||
photoGridEl.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
function renderContent(event, photos) {
|
||||
state.event = event;
|
||||
state.photos = Array.isArray(photos) ? photos : [];
|
||||
|
||||
loadingBlock.hidden = true;
|
||||
errorBlock.hidden = true;
|
||||
contentBlock.hidden = false;
|
||||
|
||||
renderHeader(event);
|
||||
renderStatusBanner(event);
|
||||
renderEmptyState(event);
|
||||
renderPhotoGrid();
|
||||
|
||||
const hasPhotos = state.photos.length > 0;
|
||||
zipBtn.disabled = !hasPhotos;
|
||||
zipBtn.title = hasPhotos ? "" : "Aucune photo à télécharger";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Appels réseau
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildFunctionUrl(action) {
|
||||
const { SUPABASE_URL } = getConfig();
|
||||
const url = new URL(`${SUPABASE_URL.replace(/\/+$/, "")}/functions/v1/admin-gallery`);
|
||||
url.searchParams.set("slug", state.slug);
|
||||
url.searchParams.set("token", state.token);
|
||||
if (action) url.searchParams.set("action", action);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function buildAuthHeaders() {
|
||||
// SUPABASE_ANON_KEY est optionnelle (voir config.example.js) : ne
|
||||
// renseignée que si l'Edge Function nécessite un header apikey/Authorization
|
||||
// en plus du token applicatif. Non requis dans le cas nominal attendu
|
||||
// (accès contrôlé uniquement par slug+token, verify_jwt=false côté
|
||||
// Supabase) — cf. rapport de tâche pour le détail de cette hypothèse.
|
||||
const cfg = window.__PHOTOBOOTH_CONFIG__ || {};
|
||||
if (!cfg.SUPABASE_ANON_KEY) return {};
|
||||
return {
|
||||
apikey: cfg.SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${cfg.SUPABASE_ANON_KEY}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function safeReadText(res) {
|
||||
try {
|
||||
return await res.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGallery() {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(buildFunctionUrl(), { headers: buildAuthHeaders() });
|
||||
} catch (err) {
|
||||
throw appError("network", err);
|
||||
}
|
||||
|
||||
if (res.status === 403 || res.status === 404) throw appError("link-invalid");
|
||||
if (res.status >= 500) throw appError("server-error", await safeReadText(res));
|
||||
if (!res.ok) throw appError("unknown", await safeReadText(res));
|
||||
|
||||
const data = await res.json().catch((err) => {
|
||||
throw appError("unknown", err);
|
||||
});
|
||||
|
||||
if (!data || !data.event) throw appError("unknown", data);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadGallery() {
|
||||
showLoading();
|
||||
try {
|
||||
const data = await fetchGallery();
|
||||
state.loadedAt = Date.now();
|
||||
renderContent(data.event, data.photos);
|
||||
} catch (err) {
|
||||
showError(err, () => loadGallery());
|
||||
}
|
||||
}
|
||||
|
||||
function parseZipFilename(res, fallback) {
|
||||
const header = res.headers.get("Content-Disposition") || "";
|
||||
const match = header.match(/filename\*?=(?:UTF-8'')?"?([^";\n]+)"?/i);
|
||||
return match ? decodeURIComponent(match[1]) : fallback;
|
||||
}
|
||||
|
||||
function triggerBlobDownload(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
async function downloadZip() {
|
||||
if (state.isZipping) return;
|
||||
state.isZipping = true;
|
||||
zipBtn.disabled = true;
|
||||
zipStatusEl.hidden = false;
|
||||
zipStatusEl.className = "zip-status";
|
||||
zipStatusEl.textContent = "Préparation du ZIP…";
|
||||
|
||||
try {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(buildFunctionUrl("zip"), { headers: buildAuthHeaders() });
|
||||
} catch (err) {
|
||||
throw appError("network", err);
|
||||
}
|
||||
|
||||
if (res.status === 403 || res.status === 404) throw appError("link-invalid");
|
||||
if (res.status >= 500) throw appError("server-error", await safeReadText(res));
|
||||
if (!res.ok) throw appError("unknown", await safeReadText(res));
|
||||
|
||||
const blob = await res.blob();
|
||||
const filename = parseZipFilename(res, `photos-${state.slug}.zip`);
|
||||
triggerBlobDownload(blob, filename);
|
||||
|
||||
zipStatusEl.textContent = "Téléchargement lancé.";
|
||||
} catch (err) {
|
||||
const normalized = normalizeError(err);
|
||||
zipStatusEl.className = "zip-status zip-status--error";
|
||||
zipStatusEl.textContent = ERROR_MESSAGES[normalized.code] || ERROR_MESSAGES.unknown;
|
||||
if (normalized.cause) console.error(`[gallery:zip:${normalized.code}]`, normalized.cause);
|
||||
} finally {
|
||||
state.isZipping = false;
|
||||
zipBtn.disabled = state.photos.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Événements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
retryBtn.addEventListener("click", () => {
|
||||
if (state.lastRetryAction) state.lastRetryAction();
|
||||
});
|
||||
|
||||
refreshBtn.addEventListener("click", () => {
|
||||
zipStatusEl.hidden = true;
|
||||
loadGallery();
|
||||
});
|
||||
|
||||
zipBtn.addEventListener("click", () => {
|
||||
downloadZip();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Démarrage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function init() {
|
||||
try {
|
||||
getConfig();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
return;
|
||||
}
|
||||
|
||||
state.slug = getSlugFromPath();
|
||||
state.token = getTokenFromQuery();
|
||||
|
||||
if (!state.slug || !state.token) {
|
||||
showError(appError("link-invalid"));
|
||||
return;
|
||||
}
|
||||
|
||||
loadGallery();
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Galerie événement — admin</title>
|
||||
<!--
|
||||
Cette page est servie pour toute URL du type /admin/<slug> (le slug est lu
|
||||
côté client depuis window.location.pathname, le token depuis les query
|
||||
params ?token=..., voir gallery.js). Comme pour /e/<slug> (voir
|
||||
src/e/index.html), ça suppose une règle de réécriture côté serveur qui
|
||||
renvoie ce fichier pour toute requête sous /admin/* — hors scope de ce
|
||||
fichier front, à mettre en place lors du déploiement.
|
||||
-->
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<header class="page__header">
|
||||
<h1 id="event-name">Galerie événement</h1>
|
||||
<p id="event-meta" class="event-meta" hidden></p>
|
||||
</header>
|
||||
|
||||
<section id="status-loading" class="state-block" role="status">
|
||||
<p>Chargement de la galerie…</p>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
|
||||
<section id="gallery-content" hidden>
|
||||
<p id="status-banner" class="status-banner" hidden></p>
|
||||
|
||||
<div class="toolbar">
|
||||
<button type="button" id="refresh-btn" class="btn btn--secondary">Actualiser</button>
|
||||
<button type="button" id="download-zip-btn" class="btn">Télécharger tout (ZIP)</button>
|
||||
</div>
|
||||
<p id="zip-status" class="zip-status" aria-live="polite" hidden></p>
|
||||
|
||||
<p id="empty-state" class="state-block" hidden></p>
|
||||
|
||||
<ul id="photo-grid" class="photo-grid"></ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!--
|
||||
config.js n'est PAS commité (voir .gitignore) : copier config.example.js
|
||||
en config.js et renseigner les vraies valeurs 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="config.js"></script>
|
||||
<script type="module" src="gallery.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,229 @@
|
||||
/* Page galerie admin — mobile-first mais utilisable au clavier/souris sur
|
||||
desktop (l'admin peut consulter depuis un ordinateur), sans dépendance
|
||||
externe. Mêmes tokens de couleur que la page invité (src/e/style.css)
|
||||
pour rester cohérent visuellement. */
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--color-bg: #faf9f7;
|
||||
--color-text: #2a2a28;
|
||||
--color-muted: #6b6b66;
|
||||
--color-accent: #8a5a44;
|
||||
--color-accent-contrast: #ffffff;
|
||||
--color-error-bg: #fdecea;
|
||||
--color-error-text: #7a1f14;
|
||||
--color-warning-bg: #fdf3e3;
|
||||
--color-warning-text: #7a5a14;
|
||||
--radius: 10px;
|
||||
--spacing: 1rem;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1rem 3rem;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.page__header {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page__header h1 {
|
||||
font-size: 1.4rem;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.event-meta {
|
||||
color: var(--color-muted);
|
||||
font-size: 0.9rem;
|
||||
margin: 0.4rem 0 0;
|
||||
}
|
||||
|
||||
.state-block {
|
||||
text-align: center;
|
||||
padding: 1.25rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.state-block--error {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error-text);
|
||||
}
|
||||
|
||||
.state-block p {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.state-block p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
background: var(--color-warning-bg);
|
||||
color: var(--color-warning-text);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem 1rem;
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 1rem;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-contrast);
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: #fff;
|
||||
color: var(--color-accent);
|
||||
border: 1px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.zip-status {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-muted);
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
.zip-status--error {
|
||||
color: var(--color-error-text);
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.photo-card {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.photo-card__thumb-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background: #ececec;
|
||||
}
|
||||
|
||||
.photo-card__thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-card__thumb-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.photo-card--broken .photo-card__thumb {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.photo-card--broken .photo-card__thumb-fallback {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.photo-card__meta {
|
||||
padding: 0.6rem 0.7rem 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.photo-card__name {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 0.2rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.photo-card__message {
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
margin: 0 0 0.35rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.photo-card__date {
|
||||
color: var(--color-muted);
|
||||
font-size: 0.72rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.photo-card__download {
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 0.55rem;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-contrast);
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.photo-card__download:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Générateur de QR code — photobooth-qr (admin)</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--border: #d8d8d8;
|
||||
--text: #1a1a1a;
|
||||
--muted: #666;
|
||||
--accent: #1a1a1a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 1.25rem;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background: #fafafa;
|
||||
max-width: 480px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
p.hint {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.25rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
input, select {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
font-size: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
.preview {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.preview svg {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
height: auto;
|
||||
}
|
||||
.preview .url {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
word-break: break-all;
|
||||
text-align: center;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 0.7rem;
|
||||
font-size: 0.95rem;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.secondary {
|
||||
background: #fff;
|
||||
color: var(--accent);
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error {
|
||||
color: #b00020;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.5rem;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Générateur de QR code — événement</h1>
|
||||
<p class="hint">
|
||||
Outil admin autonome : saisir le slug d'un événement et le domaine de
|
||||
l'app pour générer et exporter le QR code (PNG / SVG) à imprimer.
|
||||
Ne fait aucun appel à Supabase.
|
||||
</p>
|
||||
|
||||
<label for="slug">Slug de l'événement</label>
|
||||
<input id="slug" type="text" placeholder="mariage-julie-marc" autocomplete="off" />
|
||||
|
||||
<label for="baseUrl">Domaine de base de l'app</label>
|
||||
<input id="baseUrl" type="text" placeholder="https://photobooth.mondomaine.fr" autocomplete="off" />
|
||||
|
||||
<label for="ecLevel">Niveau de correction d'erreur</label>
|
||||
<select id="ecLevel">
|
||||
<option value="L">L — bas (fichier le plus léger)</option>
|
||||
<option value="M" selected>M — moyen (par défaut)</option>
|
||||
<option value="Q">Q — élevé (recommandé si affiché en extérieur)</option>
|
||||
<option value="H">H — maximal (support très exposé/manipulé)</option>
|
||||
</select>
|
||||
|
||||
<div class="preview" id="preview" hidden>
|
||||
<div id="svgContainer"></div>
|
||||
<div class="url" id="urlDisplay"></div>
|
||||
</div>
|
||||
|
||||
<div class="error" id="error" role="alert"></div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="downloadSvg" disabled>Télécharger SVG</button>
|
||||
<button id="downloadPng" class="secondary" disabled>Télécharger PNG</button>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
DEFAULT_BASE_URL,
|
||||
buildEventUrl,
|
||||
generateEventQrSvg,
|
||||
downloadEventQrSvg,
|
||||
downloadEventQrPng,
|
||||
} from './qrcode.js';
|
||||
|
||||
const slugInput = document.getElementById('slug');
|
||||
const baseUrlInput = document.getElementById('baseUrl');
|
||||
const ecLevelSelect = document.getElementById('ecLevel');
|
||||
const preview = document.getElementById('preview');
|
||||
const svgContainer = document.getElementById('svgContainer');
|
||||
const urlDisplay = document.getElementById('urlDisplay');
|
||||
const errorEl = document.getElementById('error');
|
||||
const btnSvg = document.getElementById('downloadSvg');
|
||||
const btnPng = document.getElementById('downloadPng');
|
||||
|
||||
// Champ laissé vide par défaut : si non renseigné, le module résout le
|
||||
// domaine via window.__PHOTOBOOTH_CONFIG__.APP_BASE_URL (si présent,
|
||||
// voir src/e/config.example.js) puis via DEFAULT_BASE_URL en secours.
|
||||
baseUrlInput.placeholder = DEFAULT_BASE_URL;
|
||||
|
||||
function currentOptions() {
|
||||
const baseUrl = baseUrlInput.value.trim() || undefined;
|
||||
const errorCorrectionLevel = ecLevelSelect.value;
|
||||
return { baseUrl, errorCorrectionLevel };
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
errorEl.textContent = '';
|
||||
const slug = slugInput.value.trim();
|
||||
|
||||
if (!slug) {
|
||||
preview.hidden = true;
|
||||
btnSvg.disabled = true;
|
||||
btnPng.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const options = currentOptions();
|
||||
const svg = generateEventQrSvg(slug, options);
|
||||
svgContainer.innerHTML = svg;
|
||||
urlDisplay.textContent = buildEventUrl(slug, options.baseUrl);
|
||||
preview.hidden = false;
|
||||
btnSvg.disabled = false;
|
||||
btnPng.disabled = false;
|
||||
} catch (err) {
|
||||
preview.hidden = true;
|
||||
btnSvg.disabled = true;
|
||||
btnPng.disabled = true;
|
||||
errorEl.textContent = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
slugInput.addEventListener('input', refresh);
|
||||
baseUrlInput.addEventListener('input', refresh);
|
||||
ecLevelSelect.addEventListener('change', refresh);
|
||||
|
||||
btnSvg.addEventListener('click', () => {
|
||||
const slug = slugInput.value.trim();
|
||||
if (!slug) return;
|
||||
downloadEventQrSvg(slug, { ...currentOptions(), filename: `qr-${slug}.svg` });
|
||||
});
|
||||
|
||||
btnPng.addEventListener('click', async () => {
|
||||
const slug = slugInput.value.trim();
|
||||
if (!slug) return;
|
||||
btnPng.disabled = true;
|
||||
try {
|
||||
await downloadEventQrPng(slug, { ...currentOptions(), filename: `qr-${slug}.png` });
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
} finally {
|
||||
btnPng.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Génération de QR code pour un événement photobooth-qr.
|
||||
*
|
||||
* Module autonome : ne dépend d'aucun appel Supabase / base de données.
|
||||
* Le `slug` de l'événement est fourni en paramètre par l'appelant (page
|
||||
* admin, script de création d'événement, etc.).
|
||||
*
|
||||
* Lib utilisée : `qrcode-generator` (Kazuhiko Arase, MIT), vendorée telle
|
||||
* quelle dans `src/vendor/qrcode-generator.mjs`. Choisie plutôt que
|
||||
* `browser-image-compression`-style bundle plus lourd car :
|
||||
* - zéro dépendance, un seul fichier, ~50 Ko non minifié ;
|
||||
* - fonctionne aussi bien côté navigateur que côté Node (pas de DOM requis
|
||||
* pour calculer la matrice ou produire le SVG) ;
|
||||
* - fournit déjà un export SVG vectoriel natif, idéal pour l'impression.
|
||||
*
|
||||
* Ce module expose :
|
||||
* - des fonctions pures (buildEventUrl, generateEventQrSvg, getEventQrMatrix)
|
||||
* utilisables sans navigateur ;
|
||||
* - des fonctions qui nécessitent un navigateur (canvas, Blob, URL, DOM)
|
||||
* pour l'export PNG et le déclenchement de téléchargement.
|
||||
*/
|
||||
|
||||
import { qrcode as createQrCodeMatrix } from '../../vendor/qrcode-generator.mjs';
|
||||
|
||||
/**
|
||||
* Domaine de secours utilisé pour construire l'URL publique de l'événement
|
||||
* si aucun `baseUrl` n'est fourni à l'appel ET qu'aucune config globale
|
||||
* n'est disponible. À ne PAS considérer comme le domaine réel de
|
||||
* production.
|
||||
*
|
||||
* Résolution du domaine (par ordre de priorité) :
|
||||
* 1. paramètre explicite `baseUrl` passé à la fonction ;
|
||||
* 2. `window.__PHOTOBOOTH_CONFIG__.APP_BASE_URL` si défini — même
|
||||
* convention de config que la page invité (voir `src/e/config.example.js`,
|
||||
* chargée via un `config.js` non commité) ;
|
||||
* 3. cette constante de secours.
|
||||
*
|
||||
* Volontairement pas de lecture directe de `process.env`/`import.meta.env` :
|
||||
* ce module doit rester utilisable tel quel dans un simple
|
||||
* `<script type="module">` sans étape de build.
|
||||
*/
|
||||
export const DEFAULT_BASE_URL = 'https://REPLACE_ME.example.com';
|
||||
|
||||
function resolveBaseUrl(baseUrl) {
|
||||
if (baseUrl) {
|
||||
return baseUrl;
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.__PHOTOBOOTH_CONFIG__?.APP_BASE_URL) {
|
||||
return window.__PHOTOBOOTH_CONFIG__.APP_BASE_URL;
|
||||
}
|
||||
return DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
/** Niveau de correction d'erreur par défaut ('L' | 'M' | 'Q' | 'H').
|
||||
* 'M' est un bon compromis densité/robustesse pour un usage courant.
|
||||
* Pour un support imprimé très exposé (extérieur, manipulation fréquente),
|
||||
* préférer 'Q' ou 'H' via les options. */
|
||||
export const DEFAULT_ERROR_CORRECTION_LEVEL = 'M';
|
||||
|
||||
/** Résolution cible par défaut (en pixels, largeur = hauteur) pour l'export
|
||||
* PNG. 1200 px est largement suffisant pour une impression nette (un QR
|
||||
* code étant un aplat noir/blanc à fort contraste, il ne nécessite pas une
|
||||
* définition aussi élevée qu'une photo pour rester lisible à l'impression). */
|
||||
export const DEFAULT_PNG_TARGET_SIZE_PX = 1200;
|
||||
|
||||
function assertNonEmptyString(value, label) {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new TypeError(`${label} requis (chaîne non vide).`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit l'URL publique d'upload d'un événement à partir de son slug.
|
||||
*
|
||||
* @param {string} slug - slug unique de l'événement (ex: "mariage-julie-marc").
|
||||
* @param {string} [baseUrl] - domaine de base de l'app
|
||||
* (ex: "https://photobooth.mondomaine.fr"), sans slash final obligatoire.
|
||||
* Si omis, résolu via `window.__PHOTOBOOTH_CONFIG__.APP_BASE_URL` puis
|
||||
* `DEFAULT_BASE_URL` (voir `resolveBaseUrl`).
|
||||
* @returns {string} URL complète, ex: "https://.../e/mariage-julie-marc".
|
||||
*/
|
||||
export function buildEventUrl(slug, baseUrl) {
|
||||
assertNonEmptyString(slug, 'slug');
|
||||
const resolvedBaseUrl = resolveBaseUrl(baseUrl);
|
||||
assertNonEmptyString(resolvedBaseUrl, 'baseUrl');
|
||||
|
||||
const cleanBase = resolvedBaseUrl.trim().replace(/\/+$/, '');
|
||||
const cleanSlug = encodeURIComponent(slug.trim());
|
||||
|
||||
return `${cleanBase}/e/${cleanSlug}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule la matrice QR (objet natif de la lib `qrcode-generator`) pour un
|
||||
* texte donné. Fonction bas niveau, utilisable pour un rendu personnalisé
|
||||
* (ex: dessiner directement sur un <canvas> existant dans une page admin).
|
||||
*
|
||||
* @param {string} text - contenu à encoder (typiquement une URL d'événement).
|
||||
* @param {{errorCorrectionLevel?: 'L'|'M'|'Q'|'H'}} [options]
|
||||
* @returns {{getModuleCount: () => number, isDark: (row: number, col: number) => boolean}}
|
||||
*/
|
||||
export function buildQrMatrix(text, { errorCorrectionLevel = DEFAULT_ERROR_CORRECTION_LEVEL } = {}) {
|
||||
assertNonEmptyString(text, 'text');
|
||||
|
||||
// typeNumber = 0 => la lib choisit automatiquement la plus petite version
|
||||
// de QR code capable de contenir les données (URL courte à moyenne ici).
|
||||
const qr = createQrCodeMatrix(0, errorCorrectionLevel);
|
||||
qr.addData(text);
|
||||
qr.make();
|
||||
return qr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule la matrice QR correspondant directement à l'URL d'un événement.
|
||||
*
|
||||
* @param {string} slug
|
||||
* @param {{baseUrl?: string, errorCorrectionLevel?: 'L'|'M'|'Q'|'H'}} [options]
|
||||
* @returns {{url: string, matrix: object}}
|
||||
*/
|
||||
export function getEventQrMatrix(slug, { baseUrl, errorCorrectionLevel } = {}) {
|
||||
const url = buildEventUrl(slug, baseUrl);
|
||||
const matrix = buildQrMatrix(url, { errorCorrectionLevel });
|
||||
return { url, matrix };
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le QR code d'un événement au format SVG (vectoriel), le meilleur
|
||||
* choix pour une impression de qualité (mise à l'échelle sans perte).
|
||||
* Fonction pure, ne nécessite pas de DOM (fonctionne aussi côté Node).
|
||||
*
|
||||
* @param {string} slug
|
||||
* @param {{
|
||||
* baseUrl?: string,
|
||||
* errorCorrectionLevel?: 'L'|'M'|'Q'|'H',
|
||||
* cellSize?: number,
|
||||
* margin?: number,
|
||||
* }} [options]
|
||||
* @returns {string} balise <svg>...</svg> complète.
|
||||
*/
|
||||
export function generateEventQrSvg(slug, options = {}) {
|
||||
const { baseUrl, errorCorrectionLevel, cellSize = 10, margin = cellSize * 4 } = options;
|
||||
const { url, matrix } = getEventQrMatrix(slug, { baseUrl, errorCorrectionLevel });
|
||||
|
||||
return matrix.createSvgTag({
|
||||
cellSize,
|
||||
margin,
|
||||
alt: `QR code d'accès à l'événement : ${url}`,
|
||||
title: `QR code — ${slug}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dessine la matrice QR d'un événement sur un <canvas> fourni par
|
||||
* l'appelant (utile pour un aperçu live dans une page admin, sans passer
|
||||
* par un export fichier). Nécessite un navigateur.
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {string} slug
|
||||
* @param {{
|
||||
* baseUrl?: string,
|
||||
* errorCorrectionLevel?: 'L'|'M'|'Q'|'H',
|
||||
* targetSizePx?: number,
|
||||
* marginModules?: number,
|
||||
* }} [options]
|
||||
* @returns {HTMLCanvasElement} le canvas passé en argument, modifié.
|
||||
*/
|
||||
export function renderEventQrToCanvas(canvas, slug, options = {}) {
|
||||
if (!canvas || typeof canvas.getContext !== 'function') {
|
||||
throw new TypeError('renderEventQrToCanvas: un élément <canvas> valide est requis.');
|
||||
}
|
||||
|
||||
const {
|
||||
baseUrl,
|
||||
errorCorrectionLevel,
|
||||
targetSizePx = DEFAULT_PNG_TARGET_SIZE_PX,
|
||||
marginModules = 4,
|
||||
} = options;
|
||||
|
||||
const { matrix } = getEventQrMatrix(slug, { baseUrl, errorCorrectionLevel });
|
||||
const moduleCount = matrix.getModuleCount();
|
||||
const cellSize = Math.max(1, Math.round(targetSizePx / (moduleCount + marginModules * 2)));
|
||||
const margin = cellSize * marginModules;
|
||||
const size = moduleCount * cellSize + margin * 2;
|
||||
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
ctx.fillStyle = '#000000';
|
||||
|
||||
for (let row = 0; row < moduleCount; row += 1) {
|
||||
for (let col = 0; col < moduleCount; col += 1) {
|
||||
if (matrix.isDark(row, col)) {
|
||||
ctx.fillRect(col * cellSize + margin, row * cellSize + margin, cellSize, cellSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le QR code d'un événement au format PNG (Blob), en résolution
|
||||
* suffisante pour l'impression. Nécessite un navigateur (canvas.toBlob).
|
||||
*
|
||||
* @param {string} slug
|
||||
* @param {{
|
||||
* baseUrl?: string,
|
||||
* errorCorrectionLevel?: 'L'|'M'|'Q'|'H',
|
||||
* targetSizePx?: number,
|
||||
* marginModules?: number,
|
||||
* }} [options]
|
||||
* @returns {Promise<Blob>}
|
||||
*/
|
||||
export function generateEventQrPngBlob(slug, options = {}) {
|
||||
const canvas = document.createElement('canvas');
|
||||
renderEventQrToCanvas(canvas, slug, options);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Échec de la génération du PNG (canvas.toBlob a renvoyé null).'));
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
function triggerBlobDownload(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
// Laisse le temps au téléchargement de démarrer avant de libérer l'URL.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Déclenche le téléchargement du QR code d'un événement au format SVG.
|
||||
* Nécessite un navigateur.
|
||||
*
|
||||
* @param {string} slug
|
||||
* @param {{
|
||||
* baseUrl?: string,
|
||||
* errorCorrectionLevel?: 'L'|'M'|'Q'|'H',
|
||||
* cellSize?: number,
|
||||
* margin?: number,
|
||||
* filename?: string,
|
||||
* }} [options]
|
||||
*/
|
||||
export function downloadEventQrSvg(slug, options = {}) {
|
||||
const svg = generateEventQrSvg(slug, options);
|
||||
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
||||
triggerBlobDownload(blob, options.filename || `qr-${slug}.svg`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Déclenche le téléchargement du QR code d'un événement au format PNG.
|
||||
* Nécessite un navigateur.
|
||||
*
|
||||
* @param {string} slug
|
||||
* @param {{
|
||||
* baseUrl?: string,
|
||||
* errorCorrectionLevel?: 'L'|'M'|'Q'|'H',
|
||||
* targetSizePx?: number,
|
||||
* marginModules?: number,
|
||||
* filename?: string,
|
||||
* }} [options]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function downloadEventQrPng(slug, options = {}) {
|
||||
const blob = await generateEventQrPngBlob(slug, options);
|
||||
triggerBlobDownload(blob, options.filename || `qr-${slug}.png`);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copier ce fichier en config.js (non commité, voir .gitignore) et renseigner
|
||||
// les vraies valeurs du projet Supabase. Ce fichier est chargé avant
|
||||
// upload.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",
|
||||
// Doit correspondre à SUPABASE_STORAGE_BUCKET dans .env
|
||||
SUPABASE_STORAGE_BUCKET: "event-photos",
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Ajouter une photo</title>
|
||||
<!--
|
||||
Cette page est servie pour toute URL du type /e/<slug> (le slug est lu
|
||||
côté client depuis window.location.pathname, voir upload.js).
|
||||
Ça suppose une règle de réécriture côté serveur (ex. nginx try_files)
|
||||
qui renvoie ce fichier pour toute requête sous /e/* — à mettre en place
|
||||
lors du déploiement, hors scope de ce fichier front.
|
||||
-->
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<header class="page__header">
|
||||
<h1 id="event-name">Ajouter une photo</h1>
|
||||
</header>
|
||||
|
||||
<section id="status-loading" class="state-block" role="status">
|
||||
<p>Chargement de l'événement…</p>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
|
||||
<section id="success-block" class="state-block state-block--success" hidden role="status">
|
||||
<p>Merci ! Ta photo est bien envoyée 🎉</p>
|
||||
<button type="button" id="add-another-btn">Ajouter une autre photo</button>
|
||||
</section>
|
||||
|
||||
<form id="upload-form" class="upload-form" hidden novalidate>
|
||||
<div class="field">
|
||||
<label for="photo-input">Ta photo</label>
|
||||
<!-- Pas d'attribut "capture" : on laisse le téléphone proposer à la
|
||||
fois l'appareil photo ET la galerie (capture forcerait l'un ou
|
||||
l'autre selon les navigateurs). -->
|
||||
<input
|
||||
type="file"
|
||||
id="photo-input"
|
||||
name="photo"
|
||||
accept="image/*,.heic,.heif"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<img id="preview" class="preview" alt="Aperçu de la photo choisie" hidden>
|
||||
|
||||
<div class="field">
|
||||
<label for="nom-input">Ton prénom <span class="optional">(facultatif)</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="nom-input"
|
||||
name="nom"
|
||||
maxlength="60"
|
||||
autocomplete="given-name"
|
||||
placeholder="Ex : Camille"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="message-input">Un petit mot <span class="optional">(facultatif)</span></label>
|
||||
<textarea
|
||||
id="message-input"
|
||||
name="message"
|
||||
maxlength="300"
|
||||
rows="3"
|
||||
placeholder="Ton petit mot pour l'occasion…"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<p class="legal-notice">
|
||||
En déposant une photo, vous acceptez qu'elle soit stockée temporairement
|
||||
dans le cadre de cet événement et mise à disposition de l'organisateur.
|
||||
Vos photos sont automatiquement supprimées 7 jours après la date de
|
||||
l'événement, sans action de votre part. Aucune photo n'est utilisée à
|
||||
d'autres fins, ni transmise à des tiers. Vous pouvez demander la
|
||||
suppression anticipée d'une photo en contactant l'organisateur de
|
||||
l'événement.
|
||||
</p>
|
||||
|
||||
<p id="progress-text" class="progress-text" aria-live="polite"></p>
|
||||
|
||||
<button type="submit" id="submit-btn">Envoyer ma photo</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<!--
|
||||
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 upload.js.
|
||||
-->
|
||||
<script src="config.js"></script>
|
||||
<script type="module" src="upload.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
/* Page upload invité — mobile-first, sobre, sans dépendance externe */
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--color-bg: #faf9f7;
|
||||
--color-text: #2a2a28;
|
||||
--color-muted: #6b6b66;
|
||||
--color-accent: #8a5a44;
|
||||
--color-accent-contrast: #ffffff;
|
||||
--color-error-bg: #fdecea;
|
||||
--color-error-text: #7a1f14;
|
||||
--color-success-bg: #eaf5ec;
|
||||
--color-success-text: #1e5c2d;
|
||||
--radius: 10px;
|
||||
--spacing: 1rem;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1rem 3rem;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.page__header {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page__header h1 {
|
||||
font-size: 1.4rem;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.state-block {
|
||||
text-align: center;
|
||||
padding: 1.25rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.state-block--error {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error-text);
|
||||
}
|
||||
|
||||
.state-block--success {
|
||||
background: var(--color-success-bg);
|
||||
color: var(--color-success-text);
|
||||
}
|
||||
|
||||
.state-block p {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.state-block p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.upload-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.field .optional {
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="file"],
|
||||
textarea {
|
||||
font: inherit;
|
||||
padding: 0.7rem 0.75rem;
|
||||
border: 1px solid #d8d5cf;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
padding: 0.55rem 0.5rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 4.5rem;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius);
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.legal-notice {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted);
|
||||
background: #f1efe9;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
min-height: 1.2em;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-muted);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem 1rem;
|
||||
min-height: 48px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#submit-btn,
|
||||
#add-another-btn {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-contrast);
|
||||
}
|
||||
|
||||
#submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#retry-btn {
|
||||
background: var(--color-error-text);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
+503
@@ -0,0 +1,503 @@
|
||||
// Page upload invité — photobooth-qr
|
||||
//
|
||||
// Aucune dépendance framework. Une seule lib tierce chargée systématiquement
|
||||
// (browser-image-compression, décision actée dans docs/decisions.md), plus
|
||||
// heic2any chargée UNIQUEMENT si un fichier HEIC/HEIF est détecté (lazy load,
|
||||
// pour ne pas alourdir la page pour les invités qui n'en ont pas besoin).
|
||||
//
|
||||
// Contrat backend (schéma `photobooth`, privé, jamais exposé via PostgREST —
|
||||
// cf. supabase/migrations/20260916090000_create_photobooth_schema.sql) :
|
||||
// - RPC "get_public_event(p_slug text)" (POST .../rpc/get_public_event) :
|
||||
// { id, nom, statut, expire_at } (ou null) SANS exposer admin_token.
|
||||
// - RPC "insert_public_photo(p_event_id, p_url_storage, p_nom_invite,
|
||||
// p_message)" (POST .../rpc/insert_public_photo) : seul chemin d'écriture,
|
||||
// revalide elle-même que l'event accepte encore les uploads.
|
||||
|
||||
import imageCompression from "../vendor/browser-image-compression.mjs";
|
||||
|
||||
const MAX_INPUT_BYTES = 15 * 1024 * 1024; // 15 Mo avant compression (cahier des charges)
|
||||
const MAX_OUTPUT_BYTES = 6 * 1024 * 1024; // garde-fou après compression (cible ~2-3 Mo)
|
||||
|
||||
const COMPRESSION_OPTIONS = {
|
||||
maxSizeMB: 2,
|
||||
maxWidthOrHeight: 1920,
|
||||
useWebWorker: true,
|
||||
fileType: "image/jpeg",
|
||||
initialQuality: 0.8,
|
||||
preserveExif: false, // on ne conserve pas les métadonnées (GPS, etc.) — minimisation RGPD
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Éléments DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const eventNameEl = document.getElementById("event-name");
|
||||
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 successBlock = document.getElementById("success-block");
|
||||
const addAnotherBtn = document.getElementById("add-another-btn");
|
||||
const form = document.getElementById("upload-form");
|
||||
const photoInput = document.getElementById("photo-input");
|
||||
const previewImg = document.getElementById("preview");
|
||||
const nomInput = document.getElementById("nom-input");
|
||||
const messageInput = document.getElementById("message-input");
|
||||
const progressText = document.getElementById("progress-text");
|
||||
const submitBtn = document.getElementById("submit-btn");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// État
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const state = {
|
||||
event: null,
|
||||
selectedFile: null,
|
||||
compressedBlob: null,
|
||||
isSubmitting: false,
|
||||
lastRetryAction: null,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Erreurs applicatives : un code technique -> un message clair pour l'invité
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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.",
|
||||
"event-closed":
|
||||
"Cet événement n'accepte plus de photos (il est clos). Merci quand même d'avoir essayé !",
|
||||
"file-missing": "Choisis d'abord une photo avant d'envoyer.",
|
||||
"file-not-image":
|
||||
"Ce fichier n'est pas une photo. Choisis une image (JPEG, PNG, HEIC…).",
|
||||
"file-too-large":
|
||||
"Cette photo est trop lourde (plus de 15 Mo). Essaie avec une autre photo.",
|
||||
"heic-load-failed":
|
||||
"Impossible de préparer la conversion de cette photo iPhone (HEIC). Vérifie ta connexion et réessaie, ou choisis une autre photo.",
|
||||
"heic-convert-failed":
|
||||
"Impossible de lire cette photo au format HEIC. Essaie de changer le format des photos dans les réglages de ton iPhone (Réglages > Appareil photo > Formats > « Le plus compatible »), ou choisis une autre photo.",
|
||||
"compression-failed":
|
||||
"La préparation de la photo a échoué. Réessaie, ou choisis une autre photo.",
|
||||
"compressed-too-large":
|
||||
"Cette photo reste trop lourde même après compression. Essaie une autre photo.",
|
||||
network:
|
||||
"La connexion a été coupée pendant l'envoi. Vérifie ta connexion internet et réessaie.",
|
||||
"storage-upload-failed":
|
||||
"L'envoi de la photo a échoué. Réessaie dans un instant.",
|
||||
"db-insert-failed":
|
||||
"Ta photo a bien été transmise, mais une erreur est survenue à la dernière étape. Réessaie, ou préviens l'organisateur si ça persiste.",
|
||||
"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.",
|
||||
};
|
||||
|
||||
// Codes pour lesquels on propose un bouton "Réessayer" (échecs transitoires,
|
||||
// pas la peine pour un fichier invalide ou un événement clos).
|
||||
const RETRYABLE_CODES = new Set([
|
||||
"network",
|
||||
"storage-upload-failed",
|
||||
"db-insert-failed",
|
||||
"server-overloaded",
|
||||
"compression-failed",
|
||||
"heic-load-failed",
|
||||
"heic-convert-failed",
|
||||
]);
|
||||
|
||||
function appError(code, cause) {
|
||||
const err = new Error(code);
|
||||
err.code = code;
|
||||
err.cause = cause;
|
||||
return err;
|
||||
}
|
||||
|
||||
function normalizeError(err) {
|
||||
if (err && err.code && ERROR_MESSAGES[err.code]) return err;
|
||||
if (err instanceof TypeError) return appError("network", err); // fetch() qui échoue = coupure réseau
|
||||
return appError("unknown", err);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getConfig() {
|
||||
const cfg = window.__PHOTOBOOTH_CONFIG__;
|
||||
if (!cfg || !cfg.SUPABASE_URL || !cfg.SUPABASE_ANON_KEY || !cfg.SUPABASE_STORAGE_BUCKET) {
|
||||
throw appError("config-missing");
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utilitaires
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateId() {
|
||||
if (window.crypto && typeof window.crypto.randomUUID === "function") {
|
||||
return window.crypto.randomUUID();
|
||||
}
|
||||
// Repli simple pour de très vieux navigateurs (pas un usage cryptographique ici)
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function getSlugFromPath() {
|
||||
const match = window.location.pathname.match(/\/e\/([^/]+)\/?$/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function isHeicFile(file) {
|
||||
const type = (file.type || "").toLowerCase();
|
||||
if (type === "image/heic" || type === "image/heif") return true;
|
||||
return /\.hei[cf]$/i.test(file.name || "");
|
||||
}
|
||||
|
||||
function isAcceptableImageFile(file) {
|
||||
if (isHeicFile(file)) return true;
|
||||
if (file.type && file.type.startsWith("image/")) return true;
|
||||
// Sur certains navigateurs Android, un fichier HEIC peut arriver avec un
|
||||
// type MIME vide ou générique : on se rabat sur l'extension.
|
||||
return /\.(jpe?g|png|gif|webp|bmp)$/i.test(file.name || "");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Affichage des états
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function showLoading() {
|
||||
loadingBlock.hidden = false;
|
||||
errorBlock.hidden = true;
|
||||
successBlock.hidden = true;
|
||||
form.hidden = true;
|
||||
}
|
||||
|
||||
function showForm(event) {
|
||||
eventNameEl.textContent = event.nom || "Ajouter une photo";
|
||||
loadingBlock.hidden = true;
|
||||
errorBlock.hidden = true;
|
||||
successBlock.hidden = true;
|
||||
form.hidden = false;
|
||||
}
|
||||
|
||||
function showError(rawErr, retryAction) {
|
||||
const err = normalizeError(rawErr);
|
||||
loadingBlock.hidden = true;
|
||||
successBlock.hidden = true;
|
||||
errorBlock.hidden = false;
|
||||
errorMessageEl.textContent = ERROR_MESSAGES[err.code] || ERROR_MESSAGES.unknown;
|
||||
|
||||
state.lastRetryAction = retryAction || null;
|
||||
const canRetry = Boolean(retryAction) && RETRYABLE_CODES.has(err.code);
|
||||
retryBtn.hidden = !canRetry;
|
||||
|
||||
// Erreurs bloquantes de chargement d'événement : on masque le formulaire.
|
||||
// Erreurs liées à une tentative d'envoi : le formulaire reste visible.
|
||||
if (["event-not-found", "event-closed", "config-missing"].includes(err.code)) {
|
||||
form.hidden = true;
|
||||
}
|
||||
|
||||
if (err.cause) {
|
||||
// Pas de jargon technique affiché à l'invité, mais on garde une trace
|
||||
// en console pour le débogage.
|
||||
console.error(`[upload:${err.code}]`, err.cause);
|
||||
}
|
||||
}
|
||||
|
||||
function showSuccess() {
|
||||
loadingBlock.hidden = true;
|
||||
errorBlock.hidden = true;
|
||||
form.hidden = true;
|
||||
successBlock.hidden = false;
|
||||
}
|
||||
|
||||
function setProgress(text) {
|
||||
progressText.textContent = text || "";
|
||||
}
|
||||
|
||||
function setSubmitting(isSubmitting) {
|
||||
state.isSubmitting = isSubmitting;
|
||||
submitBtn.disabled = isSubmitting;
|
||||
submitBtn.textContent = isSubmitting ? "Envoi en cours…" : "Envoyer ma photo";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chargement de l'événement (vérifie existence + statut actif + non expiré)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fetchEventPublicInfo(slug) {
|
||||
const { SUPABASE_URL, SUPABASE_ANON_KEY } = getConfig();
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/get_public_event`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({ p_slug: slug }),
|
||||
});
|
||||
} 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 safeReadText(res));
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
const event = Array.isArray(data) ? data[0] : data;
|
||||
if (!event) throw appError("event-not-found");
|
||||
return event;
|
||||
}
|
||||
|
||||
async function safeReadText(res) {
|
||||
try {
|
||||
return await res.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isEventActive(event) {
|
||||
if (!event) return false;
|
||||
if (event.statut !== "actif") return false;
|
||||
if (event.expire_at && new Date(event.expire_at).getTime() <= Date.now()) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function initEventCheck() {
|
||||
showLoading();
|
||||
const slug = getSlugFromPath();
|
||||
if (!slug) {
|
||||
showError(appError("event-not-found"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const event = await fetchEventPublicInfo(slug);
|
||||
if (!isEventActive(event)) throw appError("event-closed");
|
||||
state.event = event;
|
||||
showForm(event);
|
||||
} catch (err) {
|
||||
showError(err, () => initEventCheck());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversion HEIC (lazy load) + compression
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let heic2anyLoadPromise = null;
|
||||
|
||||
function loadHeic2any() {
|
||||
if (window.heic2any) return Promise.resolve();
|
||||
if (heic2anyLoadPromise) return heic2anyLoadPromise;
|
||||
|
||||
heic2anyLoadPromise = new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = new URL("../vendor/heic2any.min.js", import.meta.url).href;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => {
|
||||
heic2anyLoadPromise = null;
|
||||
reject(appError("heic-load-failed"));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return heic2anyLoadPromise;
|
||||
}
|
||||
|
||||
async function convertHeicToJpeg(file) {
|
||||
await loadHeic2any();
|
||||
let resultBlob;
|
||||
try {
|
||||
resultBlob = await window.heic2any({ blob: file, toType: "image/jpeg", quality: 0.9 });
|
||||
} catch (err) {
|
||||
throw appError("heic-convert-failed", err);
|
||||
}
|
||||
const blob = Array.isArray(resultBlob) ? resultBlob[0] : resultBlob;
|
||||
return new File([blob], file.name.replace(/\.hei[cf]$/i, ".jpg"), { type: "image/jpeg" });
|
||||
}
|
||||
|
||||
async function normalizeAndCompress(file) {
|
||||
let workingFile = file;
|
||||
if (isHeicFile(file)) {
|
||||
setProgress("Conversion de la photo…");
|
||||
workingFile = await convertHeicToJpeg(file);
|
||||
}
|
||||
|
||||
setProgress("Compression de la photo…");
|
||||
let compressed;
|
||||
try {
|
||||
compressed = await imageCompression(workingFile, COMPRESSION_OPTIONS);
|
||||
} catch (err) {
|
||||
throw appError("compression-failed", err);
|
||||
}
|
||||
|
||||
if (compressed.size > MAX_OUTPUT_BYTES) {
|
||||
throw appError("compressed-too-large");
|
||||
}
|
||||
|
||||
return compressed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Envoi vers Supabase (Storage + table photos)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function uploadToStorage(eventId, blob) {
|
||||
const { SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_STORAGE_BUCKET } = getConfig();
|
||||
const path = `${eventId}/${generateId()}.jpg`;
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(
|
||||
`${SUPABASE_URL}/storage/v1/object/${encodeURIComponent(SUPABASE_STORAGE_BUCKET)}/${path}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
|
||||
"Content-Type": blob.type || "image/jpeg",
|
||||
"x-upsert": "false",
|
||||
},
|
||||
body: blob,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
throw appError("network", err);
|
||||
}
|
||||
|
||||
if (res.status >= 500) throw appError("server-overloaded", await safeReadText(res));
|
||||
if (!res.ok) throw appError("storage-upload-failed", await safeReadText(res));
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
async function insertPhotoRow(eventId, path, nom, message) {
|
||||
const { SUPABASE_URL, SUPABASE_ANON_KEY } = getConfig();
|
||||
|
||||
// photobooth.photos n'est jamais exposée directement (schéma privé, cf.
|
||||
// supabase/migrations/20260916090000_create_photobooth_schema.sql) : le
|
||||
// seul chemin d'écriture est la RPC public.insert_public_photo, qui
|
||||
// revalide elle-même que l'event accepte encore les uploads.
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/insert_public_photo`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=minimal",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_event_id: eventId,
|
||||
p_url_storage: path,
|
||||
p_nom_invite: nom || null,
|
||||
p_message: message || null,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
throw appError("network", err);
|
||||
}
|
||||
|
||||
if (res.status >= 500) throw appError("server-overloaded", await safeReadText(res));
|
||||
if (!res.ok) throw appError("db-insert-failed", await safeReadText(res));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formulaire
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let previewObjectUrl = null;
|
||||
|
||||
photoInput.addEventListener("change", () => {
|
||||
// Nouveau fichier choisi -> on invalide toute compression déjà faite.
|
||||
state.compressedBlob = null;
|
||||
state.selectedFile = photoInput.files[0] || null;
|
||||
|
||||
if (previewObjectUrl) {
|
||||
URL.revokeObjectURL(previewObjectUrl);
|
||||
previewObjectUrl = null;
|
||||
}
|
||||
|
||||
const file = state.selectedFile;
|
||||
if (file && file.type && file.type.startsWith("image/")) {
|
||||
previewObjectUrl = URL.createObjectURL(file);
|
||||
previewImg.src = previewObjectUrl;
|
||||
previewImg.hidden = false;
|
||||
previewImg.onerror = () => {
|
||||
previewImg.hidden = true;
|
||||
};
|
||||
} else {
|
||||
// Pas d'aperçu fiable pour les HEIC sur la plupart des navigateurs.
|
||||
previewImg.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
async function submitPhoto() {
|
||||
if (state.isSubmitting) return;
|
||||
if (!state.event) {
|
||||
showError(appError("event-closed"));
|
||||
return;
|
||||
}
|
||||
|
||||
const file = photoInput.files[0];
|
||||
if (!file) return showError(appError("file-missing"));
|
||||
if (!isAcceptableImageFile(file)) return showError(appError("file-not-image"));
|
||||
if (file.size > MAX_INPUT_BYTES) return showError(appError("file-too-large"));
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
let blob = state.compressedBlob;
|
||||
if (!blob || state.selectedFile !== file) {
|
||||
blob = await normalizeAndCompress(file);
|
||||
state.compressedBlob = blob;
|
||||
state.selectedFile = file;
|
||||
}
|
||||
|
||||
setProgress("Envoi de la photo…");
|
||||
const path = await uploadToStorage(state.event.id, blob);
|
||||
|
||||
setProgress("Dernière étape…");
|
||||
await insertPhotoRow(state.event.id, path, nomInput.value.trim(), messageInput.value.trim());
|
||||
|
||||
setProgress("");
|
||||
showSuccess();
|
||||
} catch (err) {
|
||||
setProgress("");
|
||||
showError(err, () => submitPhoto());
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener("submit", (evt) => {
|
||||
evt.preventDefault();
|
||||
submitPhoto();
|
||||
});
|
||||
|
||||
retryBtn.addEventListener("click", () => {
|
||||
if (state.lastRetryAction) state.lastRetryAction();
|
||||
});
|
||||
|
||||
addAnotherBtn.addEventListener("click", () => {
|
||||
form.reset();
|
||||
state.selectedFile = null;
|
||||
state.compressedBlob = null;
|
||||
previewImg.hidden = true;
|
||||
if (previewObjectUrl) {
|
||||
URL.revokeObjectURL(previewObjectUrl);
|
||||
previewObjectUrl = null;
|
||||
}
|
||||
successBlock.hidden = true;
|
||||
form.hidden = false;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Démarrage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
initEventCheck();
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
# Librairies vendorisées
|
||||
|
||||
Ces fichiers sont copiés tels quels (pas de build/bundler dans ce projet) et
|
||||
ne doivent pas être modifiés manuellement. Pour mettre à jour, retélécharger
|
||||
depuis la source indiquée.
|
||||
|
||||
## browser-image-compression
|
||||
|
||||
- Version : 2.0.2 (dernière version publiée au 15/09/2026)
|
||||
- Licence : MIT
|
||||
- Source : `https://cdn.jsdelivr.net/npm/browser-image-compression@2.0.2/dist/browser-image-compression.mjs`
|
||||
- Usage : compression + correction d'orientation EXIF, obligatoire avant tout
|
||||
upload (décision actée dans `docs/decisions.md`). Chargé systématiquement
|
||||
via `import` dans `src/e/upload.js`.
|
||||
|
||||
## heic2any
|
||||
|
||||
- Version : 0.0.4 (dernière version publiée au 15/09/2026)
|
||||
- Licence : MIT
|
||||
- Source : `https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js`
|
||||
- Usage : conversion HEIC/HEIF (photos iPhone) vers JPEG avant compression.
|
||||
Fichier volumineux (~1,3 Mo, décodeur HEIF en WASM) : **chargé uniquement à
|
||||
la demande** (lazy load via injection de `<script>`), seulement si un
|
||||
fichier HEIC/HEIF est détecté côté client — cf. `loadHeic2any()` dans
|
||||
`src/e/upload.js`. La majorité des invités (Android, ou iPhone dont Safari
|
||||
a déjà transcodé le fichier en JPEG lors de la sélection) ne téléchargeront
|
||||
jamais ce fichier.
|
||||
+9
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2237
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user