diff --git a/.claude/agents/front-upload.md b/.claude/agents/front-upload.md new file mode 100644 index 0000000..839e70b --- /dev/null +++ b/.claude/agents/front-upload.md @@ -0,0 +1,19 @@ +--- +name: front-upload +description: Utiliser pour la page upload invité et la compression image côté client du projet photobooth-qr. +tools: Read, Edit, Write, Bash, Grep, Glob +--- + +Tu es responsable exclusivement du front invité (page upload, mobile-first, +sans framework lourd) du projet photobooth-qr. + +Règles : +- Compression obligatoire côté client avant upload, via la lib + browser-image-compression (décision actée, ne pas réinventer en Canvas + maison sauf si explicitement demandé). +- Gère l'orientation EXIF et les formats HEIC (iPhone) — cas fréquent, + ne pas négliger. +- Ne touche jamais aux migrations Supabase (supabase/) sauf demande + explicite. +- Interface simple, sobre, mobile-first — pas de dépendance front lourde + sans justification. diff --git a/.claude/agents/reviewer-securite.md b/.claude/agents/reviewer-securite.md new file mode 100644 index 0000000..d6fa997 --- /dev/null +++ b/.claude/agents/reviewer-securite.md @@ -0,0 +1,22 @@ +--- +name: reviewer-securite +description: Utiliser après chaque bloc de code significatif, pour relecture sécurité/RGPD du projet photobooth-qr. Lecture seule — ne corrige rien, se contente de challenger et lister les problèmes. +tools: Read, Grep, Glob +--- + +Tu relis le code du projet photobooth-qr avec un œil critique, uniquement +en lecture. Tu ne modifies rien toi-même. + +Points de vigilance systématiques : +- La clé anon Supabase ne doit JAMAIS permettre de lister/lire les photos + d'un autre event (vérifie les RLS). +- L'admin_token doit être suffisamment long et généré aléatoirement + (pas de pattern prévisible). +- La mention RGPD doit être visible avant l'upload, pas cachée. +- Toute donnée qui devrait expirer à J+7 doit effectivement être supprimée + (fichiers Storage ET lignes DB, pas l'un sans l'autre). +- Signale tout appel qui utiliserait la service_role key côté client + (front) au lieu de côté serveur — c'est une faille critique. + +Sois direct et liste les problèmes trouvés, du plus critique au moins +critique. Ne complimente pas le code juste pour être agréable. diff --git a/.claude/agents/supabase-backend.md b/.claude/agents/supabase-backend.md new file mode 100644 index 0000000..3aee460 --- /dev/null +++ b/.claude/agents/supabase-backend.md @@ -0,0 +1,19 @@ +--- +name: supabase-backend +description: Responsable exclusif de la couche Supabase du projet photobooth-qr (schéma DB, RLS, migrations SQL, Edge Functions). À utiliser pour toute tâche touchant aux tables events/photos, aux policies RLS, au job d'expiration J+7, ou à l'Edge Function admin de zip/listing des photos. +tools: Read, Edit, Write, Bash, Grep, Glob +--- + +Tu es responsable exclusivement de la couche Supabase du projet photobooth-qr : +schéma DB (tables events, photos), policies RLS, migrations SQL, Edge Functions +(notamment le job d'expiration J+7 via pg_cron, et l'Edge Function admin qui +utilise la service_role key pour lister/zipper les photos). + +Règles : +- Ne touche jamais au front (src/) sauf si explicitement demandé. +- Toute policy RLS doit suivre le principe : upload anonyme autorisé + uniquement si l'event est actif, lecture des photos jamais publique. +- Référence-toi systématiquement à docs/modele-donnees.md avant toute + modification de schéma. +- Si une décision structurante n'est pas déjà actée dans docs/decisions.md, + ne tranche pas seul : signale-le clairement dans ta réponse. diff --git a/.env.example b/.env.example index be9ad56..fa84976 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,7 @@ SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= # Nom du bucket Supabase Storage utilisé pour les photos -SUPABASE_STORAGE_BUCKET=photos +SUPABASE_STORAGE_BUCKET=event-photos # URL de base de l'application (utilisée pour générer les liens QR code) APP_BASE_URL= diff --git a/.gitignore b/.gitignore index c01f48e..8cff6e7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,13 @@ node_modules/ .env.local .env.*.local +# Config front runtime (contient l'URL + la clé anon Supabase réelles, +# cf. src/e/config.example.js pour le modèle à copier) +src/e/config.js + +# Config front runtime — galerie admin (cf. src/admin/gallery/config.example.js) +src/admin/gallery/config.js + # Build dist/ build/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..ff94bb4 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "supabase": { + "type": "http", + "url": "https://mcp.supabase.com/mcp?features=docs,account,database,debugging,development,functions,branching" + } + } +} \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index 763e6b7..a4c14cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,5 +51,3 @@ flowchart TD - Mécanisme exact du job d'expiration (cron Supabase, Edge Function planifiée, ou script sur le VPS existant). - Génération et hébergement du QR code (généré à la volée ou stocké). -- Bibliothèque de compression image côté client (ou implémentation maison via - Canvas API pour rester sobre en dépendances). diff --git a/docs/cahier-des-charges.md b/docs/cahier-des-charges.md index 4878dfc..5995faf 100644 --- a/docs/cahier-des-charges.md +++ b/docs/cahier-des-charges.md @@ -1 +1,233 @@ -# Cahier des charges +# Cahier des charges — Application Photobooth QR Code +**Porteur de projet :** Kévin Lecou +**Marque :** Personnelle (Kévin Lecou) — usage v1 perso, ouverture commerciale v2/v3 +**Durée de dev estimée :** ~1 journée (V1) +**Date de rédaction :** 13/09/2026 + +--- + +## 🧭 1. Contexte & objectif + +Application web (pas d'appli native, pas de compte requis) permettant aux invités d'un événement (mariage, anniversaire, soirée) d'uploader leurs photos via un QR code affiché sur place. Les photos sont centralisées, accompagnées d'un nom/message optionnel façon livre d'or, et récupérables en un clic par l'organisateur (Kévin ou son client) sous forme de ZIP. + +**V1 (maintenant) :** usage personnel de Kévin, pour ses propres événements. +**V2/V3 (plus tard) :** produit facturé à l'événement pour des clients externes (mariages, entreprises), potentiellement piloté via n8n/Notion. + +> ⚠️ Point à garder en tête dès la V1 : même si l'usage est perso pour l'instant, autant construire la structure de données comme si chaque "événement" était déjà un client — ça évite de tout refaire en V2. C'est déjà comme ça que je décris le modèle de données plus bas. + +--- + +## ⚙️ 2. Stack technique imposée + +| Brique | Outil | Rôle | +|---|---|---| +| Backend / DB / Storage | **Supabase** (déjà connecté) | Stockage des photos, table événements, table photos, gestion RLS | +| Front | À définir par le dev (recommandé : léger, mobile-first, pas de framework lourd) | Page upload + page galerie admin | +| Automatisation (V2) | **n8n** | Création d'événement, notifications, facturation | +| Suivi client (V2) | **Notion** | Base "Événements" (statut, date, lien, facturation) | +| Notifications (V2) | **Telegram** | Alerte "X nouvelles photos" | +| Hébergement front | À proposer par le dev — critère : **écoconception** (hébergeur mutualisé/vert type Scalingo, o2switch, ou hébergement statique très léger type Cloudflare Pages/Netlify avec faible empreinte) | +| Génération QR code | Lib front ou back (ex: `qrcode` npm) — pas de service tiers payant nécessaire | + +**Contrainte transverse :** sobriété numérique — compression des images côté client avant upload (réduit stockage Supabase + bande passante + empreinte carbone). + +--- + +## 🗂️ 3. Modèle de données (Supabase) + +### Table `events` +| Champ | Type | Notes | +|---|---|---| +| id | uuid (PK) | généré auto | +| slug | text unique | utilisé dans l'URL publique (ex: `/e/mariage-julie-marc`) | +| nom | text | nom de l'événement | +| date_evenement | date | | +| created_at | timestamptz | | +| expire_at | timestamptz | **date_evenement + 7 jours** (décidé : le compteur démarre à la date de l'événement, pas à la création de la fiche ni à la 1ère photo) | +| statut | enum | `actif`, `expire`, `telecharge`, `archive` | +| admin_token | text (secret) | token unique pour accéder à la galerie admin + déclencher le ZIP | +| client_nom | text (nullable) | vide en V1, utilisé en V2 pour la facturation | +| facture_montant | numeric (nullable) | V2 | + +### Table `photos` +| Champ | Type | Notes | +|---|---|---| +| id | uuid (PK) | | +| event_id | uuid (FK → events) | | +| url_storage | text | chemin dans le bucket Supabase | +| nom_invite | text (nullable) | champ "livre d'or" | +| message | text (nullable) | message optionnel associé à la photo | +| uploaded_at | timestamptz | | + +### Storage bucket +- 1 bucket par environnement (`event-photos`), organisé en sous-dossiers par `event_id`. +- **RLS (Row Level Security) :** + - Écriture (upload) : **anonyme autorisée**, mais uniquement sur un `event_id` existant et **actif** (pas expiré) — pas d'auth invité, le lien/QR = le ticket d'entrée. + - Lecture publique des fichiers : **non** — seul l'admin (via `admin_token`) peut lister/voir les photos. Les invités uploadent en aveugle, ils ne voient pas la galerie des autres. + - Suppression : réservée à l'admin_token, ou automatique après expiration (cf. §6). + +--- + +## ✅ 4. Fonctionnalités V1 (scope figé pour le dev) + +### Côté invité (page publique, mobile-first) +1. Scan du QR code → arrive sur `/e/{slug}`. +2. Page simple : nom de l'événement, bouton "Ajouter une photo" (accès caméra + galerie du téléphone). +3. Champ optionnel : "Ton prénom" + "Un petit mot" (livre d'or). +4. Upload → compression client → confirmation visuelle ("Merci ! Ta photo est bien envoyée 🎉"). +5. **Pas de galerie visible côté invité** (confirmé : upload only, pas de consultation). +6. **Pas de téléchargement invité** (confirmé). +7. Si l'événement est expiré/archivé → message "Cet événement n'accepte plus de photos". + +### Côté admin (Kévin uniquement, V1) +1. Accès via URL secrète type `/admin/{slug}?token={admin_token}` (décidé : pas de vrai login en V1, juste le token dans l'URL — token long et non devinable, généré aléatoirement, suffisant pour un usage perso). +2. Vue galerie : toutes les photos de l'événement + nom/message associé. +3. Bouton **"Télécharger tout en ZIP"**. +4. Statut de l'événement (actif / expire dans X jours / expiré). + +### QR code +- Généré automatiquement à la création de l'événement (lien direct vers `/e/{slug}`). +- Exportable en image (PNG/SVG) pour impression sur le support physique (comme sur ta photo de référence). + +--- + +## 🔁 5. Flux fonctionnel (schéma) + +``` +[Création événement] (manuel en V1, via un simple formulaire ou directement en base) + │ + ▼ + Génère : slug + admin_token + QR code + expire_at (J+7) + │ + ▼ +[QR code imprimé / affiché lors de la soirée] + │ + ▼ +[Invité scanne] → page /e/{slug} + │ + ├─ Événement actif ? ──NON──▶ "Événement clos" + │ + OUI + ▼ + Formulaire upload (photo + prénom + message optionnel) + │ + ▼ + Compression image (client-side) + │ + ▼ + Upload → Supabase Storage + insert ligne `photos` + │ + ▼ + Confirmation "Merci !" + +--- Côté admin, à tout moment --- + +[Kévin ouvre /admin/{slug}?token={admin_token}] + │ + ▼ + Liste des photos + noms/messages + │ + ▼ + Bouton "Télécharger ZIP" + │ + ▼ + Génération ZIP (backend) → téléchargement + │ + ▼ + statut passe à "telecharge" (mais les fichiers restent en ligne : + décidé → pas de suppression immédiate même après téléchargement du ZIP) + +--- Expiration --- + +[J+7 après la date de l'événement] (décidé : basé sur date_evenement, pas sur la création de la fiche) + │ + ▼ + Cron/tâche planifiée Supabase (Edge Function + pg_cron) : + statut → "expire", upload bloqué + │ + ▼ + Suppression effective des fichiers (storage + lignes `photos`) à J+7, + que le ZIP ait été téléchargé ou non (décidé : le J+7 prime dans tous les cas — + ⚠️ à bien faire comprendre au client/à l'organisateur que s'il n'a pas téléchargé + son ZIP avant J+7, les photos sont perdues) +``` + +**Erreurs à gérer explicitement (pour le dev) :** +- Upload d'un fichier non-image → rejeté avec message clair. +- Fichier trop volumineux même après compression → limite fixée (ex : 15 Mo max avant compression, ~2-3 Mo après). +- Connexion coupée pendant l'upload → retry ou message d'erreur explicite (pas d'échec silencieux). +- Événement inexistant (mauvais slug) → page 404 propre, pas d'erreur technique visible. +- Deux invités uploadent en simultané → pas de conflit attendu (chaque upload = une ligne indépendante), mais à tester en charge légère (ex: 20-30 uploads simultanés, taille réaliste d'une soirée). +- Quota Supabase (plan gratuit) approché/dépassé → **⚠️ point ouvert : je n'ai pas les seuils exacts à jour du plan gratuit Supabase (ils évoluent), à vérifier par le dev directement sur la doc Supabase au moment du dev plutôt que de se fier à un chiffre que je pourrais avoir en tête et qui serait faux.** + +--- + +## 🔐 6. RGPD — à intégrer dans le script (V1) + +Comme les invités uploadent des photos pouvant contenir des visages d'autres personnes, quelques éléments minimaux sont à intégrer. **Je ne suis pas juriste et ceci ne remplace pas un avis légal — mais voici la base habituellement recommandée pour ce type d'outil :** + +1. **Mention d'information** affichée sur la page d'upload avant envoi (courte, pas besoin de case à cocher bloquante en V1 perso, mais recommandé dès la V2 commerciale) : + > *"En envoyant une photo, tu acceptes qu'elle soit visible par l'organisateur de l'événement et éventuellement partagée dans le cadre de cette soirée. Les photos sont automatiquement supprimées après [durée]. Pour toute demande de suppression, contacte [email/contact organisateur]."* + +2. **Durée de conservation limitée** : cohérent avec ton besoin (7 jours / jusqu'à téléchargement du ZIP) — c'est déjà une bonne pratique RGPD (minimisation). + +3. **Droit à la suppression** : prévoir un moyen simple (même juste un email de contact affiché) pour qu'une personne photographiée demande le retrait d'une photo la concernant. + +4. **Pas de données personnelles obligatoires** : le prénom/message sont optionnels — bon réflexe déjà prévu. + +5. **Pour la V2 commerciale** (clients externes) : il faudra à ce moment-là un texte de mentions légales plus formel, et probablement une case à cocher explicite selon le nombre d'invités et le contexte — **⚠️ à faire valider par un professionnel du droit avant la V2 commerciale**, je peux t'aider à préparer un brouillon mais je ne peux pas garantir la conformité légale complète. + +--- + +## 📦 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/`. + +- [x] Page publique d'upload (`/e/{slug}`) — code écrit (`src/e/`), compression + HEIC + gestion d'erreurs. **Non testée en navigateur réel** (pas de projet Supabase live pour l'instant). +- [x] Page admin galerie + export ZIP (`/admin/{slug}?token=...`) — code écrit (`src/admin/gallery/`), consomme l'Edge Function `admin-gallery`. **Non testée en réel**, dépend de l'Edge Function ci-dessous. +- [x] Génération du QR code (export PNG/SVG imprimable) — code écrit (`src/admin/qrcode/`), page démo utilisable dès maintenant. +- [ ] Schéma Supabase (tables `pq_events`/`pq_photos` + RLS + policies Storage) — **SQL écrit et relu (sécurité OK après corrections), PAS ENCORE APPLIQUÉ**. En attente de validation finale de Kévin avant `apply_migration`. +- [x] Compression image côté client avant upload — `browser-image-compression`, intégrée dans `src/e/upload.js`. +- [x] Gestion de l'expiration (statut événement, blocage upload, suppression J+7) — blocage upload prêt côté schéma (`event_accepts_uploads`) ; job SQL `pq_expire_events` (pg_cron) écrit. **Reste ouvert** : mécanisme de déclenchement de la suppression des fichiers Storage (pg_net vs n8n, cf. `docs/decisions.md`) — à trancher avant implémentation. +- [x] Mention RGPD intégrée sur la page upload — texte de `docs/rgpd.md` repris verbatim. +- [x] Edge Function admin (liste photos + ZIP) — code écrit (`supabase/functions/admin-gallery/`), ZIP en streaming. **Non déployée, dépendance `zip.js` non testée en environnement réel** — à vérifier via `supabase functions serve` avant tout déploiement. +- [ ] 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. + +--- + +## 💰 8. Budget & contraintes + +- Budget outils externes : **léger accepté** (ex: nom de domaine, éventuel plan Supabase payant si besoin, hébergement front à petit coût). Pas de contrainte "0€ strict". +- Écoconception à privilégier dans les choix d'hébergement et de dépendances (pas de lib front lourde inutile). +- Dev réalisé par un tiers (freelance/dev externe) sur la base de ce document. + +--- + +## 🚀 9. Roadmap V2/V3 (hors scope dev V1, pour information) + +- Création d'événement automatisée via **n8n** (formulaire → génère event + QR + entrée Notion). +- Notification **Telegram** en temps réel ("5 nouvelles photos ajoutées"). +- Base **Notion "Événements"** : statut, client, date, lien galerie, montant facturé. +- Authentification admin renforcée (vrai login) si ouverture à plusieurs clients simultanés. +- Facturation à l'événement (tarif à définir). +- Éventuelle marque dédiée si le produit décolle (distincte du nom personnel Kévin Lecou). + +--- + +## ❓ 10. Décisions actées + +1. ✅ Le J+7 démarre à la **date de l'événement** (`date_evenement`), pas à la création de la fiche ni à la 1ère photo. +2. ✅ Sécurité admin V1 : **token secret dans l'URL**, pas de vrai login. +3. ✅ Le J+7 **prime toujours** : suppression effective des photos à J+7 même si le ZIP a déjà été téléchargé (pas de suppression anticipée au moment du téléchargement). +4. ⚠️ **Repo de code — point encore ouvert.** Tu ne sais pas trop, donc voici ma recommandation concrète : crée un compte **GitHub gratuit à ton nom** (ou au nom "Kévin Lecou" / futur nom de marque), en **dépôt privé**. Pourquoi : + - C'est toi le porteur du projet et celui qui va potentiellement le revendre en V2/V3 → tu dois être **propriétaire du code**, pas le dev. + - Un repo privé gratuit suffit largement pour ce volume de code. + - Ça te permet de changer de dev plus tard sans dépendre de qui que ce soit. + - Si tu n'as pas encore de compte GitHub, je peux te faire un guide pas-à-pas (2 minutes, gratuit) — dis-le moi. + - Alternative si tu préfères tout garder sur ton VPS : un dépôt Git auto-hébergé (ex: via Gitea, léger) est possible vu que tu as déjà un VPS pour n8n — mais GitHub reste plus simple si tu ne gères pas encore ça au quotidien. +5. ✅ Nom de domaine : **probablement sur ton VPS/serveur existant** — pas de coût supplémentaire à prévoir a priori, à confirmer avec le dev que ton VPS peut effectivement héberger le front + les redirections DNS nécessaires. + +--- + +*Document rédigé pour servir de brief à un développeur externe. Le point marqué ⚠️ reste à trancher (repo de code) — une recommandation est proposée en attendant ta décision finale.* \ No newline at end of file diff --git a/docs/decisions.md b/docs/decisions.md index 52a9a20..c8476a7 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -17,3 +17,45 @@ par Kévin. compris si l'organisateur a déjà téléchargé le ZIP d'export des photos. - **Hébergement** : utilisation du **VPS existant de Kévin** (148.230.114.175), pas de nouvel hébergement dédié pour l'instant. + +## 2026-09-16 + +- **Compression image côté client** : utilisation de la lib + **`browser-image-compression`**, plutôt qu'une implémentation maison via + Canvas API. Doit gérer l'orientation EXIF et les formats HEIC (iPhone). + +## 2026-09-15 (bis) + +- **Préfixage des tables** : le projet Supabase de photobooth-qr sera + mutualisé avec les autres projets de Kévin (KOMI, KAZA...). Tables + préfixées `pq_` (`pq_events`, `pq_photos`), conformément à la convention + du CLAUDE.md global. Les fonctions Postgres exposées au front + (`get_public_event`, `event_accepts_uploads`) restent sans préfixe : ce + sont des points de contrat public, pas des tables internes. +- **Bucket Supabase Storage `event-photos`** : bucket **privé** (pas de + lecture publique). Convention de chemin : `{event_id}/{uuid}.{ext}`. + Consultation admin exclusivement via URLs signées générées par l'Edge + Function `service_role`. Aucune policy SELECT/UPDATE/DELETE publique sur + `storage.objects`. Limité à `file_size_limit = 6 Mo` et + `allowed_mime_types = image/jpeg` (aligné sur la sortie de compression + du front, qui force tout en JPEG y compris les HEIC convertis). +- **`pq_photos.url_storage`** : contrainte `CHECK` garantissant que le + chemin commence par `event_id/`, pour empêcher qu'une ligne de l'event A + pointe vers un fichier de l'event B (isolation multi-tenant), trouvé lors + de la relecture sécurité du 2026-09-15. +- **`get_public_event`** : renvoie l'événement même si son statut n'est pas + `actif` (expiré/archivé), pour permettre au front d'afficher un message + adapté. Risque jugé faible : les slugs sont partagés via QR code, donc + non secrets par nature. + +## 2026-09-16 (bis) + +- **Déclenchement de la purge Storage à J+7** : **option n8n retenue** + (plutôt que `pg_cron`+`pg_net`). Un workflow n8n planifié quotidien + appelle l'Edge Function `expire-events` avec la `service_role` key + (Credentials Store n8n), avec notification Telegram en cas d'échec. + Cohérent avec la stack n8n déjà utilisée pour KOMI/KAZA, et le VPS n8n + existant sert de point d'orchestration commun. Conception du workflow : + `n8n/workflows/purge-storage-j7.json` (JSON importable, **non testé sur + l'instance n8n réelle**, credentials à relier manuellement avant + activation). diff --git a/n8n/workflows/purge-storage-j7.json b/n8n/workflows/purge-storage-j7.json new file mode 100644 index 0000000..ec0b741 --- /dev/null +++ b/n8n/workflows/purge-storage-j7.json @@ -0,0 +1,171 @@ +{ + "name": "photobooth-qr — Purge Storage J+7", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "cronExpression", + "expression": "30 3 * * *" + } + ] + } + }, + "id": "a1a1a1a1-0000-4000-8000-000000000001", + "name": "Cron quotidien 03h30 UTC", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ -400, 0 ] + }, + { + "parameters": { + "content": "## photobooth-qr — Purge Storage J+7\n\n**Pourquoi 03h30 et pas 03h00 ?** Le job `public.expire_events()` (pg_cron, côté Supabase, projet `kevin-lecou-hub`) tourne à 03h00 UTC et marque les events expirés (`statut = 'expire'`) + supprime les lignes `photobooth.photos`. Ce workflow tourne 30 min plus tard pour laisser le temps à ce job SQL de terminer avant d'aller nettoyer les fichiers réels dans Storage.\n\n**Input** : aucun (le trigger est un cron, pas de payload).\n\n**Ce que fait l'Edge Function `expire-events`** : elle liste elle-même les events `statut = 'expire'` côté Supabase (via `public.get_expired_event_ids()`) et purge leur dossier `{event_id}/` dans le bucket `event-photos`. Ce workflow n'a donc rien à lui passer — il se contente de l'appeler et de vérifier que ça s'est bien passé.\n\n**Credential requis** : `Supabase service_role (photobooth-qr)` — credential type Header Auth, header **`Authorization`**, valeur `Bearer `. Seul ce header est nécessaire (pas de header `apikey` — vérifié en test réel le 2026-09-16, `Authorization` seul suffit). ⚠️ Utiliser la clé au **nouveau format `sb_secret_...`** (Dashboard Supabase → kevin-lecou-hub → Settings → API Keys), PAS l'ancien JWT `service_role` legacy — les deux sont visibles dans le dashboard mais seule la valeur `sb_secret_...` est celle injectée par la plateforme et vérifiée côté Edge Function. À créer dans le Credentials Store n8n, jamais en dur dans le workflow.\n\n**Statut** : Edge Function déployée et testée en conditions réelles le 2026-09-16 (upload, purge, idempotence — tout fonctionne). Il reste à créer le credential ci-dessus dans n8n et à activer ce workflow (`active: false` actuellement).\n\n**Sortie / échec** : en cas d'échec (HTTP != 200, ou erreur réseau), notification Telegram immédiate — aucune erreur ne doit passer silencieusement, cf. règle globale CLAUDE.md.", + "height": 460, + "width": 460 + }, + "id": "a1a1a1a1-0000-4000-8000-000000000099", + "name": "Sticky Note — Doc workflow", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ -400, -520 ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.SUPABASE_URL }}/functions/v1/expire-events", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "httpHeaderAuth", + "options": { + "timeout": 120000 + } + }, + "id": "a1a1a1a1-0000-4000-8000-000000000002", + "name": "Appel Edge Function expire-events", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ -160, 0 ], + "credentials": { + "httpHeaderAuth": { + "id": "REMPLACER_PAR_ID_CREDENTIAL_N8N", + "name": "Supabase service_role (photobooth-qr)" + } + }, + "onError": "continueErrorOutput" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "cond-1", + "leftValue": "={{ $json.statusCode }}", + "rightValue": 200, + "operator": { + "type": "number", + "operation": "equals" + } + } + ], + "combinator": "and" + } + }, + "id": "a1a1a1a1-0000-4000-8000-000000000003", + "name": "Purge réussie ?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ 80, 0 ] + }, + { + "parameters": { + "chatId": "={{ $env.TELEGRAM_CHAT_ID_KEVIN }}", + "text": "=⚠️ *photobooth-qr — échec purge Storage J+7*\n\nL'appel à l'Edge Function `expire-events` a échoué.\n\nStatut HTTP : {{ $json.statusCode || 'inconnu (erreur réseau)' }}\nDétail : {{ $json.error?.message || $json.body || 'voir logs n8n' }}\n\n➡️ Les fichiers expirés ne sont PAS supprimés tant que ce n'est pas corrigé — vérifier manuellement le bucket `event-photos` si le problème persiste plusieurs jours (engagement RGPD J+7).", + "additionalFields": { + "parse_mode": "Markdown" + } + }, + "id": "a1a1a1a1-0000-4000-8000-000000000004", + "name": "Telegram → Erreur purge Storage", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1.2, + "position": [ 340, 120 ], + "credentials": { + "telegramApi": { + "id": "REMPLACER_PAR_ID_CREDENTIAL_N8N", + "name": "Telegram Bot Kevin" + } + } + }, + { + "parameters": { + "chatId": "={{ $env.TELEGRAM_CHAT_ID_KEVIN }}", + "text": "=⚠️ *photobooth-qr — échec purge Storage J+7 (erreur réseau/timeout)*\n\nL'appel HTTP vers `expire-events` n'a même pas abouti (timeout ou erreur réseau).\n\n➡️ Vérifier que l'Edge Function est bien déployée et que le VPS/Supabase sont accessibles.", + "additionalFields": { + "parse_mode": "Markdown" + } + }, + "id": "a1a1a1a1-0000-4000-8000-000000000005", + "name": "Telegram → Erreur réseau", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1.2, + "position": [ -160, 240 ], + "credentials": { + "telegramApi": { + "id": "REMPLACER_PAR_ID_CREDENTIAL_N8N", + "name": "Telegram Bot Kevin" + } + } + }, + { + "parameters": {}, + "id": "a1a1a1a1-0000-4000-8000-000000000006", + "name": "OK (pas de notif)", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ 340, -100 ] + } + ], + "connections": { + "Cron quotidien 03h30 UTC": { + "main": [ + [ + { "node": "Appel Edge Function expire-events", "type": "main", "index": 0 } + ] + ] + }, + "Appel Edge Function expire-events": { + "main": [ + [ + { "node": "Purge réussie ?", "type": "main", "index": 0 } + ] + ], + "error": [ + [ + { "node": "Telegram → Erreur réseau", "type": "main", "index": 0 } + ] + ] + }, + "Purge réussie ?": { + "main": [ + [ + { "node": "OK (pas de notif)", "type": "main", "index": 0 } + ], + [ + { "node": "Telegram → Erreur purge Storage", "type": "main", "index": 0 } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "meta": { + "instructions": "Workflow conçu hors-ligne (pas de connexion à l'instance n8n réelle) — à relire et adapter dans l'éditeur n8n avant activation : (1) lier les credentials 'Supabase service_role (photobooth-qr)' et 'Telegram Bot Kevin' existants ou à créer, (2) vérifier les noms d'env vars $env.SUPABASE_URL et $env.TELEGRAM_CHAT_ID_KEVIN dans la config n8n du VPS, (3) laisser 'active: false' tant que l'Edge Function expire-events n'a pas été testée en réel." + } +} diff --git a/src/admin/gallery/config.example.js b/src/admin/gallery/config.example.js new file mode 100644 index 0000000..42083b0 --- /dev/null +++ b/src/admin/gallery/config.example.js @@ -0,0 +1,19 @@ +// Copier ce fichier en config.js (non commité, voir .gitignore) et renseigner +// les vraies valeurs. Ce fichier est chargé avant gallery.js dans index.html. +// +// SUPABASE_URL sert uniquement à construire l'URL de l'Edge Function +// "admin-gallery" (https:///functions/v1/admin-gallery). +// Le contrôle d'accès à la galerie repose sur le slug + le token secret +// transmis dans l'URL de la page (/admin/?token=...), PAS sur une clé +// Supabase : aucune clé n'est donc requise en théorie. +window.__PHOTOBOOTH_CONFIG__ = { + SUPABASE_URL: "https://xxxxxxxxxxxx.supabase.co", + + // Requis. La fonction "admin-gallery" est déployée avec verify_jwt=true + // (vérifié en conditions réelles le 2026-09-16) : la gateway Supabase + // exige un header apikey/Authorization valide en plus du slug+token. + // La clé publique (anon/publishable) suffit ici — le contrôle d'accès + // réel à la galerie reste entièrement basé sur le slug + admin_token, + // cette clé ne fait que satisfaire la vérification JWT de la gateway. + SUPABASE_ANON_KEY: "sb_publishable_xxxxxxxxxxxxxxxxxxxxxxxx", +}; diff --git a/src/admin/gallery/gallery.js b/src/admin/gallery/gallery.js new file mode 100644 index 0000000..2b203e4 --- /dev/null +++ b/src/admin/gallery/gallery.js @@ -0,0 +1,469 @@ +// Page galerie admin — photobooth-qr +// +// Aucune dépendance framework, aucune lib tierce. Accès contrôlé par un +// slug (lu dans le chemin /admin/) et un token secret (lu dans le +// query param ?token=..., voir docs/decisions.md : "accès admin via token +// secret dans l'URL, pas de login"). L'un ou l'autre invalide -> message +// générique, on ne distingue jamais "slug inconnu" de "token invalide" +// côté client (cf. contrat de l'Edge Function). +// +// Contrat backend attendu (Edge Function "admin-gallery", implémentée en +// parallèle par un autre agent — voir rapport de tâche pour le détail des +// points d'ambiguïté identifiés) : +// GET {SUPABASE_URL}/functions/v1/admin-gallery?slug=&token= +// -> 200 { event: { nom, date_evenement, statut, photo_count }, +// photos: [ { id, nom_invite, message, uploaded_at, +// signed_url, expires_in } ] } +// -> 403/404 générique si slug/token invalide (pas de distinction) +// GET {...}&action=zip +// -> 200 application/zip (binaire, toutes les photos de l'événement) + +// --------------------------------------------------------------------------- +// Éléments DOM +// --------------------------------------------------------------------------- + +const eventNameEl = document.getElementById("event-name"); +const eventMetaEl = document.getElementById("event-meta"); +const loadingBlock = document.getElementById("status-loading"); +const errorBlock = document.getElementById("status-error"); +const errorMessageEl = document.getElementById("status-error-message"); +const retryBtn = document.getElementById("retry-btn"); +const contentBlock = document.getElementById("gallery-content"); +const statusBanner = document.getElementById("status-banner"); +const refreshBtn = document.getElementById("refresh-btn"); +const zipBtn = document.getElementById("download-zip-btn"); +const zipStatusEl = document.getElementById("zip-status"); +const emptyStateEl = document.getElementById("empty-state"); +const photoGridEl = document.getElementById("photo-grid"); + +// --------------------------------------------------------------------------- +// État +// --------------------------------------------------------------------------- + +const state = { + slug: null, + token: null, + event: null, + photos: [], + loadedAt: 0, // Date.now() au moment de la dernière réponse reçue + lastRetryAction: null, + isZipping: false, +}; + +// --------------------------------------------------------------------------- +// Erreurs applicatives : un code technique -> un message clair pour l'admin +// --------------------------------------------------------------------------- + +const ERROR_MESSAGES = { + "config-missing": + "Cette page n'est pas configurée correctement. Contacte le développeur du service.", + "link-invalid": + "Ce lien est invalide ou a expiré. Vérifie l'URL utilisée, ou redemande un lien d'accès.", + network: + "La connexion a été coupée. Vérifie ta connexion internet et réessaie.", + "server-error": + "Le service est momentanément indisponible. Réessaie dans quelques instants.", + unknown: "Une erreur inattendue est survenue. Réessaie, ou reviens un peu plus tard.", +}; + +const RETRYABLE_CODES = new Set(["network", "server-error", "unknown"]); + +function appError(code, cause) { + const err = new Error(code); + err.code = code; + err.cause = cause; + return err; +} + +function normalizeError(err) { + if (err && err.code && ERROR_MESSAGES[err.code]) return err; + if (err instanceof TypeError) return appError("network", err); // fetch() qui échoue = coupure réseau + return appError("unknown", err); +} + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +function getConfig() { + const cfg = window.__PHOTOBOOTH_CONFIG__; + if (!cfg || !cfg.SUPABASE_URL) { + throw appError("config-missing"); + } + return cfg; +} + +// --------------------------------------------------------------------------- +// Lecture slug (chemin) + token (query param) +// --------------------------------------------------------------------------- + +function getSlugFromPath() { + const match = window.location.pathname.match(/\/admin\/([^/]+)\/?$/); + return match ? decodeURIComponent(match[1]) : null; +} + +function getTokenFromQuery() { + return new URLSearchParams(window.location.search).get("token"); +} + +// --------------------------------------------------------------------------- +// Affichage des états +// --------------------------------------------------------------------------- + +function showLoading() { + loadingBlock.hidden = false; + errorBlock.hidden = true; + contentBlock.hidden = true; +} + +function showError(rawErr, retryAction) { + const err = normalizeError(rawErr); + loadingBlock.hidden = true; + contentBlock.hidden = true; + errorBlock.hidden = false; + errorMessageEl.textContent = ERROR_MESSAGES[err.code] || ERROR_MESSAGES.unknown; + + state.lastRetryAction = retryAction || null; + const canRetry = Boolean(retryAction) && RETRYABLE_CODES.has(err.code); + retryBtn.hidden = !canRetry; + + if (err.cause) { + console.error(`[gallery:${err.code}]`, err.cause); + } +} + +const STATUT_LABELS = { + actif: "Actif", + expire: "Expiré", + archive: "Archivé", +}; + +function formatDateFr(dateStr) { + if (!dateStr) return null; + // "date" Postgres arrive en "YYYY-MM-DD" : on force un parsing en heure + // locale pour éviter un décalage de jour selon le fuseau du navigateur. + const d = new Date(`${dateStr}T00:00:00`); + if (Number.isNaN(d.getTime())) return null; + return d.toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" }); +} + +function formatDateTimeFr(dateStr) { + if (!dateStr) return null; + const d = new Date(dateStr); + if (Number.isNaN(d.getTime())) return null; + return d.toLocaleString("fr-FR", { + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + }); +} + +function renderHeader(event) { + eventNameEl.textContent = event.nom || "Galerie événement"; + document.title = `${event.nom || "Galerie événement"} — admin`; + + const parts = []; + const dateFr = formatDateFr(event.date_evenement); + if (dateFr) parts.push(dateFr); + const count = Number.isFinite(event.photo_count) ? event.photo_count : state.photos.length; + parts.push(`${count} photo${count > 1 ? "s" : ""}`); + const statutLabel = STATUT_LABELS[event.statut] || event.statut || ""; + if (statutLabel) parts.push(statutLabel); + + eventMetaEl.textContent = parts.join(" · "); + eventMetaEl.hidden = parts.length === 0; +} + +function renderStatusBanner(event) { + if (event.statut === "expire") { + statusBanner.textContent = + "Cet événement est expiré : les photos ne sont plus disponibles (suppression automatique après la période de conservation)."; + statusBanner.hidden = false; + } else if (event.statut === "archive") { + statusBanner.textContent = "Cet événement est archivé. Les photos ne sont plus disponibles."; + statusBanner.hidden = false; + } else { + statusBanner.hidden = true; + } +} + +function renderEmptyState(event) { + if (state.photos.length > 0) { + emptyStateEl.hidden = true; + return; + } + emptyStateEl.hidden = false; + emptyStateEl.textContent = + event.statut === "actif" + ? "Aucune photo n'a encore été déposée pour cet événement." + : "Il n'y a plus de photo disponible pour cet événement."; +} + +function photoExpiryTimestamp(photo) { + if (!Number.isFinite(photo.expires_in)) return null; + return state.loadedAt + photo.expires_in * 1000; +} + +function isPhotoLinkLikelyExpired(photo) { + const expiresAt = photoExpiryTimestamp(photo); + return expiresAt !== null && Date.now() >= expiresAt; +} + +function renderPhotoGrid() { + photoGridEl.textContent = ""; + + for (const photo of state.photos) { + const li = document.createElement("li"); + li.className = "photo-card"; + + const thumbWrap = document.createElement("div"); + thumbWrap.className = "photo-card__thumb-wrap"; + + const img = document.createElement("img"); + img.className = "photo-card__thumb"; + img.loading = "lazy"; + img.alt = photo.nom_invite + ? `Photo envoyée par ${photo.nom_invite}` + : "Photo envoyée par un invité"; + img.src = photo.signed_url; + img.onerror = () => { + li.classList.add("photo-card--broken"); + }; + + const fallback = document.createElement("div"); + fallback.className = "photo-card__thumb-fallback"; + fallback.textContent = "Aperçu indisponible (lien peut-être expiré — clique sur Actualiser)"; + + thumbWrap.append(img, fallback); + + const meta = document.createElement("div"); + meta.className = "photo-card__meta"; + + if (photo.nom_invite) { + const nameEl = document.createElement("p"); + nameEl.className = "photo-card__name"; + nameEl.textContent = photo.nom_invite; + meta.appendChild(nameEl); + } + + if (photo.message) { + const msgEl = document.createElement("p"); + msgEl.className = "photo-card__message"; + msgEl.textContent = photo.message; + meta.appendChild(msgEl); + } + + const dateFr = formatDateTimeFr(photo.uploaded_at); + if (dateFr) { + const dateEl = document.createElement("p"); + dateEl.className = "photo-card__date"; + dateEl.textContent = dateFr; + meta.appendChild(dateEl); + } + + const downloadLink = document.createElement("a"); + downloadLink.className = "photo-card__download"; + downloadLink.href = photo.signed_url; + downloadLink.target = "_blank"; + downloadLink.rel = "noopener"; + // Indication pour les navigateurs same-origin ; pour une URL signée + // cross-origin (cas normal ici), l'attribut "download" est ignoré par + // la plupart des navigateurs, qui ouvrent l'image dans un nouvel onglet + // à la place (limitation connue, acceptée pour éviter un fetch+blob par + // photo qui multiplierait les requêtes réseau). + downloadLink.download = ""; + downloadLink.textContent = "Télécharger"; + downloadLink.addEventListener("click", (evt) => { + if (isPhotoLinkLikelyExpired(photo)) { + evt.preventDefault(); + zipStatusEl.hidden = false; + zipStatusEl.className = "zip-status zip-status--error"; + zipStatusEl.textContent = + "Ce lien a probablement expiré. Clique sur \"Actualiser\" puis réessaie."; + } + }); + + li.append(thumbWrap, meta, downloadLink); + photoGridEl.appendChild(li); + } +} + +function renderContent(event, photos) { + state.event = event; + state.photos = Array.isArray(photos) ? photos : []; + + loadingBlock.hidden = true; + errorBlock.hidden = true; + contentBlock.hidden = false; + + renderHeader(event); + renderStatusBanner(event); + renderEmptyState(event); + renderPhotoGrid(); + + const hasPhotos = state.photos.length > 0; + zipBtn.disabled = !hasPhotos; + zipBtn.title = hasPhotos ? "" : "Aucune photo à télécharger"; +} + +// --------------------------------------------------------------------------- +// Appels réseau +// --------------------------------------------------------------------------- + +function buildFunctionUrl(action) { + const { SUPABASE_URL } = getConfig(); + const url = new URL(`${SUPABASE_URL.replace(/\/+$/, "")}/functions/v1/admin-gallery`); + url.searchParams.set("slug", state.slug); + url.searchParams.set("token", state.token); + if (action) url.searchParams.set("action", action); + return url.toString(); +} + +function buildAuthHeaders() { + // SUPABASE_ANON_KEY est optionnelle (voir config.example.js) : ne + // renseignée que si l'Edge Function nécessite un header apikey/Authorization + // en plus du token applicatif. Non requis dans le cas nominal attendu + // (accès contrôlé uniquement par slug+token, verify_jwt=false côté + // Supabase) — cf. rapport de tâche pour le détail de cette hypothèse. + const cfg = window.__PHOTOBOOTH_CONFIG__ || {}; + if (!cfg.SUPABASE_ANON_KEY) return {}; + return { + apikey: cfg.SUPABASE_ANON_KEY, + Authorization: `Bearer ${cfg.SUPABASE_ANON_KEY}`, + }; +} + +async function safeReadText(res) { + try { + return await res.text(); + } catch { + return null; + } +} + +async function fetchGallery() { + let res; + try { + res = await fetch(buildFunctionUrl(), { headers: buildAuthHeaders() }); + } catch (err) { + throw appError("network", err); + } + + if (res.status === 403 || res.status === 404) throw appError("link-invalid"); + if (res.status >= 500) throw appError("server-error", await safeReadText(res)); + if (!res.ok) throw appError("unknown", await safeReadText(res)); + + const data = await res.json().catch((err) => { + throw appError("unknown", err); + }); + + if (!data || !data.event) throw appError("unknown", data); + return data; +} + +async function loadGallery() { + showLoading(); + try { + const data = await fetchGallery(); + state.loadedAt = Date.now(); + renderContent(data.event, data.photos); + } catch (err) { + showError(err, () => loadGallery()); + } +} + +function parseZipFilename(res, fallback) { + const header = res.headers.get("Content-Disposition") || ""; + const match = header.match(/filename\*?=(?:UTF-8'')?"?([^";\n]+)"?/i); + return match ? decodeURIComponent(match[1]) : fallback; +} + +function triggerBlobDownload(blob, filename) { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +async function downloadZip() { + if (state.isZipping) return; + state.isZipping = true; + zipBtn.disabled = true; + zipStatusEl.hidden = false; + zipStatusEl.className = "zip-status"; + zipStatusEl.textContent = "Préparation du ZIP…"; + + try { + let res; + try { + res = await fetch(buildFunctionUrl("zip"), { headers: buildAuthHeaders() }); + } catch (err) { + throw appError("network", err); + } + + if (res.status === 403 || res.status === 404) throw appError("link-invalid"); + if (res.status >= 500) throw appError("server-error", await safeReadText(res)); + if (!res.ok) throw appError("unknown", await safeReadText(res)); + + const blob = await res.blob(); + const filename = parseZipFilename(res, `photos-${state.slug}.zip`); + triggerBlobDownload(blob, filename); + + zipStatusEl.textContent = "Téléchargement lancé."; + } catch (err) { + const normalized = normalizeError(err); + zipStatusEl.className = "zip-status zip-status--error"; + zipStatusEl.textContent = ERROR_MESSAGES[normalized.code] || ERROR_MESSAGES.unknown; + if (normalized.cause) console.error(`[gallery:zip:${normalized.code}]`, normalized.cause); + } finally { + state.isZipping = false; + zipBtn.disabled = state.photos.length === 0; + } +} + +// --------------------------------------------------------------------------- +// Événements +// --------------------------------------------------------------------------- + +retryBtn.addEventListener("click", () => { + if (state.lastRetryAction) state.lastRetryAction(); +}); + +refreshBtn.addEventListener("click", () => { + zipStatusEl.hidden = true; + loadGallery(); +}); + +zipBtn.addEventListener("click", () => { + downloadZip(); +}); + +// --------------------------------------------------------------------------- +// Démarrage +// --------------------------------------------------------------------------- + +function init() { + try { + getConfig(); + } catch (err) { + showError(err); + return; + } + + state.slug = getSlugFromPath(); + state.token = getTokenFromQuery(); + + if (!state.slug || !state.token) { + showError(appError("link-invalid")); + return; + } + + loadGallery(); +} + +init(); diff --git a/src/admin/gallery/index.html b/src/admin/gallery/index.html new file mode 100644 index 0000000..d6fc379 --- /dev/null +++ b/src/admin/gallery/index.html @@ -0,0 +1,58 @@ + + + + + +Galerie événement — admin + + + + + +
+ + +
+

Chargement de la galerie…

+
+ + + + +
+ + + + + + diff --git a/src/admin/gallery/style.css b/src/admin/gallery/style.css new file mode 100644 index 0000000..1619927 --- /dev/null +++ b/src/admin/gallery/style.css @@ -0,0 +1,229 @@ +/* Page galerie admin — mobile-first mais utilisable au clavier/souris sur + desktop (l'admin peut consulter depuis un ordinateur), sans dépendance + externe. Mêmes tokens de couleur que la page invité (src/e/style.css) + pour rester cohérent visuellement. */ + +:root { + color-scheme: light; + --color-bg: #faf9f7; + --color-text: #2a2a28; + --color-muted: #6b6b66; + --color-accent: #8a5a44; + --color-accent-contrast: #ffffff; + --color-error-bg: #fdecea; + --color-error-text: #7a1f14; + --color-warning-bg: #fdf3e3; + --color-warning-text: #7a5a14; + --radius: 10px; + --spacing: 1rem; +} + +* { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background: var(--color-bg); + color: var(--color-text); + line-height: 1.45; +} + +.page { + max-width: 960px; + margin: 0 auto; + padding: 1.25rem 1rem 3rem; + min-height: 100dvh; +} + +.page__header { + text-align: center; + margin-bottom: 1.5rem; +} + +.page__header h1 { + font-size: 1.4rem; + margin: 0.25rem 0 0; +} + +.event-meta { + color: var(--color-muted); + font-size: 0.9rem; + margin: 0.4rem 0 0; +} + +.state-block { + text-align: center; + padding: 1.25rem 1rem; + border-radius: var(--radius); + margin-bottom: 1rem; +} + +.state-block--error { + background: var(--color-error-bg); + color: var(--color-error-text); +} + +.state-block p { + margin: 0 0 0.75rem; +} + +.state-block p:last-child { + margin-bottom: 0; +} + +.status-banner { + background: var(--color-warning-bg); + color: var(--color-warning-text); + border-radius: var(--radius); + padding: 0.85rem 1rem; + margin: 0 0 1.25rem; + font-size: 0.9rem; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin-bottom: 0.75rem; +} + +.btn { + font: inherit; + font-weight: 600; + border: none; + border-radius: var(--radius); + padding: 0.75rem 1rem; + min-height: 44px; + cursor: pointer; + background: var(--color-accent); + color: var(--color-accent-contrast); +} + +.btn--secondary { + background: #fff; + color: var(--color-accent); + border: 1px solid var(--color-accent); +} + +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.btn:active:not(:disabled) { + transform: translateY(1px); +} + +.btn:focus-visible, +a:focus-visible { + outline: 3px solid var(--color-accent); + outline-offset: 1px; +} + +.zip-status { + font-size: 0.85rem; + color: var(--color-muted); + margin: 0 0 1.25rem; +} + +.zip-status--error { + color: var(--color-error-text); +} + +.photo-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 0.9rem; +} + +.photo-card { + background: #fff; + border-radius: var(--radius); + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.photo-card__thumb-wrap { + position: relative; + width: 100%; + aspect-ratio: 1 / 1; + background: #ececec; +} + +.photo-card__thumb { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.photo-card__thumb-fallback { + position: absolute; + inset: 0; + display: none; + align-items: center; + justify-content: center; + text-align: center; + font-size: 0.75rem; + color: var(--color-muted); + padding: 0.5rem; +} + +.photo-card--broken .photo-card__thumb { + display: none; +} + +.photo-card--broken .photo-card__thumb-fallback { + display: flex; +} + +.photo-card__meta { + padding: 0.6rem 0.7rem 0.5rem; + flex: 1; +} + +.photo-card__name { + font-weight: 600; + font-size: 0.85rem; + margin: 0 0 0.2rem; + word-break: break-word; +} + +.photo-card__message { + color: var(--color-text); + font-size: 0.8rem; + margin: 0 0 0.35rem; + word-break: break-word; +} + +.photo-card__date { + color: var(--color-muted); + font-size: 0.72rem; + margin: 0; +} + +.photo-card__download { + display: block; + text-align: center; + padding: 0.55rem; + background: var(--color-accent); + color: var(--color-accent-contrast); + text-decoration: none; + font-size: 0.82rem; + font-weight: 600; +} + +.photo-card__download:hover { + filter: brightness(1.05); +} diff --git a/src/admin/qrcode/index.html b/src/admin/qrcode/index.html new file mode 100644 index 0000000..36797f2 --- /dev/null +++ b/src/admin/qrcode/index.html @@ -0,0 +1,218 @@ + + + + + +Générateur de QR code — photobooth-qr (admin) + + + + +

Générateur de QR code — événement

+

+ Outil admin autonome : saisir le slug d'un événement et le domaine de + l'app pour générer et exporter le QR code (PNG / SVG) à imprimer. + Ne fait aucun appel à Supabase. +

+ + + + + + + + + + + + + + +
+ + +
+ + + + diff --git a/src/admin/qrcode/qrcode.js b/src/admin/qrcode/qrcode.js new file mode 100644 index 0000000..1f45f7b --- /dev/null +++ b/src/admin/qrcode/qrcode.js @@ -0,0 +1,280 @@ +/** + * Génération de QR code pour un événement photobooth-qr. + * + * Module autonome : ne dépend d'aucun appel Supabase / base de données. + * Le `slug` de l'événement est fourni en paramètre par l'appelant (page + * admin, script de création d'événement, etc.). + * + * Lib utilisée : `qrcode-generator` (Kazuhiko Arase, MIT), vendorée telle + * quelle dans `src/vendor/qrcode-generator.mjs`. Choisie plutôt que + * `browser-image-compression`-style bundle plus lourd car : + * - zéro dépendance, un seul fichier, ~50 Ko non minifié ; + * - fonctionne aussi bien côté navigateur que côté Node (pas de DOM requis + * pour calculer la matrice ou produire le SVG) ; + * - fournit déjà un export SVG vectoriel natif, idéal pour l'impression. + * + * Ce module expose : + * - des fonctions pures (buildEventUrl, generateEventQrSvg, getEventQrMatrix) + * utilisables sans navigateur ; + * - des fonctions qui nécessitent un navigateur (canvas, Blob, URL, DOM) + * pour l'export PNG et le déclenchement de téléchargement. + */ + +import { qrcode as createQrCodeMatrix } from '../../vendor/qrcode-generator.mjs'; + +/** + * Domaine de secours utilisé pour construire l'URL publique de l'événement + * si aucun `baseUrl` n'est fourni à l'appel ET qu'aucune config globale + * n'est disponible. À ne PAS considérer comme le domaine réel de + * production. + * + * Résolution du domaine (par ordre de priorité) : + * 1. paramètre explicite `baseUrl` passé à la fonction ; + * 2. `window.__PHOTOBOOTH_CONFIG__.APP_BASE_URL` si défini — même + * convention de config que la page invité (voir `src/e/config.example.js`, + * chargée via un `config.js` non commité) ; + * 3. cette constante de secours. + * + * Volontairement pas de lecture directe de `process.env`/`import.meta.env` : + * ce module doit rester utilisable tel quel dans un simple + * ` + + + diff --git a/src/e/style.css b/src/e/style.css new file mode 100644 index 0000000..458d133 --- /dev/null +++ b/src/e/style.css @@ -0,0 +1,180 @@ +/* Page upload invité — mobile-first, sobre, sans dépendance externe */ + +:root { + color-scheme: light; + --color-bg: #faf9f7; + --color-text: #2a2a28; + --color-muted: #6b6b66; + --color-accent: #8a5a44; + --color-accent-contrast: #ffffff; + --color-error-bg: #fdecea; + --color-error-text: #7a1f14; + --color-success-bg: #eaf5ec; + --color-success-text: #1e5c2d; + --radius: 10px; + --spacing: 1rem; +} + +* { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background: var(--color-bg); + color: var(--color-text); + line-height: 1.45; +} + +.page { + max-width: 480px; + margin: 0 auto; + padding: 1.25rem 1rem 3rem; + min-height: 100dvh; +} + +.page__header { + text-align: center; + margin-bottom: 1.5rem; +} + +.page__header h1 { + font-size: 1.4rem; + margin: 0.25rem 0 0; +} + +.state-block { + text-align: center; + padding: 1.25rem 1rem; + border-radius: var(--radius); + margin-bottom: 1rem; +} + +.state-block--error { + background: var(--color-error-bg); + color: var(--color-error-text); +} + +.state-block--success { + background: var(--color-success-bg); + color: var(--color-success-text); +} + +.state-block p { + margin: 0 0 0.75rem; +} + +.state-block p:last-child { + margin-bottom: 0; +} + +.upload-form { + display: flex; + flex-direction: column; + gap: 1.1rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.field label { + font-weight: 600; + font-size: 0.95rem; +} + +.field .optional { + font-weight: 400; + color: var(--color-muted); + font-size: 0.85rem; +} + +input[type="text"], +input[type="file"], +textarea { + font: inherit; + padding: 0.7rem 0.75rem; + border: 1px solid #d8d5cf; + border-radius: var(--radius); + background: #fff; + color: inherit; + width: 100%; +} + +input[type="file"] { + padding: 0.55rem 0.5rem; +} + +textarea { + resize: vertical; + min-height: 4.5rem; +} + +input:focus-visible, +textarea:focus-visible, +button:focus-visible { + outline: 3px solid var(--color-accent); + outline-offset: 1px; +} + +.preview { + width: 100%; + max-height: 320px; + object-fit: contain; + border-radius: var(--radius); + background: #eee; +} + +.legal-notice { + font-size: 0.8rem; + color: var(--color-muted); + background: #f1efe9; + border-radius: var(--radius); + padding: 0.75rem; + margin: 0; +} + +.progress-text { + min-height: 1.2em; + font-size: 0.9rem; + color: var(--color-muted); + margin: 0; + text-align: center; +} + +button { + font: inherit; + font-weight: 600; + border: none; + border-radius: var(--radius); + padding: 0.85rem 1rem; + min-height: 48px; + cursor: pointer; +} + +#submit-btn, +#add-another-btn { + background: var(--color-accent); + color: var(--color-accent-contrast); +} + +#submit-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +#retry-btn { + background: var(--color-error-text); + color: #fff; +} + +button:active { + transform: translateY(1px); +} diff --git a/src/e/upload.js b/src/e/upload.js new file mode 100644 index 0000000..72e4507 --- /dev/null +++ b/src/e/upload.js @@ -0,0 +1,503 @@ +// Page upload invité — photobooth-qr +// +// Aucune dépendance framework. Une seule lib tierce chargée systématiquement +// (browser-image-compression, décision actée dans docs/decisions.md), plus +// heic2any chargée UNIQUEMENT si un fichier HEIC/HEIF est détecté (lazy load, +// pour ne pas alourdir la page pour les invités qui n'en ont pas besoin). +// +// Contrat backend (schéma `photobooth`, privé, jamais exposé via PostgREST — +// cf. supabase/migrations/20260916090000_create_photobooth_schema.sql) : +// - RPC "get_public_event(p_slug text)" (POST .../rpc/get_public_event) : +// { id, nom, statut, expire_at } (ou null) SANS exposer admin_token. +// - RPC "insert_public_photo(p_event_id, p_url_storage, p_nom_invite, +// p_message)" (POST .../rpc/insert_public_photo) : seul chemin d'écriture, +// revalide elle-même que l'event accepte encore les uploads. + +import imageCompression from "../vendor/browser-image-compression.mjs"; + +const MAX_INPUT_BYTES = 15 * 1024 * 1024; // 15 Mo avant compression (cahier des charges) +const MAX_OUTPUT_BYTES = 6 * 1024 * 1024; // garde-fou après compression (cible ~2-3 Mo) + +const COMPRESSION_OPTIONS = { + maxSizeMB: 2, + maxWidthOrHeight: 1920, + useWebWorker: true, + fileType: "image/jpeg", + initialQuality: 0.8, + preserveExif: false, // on ne conserve pas les métadonnées (GPS, etc.) — minimisation RGPD +}; + +// --------------------------------------------------------------------------- +// Éléments DOM +// --------------------------------------------------------------------------- + +const eventNameEl = document.getElementById("event-name"); +const loadingBlock = document.getElementById("status-loading"); +const errorBlock = document.getElementById("status-error"); +const errorMessageEl = document.getElementById("status-error-message"); +const retryBtn = document.getElementById("retry-btn"); +const successBlock = document.getElementById("success-block"); +const addAnotherBtn = document.getElementById("add-another-btn"); +const form = document.getElementById("upload-form"); +const photoInput = document.getElementById("photo-input"); +const previewImg = document.getElementById("preview"); +const nomInput = document.getElementById("nom-input"); +const messageInput = document.getElementById("message-input"); +const progressText = document.getElementById("progress-text"); +const submitBtn = document.getElementById("submit-btn"); + +// --------------------------------------------------------------------------- +// État +// --------------------------------------------------------------------------- + +const state = { + event: null, + selectedFile: null, + compressedBlob: null, + isSubmitting: false, + lastRetryAction: null, +}; + +// --------------------------------------------------------------------------- +// Erreurs applicatives : un code technique -> un message clair pour l'invité +// --------------------------------------------------------------------------- + +const ERROR_MESSAGES = { + "config-missing": + "Cette page n'est pas configurée correctement. Préviens l'organisateur de l'événement.", + "event-not-found": + "Cet événement n'existe pas, ou le lien utilisé est incorrect. Vérifie le QR code ou le lien.", + "event-closed": + "Cet événement n'accepte plus de photos (il est clos). Merci quand même d'avoir essayé !", + "file-missing": "Choisis d'abord une photo avant d'envoyer.", + "file-not-image": + "Ce fichier n'est pas une photo. Choisis une image (JPEG, PNG, HEIC…).", + "file-too-large": + "Cette photo est trop lourde (plus de 15 Mo). Essaie avec une autre photo.", + "heic-load-failed": + "Impossible de préparer la conversion de cette photo iPhone (HEIC). Vérifie ta connexion et réessaie, ou choisis une autre photo.", + "heic-convert-failed": + "Impossible de lire cette photo au format HEIC. Essaie de changer le format des photos dans les réglages de ton iPhone (Réglages > Appareil photo > Formats > « Le plus compatible »), ou choisis une autre photo.", + "compression-failed": + "La préparation de la photo a échoué. Réessaie, ou choisis une autre photo.", + "compressed-too-large": + "Cette photo reste trop lourde même après compression. Essaie une autre photo.", + network: + "La connexion a été coupée pendant l'envoi. Vérifie ta connexion internet et réessaie.", + "storage-upload-failed": + "L'envoi de la photo a échoué. Réessaie dans un instant.", + "db-insert-failed": + "Ta photo a bien été transmise, mais une erreur est survenue à la dernière étape. Réessaie, ou préviens l'organisateur si ça persiste.", + "server-overloaded": + "Le service est momentanément surchargé. Réessaie dans quelques minutes.", + unknown: "Une erreur inattendue est survenue. Réessaie, ou reviens un peu plus tard.", +}; + +// Codes pour lesquels on propose un bouton "Réessayer" (échecs transitoires, +// pas la peine pour un fichier invalide ou un événement clos). +const RETRYABLE_CODES = new Set([ + "network", + "storage-upload-failed", + "db-insert-failed", + "server-overloaded", + "compression-failed", + "heic-load-failed", + "heic-convert-failed", +]); + +function appError(code, cause) { + const err = new Error(code); + err.code = code; + err.cause = cause; + return err; +} + +function normalizeError(err) { + if (err && err.code && ERROR_MESSAGES[err.code]) return err; + if (err instanceof TypeError) return appError("network", err); // fetch() qui échoue = coupure réseau + return appError("unknown", err); +} + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +function getConfig() { + const cfg = window.__PHOTOBOOTH_CONFIG__; + if (!cfg || !cfg.SUPABASE_URL || !cfg.SUPABASE_ANON_KEY || !cfg.SUPABASE_STORAGE_BUCKET) { + throw appError("config-missing"); + } + return cfg; +} + +// --------------------------------------------------------------------------- +// Utilitaires +// --------------------------------------------------------------------------- + +function generateId() { + if (window.crypto && typeof window.crypto.randomUUID === "function") { + return window.crypto.randomUUID(); + } + // Repli simple pour de très vieux navigateurs (pas un usage cryptographique ici) + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function getSlugFromPath() { + const match = window.location.pathname.match(/\/e\/([^/]+)\/?$/); + return match ? decodeURIComponent(match[1]) : null; +} + +function isHeicFile(file) { + const type = (file.type || "").toLowerCase(); + if (type === "image/heic" || type === "image/heif") return true; + return /\.hei[cf]$/i.test(file.name || ""); +} + +function isAcceptableImageFile(file) { + if (isHeicFile(file)) return true; + if (file.type && file.type.startsWith("image/")) return true; + // Sur certains navigateurs Android, un fichier HEIC peut arriver avec un + // type MIME vide ou générique : on se rabat sur l'extension. + return /\.(jpe?g|png|gif|webp|bmp)$/i.test(file.name || ""); +} + +// --------------------------------------------------------------------------- +// Affichage des états +// --------------------------------------------------------------------------- + +function showLoading() { + loadingBlock.hidden = false; + errorBlock.hidden = true; + successBlock.hidden = true; + form.hidden = true; +} + +function showForm(event) { + eventNameEl.textContent = event.nom || "Ajouter une photo"; + loadingBlock.hidden = true; + errorBlock.hidden = true; + successBlock.hidden = true; + form.hidden = false; +} + +function showError(rawErr, retryAction) { + const err = normalizeError(rawErr); + loadingBlock.hidden = true; + successBlock.hidden = true; + errorBlock.hidden = false; + errorMessageEl.textContent = ERROR_MESSAGES[err.code] || ERROR_MESSAGES.unknown; + + state.lastRetryAction = retryAction || null; + const canRetry = Boolean(retryAction) && RETRYABLE_CODES.has(err.code); + retryBtn.hidden = !canRetry; + + // Erreurs bloquantes de chargement d'événement : on masque le formulaire. + // Erreurs liées à une tentative d'envoi : le formulaire reste visible. + if (["event-not-found", "event-closed", "config-missing"].includes(err.code)) { + form.hidden = true; + } + + if (err.cause) { + // Pas de jargon technique affiché à l'invité, mais on garde une trace + // en console pour le débogage. + console.error(`[upload:${err.code}]`, err.cause); + } +} + +function showSuccess() { + loadingBlock.hidden = true; + errorBlock.hidden = true; + form.hidden = true; + successBlock.hidden = false; +} + +function setProgress(text) { + progressText.textContent = text || ""; +} + +function setSubmitting(isSubmitting) { + state.isSubmitting = isSubmitting; + submitBtn.disabled = isSubmitting; + submitBtn.textContent = isSubmitting ? "Envoi en cours…" : "Envoyer ma photo"; +} + +// --------------------------------------------------------------------------- +// Chargement de l'événement (vérifie existence + statut actif + non expiré) +// --------------------------------------------------------------------------- + +async function fetchEventPublicInfo(slug) { + const { SUPABASE_URL, SUPABASE_ANON_KEY } = getConfig(); + let res; + try { + res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/get_public_event`, { + method: "POST", + headers: { + "Content-Type": "application/json", + apikey: SUPABASE_ANON_KEY, + Authorization: `Bearer ${SUPABASE_ANON_KEY}`, + }, + body: JSON.stringify({ p_slug: slug }), + }); + } catch (err) { + throw appError("network", err); + } + + if (res.status === 404) throw appError("event-not-found"); + if (res.status >= 500) throw appError("server-overloaded"); + if (!res.ok) throw appError("unknown", await safeReadText(res)); + + const data = await res.json().catch(() => null); + const event = Array.isArray(data) ? data[0] : data; + if (!event) throw appError("event-not-found"); + return event; +} + +async function safeReadText(res) { + try { + return await res.text(); + } catch { + return null; + } +} + +function isEventActive(event) { + if (!event) return false; + if (event.statut !== "actif") return false; + if (event.expire_at && new Date(event.expire_at).getTime() <= Date.now()) return false; + return true; +} + +async function initEventCheck() { + showLoading(); + const slug = getSlugFromPath(); + if (!slug) { + showError(appError("event-not-found")); + return; + } + try { + const event = await fetchEventPublicInfo(slug); + if (!isEventActive(event)) throw appError("event-closed"); + state.event = event; + showForm(event); + } catch (err) { + showError(err, () => initEventCheck()); + } +} + +// --------------------------------------------------------------------------- +// Conversion HEIC (lazy load) + compression +// --------------------------------------------------------------------------- + +let heic2anyLoadPromise = null; + +function loadHeic2any() { + if (window.heic2any) return Promise.resolve(); + if (heic2anyLoadPromise) return heic2anyLoadPromise; + + heic2anyLoadPromise = new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = new URL("../vendor/heic2any.min.js", import.meta.url).href; + script.onload = () => resolve(); + script.onerror = () => { + heic2anyLoadPromise = null; + reject(appError("heic-load-failed")); + }; + document.head.appendChild(script); + }); + + return heic2anyLoadPromise; +} + +async function convertHeicToJpeg(file) { + await loadHeic2any(); + let resultBlob; + try { + resultBlob = await window.heic2any({ blob: file, toType: "image/jpeg", quality: 0.9 }); + } catch (err) { + throw appError("heic-convert-failed", err); + } + const blob = Array.isArray(resultBlob) ? resultBlob[0] : resultBlob; + return new File([blob], file.name.replace(/\.hei[cf]$/i, ".jpg"), { type: "image/jpeg" }); +} + +async function normalizeAndCompress(file) { + let workingFile = file; + if (isHeicFile(file)) { + setProgress("Conversion de la photo…"); + workingFile = await convertHeicToJpeg(file); + } + + setProgress("Compression de la photo…"); + let compressed; + try { + compressed = await imageCompression(workingFile, COMPRESSION_OPTIONS); + } catch (err) { + throw appError("compression-failed", err); + } + + if (compressed.size > MAX_OUTPUT_BYTES) { + throw appError("compressed-too-large"); + } + + return compressed; +} + +// --------------------------------------------------------------------------- +// Envoi vers Supabase (Storage + table photos) +// --------------------------------------------------------------------------- + +async function uploadToStorage(eventId, blob) { + const { SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_STORAGE_BUCKET } = getConfig(); + const path = `${eventId}/${generateId()}.jpg`; + + let res; + try { + res = await fetch( + `${SUPABASE_URL}/storage/v1/object/${encodeURIComponent(SUPABASE_STORAGE_BUCKET)}/${path}`, + { + method: "POST", + headers: { + apikey: SUPABASE_ANON_KEY, + Authorization: `Bearer ${SUPABASE_ANON_KEY}`, + "Content-Type": blob.type || "image/jpeg", + "x-upsert": "false", + }, + body: blob, + } + ); + } catch (err) { + throw appError("network", err); + } + + if (res.status >= 500) throw appError("server-overloaded", await safeReadText(res)); + if (!res.ok) throw appError("storage-upload-failed", await safeReadText(res)); + + return path; +} + +async function insertPhotoRow(eventId, path, nom, message) { + const { SUPABASE_URL, SUPABASE_ANON_KEY } = getConfig(); + + // photobooth.photos n'est jamais exposée directement (schéma privé, cf. + // supabase/migrations/20260916090000_create_photobooth_schema.sql) : le + // seul chemin d'écriture est la RPC public.insert_public_photo, qui + // revalide elle-même que l'event accepte encore les uploads. + let res; + try { + res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/insert_public_photo`, { + method: "POST", + headers: { + apikey: SUPABASE_ANON_KEY, + Authorization: `Bearer ${SUPABASE_ANON_KEY}`, + "Content-Type": "application/json", + Prefer: "return=minimal", + }, + body: JSON.stringify({ + p_event_id: eventId, + p_url_storage: path, + p_nom_invite: nom || null, + p_message: message || null, + }), + }); + } catch (err) { + throw appError("network", err); + } + + if (res.status >= 500) throw appError("server-overloaded", await safeReadText(res)); + if (!res.ok) throw appError("db-insert-failed", await safeReadText(res)); +} + +// --------------------------------------------------------------------------- +// Formulaire +// --------------------------------------------------------------------------- + +let previewObjectUrl = null; + +photoInput.addEventListener("change", () => { + // Nouveau fichier choisi -> on invalide toute compression déjà faite. + state.compressedBlob = null; + state.selectedFile = photoInput.files[0] || null; + + if (previewObjectUrl) { + URL.revokeObjectURL(previewObjectUrl); + previewObjectUrl = null; + } + + const file = state.selectedFile; + if (file && file.type && file.type.startsWith("image/")) { + previewObjectUrl = URL.createObjectURL(file); + previewImg.src = previewObjectUrl; + previewImg.hidden = false; + previewImg.onerror = () => { + previewImg.hidden = true; + }; + } else { + // Pas d'aperçu fiable pour les HEIC sur la plupart des navigateurs. + previewImg.hidden = true; + } +}); + +async function submitPhoto() { + if (state.isSubmitting) return; + if (!state.event) { + showError(appError("event-closed")); + return; + } + + const file = photoInput.files[0]; + if (!file) return showError(appError("file-missing")); + if (!isAcceptableImageFile(file)) return showError(appError("file-not-image")); + if (file.size > MAX_INPUT_BYTES) return showError(appError("file-too-large")); + + setSubmitting(true); + try { + let blob = state.compressedBlob; + if (!blob || state.selectedFile !== file) { + blob = await normalizeAndCompress(file); + state.compressedBlob = blob; + state.selectedFile = file; + } + + setProgress("Envoi de la photo…"); + const path = await uploadToStorage(state.event.id, blob); + + setProgress("Dernière étape…"); + await insertPhotoRow(state.event.id, path, nomInput.value.trim(), messageInput.value.trim()); + + setProgress(""); + showSuccess(); + } catch (err) { + setProgress(""); + showError(err, () => submitPhoto()); + } finally { + setSubmitting(false); + } +} + +form.addEventListener("submit", (evt) => { + evt.preventDefault(); + submitPhoto(); +}); + +retryBtn.addEventListener("click", () => { + if (state.lastRetryAction) state.lastRetryAction(); +}); + +addAnotherBtn.addEventListener("click", () => { + form.reset(); + state.selectedFile = null; + state.compressedBlob = null; + previewImg.hidden = true; + if (previewObjectUrl) { + URL.revokeObjectURL(previewObjectUrl); + previewObjectUrl = null; + } + successBlock.hidden = true; + form.hidden = false; +}); + +// --------------------------------------------------------------------------- +// Démarrage +// --------------------------------------------------------------------------- + +initEventCheck(); diff --git a/src/vendor/NOTICE.md b/src/vendor/NOTICE.md new file mode 100644 index 0000000..924f92d --- /dev/null +++ b/src/vendor/NOTICE.md @@ -0,0 +1,27 @@ +# Librairies vendorisées + +Ces fichiers sont copiés tels quels (pas de build/bundler dans ce projet) et +ne doivent pas être modifiés manuellement. Pour mettre à jour, retélécharger +depuis la source indiquée. + +## browser-image-compression + +- Version : 2.0.2 (dernière version publiée au 15/09/2026) +- Licence : MIT +- Source : `https://cdn.jsdelivr.net/npm/browser-image-compression@2.0.2/dist/browser-image-compression.mjs` +- Usage : compression + correction d'orientation EXIF, obligatoire avant tout + upload (décision actée dans `docs/decisions.md`). Chargé systématiquement + via `import` dans `src/e/upload.js`. + +## heic2any + +- Version : 0.0.4 (dernière version publiée au 15/09/2026) +- Licence : MIT +- Source : `https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js` +- Usage : conversion HEIC/HEIF (photos iPhone) vers JPEG avant compression. + Fichier volumineux (~1,3 Mo, décodeur HEIF en WASM) : **chargé uniquement à + la demande** (lazy load via injection de `