Compare commits

...
10 Commits
Author SHA1 Message Date
KevinLecou 8568b48461 Ajoute la création d'événement via n8n et refond la DA en cartes papier
- Nouvelle Edge Function create-event + RPC public.create_event : génère
  slug/admin_token/expire_at et insère l'event, réservée à service_role,
  appelée par le workflow n8n "Créer un événement" (Form Trigger, publié).
- Passe DA "polaroid poussé à fond" : les cartes d'état (chargement/erreur/
  succès), la modale de bienvenue et le formulaire passent en papier clair
  avec ruban washi tape et léger tilt, plutôt qu'en carte sombre sur fond
  sombre (ombre invisible dans l'ancienne version). Admin resté sobre/sombre
  mais cartes plus lisibles (liseré coloré, contraste renforcé).
- Générateur QR et carton imprimable : lisent maintenant ?slug= et
  ?baseUrl= pour se pré-remplir depuis le lien renvoyé par create-event.
- Correctifs : icône manquante sur "Télécharger" (admin), bug [hidden]
  anticipé sur l'état vide de la galerie invité.
2026-09-18 12:16:25 +02:00
KevinLecouandClaude Sonnet 5 d0131331a6 Supprime les fichiers de police Fraunces inutilisés
Reliquat de l'ancienne direction artistique, superseded par la DA
"Polaroid Néon" (General Sans/Caveat/Inter). Supprimés localement et
sur le VPS ; NOTICE.md mis à jour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 09:54:14 +02:00
KevinLecouandClaude Sonnet 5 e06f8afd3e Corrige le lien vers la galerie inatteignable derrière la modale + renomme "mur" en "fête"
La modale de bienvenue (position: fixed, inset: 0) recouvre tout
l'écran y compris l'en-tête où vivait le seul lien vers la galerie :
au premier chargement (cas le plus fréquent, un seul passage par
invité et par événement), le lien était visuellement présent mais
totalement inatteignable au clic. Ajout d'un lien dupliqué dans la
carte de la modale elle-même. Au passage, "mur" remplacé par "fête"
partout (jugé pas assez fun) pour rester cohérent avec le texte déjà
utilisé sur l'écran de succès.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 09:54:09 +02:00
KevinLecou c714316974 Ajoute un lien vers le mur depuis la page upload + corrige la mention RGPD
Lien "Voir le mur" dans l'en-tête (permanent) et sur l'écran de succès,
demandé par Kévin. href posé dès que le slug est connu, sans attendre la
réponse de get_public_event.

Corrige un oubli réel : la mention RGPD disait encore "mise à disposition
de l'organisateur" alors que la décision du 2026-09-18 (galerie invité
visible par tous) avait été consignée dans docs/decisions.md sans que le
texte affiché aux invités soit réellement mis à jour. Corrigé dans
docs/rgpd.md et dans la modale de bienvenue réelle.
2026-09-18 09:33:44 +02:00
KevinLecou ea3e49cc04 Étend la DA "Polaroid Néon" aux 4 derniers écrans
Upload invité (priorité haute, full traitement : fond #12100E, CTA
corail, General Sans/Inter, touche Caveat sur le message de succès),
galerie admin (exception admin du skill : sobre, sombre, cartes #1E1B18,
pas de rotation/confettis, corail réservé aux CTA), générateur QR (même
exception admin, traitement minimal), carton imprimable (reste clair —
un support papier n'a pas vocation à avoir un fond noir — mais adopte les
polices et l'accent corail de la nouvelle DA, contraste recalculé pour
l'impression).

Double thème clair/sombre de l'ancienne DA retiré au profit d'un thème
unique sombre, cohérent avec src/galerie/ (premier écran de cette DA,
déjà validé). Dégradés de texte et halo de fond de l'ancienne DA retirés
(skill : jamais de glow/neon artificiel).

Contrastes vérifiés par calcul WCAG AA pour toutes les nouvelles
combinaisons. Chemins d'assets absolus et pattern [hidden]/display
vérifiés sur les 4 écrans (bugs déjà rencontrés deux fois en prod).
2026-09-18 09:21:40 +02:00
KevinLecou f4322850d6 Branche la galerie invité au vrai backend (Edge Function public-gallery)
Nouvelle Edge Function publique (aucun token requis — même modèle de
confiance que l'upload, le slug n'est pas un secret) qui résout un slug
via get_public_event puis renvoie les photos de get_event_photos avec
des URLs signées. Déployée et testée en conditions réelles sur
kevin-lecou-hub (slug inconnu -> 404, event sans photo -> 200 vide,
event avec photo -> signed_url fonctionnelle), CORS vérifié.

Front (src/galerie/) : remplace les données factices par un vrai fetch +
sondage (polling 25s, pas de Supabase Realtime — le schéma photobooth
reste volontairement jamais exposé, l'activer aurait demandé de rouvrir
ce verrou RLS). Rotation des cartes figée par photo (pas de "tremblement"
au rafraîchissement), seules les nouvelles photos jouent l'animation de
chute. États chargement/erreur/vide ajoutés (absents de la maquette).
Testé en réel : une photo uploadée apparaît bien sur le mur.

Suppression des données de maquette (gallery-mock.js, mock-photos/).
2026-09-18 09:21:31 +02:00
KevinLecou 107e5c619a Nouvelle DA "Polaroid Néon" : écran galerie invité (maquette visuelle)
Nouvelle direction artistique actée (.claude/skills/photobooth-da/SKILL.md)
qui remplace progressivement la DA corail/prune/doré des 3 passes
précédentes. Décision associée : les invités voient désormais toutes les
photos de l'événement (revirement du "upload only" initial), cf.
docs/decisions.md — implique une mise à jour de la mention RGPD à faire.

Écran 3 du brief (galerie invité) livré en premier, comme demandé,
priorité au rendu visuel : grille de cadres polaroid (rotation aléatoire
-4°/+4°, légende manuscrite Caveat), fond noir chaud #12100E, effet
"chute avec rebond" pour un nouveau polaroid (bouton de démo temporaire
pour l'évaluer sans backend réel).

Contrastes vérifiés par calcul WCAG AA (skill) : texte clair sur fond
sombre largement conforme, mais texte clair sur les 3 couleurs d'accent
échouait (1.3:1 à 2.8:1) — corrigé en texte sombre systématique sur les
surfaces accent (6.3:1 à 13.2:1).

Nouvelles polices auto-hébergées (General Sans 700, Caveat variable,
Inter variable — ~144 Ko cumulés, poids documenté dans src/vendor/NOTICE.md).

⚠️ Maquette avec données factices (src/galerie/mock-photos/) : pas encore
câblée à Supabase, il manque une RPC/Edge Function publique de listing
(aucune fonction actuelle ne convient à un accès invité anonyme sans
admin_token). Route /galerie/<slug> ajoutée à deploy/nginx-photobooth.conf
et déployée sur photobooth.assistantaikev.eu.
2026-09-18 09:04:05 +02:00
KevinLecou 2dd0db54c8 Consigne l'état de fin de journée du 2026-09-16 (déploiement réel, bugs corrigés, DA en cours) 2026-09-18 08:51:46 +02:00
KevinLecou 4f31e98079 Corrige un bug présent depuis le tout premier commit : hidden ne masquait rien
.upload-form et .welcome-modal déclarent `display: flex` en dur — une
déclaration `display` en CSS auteur passe TOUJOURS devant la règle
[hidden] { display: none } du navigateur (l'origine auteur l'emporte sur
l'origine user-agent dans la cascade, indépendamment de la spécificité).
Résultat concret :
- form.hidden = true/false (JS) ne masquait jamais réellement le
  formulaire, resté affiché en permanence superposé aux états
  chargement/erreur/succès depuis le commit initial (9b14619).
- welcomeModal.hidden = true (clic sur "C'est parti") mettait bien l'état
  à jour mais la modale ne disparaissait jamais visuellement — signalé
  par Kévin après test réel.
- Même bug sur .preview dans le générateur QR (src/admin/qrcode/).

Fix : règles explicites .upload-form[hidden], .welcome-modal[hidden],
.preview[hidden] { display: none }, spécificité plus élevée que la règle
display de base donc gagnent correctement la cascade. La galerie admin
n'avait pas ce bug (aucun display explicite sur ses éléments togglés).
2026-09-16 16:25:17 +02:00
KevinLecou 6d6be3ef99 Passe 3 DA : mode sombre + finition "premium type iPhone"
Vrai mode sombre via prefers-color-scheme (palette repensée, pas une
inversion — fond brun quasi-noir pour rester dans la famille chaude de la
marque, contrastes vérifiés WCAG AA). Répond aux deux demandes de Kévin en
une implémentation : mode sombre réel + rendu moins agressif en usage
réel (soirée en intérieur tamisé). Appliqué à la page upload, la galerie
admin et le générateur QR ; le carton imprimable reste volontairement en
clair uniquement (pas de mode sombre pertinent pour un PDF imprimé).

Finition : easing dédié aux transitions d'état (séparé du rebond ludique
existant), halo de couleur en fond de page, hover desktop enrichi,
focus ring teinté, entrées en fondu au chargement, modale de bienvenue
peaufinée.

Brainstorm documenté (voir rapport de tâche) : palette/typo confirmées
plutôt qu'étendues avec une 4e couleur ou une police supplémentaire, pour
rester fidèle à l'identité perso de Kévin plutôt que de la diluer.
2026-09-16 16:10:23 +02:00
28 changed files with 2621 additions and 444 deletions
+33
View File
@@ -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).
+3
View File
@@ -13,6 +13,9 @@ src/e/config.js
# Config front runtime — galerie admin (cf. src/admin/gallery/config.example.js) # Config front runtime — galerie admin (cf. src/admin/gallery/config.example.js)
src/admin/gallery/config.js src/admin/gallery/config.js
# Config front runtime — galerie invité (cf. src/galerie/config.example.js)
src/galerie/config.js
# Build # Build
dist/ dist/
build/ build/
+8
View File
@@ -28,6 +28,14 @@ server {
try_files $uri $uri/ /admin/gallery/index.html; 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/ { location /vendor/ {
try_files $uri =404; try_files $uri =404;
} }
+11 -9
View File
@@ -181,16 +181,18 @@ Comme les invités uploadent des photos pouvant contenir des visages d'autres pe
## 📦 7. Livrables attendus du développeur ## 📦 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 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=...`) — 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] 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) — code écrit (`src/admin/qrcode/`), page démo utilisable dès maintenant. - [x] Génération du QR code (export PNG/SVG imprimable) — déployée (`/admin/qrcode/`), + carton imprimable A5 bonus (`/admin/print-template/`).
- [ ] 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] 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`, intégrée dans `src/e/upload.js`. - [x] Compression image côté client avant upload — `browser-image-compression`, testée en conditions réelles.
- [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] 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 intégrée sur la page upload — texte de `docs/rgpd.md` repris verbatim. - [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 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] 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. - [ ] 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. - [ ] 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.
+185
View File
@@ -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 (schéma DB, auth, hébergement, etc.) doit être ajoutée ici après validation
par Kévin. 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 ## 2026-09-15
- **Expiration des données** : suppression à **J+7 à partir de la date de - **Expiration des données** : suppression à **J+7 à partir de la date de
+13 -6
View File
@@ -11,12 +11,13 @@ Texte à afficher, de façon visible, sur la page d'upload avant que l'invité
ne dépose une photo : ne dépose une photo :
> En déposant une photo, vous acceptez qu'elle soit stockée temporairement > 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. > dans le cadre de cet événement et **visible par les autres invités de
> Vos photos sont automatiquement supprimées **7 jours après la date de > l'événement ainsi que par l'organisateur** (galerie commune accessible via
> l'événement**, sans action de votre part. Aucune photo n'est utilisée à > le même lien/QR code). Vos photos sont automatiquement supprimées
> d'autres fins, ni transmise à des tiers. Vous pouvez demander la > **7 jours après la date de l'événement**, sans action de votre part.
> suppression anticipée d'une photo en contactant l'organisateur de > Aucune photo n'est utilisée à d'autres fins, ni transmise à des tiers en
> l'événement. > 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 ## 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 ## 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 - Rédaction définitive des CGU/mentions légales pour un usage commercial
auprès de clients externes (mariages, événementiel pro). auprès de clients externes (mariages, événementiel pro).
- Statut du client (organisateur) comme responsable de traitement, et de - Statut du client (organisateur) comme responsable de traitement, et de
+4 -1
View File
@@ -273,7 +273,10 @@ function renderPhotoGrid() {
// à la place (limitation connue, acceptée pour éviter un fetch+blob par // à la place (limitation connue, acceptée pour éviter un fetch+blob par
// photo qui multiplierait les requêtes réseau). // photo qui multiplierait les requêtes réseau).
downloadLink.download = ""; 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) => { downloadLink.addEventListener("click", (evt) => {
if (isPhotoLinkLikelyExpired(photo)) { if (isPhotoLinkLikelyExpired(photo)) {
evt.preventDefault(); evt.preventDefault();
+9 -3
View File
@@ -13,8 +13,10 @@
fichier front, à mettre en place lors du déploiement. fichier front, à mettre en place lors du déploiement.
--> -->
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#faf6f0"> <!-- Thème unique sombre "Polaroid Néon", exception admin (sobre/data-first,
<link rel="preload" href="/vendor/fonts/fraunces-latin-600-normal.woff2" as="font" type="font/woff2" crossorigin> 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"> <link rel="stylesheet" href="/admin/gallery/style.css">
</head> </head>
<body> <body>
@@ -38,7 +40,11 @@
<section id="status-error" class="state-block state-block--error" hidden role="alert"> <section id="status-error" class="state-block state-block--error" hidden role="alert">
<p id="status-error-message"></p> <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>
<section id="gallery-content" hidden> <section id="gallery-content" hidden>
+190 -90
View File
@@ -1,58 +1,81 @@
/* Page galerie admin — mobile-first mais utilisable au clavier/souris sur /* Galerie admin — direction artistique "Polaroid Néon"
desktop (l'admin peut consulter depuis un ordinateur), sans dépendance (.claude/skills/photobooth-da/SKILL.md), variante "écran admin" du skill :
externe. Mêmes tokens de couleur/rayon/ombre/police que la page invité sobre, sombre, data-first. Pas de confettis, pas de rotation façon
(src/e/style.css) pour rester cohérent visuellement — c'est aussi l'écran polaroid sur les cartes photo (grille classique) — l'accent corail est
que verront les prospects (photographes, wedding planners) lors d'une réservé aux CTA (boutons), jamais décoratif.
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 Tokens repris de src/galerie/style.css (écran de référence de cette DA)
la justification complète de la direction créative. */ 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-face {
font-family: "Fraunces"; font-family: "General Sans";
src: url("/vendor/fonts/fraunces-latin-600-normal.woff2") format("woff2"); src: url("/vendor/fonts/generalsans-700.woff2") format("woff2");
font-weight: 600; 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-style: normal;
font-display: swap; 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 { :root {
color-scheme: light; color-scheme: dark;
--color-bg: #fff8f0; --color-bg: #12100e;
--color-surface: #ffffff; --color-ink: #12100e;
--color-surface-muted: #fdecdd; --color-text: #faf6f0;
--color-text: #2b2016; --color-muted: #e8dfd3;
--color-muted: #8a7869; --color-muted-2: #a89e90;
--color-border: #f1ddc6;
--color-accent: #e35f2b; --color-coral: #ff5e5b;
--color-accent-hover: #c74c1e; --color-yellow: #ffd166;
--color-accent-light: #ff8a52; --color-mint: #06d6a0;
--color-accent-soft: #ffe3d0;
--color-accent-contrast: #fffaf6;
--color-plum: #7c2f56; /* Exception admin du skill : cartes sombres, jamais blanc cassé. Un peu
--color-plum-soft: #f8e3ee; é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-bg: rgba(255, 94, 91, 0.14);
--color-error-text: #a3341c; --color-warning-bg: rgba(255, 209, 102, 0.16);
--color-warning-bg: #fbe9d8;
--color-warning-text: #8a4a10;
--radius-sm: 12px; --radius-ui: 14px;
--radius: 20px;
--radius-pill: 999px; --radius-pill: 999px;
--shadow-sm: 0 2px 6px rgba(163, 78, 30, 0.1); --shadow-soft: 0 10px 24px rgba(0, 0, 0, 0.35);
--shadow-md: 0 10px 28px rgba(163, 78, 30, 0.16); --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, --font-display: "General Sans", -apple-system, BlinkMacSystemFont, sans-serif;
"Helvetica Neue", Arial, sans-serif; --font-ui: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-display: "Fraunces", Georgia, "Times New Roman", 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 { body {
margin: 0; margin: 0;
font-family: var(--font-sans); font-family: var(--font-ui);
background: var(--color-bg); /* 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); color: var(--color-text);
line-height: 1.45; line-height: 1.45;
} }
@@ -89,14 +118,22 @@ body {
justify-content: center; justify-content: center;
gap: 0.4rem; gap: 0.4rem;
margin-bottom: 0.6rem; 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 { .brand__mark {
width: 16px; width: 22px;
height: 16px; height: 22px;
padding: 4px;
box-sizing: border-box;
flex-shrink: 0; flex-shrink: 0;
color: var(--color-accent); color: var(--color-coral);
background: rgba(255, 94, 91, 0.14);
border-radius: 50%;
} }
.brand__name { .brand__name {
@@ -106,35 +143,49 @@ body {
text-transform: uppercase; 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 { .page__header h1 {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 1.7rem; font-size: 1.5rem;
font-weight: 600; font-weight: 700;
letter-spacing: -0.01em; letter-spacing: -0.01em;
margin: 0.15rem 0 0; margin: 0.15rem 0 0;
color: var(--color-accent); color: var(--color-text);
background: linear-gradient(100deg, var(--color-accent) 20%, var(--color-plum) 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
} }
.event-meta { .event-meta {
color: var(--color-muted); color: var(--color-muted-2);
font-size: 0.9rem; font-size: 0.9rem;
margin: 0.4rem 0 0; 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 { .state-block {
position: relative;
text-align: center; text-align: center;
padding: 1.25rem 1rem; padding: 1.5rem 1rem 1.25rem;
border-radius: var(--radius); border-radius: var(--radius-ui);
margin-bottom: 1rem; 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 { .state-block--error {
background: var(--color-error-bg); color: var(--color-text);
color: var(--color-error-text);
} }
.state-block p { .state-block p {
@@ -150,8 +201,8 @@ body {
width: 26px; width: 26px;
height: 26px; height: 26px;
margin-bottom: 0.6rem; margin-bottom: 0.6rem;
border: 3px solid var(--color-accent-soft); border: 3px solid rgba(255, 94, 91, 0.22);
border-top-color: var(--color-accent); border-top-color: var(--color-coral);
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
@@ -164,8 +215,8 @@ body {
.status-banner { .status-banner {
background: var(--color-warning-bg); background: var(--color-warning-bg);
color: var(--color-warning-text); color: var(--color-text);
border-radius: var(--radius); border-radius: var(--radius-ui);
padding: 0.85rem 1rem; padding: 0.85rem 1rem;
margin: 0 0 1.25rem; margin: 0 0 1.25rem;
font-size: 0.9rem; font-size: 0.9rem;
@@ -178,6 +229,8 @@ body {
margin-bottom: 0.75rem; 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 { .btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -190,10 +243,10 @@ body {
padding: 0.75rem 1.15rem; padding: 0.75rem 1.15rem;
min-height: 46px; min-height: 46px;
cursor: pointer; cursor: pointer;
background: linear-gradient(135deg, var(--color-accent-light), var(--color-accent)); background: var(--color-coral);
color: var(--color-accent-contrast); color: var(--color-ink);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-card), var(--inset-rim-btn);
transition: transform 0.12s var(--ease-bounce), background 0.12s ease; transition: transform 0.12s var(--ease-standard), background 0.12s ease, box-shadow 0.12s ease;
} }
.btn-icon { .btn-icon {
@@ -203,40 +256,53 @@ body {
} }
.btn--secondary { .btn--secondary {
background: var(--color-surface); background: var(--color-card);
color: var(--color-plum); color: var(--color-text);
border: 1.5px solid var(--color-plum); border: 1.5px solid var(--color-border-ui-strong);
box-shadow: none; box-shadow: var(--inset-rim);
} }
.btn:disabled { .btn:disabled {
opacity: 0.55; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.btn:active:not(:disabled) { .btn:active:not(:disabled) {
background: linear-gradient(135deg, var(--color-accent), var(--color-accent-hover)); background: #ff7673;
transform: scale(0.97); transform: scale(0.97);
} }
.btn--secondary:active:not(:disabled) { .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, .btn:focus-visible,
a:focus-visible { a:focus-visible {
outline: 3px solid var(--color-plum); outline: 3px solid var(--color-coral);
outline-offset: 2px; outline-offset: 2px;
} }
.zip-status { .zip-status {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-muted); color: var(--color-muted-2);
margin: 0 0 1.25rem; margin: 0 0 1.25rem;
} }
.zip-status--error { .zip-status--error {
color: var(--color-error-text); color: var(--color-coral);
} }
.photo-grid { .photo-grid {
@@ -248,20 +314,42 @@ a:focus-visible {
gap: 1rem; 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 { .photo-card {
background: var(--color-surface); position: relative;
border-radius: var(--radius); background: var(--color-card);
border-radius: var(--radius-ui);
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-card), var(--inset-rim);
transition: box-shadow 0.15s var(--ease-bounce), transform 0.15s var(--ease-bounce); 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) { @media (hover: hover) {
.photo-card:hover { .photo-card:hover {
box-shadow: var(--shadow-md); box-shadow: var(--shadow-soft), var(--inset-rim);
transform: translateY(-3px) rotate(-0.6deg); transform: translateY(-3px);
}
.photo-card:hover::before {
opacity: 1;
} }
} }
@@ -269,7 +357,7 @@ a:focus-visible {
position: relative; position: relative;
width: 100%; width: 100%;
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
background: var(--color-surface-muted); background: var(--color-card-muted);
} }
.photo-card__thumb { .photo-card__thumb {
@@ -287,7 +375,7 @@ a:focus-visible {
justify-content: center; justify-content: center;
text-align: center; text-align: center;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--color-muted); color: var(--color-muted-2);
padding: 0.5rem; padding: 0.5rem;
} }
@@ -309,39 +397,51 @@ a:focus-visible {
font-size: 0.85rem; font-size: 0.85rem;
margin: 0 0 0.2rem; margin: 0 0 0.2rem;
word-break: break-word; word-break: break-word;
color: var(--color-text);
} }
.photo-card__message { .photo-card__message {
color: var(--color-text); color: var(--color-muted);
font-size: 0.8rem; font-size: 0.8rem;
margin: 0 0 0.35rem; margin: 0 0 0.35rem;
word-break: break-word; word-break: break-word;
} }
.photo-card__date { .photo-card__date {
color: var(--color-muted); color: var(--color-muted-2);
font-size: 0.72rem; font-size: 0.72rem;
margin: 0; margin: 0;
} }
.photo-card__download { .photo-card__download {
display: block; display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
text-align: center; text-align: center;
padding: 0.6rem; padding: 0.6rem;
background: var(--color-accent); background: var(--color-coral);
color: var(--color-accent-contrast); color: var(--color-ink);
text-decoration: none; text-decoration: none;
font-size: 0.82rem; 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 { .photo-card__download:hover {
background: var(--color-accent-hover); background: #ff7673;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.spinner, .spinner,
.photo-card, .photo-card,
.photo-card::before,
.btn { .btn {
animation: none !important; animation: none !important;
transition: none !important; transition: none !important;
+93 -47
View File
@@ -17,51 +17,62 @@
tables lors de l'événement. tables lors de l'événement.
--> -->
<style> <style>
/* Palette/police alignées sur la passe 2 (2026-09-16) du reste du produit /* Direction artistique "Polaroid Néon" (.claude/skills/photobooth-da/SKILL.md).
— voir src/e/style.css pour la justification complète de la direction Compromis assumé pour cet écran (signalé à Kévin, pas tranché seul) :
créative (punchy/friendly plutôt que sobre). Le carton imprimé est un le carton reste un support IMPRIMÉ (papier, posé sur les tables), donc
livrable physique posé sur les tables : mêmes contraintes qu'ailleurs il garde un fond clair — un fond #12100E n'a pas de sens sur un support
côté fond (pas de dépendance CDN), mais on évite ici le texte en physique et gâcherait de l'encre en impression maison. En revanche il
dégradé (background-clip:text) sur les éléments imprimés — beaucoup adopte les polices (General Sans pour le titre, Caveat pour
d'utilisateurs impriment sans l'option "Imprimer les arrière-plans", l'accroche manuscrite — remplace Fraunces) et l'accent corail de la
ce qui ferait disparaître un titre en dégradé. La personnalité vient nouvelle DA pour rester cohérent avec le reste de la marque. Comme
donc de la police Fraunces + d'une couleur pleine, pas d'un effet qui avant : pas de dégradé (background-clip:text) sur les éléments
dépend d'un réglage d'impression. */ 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-face {
font-family: "Fraunces"; font-family: "General Sans";
src: url("/vendor/fonts/fraunces-latin-600-normal.woff2") format("woff2"); src: url("/vendor/fonts/generalsans-700.woff2") format("woff2");
font-weight: 600; font-weight: 700;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "Fraunces"; font-family: "Caveat";
src: url("/vendor/fonts/fraunces-latin-500-italic.woff2") format("woff2"); src: url("/vendor/fonts/caveat-latin-variable.woff2") format("woff2");
font-weight: 500; font-weight: 400 700;
font-style: italic; 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; font-display: swap;
} }
:root { :root {
color-scheme: light; color-scheme: light;
--color-bg: #fff8f0; --color-bg: #faf6f0;
--color-surface: #ffffff; --color-surface: #ffffff;
--color-surface-muted: #fdecdd; --color-surface-muted: #f3ece1;
--color-text: #2b2016; --color-text: #12100e;
--color-muted: #8a7869; --color-ink: #12100e;
--color-border: #f1ddc6; --color-muted: #5c5348;
--color-accent: #e35f2b; --color-border: #e8dfd3;
--color-accent-hover: #c74c1e; --color-coral: #ff5e5b;
--color-accent-light: #ff8a52; --color-coral-hover: #e44542;
--color-accent-contrast: #fffaf6;
--color-plum: #7c2f56;
--radius-sm: 12px; --radius-sm: 12px;
--radius: 20px; --radius: 20px;
--radius-pill: 999px; --radius-pill: 999px;
--ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif; "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); color: var(--color-text);
} }
h1 {
font-family: var(--font-display);
}
.workspace { .workspace {
display: grid; display: grid;
grid-template-columns: 300px 1fr; grid-template-columns: 300px 1fr;
@@ -141,7 +156,7 @@
input:focus-visible, input:focus-visible,
select:focus-visible, select:focus-visible,
button:focus-visible { button:focus-visible {
outline: 3px solid var(--color-accent); outline: 3px solid var(--color-coral);
outline-offset: 1px; outline-offset: 1px;
} }
@@ -162,10 +177,10 @@
padding: 0.8rem 1rem; padding: 0.8rem 1rem;
min-height: 46px; min-height: 46px;
cursor: pointer; cursor: pointer;
background: linear-gradient(135deg, var(--color-accent-light), var(--color-accent)); background: var(--color-coral);
color: var(--color-accent-contrast); color: var(--color-ink);
box-shadow: 0 6px 16px rgba(195, 76, 30, 0.22); box-shadow: 0 6px 16px rgba(255, 94, 91, 0.28);
transition: transform 0.12s var(--ease-bounce), background 0.12s ease; transition: transform 0.12s var(--ease-spring), background 0.12s ease;
} }
#printBtn:disabled { #printBtn:disabled {
@@ -175,7 +190,7 @@
} }
#printBtn:active:not(:disabled) { #printBtn:active:not(:disabled) {
background: linear-gradient(135deg, var(--color-accent), var(--color-accent-hover)); background: var(--color-coral-hover);
transform: scale(0.98); transform: scale(0.98);
} }
@@ -192,7 +207,7 @@
width: 148mm; width: 148mm;
height: 210mm; height: 210mm;
background: var(--color-surface); background: var(--color-surface);
border: 1.5mm solid var(--color-accent); border: 1.5mm solid var(--color-coral);
border-radius: 6mm; border-radius: 6mm;
box-shadow: 0 10px 30px rgba(38, 36, 32, 0.18); box-shadow: 0 10px 30px rgba(38, 36, 32, 0.18);
padding: 14mm 12mm; padding: 14mm 12mm;
@@ -209,10 +224,19 @@
color: var(--color-muted); 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 { .card__mark {
width: 5mm; width: 7mm;
height: 5mm; height: 7mm;
color: var(--color-accent); padding: 1.2mm;
box-sizing: border-box;
color: var(--color-coral);
background: rgba(255, 94, 91, 0.12);
border-radius: 50%;
} }
.card__brand-name { .card__brand-name {
@@ -222,23 +246,30 @@
text-transform: uppercase; 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 { .card__title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 9mm; font-size: 9mm;
font-weight: 600; font-weight: 700;
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: var(--color-accent-hover); color: var(--color-coral-hover);
margin: 6mm 0 2mm; margin: 6mm 0 2mm;
line-height: 1.2; line-height: 1.2;
word-break: break-word; 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 { .card__prompt {
font-family: var(--font-display); font-family: var(--font-hand);
font-style: italic; font-weight: 600;
font-weight: 500; font-size: 6.5mm;
font-size: 4.6mm; color: var(--color-ink);
color: var(--color-plum);
margin: 0 0 8mm; margin: 0 0 8mm;
} }
@@ -417,6 +448,21 @@
baseUrlInput.placeholder = DEFAULT_BASE_URL; 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) { function humanizeSlug(slug) {
return slug return slug
.replace(/[-_]+/g, ' ') .replace(/[-_]+/g, ' ')
+180 -44
View File
@@ -5,39 +5,77 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Générateur de QR code — photobooth-qr (admin)</title> <title>Générateur de QR code — photobooth-qr (admin)</title>
<meta name="robots" content="noindex, nofollow" /> <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> <style>
/* Mêmes tokens que src/e/style.css et src/admin/gallery/style.css — /* Direction artistique "Polaroid Néon" (.claude/skills/photobooth-da/SKILL.md),
cohérence visuelle même sur cet outil interne que les clients ne exception admin : sobre, sombre, data-first. Mêmes tokens que
voient pas (cf. retour Kévin 2026-09-16). Palette/rayons resynchronisés src/admin/gallery/style.css pour la cohérence visuelle entre écrans
avec la passe 2 (2026-09-16, direction "punchy/friendly") ; outil admin, même sur cet outil interne que les clients ne voient pas (cf.
interne, priorité basse donc pas de police d'accent ni d'animation retour Kévin 2026-09-16, toujours valable avec la nouvelle DA). Outil
ajoutées ici, juste la cohérence de base (couleurs, rayons). */ 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 { :root {
color-scheme: light; color-scheme: dark;
--color-bg: #fff8f0; --color-bg: #12100e;
--color-surface: #ffffff; --color-ink: #12100e;
--color-surface-muted: #fdecdd; --color-text: #faf6f0;
--color-text: #2b2016; --color-muted: #e8dfd3;
--color-muted: #8a7869; --color-muted-2: #a89e90;
--color-border: #f1ddc6; --color-coral: #ff5e5b;
--color-accent: #e35f2b; --color-card: #241f1a;
--color-accent-hover: #c74c1e; --color-card-muted: #2c2621;
--color-accent-soft: #ffe3d0; --color-border-ui: rgba(250, 246, 240, 0.16);
--color-accent-contrast: #fffaf6; --radius-sm: 10px;
--color-error-text: #a3341c; --radius: 14px;
--radius-sm: 12px; --shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.3);
--radius: 20px; /* Carte plus soutenue + ombre large + liseré haut coloré (pas de tilt/
--shadow-sm: 0 2px 6px rgba(163, 78, 30, 0.1); tape ici : "écran admin = exception, sobre" du skill DA). Passe
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "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; "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; } * { box-sizing: border-box; }
body { body {
margin: 0; margin: 0;
padding: 1.5rem 1.1rem 3rem; padding: 1.5rem 1.1rem 3rem;
font-family: var(--font-sans); font-family: var(--font-sans);
color: var(--color-text); 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; max-width: 480px;
margin-inline: auto; margin-inline: auto;
line-height: 1.45; line-height: 1.45;
@@ -51,10 +89,14 @@
color: var(--color-muted); color: var(--color-muted);
} }
.brand svg { .brand svg {
width: 16px; width: 22px;
height: 16px; height: 22px;
padding: 4px;
box-sizing: border-box;
flex-shrink: 0; flex-shrink: 0;
color: var(--color-accent); color: var(--color-coral);
background: rgba(255, 94, 91, 0.14);
border-radius: 50%;
} }
.brand span { .brand span {
font-size: 0.7rem; font-size: 0.7rem;
@@ -63,7 +105,8 @@
text-transform: uppercase; text-transform: uppercase;
} }
h1 { h1 {
font-size: 1.2rem; font-family: "General Sans", var(--font-sans);
font-size: 1.45rem;
font-weight: 700; font-weight: 700;
letter-spacing: -0.01em; letter-spacing: -0.01em;
margin: 0 0 0.35rem; margin: 0 0 0.35rem;
@@ -72,7 +115,26 @@
color: var(--color-muted); color: var(--color-muted);
font-size: 0.85rem; font-size: 0.85rem;
margin-top: 0; 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 { label {
display: block; display: block;
@@ -81,27 +143,63 @@
margin-bottom: 0.35rem; margin-bottom: 0.35rem;
margin-top: 1.1rem; margin-top: 1.1rem;
} }
.form-card label:first-child {
margin-top: 0;
}
input, select { input, select {
width: 100%; width: 100%;
padding: 0.7rem 0.75rem; padding: 0.7rem 0.75rem;
font: inherit; font: inherit;
font-size: 1rem; font-size: 1rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border-ui);
border-radius: var(--radius-sm); 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; 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 { .preview {
position: relative;
margin-top: 1.5rem; margin-top: 1.5rem;
padding: 1.1rem; padding: 1.3rem 1.1rem 1.1rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border-ui);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--color-surface); background: var(--color-card);
box-shadow: var(--shadow-card);
overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.75rem; 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 { .preview svg {
width: 100%; width: 100%;
max-width: 280px; max-width: 280px;
@@ -121,38 +219,55 @@
button { button {
flex: 1; flex: 1;
font: inherit; font: inherit;
font-weight: 600; font-weight: 700;
padding: 0.85rem 0.7rem; padding: 0.85rem 0.7rem;
min-height: 48px; min-height: 48px;
font-size: 0.95rem; font-size: 0.95rem;
border: 1px solid var(--color-accent); border: 1px solid var(--color-coral);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--color-accent); background: var(--color-coral);
color: var(--color-accent-contrast); color: var(--color-ink);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm), var(--inset-rim-btn);
cursor: pointer; cursor: pointer;
transition: transform 0.15s var(--ease-standard), background 0.15s ease;
} }
button:active:not(:disabled) { button:active:not(:disabled) {
background: var(--color-accent-hover); background: #ff7673;
} }
button.secondary { button.secondary {
background: var(--color-surface); background: var(--color-card);
color: var(--color-accent); color: var(--color-text);
box-shadow: none; border-color: var(--color-border-ui);
box-shadow: var(--inset-rim);
} }
button.secondary:active:not(:disabled) { 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 { button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.error { .error {
color: var(--color-error-text); color: var(--color-coral);
font-size: 0.85rem; font-size: 0.85rem;
margin-top: 0.5rem; margin-top: 0.5rem;
min-height: 1.2em; min-height: 1.2em;
} }
@media (prefers-reduced-motion: reduce) {
button,
input,
select {
transition: none !important;
}
}
</style> </style>
</head> </head>
<body> <body>
@@ -170,6 +285,7 @@
Ne fait aucun appel à Supabase. Ne fait aucun appel à Supabase.
</p> </p>
<div class="form-card">
<label for="slug">Slug de l'événement</label> <label for="slug">Slug de l'événement</label>
<input id="slug" type="text" placeholder="mariage-julie-marc" autocomplete="off" /> <input id="slug" type="text" placeholder="mariage-julie-marc" autocomplete="off" />
@@ -183,6 +299,7 @@
<option value="Q">Q — élevé (recommandé si affiché en extérieur)</option> <option value="Q">Q — élevé (recommandé si affiché en extérieur)</option>
<option value="H">H — maximal (support très exposé/manipulé)</option> <option value="H">H — maximal (support très exposé/manipulé)</option>
</select> </select>
</div>
<div class="preview" id="preview" hidden> <div class="preview" id="preview" hidden>
<div id="svgContainer"></div> <div id="svgContainer"></div>
@@ -220,6 +337,25 @@
// voir src/e/config.example.js) puis via DEFAULT_BASE_URL en secours. // voir src/e/config.example.js) puis via DEFAULT_BASE_URL en secours.
baseUrlInput.placeholder = DEFAULT_BASE_URL; 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() { function currentOptions() {
const baseUrl = baseUrlInput.value.trim() || undefined; const baseUrl = baseUrlInput.value.trim() || undefined;
const errorCorrectionLevel = ecLevelSelect.value; const errorCorrectionLevel = ecLevelSelect.value;
+34 -12
View File
@@ -12,8 +12,11 @@
lors du déploiement, hors scope de ce fichier front. lors du déploiement, hors scope de ce fichier front.
--> -->
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#faf6f0"> <!-- Thème unique sombre "Polaroid Néon" (voir src/e/style.css) : une seule
<link rel="preload" href="/vendor/fonts/fraunces-latin-600-normal.woff2" as="font" type="font/woff2" crossorigin> 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"> <link rel="stylesheet" href="/e/style.css">
</head> </head>
<body> <body>
@@ -28,6 +31,13 @@
</p> </p>
<h1 id="event-name">Ajouter une photo</h1> <h1 id="event-name">Ajouter une photo</h1>
<p class="page__kicker">Une photo, un petit mot, et hop dans l'album&nbsp;!</p> <p class="page__kicker">Une photo, un petit mot, et hop dans l'album&nbsp;!</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&nbsp;</a>
</header> </header>
<!-- Mot de bienvenue + mention légale RGPD condensée, affiché une seule <!-- Mot de bienvenue + mention légale RGPD condensée, affiché une seule
@@ -47,8 +57,8 @@
</p> </p>
<p class="welcome-modal__title" id="welcome-modal-title">Bienvenue&nbsp;!</p> <p class="welcome-modal__title" id="welcome-modal-title">Bienvenue&nbsp;!</p>
<p class="welcome-modal__text"> <p class="welcome-modal__text">
Scanne, souris, dépose ta photo — elle rejoint l'album de l'événement Scanne, souris, dépose ta photo — elle rejoint la fête en quelques
en quelques secondes. secondes, visible par tous les invités.
</p> </p>
<p class="legal-notice welcome-modal__legal"> <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"> <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> </svg>
<!-- Texte légal complet, identique à docs/rgpd.md (mention <!-- Texte légal complet, identique à docs/rgpd.md (mention
obligatoire) — seule la présentation change (modale au premier 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> <span>
En déposant une photo, tu acceptes qu'elle soit stockée temporairement 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. dans le cadre de cet événement et visible par les autres invités de
Tes photos sont automatiquement supprimées 7 jours après la date de l'événement ainsi que par l'organisateur. Tes photos sont
l'événement, sans action de ta part. Aucune photo n'est utilisée à automatiquement supprimées 7 jours après la date de l'événement,
d'autres fins, ni transmise à des tiers. Tu peux demander la sans action de ta part. Aucune photo n'est utilisée à d'autres
suppression anticipée d'une photo en contactant l'organisateur de fins, ni transmise à des tiers en dehors des invités de
l'événement. l'événement. Tu peux demander la suppression anticipée d'une
photo en contactant l'organisateur de l'événement.
</span> </span>
</p> </p>
<button type="button" id="welcome-modal-dismiss">C'est parti&nbsp;!</button> <button type="button" id="welcome-modal-dismiss">C'est parti&nbsp;!</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&nbsp;</a>
</div> </div>
</div> </div>
@@ -96,7 +115,7 @@
<section id="success-block" class="state-block state-block--success" hidden role="status"> <section id="success-block" class="state-block state-block--success" hidden role="status">
<div class="confetti" aria-hidden="true"> <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>
<div class="success-check" aria-hidden="true"> <div class="success-check" aria-hidden="true">
<svg viewBox="0 0 52 52" fill="none" focusable="false"> <svg viewBox="0 0 52 52" fill="none" focusable="false">
@@ -106,7 +125,10 @@
</div> </div>
<p class="success-title">C'est envoyé&nbsp;!</p> <p class="success-title">C'est envoyé&nbsp;!</p>
<p class="success-text">Ta photo vient de rejoindre la fête. Merci d'avoir été là&nbsp;!</p> <p class="success-text">Ta photo vient de rejoindre la fête. Merci d'avoir été là&nbsp;!</p>
<div class="success-actions">
<button type="button" id="add-another-btn">Ajouter une autre photo</button> <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&nbsp;</a>
</div>
</section> </section>
<form id="upload-form" class="upload-form" hidden novalidate> <form id="upload-form" class="upload-form" hidden novalidate>
+492 -202
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -51,6 +51,9 @@ const submitBtnLabel = document.getElementById("submit-btn-label");
const welcomeModal = document.getElementById("welcome-modal"); const welcomeModal = document.getElementById("welcome-modal");
const welcomeModalTitle = document.getElementById("welcome-modal-title"); const welcomeModalTitle = document.getElementById("welcome-modal-title");
const welcomeModalDismiss = document.getElementById("welcome-modal-dismiss"); 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"); const legalLink = document.getElementById("legal-link");
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -410,6 +413,13 @@ async function initEventCheck() {
showError(appError("event-not-found")); showError(appError("event-not-found"));
return; 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 { try {
const event = await fetchEventPublicInfo(slug); const event = await fetchEventPublicInfo(slug);
if (!isEventActive(event)) throw appError("event-closed"); if (!isEventActive(event)) throw appError("event-closed");
+10
View File
@@ -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",
};
+247
View File
@@ -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);
+74
View File
@@ -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&nbsp;!</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>
+411
View File
@@ -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)));
}
}
+34 -18
View File
@@ -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 a déjà transcodé le fichier en JPEG lors de la sélection) ne téléchargeront
jamais ce fichier. jamais ce fichier.
## Fraunces (fonts/) ## General Sans, Caveat, Inter (fonts/) — direction "Polaroid Néon"
- Fichiers : `fonts/fraunces-latin-600-normal.woff2` (18 Ko), Trio typographique de la direction artistique du projet
`fonts/fraunces-latin-500-italic.woff2` (22,9 Ko). (`.claude/skills/photobooth-da/SKILL.md`), appliqué à tous les écrans web
Sous-ensemble « latin » uniquement (couvre les accents français : la (le carton imprimable garde Caveat + General Sans mais reste en thème
plage Google Fonts « latin » inclut Latin-1 Supplement + œ/Œ, suffisant clair, cf. `src/admin/print-template/`).
pour le français — pas besoin de « latin-ext », plus lourd).
- Licence : SIL Open Font License 1.1 (police Fraunces par Undercase Type / - **General Sans** — `fonts/generalsans-700.woff2` (21,2 Ko), graisse 700
Oh no Type Co, distribuée via Google Fonts). uniquement (titres/display, "bold arrondie" demandée dans le brief — pas
- Source : régénérée par le projet **Fontsource** besoin de graisse 600 en plus pour ce rôle). Licence Open Font License,
(`https://cdn.jsdelivr.net/npm/@fontsource/fraunces@5/files/`), qui distribuée via Fontshare (`https://api.fontshare.com`). Fichier statique
convertit Google Fonts en fichiers statiques par graisse — évite de servir (pas la version variable, plus légère pour une seule graisse utilisée).
la version variable complète (beaucoup plus lourde) alors qu'on n'utilise - **Caveat** — `fonts/caveat-latin-variable.woff2` (74,9 Ko), légendes
que 2 graisses fixes. manuscrites (prénom/message sur les cadres polaroid). Licence SIL Open
- Usage : police d'accent (titres, moment de succès) chargée en Font License, Google Fonts. Fichier **variable** (couvre 400-700) : Google
`@font-face` avec `font-display: swap`, jamais via lien Google Fonts en sert le même fichier variable pour toutes les graisses statiques
direct (règle du projet). Voir `src/e/style.css` (`--font-display`). demandées à cette police via l'API CSS2, donc pas d'intérêt à en
Le corps de texte reste sur la pile système (`--font-sans`), aucun poids télécharger plusieurs — un seul fichier suffit, `font-weight: 400 700`
supplémentaire pour le texte courant. 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).
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+195
View File
@@ -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)}`,
},
});
});
+177
View File
@@ -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;
$$;