Compare commits
10
Commits
45e0bd7bcc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8568b48461 | ||
|
|
d0131331a6 | ||
|
|
e06f8afd3e | ||
|
|
c714316974 | ||
|
|
ea3e49cc04 | ||
|
|
f4322850d6 | ||
|
|
107e5c619a | ||
|
|
2dd0db54c8 | ||
|
|
4f31e98079 | ||
|
|
6d6be3ef99 |
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: photobooth-da
|
||||
description: Applique la direction artistique "Polaroid Néon" du projet Rêves Liquides Photobooth. À utiliser pour tout écran, composant ou style visuel de cette appli — scan, upload, galerie invité, admin.
|
||||
---
|
||||
|
||||
# Direction artistique — Polaroid Néon
|
||||
|
||||
## Palette
|
||||
- Fond principal : #12100E (noir chaud, jamais #000 pur)
|
||||
- Accent corail : #FF5E5B (CTA, boutons principaux)
|
||||
- Accent jaune : #FFD166 (badges, highlights)
|
||||
- Accent vert : #06D6A0 (succès, validation)
|
||||
- Blanc cassé : #FAF6F0 (cadre polaroid, textes clairs)
|
||||
- Beige poudré : #E8DFD3 (bordures, séparateurs)
|
||||
|
||||
## Typographie
|
||||
- Titres/display : Clash Display ou General Sans (bold arrondie)
|
||||
- Légendes manuscrites : Caveat ou Permanent Marker
|
||||
- UI/corps de texte : Inter ou Satoshi
|
||||
|
||||
## Composant signature : cadre polaroid
|
||||
- Fond blanc cassé, marge basse épaisse pour la légende
|
||||
- Rotation aléatoire entre -4° et +4° par carte
|
||||
- Ombre douce (box-shadow léger), jamais de glow/neon CSS artificiel
|
||||
- Prénom + message en typo manuscrite dans la zone basse
|
||||
|
||||
## Écran admin = exception
|
||||
Sobre, sombre, data-first. Pas de confettis, pas de rotation polaroid.
|
||||
Cartes en #1E1B18. Accent corail réservé aux CTA uniquement.
|
||||
|
||||
## Règle non négociable
|
||||
Vérifier systématiquement le contraste texte/fond sur #12100E avant
|
||||
de livrer un écran (norme WCAG AA minimum).
|
||||
@@ -13,6 +13,9 @@ src/e/config.js
|
||||
# Config front runtime — galerie admin (cf. src/admin/gallery/config.example.js)
|
||||
src/admin/gallery/config.js
|
||||
|
||||
# Config front runtime — galerie invité (cf. src/galerie/config.example.js)
|
||||
src/galerie/config.js
|
||||
|
||||
# Build
|
||||
dist/
|
||||
build/
|
||||
|
||||
@@ -28,6 +28,14 @@ server {
|
||||
try_files $uri $uri/ /admin/gallery/index.html;
|
||||
}
|
||||
|
||||
# /galerie/<slug> -> galerie invité (nouvelle DA "Polaroid Néon",
|
||||
# 2026-09-18, cf. docs/decisions.md). MAQUETTE au 2026-09-18 : données
|
||||
# mock, pas encore câblée à Supabase (cf. commentaire en tête de
|
||||
# src/galerie/index.html).
|
||||
location /galerie/ {
|
||||
try_files $uri $uri/ /galerie/index.html;
|
||||
}
|
||||
|
||||
location /vendor/ {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
@@ -181,16 +181,18 @@ Comme les invités uploadent des photos pouvant contenir des visages d'autres pe
|
||||
|
||||
## 📦 7. Livrables attendus du développeur
|
||||
|
||||
État au 2026-09-15 (fin de journée) — voir `docs/decisions.md` pour les décisions actées et le SQL complet dans `supabase/migrations/`.
|
||||
État au 2026-09-16 (fin de journée) — voir `docs/decisions.md` pour les décisions actées et le SQL complet dans `supabase/migrations/`.
|
||||
|
||||
- [x] Page publique d'upload (`/e/{slug}`) — code écrit (`src/e/`), compression + HEIC + gestion d'erreurs. **Non testée en navigateur réel** (pas de projet Supabase live pour l'instant).
|
||||
- [x] Page admin galerie + export ZIP (`/admin/{slug}?token=...`) — code écrit (`src/admin/gallery/`), consomme l'Edge Function `admin-gallery`. **Non testée en réel**, dépend de l'Edge Function ci-dessous.
|
||||
- [x] Génération du QR code (export PNG/SVG imprimable) — code écrit (`src/admin/qrcode/`), page démo utilisable dès maintenant.
|
||||
- [ ] Schéma Supabase (tables `pq_events`/`pq_photos` + RLS + policies Storage) — **SQL écrit et relu (sécurité OK après corrections), PAS ENCORE APPLIQUÉ**. En attente de validation finale de Kévin avant `apply_migration`.
|
||||
- [x] Compression image côté client avant upload — `browser-image-compression`, intégrée dans `src/e/upload.js`.
|
||||
- [x] Gestion de l'expiration (statut événement, blocage upload, suppression J+7) — blocage upload prêt côté schéma (`event_accepts_uploads`) ; job SQL `pq_expire_events` (pg_cron) écrit. **Reste ouvert** : mécanisme de déclenchement de la suppression des fichiers Storage (pg_net vs n8n, cf. `docs/decisions.md`) — à trancher avant implémentation.
|
||||
- [x] Mention RGPD intégrée sur la page upload — texte de `docs/rgpd.md` repris verbatim.
|
||||
- [x] Edge Function admin (liste photos + ZIP) — code écrit (`supabase/functions/admin-gallery/`), ZIP en streaming. **Non déployée, dépendance `zip.js` non testée en environnement réel** — à vérifier via `supabase functions serve` avant tout déploiement.
|
||||
- [x] Page publique d'upload (`/e/{slug}`) — **déployée et testée en réel** sur `https://photobooth.assistantaikev.eu/e/{slug}`, upload confirmé depuis un vrai téléphone.
|
||||
- [x] Page admin galerie + export ZIP (`/admin/{slug}?token=...`) — **déployée et testée en réel**, ZIP en streaming fonctionnel.
|
||||
- [x] Génération du QR code (export PNG/SVG imprimable) — déployée (`/admin/qrcode/`), + carton imprimable A5 bonus (`/admin/print-template/`).
|
||||
- [x] Schéma Supabase (schéma dédié `photobooth`, jamais exposé via PostgREST, accès via fonctions `SECURITY DEFINER`) — **appliqué et vérifié** sur le projet définitif `kevin-lecou-hub`.
|
||||
- [x] Compression image côté client avant upload — `browser-image-compression`, testée en conditions réelles.
|
||||
- [x] Gestion de l'expiration — blocage upload actif, job SQL `pq_expire_events`→`expire_events` (pg_cron) actif et vérifié planifié. Purge des fichiers Storage : Edge Function `expire-events` déployée et testée manuellement ; **déclenchement automatique (workflow n8n) créé mais pas encore activé** (credentials à finaliser côté Kévin).
|
||||
- [x] Mention RGPD — déplacée d'un bloc permanent vers une modale de bienvenue au 1er passage par événement (demande Kévin), texte légal inchangé, toujours consultable via un lien dédié.
|
||||
- [x] Edge Functions admin (`admin-gallery`, `expire-events`) — déployées et testées en réel sur `kevin-lecou-hub`.
|
||||
- [x] **Nouveau** : limite de photos par invité, réglable par événement (`max_photos_per_guest`), appliquée côté serveur.
|
||||
- [x] **Nouveau** : 3 passes de direction artistique (palette punchy corail/prune/doré, mode sombre automatique, finition animations/micro-interactions) — DA jugée "pas mal" par Kévin mais **pas encore validée définitivement** (retour direct : "pas encore vraiment fan des couleurs"), itération à poursuivre.
|
||||
- [ ] Documentation courte : comment créer un nouvel événement manuellement (en attendant l'automatisation n8n en V2) — pas encore rédigée.
|
||||
- [ ] Code source versionné (repo Git — **à préciser : GitHub perso de Kévin, ou autre ?** ⚠️ point ouvert) — dépôt local Git existant, pas de remote configuré à ce stade.
|
||||
|
||||
|
||||
@@ -5,6 +5,191 @@ la date à laquelle elles ont été prises. Toute nouvelle décision structurant
|
||||
(schéma DB, auth, hébergement, etc.) doit être ajoutée ici après validation
|
||||
par Kévin.
|
||||
|
||||
## 2026-09-18 (six)
|
||||
|
||||
- **Limitation connue (pas un bug à corriger)** : sur /e/OS + navigateur
|
||||
DuckDuckGo, le bouton "Prendre une photo" (`capture="environment"`)
|
||||
ouvre la galerie au lieu de l'appareil photo, et l'inverse pour "Depuis
|
||||
la galerie". Vérifié : le HTML/JS est conforme à la spec (wiring
|
||||
correct), c'est DuckDuckGo Android qui n'honore pas fiablement
|
||||
l'attribut `capture`. Décision : ne rien coder de spécifique pour ce
|
||||
navigateur (combo /e/OS + DuckDuckGo trop marginal côté invités réels,
|
||||
qui arrivent très majoritairement via Safari/Chrome par défaut) — à
|
||||
revérifier par Kévin sur iPhone/Android dès qu'il en a sous la main.
|
||||
- **Événement de test créé en conditions réelles** via le workflow n8n +
|
||||
`create-event` (slug `test-da-verification-kevin`) : premier passage
|
||||
bout-en-bout du pipeline complet (n8n → Edge Function → Telegram),
|
||||
fonctionnel. Confirme aussi que le déploiement de la passe DA
|
||||
"polaroid poussé à fond" (voir entrée précédente) rend bien en
|
||||
conditions réelles sur le domaine de prod, pas seulement en local —
|
||||
modale de bienvenue et état vide de la galerie vérifiés par capture
|
||||
d'écran sur `https://photobooth.assistantaikev.eu`.
|
||||
- **Déployé sur le VPS** (scp direct des 7 fichiers modifiés vers
|
||||
`/home/kevin/photobooth-qr/site/`, pas de rebuild nécessaire — site
|
||||
statique servi par nginx).
|
||||
|
||||
## 2026-09-18 (cinq)
|
||||
|
||||
- **2e itération de la 4e passe DA, plus radicale** : retour Kévin sur la
|
||||
version "rim light + grain" ("franchement c'est nul", captures à l'appui
|
||||
après avoir vérifié moi-même dans Chrome) — l'effet était bien trop
|
||||
subtil, l'app restait un grand vide noir avec une petite carte sombre
|
||||
illisible. Changement structurel plutôt qu'un réglage plus fort du même
|
||||
hack : les cartes "props" côté invité (bloc de chargement/erreur/succès,
|
||||
modale de bienvenue, formulaire prénom+message) passent en **papier
|
||||
clair** (`#FAF6F0`, encre `#12100E`) posé sur la table sombre, avec un
|
||||
ruban "washi tape" en haut de carte et une légère inclinaison
|
||||
(-2.2°/+1.8° selon l'état) — comme un vrai polaroid, plutôt qu'un panneau
|
||||
UI sombre. Une carte claire sur fond quasi noir projette une vraie ombre
|
||||
(`--shadow-paper`), contrairement à une carte sombre sur fond sombre :
|
||||
ça résout le problème à la racine plutôt qu'en le maquillant.
|
||||
- Bug technique corrigé au passage : l'inclinaison ne s'affichait pas du
|
||||
tout au premier essai — une `animation` qui touche `transform`
|
||||
(`fade-rise`) remplace toute la propriété, elle ne fusionne pas avec un
|
||||
`rotate()` posé à côté en statique. Fix : rotation intégrée dans les
|
||||
keyframes (`state-card-in`), avec repli statique sous
|
||||
`prefers-reduced-motion: reduce`.
|
||||
- **Écran admin** (galerie + générateur QR) : reste sombre/sobre, sans
|
||||
tilt ni tape (exception du skill DA conservée), mais `--color-card`
|
||||
légèrement éclairci (`#1E1B18` → `#241F1A`) et liseré coloré plein en
|
||||
haut de carte (plus un fond teinté translucide) pour rester repérable —
|
||||
même diagnostic que côté invité (contraste carte/fond trop faible pour
|
||||
se voir), remède différent (sobre, pas de papier). Le générateur QR
|
||||
(`src/admin/qrcode/`) n'avait quasiment aucune mise en forme avant cette
|
||||
passe (champs nus sur le fond, aucun regroupement visuel, police
|
||||
"General Sans" référencée mais jamais chargée dans ce fichier) : plus
|
||||
gros écart identifié lors de l'audit, corrigé (carte de formulaire,
|
||||
police chargée, contraste champs/carte).
|
||||
- **Vérifié visuellement cette fois** : captures d'écran via Chrome headless
|
||||
local (`google-chrome --headless=new --screenshot=...`) sur les 5 écrans
|
||||
+ la modale de bienvenue forcée visible temporairement pour vérification
|
||||
— pas juste une relecture de CSS. Contrastes WCAG AA recalculés pour les
|
||||
nouvelles combinaisons papier (encre/muted sur `#FAF6F0`) : ≥5:1 sur tous
|
||||
les textes ajoutés.
|
||||
- Pas testé avec un vrai événement (formulaire rempli, grille de photos
|
||||
garnie, écran de succès) faute de slug valide en local — seuls les états
|
||||
vides/erreur ont pu être vérifiés en conditions réelles de rendu.
|
||||
|
||||
## 2026-09-18 (quater)
|
||||
|
||||
- **4e passe DA "Polaroid Néon"** : suite au retour "ça fait vraiment
|
||||
low-cost, il faut me rendre cette application sexy", audit écran par
|
||||
écran (upload, galerie invité, admin galerie, générateur QR, carton
|
||||
imprimable). Diagnostic : ce n'était pas la direction elle-même
|
||||
(conservée intégralement, aucune valeur de palette changée) mais des
|
||||
défauts d'exécution — ombres noires invisibles sur fond quasi noir
|
||||
(`#12100E`), fond en aplat sans texture, pastille de marque non
|
||||
généralisée à tous les écrans, icône manquante sur le lien "Télécharger"
|
||||
admin. Corrigé via : liseré clair ("rim light") sur les cartes/boutons,
|
||||
grain de fond en SVG data URI inline (~350 octets, 0 requête réseau),
|
||||
pastille corail derrière le logo sur tous les écrans, icône ajoutée au
|
||||
lien de téléchargement admin. Bug `[hidden]` masqué par un `display`
|
||||
auteur (déjà rencontré 3 fois sur ce projet) anticipé et corrigé au
|
||||
passage sur `#empty-state` (galerie invité, passé en `display: flex`).
|
||||
Contrastes WCAG AA revérifiés, aucun n'a changé (aucune couleur de texte/
|
||||
fond modifiée). Non vérifié visuellement dans un navigateur avant cette
|
||||
entrée (pas d'accès Chrome dans l'environnement) — à prévisualiser en
|
||||
local avant déploiement.
|
||||
|
||||
## 2026-09-18 (ter)
|
||||
|
||||
- **`create-event` déployée en prod** sur `kevin-lecou-hub` (migration
|
||||
`20260918100000_create_event_rpc.sql` + Edge Function
|
||||
`supabase/functions/create-event/`). Vérifié après coup : grants limités à
|
||||
`service_role` (n'apparaît pas dans les fonctions `SECURITY DEFINER`
|
||||
exécutables par `anon`/`authenticated`, contrairement à
|
||||
`get_public_event`/`insert_public_photo` qui le sont intentionnellement).
|
||||
Un `search_path` manquant sur la fonction interne
|
||||
`photobooth.generate_unique_slug` (signalé par le linter sécurité Supabase
|
||||
juste après déploiement) a été corrigé dans la foulée
|
||||
(`20260918101500_harden_generate_unique_slug_search_path.sql`).
|
||||
- Le workflow n8n `photobooth-qr — Créer un événement` reste **inactif** :
|
||||
à activer par Kévin une fois le DNS `git.assistantaikev.eu` et un premier
|
||||
test réel du formulaire validés.
|
||||
|
||||
## 2026-09-18 (bis)
|
||||
|
||||
- **Création d'événement** : via un **formulaire n8n** (Form Trigger),
|
||||
plutôt qu'un formulaire dans l'appli photobooth-qr ou du SQL manuel. Le
|
||||
workflow insère l'event en base (génère `slug` + `admin_token`) et
|
||||
renvoie les liens invité/admin à Kévin. Choisi pour rester cohérent avec
|
||||
la stack n8n déjà en place et éviter d'ajouter une nouvelle couche
|
||||
d'authentification "admin produit" dans l'appli (aujourd'hui il n'existe
|
||||
que des tokens par événement, pas de compte admin global).
|
||||
- **Pas de personnalisation visuelle par événement** : la DA "Polaroid
|
||||
Néon" reste identique pour tous les événements ; seules les infos
|
||||
textuelles varient par event (nom, date, limite de photos). Pas de
|
||||
marque blanche (palette/logo par client) en V1 — jugé de la
|
||||
sur-ingénierie pour un besoin V2/V3 pas encore confirmé par un client
|
||||
réel.
|
||||
- **Remote Git : Gitea auto-hébergé sur le VPS existant** (plutôt que
|
||||
GitHub). Cohérent avec la préférence d'autonomie de Kévin — tout reste
|
||||
chez lui, pas de dépendance à un service tiers pour le code source. Le
|
||||
VPS héberge déjà n8n (Traefik/SSL en place) ; Gitea tournera comme
|
||||
service Docker supplémentaire derrière le même reverse proxy. Détails
|
||||
d'implémentation (sous-domaine, ressources, sauvegarde) à définir avant
|
||||
déploiement.
|
||||
|
||||
## 2026-09-18
|
||||
|
||||
- **Galerie invité (revirement)** : les invités peuvent désormais voir
|
||||
**toutes** les photos de l'événement (pas juste envoyer la leur), dans
|
||||
le cadre de la nouvelle DA "Polaroid Néon". Ça **inverse** la décision du
|
||||
cahier des charges initial ("Pas de galerie visible côté invité,
|
||||
confirmé : upload only"). Conséquence RGPD : le texte de mention légale
|
||||
(`docs/rgpd.md` + modale de bienvenue) doit indiquer que photo/prénom/
|
||||
message sont visibles par les autres invités de l'événement, pas
|
||||
seulement mis à disposition de l'organisateur — mis à jour dans la
|
||||
foulée. Implémentation : backend (nouvelle RPC/Edge Function publique de
|
||||
listing + URLs signées pour les invités) pas encore fait à cette date,
|
||||
seul le rendu visuel est en cours.
|
||||
- **Nouvelle direction artistique "Polaroid Néon"** actée, voir
|
||||
`.claude/skills/photobooth-da/SKILL.md` pour le détail complet (palette,
|
||||
typographie, composant polaroid signature). Remplace les 3 passes DA
|
||||
précédentes (palette corail/prune/doré + Fraunces) — celles-ci restent
|
||||
en prod tant que la nouvelle direction n'est pas implémentée écran par
|
||||
écran.
|
||||
- **Stack confirmée inchangée** : vanilla JS/HTML/CSS, pas de framework
|
||||
(React proposé dans un brief externe, décliné — mandat CLAUDE.md déjà
|
||||
explicite sur ce point, aucune décision contraire prise).
|
||||
|
||||
## 2026-09-16 (récap fin de journée)
|
||||
|
||||
Produit passé de "code écrit, rien de déployé" à **réellement en ligne et
|
||||
testé** : `https://photobooth.assistantaikev.eu` (VPS Hostinger, nginx +
|
||||
Traefik), backend sur le projet Supabase définitif `kevin-lecou-hub`.
|
||||
|
||||
- **Bugs réels trouvés et corrigés en cours de route** (tous via tests en
|
||||
conditions réelles, pas en théorie) :
|
||||
- MIME type `.mjs` mal servi par nginx (bloquait tout le JS qui importe
|
||||
un module vendorisé — upload invité ET générateur QR étaient cassés).
|
||||
- Chemins relatifs (`style.css`, `config.js`, `gallery.js`) cassés sur les
|
||||
URLs réécrites `/e/<slug>` et `/admin/<slug>` — passage en chemins
|
||||
absolus partout.
|
||||
- Bug de cascade CSS présent **depuis le tout premier commit** :
|
||||
`display: flex` déclaré en dur sur `.upload-form`/`.welcome-modal`
|
||||
empêchait `hidden` (JS) de masquer quoi que ce soit visuellement — le
|
||||
formulaire est resté affiché en permanence, superposé aux autres états,
|
||||
sans que personne ne s'en rende compte avant un test réel.
|
||||
- Faille d'abus sur `insert_public_photo` (spam de lignes sans upload
|
||||
réel) — corrigée, vérification d'existence du fichier Storage ajoutée.
|
||||
- **Nouvelle fonctionnalité** : limite de photos par invité par événement
|
||||
(`max_photos_per_guest`), appliquée côté serveur via un `guest_id`
|
||||
anonyme (localStorage) — pas une identité vérifiée, contournable, mais
|
||||
fait respecter la vraie limite côté base.
|
||||
- **RGPD** : mention légale déplacée d'un bloc permanent vers une modale de
|
||||
bienvenue personnalisée (nom de l'événement) au premier passage par
|
||||
événement — texte légal inchangé, juste la présentation.
|
||||
- **Direction artistique** : 3 passes (palette sobre → palette punchy
|
||||
corail/prune/doré/Fraunces → mode sombre + finition animations). Statut
|
||||
au 2026-09-16 soir : jugée "pas mal, vraiment fonctionnel" par Kévin,
|
||||
mais **couleurs/DA pas encore validées définitivement** — Kévin prévoit
|
||||
un retour détaillé à l'agent direction-artistique lors d'une prochaine
|
||||
session.
|
||||
- **Reste ouvert** : activation du workflow n8n de purge Storage
|
||||
(credentials à créer côté Kévin), documentation de création d'un
|
||||
événement (encore manuel en SQL), remote Git à définir.
|
||||
|
||||
## 2026-09-15
|
||||
|
||||
- **Expiration des données** : suppression à **J+7 à partir de la date de
|
||||
|
||||
+13
-6
@@ -11,12 +11,13 @@ Texte à afficher, de façon visible, sur la page d'upload avant que l'invité
|
||||
ne dépose une photo :
|
||||
|
||||
> 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.
|
||||
> dans le cadre de cet événement et **visible par les autres invités de
|
||||
> l'événement ainsi que par l'organisateur** (galerie commune accessible via
|
||||
> le même lien/QR code). 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 en
|
||||
> dehors des invités de l'événement. Vous pouvez demander la suppression
|
||||
> anticipée d'une photo en contactant l'organisateur de l'événement.
|
||||
|
||||
## Politique de conservation
|
||||
|
||||
@@ -38,6 +39,12 @@ ne dépose une photo :
|
||||
|
||||
## Points à valider avec Kévin (et si besoin un juriste) avant V2/V3
|
||||
|
||||
- **Galerie invité (2026-09-18)** : les photos/prénoms/messages sont
|
||||
désormais visibles par tous les invités d'un même événement (pas
|
||||
seulement par l'organisateur), cf. `docs/decisions.md`. Mention légale
|
||||
mise à jour en conséquence — à faire valider par un juriste avant V2/V3
|
||||
comme le reste de cette page, ce changement rend le sujet plus sensible
|
||||
(données visibles par un groupe plus large que prévu initialement).
|
||||
- Rédaction définitive des CGU/mentions légales pour un usage commercial
|
||||
auprès de clients externes (mariages, événementiel pro).
|
||||
- Statut du client (organisateur) comme responsable de traitement, et de
|
||||
|
||||
@@ -273,7 +273,10 @@ function renderPhotoGrid() {
|
||||
// à 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";
|
||||
// Icône + texte plutôt qu'un lien texte nu : cohérent avec le langage
|
||||
// iconographique du reste de l'écran (Actualiser, Télécharger tout).
|
||||
downloadLink.innerHTML =
|
||||
'<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 4v11"/><path d="M7 10.5 12 15.5 17 10.5"/><path d="M5 19.5h14"/></svg><span>Télécharger</span>';
|
||||
downloadLink.addEventListener("click", (evt) => {
|
||||
if (isPhotoLinkLikelyExpired(photo)) {
|
||||
evt.preventDefault();
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
fichier front, à mettre en place lors du déploiement.
|
||||
-->
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta name="theme-color" content="#faf6f0">
|
||||
<link rel="preload" href="/vendor/fonts/fraunces-latin-600-normal.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<!-- Thème unique sombre "Polaroid Néon", exception admin (sobre/data-first,
|
||||
voir src/admin/gallery/style.css). -->
|
||||
<meta name="theme-color" content="#12100E">
|
||||
<link rel="preload" href="/vendor/fonts/generalsans-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/admin/gallery/style.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -38,7 +40,11 @@
|
||||
|
||||
<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>
|
||||
<!-- class="btn btn--secondary" ajoutée passe 3 : ce bouton n'avait
|
||||
jusqu'ici aucune classe et retombait donc sur le style navigateur
|
||||
par défaut (papercut visuel repéré pendant la passe DA, corrigé
|
||||
ici — rendu uniquement, aucune logique touchée). -->
|
||||
<button type="button" id="retry-btn" class="btn btn--secondary" hidden>Réessayer</button>
|
||||
</section>
|
||||
|
||||
<section id="gallery-content" hidden>
|
||||
|
||||
+190
-90
@@ -1,58 +1,81 @@
|
||||
/* 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/rayon/ombre/police que la page invité
|
||||
(src/e/style.css) pour rester cohérent visuellement — c'est aussi l'écran
|
||||
que verront les prospects (photographes, wedding planners) lors d'une
|
||||
démo, donc soigné au même niveau que la page invité. Palette et police
|
||||
Fraunces alignées sur la passe 2 (2026-09-16) : voir src/e/style.css pour
|
||||
la justification complète de la direction créative. */
|
||||
/* Galerie admin — direction artistique "Polaroid Néon"
|
||||
(.claude/skills/photobooth-da/SKILL.md), variante "écran admin" du skill :
|
||||
sobre, sombre, data-first. Pas de confettis, pas de rotation façon
|
||||
polaroid sur les cartes photo (grille classique) — l'accent corail est
|
||||
réservé aux CTA (boutons), jamais décoratif.
|
||||
|
||||
Tokens repris de src/galerie/style.css (écran de référence de cette DA)
|
||||
pour la cohérence de couleurs/typo entre écrans invité et admin ; les
|
||||
cartes utilisent #1E1B18 comme demandé par l'exception admin du skill
|
||||
(plus clair que --color-bg, pour distinguer une "carte" du fond de
|
||||
page, sans jamais recourir au blanc cassé des polaroids invité).
|
||||
|
||||
Contrastes vérifiés par calcul WCAG AA (même méthode que
|
||||
src/galerie/style.css) : texte clair sur #12100E et sur #1E1B18 passe
|
||||
très largement ; texte sombre (#12100E) sur les couleurs d'accent
|
||||
pleines (utilisé uniquement sur les CTA .btn) passe confortablement
|
||||
(6.3:1 à 13.2:1). */
|
||||
|
||||
@font-face {
|
||||
font-family: "Fraunces";
|
||||
src: url("/vendor/fonts/fraunces-latin-600-normal.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-family: "General Sans";
|
||||
src: url("/vendor/fonts/generalsans-700.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@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;
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+2000-206F,
|
||||
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
color-scheme: dark;
|
||||
|
||||
--color-bg: #fff8f0;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-muted: #fdecdd;
|
||||
--color-text: #2b2016;
|
||||
--color-muted: #8a7869;
|
||||
--color-border: #f1ddc6;
|
||||
--color-bg: #12100e;
|
||||
--color-ink: #12100e;
|
||||
--color-text: #faf6f0;
|
||||
--color-muted: #e8dfd3;
|
||||
--color-muted-2: #a89e90;
|
||||
|
||||
--color-accent: #e35f2b;
|
||||
--color-accent-hover: #c74c1e;
|
||||
--color-accent-light: #ff8a52;
|
||||
--color-accent-soft: #ffe3d0;
|
||||
--color-accent-contrast: #fffaf6;
|
||||
--color-coral: #ff5e5b;
|
||||
--color-yellow: #ffd166;
|
||||
--color-mint: #06d6a0;
|
||||
|
||||
--color-plum: #7c2f56;
|
||||
--color-plum-soft: #f8e3ee;
|
||||
/* Exception admin du skill : cartes sombres, jamais blanc cassé. Un peu
|
||||
éclairci par rapport à la version initiale (#1E1B18 -> #241F1A) lors de
|
||||
la passe "polaroid poussé à fond" (2026-09-18) : l'écart avec
|
||||
--color-bg restait trop faible pour se voir sans l'aide d'une ombre,
|
||||
qui elle-même ne portait pas sur un fond aussi sombre — repéré en
|
||||
capture d'écran, pas en théorie. */
|
||||
--color-card: #241f1a;
|
||||
--color-card-muted: #2c2621;
|
||||
--color-border-ui: rgba(250, 246, 240, 0.14);
|
||||
--color-border-ui-strong: rgba(250, 246, 240, 0.26);
|
||||
|
||||
--color-error-bg: #fdeae6;
|
||||
--color-error-text: #a3341c;
|
||||
--color-warning-bg: #fbe9d8;
|
||||
--color-warning-text: #8a4a10;
|
||||
--color-error-bg: rgba(255, 94, 91, 0.14);
|
||||
--color-warning-bg: rgba(255, 209, 102, 0.16);
|
||||
|
||||
--radius-sm: 12px;
|
||||
--radius: 20px;
|
||||
--radius-ui: 14px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
--shadow-sm: 0 2px 6px rgba(163, 78, 30, 0.1);
|
||||
--shadow-md: 0 10px 28px rgba(163, 78, 30, 0.16);
|
||||
--shadow-soft: 0 10px 24px rgba(0, 0, 0, 0.35);
|
||||
--shadow-card: 0 16px 32px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--ease-standard: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
--font-display: "Fraunces", Georgia, "Times New Roman", serif;
|
||||
--font-display: "General Sans", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-ui: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
|
||||
/* Rim light : voir le commentaire détaillé dans src/e/style.css. Même
|
||||
nécessité ici (#1E1B18 sur #12100E, écart de luminance faible : une
|
||||
ombre noire classique n'y est presque pas visible). */
|
||||
--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);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -65,8 +88,14 @@ html {
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--color-bg);
|
||||
font-family: var(--font-ui);
|
||||
/* Grain identique aux écrans invité (voir src/e/style.css) : cohérence
|
||||
visuelle admin/invité, casse l'aplat du fond, sans impact mesurable
|
||||
sur le contraste WCAG déjà vérifié pour ce fichier. */
|
||||
background-color: var(--color-bg);
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxODAiIGhlaWdodD0iMTgwIj48ZmlsdGVyIGlkPSJuIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iMC44NSIgbnVtT2N0YXZlcz0iMiIgc3RpdGNoVGlsZXM9InN0aXRjaCIgcmVzdWx0PSJub2lzZSIvPjxmZUNvbG9yTWF0cml4IGluPSJub2lzZSIgdHlwZT0ibWF0cml4IiB2YWx1ZXM9IjAgMCAwIDAgMSAgMCAwIDAgMCAxICAwIDAgMCAwIDEgIDAgMCAwIDAuMDUgMCIvPjwvZmlsdGVyPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbHRlcj0idXJsKCUyM24pIi8+PC9zdmc+");
|
||||
background-repeat: repeat;
|
||||
background-size: 180px 180px;
|
||||
color: var(--color-text);
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -89,14 +118,22 @@ body {
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.6rem;
|
||||
color: var(--color-muted);
|
||||
color: var(--color-muted-2);
|
||||
}
|
||||
|
||||
/* Pastille corail derrière la marque, cohérente avec les écrans invité
|
||||
(src/e/style.css, src/galerie/style.css) — un détail de marque, pas une
|
||||
décoration : reste discret, n'entre pas en conflit avec l'exception
|
||||
admin (sobre, data-first). */
|
||||
.brand__mark {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-accent);
|
||||
color: var(--color-coral);
|
||||
background: rgba(255, 94, 91, 0.14);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.brand__name {
|
||||
@@ -106,35 +143,49 @@ body {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Écran admin = data-first, sobre : titre en texte plein (pas de dégradé
|
||||
décoratif), contrairement à ce que faisait l'ancienne DA. */
|
||||
.page__header h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.7rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0.15rem 0 0;
|
||||
color: var(--color-accent);
|
||||
background: linear-gradient(100deg, var(--color-accent) 20%, var(--color-plum) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.event-meta {
|
||||
color: var(--color-muted);
|
||||
color: var(--color-muted-2);
|
||||
font-size: 0.9rem;
|
||||
margin: 0.4rem 0 0;
|
||||
}
|
||||
|
||||
/* Liseré haut plein plutôt qu'un fond entièrement teinté (l'ancien
|
||||
.state-block--error en rgba coral 0.14% se distinguait à peine de
|
||||
--color-card une fois les deux sombres) : reste "sobre" (aucune couleur
|
||||
décorative sur toute la surface) tout en rendant la carte repérable. */
|
||||
.state-block {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
padding: 1.25rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem 1rem 1.25rem;
|
||||
border-radius: var(--radius-ui);
|
||||
margin-bottom: 1rem;
|
||||
background: var(--color-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.state-block::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 3px;
|
||||
background: var(--accent-color, var(--color-coral));
|
||||
}
|
||||
|
||||
.state-block--error {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error-text);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.state-block p {
|
||||
@@ -150,8 +201,8 @@ body {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin-bottom: 0.6rem;
|
||||
border: 3px solid var(--color-accent-soft);
|
||||
border-top-color: var(--color-accent);
|
||||
border: 3px solid rgba(255, 94, 91, 0.22);
|
||||
border-top-color: var(--color-coral);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@@ -164,8 +215,8 @@ body {
|
||||
|
||||
.status-banner {
|
||||
background: var(--color-warning-bg);
|
||||
color: var(--color-warning-text);
|
||||
border-radius: var(--radius);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-ui);
|
||||
padding: 0.85rem 1rem;
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
@@ -178,6 +229,8 @@ body {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Boutons — l'accent corail est réservé au CTA principal (télécharger le
|
||||
ZIP) ; le bouton secondaire (Actualiser) reste sobre, sans couleur. */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -190,10 +243,10 @@ body {
|
||||
padding: 0.75rem 1.15rem;
|
||||
min-height: 46px;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, var(--color-accent-light), var(--color-accent));
|
||||
color: var(--color-accent-contrast);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.12s var(--ease-bounce), background 0.12s ease;
|
||||
background: var(--color-coral);
|
||||
color: var(--color-ink);
|
||||
box-shadow: var(--shadow-card), var(--inset-rim-btn);
|
||||
transition: transform 0.12s var(--ease-standard), background 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
@@ -203,40 +256,53 @@ body {
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-plum);
|
||||
border: 1.5px solid var(--color-plum);
|
||||
box-shadow: none;
|
||||
background: var(--color-card);
|
||||
color: var(--color-text);
|
||||
border: 1.5px solid var(--color-border-ui-strong);
|
||||
box-shadow: var(--inset-rim);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
background: linear-gradient(135deg, var(--color-accent), var(--color-accent-hover));
|
||||
background: #ff7673;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.btn--secondary:active:not(:disabled) {
|
||||
background: var(--color-plum-soft);
|
||||
background: var(--color-card-muted);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.btn--secondary:hover:not(:disabled) {
|
||||
background: var(--color-card-muted);
|
||||
border-color: var(--color-coral);
|
||||
box-shadow: var(--inset-rim);
|
||||
}
|
||||
}
|
||||
|
||||
.btn:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 3px solid var(--color-plum);
|
||||
outline: 3px solid var(--color-coral);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.zip-status {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-muted);
|
||||
color: var(--color-muted-2);
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
.zip-status--error {
|
||||
color: var(--color-error-text);
|
||||
color: var(--color-coral);
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
@@ -248,20 +314,42 @@ a:focus-visible {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Exception admin explicite : grille classique, cartes rectangulaires,
|
||||
AUCUNE rotation façon polaroid (contrairement à src/galerie/style.css)
|
||||
ni glow/neon — juste une carte sobre en #1E1B18. */
|
||||
.photo-card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
position: relative;
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-ui);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow 0.15s var(--ease-bounce), transform 0.15s var(--ease-bounce);
|
||||
box-shadow: var(--shadow-card), var(--inset-rim);
|
||||
transition: box-shadow 0.2s var(--ease-standard), transform 0.2s var(--ease-standard);
|
||||
}
|
||||
|
||||
/* Liseré corail en haut de carte au survol desktop uniquement : seule
|
||||
touche décorative de couleur tolérée ici, et encore, discrète et
|
||||
réactive au hover plutôt que permanente. */
|
||||
.photo-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 3px;
|
||||
background: var(--color-coral);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s var(--ease-standard);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.photo-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-3px) rotate(-0.6deg);
|
||||
box-shadow: var(--shadow-soft), var(--inset-rim);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.photo-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +357,7 @@ a:focus-visible {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background: var(--color-surface-muted);
|
||||
background: var(--color-card-muted);
|
||||
}
|
||||
|
||||
.photo-card__thumb {
|
||||
@@ -287,7 +375,7 @@ a:focus-visible {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted);
|
||||
color: var(--color-muted-2);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -309,39 +397,51 @@ a:focus-visible {
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 0.2rem;
|
||||
word-break: break-word;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.photo-card__message {
|
||||
color: var(--color-text);
|
||||
color: var(--color-muted);
|
||||
font-size: 0.8rem;
|
||||
margin: 0 0 0.35rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.photo-card__date {
|
||||
color: var(--color-muted);
|
||||
color: var(--color-muted-2);
|
||||
font-size: 0.72rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.photo-card__download {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
text-align: center;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-contrast);
|
||||
background: var(--color-coral);
|
||||
color: var(--color-ink);
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
transition: background 0.15s var(--ease-standard);
|
||||
}
|
||||
|
||||
.photo-card__download .btn-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.photo-card__download:hover {
|
||||
background: var(--color-accent-hover);
|
||||
background: #ff7673;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spinner,
|
||||
.photo-card,
|
||||
.photo-card::before,
|
||||
.btn {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
|
||||
@@ -17,51 +17,62 @@
|
||||
tables lors de l'événement.
|
||||
-->
|
||||
<style>
|
||||
/* Palette/police alignées sur la passe 2 (2026-09-16) du reste du produit
|
||||
— voir src/e/style.css pour la justification complète de la direction
|
||||
créative (punchy/friendly plutôt que sobre). Le carton imprimé est un
|
||||
livrable physique posé sur les tables : mêmes contraintes qu'ailleurs
|
||||
côté fond (pas de dépendance CDN), mais on évite ici le texte en
|
||||
dégradé (background-clip:text) sur les éléments imprimés — beaucoup
|
||||
d'utilisateurs impriment sans l'option "Imprimer les arrière-plans",
|
||||
ce qui ferait disparaître un titre en dégradé. La personnalité vient
|
||||
donc de la police Fraunces + d'une couleur pleine, pas d'un effet qui
|
||||
dépend d'un réglage d'impression. */
|
||||
/* Direction artistique "Polaroid Néon" (.claude/skills/photobooth-da/SKILL.md).
|
||||
Compromis assumé pour cet écran (signalé à Kévin, pas tranché seul) :
|
||||
le carton reste un support IMPRIMÉ (papier, posé sur les tables), donc
|
||||
il garde un fond clair — un fond #12100E n'a pas de sens sur un support
|
||||
physique et gâcherait de l'encre en impression maison. En revanche il
|
||||
adopte les polices (General Sans pour le titre, Caveat pour
|
||||
l'accroche manuscrite — remplace Fraunces) et l'accent corail de la
|
||||
nouvelle DA pour rester cohérent avec le reste de la marque. Comme
|
||||
avant : pas de dégradé (background-clip:text) sur les éléments
|
||||
imprimés — beaucoup d'utilisateurs impriment sans "Imprimer les
|
||||
arrière-plans", ce qui ferait disparaître un titre en dégradé. Le
|
||||
texte sur fond corail plein (bouton "Imprimer") utilise du texte
|
||||
sombre (#12100E), conformément à la règle de contraste WCAG AA
|
||||
vérifiée pour cette DA (le corail #FF5E5B échoue avec du texte clair,
|
||||
6.3:1 à 13.2:1 avec du texte sombre). */
|
||||
@font-face {
|
||||
font-family: "Fraunces";
|
||||
src: url("/vendor/fonts/fraunces-latin-600-normal.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-family: "General Sans";
|
||||
src: url("/vendor/fonts/generalsans-700.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fraunces";
|
||||
src: url("/vendor/fonts/fraunces-latin-500-italic.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
font-family: "Caveat";
|
||||
src: url("/vendor/fonts/caveat-latin-variable.woff2") format("woff2");
|
||||
font-weight: 400 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@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;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--color-bg: #fff8f0;
|
||||
--color-bg: #faf6f0;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-muted: #fdecdd;
|
||||
--color-text: #2b2016;
|
||||
--color-muted: #8a7869;
|
||||
--color-border: #f1ddc6;
|
||||
--color-accent: #e35f2b;
|
||||
--color-accent-hover: #c74c1e;
|
||||
--color-accent-light: #ff8a52;
|
||||
--color-accent-contrast: #fffaf6;
|
||||
--color-plum: #7c2f56;
|
||||
--color-surface-muted: #f3ece1;
|
||||
--color-text: #12100e;
|
||||
--color-ink: #12100e;
|
||||
--color-muted: #5c5348;
|
||||
--color-border: #e8dfd3;
|
||||
--color-coral: #ff5e5b;
|
||||
--color-coral-hover: #e44542;
|
||||
--radius-sm: 12px;
|
||||
--radius: 20px;
|
||||
--radius-pill: 999px;
|
||||
--ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
--font-display: "Fraunces", Georgia, "Times New Roman", serif;
|
||||
--font-display: "General Sans", Arial, sans-serif;
|
||||
--font-hand: "Caveat", "Segoe Script", cursive;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -75,6 +86,10 @@
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
@@ -141,7 +156,7 @@
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline: 3px solid var(--color-coral);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -162,10 +177,10 @@
|
||||
padding: 0.8rem 1rem;
|
||||
min-height: 46px;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, var(--color-accent-light), var(--color-accent));
|
||||
color: var(--color-accent-contrast);
|
||||
box-shadow: 0 6px 16px rgba(195, 76, 30, 0.22);
|
||||
transition: transform 0.12s var(--ease-bounce), background 0.12s ease;
|
||||
background: var(--color-coral);
|
||||
color: var(--color-ink);
|
||||
box-shadow: 0 6px 16px rgba(255, 94, 91, 0.28);
|
||||
transition: transform 0.12s var(--ease-spring), background 0.12s ease;
|
||||
}
|
||||
|
||||
#printBtn:disabled {
|
||||
@@ -175,7 +190,7 @@
|
||||
}
|
||||
|
||||
#printBtn:active:not(:disabled) {
|
||||
background: linear-gradient(135deg, var(--color-accent), var(--color-accent-hover));
|
||||
background: var(--color-coral-hover);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
@@ -192,7 +207,7 @@
|
||||
width: 148mm;
|
||||
height: 210mm;
|
||||
background: var(--color-surface);
|
||||
border: 1.5mm solid var(--color-accent);
|
||||
border: 1.5mm solid var(--color-coral);
|
||||
border-radius: 6mm;
|
||||
box-shadow: 0 10px 30px rgba(38, 36, 32, 0.18);
|
||||
padding: 14mm 12mm;
|
||||
@@ -209,10 +224,19 @@
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* Pastille corail derrière la marque, même traitement que les écrans
|
||||
numériques (src/e/style.css, src/galerie/style.css,
|
||||
src/admin/gallery/style.css) — adapté en unités mm pour l'impression,
|
||||
opacité un peu plus marquée qu'à l'écran pour rester visible une fois
|
||||
imprimé (l'encre "délave" toujours un peu le rendu). */
|
||||
.card__mark {
|
||||
width: 5mm;
|
||||
height: 5mm;
|
||||
color: var(--color-accent);
|
||||
width: 7mm;
|
||||
height: 7mm;
|
||||
padding: 1.2mm;
|
||||
box-sizing: border-box;
|
||||
color: var(--color-coral);
|
||||
background: rgba(255, 94, 91, 0.12);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.card__brand-name {
|
||||
@@ -222,23 +246,30 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Titre en --color-coral-hover (pas --color-coral tel quel) : contraste
|
||||
vérifié par calcul sur fond blanc (~4:1, au-dessus du seuil 3:1 des
|
||||
"grands textes" WCAG AA applicable ici vu la taille/graisse) — le
|
||||
corail plein pur est trop proche du seuil (~3.0:1, marge trop faible
|
||||
pour un support imprimé). */
|
||||
.card__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 9mm;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-accent-hover);
|
||||
color: var(--color-coral-hover);
|
||||
margin: 6mm 0 2mm;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Accroche manuscrite (Caveat, remplace l'italique Fraunces) : en encre
|
||||
sombre pleine (--color-ink) plutôt qu'une couleur d'accent, pour rester
|
||||
lisible et fidèle à l'impression quel que soit le réglage d'encre. */
|
||||
.card__prompt {
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-size: 4.6mm;
|
||||
color: var(--color-plum);
|
||||
font-family: var(--font-hand);
|
||||
font-weight: 600;
|
||||
font-size: 6.5mm;
|
||||
color: var(--color-ink);
|
||||
margin: 0 0 8mm;
|
||||
}
|
||||
|
||||
@@ -417,6 +448,21 @@
|
||||
|
||||
baseUrlInput.placeholder = DEFAULT_BASE_URL;
|
||||
|
||||
// Pré-remplissage depuis l'URL (?slug=...&baseUrl=...), même logique
|
||||
// que src/admin/qrcode/index.html — le lien "qrcode" renvoyé par
|
||||
// create-event pointe vers le générateur QR, pas vers ce carton, mais
|
||||
// les paramètres sont repris ici aussi pour un usage direct/futur
|
||||
// (lien partagé à la main, favori, etc.).
|
||||
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;
|
||||
}
|
||||
|
||||
function humanizeSlug(slug) {
|
||||
return slug
|
||||
.replace(/[-_]+/g, ' ')
|
||||
|
||||
+191
-55
@@ -5,39 +5,77 @@
|
||||
<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>
|
||||
/* Mêmes tokens que src/e/style.css et src/admin/gallery/style.css —
|
||||
cohérence visuelle même sur cet outil interne que les clients ne
|
||||
voient pas (cf. retour Kévin 2026-09-16). Palette/rayons resynchronisés
|
||||
avec la passe 2 (2026-09-16, direction "punchy/friendly") ; outil
|
||||
interne, priorité basse donc pas de police d'accent ni d'animation
|
||||
ajoutées ici, juste la cohérence de base (couleurs, rayons). */
|
||||
/* 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: light;
|
||||
--color-bg: #fff8f0;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-muted: #fdecdd;
|
||||
--color-text: #2b2016;
|
||||
--color-muted: #8a7869;
|
||||
--color-border: #f1ddc6;
|
||||
--color-accent: #e35f2b;
|
||||
--color-accent-hover: #c74c1e;
|
||||
--color-accent-soft: #ffe3d0;
|
||||
--color-accent-contrast: #fffaf6;
|
||||
--color-error-text: #a3341c;
|
||||
--radius-sm: 12px;
|
||||
--radius: 20px;
|
||||
--shadow-sm: 0 2px 6px rgba(163, 78, 30, 0.1);
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
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);
|
||||
background: var(--color-bg);
|
||||
/* 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;
|
||||
@@ -51,10 +89,14 @@
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.brand svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-accent);
|
||||
color: var(--color-coral);
|
||||
background: rgba(255, 94, 91, 0.14);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.brand span {
|
||||
font-size: 0.7rem;
|
||||
@@ -63,7 +105,8 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.2rem;
|
||||
font-family: "General Sans", var(--font-sans);
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 0.35rem;
|
||||
@@ -72,7 +115,26 @@
|
||||
color: var(--color-muted);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.25rem;
|
||||
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;
|
||||
@@ -81,27 +143,63 @@
|
||||
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);
|
||||
border: 1px solid var(--color-border-ui);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
/* 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.1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 1.3rem 1.1rem 1.1rem;
|
||||
border: 1px solid var(--color-border-ui);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
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;
|
||||
@@ -121,38 +219,55 @@
|
||||
button {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
padding: 0.85rem 0.7rem;
|
||||
min-height: 48px;
|
||||
font-size: 0.95rem;
|
||||
border: 1px solid var(--color-accent);
|
||||
border: 1px solid var(--color-coral);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-contrast);
|
||||
box-shadow: var(--shadow-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: var(--color-accent-hover);
|
||||
background: #ff7673;
|
||||
}
|
||||
button.secondary {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-accent);
|
||||
box-shadow: none;
|
||||
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-accent-soft);
|
||||
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-error-text);
|
||||
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>
|
||||
@@ -170,19 +285,21 @@
|
||||
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" />
|
||||
<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="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>
|
||||
<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>
|
||||
@@ -220,6 +337,25 @@
|
||||
// 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;
|
||||
|
||||
+35
-13
@@ -12,8 +12,11 @@
|
||||
lors du déploiement, hors scope de ce fichier front.
|
||||
-->
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta name="theme-color" content="#faf6f0">
|
||||
<link rel="preload" href="/vendor/fonts/fraunces-latin-600-normal.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<!-- Thème unique sombre "Polaroid Néon" (voir src/e/style.css) : une seule
|
||||
meta theme-color, comme src/galerie/index.html. -->
|
||||
<meta name="theme-color" content="#12100E">
|
||||
<link rel="preload" href="/vendor/fonts/generalsans-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/vendor/fonts/caveat-latin-variable.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/e/style.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -28,6 +31,13 @@
|
||||
</p>
|
||||
<h1 id="event-name">Ajouter une photo</h1>
|
||||
<p class="page__kicker">Une photo, un petit mot, et hop dans l'album !</p>
|
||||
<!-- href posé par upload.js une fois le slug connu (getSlugFromPath) ;
|
||||
"#" par défaut pour ne jamais pointer nulle part avant ça. Lien
|
||||
permanent vers la galerie, accessible même sans avoir uploadé —
|
||||
ajouté suite à la demande de Kévin le 2026-09-18. "Mur" jugé pas
|
||||
assez fun par Kévin -> renommé "la fête" partout (cohérent avec
|
||||
le texte déjà existant de l'écran de succès). -->
|
||||
<a href="#" id="wall-link" class="wall-link">Voir la fête →</a>
|
||||
</header>
|
||||
|
||||
<!-- Mot de bienvenue + mention légale RGPD condensée, affiché une seule
|
||||
@@ -47,8 +57,8 @@
|
||||
</p>
|
||||
<p class="welcome-modal__title" id="welcome-modal-title">Bienvenue !</p>
|
||||
<p class="welcome-modal__text">
|
||||
Scanne, souris, dépose ta photo — elle rejoint l'album de l'événement
|
||||
en quelques secondes.
|
||||
Scanne, souris, dépose ta photo — elle rejoint la fête en quelques
|
||||
secondes, visible par tous les invités.
|
||||
</p>
|
||||
<p class="legal-notice welcome-modal__legal">
|
||||
<svg class="legal-notice__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||
@@ -57,18 +67,27 @@
|
||||
</svg>
|
||||
<!-- Texte légal complet, identique à docs/rgpd.md (mention
|
||||
obligatoire) — seule la présentation change (modale au premier
|
||||
passage + lien de rappel), pas le contenu. -->
|
||||
passage + lien de rappel), pas le contenu. Mis à jour le
|
||||
2026-09-18 : mentionne explicitement la visibilité par les
|
||||
autres invités (galerie commune), cf. docs/decisions.md. -->
|
||||
<span>
|
||||
En déposant une photo, tu acceptes qu'elle soit stockée temporairement
|
||||
dans le cadre de cet événement et mise à disposition de l'organisateur.
|
||||
Tes photos sont automatiquement supprimées 7 jours après la date de
|
||||
l'événement, sans action de ta part. Aucune photo n'est utilisée à
|
||||
d'autres fins, ni transmise à des tiers. Tu peux demander la
|
||||
suppression anticipée d'une photo en contactant l'organisateur de
|
||||
l'événement.
|
||||
dans le cadre de cet événement et visible par les autres invités de
|
||||
l'événement ainsi que par l'organisateur. Tes photos sont
|
||||
automatiquement supprimées 7 jours après la date de l'événement,
|
||||
sans action de ta part. Aucune photo n'est utilisée à d'autres
|
||||
fins, ni transmise à des tiers en dehors des invités de
|
||||
l'événement. Tu peux demander la suppression anticipée d'une
|
||||
photo en contactant l'organisateur de l'événement.
|
||||
</span>
|
||||
</p>
|
||||
<button type="button" id="welcome-modal-dismiss">C'est parti !</button>
|
||||
<!-- Lien dupliqué ici (même que #wall-link dans l'en-tête) : la
|
||||
modale recouvre tout l'écran en position fixed, donc le lien de
|
||||
l'en-tête est inatteignable tant qu'elle est ouverte — bug réel
|
||||
remonté par Kévin le 2026-09-18 ("le bouton ne marche pas", en
|
||||
fait il tapait dessus derrière la modale). -->
|
||||
<a href="#" id="wall-link-modal" class="btn-secondary-link">Voir la fête sans envoyer de photo →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +115,7 @@
|
||||
|
||||
<section id="success-block" class="state-block state-block--success" hidden role="status">
|
||||
<div class="confetti" aria-hidden="true">
|
||||
<span></span><span></span><span></span><span></span><span></span><span></span>
|
||||
<span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="success-check" aria-hidden="true">
|
||||
<svg viewBox="0 0 52 52" fill="none" focusable="false">
|
||||
@@ -106,7 +125,10 @@
|
||||
</div>
|
||||
<p class="success-title">C'est envoyé !</p>
|
||||
<p class="success-text">Ta photo vient de rejoindre la fête. Merci d'avoir été là !</p>
|
||||
<button type="button" id="add-another-btn">Ajouter une autre photo</button>
|
||||
<div class="success-actions">
|
||||
<button type="button" id="add-another-btn">Ajouter une autre photo</button>
|
||||
<a href="#" id="wall-link-success" class="btn-secondary-link">Voir la fête →</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form id="upload-form" class="upload-form" hidden novalidate>
|
||||
|
||||
+492
-202
File diff suppressed because it is too large
Load Diff
@@ -51,6 +51,9 @@ const submitBtnLabel = document.getElementById("submit-btn-label");
|
||||
const welcomeModal = document.getElementById("welcome-modal");
|
||||
const welcomeModalTitle = document.getElementById("welcome-modal-title");
|
||||
const welcomeModalDismiss = document.getElementById("welcome-modal-dismiss");
|
||||
const wallLink = document.getElementById("wall-link");
|
||||
const wallLinkSuccess = document.getElementById("wall-link-success");
|
||||
const wallLinkModal = document.getElementById("wall-link-modal");
|
||||
const legalLink = document.getElementById("legal-link");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -410,6 +413,13 @@ async function initEventCheck() {
|
||||
showError(appError("event-not-found"));
|
||||
return;
|
||||
}
|
||||
// Lien vers la galerie posé dès que le slug est connu, sans attendre la
|
||||
// réponse de get_public_event : /galerie/<slug> fait sa propre
|
||||
// vérification (event inconnu/clos), pas besoin de la dupliquer ici.
|
||||
const wallHref = `/galerie/${encodeURIComponent(slug)}`;
|
||||
wallLink.href = wallHref;
|
||||
wallLinkSuccess.href = wallHref;
|
||||
wallLinkModal.href = wallHref;
|
||||
try {
|
||||
const event = await fetchEventPublicInfo(slug);
|
||||
if (!isEventActive(event)) throw appError("event-closed");
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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);
|
||||
@@ -0,0 +1,74 @@
|
||||
<!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 de l'événement</title>
|
||||
<!--
|
||||
É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 toutes les photos de l'événement (revirement par
|
||||
rapport au cahier des charges initial "upload only"), cf.
|
||||
docs/decisions.md.
|
||||
|
||||
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">
|
||||
<link rel="preload" href="/vendor/fonts/generalsans-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/vendor/fonts/caveat-latin-variable.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/galerie/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<header class="page__header">
|
||||
<p class="brand" aria-hidden="true">
|
||||
<svg class="brand__mark" 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 class="brand__name">photobooth</span>
|
||||
</p>
|
||||
<h1 id="event-name">Galerie de l'événement</h1>
|
||||
<p id="event-meta" class="page__kicker">Tous les souvenirs déposés par les invités, en direct</p>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<div id="empty-state" class="empty-state" hidden>
|
||||
<svg class="empty-state__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||
<path d="M12 20.2s-7.5-4.6-7.5-10.1a4.4 4.4 0 0 1 7.5-3.1 4.4 4.4 0 0 1 7.5 3.1c0 5.5-7.5 10.1-7.5 10.1Z"/>
|
||||
</svg>
|
||||
<p>Aucune photo pour l'instant — sois le·la premier·ère à en déposer une !</p>
|
||||
</div>
|
||||
|
||||
<ul id="polaroid-grid" class="polaroid-grid" aria-live="polite" hidden></ul>
|
||||
</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 gallery.js.
|
||||
-->
|
||||
<script src="/galerie/config.js"></script>
|
||||
<script type="module" src="/galerie/gallery.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,411 @@
|
||||
/* Galerie invité — direction artistique "Polaroid Néon"
|
||||
(.claude/skills/photobooth-da/SKILL.md). Premier écran de cette nouvelle
|
||||
DA, mobile-first (95% des invités sur mobile, cf. brief).
|
||||
|
||||
Contrastes vérifiés par calcul WCAG AA (formule de luminance relative,
|
||||
voir historique de session) : texte clair (#FAF6F0/#E8DFD3) sur fond
|
||||
sombre (#12100E) est très largement au-dessus du seuil, mais texte clair
|
||||
SUR les couleurs d'accent (corail/jaune/vert) échoue largement (1.3:1 à
|
||||
2.8:1 selon la couleur — bien en dessous du minimum 4.5:1). Le texte sur
|
||||
accent utilise donc systématiquement #12100E (sombre), qui lui passe
|
||||
confortablement (6.3:1 à 13.2:1) sur les trois accents. */
|
||||
|
||||
@font-face {
|
||||
font-family: "General Sans";
|
||||
src: url("/vendor/fonts/generalsans-700.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Caveat";
|
||||
src: url("/vendor/fonts/caveat-latin-variable.woff2") format("woff2");
|
||||
font-weight: 400 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
--color-bg: #12100e;
|
||||
--color-ink: #12100e; /* texte sombre sur surfaces claires/accents */
|
||||
--color-text: #faf6f0;
|
||||
--color-muted: #e8dfd3;
|
||||
--color-muted-2: #a89e90;
|
||||
|
||||
--color-coral: #ff5e5b;
|
||||
--color-yellow: #ffd166;
|
||||
--color-mint: #06d6a0;
|
||||
|
||||
--color-polaroid: #faf6f0;
|
||||
--color-border: #e8dfd3;
|
||||
|
||||
--radius: 4px; /* les polaroids restent anguleux, pas de coins arrondis */
|
||||
--radius-ui: 14px;
|
||||
|
||||
--shadow-soft: 0 10px 24px rgba(0, 0, 0, 0.35);
|
||||
--shadow-card: 0 6px 16px rgba(0, 0, 0, 0.4);
|
||||
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
--font-display: "General Sans", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-hand: "Caveat", "Segoe Script", cursive;
|
||||
--font-ui: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
|
||||
/* --- Passe "polaroid poussé à fond" (2026-09-18) : mêmes tokens et même
|
||||
logique que src/e/style.css — les blocs d'état (chargement/erreur/vide)
|
||||
n'avaient ICI aucune carte du tout (texte nu sur fond noir), encore
|
||||
plus "low-cost" que sur l'écran upload. Aligné sur src/e/ pour que les
|
||||
deux écrans invité partagent la même signature visuelle. */
|
||||
--color-paper: #faf6f0;
|
||||
--color-paper-ink: #12100e;
|
||||
--color-paper-muted: #6b6153;
|
||||
|
||||
--shadow-paper: 0 18px 34px rgba(0, 0, 0, 0.55), 0 4px 10px rgba(0, 0, 0, 0.4);
|
||||
|
||||
--tilt-1: -2.2deg;
|
||||
--tilt-2: 1.8deg;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-ui);
|
||||
/* Même grain que src/e/style.css (cohérence entre écrans), voir le
|
||||
commentaire détaillé là-bas : casse l'aplat du fond quasi noir,
|
||||
opacité trop basse pour affecter le contraste WCAG déjà vérifié. */
|
||||
background-color: var(--color-bg);
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxODAiIGhlaWdodD0iMTgwIj48ZmlsdGVyIGlkPSJuIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iMC44NSIgbnVtT2N0YXZlcz0iMiIgc3RpdGNoVGlsZXM9InN0aXRjaCIgcmVzdWx0PSJub2lzZSIvPjxmZUNvbG9yTWF0cml4IGluPSJub2lzZSIgdHlwZT0ibWF0cml4IiB2YWx1ZXM9IjAgMCAwIDAgMSAgMCAwIDAgMCAxICAwIDAgMCAwIDEgIDAgMCAwIDAuMDUgMCIvPjwvZmlsdGVyPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbHRlcj0idXJsKCUyM24pIi8+PC9zdmc+");
|
||||
background-repeat: repeat;
|
||||
background-size: 180px 180px;
|
||||
color: var(--color-text);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1.1rem 6rem;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.page__header {
|
||||
text-align: center;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.9rem;
|
||||
color: var(--color-muted-2);
|
||||
}
|
||||
|
||||
/* Pastille corail derrière la marque, cohérente avec src/e/style.css. */
|
||||
.brand__mark {
|
||||
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__name {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.13em;
|
||||
text-transform: uppercase;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.page__header h1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.9rem;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 0.5rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.page__kicker {
|
||||
color: var(--color-muted);
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- */
|
||||
/* Grille de polaroids */
|
||||
/* --------------------------------------------------------------------- */
|
||||
|
||||
.polaroid-grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 860px) {
|
||||
.polaroid-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.polaroid {
|
||||
--rot: 0deg;
|
||||
background: var(--color-polaroid);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.6rem 0.6rem 0;
|
||||
box-shadow: var(--shadow-card);
|
||||
transform: rotate(var(--rot));
|
||||
transition: transform 0.25s var(--ease-spring), box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
/* Léger relief au survol desktop uniquement — jamais sur mobile tactile. */
|
||||
@media (hover: hover) {
|
||||
.polaroid:hover {
|
||||
transform: rotate(var(--rot)) translateY(-4px) scale(1.03);
|
||||
box-shadow: var(--shadow-soft);
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.polaroid__photo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.polaroid__caption {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.3rem 0.9rem;
|
||||
min-height: 2.6rem;
|
||||
font-family: var(--font-hand);
|
||||
font-weight: 600;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.15;
|
||||
color: var(--color-ink);
|
||||
text-align: center;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.polaroid__caption--empty::before {
|
||||
content: "♡";
|
||||
color: var(--color-coral);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Nouvel envoi : "chute" dans la pile avec rebond (skill: effet spring). */
|
||||
@keyframes polaroid-drop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-56px) rotate(0deg) scale(0.7);
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
transform: translateY(6px) rotate(var(--rot)) scale(1.04);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) rotate(var(--rot)) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.polaroid--dropping {
|
||||
animation: polaroid-drop 0.6s var(--ease-spring) both;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- */
|
||||
/* États chargement / erreur / vide */
|
||||
/* --------------------------------------------------------------------- */
|
||||
|
||||
/* Carte papier, même geste que src/e/style.css : ombre réelle (impossible
|
||||
sur une carte sombre posée sur #12100E), ruban washi tape, léger tilt —
|
||||
traite le message d'état comme un objet posé sur la table plutôt que du
|
||||
texte flottant dans le vide. */
|
||||
.state-block {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
padding: 1.75rem 1.3rem 1.5rem;
|
||||
margin: 0.75rem 0.35rem 1.5rem;
|
||||
background: var(--color-paper);
|
||||
color: var(--color-paper-ink);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-paper);
|
||||
animation: state-card-in 0.5s var(--ease-spring) both;
|
||||
}
|
||||
|
||||
@keyframes state-card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) rotate(var(--tilt, var(--tilt-1)));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) rotate(var(--tilt, var(--tilt-1)));
|
||||
}
|
||||
}
|
||||
|
||||
.state-block::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -11px;
|
||||
left: 50%;
|
||||
width: 74px;
|
||||
height: 24px;
|
||||
transform: translateX(-50%) rotate(-2.5deg);
|
||||
background: var(--tape-color, var(--color-muted-2));
|
||||
opacity: 0.88;
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.state-block--loading {
|
||||
--tilt: var(--tilt-2);
|
||||
}
|
||||
|
||||
.state-block--error {
|
||||
--tilt: -2.6deg;
|
||||
--tape-color: var(--color-coral);
|
||||
}
|
||||
|
||||
/* 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-weight: 700;
|
||||
color: var(--color-ink);
|
||||
background: var(--color-coral);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.7rem 1.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Même carte papier que .state-block (tilt inverse, tape jaune, pour
|
||||
varier des deux autres états plutôt que de répéter le même angle). */
|
||||
.empty-state {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
text-align: center;
|
||||
padding: 2.25rem 1.3rem 2rem;
|
||||
margin: 0.75rem 0.35rem 1.5rem;
|
||||
background: var(--color-paper);
|
||||
color: var(--color-paper-ink);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-paper);
|
||||
transform: rotate(1.6deg);
|
||||
}
|
||||
|
||||
.empty-state::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -11px;
|
||||
left: 50%;
|
||||
width: 74px;
|
||||
height: 24px;
|
||||
transform: translateX(-50%) rotate(2deg);
|
||||
background: var(--color-yellow);
|
||||
opacity: 0.88;
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* #empty-state est maintenant un <div> avec un display personnalisé (flex,
|
||||
pour empiler icône + texte) : sans cette règle explicite, [hidden] du
|
||||
navigateur serait masqué par la déclaration `display` ci-dessus (bug
|
||||
déjà rencontré trois fois sur ce projet, voir .polaroid-grid[hidden]
|
||||
plus haut et les mêmes règles dans src/e/style.css). */
|
||||
.empty-state[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-family: var(--font-hand);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.empty-state__icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
color: var(--color-coral);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.polaroid,
|
||||
.polaroid--dropping,
|
||||
.state-block,
|
||||
.spinner {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.state-block {
|
||||
transform: rotate(var(--tilt, var(--tilt-1)));
|
||||
}
|
||||
}
|
||||
Vendored
+34
-18
@@ -26,22 +26,38 @@ depuis la source indiquée.
|
||||
a déjà transcodé le fichier en JPEG lors de la sélection) ne téléchargeront
|
||||
jamais ce fichier.
|
||||
|
||||
## Fraunces (fonts/)
|
||||
## General Sans, Caveat, Inter (fonts/) — direction "Polaroid Néon"
|
||||
|
||||
- Fichiers : `fonts/fraunces-latin-600-normal.woff2` (18 Ko),
|
||||
`fonts/fraunces-latin-500-italic.woff2` (22,9 Ko).
|
||||
Sous-ensemble « latin » uniquement (couvre les accents français : la
|
||||
plage Google Fonts « latin » inclut Latin-1 Supplement + œ/Œ, suffisant
|
||||
pour le français — pas besoin de « latin-ext », plus lourd).
|
||||
- Licence : SIL Open Font License 1.1 (police Fraunces par Undercase Type /
|
||||
Oh no Type Co, distribuée via Google Fonts).
|
||||
- Source : régénérée par le projet **Fontsource**
|
||||
(`https://cdn.jsdelivr.net/npm/@fontsource/fraunces@5/files/`), qui
|
||||
convertit Google Fonts en fichiers statiques par graisse — évite de servir
|
||||
la version variable complète (beaucoup plus lourde) alors qu'on n'utilise
|
||||
que 2 graisses fixes.
|
||||
- Usage : police d'accent (titres, moment de succès) chargée en
|
||||
`@font-face` avec `font-display: swap`, jamais via lien Google Fonts en
|
||||
direct (règle du projet). Voir `src/e/style.css` (`--font-display`).
|
||||
Le corps de texte reste sur la pile système (`--font-sans`), aucun poids
|
||||
supplémentaire pour le texte courant.
|
||||
Trio typographique de la direction artistique du projet
|
||||
(`.claude/skills/photobooth-da/SKILL.md`), appliqué à tous les écrans web
|
||||
(le carton imprimable garde Caveat + General Sans mais reste en thème
|
||||
clair, cf. `src/admin/print-template/`).
|
||||
|
||||
- **General Sans** — `fonts/generalsans-700.woff2` (21,2 Ko), graisse 700
|
||||
uniquement (titres/display, "bold arrondie" demandée dans le brief — pas
|
||||
besoin de graisse 600 en plus pour ce rôle). Licence Open Font License,
|
||||
distribuée via Fontshare (`https://api.fontshare.com`). Fichier statique
|
||||
(pas la version variable, plus légère pour une seule graisse utilisée).
|
||||
- **Caveat** — `fonts/caveat-latin-variable.woff2` (74,9 Ko), légendes
|
||||
manuscrites (prénom/message sur les cadres polaroid). Licence SIL Open
|
||||
Font License, Google Fonts. Fichier **variable** (couvre 400-700) : Google
|
||||
sert le même fichier variable pour toutes les graisses statiques
|
||||
demandées à cette police via l'API CSS2, donc pas d'intérêt à en
|
||||
télécharger plusieurs — un seul fichier suffit, `font-weight: 400 700`
|
||||
dans le `@font-face`.
|
||||
- **Inter** — `fonts/inter-latin-variable.woff2` (48,3 Ko), texte UI/corps
|
||||
des écrans utilisant la nouvelle DA (remplace la pile système
|
||||
`--font-sans` sur ces écrans uniquement — poids assumé, demandé
|
||||
explicitement dans le brief). Licence SIL Open Font License, Google
|
||||
Fonts. Fichier variable, même raison qu'au-dessus.
|
||||
|
||||
Sous-ensemble « latin » uniquement pour les trois (couvre les accents
|
||||
français, pas besoin de « latin-ext », plus lourd). Récupérées une fois via
|
||||
l'API CSS de Fontshare/Google Fonts (`curl`) puis auto-hébergées ici —
|
||||
jamais de lien CDN live, cf. règle du projet.
|
||||
|
||||
⚠️ Poids cumulé si les 3 sont chargées sur un même écran : ~144 Ko. Si le
|
||||
poids total devient un problème, la piste la plus simple est de retirer
|
||||
Inter et revenir à la pile système pour le corps de texte (c'est la police
|
||||
la plus "remplaçable" des trois, celle qui apporte le moins de personnalité
|
||||
propre à la DA).
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,195 @@
|
||||
// Edge Function `create-event`
|
||||
//
|
||||
// Contrat :
|
||||
// POST {SUPABASE_URL}/functions/v1/create-event
|
||||
// Authorization: Bearer <SUPABASE_SERVICE_ROLE_KEY>
|
||||
// Body JSON : { nom: string, date_evenement: "YYYY-MM-DD",
|
||||
// client_nom?: string, max_photos_per_guest?: number }
|
||||
//
|
||||
// Appelée uniquement par le workflow n8n de création d'événement (Form
|
||||
// Trigger -> cet endpoint), jamais depuis le front photobooth-qr : il
|
||||
// n'existe aucun formulaire de création d'event côté appli (cf.
|
||||
// docs/decisions.md, "2026-09-18 (bis)"). Avant cette fonction, la création
|
||||
// d'un event se faisait uniquement en SQL manuel.
|
||||
//
|
||||
// Tourne avec la service_role key (cf. _shared/supabase-admin.ts). Appelle
|
||||
// la RPC `public.create_event` (cf.
|
||||
// supabase/migrations/20260918100000_create_event_rpc.sql), qui génère
|
||||
// slug + admin_token + expire_at côté SQL et insère la ligne
|
||||
// photobooth.events.
|
||||
//
|
||||
// Sécurité : même pattern que expire-events/index.ts — pas d'auth Supabase
|
||||
// standard (le JWT anon suffirait à passer la gateway, ce qui ne protège
|
||||
// rien pour un endpoint qui crée des events), donc comparaison EXPLICITE en
|
||||
// temps constant du Bearer fourni contre la service_role key, pour
|
||||
// s'assurer que seul un appelant serveur-à-serveur de confiance (le
|
||||
// workflow n8n, avec la clé dans son Credentials Store, jamais côté client)
|
||||
// peut créer un événement.
|
||||
//
|
||||
// Pas de CORS (écart assumé par rapport à admin-gallery/public-gallery, qui
|
||||
// importent _shared/cors.ts) : cette fonction n'est appelée qu'en
|
||||
// serveur-à-serveur depuis n8n, jamais depuis un navigateur — un
|
||||
// Access-Control-Allow-Origin n'aurait aucun effet utile ici, et l'ajouter
|
||||
// suggérerait à tort qu'un appel cross-origin navigateur est un usage
|
||||
// prévu.
|
||||
//
|
||||
// ⚠️ Ne pas déployer depuis cet agent (pas de `deploy_edge_function`) :
|
||||
// fichier fourni pour relecture humaine avant déploiement (Kévin applique
|
||||
// lui-même via son accès MCP Supabase direct).
|
||||
import { getSupabaseAdmin } from "../_shared/supabase-admin.ts";
|
||||
import { timingSafeEqual } from "../_shared/timing-safe.ts";
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function extractBearerToken(req: Request): string | null {
|
||||
const header = req.headers.get("authorization") ?? "";
|
||||
const match = /^Bearer\s+(.+)$/i.exec(header);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
type CreateEventBody = {
|
||||
nom?: unknown;
|
||||
date_evenement?: unknown;
|
||||
client_nom?: unknown;
|
||||
max_photos_per_guest?: unknown;
|
||||
};
|
||||
|
||||
type CreateEventRow = {
|
||||
id: string;
|
||||
slug: string;
|
||||
nom: string;
|
||||
date_evenement: string;
|
||||
expire_at: string;
|
||||
admin_token: string;
|
||||
client_nom: string | null;
|
||||
max_photos_per_guest: number | null;
|
||||
};
|
||||
|
||||
// YYYY-MM-DD strict : évite qu'une chaîne ambiguë ("01/10/2026") ou une
|
||||
// date-heure complète soit acceptée silencieusement puis mal interprétée
|
||||
// par la colonne `date` de photobooth.events. Pas de vérification
|
||||
// passé/futur (aucune règle produit ne l'exige à ce jour).
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function isValidDateString(value: string): boolean {
|
||||
if (!DATE_RE.test(value)) return false;
|
||||
const parsed = new Date(`${value}T00:00:00Z`);
|
||||
return !Number.isNaN(parsed.getTime());
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method !== "POST") {
|
||||
return jsonResponse(405, { error: "method_not_allowed" });
|
||||
}
|
||||
|
||||
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||
const bearer = extractBearerToken(req);
|
||||
|
||||
if (!serviceRoleKey || !(await timingSafeEqual(bearer, serviceRoleKey))) {
|
||||
return jsonResponse(401, { error: "unauthorized" });
|
||||
}
|
||||
|
||||
// Même secret que admin-gallery/public-gallery (cf. _shared/cors.ts), réutilisé
|
||||
// ici pour construire les liens, pas pour du CORS.
|
||||
const appBaseUrl = Deno.env.get("APP_BASE_URL");
|
||||
if (!appBaseUrl) {
|
||||
console.error("[create-event] APP_BASE_URL manquante dans l'environnement.");
|
||||
return jsonResponse(500, { error: "internal_error" });
|
||||
}
|
||||
|
||||
let body: CreateEventBody;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return jsonResponse(400, { error: "invalid_json_body" });
|
||||
}
|
||||
|
||||
const nom = typeof body.nom === "string" ? body.nom.trim() : "";
|
||||
if (!nom) {
|
||||
return jsonResponse(400, { error: "nom_required" });
|
||||
}
|
||||
|
||||
const dateEvenement =
|
||||
typeof body.date_evenement === "string" ? body.date_evenement.trim() : "";
|
||||
if (!dateEvenement || !isValidDateString(dateEvenement)) {
|
||||
return jsonResponse(400, { error: "date_evenement_invalid" });
|
||||
}
|
||||
|
||||
let clientNom: string | null = null;
|
||||
if (body.client_nom !== undefined && body.client_nom !== null) {
|
||||
if (typeof body.client_nom !== "string") {
|
||||
return jsonResponse(400, { error: "client_nom_invalid" });
|
||||
}
|
||||
const trimmed = body.client_nom.trim();
|
||||
clientNom = trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
let maxPhotosPerGuest: number | null = null;
|
||||
if (body.max_photos_per_guest !== undefined && body.max_photos_per_guest !== null) {
|
||||
const value = body.max_photos_per_guest;
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
||||
return jsonResponse(400, { error: "max_photos_per_guest_invalid" });
|
||||
}
|
||||
maxPhotosPerGuest = value;
|
||||
}
|
||||
|
||||
const supabaseAdmin = getSupabaseAdmin();
|
||||
|
||||
// public.create_event (service_role uniquement) génère slug + admin_token
|
||||
// + expire_at côté SQL et insère la ligne — cf.
|
||||
// 20260918100000_create_event_rpc.sql. Revalide elle-même nom/date/max
|
||||
// (ne fait pas confiance à cette validation faite en amont, même principe
|
||||
// que insert_public_photo côté RPC anon).
|
||||
const { data: event, error } = await supabaseAdmin
|
||||
.rpc("create_event", {
|
||||
p_nom: nom,
|
||||
p_date_evenement: dateEvenement,
|
||||
p_client_nom: clientNom,
|
||||
p_max_photos_per_guest: maxPhotosPerGuest,
|
||||
})
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
console.error("[create-event] create_event RPC failed", error);
|
||||
return jsonResponse(500, { error: "internal_error" });
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
console.error("[create-event] create_event RPC returned no row");
|
||||
return jsonResponse(500, { error: "internal_error" });
|
||||
}
|
||||
|
||||
const typedEvent = event as CreateEventRow;
|
||||
const cleanBaseUrl = appBaseUrl.trim().replace(/\/+$/, "");
|
||||
|
||||
// 201 (plutôt que 200 comme les autres fonctions, toutes des GET) : seule
|
||||
// fonction du projet qui crée une ressource.
|
||||
return jsonResponse(201, {
|
||||
event: {
|
||||
nom: typedEvent.nom,
|
||||
slug: typedEvent.slug,
|
||||
date_evenement: typedEvent.date_evenement,
|
||||
expire_at: typedEvent.expire_at,
|
||||
client_nom: typedEvent.client_nom,
|
||||
max_photos_per_guest: typedEvent.max_photos_per_guest,
|
||||
},
|
||||
links: {
|
||||
invite: `${cleanBaseUrl}/e/${encodeURIComponent(typedEvent.slug)}`,
|
||||
admin: `${cleanBaseUrl}/admin/${encodeURIComponent(typedEvent.slug)}?token=${encodeURIComponent(
|
||||
typedEvent.admin_token
|
||||
)}`,
|
||||
// src/admin/qrcode/index.html lit désormais `?slug=` ET `?baseUrl=`
|
||||
// pour se pré-remplir entièrement (corrigé le 2026-09-18, cf.
|
||||
// docs/decisions.md — ce lien ne servait à rien tant que la page ne
|
||||
// lisait aucun des deux paramètres).
|
||||
qrcode: `${cleanBaseUrl}/admin/qrcode/?slug=${encodeURIComponent(
|
||||
typedEvent.slug
|
||||
)}&baseUrl=${encodeURIComponent(cleanBaseUrl)}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
// Edge Function `public-gallery`
|
||||
//
|
||||
// Contrat : GET {SUPABASE_URL}/functions/v1/public-gallery?slug=<slug>
|
||||
//
|
||||
// Tourne avec la service_role key (cf. _shared/supabase-admin.ts), MAIS
|
||||
// sans aucune vérification de token — c'est le point central de cette
|
||||
// fonction, à la différence de `admin-gallery`. L'accès est intentionnellement
|
||||
// ouvert à quiconque connaît le `slug` : même modèle de confiance que
|
||||
// l'upload invité (le slug circule via QR code, ce n'est pas un secret).
|
||||
//
|
||||
// Origine : revirement produit du 2026-09-18 (cf. docs/decisions.md) — les
|
||||
// invités peuvent désormais voir toutes les photos d'un événement, pas
|
||||
// seulement envoyer la leur. Ça inverse la règle "lecture des photos jamais
|
||||
// publique" du cahier des charges initial / docs/modele-donnees.md : ce
|
||||
// revirement est acté comme décision explicite du 2026-09-18, pas tranché
|
||||
// par cet agent.
|
||||
//
|
||||
// Sécurité : on résout le slug via `public.get_public_event` (PAS
|
||||
// `get_event_for_admin`, qui expose admin_token) — cette fonction ne
|
||||
// renvoie que {id, nom, statut, date_evenement, expire_at,
|
||||
// max_photos_per_guest}, jamais admin_token / client_nom / facture_montant.
|
||||
// Les photos elles-mêmes viennent de `public.get_event_photos`
|
||||
// (service_role uniquement), qui ne renvoie que {id, url_storage,
|
||||
// nom_invite, message, uploaded_at} — jamais l'email/téléphone d'un invité
|
||||
// (ces colonnes n'existent d'ailleurs pas dans photobooth.photos, cf.
|
||||
// docs/modele-donnees.md).
|
||||
//
|
||||
// `get_public_event` renvoie l'événement même si son statut n'est pas
|
||||
// `actif` (expiré/archivé) — comportement hérité, inchangé ici. Cette
|
||||
// fonction renvoie donc telles quelles les photos d'un événement expiré si
|
||||
// elles existent encore en base (la purge Storage/DB à J+7 est un job
|
||||
// séparé, cf. expire-events) : pas de filtre `statut = 'actif'` ajouté ici,
|
||||
// le front peut décider d'afficher un message adapté à partir de
|
||||
// `event.statut`, cohérent avec le comportement déjà choisi pour
|
||||
// `get_public_event` côté page d'upload.
|
||||
import { getSupabaseAdmin, getStorageBucket } from "../_shared/supabase-admin.ts";
|
||||
import { corsHeaders, handleCorsPreflight } from "../_shared/cors.ts";
|
||||
|
||||
// URLs signées affichées dans la galerie invité : courte durée, c'est pour
|
||||
// un affichage immédiat dans le navigateur de l'invité, pas pour un lien à
|
||||
// partager. Même valeur que `admin-gallery` (cf. GALLERY_SIGNED_URL_TTL_SECONDS
|
||||
// dans supabase/functions/admin-gallery/index.ts).
|
||||
const GALLERY_SIGNED_URL_TTL_SECONDS = 10 * 60; // 10 minutes
|
||||
|
||||
type PhotoRow = {
|
||||
id: string;
|
||||
nom_invite: string | null;
|
||||
message: string | null;
|
||||
uploaded_at: string;
|
||||
url_storage: string;
|
||||
};
|
||||
|
||||
type PublicEventRow = {
|
||||
id: string;
|
||||
nom: string;
|
||||
statut: string;
|
||||
date_evenement: string;
|
||||
expire_at: string;
|
||||
max_photos_per_guest: number | null;
|
||||
};
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders(), "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// 404 générique : même statut/corps que le "slug inconnu" de admin-gallery,
|
||||
// pour ne pas distinguer un slug qui n'existe pas d'un autre cas d'erreur
|
||||
// silencieuse côté appelant.
|
||||
function notFound(): Response {
|
||||
return jsonResponse(404, { error: "not_found" });
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const preflight = handleCorsPreflight(req);
|
||||
if (preflight) return preflight;
|
||||
|
||||
if (req.method !== "GET") {
|
||||
return jsonResponse(405, { error: "method_not_allowed" });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const slug = url.searchParams.get("slug");
|
||||
|
||||
if (!slug) {
|
||||
return jsonResponse(400, { error: "missing_parameters" });
|
||||
}
|
||||
|
||||
const supabaseAdmin = getSupabaseAdmin();
|
||||
|
||||
// 1. Résout l'event par slug via public.get_public_event (anon-safe,
|
||||
// n'expose jamais admin_token/client_nom/facture_montant) — pas
|
||||
// get_event_for_admin, réservée à admin-gallery.
|
||||
const { data: event, error: eventError } = await supabaseAdmin
|
||||
.rpc("get_public_event", { p_slug: slug })
|
||||
.maybeSingle();
|
||||
|
||||
if (eventError) {
|
||||
console.error("[public-gallery] get_public_event failed", eventError);
|
||||
return jsonResponse(500, { error: "internal_error" });
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const typedEvent = event as PublicEventRow;
|
||||
|
||||
// 2. Photos de l'event, triées chronologiquement (get_event_photos renvoie
|
||||
// déjà uploaded_at ASC) — pas de tri inversé ici contrairement à
|
||||
// admin-gallery : une galerie invité affiche naturellement l'ordre
|
||||
// d'arrivée des photos, pas de raison produit de l'inverser pour l'instant.
|
||||
let photos: PhotoRow[];
|
||||
try {
|
||||
const { data, error } = await supabaseAdmin.rpc("get_event_photos", {
|
||||
p_event_id: typedEvent.id,
|
||||
});
|
||||
if (error) throw error;
|
||||
photos = (data ?? []) as PhotoRow[];
|
||||
} catch (err) {
|
||||
console.error("[public-gallery] get_event_photos failed", err);
|
||||
return jsonResponse(500, { error: "internal_error" });
|
||||
}
|
||||
|
||||
// Event sans photo : état normal (pas d'upload pour l'instant), pas une
|
||||
// erreur — 200 avec photos: [], contrairement à admin-gallery?action=zip
|
||||
// qui renvoie 404/no_photos (là, l'absence de photo empêche l'action
|
||||
// demandée "générer un ZIP" ; ici il n'y a pas d'action, juste un état
|
||||
// d'affichage à rendre).
|
||||
const signedUrlByPath = new Map<string, string>();
|
||||
if (photos.length > 0) {
|
||||
const paths = photos.map((p) => p.url_storage);
|
||||
const bucket = getStorageBucket();
|
||||
const { data: signedUrls, error: signError } = await supabaseAdmin.storage
|
||||
.from(bucket)
|
||||
.createSignedUrls(paths, GALLERY_SIGNED_URL_TTL_SECONDS);
|
||||
|
||||
if (signError) {
|
||||
console.error("[public-gallery] createSignedUrls failed", signError);
|
||||
return jsonResponse(500, { error: "internal_error" });
|
||||
}
|
||||
|
||||
for (let i = 0; i < paths.length; i++) {
|
||||
const entry = signedUrls?.[i];
|
||||
if (entry?.signedUrl) {
|
||||
signedUrlByPath.set(paths[i], entry.signedUrl);
|
||||
} else if (entry?.error) {
|
||||
// Une photo dont l'URL n'a pas pu être signée (ex. fichier absent du
|
||||
// Storage alors que la ligne existe encore côté DB) ne doit pas
|
||||
// faire échouer toute la galerie : on la renvoie avec
|
||||
// signed_url: null, au front de gérer l'affichage.
|
||||
console.warn(
|
||||
`[public-gallery] signed url failed for ${paths[i]}`,
|
||||
entry.error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse(200, {
|
||||
event: {
|
||||
nom: typedEvent.nom,
|
||||
date_evenement: typedEvent.date_evenement,
|
||||
statut: typedEvent.statut,
|
||||
photo_count: photos.length,
|
||||
},
|
||||
photos: photos.map((p) => ({
|
||||
id: p.id,
|
||||
nom_invite: p.nom_invite,
|
||||
message: p.message,
|
||||
uploaded_at: p.uploaded_at,
|
||||
signed_url: signedUrlByPath.get(p.url_storage) ?? null,
|
||||
})),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
-- Migration : fonction `create_event` (public, service_role uniquement)
|
||||
-- Réf. : docs/decisions.md ("2026-09-18 (bis)", création d'événement via un
|
||||
-- formulaire n8n + Edge Function `create-event`), docs/modele-donnees.md
|
||||
-- (table `events`), 20260916090400_create_public_functions.sql (pattern
|
||||
-- des fonctions SECURITY DEFINER réservées à service_role).
|
||||
--
|
||||
-- Avant cette migration, la création d'un event se faisait uniquement en SQL
|
||||
-- manuel (cf. docs/cahier-des-charges.md, livrable non coché "Documentation
|
||||
-- courte : comment créer un nouvel événement manuellement"). Cette fonction
|
||||
-- devient le point d'entrée unique, appelée par la nouvelle Edge Function
|
||||
-- `supabase/functions/create-event/index.ts` (elle-même appelée par un
|
||||
-- workflow n8n serveur-à-serveur, jamais depuis le front).
|
||||
--
|
||||
-- ⚠️ Ne pas appliquer depuis cet agent (Kévin applique lui-même).
|
||||
|
||||
-- unaccent est nécessaire pour dériver un slug lisible à partir de `nom`
|
||||
-- (ex: "Mariage Julie & Marc" -> "mariage-julie-marc"), même logique que
|
||||
-- `sanitizeFilenamePart` côté admin-gallery/index.ts mais en SQL (extension
|
||||
-- contrib standard, pas de dépendance tierce).
|
||||
create extension if not exists unaccent with schema extensions;
|
||||
|
||||
-- =============================================================================
|
||||
-- photobooth.generate_unique_slug — interne (pas un point de contrat public)
|
||||
-- =============================================================================
|
||||
-- Dérive un slug lisible à partir d'un texte libre, et garantit son unicité
|
||||
-- contre photobooth.events.slug en ajoutant un suffixe numérique en cas de
|
||||
-- collision ("mariage-julie-marc", puis "mariage-julie-marc-2", etc.).
|
||||
-- Fonction interne au schéma photobooth : ce n'est pas un point de contrat
|
||||
-- public (comme photobooth.events_set_expire_at()), pas besoin d'être dans
|
||||
-- public ni d'avoir des grants dédiés (appelée uniquement par
|
||||
-- public.create_event, SECURITY DEFINER, propriétaire postgres).
|
||||
create or replace function photobooth.generate_unique_slug(p_source text)
|
||||
returns text
|
||||
language plpgsql
|
||||
as $$
|
||||
declare
|
||||
v_base_slug text;
|
||||
v_candidate text;
|
||||
v_suffix integer := 1;
|
||||
-- Garde-fou défensif : en pratique la boucle se termine toujours au
|
||||
-- premier ou deuxième tour (collision rarissime), mais on évite une
|
||||
-- boucle infinie en cas de bug/donnée pathologique.
|
||||
v_max_attempts constant integer := 1000;
|
||||
begin
|
||||
v_base_slug := lower(trim(both '-' from
|
||||
regexp_replace(extensions.unaccent(coalesce(p_source, '')), '[^a-zA-Z0-9]+', '-', 'g')
|
||||
));
|
||||
|
||||
if v_base_slug = '' then
|
||||
v_base_slug := 'evenement';
|
||||
end if;
|
||||
|
||||
v_candidate := v_base_slug;
|
||||
|
||||
while exists (select 1 from photobooth.events where slug = v_candidate) loop
|
||||
v_suffix := v_suffix + 1;
|
||||
v_candidate := v_base_slug || '-' || v_suffix;
|
||||
|
||||
if v_suffix > v_max_attempts then
|
||||
raise exception 'slug_generation_failed'
|
||||
using errcode = 'P0001',
|
||||
detail = format('Impossible de générer un slug unique pour "%s" après %s tentatives.', p_source, v_max_attempts);
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
return v_candidate;
|
||||
end;
|
||||
$$;
|
||||
|
||||
comment on function photobooth.generate_unique_slug(text) is
|
||||
'Dérive un slug lisible et unique (photobooth.events.slug) à partir d''un texte libre (typiquement events.nom) : normalise via unaccent, remplace tout ce qui n''est pas [a-zA-Z0-9] par un tiret, ajoute un suffixe numérique en cas de collision. Fonction interne, appelée uniquement par public.create_event.';
|
||||
|
||||
-- =============================================================================
|
||||
-- create_event — service_role UNIQUEMENT (NOUVEAU)
|
||||
-- =============================================================================
|
||||
-- Point d'entrée unique pour créer un événement. Réservée à service_role :
|
||||
-- appelée par l'Edge Function `create-event`, elle-même appelée uniquement
|
||||
-- par le workflow n8n de création d'événement (jamais depuis le front, pas
|
||||
-- de formulaire de création dans l'appli photobooth-qr, cf. décision
|
||||
-- "2026-09-18 (bis)").
|
||||
--
|
||||
-- Ne calcule NI admin_token NI expire_at explicitement : ces deux valeurs
|
||||
-- sont déjà garanties par la table elle-même (default
|
||||
-- `encode(extensions.gen_random_bytes(32), 'hex')` pour admin_token, trigger
|
||||
-- `trg_events_set_expire_at` pour expire_at, cf.
|
||||
-- 20260916090100_create_events_table.sql) — dupliquer cette logique ici
|
||||
-- créerait deux sources de vérité pour le même comportement.
|
||||
create or replace function public.create_event(
|
||||
p_nom text,
|
||||
p_date_evenement date,
|
||||
p_client_nom text default null,
|
||||
p_max_photos_per_guest integer default null
|
||||
)
|
||||
returns table (
|
||||
id uuid,
|
||||
slug text,
|
||||
nom text,
|
||||
date_evenement date,
|
||||
expire_at timestamptz,
|
||||
admin_token text,
|
||||
client_nom text,
|
||||
max_photos_per_guest integer
|
||||
)
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public, photobooth, extensions, pg_temp
|
||||
as $$
|
||||
declare
|
||||
v_slug text;
|
||||
begin
|
||||
-- Revalidation défensive côté SQL (l'Edge Function valide déjà ces mêmes
|
||||
-- règles avant l'appel RPC, cf. supabase/functions/create-event/index.ts) :
|
||||
-- même principe que public.insert_public_photo(), qui ne fait pas confiance
|
||||
-- à un appelant "déjà propre".
|
||||
if p_nom is null or trim(p_nom) = '' then
|
||||
raise exception 'nom_required'
|
||||
using errcode = 'P0001',
|
||||
detail = 'Le nom de l''événement est requis.';
|
||||
end if;
|
||||
|
||||
if p_date_evenement is null then
|
||||
raise exception 'date_evenement_required'
|
||||
using errcode = 'P0001',
|
||||
detail = 'La date de l''événement est requise.';
|
||||
end if;
|
||||
|
||||
if p_max_photos_per_guest is not null and p_max_photos_per_guest <= 0 then
|
||||
raise exception 'max_photos_per_guest_invalid'
|
||||
using errcode = 'P0001',
|
||||
detail = 'max_photos_per_guest doit être un entier strictement positif si fourni.';
|
||||
end if;
|
||||
|
||||
v_slug := photobooth.generate_unique_slug(p_nom);
|
||||
|
||||
return query
|
||||
insert into photobooth.events (slug, nom, date_evenement, client_nom, max_photos_per_guest)
|
||||
values (v_slug, p_nom, p_date_evenement, p_client_nom, p_max_photos_per_guest)
|
||||
returning
|
||||
events.id,
|
||||
events.slug,
|
||||
events.nom,
|
||||
events.date_evenement,
|
||||
events.expire_at,
|
||||
events.admin_token,
|
||||
events.client_nom,
|
||||
events.max_photos_per_guest;
|
||||
end;
|
||||
$$;
|
||||
|
||||
comment on function public.create_event(text, date, text, integer) is
|
||||
'Crée un événement (photobooth.events) : génère un slug unique à partir de `nom`, laisse la table calculer admin_token (default) et expire_at (trigger). Réservée à service_role (Edge Function create-event) : ne JAMAIS accorder EXECUTE à anon/authenticated.';
|
||||
|
||||
revoke all on function public.create_event(text, date, text, integer) from public, anon, authenticated;
|
||||
grant execute on function public.create_event(text, date, text, integer) to service_role;
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Durcit photobooth.generate_unique_slug : le linter de sécurité Supabase
|
||||
-- signale un search_path mutable (absent) sur cette fonction, introduite par
|
||||
-- 20260918100000_create_event_rpc.sql. Même remède que le reste du projet
|
||||
-- (public.create_event fixe déjà search_path) : figer explicitement, pour
|
||||
-- empêcher qu'un search_path attaquant (ex: schéma temporaire malveillant)
|
||||
-- ne fasse résoudre unaccent() ou photobooth.events vers autre chose que
|
||||
-- prévu.
|
||||
create or replace function photobooth.generate_unique_slug(p_source text)
|
||||
returns text
|
||||
language plpgsql
|
||||
set search_path = photobooth, extensions, pg_temp
|
||||
as $$
|
||||
declare
|
||||
v_base_slug text;
|
||||
v_candidate text;
|
||||
v_suffix integer := 1;
|
||||
v_max_attempts constant integer := 1000;
|
||||
begin
|
||||
v_base_slug := lower(trim(both '-' from
|
||||
regexp_replace(extensions.unaccent(coalesce(p_source, '')), '[^a-zA-Z0-9]+', '-', 'g')
|
||||
));
|
||||
|
||||
if v_base_slug = '' then
|
||||
v_base_slug := 'evenement';
|
||||
end if;
|
||||
|
||||
v_candidate := v_base_slug;
|
||||
|
||||
while exists (select 1 from photobooth.events where slug = v_candidate) loop
|
||||
v_suffix := v_suffix + 1;
|
||||
v_candidate := v_base_slug || '-' || v_suffix;
|
||||
|
||||
if v_suffix > v_max_attempts then
|
||||
raise exception 'slug_generation_failed'
|
||||
using errcode = 'P0001',
|
||||
detail = format('Impossible de générer un slug unique pour "%s" après %s tentatives.', p_source, v_max_attempts);
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
return v_candidate;
|
||||
end;
|
||||
$$;
|
||||
Reference in New Issue
Block a user