Compare commits
10
Commits
45e0bd7bcc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8568b48461 | ||
|
|
d0131331a6 | ||
|
|
e06f8afd3e | ||
|
|
c714316974 | ||
|
|
ea3e49cc04 | ||
|
|
f4322850d6 | ||
|
|
107e5c619a | ||
|
|
2dd0db54c8 | ||
|
|
4f31e98079 | ||
|
|
6d6be3ef99 |
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
name: photobooth-da
|
||||||
|
description: Applique la direction artistique "Polaroid Néon" du projet Rêves Liquides Photobooth. À utiliser pour tout écran, composant ou style visuel de cette appli — scan, upload, galerie invité, admin.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Direction artistique — Polaroid Néon
|
||||||
|
|
||||||
|
## Palette
|
||||||
|
- Fond principal : #12100E (noir chaud, jamais #000 pur)
|
||||||
|
- Accent corail : #FF5E5B (CTA, boutons principaux)
|
||||||
|
- Accent jaune : #FFD166 (badges, highlights)
|
||||||
|
- Accent vert : #06D6A0 (succès, validation)
|
||||||
|
- Blanc cassé : #FAF6F0 (cadre polaroid, textes clairs)
|
||||||
|
- Beige poudré : #E8DFD3 (bordures, séparateurs)
|
||||||
|
|
||||||
|
## Typographie
|
||||||
|
- Titres/display : Clash Display ou General Sans (bold arrondie)
|
||||||
|
- Légendes manuscrites : Caveat ou Permanent Marker
|
||||||
|
- UI/corps de texte : Inter ou Satoshi
|
||||||
|
|
||||||
|
## Composant signature : cadre polaroid
|
||||||
|
- Fond blanc cassé, marge basse épaisse pour la légende
|
||||||
|
- Rotation aléatoire entre -4° et +4° par carte
|
||||||
|
- Ombre douce (box-shadow léger), jamais de glow/neon CSS artificiel
|
||||||
|
- Prénom + message en typo manuscrite dans la zone basse
|
||||||
|
|
||||||
|
## Écran admin = exception
|
||||||
|
Sobre, sombre, data-first. Pas de confettis, pas de rotation polaroid.
|
||||||
|
Cartes en #1E1B18. Accent corail réservé aux CTA uniquement.
|
||||||
|
|
||||||
|
## Règle non négociable
|
||||||
|
Vérifier systématiquement le contraste texte/fond sur #12100E avant
|
||||||
|
de livrer un écran (norme WCAG AA minimum).
|
||||||
@@ -13,6 +13,9 @@ src/e/config.js
|
|||||||
# Config front runtime — galerie admin (cf. src/admin/gallery/config.example.js)
|
# 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/
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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, ' ')
|
||||||
|
|||||||
+191
-55
@@ -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,19 +285,21 @@
|
|||||||
Ne fait aucun appel à Supabase.
|
Ne fait aucun appel à Supabase.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label for="slug">Slug de l'événement</label>
|
<div class="form-card">
|
||||||
<input id="slug" type="text" placeholder="mariage-julie-marc" autocomplete="off" />
|
<label for="slug">Slug de l'événement</label>
|
||||||
|
<input id="slug" type="text" placeholder="mariage-julie-marc" autocomplete="off" />
|
||||||
|
|
||||||
<label for="baseUrl">Domaine de base de l'app</label>
|
<label for="baseUrl">Domaine de base de l'app</label>
|
||||||
<input id="baseUrl" type="text" placeholder="https://photobooth.mondomaine.fr" autocomplete="off" />
|
<input id="baseUrl" type="text" placeholder="https://photobooth.mondomaine.fr" autocomplete="off" />
|
||||||
|
|
||||||
<label for="ecLevel">Niveau de correction d'erreur</label>
|
<label for="ecLevel">Niveau de correction d'erreur</label>
|
||||||
<select id="ecLevel">
|
<select id="ecLevel">
|
||||||
<option value="L">L — bas (fichier le plus léger)</option>
|
<option value="L">L — bas (fichier le plus léger)</option>
|
||||||
<option value="M" selected>M — moyen (par défaut)</option>
|
<option value="M" selected>M — moyen (par défaut)</option>
|
||||||
<option value="Q">Q — élevé (recommandé si affiché en extérieur)</option>
|
<option value="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;
|
||||||
|
|||||||
+35
-13
@@ -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 !</p>
|
<p class="page__kicker">Une photo, un petit mot, et hop dans l'album !</p>
|
||||||
|
<!-- href posé par upload.js une fois le slug connu (getSlugFromPath) ;
|
||||||
|
"#" par défaut pour ne jamais pointer nulle part avant ça. Lien
|
||||||
|
permanent vers la galerie, accessible même sans avoir uploadé —
|
||||||
|
ajouté suite à la demande de Kévin le 2026-09-18. "Mur" jugé pas
|
||||||
|
assez fun par Kévin -> renommé "la fête" partout (cohérent avec
|
||||||
|
le texte déjà existant de l'écran de succès). -->
|
||||||
|
<a href="#" id="wall-link" class="wall-link">Voir la fête →</a>
|
||||||
</header>
|
</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 !</p>
|
<p class="welcome-modal__title" id="welcome-modal-title">Bienvenue !</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 !</button>
|
<button type="button" id="welcome-modal-dismiss">C'est parti !</button>
|
||||||
|
<!-- Lien dupliqué ici (même que #wall-link dans l'en-tête) : la
|
||||||
|
modale recouvre tout l'écran en position fixed, donc le lien de
|
||||||
|
l'en-tête est inatteignable tant qu'elle est ouverte — bug réel
|
||||||
|
remonté par Kévin le 2026-09-18 ("le bouton ne marche pas", en
|
||||||
|
fait il tapait dessus derrière la modale). -->
|
||||||
|
<a href="#" id="wall-link-modal" class="btn-secondary-link">Voir la fête sans envoyer de photo →</a>
|
||||||
</div>
|
</div>
|
||||||
</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é !</p>
|
<p class="success-title">C'est envoyé !</p>
|
||||||
<p class="success-text">Ta photo vient de rejoindre la fête. Merci d'avoir été là !</p>
|
<p class="success-text">Ta photo vient de rejoindre la fête. Merci d'avoir été là !</p>
|
||||||
<button type="button" id="add-another-btn">Ajouter une autre photo</button>
|
<div class="success-actions">
|
||||||
|
<button type="button" id="add-another-btn">Ajouter une autre photo</button>
|
||||||
|
<a href="#" id="wall-link-success" class="btn-secondary-link">Voir la fête →</a>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form id="upload-form" class="upload-form" hidden novalidate>
|
<form id="upload-form" class="upload-form" hidden novalidate>
|
||||||
|
|||||||
+492
-202
File diff suppressed because it is too large
Load Diff
@@ -51,6 +51,9 @@ const submitBtnLabel = document.getElementById("submit-btn-label");
|
|||||||
const welcomeModal = document.getElementById("welcome-modal");
|
const 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");
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Copier ce fichier en config.js (non commité, voir .gitignore) et renseigner
|
||||||
|
// les vraies valeurs Supabase avant déploiement. Ce fichier est chargé avant
|
||||||
|
// gallery.js dans index.html.
|
||||||
|
//
|
||||||
|
// SUPABASE_ANON_KEY est la clé PUBLIQUE (anon) Supabase — jamais la
|
||||||
|
// service_role key, qui ne doit jamais être exposée côté client.
|
||||||
|
window.__PHOTOBOOTH_CONFIG__ = {
|
||||||
|
SUPABASE_URL: "https://xxxxxxxxxxxx.supabase.co",
|
||||||
|
SUPABASE_ANON_KEY: "REMPLACER_PAR_LA_CLE_ANON_PUBLIQUE",
|
||||||
|
};
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
// Écran galerie invité — photobooth-qr
|
||||||
|
//
|
||||||
|
// Contrat backend : GET {SUPABASE_URL}/functions/v1/public-gallery?slug=<slug>
|
||||||
|
// (Edge Function publique, aucun token requis — cf. docs/decisions.md
|
||||||
|
// 2026-09-18 : les invités voient désormais toutes les photos de
|
||||||
|
// l'événement, même modèle de confiance que l'upload : le slug n'est pas
|
||||||
|
// un secret, il circule via QR code).
|
||||||
|
//
|
||||||
|
// Rafraîchissement par sondage (polling), pas de Supabase Realtime : le
|
||||||
|
// schéma photobooth est volontairement jamais exposé et Realtime respecte
|
||||||
|
// les policies RLS (aucune pour anon), l'activer aurait demandé de rouvrir
|
||||||
|
// ce verrou pour un gain marginal sur un usage V1 (événements de taille
|
||||||
|
// raisonnable). Compromis assumé : chaque poll régénère des URLs signées
|
||||||
|
// pour toutes les photos, pour tous les invités qui ont la page ouverte —
|
||||||
|
// acceptable pour un mariage, à revisiter si le produit vise un jour des
|
||||||
|
// événements à très fort trafic simultané.
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 25_000;
|
||||||
|
|
||||||
|
const eventNameEl = document.getElementById("event-name");
|
||||||
|
const eventMetaEl = document.getElementById("event-meta");
|
||||||
|
const loadingBlock = document.getElementById("status-loading");
|
||||||
|
const errorBlock = document.getElementById("status-error");
|
||||||
|
const errorMessageEl = document.getElementById("status-error-message");
|
||||||
|
const retryBtn = document.getElementById("retry-btn");
|
||||||
|
const emptyStateEl = document.getElementById("empty-state");
|
||||||
|
const gridEl = document.getElementById("polaroid-grid");
|
||||||
|
|
||||||
|
const ERROR_MESSAGES = {
|
||||||
|
"config-missing":
|
||||||
|
"Cette page n'est pas configurée correctement. Préviens l'organisateur de l'événement.",
|
||||||
|
"event-not-found":
|
||||||
|
"Cet événement n'existe pas, ou le lien utilisé est incorrect. Vérifie le QR code ou le lien.",
|
||||||
|
network:
|
||||||
|
"La connexion a été coupée. Vérifie ta connexion internet et réessaie.",
|
||||||
|
"server-overloaded":
|
||||||
|
"Le service est momentanément surchargé. Réessaie dans quelques minutes.",
|
||||||
|
unknown: "Une erreur inattendue est survenue. Réessaie, ou reviens un peu plus tard.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function appError(code, cause) {
|
||||||
|
const err = new Error(code);
|
||||||
|
err.code = code;
|
||||||
|
err.cause = cause;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
const cfg = window.__PHOTOBOOTH_CONFIG__;
|
||||||
|
if (!cfg || !cfg.SUPABASE_URL || !cfg.SUPABASE_ANON_KEY) {
|
||||||
|
throw appError("config-missing");
|
||||||
|
}
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSlugFromPath() {
|
||||||
|
const match = window.location.pathname.match(/\/galerie\/([^/]+)\/?$/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotation aléatoire -4°/+4° (skill photobooth-da), figée par photo (Map
|
||||||
|
// id -> degré) pour que la grille ne "tremble" pas à chaque poll — seules
|
||||||
|
// les photos réellement nouvelles reçoivent une rotation fraîche.
|
||||||
|
const rotationById = new Map();
|
||||||
|
function rotationFor(id) {
|
||||||
|
if (!rotationById.has(id)) {
|
||||||
|
rotationById.set(id, (Math.random() * 8 - 4).toFixed(2));
|
||||||
|
}
|
||||||
|
return rotationById.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderedIds = new Set();
|
||||||
|
|
||||||
|
function buildCaption(photo) {
|
||||||
|
const parts = [];
|
||||||
|
if (photo.nom_invite) parts.push(photo.nom_invite);
|
||||||
|
if (photo.message) parts.push(photo.message);
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPolaroid(photo, { animate = false } = {}) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "polaroid" + (animate ? " polaroid--dropping" : "");
|
||||||
|
li.style.setProperty("--rot", `${rotationFor(photo.id)}deg`);
|
||||||
|
li.dataset.photoId = photo.id;
|
||||||
|
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.className = "polaroid__photo";
|
||||||
|
img.src = photo.signed_url || "";
|
||||||
|
img.alt = photo.nom_invite
|
||||||
|
? `Photo envoyée par ${photo.nom_invite}`
|
||||||
|
: "Photo envoyée par un invité";
|
||||||
|
img.loading = "lazy";
|
||||||
|
|
||||||
|
const captionParts = buildCaption(photo);
|
||||||
|
const caption = document.createElement("p");
|
||||||
|
caption.className = "polaroid__caption";
|
||||||
|
if (captionParts.length === 0) {
|
||||||
|
caption.classList.add("polaroid__caption--empty");
|
||||||
|
} else {
|
||||||
|
caption.textContent = captionParts.join(" — ");
|
||||||
|
}
|
||||||
|
|
||||||
|
li.append(img, caption);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoading() {
|
||||||
|
loadingBlock.hidden = false;
|
||||||
|
errorBlock.hidden = true;
|
||||||
|
emptyStateEl.hidden = true;
|
||||||
|
gridEl.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(rawErr, retryAction) {
|
||||||
|
const err = rawErr && rawErr.code ? rawErr : appError("unknown", rawErr);
|
||||||
|
loadingBlock.hidden = true;
|
||||||
|
errorBlock.hidden = false;
|
||||||
|
errorMessageEl.textContent = ERROR_MESSAGES[err.code] || ERROR_MESSAGES.unknown;
|
||||||
|
retryBtn.hidden = !retryAction;
|
||||||
|
retryBtn.onclick = retryAction || null;
|
||||||
|
if (err.cause) console.error(`[galerie:${err.code}]`, err.cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGallery(slug) {
|
||||||
|
const { SUPABASE_URL, SUPABASE_ANON_KEY } = getConfig();
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(
|
||||||
|
`${SUPABASE_URL}/functions/v1/public-gallery?slug=${encodeURIComponent(slug)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
apikey: SUPABASE_ANON_KEY,
|
||||||
|
Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
throw appError("network", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 404) throw appError("event-not-found");
|
||||||
|
if (res.status >= 500) throw appError("server-overloaded");
|
||||||
|
if (!res.ok) throw appError("unknown", await res.text().catch(() => null));
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventMeta(event) {
|
||||||
|
const date = new Date(event.date_evenement);
|
||||||
|
const dateLabel = Number.isNaN(date.getTime())
|
||||||
|
? null
|
||||||
|
: date.toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
|
||||||
|
const parts = [];
|
||||||
|
if (dateLabel) parts.push(dateLabel);
|
||||||
|
parts.push(`${event.photo_count} photo${event.photo_count > 1 ? "s" : ""}`);
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGallery(data) {
|
||||||
|
eventNameEl.textContent = data.event.nom || "Galerie de l'événement";
|
||||||
|
eventMetaEl.textContent = formatEventMeta(data.event);
|
||||||
|
|
||||||
|
loadingBlock.hidden = true;
|
||||||
|
errorBlock.hidden = true;
|
||||||
|
|
||||||
|
if (data.photos.length === 0) {
|
||||||
|
emptyStateEl.hidden = false;
|
||||||
|
gridEl.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyStateEl.hidden = true;
|
||||||
|
gridEl.hidden = false;
|
||||||
|
|
||||||
|
// Plus récent en premier. On ne reconstruit pas tout le DOM à chaque
|
||||||
|
// poll (perdrait l'état d'animation/scroll) : seules les photos dont
|
||||||
|
// l'id n'a jamais été vu sont insérées, avec l'effet "chute".
|
||||||
|
const photosDesc = [...data.photos].reverse();
|
||||||
|
const incomingIds = new Set(photosDesc.map((p) => p.id));
|
||||||
|
|
||||||
|
// Retire les photos qui auraient disparu côté serveur (rare : édition
|
||||||
|
// manuelle en base, ou event modifié) pour rester cohérent.
|
||||||
|
[...gridEl.children].forEach((li) => {
|
||||||
|
if (!incomingIds.has(li.dataset.photoId)) {
|
||||||
|
li.remove();
|
||||||
|
renderedIds.delete(li.dataset.photoId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
photosDesc.forEach((photo, index) => {
|
||||||
|
if (renderedIds.has(photo.id)) {
|
||||||
|
// Déjà affichée : on rafraîchit juste l'URL signée (elle expire au
|
||||||
|
// bout de 10 min), sans toucher au reste du DOM de la carte.
|
||||||
|
const existing = gridEl.querySelector(`[data-photo-id="${photo.id}"] .polaroid__photo`);
|
||||||
|
if (existing && photo.signed_url) existing.src = photo.signed_url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const card = renderPolaroid(photo, { animate: true });
|
||||||
|
if (index === 0) {
|
||||||
|
gridEl.prepend(card);
|
||||||
|
} else {
|
||||||
|
const prevSibling = gridEl.children[index - 1];
|
||||||
|
prevSibling ? prevSibling.after(card) : gridEl.appendChild(card);
|
||||||
|
}
|
||||||
|
renderedIds.add(photo.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let pollTimer = null;
|
||||||
|
|
||||||
|
async function loadOnce({ showSpinner } = { showSpinner: false }) {
|
||||||
|
const slug = getSlugFromPath();
|
||||||
|
if (!slug) {
|
||||||
|
showError(appError("event-not-found"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (showSpinner) showLoading();
|
||||||
|
try {
|
||||||
|
const data = await fetchGallery(slug);
|
||||||
|
renderGallery(data);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err, () => loadOnce({ showSpinner: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (pollTimer) return;
|
||||||
|
pollTimer = setInterval(() => loadOnce({ showSpinner: false }), POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pas de sondage pendant que l'onglet est masqué (économie réseau/batterie,
|
||||||
|
// cohérent avec l'esprit sobriété du projet) ; on refait un chargement
|
||||||
|
// immédiat au retour au premier plan pour ne pas attendre un cycle entier.
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loadOnce({ showSpinner: false });
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadOnce({ showSpinner: true }).then(startPolling);
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>Galerie de l'événement</title>
|
||||||
|
<!--
|
||||||
|
Écran "galerie invité" — direction artistique "Polaroid Néon"
|
||||||
|
(.claude/skills/photobooth-da/SKILL.md). Décision actée le 2026-09-18 :
|
||||||
|
les invités voient toutes les photos de l'événement (revirement par
|
||||||
|
rapport au cahier des charges initial "upload only"), cf.
|
||||||
|
docs/decisions.md.
|
||||||
|
|
||||||
|
Câblé au vrai backend depuis le 2026-09-18 : Edge Function publique
|
||||||
|
`public-gallery?slug=<slug>` (aucun token requis, même modèle de
|
||||||
|
confiance que l'upload — le slug circule via QR code). Rafraîchissement
|
||||||
|
par sondage (polling), pas de Supabase Realtime : le schéma photobooth
|
||||||
|
est volontairement jamais exposé et Realtime respecte les policies RLS
|
||||||
|
(aucune pour anon sur photobooth.photos), l'activer aurait demandé de
|
||||||
|
rouvrir ce verrou. Le slug est lu côté client via
|
||||||
|
window.location.pathname, cf. gallery.js — suppose une réécriture
|
||||||
|
serveur qui renvoie ce fichier pour toute requête sous /galerie/*
|
||||||
|
(voir deploy/nginx-photobooth.conf).
|
||||||
|
-->
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<meta name="theme-color" content="#12100E">
|
||||||
|
<link rel="preload" href="/vendor/fonts/generalsans-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
|
<link rel="preload" href="/vendor/fonts/caveat-latin-variable.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
|
<link rel="stylesheet" href="/galerie/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<header class="page__header">
|
||||||
|
<p class="brand" aria-hidden="true">
|
||||||
|
<svg class="brand__mark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" focusable="false">
|
||||||
|
<path d="M4 8h3l1.5-2h7L17 8h3a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1Z"/>
|
||||||
|
<circle cx="12" cy="13" r="3.5"/>
|
||||||
|
</svg>
|
||||||
|
<span class="brand__name">photobooth</span>
|
||||||
|
</p>
|
||||||
|
<h1 id="event-name">Galerie de l'événement</h1>
|
||||||
|
<p id="event-meta" class="page__kicker">Tous les souvenirs déposés par les invités, en direct</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="status-loading" class="state-block state-block--loading" role="status">
|
||||||
|
<span class="spinner" aria-hidden="true"></span>
|
||||||
|
<p>Chargement de la galerie…</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="status-error" class="state-block state-block--error" hidden role="alert">
|
||||||
|
<p id="status-error-message"></p>
|
||||||
|
<button type="button" id="retry-btn" hidden>Réessayer</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="empty-state" class="empty-state" hidden>
|
||||||
|
<svg class="empty-state__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M12 20.2s-7.5-4.6-7.5-10.1a4.4 4.4 0 0 1 7.5-3.1 4.4 4.4 0 0 1 7.5 3.1c0 5.5-7.5 10.1-7.5 10.1Z"/>
|
||||||
|
</svg>
|
||||||
|
<p>Aucune photo pour l'instant — sois le·la premier·ère à en déposer une !</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul id="polaroid-grid" class="polaroid-grid" aria-live="polite" hidden></ul>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
config.js n'est PAS commité (voir .gitignore) : copier config.example.js
|
||||||
|
en config.js et renseigner les vraies valeurs Supabase avant déploiement.
|
||||||
|
Chargé en script classique (donc synchrone, avant le module) pour garantir
|
||||||
|
que window.__PHOTOBOOTH_CONFIG__ existe avant l'exécution de gallery.js.
|
||||||
|
-->
|
||||||
|
<script src="/galerie/config.js"></script>
|
||||||
|
<script type="module" src="/galerie/gallery.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
/* Galerie invité — direction artistique "Polaroid Néon"
|
||||||
|
(.claude/skills/photobooth-da/SKILL.md). Premier écran de cette nouvelle
|
||||||
|
DA, mobile-first (95% des invités sur mobile, cf. brief).
|
||||||
|
|
||||||
|
Contrastes vérifiés par calcul WCAG AA (formule de luminance relative,
|
||||||
|
voir historique de session) : texte clair (#FAF6F0/#E8DFD3) sur fond
|
||||||
|
sombre (#12100E) est très largement au-dessus du seuil, mais texte clair
|
||||||
|
SUR les couleurs d'accent (corail/jaune/vert) échoue largement (1.3:1 à
|
||||||
|
2.8:1 selon la couleur — bien en dessous du minimum 4.5:1). Le texte sur
|
||||||
|
accent utilise donc systématiquement #12100E (sombre), qui lui passe
|
||||||
|
confortablement (6.3:1 à 13.2:1) sur les trois accents. */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "General Sans";
|
||||||
|
src: url("/vendor/fonts/generalsans-700.woff2") format("woff2");
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Caveat";
|
||||||
|
src: url("/vendor/fonts/caveat-latin-variable.woff2") format("woff2");
|
||||||
|
font-weight: 400 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Inter";
|
||||||
|
src: url("/vendor/fonts/inter-latin-variable.woff2") format("woff2");
|
||||||
|
font-weight: 400 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--color-bg: #12100e;
|
||||||
|
--color-ink: #12100e; /* texte sombre sur surfaces claires/accents */
|
||||||
|
--color-text: #faf6f0;
|
||||||
|
--color-muted: #e8dfd3;
|
||||||
|
--color-muted-2: #a89e90;
|
||||||
|
|
||||||
|
--color-coral: #ff5e5b;
|
||||||
|
--color-yellow: #ffd166;
|
||||||
|
--color-mint: #06d6a0;
|
||||||
|
|
||||||
|
--color-polaroid: #faf6f0;
|
||||||
|
--color-border: #e8dfd3;
|
||||||
|
|
||||||
|
--radius: 4px; /* les polaroids restent anguleux, pas de coins arrondis */
|
||||||
|
--radius-ui: 14px;
|
||||||
|
|
||||||
|
--shadow-soft: 0 10px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
--shadow-card: 0 6px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
|
||||||
|
--font-display: "General Sans", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
--font-hand: "Caveat", "Segoe Script", cursive;
|
||||||
|
--font-ui: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
|
||||||
|
/* --- Passe "polaroid poussé à fond" (2026-09-18) : mêmes tokens et même
|
||||||
|
logique que src/e/style.css — les blocs d'état (chargement/erreur/vide)
|
||||||
|
n'avaient ICI aucune carte du tout (texte nu sur fond noir), encore
|
||||||
|
plus "low-cost" que sur l'écran upload. Aligné sur src/e/ pour que les
|
||||||
|
deux écrans invité partagent la même signature visuelle. */
|
||||||
|
--color-paper: #faf6f0;
|
||||||
|
--color-paper-ink: #12100e;
|
||||||
|
--color-paper-muted: #6b6153;
|
||||||
|
|
||||||
|
--shadow-paper: 0 18px 34px rgba(0, 0, 0, 0.55), 0 4px 10px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
--tilt-1: -2.2deg;
|
||||||
|
--tilt-2: 1.8deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
/* Même grain que src/e/style.css (cohérence entre écrans), voir le
|
||||||
|
commentaire détaillé là-bas : casse l'aplat du fond quasi noir,
|
||||||
|
opacité trop basse pour affecter le contraste WCAG déjà vérifié. */
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxODAiIGhlaWdodD0iMTgwIj48ZmlsdGVyIGlkPSJuIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iMC44NSIgbnVtT2N0YXZlcz0iMiIgc3RpdGNoVGlsZXM9InN0aXRjaCIgcmVzdWx0PSJub2lzZSIvPjxmZUNvbG9yTWF0cml4IGluPSJub2lzZSIgdHlwZT0ibWF0cml4IiB2YWx1ZXM9IjAgMCAwIDAgMSAgMCAwIDAgMCAxICAwIDAgMCAwIDEgIDAgMCAwIDAuMDUgMCIvPjwvZmlsdGVyPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbHRlcj0idXJsKCUyM24pIi8+PC9zdmc+");
|
||||||
|
background-repeat: repeat;
|
||||||
|
background-size: 180px 180px;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1.1rem 6rem;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
color: var(--color-muted-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pastille corail derrière la marque, cohérente avec src/e/style.css. */
|
||||||
|
.brand__mark {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-coral);
|
||||||
|
background: rgba(255, 94, 91, 0.14);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand__name {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.13em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__header h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.9rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__kicker {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------- */
|
||||||
|
/* Grille de polaroids */
|
||||||
|
/* --------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.polaroid-grid {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1.75rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* #polaroid-grid est togglée via .hidden en JS (gallery.js) : sans cette
|
||||||
|
règle, le display:grid ci-dessus passerait devant [hidden] du navigateur
|
||||||
|
(même bug que .upload-form/.welcome-modal, déjà rencontré deux fois). */
|
||||||
|
.polaroid-grid[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 560px) {
|
||||||
|
.polaroid-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 860px) {
|
||||||
|
.polaroid-grid {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.polaroid {
|
||||||
|
--rot: 0deg;
|
||||||
|
background: var(--color-polaroid);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.6rem 0.6rem 0;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
transform: rotate(var(--rot));
|
||||||
|
transition: transform 0.25s var(--ease-spring), box-shadow 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Léger relief au survol desktop uniquement — jamais sur mobile tactile. */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.polaroid:hover {
|
||||||
|
transform: rotate(var(--rot)) translateY(-4px) scale(1.03);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.polaroid__photo {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.polaroid__caption {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem 0.3rem 0.9rem;
|
||||||
|
min-height: 2.6rem;
|
||||||
|
font-family: var(--font-hand);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
color: var(--color-ink);
|
||||||
|
text-align: center;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.polaroid__caption--empty::before {
|
||||||
|
content: "♡";
|
||||||
|
color: var(--color-coral);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nouvel envoi : "chute" dans la pile avec rebond (skill: effet spring). */
|
||||||
|
@keyframes polaroid-drop {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-56px) rotate(0deg) scale(0.7);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(6px) rotate(var(--rot)) scale(1.04);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) rotate(var(--rot)) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.polaroid--dropping {
|
||||||
|
animation: polaroid-drop 0.6s var(--ease-spring) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------- */
|
||||||
|
/* États chargement / erreur / vide */
|
||||||
|
/* --------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* Carte papier, même geste que src/e/style.css : ombre réelle (impossible
|
||||||
|
sur une carte sombre posée sur #12100E), ruban washi tape, léger tilt —
|
||||||
|
traite le message d'état comme un objet posé sur la table plutôt que du
|
||||||
|
texte flottant dans le vide. */
|
||||||
|
.state-block {
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.75rem 1.3rem 1.5rem;
|
||||||
|
margin: 0.75rem 0.35rem 1.5rem;
|
||||||
|
background: var(--color-paper);
|
||||||
|
color: var(--color-paper-ink);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: var(--shadow-paper);
|
||||||
|
animation: state-card-in 0.5s var(--ease-spring) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes state-card-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px) rotate(var(--tilt, var(--tilt-1)));
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) rotate(var(--tilt, var(--tilt-1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -11px;
|
||||||
|
left: 50%;
|
||||||
|
width: 74px;
|
||||||
|
height: 24px;
|
||||||
|
transform: translateX(-50%) rotate(-2.5deg);
|
||||||
|
background: var(--tape-color, var(--color-muted-2));
|
||||||
|
opacity: 0.88;
|
||||||
|
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block--loading {
|
||||||
|
--tilt: var(--tilt-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block--error {
|
||||||
|
--tilt: -2.6deg;
|
||||||
|
--tape-color: var(--color-coral);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vigilance (bug réel déjà rencontré trois fois en prod, 2026-09-16/18) :
|
||||||
|
une déclaration `display` en CSS auteur passe toujours devant la règle
|
||||||
|
`[hidden] { display: none }` du navigateur. #polaroid-grid a un display
|
||||||
|
personnalisé (grid) et est togglée via .hidden en JS -> voir la règle
|
||||||
|
[hidden] ajoutée juste après sa définition plus haut. #status-loading,
|
||||||
|
#status-error et .empty-state n'ont pas de display personnalisé, [hidden]
|
||||||
|
fonctionne donc nativement pour eux, pas de règle supplémentaire requise
|
||||||
|
— mais si un display leur est ajouté un jour, il faudra faire pareil. */
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
border: 3px solid rgba(255, 94, 91, 0.22);
|
||||||
|
border-top-color: var(--color-coral);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#status-error p {
|
||||||
|
margin: 0 0 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#retry-btn {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-ink);
|
||||||
|
background: var(--color-coral);
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.7rem 1.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Même carte papier que .state-block (tilt inverse, tape jaune, pour
|
||||||
|
varier des deux autres états plutôt que de répéter le même angle). */
|
||||||
|
.empty-state {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.25rem 1.3rem 2rem;
|
||||||
|
margin: 0.75rem 0.35rem 1.5rem;
|
||||||
|
background: var(--color-paper);
|
||||||
|
color: var(--color-paper-ink);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: var(--shadow-paper);
|
||||||
|
transform: rotate(1.6deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -11px;
|
||||||
|
left: 50%;
|
||||||
|
width: 74px;
|
||||||
|
height: 24px;
|
||||||
|
transform: translateX(-50%) rotate(2deg);
|
||||||
|
background: var(--color-yellow);
|
||||||
|
opacity: 0.88;
|
||||||
|
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* #empty-state est maintenant un <div> avec un display personnalisé (flex,
|
||||||
|
pour empiler icône + texte) : sans cette règle explicite, [hidden] du
|
||||||
|
navigateur serait masqué par la déclaration `display` ci-dessus (bug
|
||||||
|
déjà rencontré trois fois sur ce projet, voir .polaroid-grid[hidden]
|
||||||
|
plus haut et les mêmes règles dans src/e/style.css). */
|
||||||
|
.empty-state[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-hand);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
color: var(--color-coral);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.polaroid,
|
||||||
|
.polaroid--dropping,
|
||||||
|
.state-block,
|
||||||
|
.spinner {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block {
|
||||||
|
transform: rotate(var(--tilt, var(--tilt-1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+34
-18
@@ -26,22 +26,38 @@ depuis la source indiquée.
|
|||||||
a déjà transcodé le fichier en JPEG lors de la sélection) ne téléchargeront
|
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).
|
||||||
|
|||||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,195 @@
|
|||||||
|
// Edge Function `create-event`
|
||||||
|
//
|
||||||
|
// Contrat :
|
||||||
|
// POST {SUPABASE_URL}/functions/v1/create-event
|
||||||
|
// Authorization: Bearer <SUPABASE_SERVICE_ROLE_KEY>
|
||||||
|
// Body JSON : { nom: string, date_evenement: "YYYY-MM-DD",
|
||||||
|
// client_nom?: string, max_photos_per_guest?: number }
|
||||||
|
//
|
||||||
|
// Appelée uniquement par le workflow n8n de création d'événement (Form
|
||||||
|
// Trigger -> cet endpoint), jamais depuis le front photobooth-qr : il
|
||||||
|
// n'existe aucun formulaire de création d'event côté appli (cf.
|
||||||
|
// docs/decisions.md, "2026-09-18 (bis)"). Avant cette fonction, la création
|
||||||
|
// d'un event se faisait uniquement en SQL manuel.
|
||||||
|
//
|
||||||
|
// Tourne avec la service_role key (cf. _shared/supabase-admin.ts). Appelle
|
||||||
|
// la RPC `public.create_event` (cf.
|
||||||
|
// supabase/migrations/20260918100000_create_event_rpc.sql), qui génère
|
||||||
|
// slug + admin_token + expire_at côté SQL et insère la ligne
|
||||||
|
// photobooth.events.
|
||||||
|
//
|
||||||
|
// Sécurité : même pattern que expire-events/index.ts — pas d'auth Supabase
|
||||||
|
// standard (le JWT anon suffirait à passer la gateway, ce qui ne protège
|
||||||
|
// rien pour un endpoint qui crée des events), donc comparaison EXPLICITE en
|
||||||
|
// temps constant du Bearer fourni contre la service_role key, pour
|
||||||
|
// s'assurer que seul un appelant serveur-à-serveur de confiance (le
|
||||||
|
// workflow n8n, avec la clé dans son Credentials Store, jamais côté client)
|
||||||
|
// peut créer un événement.
|
||||||
|
//
|
||||||
|
// Pas de CORS (écart assumé par rapport à admin-gallery/public-gallery, qui
|
||||||
|
// importent _shared/cors.ts) : cette fonction n'est appelée qu'en
|
||||||
|
// serveur-à-serveur depuis n8n, jamais depuis un navigateur — un
|
||||||
|
// Access-Control-Allow-Origin n'aurait aucun effet utile ici, et l'ajouter
|
||||||
|
// suggérerait à tort qu'un appel cross-origin navigateur est un usage
|
||||||
|
// prévu.
|
||||||
|
//
|
||||||
|
// ⚠️ Ne pas déployer depuis cet agent (pas de `deploy_edge_function`) :
|
||||||
|
// fichier fourni pour relecture humaine avant déploiement (Kévin applique
|
||||||
|
// lui-même via son accès MCP Supabase direct).
|
||||||
|
import { getSupabaseAdmin } from "../_shared/supabase-admin.ts";
|
||||||
|
import { timingSafeEqual } from "../_shared/timing-safe.ts";
|
||||||
|
|
||||||
|
function jsonResponse(status: number, body: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBearerToken(req: Request): string | null {
|
||||||
|
const header = req.headers.get("authorization") ?? "";
|
||||||
|
const match = /^Bearer\s+(.+)$/i.exec(header);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateEventBody = {
|
||||||
|
nom?: unknown;
|
||||||
|
date_evenement?: unknown;
|
||||||
|
client_nom?: unknown;
|
||||||
|
max_photos_per_guest?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateEventRow = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
nom: string;
|
||||||
|
date_evenement: string;
|
||||||
|
expire_at: string;
|
||||||
|
admin_token: string;
|
||||||
|
client_nom: string | null;
|
||||||
|
max_photos_per_guest: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// YYYY-MM-DD strict : évite qu'une chaîne ambiguë ("01/10/2026") ou une
|
||||||
|
// date-heure complète soit acceptée silencieusement puis mal interprétée
|
||||||
|
// par la colonne `date` de photobooth.events. Pas de vérification
|
||||||
|
// passé/futur (aucune règle produit ne l'exige à ce jour).
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
function isValidDateString(value: string): boolean {
|
||||||
|
if (!DATE_RE.test(value)) return false;
|
||||||
|
const parsed = new Date(`${value}T00:00:00Z`);
|
||||||
|
return !Number.isNaN(parsed.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return jsonResponse(405, { error: "method_not_allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||||
|
const bearer = extractBearerToken(req);
|
||||||
|
|
||||||
|
if (!serviceRoleKey || !(await timingSafeEqual(bearer, serviceRoleKey))) {
|
||||||
|
return jsonResponse(401, { error: "unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Même secret que admin-gallery/public-gallery (cf. _shared/cors.ts), réutilisé
|
||||||
|
// ici pour construire les liens, pas pour du CORS.
|
||||||
|
const appBaseUrl = Deno.env.get("APP_BASE_URL");
|
||||||
|
if (!appBaseUrl) {
|
||||||
|
console.error("[create-event] APP_BASE_URL manquante dans l'environnement.");
|
||||||
|
return jsonResponse(500, { error: "internal_error" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: CreateEventBody;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return jsonResponse(400, { error: "invalid_json_body" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const nom = typeof body.nom === "string" ? body.nom.trim() : "";
|
||||||
|
if (!nom) {
|
||||||
|
return jsonResponse(400, { error: "nom_required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateEvenement =
|
||||||
|
typeof body.date_evenement === "string" ? body.date_evenement.trim() : "";
|
||||||
|
if (!dateEvenement || !isValidDateString(dateEvenement)) {
|
||||||
|
return jsonResponse(400, { error: "date_evenement_invalid" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let clientNom: string | null = null;
|
||||||
|
if (body.client_nom !== undefined && body.client_nom !== null) {
|
||||||
|
if (typeof body.client_nom !== "string") {
|
||||||
|
return jsonResponse(400, { error: "client_nom_invalid" });
|
||||||
|
}
|
||||||
|
const trimmed = body.client_nom.trim();
|
||||||
|
clientNom = trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxPhotosPerGuest: number | null = null;
|
||||||
|
if (body.max_photos_per_guest !== undefined && body.max_photos_per_guest !== null) {
|
||||||
|
const value = body.max_photos_per_guest;
|
||||||
|
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
||||||
|
return jsonResponse(400, { error: "max_photos_per_guest_invalid" });
|
||||||
|
}
|
||||||
|
maxPhotosPerGuest = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseAdmin = getSupabaseAdmin();
|
||||||
|
|
||||||
|
// public.create_event (service_role uniquement) génère slug + admin_token
|
||||||
|
// + expire_at côté SQL et insère la ligne — cf.
|
||||||
|
// 20260918100000_create_event_rpc.sql. Revalide elle-même nom/date/max
|
||||||
|
// (ne fait pas confiance à cette validation faite en amont, même principe
|
||||||
|
// que insert_public_photo côté RPC anon).
|
||||||
|
const { data: event, error } = await supabaseAdmin
|
||||||
|
.rpc("create_event", {
|
||||||
|
p_nom: nom,
|
||||||
|
p_date_evenement: dateEvenement,
|
||||||
|
p_client_nom: clientNom,
|
||||||
|
p_max_photos_per_guest: maxPhotosPerGuest,
|
||||||
|
})
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("[create-event] create_event RPC failed", error);
|
||||||
|
return jsonResponse(500, { error: "internal_error" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
console.error("[create-event] create_event RPC returned no row");
|
||||||
|
return jsonResponse(500, { error: "internal_error" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const typedEvent = event as CreateEventRow;
|
||||||
|
const cleanBaseUrl = appBaseUrl.trim().replace(/\/+$/, "");
|
||||||
|
|
||||||
|
// 201 (plutôt que 200 comme les autres fonctions, toutes des GET) : seule
|
||||||
|
// fonction du projet qui crée une ressource.
|
||||||
|
return jsonResponse(201, {
|
||||||
|
event: {
|
||||||
|
nom: typedEvent.nom,
|
||||||
|
slug: typedEvent.slug,
|
||||||
|
date_evenement: typedEvent.date_evenement,
|
||||||
|
expire_at: typedEvent.expire_at,
|
||||||
|
client_nom: typedEvent.client_nom,
|
||||||
|
max_photos_per_guest: typedEvent.max_photos_per_guest,
|
||||||
|
},
|
||||||
|
links: {
|
||||||
|
invite: `${cleanBaseUrl}/e/${encodeURIComponent(typedEvent.slug)}`,
|
||||||
|
admin: `${cleanBaseUrl}/admin/${encodeURIComponent(typedEvent.slug)}?token=${encodeURIComponent(
|
||||||
|
typedEvent.admin_token
|
||||||
|
)}`,
|
||||||
|
// src/admin/qrcode/index.html lit désormais `?slug=` ET `?baseUrl=`
|
||||||
|
// pour se pré-remplir entièrement (corrigé le 2026-09-18, cf.
|
||||||
|
// docs/decisions.md — ce lien ne servait à rien tant que la page ne
|
||||||
|
// lisait aucun des deux paramètres).
|
||||||
|
qrcode: `${cleanBaseUrl}/admin/qrcode/?slug=${encodeURIComponent(
|
||||||
|
typedEvent.slug
|
||||||
|
)}&baseUrl=${encodeURIComponent(cleanBaseUrl)}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// Edge Function `public-gallery`
|
||||||
|
//
|
||||||
|
// Contrat : GET {SUPABASE_URL}/functions/v1/public-gallery?slug=<slug>
|
||||||
|
//
|
||||||
|
// Tourne avec la service_role key (cf. _shared/supabase-admin.ts), MAIS
|
||||||
|
// sans aucune vérification de token — c'est le point central de cette
|
||||||
|
// fonction, à la différence de `admin-gallery`. L'accès est intentionnellement
|
||||||
|
// ouvert à quiconque connaît le `slug` : même modèle de confiance que
|
||||||
|
// l'upload invité (le slug circule via QR code, ce n'est pas un secret).
|
||||||
|
//
|
||||||
|
// Origine : revirement produit du 2026-09-18 (cf. docs/decisions.md) — les
|
||||||
|
// invités peuvent désormais voir toutes les photos d'un événement, pas
|
||||||
|
// seulement envoyer la leur. Ça inverse la règle "lecture des photos jamais
|
||||||
|
// publique" du cahier des charges initial / docs/modele-donnees.md : ce
|
||||||
|
// revirement est acté comme décision explicite du 2026-09-18, pas tranché
|
||||||
|
// par cet agent.
|
||||||
|
//
|
||||||
|
// Sécurité : on résout le slug via `public.get_public_event` (PAS
|
||||||
|
// `get_event_for_admin`, qui expose admin_token) — cette fonction ne
|
||||||
|
// renvoie que {id, nom, statut, date_evenement, expire_at,
|
||||||
|
// max_photos_per_guest}, jamais admin_token / client_nom / facture_montant.
|
||||||
|
// Les photos elles-mêmes viennent de `public.get_event_photos`
|
||||||
|
// (service_role uniquement), qui ne renvoie que {id, url_storage,
|
||||||
|
// nom_invite, message, uploaded_at} — jamais l'email/téléphone d'un invité
|
||||||
|
// (ces colonnes n'existent d'ailleurs pas dans photobooth.photos, cf.
|
||||||
|
// docs/modele-donnees.md).
|
||||||
|
//
|
||||||
|
// `get_public_event` renvoie l'événement même si son statut n'est pas
|
||||||
|
// `actif` (expiré/archivé) — comportement hérité, inchangé ici. Cette
|
||||||
|
// fonction renvoie donc telles quelles les photos d'un événement expiré si
|
||||||
|
// elles existent encore en base (la purge Storage/DB à J+7 est un job
|
||||||
|
// séparé, cf. expire-events) : pas de filtre `statut = 'actif'` ajouté ici,
|
||||||
|
// le front peut décider d'afficher un message adapté à partir de
|
||||||
|
// `event.statut`, cohérent avec le comportement déjà choisi pour
|
||||||
|
// `get_public_event` côté page d'upload.
|
||||||
|
import { getSupabaseAdmin, getStorageBucket } from "../_shared/supabase-admin.ts";
|
||||||
|
import { corsHeaders, handleCorsPreflight } from "../_shared/cors.ts";
|
||||||
|
|
||||||
|
// URLs signées affichées dans la galerie invité : courte durée, c'est pour
|
||||||
|
// un affichage immédiat dans le navigateur de l'invité, pas pour un lien à
|
||||||
|
// partager. Même valeur que `admin-gallery` (cf. GALLERY_SIGNED_URL_TTL_SECONDS
|
||||||
|
// dans supabase/functions/admin-gallery/index.ts).
|
||||||
|
const GALLERY_SIGNED_URL_TTL_SECONDS = 10 * 60; // 10 minutes
|
||||||
|
|
||||||
|
type PhotoRow = {
|
||||||
|
id: string;
|
||||||
|
nom_invite: string | null;
|
||||||
|
message: string | null;
|
||||||
|
uploaded_at: string;
|
||||||
|
url_storage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PublicEventRow = {
|
||||||
|
id: string;
|
||||||
|
nom: string;
|
||||||
|
statut: string;
|
||||||
|
date_evenement: string;
|
||||||
|
expire_at: string;
|
||||||
|
max_photos_per_guest: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function jsonResponse(status: number, body: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { ...corsHeaders(), "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404 générique : même statut/corps que le "slug inconnu" de admin-gallery,
|
||||||
|
// pour ne pas distinguer un slug qui n'existe pas d'un autre cas d'erreur
|
||||||
|
// silencieuse côté appelant.
|
||||||
|
function notFound(): Response {
|
||||||
|
return jsonResponse(404, { error: "not_found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
const preflight = handleCorsPreflight(req);
|
||||||
|
if (preflight) return preflight;
|
||||||
|
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
return jsonResponse(405, { error: "method_not_allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const slug = url.searchParams.get("slug");
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
return jsonResponse(400, { error: "missing_parameters" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseAdmin = getSupabaseAdmin();
|
||||||
|
|
||||||
|
// 1. Résout l'event par slug via public.get_public_event (anon-safe,
|
||||||
|
// n'expose jamais admin_token/client_nom/facture_montant) — pas
|
||||||
|
// get_event_for_admin, réservée à admin-gallery.
|
||||||
|
const { data: event, error: eventError } = await supabaseAdmin
|
||||||
|
.rpc("get_public_event", { p_slug: slug })
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (eventError) {
|
||||||
|
console.error("[public-gallery] get_public_event failed", eventError);
|
||||||
|
return jsonResponse(500, { error: "internal_error" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const typedEvent = event as PublicEventRow;
|
||||||
|
|
||||||
|
// 2. Photos de l'event, triées chronologiquement (get_event_photos renvoie
|
||||||
|
// déjà uploaded_at ASC) — pas de tri inversé ici contrairement à
|
||||||
|
// admin-gallery : une galerie invité affiche naturellement l'ordre
|
||||||
|
// d'arrivée des photos, pas de raison produit de l'inverser pour l'instant.
|
||||||
|
let photos: PhotoRow[];
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabaseAdmin.rpc("get_event_photos", {
|
||||||
|
p_event_id: typedEvent.id,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
photos = (data ?? []) as PhotoRow[];
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[public-gallery] get_event_photos failed", err);
|
||||||
|
return jsonResponse(500, { error: "internal_error" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event sans photo : état normal (pas d'upload pour l'instant), pas une
|
||||||
|
// erreur — 200 avec photos: [], contrairement à admin-gallery?action=zip
|
||||||
|
// qui renvoie 404/no_photos (là, l'absence de photo empêche l'action
|
||||||
|
// demandée "générer un ZIP" ; ici il n'y a pas d'action, juste un état
|
||||||
|
// d'affichage à rendre).
|
||||||
|
const signedUrlByPath = new Map<string, string>();
|
||||||
|
if (photos.length > 0) {
|
||||||
|
const paths = photos.map((p) => p.url_storage);
|
||||||
|
const bucket = getStorageBucket();
|
||||||
|
const { data: signedUrls, error: signError } = await supabaseAdmin.storage
|
||||||
|
.from(bucket)
|
||||||
|
.createSignedUrls(paths, GALLERY_SIGNED_URL_TTL_SECONDS);
|
||||||
|
|
||||||
|
if (signError) {
|
||||||
|
console.error("[public-gallery] createSignedUrls failed", signError);
|
||||||
|
return jsonResponse(500, { error: "internal_error" });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < paths.length; i++) {
|
||||||
|
const entry = signedUrls?.[i];
|
||||||
|
if (entry?.signedUrl) {
|
||||||
|
signedUrlByPath.set(paths[i], entry.signedUrl);
|
||||||
|
} else if (entry?.error) {
|
||||||
|
// Une photo dont l'URL n'a pas pu être signée (ex. fichier absent du
|
||||||
|
// Storage alors que la ligne existe encore côté DB) ne doit pas
|
||||||
|
// faire échouer toute la galerie : on la renvoie avec
|
||||||
|
// signed_url: null, au front de gérer l'affichage.
|
||||||
|
console.warn(
|
||||||
|
`[public-gallery] signed url failed for ${paths[i]}`,
|
||||||
|
entry.error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(200, {
|
||||||
|
event: {
|
||||||
|
nom: typedEvent.nom,
|
||||||
|
date_evenement: typedEvent.date_evenement,
|
||||||
|
statut: typedEvent.statut,
|
||||||
|
photo_count: photos.length,
|
||||||
|
},
|
||||||
|
photos: photos.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
nom_invite: p.nom_invite,
|
||||||
|
message: p.message,
|
||||||
|
uploaded_at: p.uploaded_at,
|
||||||
|
signed_url: signedUrlByPath.get(p.url_storage) ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
-- Migration : fonction `create_event` (public, service_role uniquement)
|
||||||
|
-- Réf. : docs/decisions.md ("2026-09-18 (bis)", création d'événement via un
|
||||||
|
-- formulaire n8n + Edge Function `create-event`), docs/modele-donnees.md
|
||||||
|
-- (table `events`), 20260916090400_create_public_functions.sql (pattern
|
||||||
|
-- des fonctions SECURITY DEFINER réservées à service_role).
|
||||||
|
--
|
||||||
|
-- Avant cette migration, la création d'un event se faisait uniquement en SQL
|
||||||
|
-- manuel (cf. docs/cahier-des-charges.md, livrable non coché "Documentation
|
||||||
|
-- courte : comment créer un nouvel événement manuellement"). Cette fonction
|
||||||
|
-- devient le point d'entrée unique, appelée par la nouvelle Edge Function
|
||||||
|
-- `supabase/functions/create-event/index.ts` (elle-même appelée par un
|
||||||
|
-- workflow n8n serveur-à-serveur, jamais depuis le front).
|
||||||
|
--
|
||||||
|
-- ⚠️ Ne pas appliquer depuis cet agent (Kévin applique lui-même).
|
||||||
|
|
||||||
|
-- unaccent est nécessaire pour dériver un slug lisible à partir de `nom`
|
||||||
|
-- (ex: "Mariage Julie & Marc" -> "mariage-julie-marc"), même logique que
|
||||||
|
-- `sanitizeFilenamePart` côté admin-gallery/index.ts mais en SQL (extension
|
||||||
|
-- contrib standard, pas de dépendance tierce).
|
||||||
|
create extension if not exists unaccent with schema extensions;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- photobooth.generate_unique_slug — interne (pas un point de contrat public)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Dérive un slug lisible à partir d'un texte libre, et garantit son unicité
|
||||||
|
-- contre photobooth.events.slug en ajoutant un suffixe numérique en cas de
|
||||||
|
-- collision ("mariage-julie-marc", puis "mariage-julie-marc-2", etc.).
|
||||||
|
-- Fonction interne au schéma photobooth : ce n'est pas un point de contrat
|
||||||
|
-- public (comme photobooth.events_set_expire_at()), pas besoin d'être dans
|
||||||
|
-- public ni d'avoir des grants dédiés (appelée uniquement par
|
||||||
|
-- public.create_event, SECURITY DEFINER, propriétaire postgres).
|
||||||
|
create or replace function photobooth.generate_unique_slug(p_source text)
|
||||||
|
returns text
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_base_slug text;
|
||||||
|
v_candidate text;
|
||||||
|
v_suffix integer := 1;
|
||||||
|
-- Garde-fou défensif : en pratique la boucle se termine toujours au
|
||||||
|
-- premier ou deuxième tour (collision rarissime), mais on évite une
|
||||||
|
-- boucle infinie en cas de bug/donnée pathologique.
|
||||||
|
v_max_attempts constant integer := 1000;
|
||||||
|
begin
|
||||||
|
v_base_slug := lower(trim(both '-' from
|
||||||
|
regexp_replace(extensions.unaccent(coalesce(p_source, '')), '[^a-zA-Z0-9]+', '-', 'g')
|
||||||
|
));
|
||||||
|
|
||||||
|
if v_base_slug = '' then
|
||||||
|
v_base_slug := 'evenement';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_candidate := v_base_slug;
|
||||||
|
|
||||||
|
while exists (select 1 from photobooth.events where slug = v_candidate) loop
|
||||||
|
v_suffix := v_suffix + 1;
|
||||||
|
v_candidate := v_base_slug || '-' || v_suffix;
|
||||||
|
|
||||||
|
if v_suffix > v_max_attempts then
|
||||||
|
raise exception 'slug_generation_failed'
|
||||||
|
using errcode = 'P0001',
|
||||||
|
detail = format('Impossible de générer un slug unique pour "%s" après %s tentatives.', p_source, v_max_attempts);
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
return v_candidate;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
comment on function photobooth.generate_unique_slug(text) is
|
||||||
|
'Dérive un slug lisible et unique (photobooth.events.slug) à partir d''un texte libre (typiquement events.nom) : normalise via unaccent, remplace tout ce qui n''est pas [a-zA-Z0-9] par un tiret, ajoute un suffixe numérique en cas de collision. Fonction interne, appelée uniquement par public.create_event.';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- create_event — service_role UNIQUEMENT (NOUVEAU)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Point d'entrée unique pour créer un événement. Réservée à service_role :
|
||||||
|
-- appelée par l'Edge Function `create-event`, elle-même appelée uniquement
|
||||||
|
-- par le workflow n8n de création d'événement (jamais depuis le front, pas
|
||||||
|
-- de formulaire de création dans l'appli photobooth-qr, cf. décision
|
||||||
|
-- "2026-09-18 (bis)").
|
||||||
|
--
|
||||||
|
-- Ne calcule NI admin_token NI expire_at explicitement : ces deux valeurs
|
||||||
|
-- sont déjà garanties par la table elle-même (default
|
||||||
|
-- `encode(extensions.gen_random_bytes(32), 'hex')` pour admin_token, trigger
|
||||||
|
-- `trg_events_set_expire_at` pour expire_at, cf.
|
||||||
|
-- 20260916090100_create_events_table.sql) — dupliquer cette logique ici
|
||||||
|
-- créerait deux sources de vérité pour le même comportement.
|
||||||
|
create or replace function public.create_event(
|
||||||
|
p_nom text,
|
||||||
|
p_date_evenement date,
|
||||||
|
p_client_nom text default null,
|
||||||
|
p_max_photos_per_guest integer default null
|
||||||
|
)
|
||||||
|
returns table (
|
||||||
|
id uuid,
|
||||||
|
slug text,
|
||||||
|
nom text,
|
||||||
|
date_evenement date,
|
||||||
|
expire_at timestamptz,
|
||||||
|
admin_token text,
|
||||||
|
client_nom text,
|
||||||
|
max_photos_per_guest integer
|
||||||
|
)
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public, photobooth, extensions, pg_temp
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_slug text;
|
||||||
|
begin
|
||||||
|
-- Revalidation défensive côté SQL (l'Edge Function valide déjà ces mêmes
|
||||||
|
-- règles avant l'appel RPC, cf. supabase/functions/create-event/index.ts) :
|
||||||
|
-- même principe que public.insert_public_photo(), qui ne fait pas confiance
|
||||||
|
-- à un appelant "déjà propre".
|
||||||
|
if p_nom is null or trim(p_nom) = '' then
|
||||||
|
raise exception 'nom_required'
|
||||||
|
using errcode = 'P0001',
|
||||||
|
detail = 'Le nom de l''événement est requis.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_date_evenement is null then
|
||||||
|
raise exception 'date_evenement_required'
|
||||||
|
using errcode = 'P0001',
|
||||||
|
detail = 'La date de l''événement est requise.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_max_photos_per_guest is not null and p_max_photos_per_guest <= 0 then
|
||||||
|
raise exception 'max_photos_per_guest_invalid'
|
||||||
|
using errcode = 'P0001',
|
||||||
|
detail = 'max_photos_per_guest doit être un entier strictement positif si fourni.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_slug := photobooth.generate_unique_slug(p_nom);
|
||||||
|
|
||||||
|
return query
|
||||||
|
insert into photobooth.events (slug, nom, date_evenement, client_nom, max_photos_per_guest)
|
||||||
|
values (v_slug, p_nom, p_date_evenement, p_client_nom, p_max_photos_per_guest)
|
||||||
|
returning
|
||||||
|
events.id,
|
||||||
|
events.slug,
|
||||||
|
events.nom,
|
||||||
|
events.date_evenement,
|
||||||
|
events.expire_at,
|
||||||
|
events.admin_token,
|
||||||
|
events.client_nom,
|
||||||
|
events.max_photos_per_guest;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
comment on function public.create_event(text, date, text, integer) is
|
||||||
|
'Crée un événement (photobooth.events) : génère un slug unique à partir de `nom`, laisse la table calculer admin_token (default) et expire_at (trigger). Réservée à service_role (Edge Function create-event) : ne JAMAIS accorder EXECUTE à anon/authenticated.';
|
||||||
|
|
||||||
|
revoke all on function public.create_event(text, date, text, integer) from public, anon, authenticated;
|
||||||
|
grant execute on function public.create_event(text, date, text, integer) to service_role;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
-- Durcit photobooth.generate_unique_slug : le linter de sécurité Supabase
|
||||||
|
-- signale un search_path mutable (absent) sur cette fonction, introduite par
|
||||||
|
-- 20260918100000_create_event_rpc.sql. Même remède que le reste du projet
|
||||||
|
-- (public.create_event fixe déjà search_path) : figer explicitement, pour
|
||||||
|
-- empêcher qu'un search_path attaquant (ex: schéma temporaire malveillant)
|
||||||
|
-- ne fasse résoudre unaccent() ou photobooth.events vers autre chose que
|
||||||
|
-- prévu.
|
||||||
|
create or replace function photobooth.generate_unique_slug(p_source text)
|
||||||
|
returns text
|
||||||
|
language plpgsql
|
||||||
|
set search_path = photobooth, extensions, pg_temp
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_base_slug text;
|
||||||
|
v_candidate text;
|
||||||
|
v_suffix integer := 1;
|
||||||
|
v_max_attempts constant integer := 1000;
|
||||||
|
begin
|
||||||
|
v_base_slug := lower(trim(both '-' from
|
||||||
|
regexp_replace(extensions.unaccent(coalesce(p_source, '')), '[^a-zA-Z0-9]+', '-', 'g')
|
||||||
|
));
|
||||||
|
|
||||||
|
if v_base_slug = '' then
|
||||||
|
v_base_slug := 'evenement';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_candidate := v_base_slug;
|
||||||
|
|
||||||
|
while exists (select 1 from photobooth.events where slug = v_candidate) loop
|
||||||
|
v_suffix := v_suffix + 1;
|
||||||
|
v_candidate := v_base_slug || '-' || v_suffix;
|
||||||
|
|
||||||
|
if v_suffix > v_max_attempts then
|
||||||
|
raise exception 'slug_generation_failed'
|
||||||
|
using errcode = 'P0001',
|
||||||
|
detail = format('Impossible de générer un slug unique pour "%s" après %s tentatives.', p_source, v_max_attempts);
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
return v_candidate;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
Reference in New Issue
Block a user