- Nouvelle Edge Function create-event + RPC public.create_event : génère slug/admin_token/expire_at et insère l'event, réservée à service_role, appelée par le workflow n8n "Créer un événement" (Form Trigger, publié). - Passe DA "polaroid poussé à fond" : les cartes d'état (chargement/erreur/ succès), la modale de bienvenue et le formulaire passent en papier clair avec ruban washi tape et léger tilt, plutôt qu'en carte sombre sur fond sombre (ombre invisible dans l'ancienne version). Admin resté sobre/sombre mais cartes plus lisibles (liseré coloré, contraste renforcé). - Générateur QR et carton imprimable : lisent maintenant ?slug= et ?baseUrl= pour se pré-remplir depuis le lien renvoyé par create-event. - Correctifs : icône manquante sur "Télécharger" (admin), bug [hidden] anticipé sur l'état vide de la galerie invité.
417 lines
14 KiB
HTML
417 lines
14 KiB
HTML
<!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" />
|
|
<meta name="theme-color" content="#12100E" />
|
|
<link rel="preload" href="/vendor/fonts/generalsans-700.woff2" as="font" type="font/woff2" crossorigin>
|
|
<style>
|
|
/* Direction artistique "Polaroid Néon" (.claude/skills/photobooth-da/SKILL.md),
|
|
exception admin : sobre, sombre, data-first. Mêmes tokens que
|
|
src/admin/gallery/style.css pour la cohérence visuelle entre écrans
|
|
admin, même sur cet outil interne que les clients ne voient pas (cf.
|
|
retour Kévin 2026-09-16, toujours valable avec la nouvelle DA). Outil
|
|
interne à priorité basse : thème unique sombre (pas de double thème
|
|
clair/sombre), pas d'animation d'entrée, juste la cohérence de base
|
|
(couleurs, rayons, police). */
|
|
:root {
|
|
color-scheme: dark;
|
|
--color-bg: #12100e;
|
|
--color-ink: #12100e;
|
|
--color-text: #faf6f0;
|
|
--color-muted: #e8dfd3;
|
|
--color-muted-2: #a89e90;
|
|
--color-coral: #ff5e5b;
|
|
--color-card: #241f1a;
|
|
--color-card-muted: #2c2621;
|
|
--color-border-ui: rgba(250, 246, 240, 0.16);
|
|
--radius-sm: 10px;
|
|
--radius: 14px;
|
|
--shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.3);
|
|
/* Carte plus soutenue + ombre large + liseré haut coloré (pas de tilt/
|
|
tape ici : "écran admin = exception, sobre" du skill DA). Passe
|
|
"polaroid poussé à fond" (2026-09-18) : avant cette passe, les champs
|
|
flottaient nus sur le fond sans regroupement visuel — l'écran le plus
|
|
"low-cost" des cinq lors de la revue. */
|
|
--shadow-card: 0 16px 32px rgba(0, 0, 0, 0.5);
|
|
--ease-standard: cubic-bezier(0.22, 1, 0.36, 1);
|
|
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
"Helvetica Neue", Arial, sans-serif;
|
|
/* Rim light : voir src/e/style.css pour le détail (ombre noire
|
|
classique quasi invisible sur fond quasi noir). */
|
|
--inset-rim: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
|
--inset-rim-btn: inset 0 1px 0 0 rgba(255, 255, 255, 0.16);
|
|
}
|
|
|
|
@font-face {
|
|
font-family: "Inter";
|
|
src: url("/vendor/fonts/inter-latin-variable.woff2") format("woff2");
|
|
font-weight: 400 700;
|
|
font-style: normal;
|
|
font-display: swap;
|
|
}
|
|
|
|
/* Ajoutée dans cette passe : le h1 référençait "General Sans" (comme les
|
|
autres écrans) sans jamais la charger ici, retombant silencieusement
|
|
sur la police système. */
|
|
@font-face {
|
|
font-family: "General Sans";
|
|
src: url("/vendor/fonts/generalsans-700.woff2") format("woff2");
|
|
font-weight: 700;
|
|
font-style: normal;
|
|
font-display: swap;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
padding: 1.5rem 1.1rem 3rem;
|
|
font-family: var(--font-sans);
|
|
color: var(--color-text);
|
|
/* Grain identique aux autres écrans (voir src/e/style.css) : cohérence
|
|
visuelle, même sur cet outil interne. */
|
|
background-color: var(--color-bg);
|
|
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxODAiIGhlaWdodD0iMTgwIj48ZmlsdGVyIGlkPSJuIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iMC44NSIgbnVtT2N0YXZlcz0iMiIgc3RpdGNoVGlsZXM9InN0aXRjaCIgcmVzdWx0PSJub2lzZSIvPjxmZUNvbG9yTWF0cml4IGluPSJub2lzZSIgdHlwZT0ibWF0cml4IiB2YWx1ZXM9IjAgMCAwIDAgMSAgMCAwIDAgMCAxICAwIDAgMCAwIDEgIDAgMCAwIDAuMDUgMCIvPjwvZmlsdGVyPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbHRlcj0idXJsKCUyM24pIi8+PC9zdmc+");
|
|
background-repeat: repeat;
|
|
background-size: 180px 180px;
|
|
max-width: 480px;
|
|
margin-inline: auto;
|
|
line-height: 1.45;
|
|
}
|
|
.brand {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.4rem;
|
|
margin-bottom: 1rem;
|
|
color: var(--color-muted);
|
|
}
|
|
.brand svg {
|
|
width: 22px;
|
|
height: 22px;
|
|
padding: 4px;
|
|
box-sizing: border-box;
|
|
flex-shrink: 0;
|
|
color: var(--color-coral);
|
|
background: rgba(255, 94, 91, 0.14);
|
|
border-radius: 50%;
|
|
}
|
|
.brand span {
|
|
font-size: 0.7rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.13em;
|
|
text-transform: uppercase;
|
|
}
|
|
h1 {
|
|
font-family: "General Sans", var(--font-sans);
|
|
font-size: 1.45rem;
|
|
font-weight: 700;
|
|
letter-spacing: -0.01em;
|
|
margin: 0 0 0.35rem;
|
|
}
|
|
p.hint {
|
|
color: var(--color-muted);
|
|
font-size: 0.85rem;
|
|
margin-top: 0;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
/* Carte qui regroupe les 3 champs — avant cette passe ils flottaient nus
|
|
directement sur le fond, sans le moindre regroupement visuel. */
|
|
.form-card {
|
|
position: relative;
|
|
padding: 1.3rem 1.1rem 0.3rem;
|
|
border: 1px solid var(--color-border-ui);
|
|
border-radius: var(--radius);
|
|
background: var(--color-card);
|
|
box-shadow: var(--shadow-card);
|
|
overflow: hidden;
|
|
}
|
|
.form-card::before {
|
|
content: "";
|
|
display: block;
|
|
position: absolute;
|
|
inset: 0 0 auto 0;
|
|
height: 3px;
|
|
background: var(--color-coral);
|
|
}
|
|
label {
|
|
display: block;
|
|
font-weight: 600;
|
|
font-size: 0.85rem;
|
|
margin-bottom: 0.35rem;
|
|
margin-top: 1.1rem;
|
|
}
|
|
.form-card label:first-child {
|
|
margin-top: 0;
|
|
}
|
|
input, select {
|
|
width: 100%;
|
|
padding: 0.7rem 0.75rem;
|
|
font: inherit;
|
|
font-size: 1rem;
|
|
border: 1px solid var(--color-border-ui);
|
|
border-radius: var(--radius-sm);
|
|
/* Distinct de --color-card (fond du .form-card qui les contient
|
|
désormais) : sinon les champs se fondent dans la carte. */
|
|
background: var(--color-bg);
|
|
color: inherit;
|
|
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.4);
|
|
transition: border-color 0.15s var(--ease-standard), box-shadow 0.15s var(--ease-standard);
|
|
}
|
|
input:focus,
|
|
select:focus {
|
|
outline: none;
|
|
border-color: var(--color-coral);
|
|
box-shadow: 0 0 0 4px rgba(255, 94, 91, 0.22);
|
|
}
|
|
.preview {
|
|
position: relative;
|
|
margin-top: 1.5rem;
|
|
padding: 1.3rem 1.1rem 1.1rem;
|
|
border: 1px solid var(--color-border-ui);
|
|
border-radius: var(--radius);
|
|
background: var(--color-card);
|
|
box-shadow: var(--shadow-card);
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
.preview::before {
|
|
content: "";
|
|
display: block;
|
|
position: absolute;
|
|
inset: 0 0 auto 0;
|
|
height: 3px;
|
|
background: var(--color-mint);
|
|
}
|
|
.preview svg {
|
|
background: var(--color-paper, #faf6f0);
|
|
padding: 10px;
|
|
border-radius: 6px;
|
|
}
|
|
/* Une règle auteur avec `display` passe toujours devant `[hidden] {
|
|
display: none }` du navigateur (origine auteur > origine user-agent
|
|
dans la cascade), donc il faut le dire explicitement ici pour que
|
|
preview.hidden = true (JS) masque vraiment l'élément. */
|
|
.preview[hidden] {
|
|
display: none;
|
|
}
|
|
.preview svg {
|
|
width: 100%;
|
|
max-width: 280px;
|
|
height: auto;
|
|
}
|
|
.preview .url {
|
|
font-size: 0.8rem;
|
|
color: var(--color-muted);
|
|
word-break: break-all;
|
|
text-align: center;
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
gap: 0.6rem;
|
|
margin-top: 1.1rem;
|
|
}
|
|
button {
|
|
flex: 1;
|
|
font: inherit;
|
|
font-weight: 700;
|
|
padding: 0.85rem 0.7rem;
|
|
min-height: 48px;
|
|
font-size: 0.95rem;
|
|
border: 1px solid var(--color-coral);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--color-coral);
|
|
color: var(--color-ink);
|
|
box-shadow: var(--shadow-sm), var(--inset-rim-btn);
|
|
cursor: pointer;
|
|
transition: transform 0.15s var(--ease-standard), background 0.15s ease;
|
|
}
|
|
button:active:not(:disabled) {
|
|
background: #ff7673;
|
|
}
|
|
button.secondary {
|
|
background: var(--color-card);
|
|
color: var(--color-text);
|
|
border-color: var(--color-border-ui);
|
|
box-shadow: var(--inset-rim);
|
|
}
|
|
button.secondary:active:not(:disabled) {
|
|
background: var(--color-card-muted);
|
|
}
|
|
@media (hover: hover) {
|
|
button:hover:not(:disabled) {
|
|
transform: translateY(-1px);
|
|
}
|
|
button.secondary:hover:not(:disabled) {
|
|
border-color: var(--color-coral);
|
|
}
|
|
}
|
|
button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
.error {
|
|
color: var(--color-coral);
|
|
font-size: 0.85rem;
|
|
margin-top: 0.5rem;
|
|
min-height: 1.2em;
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
button,
|
|
input,
|
|
select {
|
|
transition: none !important;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<p class="brand" aria-hidden="true">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" focusable="false">
|
|
<path d="M4 8h3l1.5-2h7L17 8h3a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1Z"/>
|
|
<circle cx="12" cy="13" r="3.5"/>
|
|
</svg>
|
|
<span>Photobooth</span>
|
|
</p>
|
|
<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>
|
|
|
|
<div class="form-card">
|
|
<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>
|
|
|
|
<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;
|
|
|
|
// Pré-remplissage depuis l'URL (?slug=...&baseUrl=...) : le lien
|
|
// "qrcode" renvoyé par l'Edge Function create-event (cf.
|
|
// supabase/functions/create-event) et affiché par le workflow n8n de
|
|
// création d'événement pointe déjà vers cette page avec ces
|
|
// paramètres — jusqu'ici aucun des deux n'était lu, il fallait tout
|
|
// ressaisir à la main (slug ET domaine) à chaque fois.
|
|
const initialParams = new URLSearchParams(window.location.search);
|
|
const initialSlug = initialParams.get('slug');
|
|
const initialBaseUrl = initialParams.get('baseUrl');
|
|
if (initialBaseUrl) {
|
|
baseUrlInput.value = initialBaseUrl;
|
|
}
|
|
if (initialSlug) {
|
|
slugInput.value = initialSlug;
|
|
// `refresh` est déclarée plus bas (function declaration, hissée dans
|
|
// la portée du module) : appelable ici sans réorganiser le fichier.
|
|
refresh();
|
|
}
|
|
|
|
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>
|