Author SHA1 Message Date
Claude 9d31935641 Rencontres passées : une fenêtre, plusieurs photos, compression automatique
Le backoffice n'avait qu'une carte étroite par archive : un récit détaillé se
tapait dans trois lignes, les photos ne pouvaient être ajoutées qu'après
publication, une par une, et un fichier de plus de 900 Ko était simplement
refusé — « compressez-la d'abord ».

- **Fenêtre d'édition** : titre, date, lieu, rencontre liée, récit (champ de
  260 px, redimensionnable), participants et photos au même endroit, création
  comprise. Glisser-déposer multiple, réordonnancement, légendes, suppression.
- **Compression dans le navigateur** : `src/lib/image-compress.ts` (pur,
  testé) porte seuils et paliers, `image-compress-dom.ts` le décodage
  (orientation EXIF comprise) et l'encodage. On ne descend en qualité que le
  nécessaire, une image déjà dans le budget n'est pas réencodée et la
  transparence n'est jamais aplatie sur du noir. `ImageField` et le dépôt de
  promotion en profitent aussi : plus aucun envoi refusé pour son poids.
- **Actions** : `savePastMeeting` (création + mise à jour, refus renvoyés au
  lieu d'être levés, rencontre liée vérifiée), `addPastMeetingPhotos` (envois
  groupés sous la limite des server actions) et `savePastMeetingPhotos`
  (ordre, légendes, suppressions, protégée par un drapeau `syncPhotos`).
- **La fenêtre attend le rafraîchissement avant de se refermer** : sans cela,
  rouvrir la fiche affichait l'état d'avant, la rencontre liée revenant à
  « Aucune » alors qu'elle avait bien été enregistrée.
- **Site public** : cartes de hauteur fixe quel que soit le texte ou le nombre
  de photos, et fenêtre de lecture avec le récit complet (sauts de ligne
  conservés) et la galerie entière — flèches, vignettes, clavier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GHFd8bXepDKhQ7kf3WvrL
2026-09-15 14:48:35 +00:00
Claude 0e340dde58 Boîte mail : accorder le port SMTP et le chiffrement
Le port et la case « connexion chiffrée » étaient deux champs indépendants
qu'il fallait accorder de tête. Passer en 587 sans décocher fait tenter une
poignée de main TLS sur un port qui répond en clair : la connexion meurt sur
un « wrong version number » que rien ne traduisait.

- `src/lib/smtp-config.ts` (pur, testé) : `impliedSecure()`, `tlsMismatch()`
  et `describeSmtpError()`, qui traduit les échecs courants — mauvaise case,
  port filtré, identifiants refusés, certificat, expéditeur rejeté — en
  consigne actionnable, l'explication avant la trace puisque l'écran tronque.
- `SmtpAccountForm` (client) réaligne la case sur le port saisi, propose les
  deux ports courants en pastilles et affiche le désaccord s'il subsiste. Le
  mot de passe enregistré ne descend pas en prop, seul un booléen le fait.
- Transport : `requireTLS` hors TLS implicite — sans lui STARTTLS reste
  opportuniste et l'identifiant partirait en clair chez un serveur qui ne
  l'annonce pas — SNI explicite, TLS 1.2 minimum, et délais ramenés à
  quelques secondes pour qu'un port bloqué en sortie rende son verdict au
  lieu de figer l'action serveur.
- `verifySmtp` referme la socket même en échec et dit sur quel port et dans
  quel mode la connexion a abouti.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RCUSintYfXnBtwFKQezFSt
2026-09-15 13:51:51 +00:00
Claude 2da125eca2 Espace adhérent : plus de barre latérale hors staff
Un adhérent voyait deux fois le même menu : la barre latérale ne portait chez
lui que la section « Côté adhérent » — Informations, Mon profil, Mes
promotions — et EspaceHeader rend déjà ces trois entrées en onglets sous le
bandeau. Un quart de l'écran pour une redite.

BackendShell branche donc sur isStaff : hors staff, il ne reste qu'un bandeau
de marque, aligné sur la colonne de contenu, et la page prend toute la
largeur. Le staff garde la barre, qui est chez lui la navigation principale et
le seul chemin vers son propre espace.

Le piège était sur téléphone : sous 1024px les actions du compte vivaient dans
le tiroir, pas dans l'entête. Sans tiroir, la déconnexion serait devenue
inatteignable — .backend-header--plain rétablit leur affichage à toutes les
largeurs, et un test le verrouille avec l'absence d'aside et de bouton de
tiroir dans la variante.

Vérifié au navigateur en 1440 et 390 px, adhérent et administrateur : aucune
barre ni bouton de tiroir côté adhérent, actions présentes aux deux largeurs,
aucun débordement horizontal ; les dix-sept entrées de l'administrateur et son
tiroir sont intacts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-15 06:47:10 +00:00
Claude cc33cf5fbc Informations : éditeur visuel à la place de la syntaxe Markdown
La barre d'outils écrivait des étoiles dans un textarea : l'adhérent qui
rédigeait devait connaître une syntaxe pour obtenir du gras. On édite
désormais comme dans un traitement de texte — on sélectionne, on clique sur
G, le texte devient gras à l'écran.

Le format stocké ne change pas d'un octet. C'est tout l'enjeu : le WYSIWYG
porte sur la saisie, pas sur le stockage. À chaque frappe, le navigateur
retraverse ce qu'il a édité (serializeToRichText) et n'en garde que le gras,
l'italique, les listes, les sous-titres et les liens ; le balisage part dans
un champ caché et le serveur le ré-analyse avec l'analyseur existant. Le HTML
de l'éditeur ne quitte jamais la page, donc aucun assainisseur HTML n'entre
dans le produit — le risque n'a jamais été la surface d'édition, mais la base.

- rich-text-dom.ts : sérialiseur pur, testable sans navigateur (MinimalNode).
  Il lit aussi le gras codé en style CSS, pour qu'un brouillon collé depuis
  Google Docs ne perde pas sa mise en forme ; le style explicite l'emporte sur
  la balise, sinon le <b style="font-weight:normal"> dont Docs enveloppe tout
  document mettrait le texte entier en gras.
- Un collage passe par DOMParser, dont le document est inerte : un <img
  onerror> collé ne s'exécute pas. Seul du HTML produit par nous est réinjecté.
- L'italique s'écrit _ainsi_ : **_x_** est lisible par l'analyseur là où ***x***
  serait ambigu. Un tiret bas au milieu d'un mot n'ouvre rien.
- saveInformation normalise les CRLF que l'encodage des formulaires introduit.

Vérifié au navigateur contre une base réelle : aller-retour identique après
enregistrement, et un collage hostile (script, img onerror, iframe, style,
lien javascript:) ressort en texte structuré sans qu'une seule alerte parte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-15 06:37:52 +00:00
Claude 90b121a3c1 Merge remote-tracking branch 'origin/main' into claude/lucid-lovelace-c5is18 2026-09-15 06:13:05 +00:00
Claude b860eeaf29 Référencement : Google Analytics et Search Console réglables en backoffice
Deux réglages dans Backend › Paramètres :

- `google_analytics_id` pose les balises gtag.js. Vide = rien n'est chargé.
  `src/lib/analytics.ts` (pur) n'accepte qu'un identifiant GA4 « G-… » : la
  saisie ne peut donc pas s'échapper du script en ligne. Les deux `<script>`
  portent le nonce de la CSP, et seules les pages publiques sont mesurées
  (`isTrackedPath` écarte `PRIVATE_PATHS`, le chemin venant du nouvel en-tête
  `x-pathname` du middleware — un layout ne connaît pas l'URL demandée).
- `google_site_verification` rend la balise de validation Search Console via
  `verification.google`, après contrôle du jeton.

La CSP gagne les hôtes Google Analytics en `connect-src` : elle est posée sur
le runtime Edge, qui ne peut pas lire la base pour savoir si la mesure est
active. `script-src` est inchangé, `'strict-dynamic'` couvrant les scripts que
gtag.js charge ensuite.

Le plan du site n'a besoin de rien : `/sitemap.xml` est déjà servi et annoncé
dans `/robots.txt`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7v5KcYUo2taDVDgehs7ZH
2026-09-15 05:12:21 +00:00
38 changed files with 3764 additions and 446 deletions

No files matched your search

+144 -16
View File
@@ -139,12 +139,14 @@ même raison.
- Les images de promo sont stockées en data-URI : l'upload se fait donc en
binaire (multipart pour Facebook, Images API en 3 étapes pour LinkedIn), pas
par URL.
- L'image part **telle quelle** : aucun recadrage ni redimensionnement côté
application. Un visuel non carré est donc recadré — ou entouré de bandes de
couleur — par la plateforme. `src/lib/image-info.ts` (pur, verrouillé par
`tests/image-info.test.ts`) décrit le fichier déposé (dimensions, format,
poids) et `MemberSpaceForm` l'affiche sous l'aperçu : on informe, on ne
bloque pas.
- **Aucun recadrage** : le rapport largeur/hauteur n'est jamais touché, un
visuel non carré est donc recadré — ou entouré de bandes de couleur — par la
plateforme. `src/lib/image-info.ts` (pur, verrouillé par
`tests/image-info.test.ts`) décrit l'image **telle qu'elle sera publiée** et
`MemberSpaceForm` l'affiche sous l'aperçu : on informe, on ne bloque pas.
- Le poids, lui, est ramené dans les clous **par le navigateur** (cf. Images)
avant l'envoi : une photo de téléphone dépassait la limite de 3 Mo du serveur
et la publication échouait sans recours depuis l'écran de l'adhérent.
- La diffusion est déclenchée **par la validation** (ou par l'échéance d'une
publication programmée), pas par un bouton :
`promotions.share_facebook` / `share_linkedin` sont choisis par l'adhérent,
@@ -168,6 +170,64 @@ même raison.
- Les URLs publiques des pages FB/LinkedIn sont des `site_settings`
(`association_facebook`, `association_linkedin`), éditables dans Paramètres.
## Images
Toute image déposée est **compressée dans le navigateur** avant de partir :
elle voyage en data-URI à travers une server action plafonnée à 4 Mo et finit
stockée en base, alors qu'une photo de téléphone pèse 4 à 12 Mo. L'ancien
réflexe — « Image trop lourde (max ~900 Ko), compressez-la d'abord » — revenait
à demander à l'utilisateur ce que le navigateur sait faire seul.
- `src/lib/image-compress.ts` est **pur** (`tests/image-compress.test.ts`) :
seuils, `scaleToFit()` (jamais d'agrandissement), `compressionSteps()` (on
épuise la qualité à définition pleine **avant** de réduire la définition),
`base64Bytes()`, `compressionSummary()` et `chunkByBytes()` pour découper un
envoi groupé sous la limite d'une requête.
- `src/lib/image-compress-dom.ts` porte `prepareImageFile()` : décodage
(orientation EXIF appliquée via `createImageBitmap`, sans quoi une photo de
téléphone repartirait couchée), canvas, encodage. Une image **déjà dans le
budget n'est pas réencodée** — un réencodage JPEG dégrade toujours un peu —
et une image **avec transparence** ne devient jamais un JPEG (WebP, sinon
PNG) : un logo se retrouverait sur fond noir.
- Budgets : `FIELD_MAX_BYTES` (1,2 Mo) pour une image de formulaire,
`PHOTO_MAX_BYTES` (800 Ko) pour une photo de galerie, `MAX_SIDE` 2048 px.
- `ImageField` et `MemberSpaceForm` passent par là et affichent ce qui a été
fait (« 4,2 Mo → 620 Ko »). Un format que le navigateur ne sait pas décoder
(HEIC d'iPhone sur un poste Windows) est le seul refus, et il est expliqué.
## Rencontres passées
Le backoffice (`/backend/rencontres-passees`) est une grille de cartes ; tout
le reste se passe dans une **fenêtre** (`PastMeetingDialog`) : titre, date,
lieu, rencontre liée, récit, participants **et photos**, y compris à la
création. Les photos ne dépendent plus d'une archive déjà publiée.
- `savePastMeeting()` crée ou met à jour, `addPastMeetingPhotos()` attache un
lot (plusieurs appels : `chunkByBytes` découpe sous la limite des server
actions et rend les identifiants insérés), `savePastMeetingPhotos()` aligne
ordre, légendes et suppressions. Cette dernière exige `syncPhotos=1` : sans
ce drapeau, un appel amputé de sa liste viderait la galerie en silence.
- Les refus attendus sont **renvoyés** (`ActionError`), jamais levés ; la
rencontre liée est vérifiée avant l'écriture, une rencontre supprimée
entre-temps donnant sinon une violation de clé étrangère sans explication.
- **La fenêtre ne se referme qu'une fois la page rafraîchie**
(`closing && !refreshing`). Sans cette attente, rouvrir la fiche montre
l'état d'avant l'enregistrement — la rencontre liée revenue à « Aucune » —
et on croit que rien n'a été retenu.
- `ModalShell` (partagé avec le site public) rend la fenêtre dans un
**portail** vers `<body>` : une carte survolée porte un `transform`, qui
redéfinit le bloc conteneur d'un `position: fixed`, et la fenêtre s'ouvrirait
*dans* la carte. Le portail la sort aussi du bouton qui l'ouvre.
- Côté public, `PastMeetingCard` rend une carte de **hauteur fixe** (424 px,
couverture + titre sur 2 lignes + 3 lignes de texte écrêtées par
`line-clamp`, d'où des classes dans `globals.css` plutôt que des styles en
ligne) : le récit complet et toutes les photos vivent dans la fenêtre, avec
galerie, flèches, vignettes et navigation au clavier. Les sauts de ligne du
récit sont conservés (`white-space: pre-line`).
- Le nombre de participants affiché vient des inscriptions **si** une rencontre
du calendrier est liée, sinon des lignes saisies à la main ; la fenêtre le
dit sous le champ pour éviter la surprise.
## Sécurité
- **Erreurs des server actions** : un `throw` dans une action est masqué par
@@ -206,7 +266,9 @@ même raison.
journal, limitation des connexions, chiffrement des secrets, coordonnées du
référent, **mots de passe temporaires jamais mis en file**, secrets de
messagerie jamais renvoyés au navigateur, aucune copie cachée dans la chaîne
d'envoi, et rien rendu en HTML brut côté informations.
d'envoi, rien rendu en HTML brut côté informations, et **le HTML de l'éditeur
visuel qui ne quitte jamais la page** (formulaire à champ caché, collage passé
par `DOMParser`, réinjection limitée à `richTextToEditorHtml`).
## Référencement (SEO)
@@ -225,6 +287,24 @@ même raison.
`app/manifest.ts` et `app/opengraph-image.tsx` (vignette 1200×630 générée).
- `/backend`, `/login`, `/inscription/*` et les fiches non actives sont en
`NOINDEX` ; les pages publiques utilisent `<main>` et un seul `<h1>`.
- **Mesure d'audience** : `src/lib/analytics.ts` est **pur**
(`tests/analytics.test.ts`). `normalizeMeasurementId()` n'accepte qu'un
identifiant GA4 (`G-…`, ni `UA-…` ni texte libre) : c'est la garde qui interdit
d'injecter une chaîne arbitraire dans le script en ligne. Le réglage
`google_analytics_id` (Paramètres) vide = **aucune balise posée**.
`<Analytics>` (`src/components/Analytics.tsx`) rend les deux `<script>` avec le
nonce de la CSP et n'agit que sur les pages publiques : `isTrackedPath()`
écarte `PRIVATE_PATHS`, le chemin venant de l'en-tête `x-pathname` posé par le
middleware (un layout ne connaît pas l'URL demandée). La CSP liste les hôtes
`*.google-analytics.com` / `*.analytics.google.com` / `*.googletagmanager.com`
en `connect-src` sans condition : elle est posée sur le runtime Edge, qui ne
peut pas interroger la base. `script-src` n'a pas besoin d'eux —
`'strict-dynamic'` couvre les scripts que gtag.js charge ensuite.
- **Search Console** : le réglage `google_site_verification` alimente
`verification.google` dans `app/layout.tsx` (balise `<meta>`).
`normalizeSiteVerification()` refuse la balise `<meta>` complète collée telle
quelle. Le plan du site est déjà servi sur `/sitemap.xml` et annoncé dans
`/robots.txt` : il n'y a aucun fichier à déposer, seulement l'URL à déclarer.
- **Tags adhérents** : `src/lib/tags.ts` (pur) porte un vocabulaire par métier
(`CATEGORY_TAGS`, un par slug du référentiel, vérifié par `tests/tags.test.ts`)
et un vocabulaire transversal détecté dans la description. `autoTags()` ne
@@ -248,6 +328,17 @@ même raison.
droit à l'image) et `/backend/espace/promotions` (dépôt et suivi des promos,
les offres **en ligne** en tête). `EspaceHeader` porte le bandeau et les
onglets ; toute action qui touche l'espace revalide les deux chemins.
- **Pas de barre latérale hors staff** : `BackendShell` branche sur
`isStaff(user.role)`. Chez un adhérent la barre ne porterait que la section
« Côté adhérent », c'est-à-dire les trois entrées déjà rendues en onglets par
`EspaceHeader` — deux menus pour la même chose. Il ne reste qu'un bandeau de
marque, aligné sur la colonne de contenu (`max-width` 1040). Conséquence à ne
pas perdre de vue : sous 1024px les actions du compte vivaient **dans le
tiroir** ; sans tiroir, `.backend-header--plain` rétablit leur affichage dans
l'entête, sinon la déconnexion deviendrait inatteignable sur téléphone.
`tests/backend-nav.test.ts` verrouille les deux points. Le staff garde la
barre : elle y est la navigation principale, et « Côté adhérent » son seul
chemin vers son propre espace.
- La catégorie d'une promotion est un **type de produit ou de service**
(`src/lib/promo-categories.ts`, groupes pour `<optgroup>`), pas le métier de
l'adhérent ; `defaultPromoCategory(slug)` pré-sélectionne depuis le métier.
@@ -291,16 +382,38 @@ porte ce que publie le bureau depuis `/backend/informations`. Deux tables :
`information_reads`.
- **Texte riche** : `src/lib/rich-text.ts` est **pur** (`tests/rich-text.test.ts`)
et analyse un sous-ensemble de Markdown — `**gras**`, `*italique*`, `- puce`,
`1. numéro`, `[texte](https://…)`, `## sous-titre`. Deux rendus, un seul
et analyse un sous-ensemble de Markdown — `**gras**`, `_italique_`, `- puce`,
`1. numéro`, `[texte](https://…)`, `## sous-titre`. Trois rendus, un seul
analyseur : `richTextNodes()` pour l'écran (des éléments React, jamais
`dangerouslySetInnerHTML`), `richTextToEmailHtml()` pour le message. L'aperçu
du formulaire passe par le premier, il est donc fidèle par construction. Un
lien hors `http`/`https` perd sa cible et ne garde que son libellé
(`safeHttpUrl`, partagé avec `email-templates.ts`).
- **Pas de WYSIWYG** : il produirait du HTML, qu'il faudrait stocker puis
assainir — seconde dépendance, seconde surface d'attaque. La saisie est un
`<textarea>` avec une barre qui encadre la sélection (`setRangeText`).
`dangerouslySetInnerHTML`), `richTextToEmailHtml()` pour le message,
`richTextToEditorHtml()` pour remplir l'éditeur. Un lien hors `http`/`https`
perd sa cible et ne garde que son libellé (`safeHttpUrl`, partagé avec
`email-templates.ts`). L'italique s'écrit avec des tirets bas : `***x***`
serait ambigu pour l'analyseur, `**_x_**` ne l'est pas ; `*étoiles*` reste
accepté en lecture, et un tiret bas au milieu d'un mot
(`fichier_de_sauvegarde`) n'ouvre rien.
- **La syntaxe ne se montre jamais** : `RichTextEditor` est un
`contentEditable` avec une barre d'outils (G, I, Sous-titre, listes, lien).
L'adhérent qui rédige voit du gras, pas des étoiles. Le balisage voyage dans
un `<input type="hidden">`.
- **Le WYSIWYG porte sur la saisie, pas sur le stockage.** C'est l'invariant à
ne pas casser : à chaque frappe, `src/lib/rich-text-dom.ts`
(`serializeToRichText`, **pur**, `tests/rich-text-dom.test.ts`) retraverse le
contenu édité et n'en garde que le gras, l'italique, les listes, les
sous-titres et les liens. Le HTML de l'éditeur ne quitte jamais la page : la
base ne reçoit que le format balisé, que le serveur ré-analyse avec le même
analyseur qu'avant. Aucun assainisseur HTML n'est donc nécessaire.
Le module lit aussi le gras/italique **codés en style CSS**
(`<span style="font-weight:700">` de Google Docs), le style explicite
l'emportant sur la balise — sans quoi le `<b style="font-weight:normal">` dont
Docs enveloppe tout document mettrait le texte entier en gras.
- **Un collage est analysé par `DOMParser`**, jamais par une affectation
d'`innerHTML` : le document produit est inerte, donc un `<img onerror>` collé
ne s'exécute pas. Ce qui est réinjecté dans l'éditeur est du HTML **produit
par nous** (`richTextToEditorHtml`), jamais celui du presse-papier.
- **Les sauts de ligne sont normalisés à l'enregistrement** : l'encodage des
formulaires HTML convertit tout `\n` en `\r\n`, et le balisage stocké
différerait sinon de celui que l'éditeur a produit (`saveInformation`).
- **Une seule image**, en couverture, jamais dans le corps : une data-URI
recopiée dans chaque ligne de la file pèserait 3 Mo par destinataire. Elle est
servie aux clients mail par `/api/informations/[id]/image`, qui ne répond que
@@ -329,6 +442,21 @@ pas de cascade automatique, la bascule est un choix visible.
**Microsoft → API Graph** (`/me/sendMail`) : l'authentification basique SMTP
est désactivée depuis 2024, y compris sur outlook.com et hotmail.com.
**SMTP générique** via `nodemailer`, seule dépendance d'envoi.
- **Port et chiffrement SMTP sont un seul choix.** `src/lib/smtp-config.ts` est
**pur** (`tests/smtp-config.test.ts`) : `impliedSecure()` dit que seul le port
465 chiffre dès l'ouverture, les autres (587, 25, 2525) passent par STARTTLS,
et `tlsMismatch()` prévient quand la case et le port se contredisent. Le
formulaire (`SmtpAccountForm`, client) réaligne la case sur le port choisi :
un TLS tenté sur le 587 échoue sur la bannière en clair (« wrong version
number »), un dialogue en clair sur le 465 attend une réponse qui ne vient
jamais. `describeSmtpError()` traduit ces échecs avant affichage —
l'explication d'abord, la trace ensuite, parce que l'écran tronque.
- Le transport pose `requireTLS` hors TLS implicite : sans lui, STARTTLS est
*opportuniste* et l'identifiant partirait en clair chez un serveur qui ne
l'annonce pas. Les délais (`connectionTimeout`, `greetingTimeout`) sont
ramenés à quelques secondes : ces connexions s'ouvrent depuis une action
serveur, et un port filtré en sortie — cas courant chez les hébergeurs — ne
doit pas bloquer l'écran deux minutes.
- `mail_accounts.from_address` est **lu chez le fournisseur** au retour OAuth,
jamais saisi : Gmail expédie comme l'utilisateur authentifié, Graph comme la
boîte, et une adresse d'un autre domaine ferait tomber SPF/DKIM.
+18 -11
View File
@@ -6,7 +6,7 @@ import { db } from "@/db";
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
import { JsonLd } from "@/components/JsonLd";
import { MeetingCard } from "@/components/MeetingCard";
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
import { PastMeetingCard } from "@/components/PastMeetingCard";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import {
@@ -45,6 +45,11 @@ function formatDate(date: Date) {
}).format(date);
}
/** Sans le jour de la semaine : la carte d'archive est déjà dense. */
function formatPastDate(date: Date) {
return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "long", year: "numeric" }).format(date);
}
function formatTime(date: Date) {
return new Intl.DateTimeFormat("fr-FR", { hour: "2-digit", minute: "2-digit" }).format(date);
}
@@ -290,16 +295,18 @@ export default async function AssociationPage() {
? registrationCounts.get(meeting.meetingId) ?? 0
: splitLines(meeting.participants ?? "").length;
return (
<article key={meeting.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 18 }}>
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>{formatDate(meeting.eventDate)}</div>
<h3 className="font-display" style={{ color: "#26201a", fontSize: 22, margin: "7px 0" }}>{meeting.title}</h3>
{meeting.location && <div style={{ color: "#6c6150", fontSize: 13, fontWeight: 700 }}>{meeting.location}</div>}
{meeting.description && <p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.65 }}>{meeting.description}</p>}
<div style={{ color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 12 }}>
{participantCount} participant{participantCount > 1 ? "s" : ""} · {photos.length} photo{photos.length > 1 ? "s" : ""}
</div>
<PastMeetingGallery title={meeting.title} photos={photos} />
</article>
<PastMeetingCard
key={meeting.id}
meeting={{
id: meeting.id,
title: meeting.title,
dateLabel: formatPastDate(meeting.eventDate),
location: meeting.location,
description: meeting.description,
participantCount,
photos: photos.map((photo) => ({ id: photo.id, imageUrl: photo.imageUrl, caption: photo.caption })),
}}
/>
);
})}
</div>
+38 -1
View File
@@ -4,7 +4,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { BackendIcon, type BackendIconName } from "@/components/BackendIcons";
import { can, ROLE_LABELS } from "@/lib/rbac";
import { can, isStaff, ROLE_LABELS } from "@/lib/rbac";
import type { AppRole } from "@/types/next-auth";
import { doSignOut } from "./actions";
@@ -192,6 +192,43 @@ export function BackendShell({
</form>
);
/**
* Un adhérent n'a pas de barre latérale : elle ne porterait que la section
* « Côté adhérent », c'est-à-dire les trois entrées déjà présentes en onglets
* sous le bandeau de l'espace. Deux menus pour la même chose, dont un qui
* mange un quart de l'écran. Il ne reste donc qu'un bandeau de marque, et le
* contenu prend toute la largeur — recentré pour ne pas s'étirer.
*
* Le staff la garde : chez lui elle est la navigation principale, et
* « Côté adhérent » y est le seul chemin vers son propre espace.
*/
if (!isStaff(user.role)) {
return (
<div className="backend backend--plain" style={{ fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
<header className="backend-header backend-header--plain">
<Link href="/backend/espace/informations" className="backend-brand">
<span className="backend-brand__mark">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/assets/logo.png" alt="Plein R" style={{ height: 34, width: "auto", display: "block" }} />
</span>
<span className="backend-brand__text">
<span className="font-display backend-brand__name">Plein R</span>
<span className="backend-brand__sub">Espace adhérent</span>
</span>
</Link>
<div className="backend-header__actions">
{siteLink}
{signOut}
</div>
</header>
{/* Le bandeau de l'espace porte déjà le nom et les onglets : répéter
ici le titre de la page ferait un troisième étage d'en-tête. */}
<div className="backend-content backend-content--plain">{children}</div>
</div>
);
}
return (
<div className="backend" style={{ fontFamily: "'Public Sans',sans-serif", color: "#33291D" }}>
{menuOpen && (
+152 -58
View File
@@ -982,46 +982,76 @@ export async function deleteMeetingRegistration(formData: FormData) {
revalidateMeetingPaths(registration?.meetingId);
}
export async function createPastMeeting(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const title = asString(formData, "title");
if (!title) return;
await db.insert(pastMeetings).values({
title,
eventDate: asDate(formData, "eventDate"),
location: asNullableString(formData, "location"),
description: asNullableString(formData, "description"),
participants: asNullableString(formData, "participants"),
meetingId: formData.get("meetingId") ? Number(formData.get("meetingId")) : null,
});
/** Chemins où une rencontre passée est affichée, publics comme backoffice. */
function revalidatePastMeetingPaths() {
revalidatePath("/backend/rencontres");
revalidatePath("/association");
revalidatePath("/backend/rencontres-passees");
revalidatePath("/association");
revalidatePath("/rencontres-passees");
}
export async function updatePastMeeting(formData: FormData) {
export type SavedPastMeeting = { id: number };
/**
* Crée ou met à jour une rencontre passée. Une seule action pour les deux cas :
* la fenêtre du backoffice ne fait pas la différence, elle enregistre une fiche
* puis lui attache ses photos.
*
* Les refus attendus — titre vide, date illisible, rencontre liée disparue —
* sont **renvoyés** et affichés par la fenêtre, qui garde la saisie. Un `throw`
* deviendrait un digest illisible en production, et la fiche en cours de
* rédaction serait perdue.
*/
export async function savePastMeeting(formData: FormData): Promise<SavedPastMeeting | ActionError> {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
const id = Number(formData.get("id") ?? 0) || null;
const title = asString(formData, "title");
if (!id || !title) return;
await db
.update(pastMeetings)
.set({
title,
eventDate: asDate(formData, "eventDate"),
location: asNullableString(formData, "location"),
description: asNullableString(formData, "description"),
participants: asNullableString(formData, "participants"),
meetingId: formData.get("meetingId") ? Number(formData.get("meetingId")) : null,
})
.where(eq(pastMeetings.id, id));
revalidatePath("/backend/rencontres");
revalidatePath("/association");
revalidatePath("/backend/rencontres-passees");
revalidatePath("/rencontres-passees");
if (!title) return { error: "Donnez un titre à cette rencontre." };
const rawDate = asString(formData, "eventDate");
if (!/^\d{4}-\d{2}-\d{2}$/.test(rawDate)) return { error: "Renseignez la date de la rencontre." };
const eventDate = new Date(`${rawDate}T12:00:00`);
if (Number.isNaN(eventDate.getTime())) return { error: "Date invalide." };
// Le lien vers une rencontre du calendrier est vérifié : une rencontre
// supprimée entre l'ouverture de la fenêtre et l'enregistrement ferait sinon
// échouer l'écriture sur une violation de clé étrangère, donc sans explication.
const rawMeetingId = asString(formData, "meetingId");
let meetingId: number | null = null;
if (rawMeetingId) {
meetingId = Number(rawMeetingId);
if (!Number.isInteger(meetingId) || meetingId <= 0) return { error: "Rencontre liée invalide." };
const [linked] = await db.select({ id: meetings.id }).from(meetings).where(eq(meetings.id, meetingId));
if (!linked) return { error: "Cette rencontre du calendrier n'existe plus." };
}
const values = {
title,
eventDate,
location: asNullableString(formData, "location"),
// Les formulaires HTML encodent les sauts de ligne en CRLF : on normalise
// pour que le texte relu soit celui qui a été écrit.
description: asString(formData, "description").replace(/\r\n?/g, "\n") || null,
participants: asString(formData, "participants").replace(/\r\n?/g, "\n") || null,
meetingId,
};
if (id) {
const [updated] = await db
.update(pastMeetings)
.set(values)
.where(eq(pastMeetings.id, id))
.returning({ id: pastMeetings.id });
if (!updated) return { error: "Cette rencontre passée n'existe plus." };
revalidatePastMeetingPaths();
return { id: updated.id };
}
const [created] = await db.insert(pastMeetings).values(values).returning({ id: pastMeetings.id });
revalidatePastMeetingPaths();
return { id: created.id };
}
export async function deletePastMeeting(formData: FormData) {
@@ -1030,44 +1060,106 @@ export async function deletePastMeeting(formData: FormData) {
const id = Number(formData.get("id"));
if (!id) return;
await db.delete(pastMeetings).where(eq(pastMeetings.id, id));
revalidatePath("/backend/rencontres");
revalidatePath("/association");
revalidatePath("/backend/rencontres-passees");
revalidatePath("/rencontres-passees");
revalidatePastMeetingPaths();
}
export async function addPastMeetingPhoto(formData: FormData) {
/** Les identifiants reviennent dans l'ordre d'insertion : la fenêtre s'en sert
* pour réordonner l'ensemble une fois toutes les photos envoyées. */
export type AddedPhotos = { added: number; ids: number[] };
/**
* Attache un lot de photos à une rencontre passée.
*
* Le navigateur compresse puis découpe l'envoi en paquets tenant sous la limite
* des server actions (`chunkByBytes`) : plusieurs appels, donc, et chacun doit
* pouvoir échouer seul. Les photos déjà enregistrées le restent.
*/
export async function addPastMeetingPhotos(formData: FormData): Promise<AddedPhotos | ActionError> {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const pastMeetingId = Number(formData.get("pastMeetingId"));
const imageUrl = asString(formData, "imageUrl");
if (!pastMeetingId || !imageUrl) return;
if (!pastMeetingId) return { error: "Rencontre introuvable." };
const [archive] = await db.select({ id: pastMeetings.id }).from(pastMeetings).where(eq(pastMeetings.id, pastMeetingId));
if (!archive) return { error: "Cette rencontre passée n'existe plus." };
const images = formData.getAll("imageUrl").map((value) => String(value));
const captions = formData.getAll("caption").map((value) => String(value).trim().slice(0, 200));
if (images.length === 0) return { added: 0, ids: [] };
const rows: { pastMeetingId: number; imageUrl: string; caption: string | null; position: number }[] = [];
const [{ max } = { max: 0 }] = await db
.select({ max: sql<number>`coalesce(max(${pastMeetingPhotos.position}), 0)` })
.from(pastMeetingPhotos)
.where(eq(pastMeetingPhotos.pastMeetingId, pastMeetingId));
await db.insert(pastMeetingPhotos).values({
pastMeetingId,
imageUrl,
caption: asNullableString(formData, "caption"),
position: Number(max) + 1,
});
revalidatePath("/backend/rencontres");
revalidatePath("/association");
revalidatePath("/backend/rencontres-passees");
revalidatePath("/rencontres-passees");
for (const [index, image] of images.entries()) {
const value = image.trim();
if (!value) continue;
// Une image n'est acceptée qu'en data-URI : une URL ferait charger par le
// serveur une adresse choisie par l'utilisateur (SSRF).
if (value.length > MAX_IMAGE_DATA_URI) return { error: "Une photo est trop lourde même après compression." };
if (!IMAGE_DATA_URI.test(value)) return { error: "Format de photo non accepté." };
rows.push({
pastMeetingId,
imageUrl: value,
caption: captions[index] || null,
position: Number(max) + index + 1,
});
}
if (rows.length === 0) return { added: 0, ids: [] };
const inserted = await db.insert(pastMeetingPhotos).values(rows).returning({ id: pastMeetingPhotos.id });
revalidatePastMeetingPaths();
return { added: inserted.length, ids: inserted.map((photo) => photo.id) };
}
export async function deletePastMeetingPhoto(formData: FormData) {
export type SyncedPhotos = { removed: number };
/**
* Aligne les photos existantes sur ce que montre la fenêtre : ordre, légendes,
* et suppression de celles qui en ont été retirées.
*
* `syncPhotos` est obligatoire : sans ce drapeau, un appel amputé de sa liste
* (requête tronquée, formulaire partiel) effacerait toute la galerie en silence.
*/
export async function savePastMeetingPhotos(formData: FormData): Promise<SyncedPhotos | ActionError> {
const { role } = await requireRole();
if (!can(role, "manageMeetings")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
if (!id) return;
await db.delete(pastMeetingPhotos).where(eq(pastMeetingPhotos.id, id));
revalidatePath("/backend/rencontres");
revalidatePath("/association");
revalidatePath("/backend/rencontres-passees");
revalidatePath("/rencontres-passees");
if (formData.get("syncPhotos") !== "1") return { error: "Demande incomplète." };
const pastMeetingId = Number(formData.get("pastMeetingId"));
if (!pastMeetingId) return { error: "Rencontre introuvable." };
const kept = formData
.getAll("photoId")
.map((value) => Number(value))
.filter((value) => Number.isInteger(value) && value > 0);
const captions = formData.getAll("photoCaption").map((value) => String(value).trim().slice(0, 200));
const existing = await db
.select({ id: pastMeetingPhotos.id })
.from(pastMeetingPhotos)
.where(eq(pastMeetingPhotos.pastMeetingId, pastMeetingId));
const owned = new Set(existing.map((photo) => photo.id));
let removed = 0;
for (const photo of existing) {
if (kept.includes(photo.id)) continue;
await db.delete(pastMeetingPhotos).where(eq(pastMeetingPhotos.id, photo.id));
removed += 1;
}
for (const [index, photoId] of kept.entries()) {
if (!owned.has(photoId)) continue;
await db
.update(pastMeetingPhotos)
.set({ caption: captions[index] || null, position: index + 1 })
.where(eq(pastMeetingPhotos.id, photoId));
}
revalidatePastMeetingPaths();
return { removed };
}
// ---- Paramètres du site ----
@@ -1449,7 +1541,9 @@ export async function saveInformation(
const id = Number(formData.get("id") ?? 0) || null;
const title = asString(formData, "title");
const body = asString(formData, "body");
// L'encodage des formulaires HTML convertit tout saut de ligne en CRLF :
// sans cela le balisage stocké différerait de celui que l'éditeur a produit.
const body = asString(formData, "body").replace(/\r\n?/g, "\n");
if (!title) return { error: "Donnez un titre à cette information." };
if (!body) return { error: "Le message est vide." };
+13 -49
View File
@@ -11,6 +11,7 @@ import {
type MailHealth,
} from "@/lib/mail-accounts";
import { recentMails } from "@/lib/mail-outbox";
import { SmtpAccountForm } from "@/components/SmtpAccountForm";
import { publicBaseUrl } from "@/lib/social-accounts";
import { getSiteSettings } from "@/lib/site-settings";
import type { MailAccount, MailProvider } from "@/db/schema";
@@ -194,61 +195,24 @@ export default async function BoiteMailPage({
{verdict && !verdict.ok && (
<div style={{ marginTop: 13 }}>
<Banner tone="error">{verdict.reason.slice(0, 220)}</Banner>
<Banner tone="error">{verdict.reason.slice(0, 400)}</Banner>
</div>
)}
<div style={{ borderTop: "1px solid #f0e8d6", margin: "14px 0" }} />
{provider === "smtp" ? (
<form action={saveMailSmtp}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 12 }}>
<label className="field-label">
Serveur
<input className="field" name="smtpHost" defaultValue={account?.smtpHost ?? ""} placeholder="smtp.ionos.fr" style={{ marginTop: 6 }} />
</label>
<label className="field-label">
Port
<input className="field" name="smtpPort" type="number" min={1} max={65535} defaultValue={account?.smtpPort ?? 465} style={{ marginTop: 6 }} />
</label>
<label className="field-label">
Identifiant
<input className="field" name="smtpUser" defaultValue={account?.smtpUser ?? ""} placeholder="bureau@pleinr.fr" style={{ marginTop: 6 }} />
</label>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 12, marginTop: 12 }}>
<label className="field-label">
Mot de passe
<input
className="field"
name="smtpPassword"
type="password"
autoComplete="new-password"
placeholder={account?.smtpPassword ? "Enregistré — laissez vide pour le conserver" : "Mot de passe de la boîte"}
style={{ marginTop: 6 }}
/>
</label>
<label className="field-label">
Adresse d&apos;expédition
<input className="field" name="fromAddress" defaultValue={account?.fromAddress ?? ""} placeholder="contact@pleinr.fr" style={{ marginTop: 6 }} />
</label>
<label className="field-label">
Nom affiché
<input className="field" name="fromName" defaultValue={account?.fromName ?? settings.association_name} style={{ marginTop: 6 }} />
</label>
</div>
<label style={{ display: "flex", gap: 9, alignItems: "center", marginTop: 12, fontSize: 13, color: "#6c6150" }}>
<input type="checkbox" name="smtpSecure" defaultChecked={account?.smtpSecure ?? true} />
Connexion chiffrée dès l&apos;ouverture (port 465). Décochez pour STARTTLS (port 587).
</label>
<p className="field-hint" style={{ marginTop: 8 }}>
L&apos;adresse d&apos;expédition doit appartenir au domaine qui authentifie la connexion, sinon
SPF et DKIM échouent et les messages partent en indésirable.
</p>
<div style={{ display: "flex", gap: 9, marginTop: 14, flexWrap: "wrap" }}>
<button type="submit" style={primary}>Enregistrer</button>
</div>
</form>
<SmtpAccountForm
saveAction={saveMailSmtp}
host={account?.smtpHost ?? ""}
port={account?.smtpPort ?? 465}
secure={account?.smtpSecure ?? true}
user={account?.smtpUser ?? ""}
hasPassword={Boolean(account?.smtpPassword)}
fromAddress={account?.fromAddress ?? ""}
fromName={account?.fromName ?? settings.association_name}
submitStyle={primary}
/>
) : (
<>
<form action={saveMailApp}>
+27 -15
View File
@@ -11,6 +11,8 @@ import type { PromoCategoryGroup } from "@/lib/promo-categories";
import { formatValidity, isRangeInvalid } from "@/lib/promo-validity";
import { formatSchedule, parseScheduleInput } from "@/lib/promo-schedule";
import { imageNotes, imageSummary, type ImageInfo } from "@/lib/image-info";
import { FIELD_MAX_BYTES, compressionSummary } from "@/lib/image-compress";
import { prepareImageFile } from "@/lib/image-compress-dom";
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
@@ -118,6 +120,7 @@ export function MemberSpaceForm({
// c'est ici — dernier moment où l'adhérent peut changer de visuel — qu'on lui
// dit ce que les réseaux feront d'un format inattendu.
const [imgInfo, setImgInfo] = useState<ImageInfo | null>(null);
const [imgNote, setImgNote] = useState<string | null>(null);
const [shares, setShares] = useState<SocialNetwork[]>([]);
const [submitted, setSubmitted] = useState(false);
const [pending, setPending] = useState(false);
@@ -128,25 +131,30 @@ export function MemberSpaceForm({
setSubmitted(false);
}
function onFile(e: React.ChangeEvent<HTMLInputElement>) {
/**
* Le navigateur compresse avant l'envoi. Une photo de téléphone dépasse la
* limite de 3 Mo du serveur : elle était refusée en pleine publication, sans
* que l'adhérent puisse rien y faire depuis son écran. Le cadrage, lui, n'est
* pas touché — le rapport largeur/hauteur est conservé, donc les remarques de
* `ImageReport` sur ce que feront les réseaux restent vraies.
*/
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (!f) return;
const bytes = f.size;
setImgInfo(null);
const reader = new FileReader();
reader.onload = () => {
const dataUrl = String(reader.result);
setImgData(dataUrl);
setImgNote("Compression en cours…");
try {
const prepared = await prepareImageFile(f, { maxBytes: FIELD_MAX_BYTES });
setImgData(prepared.dataUri);
setSubmitted(false);
// Les dimensions ne sont lisibles qu'une fois l'image décodée par le
// navigateur. Un échec de décodage laisse simplement le résumé vide :
// l'information est un confort, elle ne conditionne pas le dépôt.
const probe = new Image();
probe.onload = () => setImgInfo({ width: probe.naturalWidth, height: probe.naturalHeight, bytes });
probe.onerror = () => setImgInfo(null);
probe.src = dataUrl;
};
reader.readAsDataURL(f);
// Le résumé décrit l'image telle qu'elle sera publiée, pas le fichier
// d'origine : c'est celle-là que verront le site et les réseaux.
setImgInfo({ width: prepared.width, height: prepared.height, bytes: prepared.bytes });
setImgNote(prepared.recompressed ? compressionSummary(prepared) : null);
} catch (cause) {
setImgNote(cause instanceof Error ? cause.message : "Image refusée.");
if (fileRef.current) fileRef.current.value = "";
}
}
function reset() {
@@ -158,6 +166,7 @@ export function MemberSpaceForm({
setPublishAt("");
setImgData("");
setImgInfo(null);
setImgNote(null);
setShares([]);
setSubmitted(false);
if (fileRef.current) fileRef.current.value = "";
@@ -245,6 +254,9 @@ export function MemberSpaceForm({
<input ref={fileRef} type="file" accept="image/*" onChange={onFile} style={{ display: "none" }} />
</label>
{imgNote && (
<div style={{ marginTop: -8, marginBottom: 10, fontSize: 12.5, color: "#6c6150" }}>{imgNote}</div>
)}
{imgInfo && <ImageReport info={imgInfo} />}
<label className="field-label">Titre de la promotion</label>
@@ -1,19 +1,23 @@
"use client";
import { useRef, useState } from "react";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ImageField } from "@/components/ImageField";
import { richTextNodes } from "@/lib/rich-text";
import { RichTextEditor } from "@/components/RichTextEditor";
import { saveInformation } from "../actions";
/**
* Rédaction d'une information.
*
* Pas d'éditeur WYSIWYG : un `<textarea>` et des boutons qui encadrent la
* sélection, plus un aperçu rendu par **le même** analyseur que le site
* (`richTextNodes`). L'aperçu est donc fidèle par construction, et rien de ce
* qui est saisi ne peut devenir du HTML.
* L'édition est **visuelle** : on sélectionne, on clique sur « G », le texte
* devient gras à l'écran. Aucune syntaxe n'est montrée — un rédacteur
* d'association n'a pas à connaître Markdown.
*
* Ce qui est enregistré reste malgré tout le format balisé restreint : c'est
* `RichTextEditor` qui resérialise à chaque frappe. Aucun HTML tiers n'atteint
* la base, et l'éditeur est son propre aperçu — il n'y a plus deux rendus à
* comparer.
*/
type Draft = {
@@ -24,18 +28,8 @@ type Draft = {
pinned: boolean;
};
const TOOLS = [
{ label: "B", title: "Gras", before: "**", after: "**", sample: "texte en gras", strong: true },
{ label: "I", title: "Italique", before: "*", after: "*", sample: "texte en italique", italic: true },
{ label: "Titre", title: "Sous-titre", before: "## ", after: "", sample: "Sous-titre", line: true },
{ label: "• liste", title: "Liste à puces", before: "- ", after: "", sample: "premier point", line: true },
{ label: "1. liste", title: "Liste numérotée", before: "1. ", after: "", sample: "première étape", line: true },
{ label: "Lien", title: "Lien", before: "[", after: "](https://)", sample: "libellé du lien" },
] as const;
export function InformationForm({ draft }: { draft: Draft }) {
const router = useRouter();
const bodyRef = useRef<HTMLTextAreaElement>(null);
const [title, setTitle] = useState(draft.title);
const [body, setBody] = useState(draft.body);
const [pinned, setPinned] = useState(draft.pinned);
@@ -45,19 +39,6 @@ export function InformationForm({ draft }: { draft: Draft }) {
// seul un changement de clé le remet à vide.
const [round, setRound] = useState(0);
function applyTool(tool: (typeof TOOLS)[number]) {
const field = bodyRef.current;
if (!field) return;
const { selectionStart, selectionEnd } = field;
const selected = field.value.slice(selectionStart, selectionEnd) || tool.sample;
// `setRangeText` conserve la pile d'annulation du navigateur, ce qu'une
// réécriture complète de la valeur ferait perdre.
const insert = "line" in tool && tool.line && selectionStart > 0 && field.value[selectionStart - 1] !== "\n";
field.setRangeText(`${insert ? "\n" : ""}${tool.before}${selected}${tool.after}`, selectionStart, selectionEnd, "select");
field.focus();
setBody(field.value);
}
async function submit(formData: FormData) {
setSaving(true);
setError(null);
@@ -99,44 +80,18 @@ export function InformationForm({ draft }: { draft: Draft }) {
style={{ marginBottom: 15, fontWeight: 700 }}
/>
<label className="field-label" htmlFor="information-body">Message</label>
<div
style={{
display: "flex", alignItems: "center", gap: 3, flexWrap: "wrap",
border: "1px solid #ddd2bb", borderBottom: "none", borderRadius: "10px 10px 0 0",
background: "#fff", padding: "6px 7px",
}}
>
{TOOLS.map((tool) => (
<button
key={tool.label}
type="button"
title={tool.title}
onClick={() => applyTool(tool)}
style={{
minWidth: 30, height: 28, padding: "0 9px", borderRadius: 7, border: "none", background: "transparent",
color: "#6c6150", fontSize: 12.5, fontWeight: 700, cursor: "pointer",
fontStyle: "italic" in tool && tool.italic ? "italic" : undefined,
}}
>
{tool.label}
</button>
))}
</div>
<textarea
id="information-body"
ref={bodyRef}
className="field"
name="body"
rows={10}
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={"Rédigez ici. Laissez une ligne vide entre deux paragraphes.\n\n- une puce\n**du gras**, [un lien](https://pleinr.fr)"}
style={{ borderRadius: "0 0 10px 10px", resize: "vertical", lineHeight: 1.7 }}
<label className="field-label" id="information-body-label">Message</label>
<RichTextEditor
value={draft.body}
onChange={setBody}
ariaLabel="Message de l'information"
placeholder="Rédigez votre message. Sélectionnez du texte puis cliquez sur G pour le mettre en gras."
/>
{/* Le format balisé ne se montre pas : il voyage dans un champ caché. */}
<input type="hidden" name="body" value={body} />
<p className="field-hint" style={{ marginTop: 6 }}>
Mise en forme simple : <strong>**gras**</strong>, <em>*italique*</em>, <code>- puce</code>,{" "}
<code>[libellé](https://…)</code>. Les liens s&apos;ouvrent dans un nouvel onglet.
Gras, italique, sous-titres, listes et liens. Le collage depuis un traitement de texte
conserve la mise en forme reconnue et laisse tomber le reste.
</p>
<div style={{ marginTop: 16 }}>
@@ -153,20 +108,6 @@ export function InformationForm({ draft }: { draft: Draft }) {
</span>
</label>
{body.trim() && (
<div style={{ marginTop: 18, borderTop: "1px solid #f0e8d6", paddingTop: 16 }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 10 }}>
Aperçu
</div>
<div style={{ background: "#faf7ef", border: "1px solid #e6dcc6", borderRadius: 12, padding: "14px 16px" }}>
<h3 className="font-display" style={{ margin: "0 0 8px", fontWeight: 700, fontSize: 17, color: "#26201a" }}>
{title || "Sans titre"}
</h3>
<div className="rich-text">{richTextNodes(body)}</div>
</div>
</div>
)}
{error && (
<div role="alert" style={{ marginTop: 14, color: "#d8472b", fontSize: 13, fontWeight: 700 }}>
{error}
+26
View File
@@ -98,6 +98,32 @@ export default async function ParametresPage() {
</div>
</section>
<section style={panel}>
<h2 className="font-display" style={title}>Référencement & mesure d&apos;audience</h2>
<div className="grid grid-2" style={{ gap: 16 }}>
<Field
label="Identifiant Google Analytics (GA4)"
name="google_analytics_id"
value={settings.google_analytics_id}
placeholder="G-XXXXXXXXXX"
/>
<Field
label="Jeton Search Console (balise HTML)"
name="google_site_verification"
value={settings.google_site_verification}
placeholder="Valeur de l'attribut content"
/>
</div>
<div style={{ marginTop: 14, color: "#9a6638", fontSize: 13, lineHeight: 1.6 }}>
Laissez vide pour ne rien charger : sans identifiant, aucune balise Google n&apos;est
posée sur le site. La mesure d&apos;audience ne couvre que les pages publiques — ni le
backoffice, ni l&apos;espace adhérent. Le plan du site est déjà publié sur{" "}
<code>/sitemap.xml</code> et annoncé dans <code>/robots.txt</code> : il suffit de le
déclarer dans Search Console. Pensez à mentionner Google Analytics dans la page
Confidentialité avant de l&apos;activer.
</div>
</section>
<button type="submit" style={submitButton}>Enregistrer les paramètres</button>
</form>
);
@@ -0,0 +1,395 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { ModalShell } from "@/components/ModalShell";
import { PHOTO_MAX_BYTES, base64Bytes, chunkByBytes, compressionSummary } from "@/lib/image-compress";
import { prepareImageFile } from "@/lib/image-compress-dom";
import type { ArchiveDraft, DialogPhoto, MeetingOption } from "./draft";
import {
addPastMeetingPhotos,
deletePastMeeting,
savePastMeeting,
savePastMeetingPhotos,
} from "../actions";
/**
* Bouton d'ouverture de la fenêtre d'édition. La fenêtre n'est montée qu'à
* l'ouverture : chaque ouverture repart donc de la fiche telle qu'elle est en
* base, sans état résiduel de la précédente.
*/
export function PastMeetingDialogButton({
draft,
meetings,
children,
style,
}: {
draft: ArchiveDraft;
meetings: MeetingOption[];
children: React.ReactNode;
style?: React.CSSProperties;
}) {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)} style={style}>
{children}
</button>
{open && <PastMeetingDialog draft={draft} meetings={meetings} onClose={() => setOpen(false)} />}
</>
);
}
function PastMeetingDialog({
draft,
meetings,
onClose,
}: {
draft: ArchiveDraft;
meetings: MeetingOption[];
onClose: () => void;
}) {
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
const photosRef = useRef<HTMLDivElement>(null);
const [fields, setFields] = useState(draft);
const [photos, setPhotos] = useState<DialogPhoto[]>(draft.photos);
const [over, setOver] = useState(false);
const [working, setWorking] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [notes, setNotes] = useState<string[]>([]);
const [closing, setClosing] = useState(false);
const [refreshing, startRefresh] = useTransition();
/**
* On ne referme qu'une fois la page rafraîchie. Sans cette attente, rouvrir la
* fiche dans la foulée montrerait encore l'état d'avant l'enregistrement — la
* rencontre liée revenant à « Aucune », par exemple — et on croirait que rien
* n'a été retenu.
*/
useEffect(() => {
if (closing && !refreshing) onClose();
}, [closing, refreshing, onClose]);
const linked = meetings.find((meeting) => String(meeting.id) === fields.meetingId);
const busy = working !== null || closing;
function set<K extends keyof ArchiveDraft>(key: K, value: ArchiveDraft[K]) {
setFields((current) => ({ ...current, [key]: value }));
}
/**
* Compression **avant** l'envoi : une photo de téléphone pèse 4 à 12 Mo, la
* limite d'une server action est à 4 Mo. Le navigateur redimensionne et
* réencode, en ne descendant en qualité que le strict nécessaire.
*/
async function addFiles(list: FileList | null) {
if (!list || list.length === 0) return;
const files = Array.from(list);
setError(null);
const added: DialogPhoto[] = [];
const messages: string[] = [];
for (const [index, file] of files.entries()) {
setWorking(`Compression de la photo ${index + 1} sur ${files.length}…`);
try {
const prepared = await prepareImageFile(file, { maxBytes: PHOTO_MAX_BYTES });
added.push({
key: `${Date.now()}-${index}-${file.name}`,
id: null,
imageUrl: prepared.dataUri,
caption: "",
bytes: prepared.bytes,
});
messages.push(`${file.name} — ${compressionSummary(prepared)}`);
} catch (cause) {
messages.push(`${file.name} — ${cause instanceof Error ? cause.message : "photo refusée"}`);
}
}
setWorking(null);
setPhotos((current) => [...current, ...added]);
setNotes(messages);
if (fileRef.current) fileRef.current.value = "";
// La galerie est en bas de la fenêtre : sans cela, on dépose des photos
// sans jamais voir apparaître ce qu'on vient d'ajouter.
photosRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
function movePhoto(index: number, step: number) {
setPhotos((current) => {
const target = index + step;
if (target < 0 || target >= current.length) return current;
const next = [...current];
[next[index], next[target]] = [next[target], next[index]];
return next;
});
}
async function save() {
setError(null);
setWorking("Enregistrement…");
try {
const form = new FormData();
if (fields.id) form.set("id", String(fields.id));
form.set("title", fields.title);
form.set("eventDate", fields.eventDate);
form.set("location", fields.location);
form.set("description", fields.description);
form.set("participants", fields.participants);
form.set("meetingId", fields.meetingId);
const saved = await savePastMeeting(form);
if ("error" in saved) {
setError(saved.error);
return;
}
// Les nouvelles photos partent en paquets : plusieurs data-URI dans une
// seule requête dépasseraient la limite de corps des server actions.
const fresh = photos.filter((photo) => photo.id === null);
const groups = chunkByBytes(fresh.map((photo) => photo.bytes || base64Bytes(photo.imageUrl)));
const freshIds: number[] = [];
let sent = 0;
for (const group of groups) {
setWorking(`Envoi des photos ${sent + 1} à ${sent + group.length} sur ${fresh.length}…`);
const batch = new FormData();
batch.set("pastMeetingId", String(saved.id));
for (const index of group) {
batch.append("imageUrl", fresh[index].imageUrl);
batch.append("caption", fresh[index].caption);
}
const result = await addPastMeetingPhotos(batch);
if ("error" in result) {
setError(`${result.error} Les photos déjà envoyées sont conservées.`);
router.refresh();
return;
}
freshIds.push(...result.ids);
sent += group.length;
}
// Passage final : ordre affiché, légendes, et suppression de ce qui a été
// retiré de la fenêtre.
setWorking("Mise en ordre de la galerie…");
const sync = new FormData();
sync.set("syncPhotos", "1");
sync.set("pastMeetingId", String(saved.id));
let cursor = 0;
for (const photo of photos) {
const photoId = photo.id ?? freshIds[cursor++];
if (!photoId) continue;
sync.append("photoId", String(photoId));
sync.append("photoCaption", photo.caption);
}
const synced = await savePastMeetingPhotos(sync);
if ("error" in synced) {
setError(synced.error);
router.refresh();
return;
}
setClosing(true);
startRefresh(() => router.refresh());
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Enregistrement impossible.");
} finally {
setWorking(null);
}
}
async function remove() {
if (!fields.id) return;
if (!window.confirm("Supprimer définitivement cette rencontre passée et ses photos ?")) return;
setWorking("Suppression…");
const form = new FormData();
form.set("id", String(fields.id));
await deletePastMeeting(form);
setWorking(null);
setClosing(true);
startRefresh(() => router.refresh());
}
return (
<ModalShell
label={fields.id ? `Modifier ${fields.title}` : "Nouvelle rencontre passée"}
onClose={() => {
if (busy) return;
onClose();
}}
panelClassName="pm-panel pm-panel--form"
>
<div className="pm-form">
<div className="pm-form__scroll">
<h2 className="font-display" style={{ margin: "0 0 4px", fontSize: 22, color: "#26201a", paddingRight: 44 }}>
{fields.id ? "Modifier la rencontre passée" : "Nouvelle rencontre passée"}
</h2>
<p style={{ color: "#8c8068", fontSize: 13, margin: "0 0 18px" }}>
Tout se remplit ici : le récit, les participants et les photos — y compris pour une rencontre qui n'est pas
encore publiée.
</p>
{fields.refused.length > 0 && (
<div style={{ background: "#fbe9e6", border: "1px solid #f0c4bb", color: "#b53a25", borderRadius: 8, padding: "11px 13px", fontSize: 12.5, lineHeight: 1.5, marginBottom: 14 }}>
<strong>Droit à l'image refusé :</strong> {fields.refused.join(", ")}. Vérifiez que ces personnes ne
figurent pas sur les photos publiées.
</div>
)}
<div className="grid grid-2" style={{ gap: 14 }}>
<div>
<label className="field-label">Titre</label>
<input className="field" value={fields.title} onChange={(event) => set("title", event.target.value)} maxLength={200} />
</div>
<div>
<label className="field-label">Date</label>
<input type="date" className="field" value={fields.eventDate} onChange={(event) => set("eventDate", event.target.value)} />
</div>
<div style={{ gridColumn: "1 / -1" }}>
<label className="field-label">Lieu</label>
<input className="field" value={fields.location} onChange={(event) => set("location", event.target.value)} maxLength={240} />
</div>
<div style={{ gridColumn: "1 / -1" }}>
<label className="field-label">Rencontre liée (facultatif)</label>
<select className="field" value={fields.meetingId} onChange={(event) => set("meetingId", event.target.value)}>
<option value="">Aucune — participants saisis à la main</option>
{meetings.map((meeting) => (
<option key={meeting.id} value={String(meeting.id)}>
{meeting.label}
</option>
))}
</select>
<div style={{ fontSize: 12.5, color: "#8c8068", marginTop: 6, lineHeight: 1.5 }}>
{linked
? `Le site affichera ${linked.registered} participant${linked.registered > 1 ? "s" : ""} d'après les inscriptions ; la liste saisie à la main est alors ignorée.`
: "Sans rencontre liée, le nombre affiché est celui des lignes saisies ci-dessous (une personne par ligne)."}
</div>
</div>
<div style={{ gridColumn: "1 / -1" }}>
<label className="field-label">Récit de la rencontre</label>
<textarea
className="field"
rows={12}
value={fields.description}
onChange={(event) => set("description", event.target.value)}
placeholder="Déroulé de la matinée, intervenants, décisions prises, prochaines étapes…"
style={{ minHeight: 260, resize: "vertical", lineHeight: 1.7 }}
/>
<div style={{ fontSize: 12.5, color: "#8c8068", marginTop: 6 }}>
Les paragraphes sont conservés tels quels sur le site. {fields.description.length} caractères.
</div>
</div>
<div style={{ gridColumn: "1 / -1" }}>
<label className="field-label">Participants (une personne par ligne)</label>
<textarea
className="field"
rows={4}
value={fields.participants}
onChange={(event) => set("participants", event.target.value)}
style={{ resize: "vertical" }}
/>
</div>
</div>
<div ref={photosRef} style={{ marginTop: 22, borderTop: "1px solid #efe7d6", paddingTop: 18 }}>
<label className="field-label">Photos ({photos.length})</label>
<div
className={over ? "pm-dropzone pm-dropzone--over" : "pm-dropzone"}
onDragOver={(event) => {
event.preventDefault();
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={(event) => {
event.preventDefault();
setOver(false);
void addFiles(event.dataTransfer.files);
}}
>
<div style={{ marginBottom: 10 }}>
Glissez plusieurs photos ici, ou choisissez-les sur votre appareil. Elles sont redimensionnées et
compressées automatiquement : aucune n'est trop lourde.
</div>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={busy}
style={{ border: "1px solid #d6c9ad", background: "#fff", color: "#6f6450", fontWeight: 700, fontSize: 13, padding: "9px 16px", borderRadius: 9, cursor: busy ? "progress" : "pointer" }}
>
Choisir des photos
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
onChange={(event) => void addFiles(event.target.files)}
style={{ display: "none" }}
/>
</div>
{notes.length > 0 && (
<ul style={{ margin: "10px 0 0", paddingLeft: 18, color: "#8c8068", fontSize: 12.5, lineHeight: 1.6 }}>
{notes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
)}
{photos.length > 0 && (
<div className="pm-photo-grid">
{photos.map((photo, index) => (
<div key={photo.key} className="pm-photo">
<div className="pm-photo__image" style={{ backgroundImage: `url(${photo.imageUrl})` }} />
<div className="pm-photo__tools">
<button type="button" className="pm-photo__tool" onClick={() => movePhoto(index, -1)} disabled={index === 0} aria-label="Déplacer avant">←</button>
<button type="button" className="pm-photo__tool" onClick={() => movePhoto(index, 1)} disabled={index === photos.length - 1} aria-label="Déplacer après">→</button>
<button
type="button"
className="pm-photo__tool pm-photo__tool--danger"
onClick={() => setPhotos((current) => current.filter((item) => item.key !== photo.key))}
>
Retirer
</button>
</div>
<input
className="field"
style={{ border: "none", borderTop: "1px solid #efe7d6", borderRadius: 0, fontSize: 12.5 }}
placeholder="Légende (facultative)"
maxLength={200}
value={photo.caption}
onChange={(event) =>
setPhotos((current) =>
current.map((item) => (item.key === photo.key ? { ...item, caption: event.target.value } : item)),
)
}
/>
</div>
))}
</div>
)}
<div style={{ fontSize: 12.5, color: "#8c8068", marginTop: 10 }}>
La première photo sert de couverture à la carte ; les flèches changent l'ordre de la galerie.
</div>
</div>
</div>
<div className="pm-form__foot">
<div style={{ fontSize: 13, color: error ? "#a3372e" : "#6c6150", fontWeight: error ? 700 : 600, flex: "1 1 240px" }}>
{error ?? working ?? (closing ? "Mise à jour de la page…" : fields.id ? "" : "La rencontre sera visible sur le site dès l'enregistrement.")}
</div>
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
{fields.id && (
<button type="button" onClick={remove} disabled={busy} style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 800, fontSize: 13, padding: "10px 14px", borderRadius: 8, cursor: "pointer" }}>
Supprimer
</button>
)}
<button type="button" onClick={onClose} disabled={busy} style={{ border: "1px solid #d6c9ad", background: "#fff", color: "#6f6450", fontWeight: 700, fontSize: 13, padding: "10px 14px", borderRadius: 8, cursor: "pointer" }}>
Annuler
</button>
<button type="button" onClick={save} disabled={busy} style={{ border: "none", background: "#13324F", color: "#fff", fontWeight: 800, fontSize: 13.5, padding: "11px 18px", borderRadius: 8, cursor: busy ? "progress" : "pointer", opacity: busy ? 0.7 : 1 }}>
{busy ? "Patientez…" : "Enregistrer"}
</button>
</div>
</div>
</div>
</ModalShell>
);
}
@@ -0,0 +1,50 @@
/**
* Forme d'une rencontre passée telle que la manipule la fenêtre d'édition.
*
* Ce module n'est **pas** marqué `"use client"` à dessein : la page (composant
* serveur) appelle `emptyDraft()` pour préparer une fiche vierge. Exporté
* depuis un module client, cet appel deviendrait une référence client, que le
* serveur ne peut pas exécuter.
*/
export type MeetingOption = { id: number; label: string; registered: number };
export type DialogPhoto = {
/** Clé stable côté navigateur : une photo pas encore enregistrée n'a pas d'id. */
key: string;
id: number | null;
imageUrl: string;
caption: string;
/** Poids de la data-URI, utilisé pour découper les envois en paquets. */
bytes: number;
};
export type ArchiveDraft = {
id: number | null;
title: string;
eventDate: string;
location: string;
description: string;
participants: string;
meetingId: string;
photos: DialogPhoto[];
/** Personnes ayant refusé le droit à l'image sur la rencontre liée. */
refused: string[];
};
/** Fiche vierge, datée d'aujourd'hui dans le fuseau du navigateur du serveur. */
export function emptyDraft(): ArchiveDraft {
const today = new Date();
today.setMinutes(today.getMinutes() - today.getTimezoneOffset());
return {
id: null,
title: "",
eventDate: today.toISOString().slice(0, 10),
location: "",
description: "",
participants: "",
meetingId: "",
photos: [],
refused: [],
};
}
+97 -109
View File
@@ -4,15 +4,10 @@ import { asc, desc, eq, sql } from "drizzle-orm";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
import { ImageField } from "@/components/ImageField";
import { can } from "@/lib/rbac";
import {
addPastMeetingPhoto,
createPastMeeting,
deletePastMeeting,
deletePastMeetingPhoto,
updatePastMeeting,
} from "../actions";
import { splitLines } from "@/lib/site-settings";
import { PastMeetingDialogButton } from "./PastMeetingDialog";
import { emptyDraft, type ArchiveDraft, type MeetingOption } from "./draft";
export const dynamic = "force-dynamic";
@@ -22,6 +17,10 @@ function dateValue(date: Date) {
return local.toISOString().slice(0, 10);
}
function formatDate(date: Date) {
return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "long", year: "numeric" }).format(date);
}
export default async function PastMeetingsAdminPage() {
const session = await getSession();
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
@@ -52,121 +51,110 @@ export default async function PastMeetingsAdminPage() {
const refusalsByMeeting = new Map<number, string[]>();
for (const refusal of refusals) refusalsByMeeting.set(refusal.meetingId, [...(refusalsByMeeting.get(refusal.meetingId) ?? []), refusal.attendeeName]);
const meetingOptions: MeetingOption[] = meetingRows.map((meeting) => ({
id: meeting.id,
registered: Number(meeting.registered),
label: `${meeting.title} — ${formatDate(meeting.startsAt)} (${Number(meeting.registered)} inscrit${Number(meeting.registered) > 1 ? "s" : ""})`,
}));
const drafts: ArchiveDraft[] = archives.map((archive) => ({
id: archive.id,
title: archive.title,
eventDate: dateValue(archive.eventDate),
location: archive.location ?? "",
description: archive.description ?? "",
participants: archive.participants ?? "",
meetingId: archive.meetingId ? String(archive.meetingId) : "",
// `bytes` ne sert qu'aux photos à envoyer : celles déjà en base ne repartent pas.
photos: (photosByPast.get(archive.id) ?? []).map((photo) => ({
key: `photo-${photo.id}`,
id: photo.id,
imageUrl: photo.imageUrl,
caption: photo.caption ?? "",
bytes: 0,
})),
refused: archive.meetingId ? refusalsByMeeting.get(archive.meetingId) ?? [] : [],
}));
return (
<div style={{ display: "grid", gap: 28 }}>
<div style={{ display: "grid", gap: 24 }}>
<section style={panelStyle}>
<h2 className="font-display" style={{ margin: "0 0 6px", fontSize: 20, color: "#26201a" }}>Nouvelle rencontre passée</h2>
<p style={{ color: "#8c8068", fontSize: 13, margin: "0 0 18px" }}>Les trois archives les plus récentes apparaissent sur « L'association » et la page complète rassemble toutes les rencontres passées.</p>
<form action={createPastMeeting} className="grid grid-2" style={{ gap: 16 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16, flexWrap: "wrap" }}>
<div>
<label className="field-label">Titre</label>
<input name="title" className="field" required />
<h2 className="font-display" style={{ margin: "0 0 6px", fontSize: 20, color: "#26201a" }}>Rencontres passées</h2>
<p style={{ color: "#8c8068", fontSize: 13, margin: 0, maxWidth: 620, lineHeight: 1.6 }}>
Une fenêtre par rencontre : récit, participants et photos au même endroit. Les trois plus récentes
apparaissent sur « L&apos;association », toutes sur la page des rencontres passées.
</p>
</div>
<div>
<label className="field-label">Date</label>
<input name="eventDate" type="date" className="field" required />
</div>
<div>
<label className="field-label">Lieu</label>
<input name="location" className="field" />
</div>
<div>
<label className="field-label">Rencontre liée</label>
<select name="meetingId" className="field" defaultValue="">
<option value="">Aucune</option>
{meetingRows.map((meeting) => <option key={meeting.id} value={meeting.id}>{meeting.title} ({Number(meeting.registered)} participants)</option>)}
</select>
</div>
<div style={{ gridColumn: "1 / -1" }}>
<label className="field-label">Description</label>
<textarea name="description" rows={3} className="field" />
</div>
<div style={{ gridColumn: "1 / -1" }}>
<label className="field-label">Participants manuels (un par ligne, uniquement sans rencontre liée)</label>
<textarea name="participants" rows={3} className="field" />
</div>
<button type="submit" style={primaryButton}>Publier l'archive</button>
</form>
<PastMeetingDialogButton draft={emptyDraft()} meetings={meetingOptions} style={primaryButton}>
Nouvelle rencontre passée
</PastMeetingDialogButton>
</div>
</section>
<section>
<div style={sectionTitle}>Archives publiées</div>
<div className="grid grid-3" style={{ gap: 18 }}>
{archives.map((archive) => {
const refused = archive.meetingId ? refusalsByMeeting.get(archive.meetingId) ?? [] : [];
return (
<article key={archive.id} style={cardStyle}>
{refused.length > 0 && (
<div style={{ background: "#fbe9e6", border: "1px solid #f0c4bb", color: "#b53a25", borderRadius: 8, padding: "11px 13px", fontSize: 12.5, lineHeight: 1.5, marginBottom: 13 }}>
<strong>Droit à l'image refusé :</strong> {refused.join(", ")}. Vérifiez que ces personnes ne figurent pas sur les photos publiées.
</div>
)}
<form action={updatePastMeeting} style={{ display: "grid", gap: 12 }}>
<input type="hidden" name="id" value={archive.id} />
<div>
<label className="field-label">Titre</label>
<input name="title" defaultValue={archive.title} className="field" required />
</div>
<div>
<label className="field-label">Date</label>
<input name="eventDate" type="date" defaultValue={dateValue(archive.eventDate)} className="field" required />
</div>
<div>
<label className="field-label">Lieu</label>
<input name="location" defaultValue={archive.location ?? ""} className="field" />
</div>
<div>
<label className="field-label">Rencontre liée</label>
<select name="meetingId" className="field" defaultValue={archive.meetingId ?? ""}>
<option value="">Aucune</option>
{meetingRows.map((meeting) => <option key={meeting.id} value={meeting.id}>{meeting.title} ({Number(meeting.registered)})</option>)}
</select>
</div>
<div>
<label className="field-label">Description</label>
<textarea name="description" rows={3} defaultValue={archive.description ?? ""} className="field" />
</div>
<div>
<label className="field-label">Participants manuels</label>
<textarea name="participants" rows={3} defaultValue={archive.participants ?? ""} className="field" />
</div>
<button type="submit" style={primaryButton}>Enregistrer</button>
</form>
<div style={{ marginTop: 14, borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
<form action={addPastMeetingPhoto} style={{ display: "grid", gap: 10 }}>
<input type="hidden" name="pastMeetingId" value={archive.id} />
<ImageField name="imageUrl" label="Ajouter une photo" height={100} />
<input name="caption" className="field" placeholder="Légende" />
<button type="submit" style={primaryButton}>Ajouter la photo</button>
</form>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, marginTop: 12 }}>
{(photosByPast.get(archive.id) ?? []).map((photo) => (
<form key={photo.id} action={deletePastMeetingPhoto}>
<input type="hidden" name="id" value={photo.id} />
<button type="submit" title="Supprimer" aria-label={`Supprimer la photo ${photo.caption ?? ""}`} style={{ width: "100%", aspectRatio: "1 / 1", border: "1px solid #e6dcc6", borderRadius: 8, cursor: "pointer", background: `center/cover no-repeat url(${photo.imageUrl})` }} />
</form>
))}
</div>
</div>
<form action={deletePastMeeting} style={{ marginTop: 12 }}>
<input type="hidden" name="id" value={archive.id} />
<button type="submit" style={dangerButton}>Supprimer l'archive</button>
</form>
</article>
);
})}
</div>
{archives.length === 0 && <div style={emptyStyle}>Aucune rencontre passée publiée.</div>}
{archives.length === 0 ? (
<div style={emptyStyle}>Aucune rencontre passée publiée.</div>
) : (
<div className="grid grid-3" style={{ gap: 18 }}>
{archives.map((archive, index) => {
const draft = drafts[index];
const archivePhotos = photosByPast.get(archive.id) ?? [];
const participantCount = archive.meetingId
? meetingOptions.find((option) => option.id === archive.meetingId)?.registered ?? 0
: splitLines(archive.participants ?? "").length;
const cover = archivePhotos[0]?.imageUrl;
return (
<PastMeetingDialogButton key={archive.id} draft={draft} meetings={meetingOptions} style={cardButton}>
<span
className={cover ? "pm-card__cover" : "pm-card__cover pm-card__cover--empty"}
style={{ display: "block", borderRadius: "10px 10px 0 0", ...(cover ? { backgroundImage: `url(${cover})` } : {}) }}
>
{archivePhotos.length > 0 && (
<span className="pm-card__count">{archivePhotos.length} photo{archivePhotos.length > 1 ? "s" : ""}</span>
)}
</span>
<span className="pm-card__body" style={{ display: "flex" }}>
<span style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>{formatDate(archive.eventDate)}</span>
<span className="font-display pm-card__title" style={{ display: "-webkit-box" }}>{archive.title}</span>
{archive.location && <span className="pm-line" style={{ color: "#6c6150", fontSize: 13, fontWeight: 700 }}>{archive.location}</span>}
{archive.description && <span className="pm-card__text" style={{ display: "-webkit-box" }}>{archive.description}</span>}
<span className="pm-card__meta">
<span>{participantCount} participant{participantCount > 1 ? "s" : ""}</span>
<span className="pm-card__more">Modifier →</span>
</span>
</span>
{draft.refused.length > 0 && (
<span style={{ display: "block", background: "#fbe9e6", color: "#b53a25", fontSize: 12, fontWeight: 700, padding: "8px 14px" }}>
Droit à l&apos;image refusé par {draft.refused.length} participant{draft.refused.length > 1 ? "s" : ""}
</span>
)}
</PastMeetingDialogButton>
);
})}
</div>
)}
</section>
</div>
);
}
const panelStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 22 };
const cardStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 16 };
const sectionTitle: CSSProperties = { fontSize: 12.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 };
const primaryButton: CSSProperties = { border: "none", background: "#13324F", color: "#fff", fontWeight: 800, fontSize: 13.5, padding: "11px 16px", borderRadius: 8, cursor: "pointer" };
const dangerButton: CSSProperties = { width: "100%", border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 800, fontSize: 13, padding: "10px 14px", borderRadius: 8, cursor: "pointer" };
const cardButton: CSSProperties = {
display: "flex",
flexDirection: "column",
height: 424,
textAlign: "left",
background: "#fff",
border: "1px solid #e6dcc6",
borderRadius: 10,
overflow: "hidden",
padding: 0,
cursor: "pointer",
font: "inherit",
};
const emptyStyle: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 24, color: "#8c8068" };
+464
View File
@@ -850,6 +850,81 @@ img {
min-width: 0;
}
/* ---- Backend sans barre latérale (espace adhérent) ----
Un adhérent n'a que les trois onglets de son espace : une barre latérale
les répéterait. `.backend` étant une rangée flex, la variante repasse en
bloc pour que l'entête et le contenu s'empilent simplement. */
.backend--plain {
display: block;
}
/* L'entête s'aligne sur la colonne de contenu : sans cela le logo collerait
au bord de l'écran pendant que le contenu reste centré. */
.backend-header--plain {
padding: 14px max(24px, calc((100% - 1040px) / 2));
}
.backend-brand {
display: flex;
align-items: center;
gap: 11px;
margin-right: auto;
min-width: 0;
text-decoration: none;
}
.backend-brand__mark {
display: inline-flex;
flex-shrink: 0;
padding: 5px;
border-radius: 11px;
background: #fff;
border: 1px solid #e6dcc6;
}
.backend-brand__text {
display: flex;
flex-direction: column;
line-height: 1.15;
min-width: 0;
}
.backend-brand__name {
font-size: 17px;
font-weight: 800;
color: var(--ink-strong);
}
.backend-brand__sub {
font-size: 11.5px;
color: var(--muted-3);
}
/* Sans barre latérale, il n'y a plus de tiroir où loger les actions sur
téléphone : elles restent dans l'entête à toutes les largeurs. */
.backend-header--plain .backend-header__actions {
display: flex;
}
.backend-content--plain {
width: 100%;
max-width: 1040px;
margin: 0 auto;
}
@media (max-width: 1024px) {
.backend-header--plain {
padding: 11px 16px;
}
.backend-content--plain {
max-width: none;
}
}
/* Le nom de marque s'efface avant les actions : le logo suffit à situer la
page, deux boutons tronqués ne servent à rien. */
@media (max-width: 560px) {
.backend-brand__text {
display: none;
}
.backend-header--plain .backend-action {
padding: 8px 11px;
font-size: 13px;
}
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
@@ -1039,6 +1114,92 @@ img {
text-underline-offset: 2px;
}
/* Éditeur visuel des informations : ce qu'on voit est ce qui sera publié. La
zone reprend `.rich-text` pour le rythme des paragraphes et des listes, de
sorte que l'éditeur est son propre aperçu. */
.rich-editor-toolbar {
display: flex;
align-items: center;
gap: 3px;
flex-wrap: wrap;
border: 1px solid var(--field-border);
border-bottom: none;
border-radius: 10px 10px 0 0;
background: #fff;
padding: 6px 7px;
}
.rich-editor-tool {
min-width: 30px;
height: 28px;
padding: 0 9px;
border: none;
border-radius: 7px;
background: transparent;
color: var(--muted);
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.rich-editor-tool:hover {
background: var(--cream-3);
color: var(--ink-strong);
}
.rich-editor-tool.is-active {
background: var(--gold);
color: #33291d;
}
.rich-editor-separator {
width: 1px;
height: 18px;
margin: 0 5px;
background: var(--border-2);
}
.rich-editor-link {
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
border: 1px solid var(--field-border);
border-bottom: none;
background: var(--cream);
padding: 8px 9px;
}
.rich-editor-link .field {
flex: 1;
min-width: 180px;
background: #fff;
}
.rich-editor {
position: relative;
min-height: 190px;
border: 1px solid var(--field-border);
border-radius: 0 0 10px 10px;
background: var(--field-bg);
padding: 13px 15px;
overflow-y: auto;
cursor: text;
}
.rich-editor:focus {
outline: 2px solid rgba(44, 111, 179, 0.35);
outline-offset: 1px;
}
/* Le texte d'invite s'efface dès la première frappe : `:empty` ne suffit pas,
le navigateur laisse un paragraphe vide dans la zone. Il est posé en
superposition et non à la place du paragraphe : masquer celui-ci ferait
tomber le curseur à côté, et le navigateur écrirait du texte nu hors de tout
bloc. */
.rich-editor[data-empty="true"]::before {
content: attr(data-placeholder);
position: absolute;
left: 15px;
right: 15px;
top: 13px;
color: var(--muted-4);
pointer-events: none;
}
/* Rédaction d'une information : éditeur et listes côte à côte, empilés dès
que la colonne de droite n'a plus la place d'être lisible. */
@media (max-width: 1100px) {
@@ -1644,3 +1805,306 @@ img {
}
}
/* ---- Rencontres passées : cartes égales et fenêtre de lecture ----
Les cartes ont une hauteur fixe quels que soient la longueur du texte et le
nombre de photos : c'est la grille qui doit rester lisible, le détail vit
dans la fenêtre. D'où les `line-clamp` — un CSS que des styles en ligne ne
peuvent pas porter, comme les points de rupture ci-dessous. */
.pm-card {
display: flex;
flex-direction: column;
height: 424px;
width: 100%;
text-align: left;
background: #fff;
border: 1px solid #e6dcc6;
border-radius: 12px;
overflow: hidden;
padding: 0;
cursor: pointer;
font-family: inherit;
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.pm-card:hover,
.pm-card:focus-visible {
transform: translateY(-3px);
box-shadow: 0 14px 30px rgba(38, 32, 26, 0.13);
}
.pm-card__cover {
position: relative;
height: 178px;
flex: none;
background: #ece4d3 center/cover no-repeat;
}
.pm-card__cover--empty {
background: repeating-linear-gradient(135deg, #efe7d6, #efe7d6 12px, #e7ddc8 12px, #e7ddc8 24px);
}
.pm-card__count {
position: absolute;
right: 10px;
bottom: 10px;
background: rgba(19, 50, 79, 0.82);
color: #fff;
border-radius: 999px;
padding: 5px 11px;
font-size: 12px;
font-weight: 800;
}
.pm-card__body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
padding: 15px 17px 16px;
}
.pm-card__title {
margin: 6px 0 4px;
font-size: 21px;
line-height: 1.2;
color: #26201a;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.pm-card__text {
margin: 6px 0 0;
color: #8c8068;
font-size: 13.5px;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.pm-card__meta {
margin-top: auto;
padding-top: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: #6c6150;
font-size: 12.5px;
font-weight: 800;
}
.pm-card__more {
color: #13324f;
}
.pm-line {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ---- Fenêtre (site public et backoffice) ---- */
.pm-backdrop {
position: fixed;
inset: 0;
z-index: 90;
background: rgba(19, 50, 79, 0.74);
backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
overflow: auto;
}
.pm-panel {
width: min(1080px, 100%);
max-height: min(92vh, 940px);
background: #fff;
border-radius: 16px;
overflow: hidden;
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.85fr);
box-shadow: 0 30px 70px rgba(15, 25, 40, 0.38);
}
.pm-panel--form {
grid-template-columns: minmax(0, 1fr);
width: min(920px, 100%);
}
.pm-panel__media {
background: #101f2e;
display: flex;
flex-direction: column;
min-height: 0;
}
.pm-panel__stage {
position: relative;
flex: 1;
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
padding: 14px;
}
.pm-panel__stage img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 8px;
}
.pm-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 42px;
height: 42px;
border-radius: 999px;
border: none;
background: rgba(255, 255, 255, 0.9);
color: #13324f;
font-size: 20px;
font-weight: 800;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.pm-arrow:hover {
background: #fff;
}
.pm-arrow--prev {
left: 12px;
}
.pm-arrow--next {
right: 12px;
}
.pm-thumbs {
display: flex;
gap: 8px;
padding: 10px 12px 14px;
overflow-x: auto;
}
.pm-thumb {
flex: none;
width: 62px;
height: 62px;
border-radius: 8px;
border: 2px solid transparent;
background: #24384a center/cover no-repeat;
cursor: pointer;
padding: 0;
opacity: 0.62;
}
.pm-thumb--active {
border-color: #e0a63c;
opacity: 1;
}
.pm-panel__info {
padding: 26px 26px 28px;
overflow: auto;
min-height: 0;
}
.pm-panel__close {
position: absolute;
top: 16px;
right: 18px;
z-index: 2;
border: none;
background: #fff;
color: #13324f;
border-radius: 999px;
width: 38px;
height: 38px;
font-size: 18px;
font-weight: 800;
cursor: pointer;
box-shadow: 0 6px 16px rgba(15, 25, 40, 0.24);
}
.pm-body-text {
color: #5f5646;
font-size: 14.5px;
line-height: 1.75;
white-space: pre-line;
}
/* ---- Fenêtre d'édition du backoffice ---- */
.pm-form {
display: flex;
flex-direction: column;
max-height: min(92vh, 940px);
}
.pm-form__scroll {
overflow: auto;
padding: 22px 24px 8px;
}
.pm-form__foot {
border-top: 1px solid #efe7d6;
padding: 14px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: #fdfbf6;
flex-wrap: wrap;
}
.pm-dropzone {
border: 1px dashed #d6c9ad;
border-radius: 12px;
background: #fdfbf6;
padding: 18px;
text-align: center;
color: #8c8068;
font-size: 13.5px;
}
.pm-dropzone--over {
border-color: #13324f;
background: #f1f5f9;
}
.pm-photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
margin-top: 14px;
}
.pm-photo {
border: 1px solid #e6dcc6;
border-radius: 10px;
overflow: hidden;
background: #fff;
}
.pm-photo__image {
position: relative;
aspect-ratio: 4 / 3;
background: #f1ebdd center/cover no-repeat;
}
.pm-photo__tools {
display: flex;
gap: 4px;
padding: 6px;
}
.pm-photo__tool {
border: 1px solid #e6dcc6;
background: #fff;
color: #6f6450;
border-radius: 7px;
font-size: 12px;
font-weight: 800;
padding: 4px 8px;
cursor: pointer;
}
.pm-photo__tool--danger {
color: #d8472b;
border-color: #e9cfc7;
margin-left: auto;
}
@media (max-width: 860px) {
.pm-panel {
grid-template-columns: minmax(0, 1fr);
max-height: 94vh;
}
.pm-panel__stage {
min-height: 220px;
}
.pm-backdrop {
padding: 12px;
}
.pm-panel__info {
padding: 20px;
}
}
+11 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next";
import { Analytics } from "@/components/Analytics";
import { JsonLd } from "@/components/JsonLd";
import {
INDEX_FOLLOW,
@@ -11,6 +12,7 @@ import {
organizationJsonLd,
webSiteJsonLd,
} from "@/lib/seo";
import { normalizeSiteVerification } from "@/lib/analytics";
import { metadataBase, publicBaseUrl } from "@/lib/seo-server";
import { getSiteSettings, socialLinks } from "@/lib/site-settings";
import "./globals.css";
@@ -33,8 +35,14 @@ export const viewport: Viewport = {
* relatives (canonique, Open Graph, image de partage).
*/
export async function generateMetadata(): Promise<Metadata> {
const [base, settings] = await Promise.all([
metadataBase(),
getSiteSettings().catch(() => null),
]);
// Jeton « balise HTML » de Search Console, collé dans Backend › Paramètres.
const google = normalizeSiteVerification(settings?.google_site_verification);
return {
metadataBase: await metadataBase(),
metadataBase: base,
title: {
default: SITE_TITLE,
template: `%s · ${SITE_NAME}`,
@@ -63,6 +71,7 @@ export async function generateMetadata(): Promise<Metadata> {
description: SITE_DESCRIPTION,
},
robots: INDEX_FOLLOW,
verification: google ? { google } : undefined,
icons: {
icon: [{ url: "/assets/logo.png", type: "image/png" }],
apple: [{ url: "/assets/logo.png", type: "image/png" }],
@@ -108,6 +117,7 @@ export default async function RootLayout({
<body>
{children}
<JsonLd data={structuredData} />
<Analytics measurementId={settings?.google_analytics_id ?? ""} />
</body>
</html>
);
+13 -11
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { asc, desc, inArray } from "drizzle-orm";
import { JsonLd } from "@/components/JsonLd";
import { PastMeetingGallery } from "@/components/PastMeetingGallery";
import { PastMeetingCard } from "@/components/PastMeetingCard";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
import { db } from "@/db";
@@ -79,16 +79,18 @@ export default async function PastMeetingsPage() {
? registrationCounts.get(archive.meetingId) ?? 0
: splitLines(archive.participants ?? "").length;
return (
<article key={archive.id} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 8, padding: 18 }}>
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>{formatDate(archive.eventDate)}</div>
<h2 className="font-display" style={{ color: "#26201a", fontSize: 23, margin: "7px 0" }}>{archive.title}</h2>
{archive.location && <div style={{ color: "#6c6150", fontSize: 13, fontWeight: 700 }}>{archive.location}</div>}
{archive.description && <p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.65 }}>{archive.description}</p>}
<div style={{ color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 12 }}>
{participantCount} participant{participantCount > 1 ? "s" : ""} · {archivePhotos.length} photo{archivePhotos.length > 1 ? "s" : ""}
</div>
<PastMeetingGallery title={archive.title} photos={archivePhotos} />
</article>
<PastMeetingCard
key={archive.id}
meeting={{
id: archive.id,
title: archive.title,
dateLabel: formatDate(archive.eventDate),
location: archive.location,
description: archive.description,
participantCount,
photos: archivePhotos.map((photo) => ({ id: photo.id, imageUrl: photo.imageUrl, caption: photo.caption })),
}}
/>
);
})}
</div>
+26
View File
@@ -0,0 +1,26 @@
import { headers } from "next/headers";
import { gtagConfigScript, gtagSrc, isTrackedPath, normalizeMeasurementId } from "@/lib/analytics";
/**
* Balises Google Analytics 4, posées uniquement si l'identifiant de mesure est
* renseigné dans Backend › Paramètres et si la page est publique.
*
* Les deux scripts portent le nonce de la CSP (`src/middleware.ts`) ; le
* chemin est lu dans l'en-tête `x-pathname` que le middleware recopie, un
* layout n'ayant pas accès à l'URL demandée.
*/
export async function Analytics({ measurementId }: { measurementId: string }) {
const id = normalizeMeasurementId(measurementId);
if (!id) return null;
const requestHeaders = await headers();
if (!isTrackedPath(requestHeaders.get("x-pathname") ?? "/")) return null;
const nonce = requestHeaders.get("x-nonce") ?? undefined;
return (
<>
<script async src={gtagSrc(id)} nonce={nonce} />
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: gtagConfigScript(id) }} />
</>
);
}
+32 -15
View File
@@ -1,6 +1,8 @@
"use client";
import { useRef, useState } from "react";
import { useRef, useState, useTransition } from "react";
import { FIELD_MAX_BYTES, compressionSummary } from "@/lib/image-compress";
import { prepareImageFile } from "@/lib/image-compress-dom";
export function ImageField({
name,
@@ -8,35 +10,48 @@ export function ImageField({
defaultValue = "",
height = 130,
fit = "cover",
maxBytes = FIELD_MAX_BYTES,
}: {
name: string;
label: string;
defaultValue?: string;
height?: number;
fit?: "cover" | "contain";
maxBytes?: number;
}) {
const [value, setValue] = useState(defaultValue);
const [note, setNote] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [, startTransition] = useTransition();
const fileRef = useRef<HTMLInputElement>(null);
function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (!f) return;
async function onFile(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setError(null);
// Limite raisonnable : les data-URL transitent par une server action (~1 Mo max).
if (f.size > 900_000) {
setError("Image trop lourde (max ~900 Ko). Compressez-la d'abord.");
setNote(null);
setBusy(true);
try {
// Plus de refus « image trop lourde » : le navigateur redimensionne et
// compresse lui-même, en ne descendant que le nécessaire.
const prepared = await prepareImageFile(file, { maxBytes });
startTransition(() => {
setValue(prepared.dataUri);
setNote(compressionSummary(prepared));
});
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Image refusée.");
if (fileRef.current) fileRef.current.value = "";
return;
} finally {
setBusy(false);
}
const reader = new FileReader();
reader.onload = () => setValue(String(reader.result));
reader.readAsDataURL(f);
}
function clear() {
setValue("");
setError(null);
setNote(null);
if (fileRef.current) fileRef.current.value = "";
}
@@ -62,18 +77,19 @@ export function ImageField({
// eslint-disable-next-line @next/next/no-img-element
<img src={value} alt="" style={{ maxWidth: "86%", maxHeight: "86%", objectFit: "contain", display: "block" }} />
)}
{!value && <span style={{ fontSize: 13, color: "#a99c82" }}>Aucune image</span>}
{!value && <span style={{ fontSize: 13, color: "#a99c82" }}>{busy ? "Compression en cours…" : "Aucune image"}</span>}
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<button
type="button"
onClick={() => fileRef.current?.click()}
style={{ border: "1px solid #d6c9ad", background: "#fff", color: "#6f6450", fontWeight: 600, fontSize: 13, padding: "8px 14px", borderRadius: 9, cursor: "pointer" }}
disabled={busy}
style={{ border: "1px solid #d6c9ad", background: "#fff", color: "#6f6450", fontWeight: 600, fontSize: 13, padding: "8px 14px", borderRadius: 9, cursor: busy ? "progress" : "pointer" }}
>
{value ? "Changer l'image" : "Choisir une image"}
{busy ? "Compression…" : value ? "Changer l'image" : "Choisir une image"}
</button>
{value && (
{value && !busy && (
<button
type="button"
onClick={clear}
@@ -85,6 +101,7 @@ export function ImageField({
<input ref={fileRef} type="file" accept="image/*" onChange={onFile} style={{ display: "none" }} />
</div>
{note && !error && <div style={{ fontSize: 12.5, color: "#8c8068", marginTop: 6 }}>{note}</div>}
{error && <div style={{ fontSize: 12.5, color: "#a3372e", marginTop: 6 }}>{error}</div>}
</div>
);
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
/**
* Enveloppe commune aux fenêtres du site et du backoffice : fond assombri,
* fermeture au clic extérieur, à la touche Échap et par le bouton de fermeture,
* page figée derrière.
*
* Le blocage du défilement de l'arrière-plan compte plus qu'il n'en a l'air :
* sans lui, la molette dans une galerie plein écran fait défiler la page
* *derrière* la fenêtre, et on la retrouve ailleurs en refermant.
*
* Le rendu passe par un **portail** vers `<body>`, et ce n'est pas un détail :
* une carte survolée porte un `transform`, qui redéfinit le bloc conteneur des
* éléments `position: fixed` qu'elle contient. Rendue sur place, la fenêtre
* s'ouvrirait donc *dans* la carte. Le portail la sort aussi du bouton qui
* l'ouvre, où des champs de saisie n'auraient rien à faire.
*/
export function ModalShell({
label,
onClose,
panelClassName = "pm-panel",
children,
}: {
label: string;
onClose: () => void;
panelClassName?: string;
children: ReactNode;
}) {
const panelRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
panelRef.current?.focus();
return () => {
document.body.style.overflow = previous;
};
}, []);
useEffect(() => {
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") onClose();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
if (!mounted) return null;
return createPortal(
<div className="pm-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
<div
ref={panelRef}
className={panelClassName}
role="dialog"
aria-modal="true"
aria-label={label}
tabIndex={-1}
style={{ position: "relative", outline: "none" }}
>
<button type="button" className="pm-panel__close" onClick={onClose} aria-label="Fermer">
×
</button>
{children}
</div>
</div>,
document.body,
);
}
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { ModalShell } from "./ModalShell";
export type PastMeetingPhotoView = { id: number; imageUrl: string; caption: string | null };
export type PastMeetingView = {
id: number;
title: string;
/** Date déjà mise en forme côté serveur : le composant ne refait pas de locale. */
dateLabel: string;
location: string | null;
description: string | null;
participantCount: number;
photos: PastMeetingPhotoView[];
};
/**
* Carte d'une rencontre passée, de hauteur fixe, et sa fenêtre de lecture.
*
* La carte ne montre qu'un aperçu — couverture, titre sur deux lignes, trois
* lignes de texte, compteurs : une rencontre richement racontée et une autre en
* deux phrases occupent ainsi la même place dans la grille. Le récit complet et
* la totalité des photos sont dans la fenêtre, où la place ne manque plus.
*/
export function PastMeetingCard({ meeting }: { meeting: PastMeetingView }) {
const [open, setOpen] = useState(false);
const cover = meeting.photos[0]?.imageUrl;
return (
<article className="pm-card" style={{ position: "relative" }}>
<div
className={cover ? "pm-card__cover" : "pm-card__cover pm-card__cover--empty"}
style={cover ? { backgroundImage: `url(${cover})` } : undefined}
>
{meeting.photos.length > 0 && (
<span className="pm-card__count">
{meeting.photos.length} photo{meeting.photos.length > 1 ? "s" : ""}
</span>
)}
</div>
<div className="pm-card__body">
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>{meeting.dateLabel}</div>
<h3 className="font-display pm-card__title">{meeting.title}</h3>
{meeting.location && (
<div className="pm-line" style={{ color: "#6c6150", fontSize: 13, fontWeight: 700 }}>{meeting.location}</div>
)}
{meeting.description && <p className="pm-card__text">{meeting.description}</p>}
<div className="pm-card__meta">
<span>
{meeting.participantCount} participant{meeting.participantCount > 1 ? "s" : ""}
</span>
<span className="pm-card__more">Voir la rencontre →</span>
</div>
</div>
{/* Toute la carte est cliquable, mais un seul élément interactif : le
titre reste un vrai titre, et la tabulation ne traverse pas la carte. */}
<button
type="button"
onClick={() => setOpen(true)}
aria-label={`Ouvrir la rencontre : ${meeting.title}`}
style={{ position: "absolute", inset: 0, border: "none", background: "none", cursor: "pointer", borderRadius: 12 }}
/>
{open && <PastMeetingModal meeting={meeting} onClose={() => setOpen(false)} />}
</article>
);
}
function PastMeetingModal({ meeting, onClose }: { meeting: PastMeetingView; onClose: () => void }) {
const photos = meeting.photos;
const [index, setIndex] = useState(0);
const current = photos[index];
const move = useCallback(
(step: number) => {
if (photos.length === 0) return;
setIndex((value) => (value + step + photos.length) % photos.length);
},
[photos.length],
);
useEffect(() => {
function onKey(event: KeyboardEvent) {
if (event.key === "ArrowRight") move(1);
if (event.key === "ArrowLeft") move(-1);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [move]);
return (
<ModalShell
label={`Rencontre : ${meeting.title}`}
onClose={onClose}
panelClassName={photos.length > 0 ? "pm-panel" : "pm-panel pm-panel--form"}
>
{photos.length > 0 && (
<div className="pm-panel__media">
<div className="pm-panel__stage">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={current.imageUrl} alt={current.caption ?? `${meeting.title} — photo ${index + 1}`} />
{photos.length > 1 && (
<>
<button type="button" className="pm-arrow pm-arrow--prev" onClick={() => move(-1)} aria-label="Photo précédente">‹</button>
<button type="button" className="pm-arrow pm-arrow--next" onClick={() => move(1)} aria-label="Photo suivante">›</button>
</>
)}
<span style={{ position: "absolute", left: 16, bottom: 16, background: "rgba(16,31,46,0.78)", color: "#fff", borderRadius: 999, padding: "4px 11px", fontSize: 12, fontWeight: 800 }}>
{index + 1} / {photos.length}
</span>
</div>
{current.caption && (
<div style={{ color: "#cfe0ee", fontSize: 13, padding: "0 16px", lineHeight: 1.5 }}>{current.caption}</div>
)}
{photos.length > 1 && (
<div className="pm-thumbs">
{photos.map((photo, position) => (
<button
key={photo.id}
type="button"
className={position === index ? "pm-thumb pm-thumb--active" : "pm-thumb"}
style={{ backgroundImage: `url(${photo.imageUrl})` }}
onClick={() => setIndex(position)}
aria-label={`Photo ${position + 1}`}
aria-current={position === index}
/>
))}
</div>
)}
</div>
)}
<div className="pm-panel__info">
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800, letterSpacing: "0.04em", textTransform: "uppercase" }}>{meeting.dateLabel}</div>
<h2 className="font-display" style={{ margin: "8px 0 10px", color: "#26201a", fontSize: 30, lineHeight: 1.15, paddingRight: 34 }}>{meeting.title}</h2>
{meeting.location && <div style={{ color: "#6c6150", fontSize: 14, fontWeight: 700, marginBottom: 6 }}>{meeting.location}</div>}
<div style={{ color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 16 }}>
{meeting.participantCount} participant{meeting.participantCount > 1 ? "s" : ""}
{photos.length > 0 && ` · ${photos.length} photo${photos.length > 1 ? "s" : ""}`}
</div>
{meeting.description && <p className="pm-body-text">{meeting.description}</p>}
</div>
</ModalShell>
);
}
-67
View File
@@ -1,67 +0,0 @@
"use client";
import { useState } from "react";
export function PastMeetingGallery({
title,
photos,
}: {
title: string;
photos: { id: number; imageUrl: string; caption: string | null }[];
}) {
const [open, setOpen] = useState<number | null>(null);
if (photos.length === 0) return null;
const current = open == null ? null : photos.find((photo) => photo.id === open);
return (
<>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, marginTop: 14 }}>
{photos.slice(0, 4).map((photo, index) => (
<button
key={photo.id}
type="button"
onClick={() => setOpen(photo.id)}
aria-label={`Ouvrir photo ${index + 1}`}
style={{
position: "relative",
border: "none",
padding: 0,
aspectRatio: "1 / 1",
borderRadius: 8,
overflow: "hidden",
cursor: "pointer",
background: `center/cover no-repeat url(${photo.imageUrl})`,
}}
>
{index === 3 && photos.length > 4 && (
<span style={{ position: "absolute", inset: 0, background: "rgba(19,50,79,0.62)", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800 }}>
+{photos.length - 3}
</span>
)}
</button>
))}
</div>
{current && (
<div
role="dialog"
aria-modal="true"
aria-label={`Galerie ${title}`}
onClick={() => setOpen(null)}
style={{ position: "fixed", inset: 0, background: "rgba(19,50,79,0.72)", zIndex: 80, padding: 24, display: "flex", alignItems: "center", justifyContent: "center" }}
>
<div onClick={(event) => event.stopPropagation()} style={{ maxWidth: 980, width: "100%" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={current.imageUrl} alt={current.caption ?? title} style={{ width: "100%", maxHeight: "78vh", objectFit: "contain", background: "#fff", borderRadius: 12 }} />
<div style={{ marginTop: 12, display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center" }}>
<div style={{ color: "#fff", fontWeight: 700 }}>{current.caption ?? title}</div>
<button type="button" onClick={() => setOpen(null)} style={{ border: "none", background: "#fff", color: "#13324F", borderRadius: 999, padding: "9px 16px", fontWeight: 800, cursor: "pointer" }}>
Fermer
</button>
</div>
</div>
</div>
)}
</>
);
}
+262
View File
@@ -0,0 +1,262 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { richTextToEditorHtml } from "@/lib/rich-text";
import { serializeToRichText } from "@/lib/rich-text-dom";
/**
* Éditeur visuel : on écrit, on sélectionne, on clique sur **G**, et le texte
* devient gras à l'écran. Aucune syntaxe à connaître.
*
* Ce qui part au serveur reste pourtant le format balisé restreint : à chaque
* frappe, `serializeToRichText` retraverse le contenu édité et n'en garde que
* le gras, l'italique, les listes, les sous-titres et les liens. Le HTML de
* l'éditeur ne quitte jamais la page — c'était là, et non dans l'édition
* visuelle, que se trouvait le risque.
*
* `document.execCommand` est officiellement déprécié mais reste implémenté par
* tous les navigateurs, et c'est la seule façon d'obtenir une édition riche
* sans embarquer un moteur d'édition complet. Le jour où il faudrait en
* changer, seul ce fichier bougerait : le contrat de stockage est le
* sérialiseur, pas l'éditeur.
*/
type Tool = {
key: string;
label: string;
title: string;
command: string;
argument?: string;
/** Nom d'état interrogé pour allumer le bouton quand le curseur est dedans. */
state?: string;
render?: "bold" | "italic";
};
const TOOLS: Tool[] = [
{ key: "bold", label: "G", title: "Gras (Ctrl+B)", command: "bold", state: "bold", render: "bold" },
{ key: "italic", label: "I", title: "Italique (Ctrl+I)", command: "italic", state: "italic", render: "italic" },
{ key: "h4", label: "Sous-titre", title: "Transformer la ligne en sous-titre", command: "formatBlock", argument: "<h4>" },
{ key: "ul", label: "Liste à puces", title: "Liste à puces", command: "insertUnorderedList", state: "insertUnorderedList" },
{ key: "ol", label: "Liste numérotée", title: "Liste numérotée", command: "insertOrderedList", state: "insertOrderedList" },
];
export function RichTextEditor({
value,
onChange,
placeholder = "Rédigez votre message…",
ariaLabel = "Message",
}: {
value: string;
onChange: (markup: string) => void;
placeholder?: string;
ariaLabel?: string;
}) {
const editorRef = useRef<HTMLDivElement>(null);
const [active, setActive] = useState<Record<string, boolean>>({});
const [linkUrl, setLinkUrl] = useState<string | null>(null);
const savedRange = useRef<Range | null>(null);
const sync = useCallback(() => {
const editor = editorRef.current;
if (!editor) return;
editor.dataset.empty = String(editor.textContent?.trim().length === 0);
onChange(serializeToRichText(editor));
}, [onChange]);
// Contenu initial posé une seule fois : réécrire le HTML à chaque frappe
// replacerait le curseur au début de la zone.
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
editor.innerHTML = richTextToEditorHtml(value);
editor.dataset.empty = String(editor.textContent?.trim().length === 0);
try {
// Sans cela, Chrome écrit `<span style="font-weight:bold">` au lieu de
// `<b>` — le sérialiseur, qui ne lit pas les styles, perdrait le gras.
document.execCommand("styleWithCSS", false, "false");
document.execCommand("defaultParagraphSeparator", false, "p");
} catch {
/* Navigateur sans execCommand : la saisie reste possible, sans mise en forme. */
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Les boutons s'allument selon l'endroit où se trouve le curseur.
useEffect(() => {
function refresh() {
const editor = editorRef.current;
if (!editor || !editor.contains(document.getSelection()?.anchorNode ?? null)) return;
const next: Record<string, boolean> = {};
for (const tool of TOOLS) {
if (!tool.state) continue;
try {
next[tool.key] = document.queryCommandState(tool.state);
} catch {
next[tool.key] = false;
}
}
setActive(next);
}
document.addEventListener("selectionchange", refresh);
return () => document.removeEventListener("selectionchange", refresh);
}, []);
function run(command: string, argument?: string) {
editorRef.current?.focus();
try {
document.execCommand(command, false, argument);
} catch {
/* Commande refusée : on laisse le texte tel quel. */
}
sync();
}
function applyTool(tool: Tool) {
// Re-cliquer sur « Sous-titre » revient à un paragraphe ordinaire.
if (tool.command === "formatBlock" && currentBlock() === "H4") {
run("formatBlock", "<p>");
return;
}
run(tool.command, tool.argument);
}
function currentBlock(): string {
try {
return String(document.queryCommandValue("formatBlock") ?? "").toUpperCase();
} catch {
return "";
}
}
function openLink() {
const selection = document.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
setLinkUrl("");
savedRange.current = null;
return;
}
// La sélection est perdue dès que le champ d'adresse prend le focus :
// on la met de côté pour la retrouver à la validation.
savedRange.current = selection.getRangeAt(0).cloneRange();
setLinkUrl("https://");
}
function confirmLink() {
const url = (linkUrl ?? "").trim();
setLinkUrl(null);
if (!url || !savedRange.current) return;
const selection = document.getSelection();
selection?.removeAllRanges();
selection?.addRange(savedRange.current);
savedRange.current = null;
// Une adresse non http(s) est écartée par le sérialiseur : le libellé
// reste, le lien disparaît.
run("createLink", url);
}
function onPaste(event: React.ClipboardEvent<HTMLDivElement>) {
event.preventDefault();
const html = event.clipboardData.getData("text/html");
const text = event.clipboardData.getData("text/plain");
if (html) {
// Le HTML collé — d'un Word, d'une page web — est analysé dans un
// document **inerte** : `DOMParser` n'exécute rien et ne charge aucune
// ressource, contrairement à une affectation d'`innerHTML`. On le
// repasse ensuite par notre format, donc seule la mise en forme connue
// survit ; on ne réinjecte que du HTML produit par nous.
const parsed = new DOMParser().parseFromString(html, "text/html");
run("insertHTML", richTextToEditorHtml(serializeToRichText(parsed.body)));
return;
}
run("insertText", text);
}
return (
<div>
<div className="rich-editor-toolbar" role="toolbar" aria-label="Mise en forme">
{TOOLS.map((tool) => (
<button
key={tool.key}
type="button"
title={tool.title}
aria-pressed={active[tool.key] ?? false}
onMouseDown={(event) => event.preventDefault()}
onClick={() => applyTool(tool)}
className={`rich-editor-tool${active[tool.key] ? " is-active" : ""}`}
style={{
fontWeight: tool.render === "bold" ? 800 : undefined,
fontStyle: tool.render === "italic" ? "italic" : undefined,
}}
>
{tool.label}
</button>
))}
<span className="rich-editor-separator" />
<button
type="button"
title="Insérer un lien sur le texte sélectionné"
onMouseDown={(event) => event.preventDefault()}
onClick={openLink}
className="rich-editor-tool"
>
Lien
</button>
<button
type="button"
title="Retirer la mise en forme de la sélection"
onMouseDown={(event) => event.preventDefault()}
onClick={() => run("removeFormat")}
className="rich-editor-tool"
>
Effacer la mise en forme
</button>
</div>
{linkUrl !== null && (
<div className="rich-editor-link">
{savedRange.current ? (
<>
<input
className="field"
value={linkUrl}
autoFocus
placeholder="https://…"
onChange={(event) => setLinkUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
confirmLink();
}
if (event.key === "Escape") setLinkUrl(null);
}}
/>
<button type="button" className="rich-editor-tool" onClick={confirmLink}>Ajouter</button>
<button type="button" className="rich-editor-tool" onClick={() => setLinkUrl(null)}>Annuler</button>
</>
) : (
<>
<span className="field-hint">Sélectionnez d&apos;abord le texte à transformer en lien.</span>
<button type="button" className="rich-editor-tool" onClick={() => setLinkUrl(null)}>Fermer</button>
</>
)}
</div>
)}
<div
ref={editorRef}
className="rich-editor rich-text"
contentEditable
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
aria-label={ariaLabel}
data-placeholder={placeholder}
onInput={sync}
onBlur={sync}
onPaste={onPaste}
/>
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { useState, type CSSProperties } from "react";
import { IMPLICIT_TLS_PORT, impliedSecure, tlsMismatch } from "@/lib/smtp-config";
/**
* Réglages d'un serveur SMTP générique.
*
* Client parce que le port et le chiffrement sont **un seul choix** présenté
* en deux champs : changer le port réaligne la case, et un couple incohérent
* s'annonce à l'écran plutôt qu'en échec de connexion illisible. Voir
* `src/lib/smtp-config.ts` pour le pourquoi des deux ports.
*
* Aucun secret ne descend ici : le mot de passe enregistré n'est pas passé en
* prop, seul `hasPassword` dit s'il faut proposer de le conserver.
*/
const PRESETS = [
{ port: IMPLICIT_TLS_PORT, label: "465 · chiffré dès l'ouverture" },
{ port: 587, label: "587 · STARTTLS" },
];
export function SmtpAccountForm({
saveAction,
host,
port,
secure,
user,
hasPassword,
fromAddress,
fromName,
submitStyle,
}: {
saveAction: (formData: FormData) => void | Promise<void>;
host: string;
port: number;
secure: boolean;
user: string;
hasPassword: boolean;
fromAddress: string;
fromName: string;
submitStyle: CSSProperties;
}) {
const [currentHost, setHost] = useState(host);
const [currentPort, setPort] = useState(String(port));
const [currentSecure, setSecure] = useState(secure);
const numericPort = Number(currentPort);
const knownPort = Number.isFinite(numericPort) && numericPort > 0;
const warning = knownPort ? tlsMismatch(numericPort, currentSecure) : null;
/** Le port choisi vaut choix du mode : la case suit, et reste reprenable à la main. */
function applyPort(value: string) {
setPort(value);
const next = Number(value);
if (Number.isFinite(next) && next > 0) setSecure(impliedSecure(next));
}
return (
<form action={saveAction}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 12 }}>
<label className="field-label">
Serveur
<input
className="field"
name="smtpHost"
value={currentHost}
onChange={(event) => setHost(event.target.value)}
placeholder="smtp.ionos.fr"
style={{ marginTop: 6 }}
/>
</label>
<label className="field-label">
Port
<input
className="field"
name="smtpPort"
type="number"
min={1}
max={65535}
value={currentPort}
onChange={(event) => applyPort(event.target.value)}
style={{ marginTop: 6 }}
/>
</label>
<label className="field-label">
Identifiant
<input className="field" name="smtpUser" defaultValue={user} placeholder="bureau@pleinr.fr" style={{ marginTop: 6 }} />
</label>
</div>
<div style={{ display: "flex", gap: 7, marginTop: 9, flexWrap: "wrap", alignItems: "center" }}>
<span style={{ fontSize: 12, color: "#9a8d72", fontWeight: 700 }}>Ports courants</span>
{PRESETS.map((preset) => {
const on = numericPort === preset.port;
return (
<button
key={preset.port}
type="button"
onClick={() => applyPort(String(preset.port))}
style={{
border: `1px solid ${on ? "#13324F" : "#d8cdb4"}`,
background: on ? "#13324F" : "#fff",
color: on ? "#fff" : "#6c6150",
fontWeight: 700,
fontSize: 12,
padding: "6px 11px",
borderRadius: 999,
cursor: "pointer",
}}
>
{preset.label}
</button>
);
})}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 12, marginTop: 12 }}>
<label className="field-label">
Mot de passe
<input
className="field"
name="smtpPassword"
type="password"
autoComplete="new-password"
placeholder={hasPassword ? "Enregistré — laissez vide pour le conserver" : "Mot de passe de la boîte"}
style={{ marginTop: 6 }}
/>
</label>
<label className="field-label">
Adresse d&apos;expédition
<input className="field" name="fromAddress" defaultValue={fromAddress} placeholder="contact@pleinr.fr" style={{ marginTop: 6 }} />
</label>
<label className="field-label">
Nom affiché
<input className="field" name="fromName" defaultValue={fromName} style={{ marginTop: 6 }} />
</label>
</div>
<label style={{ display: "flex", gap: 9, alignItems: "center", marginTop: 12, fontSize: 13, color: "#6c6150" }}>
<input
type="checkbox"
name="smtpSecure"
checked={currentSecure}
onChange={(event) => setSecure(event.target.checked)}
/>
Connexion chiffrée dès l&apos;ouverture (port 465). Décochez pour STARTTLS (port 587).
</label>
{warning && (
<p style={{ marginTop: 8, fontSize: 12.5, color: "#a4571f", background: "#fdf3e3", border: "1px solid #ecd9b4", borderRadius: 9, padding: "9px 11px" }}>
{warning}
</p>
)}
<p className="field-hint" style={{ marginTop: 8 }}>
L&apos;adresse d&apos;expédition doit appartenir au domaine qui authentifie la connexion, sinon
SPF et DKIM échouent et les messages partent en indésirable.
</p>
<div style={{ display: "flex", gap: 9, marginTop: 14, flexWrap: "wrap" }}>
<button type="submit" style={submitStyle}>Enregistrer</button>
</div>
</form>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { PRIVATE_PATHS } from "@/lib/seo";
/**
* Mesure d'audience Google Analytics 4.
*
* Module **pur** (verrouillé par `tests/analytics.test.ts`) : il ne sait ni lire
* la base ni rendre du HTML, il décide seulement ce qui part dans la page.
* L'identifiant vient du réglage `google_analytics_id` (Backend › Paramètres) :
* tant qu'il est vide, aucune balise n'est posée et aucun appel n'est fait.
*/
/** Identifiant de mesure GA4 : « G- » suivi de l'identifiant du flux. */
const MEASUREMENT_ID = /^G-[A-Z0-9]{4,24}$/;
/**
* Valide la saisie de l'administrateur avant qu'elle n'atteigne la page.
*
* Renvoie `""` pour tout ce qui n'est pas un identifiant GA4 : c'est la garde
* qui interdit d'injecter une chaîne arbitraire dans le script en ligne — un
* identifiant validé ne contient ni guillemet, ni `<`, ni `</script>`.
* Les identifiants Universal Analytics (`UA-…`), arrêtés depuis 2023, sont
* refusés : ils ne collectent plus rien.
*/
export function normalizeMeasurementId(raw: string | null | undefined): string {
const value = (raw ?? "").trim().toUpperCase();
return MEASUREMENT_ID.test(value) ? value : "";
}
/** URL du chargeur gtag.js pour un identifiant **déjà normalisé**. */
export function gtagSrc(measurementId: string): string {
const id = normalizeMeasurementId(measurementId);
return id ? `https://www.googletagmanager.com/gtag/js?id=${id}` : "";
}
/**
* Script d'amorçage de gtag.js. Il porte le nonce de la CSP comme le reste des
* scripts du site ; les scripts que gtag.js charge ensuite passent grâce à
* `'strict-dynamic'`.
*/
export function gtagConfigScript(measurementId: string): string {
const id = normalizeMeasurementId(measurementId);
if (!id) return "";
return [
"window.dataLayer=window.dataLayer||[];",
"function gtag(){dataLayer.push(arguments);}",
"gtag('js',new Date());",
`gtag('config','${id}');`,
].join("");
}
/**
* Seules les pages publiques sont mesurées.
*
* L'espace adhérent et le backoffice portent des identifiants de membres et de
* promotions dans leurs URLs : les envoyer à un tiers n'apporterait rien et
* ferait sortir des données personnelles du site.
*/
export function isTrackedPath(pathname: string): boolean {
const path = pathname.split("?")[0] ?? "/";
return !PRIVATE_PATHS.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
}
/** Jeton de validation Search Console (balise `google-site-verification`). */
export function normalizeSiteVerification(raw: string | null | undefined): string {
const value = (raw ?? "").trim();
// Google livre un jeton alphanumérique d'une quarantaine de caractères ; on
// refuse le reste — dont la balise <meta> complète, collée telle quelle —
// plutôt que de recopier dans le <head> une chaîne venue de n'importe où.
return /^[A-Za-z0-9_-]{20,128}$/.test(value) ? value : "";
}
+196
View File
@@ -0,0 +1,196 @@
/**
* Compression d'une image **dans le navigateur**, avant qu'elle ne parte vers
* une server action. Pendant du module pur `image-compress.ts`, qui porte les
* seuils et les paliers ; ici vivent le décodage, le canvas et l'encodage,
* c'est-à-dire tout ce qui a besoin d'un DOM.
*
* Trois choix méritent d'être expliqués :
*
* - **L'orientation EXIF est appliquée au décodage** (`createImageBitmap` avec
* `imageOrientation: "from-image"`). Une photo prise à l'horizontale porte
* souvent sa rotation dans ses métadonnées ; dessinée telle quelle sur un
* canvas, elle repartirait couchée, l'information perdue.
* - **Une image avec transparence ne devient jamais un JPEG** : le logo d'un
* adhérent se retrouverait sur fond noir. On passe alors par WebP, et par
* PNG si le navigateur ne sait pas encoder le WebP.
* - **Une image déjà dans le budget n'est pas touchée** : un réencodage JPEG
* dégrade toujours un peu, même à qualité élevée.
*/
import {
FIELD_MAX_BYTES,
MAX_SIDE,
base64Bytes,
compressionSteps,
needsCompression,
scaleToFit,
} from "./image-compress";
export type PreparedImage = {
/** Image prête à partir, toujours en data-URI (jamais une URL : cf. SSRF). */
dataUri: string;
width: number;
height: number;
bytes: number;
originalBytes: number;
recompressed: boolean;
/** Nom du fichier d'origine, repris comme légende par défaut. */
name: string;
};
export type PrepareOptions = {
maxBytes?: number;
maxSide?: number;
};
/** Formats que le serveur accepte tels quels (`asImageDataUri`). */
const PASSTHROUGH = /^image\/(png|jpeg|jpg|webp|gif)$/i;
function readDataUri(file: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error("Fichier illisible."));
reader.readAsDataURL(file);
});
}
type Decoded = { source: CanvasImageSource; width: number; height: number; release: () => void };
async function decode(file: File): Promise<Decoded> {
if (typeof createImageBitmap === "function") {
try {
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
return { source: bitmap, width: bitmap.width, height: bitmap.height, release: () => bitmap.close() };
} catch {
// Navigateur sans `imageOrientation` (ou format non décodable) : on tente
// la voie classique avant d'abandonner.
}
}
const url = URL.createObjectURL(file);
try {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const element = new Image();
element.onload = () => resolve(element);
element.onerror = () => reject(new Error("decode"));
element.src = url;
});
return {
source: image,
width: image.naturalWidth,
height: image.naturalHeight,
release: () => URL.revokeObjectURL(url),
};
} catch {
URL.revokeObjectURL(url);
throw new Error(
"Ce format d'image n'est pas lisible par le navigateur (HEIC d'iPhone, par exemple). " +
"Exportez la photo en JPEG avant de la déposer.",
);
}
}
/** Une image sans transparence peut partir en JPEG, la plus économe des trois. */
function hasAlpha(context: CanvasRenderingContext2D, width: number, height: number): boolean {
try {
const { data } = context.getImageData(0, 0, width, height);
// Un pixel sur quatre suffit à repérer une transparence : un fond
// transparent n'est jamais un pixel isolé.
for (let index = 3; index < data.length; index += 16) {
if (data[index] < 255) return true;
}
return false;
} catch {
// Canvas « sali » (impossible ici : tout vient d'un fichier local) : dans le
// doute on garde la transparence.
return true;
}
}
function draw(decoded: Decoded, size: { width: number; height: number }): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.width = size.width;
canvas.height = size.height;
const context = canvas.getContext("2d");
if (!context) throw new Error("Le navigateur n'a pas pu préparer l'image.");
context.drawImage(decoded.source, 0, 0, size.width, size.height);
return canvas;
}
function encode(canvas: HTMLCanvasElement, type: string, quality: number): string | null {
const dataUri = canvas.toDataURL(type, quality);
// Un type non géré fait retomber `toDataURL` sur le PNG sans prévenir.
return dataUri.startsWith(`data:${type}`) ? dataUri : null;
}
/**
* Prépare un fichier déposé : orientation redressée, définition ramenée à
* `maxSide`, poids ramené sous `maxBytes` en descendant les paliers de qualité
* seulement autant qu'il le faut.
*
* Lève une erreur porteuse d'un message affichable (fichier non image, format
* non décodable). Ne lève jamais pour cause de poids : c'est précisément ce que
* la fonction est là pour régler.
*/
export async function prepareImageFile(file: File, options: PrepareOptions = {}): Promise<PreparedImage> {
const maxBytes = options.maxBytes ?? FIELD_MAX_BYTES;
const maxSide = options.maxSide ?? MAX_SIDE;
if (file.type && !file.type.startsWith("image/")) throw new Error("Ce fichier n'est pas une image.");
const decoded = await decode(file);
try {
const original = { bytes: file.size, width: decoded.width, height: decoded.height };
if (PASSTHROUGH.test(file.type) && !needsCompression(original, { maxBytes, maxSide })) {
const dataUri = await readDataUri(file);
return { ...original, dataUri, originalBytes: file.size, recompressed: false, name: file.name };
}
// Premier tirage à définition pleine : il sert aussi à repérer une
// transparence, donc à choisir le format de sortie.
const full = scaleToFit(decoded.width, decoded.height, maxSide);
const probe = draw(decoded, full);
const context = probe.getContext("2d");
const transparent = context ? hasAlpha(context, probe.width, probe.height) : false;
const types = transparent ? ["image/webp", "image/png"] : ["image/jpeg"];
let best: { dataUri: string; bytes: number; size: { width: number; height: number } } | null = null;
for (const step of compressionSteps(maxSide)) {
const size = scaleToFit(decoded.width, decoded.height, step.maxSide);
const canvas = size.width === probe.width && size.height === probe.height ? probe : draw(decoded, size);
for (const type of types) {
const dataUri = encode(canvas, type, step.quality);
if (!dataUri) continue;
const bytes = base64Bytes(dataUri);
if (!best || bytes < best.bytes) best = { dataUri, bytes, size };
if (bytes <= maxBytes) {
return {
dataUri,
width: size.width,
height: size.height,
bytes,
originalBytes: file.size,
recompressed: true,
name: file.name,
};
}
// Le PNG ignore la qualité : inutile de redescendre l'échelle sur lui,
// seule une définition plus petite le fera maigrir.
if (type === "image/png") break;
}
}
if (!best) throw new Error("Le navigateur n'a pas pu compresser cette image.");
return {
dataUri: best.dataUri,
width: best.size.width,
height: best.size.height,
bytes: best.bytes,
originalBytes: file.size,
recompressed: true,
name: file.name,
};
} finally {
decoded.release();
}
}
+168
View File
@@ -0,0 +1,168 @@
/**
* Plan de compression des images déposées dans le backoffice.
*
* Module **pur** (aucun accès DOM, base ou réseau), verrouillé par
* `tests/image-compress.test.ts`. Le travail sur le canvas — donc le seul code
* qui a besoin d'un navigateur — vit dans `image-compress-dom.ts`.
*
* Pourquoi compresser côté navigateur : une image voyage jusqu'au serveur en
* data-URI, à travers une server action plafonnée à 4 Mo, et finit stockée en
* base. Une photo de téléphone (4 à 12 Mo) ne passe pas. Le réflexe précédent
* était de la refuser — « Image trop lourde, compressez-la d'abord » — ce qui
* revenait à demander à l'utilisateur de faire à la main ce que le navigateur
* sait faire tout seul.
*
* La règle : on ne descend en qualité que le strict nécessaire. Une image déjà
* dans le budget n'est **pas** réencodée (un réencodage JPEG n'est jamais
* neutre), et le premier essai garde la pleine résolution à qualité quasi
* maximale. Ce n'est qu'en cas d'échec qu'on baisse, d'abord la qualité, puis
* la définition.
*/
export type Size = { width: number; height: number };
/**
* Côté le plus long conservé. 2048 px couvre le plein écran d'un portable
* récent (et le grand format des réseaux) ; au-delà, l'œil ne voit rien de
* plus, seul le poids grimpe.
*/
export const MAX_SIDE = 2048;
/** Budget d'une image de formulaire : couverture, logo, visuel de promotion. */
export const FIELD_MAX_BYTES = 1_200_000;
/**
* Budget d'une photo de galerie. Plus serré : plusieurs photos partent dans la
* même requête, et une rencontre en compte facilement vingt.
*/
export const PHOTO_MAX_BYTES = 800_000;
/**
* Taille d'un envoi groupé de photos. La limite des server actions est à 4 Mo
* de corps de requête, et le base64 gonfle les octets d'un tiers : 2 Mo de
* photos font ~2,7 Mo sur le fil, marge comprise.
*/
export const BATCH_MAX_BYTES = 2_000_000;
/** Plancher de qualité : en dessous, le grain se voit sur une photo de groupe. */
export const MIN_QUALITY = 0.6;
const QUALITY_LADDER = [0.92, 0.84, 0.76, 0.68, MIN_QUALITY];
/** Définitions successives, en fraction du côté maximal demandé. */
const SIDE_LADDER = [1, 0.78, 0.6, 0.45];
function positive(value: number): boolean {
return Number.isFinite(value) && value > 0;
}
/**
* Dimensions ramenées dans un carré de `maxSide`, rapport conservé. Une image
* plus petite est renvoyée telle quelle : on ne l'agrandit jamais, cela
* n'ajouterait que du flou et du poids.
*/
export function scaleToFit(width: number, height: number, maxSide: number = MAX_SIDE): Size {
if (!positive(width) || !positive(height) || !positive(maxSide)) return { width: 0, height: 0 };
const longest = Math.max(width, height);
if (longest <= maxSide) return { width: Math.round(width), height: Math.round(height) };
const ratio = maxSide / longest;
return {
width: Math.max(1, Math.round(width * ratio)),
height: Math.max(1, Math.round(height * ratio)),
};
}
export type CompressionStep = { maxSide: number; quality: number };
/**
* Essais successifs, du meilleur au plus économe. On épuise la qualité à
* définition pleine avant de réduire la définition : une photo de groupe reste
* lisible bien plus longtemps en baissant la qualité qu'en perdant des pixels.
*/
export function compressionSteps(maxSide: number = MAX_SIDE): CompressionStep[] {
if (!positive(maxSide)) return [];
const steps: CompressionStep[] = [];
SIDE_LADDER.forEach((fraction, index) => {
const side = Math.max(320, Math.round(maxSide * fraction));
// Le premier palier essaie toute l'échelle de qualité ; les suivants
// repartent plus bas, la définition ayant déjà fait une partie du travail.
for (const quality of QUALITY_LADDER.slice(index)) steps.push({ maxSide: side, quality });
});
return steps;
}
/** Poids réel d'une data-URI base64, sans la décoder. */
export function base64Bytes(dataUri: string): number {
const marker = dataUri.indexOf("base64,");
if (marker < 0) return 0;
const payload = dataUri.slice(marker + 7).replace(/\s+/g, "");
if (payload.length === 0) return 0;
const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
return Math.max(0, Math.floor((payload.length * 3) / 4) - padding);
}
/** `true` pour une data-URI d'image, la seule forme acceptée par le serveur. */
export function isImageDataUri(value: string): boolean {
return /^data:image\/[a-z+.-]+;base64,/i.test(value.trim());
}
/**
* Faut-il réencoder ? Non si l'image tient déjà dans le budget **et** dans la
* définition : le fichier d'origine est alors la meilleure version possible.
*/
export function needsCompression(
info: { bytes: number; width: number; height: number },
options: { maxBytes: number; maxSide?: number } = { maxBytes: FIELD_MAX_BYTES },
): boolean {
const maxSide = options.maxSide ?? MAX_SIDE;
if (!positive(info.width) || !positive(info.height)) return false;
if (Math.max(info.width, info.height) > maxSide) return true;
return info.bytes > options.maxBytes;
}
function bytesLabel(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "";
if (bytes < 1000) return `${Math.round(bytes)} o`;
if (bytes < 1_000_000) return `${Math.round(bytes / 1000)} Ko`;
const mo = bytes / 1_000_000;
return `${(mo < 10 ? mo.toFixed(1) : String(Math.round(mo))).replace(".", ",")} Mo`;
}
export type CompressionResult = {
width: number;
height: number;
bytes: number;
originalBytes: number;
/** `false` quand le fichier d'origine a été gardé tel quel. */
recompressed: boolean;
};
/** Phrase affichée sous l'aperçu : ce que le navigateur a fait, en clair. */
export function compressionSummary(result: CompressionResult): string {
const size = `${Math.round(result.width)} × ${Math.round(result.height)} px`;
if (!result.recompressed) return `${size} · ${bytesLabel(result.bytes)} · qualité d'origine conservée`;
return `${size} · ${bytesLabel(result.originalBytes)} → ${bytesLabel(result.bytes)} · compressée automatiquement`;
}
/**
* Groupe des envois consécutifs pour tenir sous la limite d'une requête. Un
* élément plus gros que le budget part seul plutôt que d'être écarté : c'est au
* serveur de le refuser, pas à la file de l'oublier en silence.
*/
export function chunkByBytes(sizes: number[], maxBytes: number = BATCH_MAX_BYTES): number[][] {
const groups: number[][] = [];
let current: number[] = [];
let total = 0;
sizes.forEach((size, index) => {
const weight = Math.max(0, size);
if (current.length > 0 && total + weight > maxBytes) {
groups.push(current);
current = [];
total = 0;
}
current.push(index);
total += weight;
});
if (current.length > 0) groups.push(current);
return groups;
}
+39 -12
View File
@@ -1,5 +1,6 @@
import { buildMimeMessage, formatAddress, toBase64Url, utf8ToBase64 } from "@/lib/mime";
import { resolveMailSender, type MailHealth, type MailSender } from "@/lib/mail-accounts";
import { describeSmtpError } from "@/lib/smtp-config";
/**
* Expédition d'un message.
@@ -102,14 +103,19 @@ async function sendViaGraph(sender: MailSender, mail: OutgoingMail): Promise<Sen
async function sendViaSmtp(sender: MailSender, mail: OutgoingMail): Promise<SendResult> {
if (!sender.smtp) return { ok: false, reason: "Réglages SMTP incomplets." };
const transport = await smtpTransport(sender.smtp);
await transport.sendMail({
from: formatAddress(sender.fromAddress, sender.fromName),
to: formatAddress(mail.to, mail.toName),
replyTo: mail.replyTo ?? undefined,
subject: mail.subject,
html: mail.html,
text: mail.text ?? undefined,
});
try {
await transport.sendMail({
from: formatAddress(sender.fromAddress, sender.fromName),
to: formatAddress(mail.to, mail.toName),
replyTo: mail.replyTo ?? undefined,
subject: mail.subject,
html: mail.html,
text: mail.text ?? undefined,
});
} catch (error) {
// Le verdict part dans le journal d'envoi : il doit se lire sans trace OpenSSL.
return { ok: false, reason: describeSmtpError(error instanceof Error ? error.message : String(error), sender.smtp) };
}
return { ok: true, provider: "smtp" };
}
@@ -117,6 +123,17 @@ async function sendViaSmtp(sender: MailSender, mail: OutgoingMail): Promise<Send
* `nodemailer` charge `net`, `tls` et `dns` par des requires dynamiques : il
* est importé à la demande, et déclaré dans `serverExternalPackages`, pour ne
* jamais entrer dans un bundle qui ne les a pas.
*
* Deux réglages qui ne sont pas des détails :
*
* - `requireTLS` hors TLS implicite : sans lui, STARTTLS est *opportuniste*.
* Un serveur qui ne l'annonce pas — ou un intermédiaire qui retire la
* capacité de la réponse EHLO — ferait partir l'identifiant et le mot de
* passe en clair sans que rien ne le signale. On préfère l'échec.
* - les délais : ceux de `nodemailer` se comptent en minutes, or ces
* connexions sont ouvertes depuis une action serveur. Un port filtré en
* sortie — cas courant chez les hébergeurs — doit rendre son verdict en
* quelques secondes, pas bloquer l'écran.
*/
async function smtpTransport(config: SmtpConfig) {
const nodemailer = (await import("nodemailer")).default;
@@ -124,18 +141,28 @@ async function smtpTransport(config: SmtpConfig) {
host: config.host,
port: config.port,
secure: config.secure,
requireTLS: !config.secure,
auth: { user: config.user, pass: config.password },
connectionTimeout: 12_000,
greetingTimeout: 12_000,
socketTimeout: 30_000,
// SNI explicite : sans lui, un hôte joint par IP casse la validation.
tls: { servername: config.host, minVersion: "TLSv1.2" },
});
}
/** Contrôle de santé SMTP : ouvre la connexion, s'authentifie, referme. */
export async function verifySmtp(config: SmtpConfig): Promise<MailHealth> {
const transport = await smtpTransport(config).catch(() => null);
if (!transport) return { ok: false, reason: "Transport SMTP indisponible." };
try {
const transport = await smtpTransport(config);
await transport.verify();
transport.close();
return { ok: true, detail: `Connexion établie avec ${config.host}` };
const how = config.secure ? "chiffrée dès l'ouverture" : "STARTTLS";
return { ok: true, detail: `Connexion établie avec ${config.host} sur le port ${config.port} (${how})` };
} catch (error) {
return { ok: false, reason: error instanceof Error ? error.message : "Serveur SMTP injoignable" };
return { ok: false, reason: describeSmtpError(error instanceof Error ? error.message : "", config) };
} finally {
// Sans cela, un échec laisserait la socket au pool jusqu'à son expiration.
transport.close();
}
}
+190
View File
@@ -0,0 +1,190 @@
import { safeHttpUrl } from "@/lib/rich-text";
/**
* Conversion d'un contenu édité visuellement vers le format balisé stocké.
*
* C'est la pièce qui permet un éditeur « comme dans Word » sans renoncer à
* l'invariant du produit : ce qui est **stocké** reste le sous-ensemble
* restreint de `src/lib/rich-text.ts`, jamais du HTML tiers. Le danger n'a
* jamais été la surface d'édition, mais la base de données — alors le
* navigateur retraverse ce qu'il a édité et n'en garde que le gras,
* l'italique, les listes, les sous-titres et les liens. Tout le reste — styles
* de Word, `<span>`, `<script>`, attributs d'événement — disparaît avant même
* de quitter la page.
*
* Le module est **pur** (`tests/rich-text-dom.test.ts`) : il ne connaît du DOM
* que `MinimalNode`, ce qui le rend testable sans navigateur.
*/
/** Ce que le sérialiseur a besoin de savoir d'un nœud. */
export type MinimalNode = {
nodeType: number;
nodeName: string;
textContent?: string | null;
childNodes: ArrayLike<MinimalNode>;
getAttribute?: (name: string) => string | null;
};
const ELEMENT_NODE = 1;
const TEXT_NODE = 3;
/** Éléments qui ouvrent une nouvelle ligne dans le format balisé. */
const BLOCKS = new Set(["P", "DIV", "LI", "BLOCKQUOTE", "H1", "H2", "H3", "H4", "H5", "H6", "SECTION", "ARTICLE"]);
/** Éléments dont le contenu ne doit jamais être lu, même comme texte. */
const DROPPED = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "IFRAME", "OBJECT", "SVG"]);
type LineKind = "paragraph" | "heading" | "bullet" | "ordered";
type Line = { kind: LineKind; text: string };
function nodeName(node: MinimalNode): string {
return node.nodeType === ELEMENT_NODE ? String(node.nodeName ?? "").toUpperCase() : "";
}
function children(node: MinimalNode): MinimalNode[] {
return Array.from(node.childNodes ?? []);
}
/** Texte d'un nœud, espaces insécables de l'éditeur ramenés à des espaces. */
function plain(value: string | null | undefined): string {
return String(value ?? "").replace(/\u00a0/g, " ").replace(/\r/g, "");
}
/** Contenu en ligne d'un nœud : gras, italique, liens, sauts de ligne. */
function inline(node: MinimalNode): string {
if (node.nodeType === TEXT_NODE) return plain(node.textContent);
if (node.nodeType !== ELEMENT_NODE) return "";
const name = nodeName(node);
if (DROPPED.has(name)) return "";
if (name === "BR") return "\n";
const inner = children(node).map(inline).join("");
// Un marqueur autour d'une sélection vide produirait « **** », que
// l'analyseur relirait comme du texte littéral.
if (!inner.trim()) return inner;
if (name === "A") {
const href = safeHttpUrl(node.getAttribute?.("href") ?? "");
return href ? `[${inner}](${href})` : inner;
}
const style = node.getAttribute?.("style") ?? "";
let marked = inner;
// L'italique en premier : le gras l'enveloppe, jamais l'inverse.
if (isItalic(name, style) && !wrapped(marked, "_")) marked = `_${marked}_`;
if (isBold(name, style) && !wrapped(marked, "**")) marked = `**${marked}**`;
return marked;
}
/** Déjà encadré par ce marqueur : ne pas le doubler. */
function wrapped(value: string, marker: string): boolean {
return value.startsWith(marker) && value.endsWith(marker) && value.length > marker.length * 2;
}
/**
* Un traitement de texte code souvent le gras en style plutôt qu'en balise :
* Google Docs écrit `<span style="font-weight:700">`. Sans cela, coller un
* brouillon rédigé ailleurs perdrait silencieusement toute sa mise en forme.
*
* Le style explicite l'emporte sur la balise, parce que Google Docs enveloppe
* aussi tout le document dans un `<b style="font-weight:normal">` — le prendre
* au mot mettrait le compte rendu entier en gras.
*/
function isBold(name: string, style: string): boolean {
if (/font-weight\s*:\s*(normal|[1-5]00)\b/i.test(style)) return false;
return name === "STRONG" || name === "B" || /font-weight\s*:\s*(bold(er)?|[6-9]00)\b/i.test(style);
}
/**
* L'italique est marqué par des tirets bas et non des étoiles :
* « **_gras italique_** » reste lisible par l'analyseur, là où « ***…*** »
* serait ambigu.
*/
function isItalic(name: string, style: string): boolean {
if (/font-style\s*:\s*normal\b/i.test(style)) return false;
return name === "EM" || name === "I" || /font-style\s*:\s*italic\b/i.test(style);
}
function push(lines: Line[], kind: LineKind, text: string) {
// Les espaces de bord disparaissent, les sauts de ligne internes restent :
// ce sont eux qui portent les retours à la ligne dans un paragraphe.
const cleaned = text.replace(/[ \t]+\n/g, "\n").replace(/\n[ \t]+/g, "\n").trim();
if (cleaned) lines.push({ kind, text: cleaned });
}
function walk(node: MinimalNode, lines: Line[], ordered: boolean) {
let buffer = "";
const flush = () => {
push(lines, "paragraph", buffer);
buffer = "";
};
for (const child of children(node)) {
const name = nodeName(child);
if (DROPPED.has(name)) continue;
if (name === "UL" || name === "OL") {
flush();
walk(child, lines, name === "OL");
continue;
}
if (name === "LI") {
flush();
push(lines, ordered ? "ordered" : "bullet", inline(child));
continue;
}
if (BLOCKS.has(name)) {
flush();
// Un `<div>` d'éditeur peut contenir d'autres blocs plutôt que du texte.
if (children(child).some((grandChild) => {
const grandName = nodeName(grandChild);
return BLOCKS.has(grandName) || grandName === "UL" || grandName === "OL";
})) {
walk(child, lines, ordered);
} else {
push(lines, /^H[1-6]$/.test(name) ? "heading" : "paragraph", inline(child));
}
continue;
}
// Texte nu et éléments en ligne : ils appartiennent au paragraphe courant.
buffer += inline(child);
}
flush();
}
function isList(kind: LineKind): boolean {
return kind === "bullet" || kind === "ordered";
}
/**
* Sérialise un fragment édité vers le format balisé.
*
* `root` est typiquement la `<div contenteditable>` de l'éditeur, ou le
* `body` d'un document inerte produit par `DOMParser` lors d'un collage.
*/
export function serializeToRichText(root: MinimalNode): string {
const lines: Line[] = [];
walk(root, lines, false);
let counter = 0;
return lines
.map((line, index) => {
const previous = lines[index - 1];
const continues = previous && isList(previous.kind) && previous.kind === line.kind;
counter = line.kind === "ordered" ? (continues ? counter + 1 : 1) : 0;
const prefix =
line.kind === "heading" ? "## " : line.kind === "bullet" ? "- " : line.kind === "ordered" ? `${counter}. ` : "";
// Les éléments d'une même liste se suivent ; tout le reste est séparé par
// une ligne vide, qui marque un nouveau paragraphe.
const separator = index === 0 ? "" : continues ? "\n" : "\n\n";
return `${separator}${prefix}${line.text}`;
})
.join("");
}
+81 -1
View File
@@ -11,10 +11,15 @@ import { createElement, Fragment } from "react";
* produirait du HTML, qu'il faudrait stocker puis assainir : deuxième
* dépendance, deuxième surface d'attaque.
*
* Grammaire : `**gras**`, `*italique*`, `- puce`, `1. numéro`,
* Grammaire : `**gras**`, `*italique*` ou `_italique_`, `- puce`, `1. numéro`,
* `[texte](https://…)`, `## sous-titre`, ligne vide = paragraphe.
* Un saut de ligne simple reste un saut de ligne.
*
* Ce balisage n'est **pas** montré à l'utilisateur : il écrit dans un éditeur
* visuel (`src/components/RichTextEditor.tsx`) et c'est le navigateur qui
* resérialise vers ce format (`src/lib/rich-text-dom.ts`) avant l'envoi. Le
* format reste donc le contrat de stockage, jamais une syntaxe à apprendre.
*
* Deux rendus, un seul analyseur : `richTextNodes()` pour l'écran,
* `richTextToEmailHtml()` pour le message. L'aperçu du formulaire passe par
* `richTextNodes()`, il est donc fidèle par construction.
@@ -46,6 +51,19 @@ export function safeHttpUrl(value: string): string {
}
}
/** Vrai en bord de mot : début, fin, espace ou ponctuation. */
function isWordEdge(char: string | undefined): boolean {
return char === undefined || !/[\p{L}\p{N}]/u.test(char);
}
/** Position du tiret bas fermant, ou -1 s'il n'y en a pas en bord de mot. */
function closingUnderscore(text: string, from: number): number {
for (let index = from; index < text.length; index += 1) {
if (text[index] === "_" && isWordEdge(text[index + 1])) return index;
}
return -1;
}
function parseInline(text: string): Inline[] {
const nodes: Inline[] = [];
let buffer = "";
@@ -77,6 +95,19 @@ function parseInline(text: string): Inline[] {
continue;
}
}
// Tiret bas accepté comme italique — c'est ce qu'émet le sérialiseur, pour
// éviter l'ambiguïté de « ***gras italique*** ». Il ne compte qu'aux
// frontières de mot, sans quoi « fichier_de_sauvegarde » deviendrait
// italique en son milieu.
if (text[index] === "_" && isWordEdge(text[index - 1])) {
const end = closingUnderscore(text, index + 1);
if (end > index + 1) {
flush();
nodes.push({ kind: "em", children: parseInline(text.slice(index + 1, end)) });
index = end + 1;
continue;
}
}
if (text[index] === "[") {
const close = text.indexOf("]", index + 1);
if (close > index && text[close + 1] === "(") {
@@ -316,3 +347,52 @@ export function richTextExcerpt(source: string, max = 160): string {
const lastSpace = cut.lastIndexOf(" ");
return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
}
// ---- Rendu vers l'éditeur visuel ----
function inlineEditorHtml(nodes: Inline[]): string {
return nodes
.map((node) => {
switch (node.kind) {
case "break":
return "<br>";
case "strong":
return `<strong>${inlineEditorHtml(node.children)}</strong>`;
case "em":
return `<em>${inlineEditorHtml(node.children)}</em>`;
case "link":
return `<a href="${esc(node.href)}">${inlineEditorHtml(node.children)}</a>`;
default:
return esc(node.value);
}
})
.join("");
}
/**
* HTML minimal posé dans la zone d'édition visuelle.
*
* Produit exactement les balises que `serializeToRichText` sait relire, pour
* que l'aller-retour édition → stockage → édition soit stable. Un contenu vide
* rend un paragraphe avec un `<br>` : sans lui, le navigateur n'offre aucune
* ligne où poser le curseur.
*/
export function richTextToEditorHtml(source: string): string {
const blocks = parseRichText(source);
if (blocks.length === 0) return "<p><br></p>";
return blocks
.map((block) => {
switch (block.kind) {
case "heading":
return `<h4>${inlineEditorHtml(block.children)}</h4>`;
case "bullets":
return `<ul>${block.items.map((item) => `<li>${inlineEditorHtml(item)}</li>`).join("")}</ul>`;
case "ordered":
return `<ol>${block.items.map((item) => `<li>${inlineEditorHtml(item)}</li>`).join("")}</ol>`;
default:
return `<p>${inlineEditorHtml(block.children) || "<br>"}</p>`;
}
})
.join("");
}
+4
View File
@@ -45,6 +45,10 @@ export const SITE_SETTING_DEFAULTS = {
association_publication_director: "",
association_privacy_contact: "",
legal_updated: "",
// Référencement : identifiant de mesure GA4 (« G-… ») et jeton de
// validation Search Console. Vides = aucune balise posée sur le site.
google_analytics_id: "",
google_site_verification: "",
};
export type SiteSettings = typeof SITE_SETTING_DEFAULTS;
+126
View File
@@ -0,0 +1,126 @@
/**
* Port SMTP et chiffrement : deux réglages qui doivent s'accorder.
*
* Il existe deux façons de chiffrer une session SMTP, et elles ne se
* négocient pas sur le même port :
*
* - **TLS implicite** (port 465) : la connexion est chiffrée dès son
* ouverture, avant le moindre octet de protocole.
* - **STARTTLS** (ports 587, 25, 2525) : la connexion s'ouvre en clair, le
* client lit les capacités annoncées, puis demande `STARTTLS` pour passer
* la suite — authentification comprise — dans le tunnel.
*
* Se tromper ne produit pas un message d'erreur lisible : un client qui tente
* une poignée de main TLS sur le port 587 reçoit la bannière en clair du
* serveur et la lit comme un enregistrement TLS invalide (« wrong version
* number ») ; à l'inverse, un client qui parle en clair sur le port 465
* attend une bannière qui ne viendra jamais et finit en délai dépassé. D'où
* ce module : l'écran accorde les deux champs tout seul, et les échecs sont
* traduits avant d'être affichés.
*
* Module **pur** (aucun accès réseau ni base), verrouillé par
* `tests/smtp-config.test.ts`.
*/
/** Seul port où le chiffrement précède le protocole. */
export const IMPLICIT_TLS_PORT = 465;
/** Ports de soumission qui s'ouvrent en clair puis passent par STARTTLS. */
export const STARTTLS_PORTS = [587, 25, 2525];
/** Le réglage « chiffré dès l'ouverture » qu'appelle un port donné. */
export function impliedSecure(port: number): boolean {
return port === IMPLICIT_TLS_PORT;
}
/**
* Message d'avertissement quand le port et la case ne s'accordent pas —
* `null` si le couple est cohérent. On explique, on ne bloque pas :
* un relais peut très bien écouter du TLS implicite sur un port exotique.
*/
export function tlsMismatch(port: number, secure: boolean): string | null {
if (secure && !impliedSecure(port)) {
return `Le port ${port} s'ouvre en clair puis passe par STARTTLS : en gardant la case cochée, la poignée de main TLS échouera dès la connexion.`;
}
if (!secure && impliedSecure(port)) {
return "Le port 465 chiffre dès l'ouverture : sans la case cochée, la connexion reste sans réponse jusqu'au délai dépassé.";
}
return null;
}
export type SmtpErrorContext = { host?: string; port?: number; secure?: boolean };
const RAW_DETAIL = 110;
function withDetail(explanation: string, raw: string): string {
const detail = raw.replace(/\s+/g, " ").trim();
if (!detail) return explanation;
return `${explanation} (détail : ${detail.slice(0, RAW_DETAIL)})`;
}
/**
* Traduit l'échec d'une connexion SMTP en phrase actionnable.
*
* L'explication passe **avant** le message d'origine : l'écran tronque le
* verdict, et c'est la consigne qu'il faut préserver, pas la trace OpenSSL.
*/
export function describeSmtpError(raw: string, context: SmtpErrorContext = {}): string {
const text = String(raw ?? "");
const lower = text.toLowerCase();
const where = context.host ? `${context.host}:${context.port ?? IMPLICIT_TLS_PORT}` : "le serveur";
// Bannière en clair lue comme du TLS : la case est cochée sur un port STARTTLS.
if (lower.includes("wrong version number") || lower.includes("ssl3_get_record") || lower.includes("packet length too long")) {
return withDetail(
`${where} a répondu en clair alors qu'une connexion chiffrée était attendue. Décochez « Connexion chiffrée dès l'ouverture » : sur le port ${context.port ?? 587}, le chiffrement se demande par STARTTLS.`,
text,
);
}
// Le serveur exige le tunnel avant l'authentification.
if (lower.includes("must issue a starttls") || lower.includes("starttls is required") || lower.includes("no starttls")) {
return withDetail(
`${where} exige STARTTLS avant l'authentification. Vérifiez que le port est bien un port de soumission (587) et non un port de relais filtré.`,
text,
);
}
// Poignée de main en clair sur un port qui chiffre d'emblée, ou port filtré.
if (lower.includes("greeting never received") || lower.includes("etimedout") || lower.includes("timed out") || lower.includes("timeout")) {
const hint = context.secure === false && impliedSecure(context.port ?? 0)
? "Cochez « Connexion chiffrée dès l'ouverture » : le port 465 chiffre avant toute réponse."
: "Le port est probablement filtré en sortie : beaucoup d'hébergeurs bloquent les connexions SMTP sortantes, et il faut alors leur demander l'ouverture.";
return withDetail(`Aucune réponse de ${where}. ${hint}`, text);
}
if (lower.includes("econnrefused")) {
return withDetail(`${where} a refusé la connexion : ce port n'écoute pas. Essayez 465 (chiffré dès l'ouverture) ou 587 (STARTTLS).`, text);
}
if (lower.includes("enotfound") || lower.includes("eai_again") || lower.includes("getaddrinfo")) {
return withDetail(`Nom de serveur introuvable : vérifiez l'adresse « ${context.host ?? ""} ».`, text);
}
if (lower.includes("eauth") || lower.includes("535") || lower.includes("invalid login") || lower.includes("authentication failed") || lower.includes("username and password not accepted")) {
return withDetail(
"Identifiants refusés. L'identifiant est l'adresse complète de la boîte, et certains fournisseurs exigent un mot de passe d'application plutôt que celui du compte.",
text,
);
}
if (lower.includes("altnames") || lower.includes("self signed") || lower.includes("self-signed") || lower.includes("unable to verify") || lower.includes("cert")) {
return withDetail(
`Le certificat présenté par ${where} ne correspond pas à ce nom de serveur. Utilisez le nom annoncé par le fournisseur plutôt qu'une adresse IP ou un alias.`,
text,
);
}
if (lower.includes("5.7.1") || lower.includes("relay") || lower.includes("not permitted") || lower.includes("sender address rejected")) {
return withDetail(
"Le serveur refuse cette adresse d'expédition : elle doit appartenir au domaine de la boîte qui authentifie la connexion.",
text,
);
}
return text || "Serveur SMTP injoignable";
}
+8 -1
View File
@@ -32,7 +32,11 @@ function contentSecurityPolicy(nonce: string, isDev: boolean, isHttps: boolean):
"font-src 'self' https://fonts.gstatic.com",
// data: pour les images stockées en data-URI, https: pour les vignettes distantes.
"img-src 'self' data: https:",
"connect-src 'self'",
// Mesure d'audience GA4 : gtag.js renvoie ses relevés par `fetch`. Les
// hôtes sont listés même sans identifiant configuré — la CSP est posée
// sur le runtime Edge, qui ne peut pas interroger la base pour savoir si
// la mesure est active.
"connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com",
// Carte « Nous situer » de la fiche adhérent.
"frame-src https://www.google.com https://maps.google.com",
// Uniquement quand la page est servie en HTTPS : sur une page HTTP (test
@@ -55,6 +59,9 @@ export default auth((request: NextRequest) => {
// ses balises <script>. Il est ensuite renvoyé sur la réponse au navigateur.
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
// Un layout ne connaît pas l'URL demandée : `Analytics` s'en sert pour ne
// mesurer que les pages publiques.
requestHeaders.set("x-pathname", request.nextUrl.pathname);
requestHeaders.set("content-security-policy", csp);
const response = NextResponse.next({ request: { headers: requestHeaders } });
+109
View File
@@ -0,0 +1,109 @@
import { strict as assert } from "node:assert";
import { describe, it } from "node:test";
import {
gtagConfigScript,
gtagSrc,
isTrackedPath,
normalizeMeasurementId,
normalizeSiteVerification,
} from "../src/lib/analytics";
describe("Identifiant de mesure GA4", () => {
it("accepte un identifiant de flux", () => {
assert.equal(normalizeMeasurementId("G-ABC1234567"), "G-ABC1234567");
assert.equal(normalizeMeasurementId(" g-abc1234567 "), "G-ABC1234567");
});
it("refuse une saisie vide ou absente", () => {
assert.equal(normalizeMeasurementId(""), "");
assert.equal(normalizeMeasurementId(null), "");
assert.equal(normalizeMeasurementId(undefined), "");
assert.equal(normalizeMeasurementId(" "), "");
});
it("refuse Universal Analytics, arrêté depuis 2023", () => {
assert.equal(normalizeMeasurementId("UA-12345-1"), "");
});
it("refuse tout ce qui pourrait s'échapper du script en ligne", () => {
for (const payload of [
"G-ABC12345');alert(1);//",
"G-ABC12345</script><script>alert(1)</script>",
'G-ABC"12345',
"G-ABC 12345",
"<script>alert(1)</script>",
]) {
assert.equal(normalizeMeasurementId(payload), "", `accepté à tort : ${payload}`);
}
});
});
describe("Balises gtag.js", () => {
it("ne rend rien sans identifiant valide", () => {
assert.equal(gtagSrc(""), "");
assert.equal(gtagSrc("UA-12345-1"), "");
assert.equal(gtagConfigScript(""), "");
assert.equal(gtagConfigScript("pas-un-identifiant"), "");
});
it("charge gtag.js depuis Google avec l'identifiant configuré", () => {
assert.equal(
gtagSrc("G-ABC1234567"),
"https://www.googletagmanager.com/gtag/js?id=G-ABC1234567",
);
});
it("produit un script d'amorçage qui ne peut pas être refermé", () => {
const script = gtagConfigScript("G-ABC1234567");
assert.ok(script.includes("gtag('config','G-ABC1234567');"));
assert.ok(!script.includes("</script"));
assert.ok(!script.includes("<"));
});
});
describe("Pages mesurées", () => {
it("mesure les pages publiques", () => {
for (const path of ["/", "/annuaire", "/promotions", "/adherents/12-au-bon-pain", "/association"]) {
assert.equal(isTrackedPath(path), true, `devrait être mesurée : ${path}`);
}
});
it("ne mesure ni le backoffice, ni l'espace adhérent, ni les URLs privées", () => {
for (const path of [
"/backend",
"/backend/espace/promotions",
"/login",
"/inscription/adherent",
"/api/social/facebook/callback",
]) {
assert.equal(isTrackedPath(path), false, `ne devrait pas être mesurée : ${path}`);
}
});
it("ne confond pas un préfixe avec un début de mot", () => {
assert.equal(isTrackedPath("/backendeur"), true);
assert.equal(isTrackedPath("/apiculteurs"), true);
});
});
describe("Jeton Search Console", () => {
it("accepte le jeton livré par Google", () => {
assert.equal(
normalizeSiteVerification(" aBc123_def-456ghijkl "),
"aBc123_def-456ghijkl",
);
});
it("refuse une balise complète ou tout autre collage", () => {
for (const payload of [
'<meta name="google-site-verification" content="abc">',
"",
"trop-court",
"abc123 def456ghij",
"abc\"><script>alert(1)</script>",
]) {
assert.equal(normalizeSiteVerification(payload), "", `accepté à tort : ${payload}`);
}
});
});
+38
View File
@@ -42,3 +42,41 @@ test("chaque icône déclarée dans le type est dessinée", () => {
assert.ok(drawn.has(name), `l'icône « ${name} » est déclarée mais pas dessinée`);
}
});
/**
* Un adhérent n'a pas de barre latérale : elle ne porterait que la section
* « Côté adhérent », déjà rendue en onglets par `EspaceHeader`. Le test tient
* les deux bouts — la coquille doit brancher sur le rôle, et sa variante sans
* barre ne doit rendre ni `aside`, ni bouton de tiroir.
*/
const memberStart = shell.indexOf("if (!isStaff(user.role))");
// La tranche s'arrête à la coquille du staff, sinon elle l'embarquerait et
// le test verrait son <aside> — il passerait pour un échec de la variante.
const staffStart = shell.indexOf('<div className="backend" style', memberStart);
const memberBranch = shell.slice(memberStart, staffStart);
test("la coquille distingue le staff de l'adhérent", () => {
assert.ok(shell.includes("isStaff"), "BackendShell ne consulte plus le rôle");
assert.ok(memberStart >= 0, "la branche sans barre latérale a disparu");
assert.ok(staffStart > memberStart, "la coquille du staff ne suit plus la branche adhérent");
});
test("la variante adhérent ne rend ni barre latérale ni tiroir", () => {
assert.ok(!/<aside/.test(memberBranch), "un <aside> subsiste dans la variante adhérent");
assert.ok(!memberBranch.includes("backend-burger"), "le bouton de tiroir subsiste sans tiroir à ouvrir");
assert.ok(!memberBranch.includes("sidebar"), "une classe de barre latérale subsiste");
});
test("sans tiroir, les actions du compte restent dans l'entête", () => {
// Sur téléphone elles vivaient dans la barre : sans elle, se déconnecter
// deviendrait impossible si la règle d'affichage n'était pas rétablie.
assert.ok(memberBranch.includes("{signOut}"), "la déconnexion a disparu de l'entête adhérent");
assert.ok(memberBranch.includes("{siteLink}"), "le retour au site a disparu de l'entête adhérent");
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
assert.match(
css,
/\.backend-header--plain\s+\.backend-header__actions\s*\{\s*display:\s*flex/,
"les actions restent masquées sous 1024px, où le tiroir n'existe plus",
);
});
+131
View File
@@ -0,0 +1,131 @@
import { strict as assert } from "node:assert";
import { describe, it } from "node:test";
import {
BATCH_MAX_BYTES,
MAX_SIDE,
MIN_QUALITY,
base64Bytes,
chunkByBytes,
compressionSteps,
compressionSummary,
isImageDataUri,
needsCompression,
scaleToFit,
} from "../src/lib/image-compress";
describe("Mise à l'échelle", () => {
it("ramène le côté le plus long sous la limite en gardant le rapport", () => {
assert.deepEqual(scaleToFit(4032, 3024, 2048), { width: 2048, height: 1536 });
assert.deepEqual(scaleToFit(3024, 4032, 2048), { width: 1536, height: 2048 });
});
it("n'agrandit jamais une image plus petite que la limite", () => {
assert.deepEqual(scaleToFit(800, 600, 2048), { width: 800, height: 600 });
});
it("ne rend rien pour des dimensions absurdes", () => {
assert.deepEqual(scaleToFit(0, 600, 2048), { width: 0, height: 0 });
assert.deepEqual(scaleToFit(Number.NaN, 600, 2048), { width: 0, height: 0 });
});
});
describe("Paliers de compression", () => {
const steps = compressionSteps(MAX_SIDE);
it("commence à la définition pleine et à la meilleure qualité", () => {
assert.equal(steps[0].maxSide, MAX_SIDE);
assert.ok(steps[0].quality >= 0.9);
});
it("épuise la qualité avant de réduire la définition", () => {
const firstDrop = steps.findIndex((step) => step.maxSide < MAX_SIDE);
assert.ok(firstDrop > 1, "la définition ne doit pas tomber au deuxième essai");
for (let index = 1; index < firstDrop; index += 1) {
assert.ok(steps[index].quality < steps[index - 1].quality);
}
});
it("ne remonte jamais en définition et reste au-dessus du plancher de qualité", () => {
for (let index = 1; index < steps.length; index += 1) {
assert.ok(steps[index].maxSide <= steps[index - 1].maxSide);
}
for (const step of steps) {
assert.ok(step.quality >= MIN_QUALITY && step.quality <= 1);
assert.ok(step.maxSide >= 320);
}
});
});
describe("Poids d'une data-URI", () => {
it("retrouve le poids décodé sans décoder", () => {
const bytes = Buffer.from("x".repeat(1000));
const dataUri = `data:image/jpeg;base64,${bytes.toString("base64")}`;
assert.equal(base64Bytes(dataUri), 1000);
});
it("tient compte du remplissage", () => {
for (const length of [1, 2, 3, 4, 5]) {
const raw = Buffer.alloc(length, 7);
assert.equal(base64Bytes(`data:image/png;base64,${raw.toString("base64")}`), length);
}
});
it("rend zéro hors data-URI", () => {
assert.equal(base64Bytes("https://exemple.fr/photo.jpg"), 0);
assert.equal(base64Bytes(""), 0);
});
it("ne reconnaît qu'une image en data-URI", () => {
assert.ok(isImageDataUri("data:image/jpeg;base64,AAAA"));
assert.ok(!isImageDataUri("https://exemple.fr/photo.jpg"));
assert.ok(!isImageDataUri("data:text/html;base64,AAAA"));
});
});
describe("Décision de recompression", () => {
it("garde le fichier d'origine quand il tient dans le budget et la définition", () => {
assert.ok(!needsCompression({ bytes: 400_000, width: 1600, height: 1200 }, { maxBytes: 800_000 }));
});
it("recompresse une image trop lourde", () => {
assert.ok(needsCompression({ bytes: 4_000_000, width: 1600, height: 1200 }, { maxBytes: 800_000 }));
});
it("recompresse une image légère mais démesurée", () => {
assert.ok(needsCompression({ bytes: 200_000, width: 6000, height: 4000 }, { maxBytes: 800_000 }));
});
});
describe("Résumé montré à l'utilisateur", () => {
it("dit ce qui a été gagné", () => {
assert.equal(
compressionSummary({ width: 2048, height: 1536, bytes: 620_000, originalBytes: 4_200_000, recompressed: true }),
"2048 × 1536 px · 4,2 Mo → 620 Ko · compressée automatiquement",
);
});
it("dit quand rien n'a été touché", () => {
assert.equal(
compressionSummary({ width: 1200, height: 900, bytes: 340_000, originalBytes: 340_000, recompressed: false }),
"1200 × 900 px · 340 Ko · qualité d'origine conservée",
);
});
});
describe("Envoi groupé", () => {
it("regroupe les photos sous la limite d'une requête", () => {
assert.deepEqual(chunkByBytes([700_000, 700_000, 700_000, 700_000], 2_000_000), [[0, 1], [2, 3]]);
});
it("laisse partir seule une photo plus lourde que le budget", () => {
assert.deepEqual(chunkByBytes([100_000, 3_000_000, 100_000], 2_000_000), [[0], [1], [2]]);
});
it("rend un groupe vide pour une liste vide et couvre chaque index une fois", () => {
assert.deepEqual(chunkByBytes([], BATCH_MAX_BYTES), []);
const sizes = [500_000, 900_000, 200_000, 1_900_000, 50_000];
const flat = chunkByBytes(sizes).flat();
assert.deepEqual(flat, [0, 1, 2, 3, 4]);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
/**
* Invariants de l'écran « Rencontres passées ». Ils portent sur du code que les
* tests unitaires ne peuvent pas exécuter (composants client, actions serveur),
* d'où la lecture des sources — même approche que `tests/security.test.ts`.
*/
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
const actions = read("../src/app/backend/actions.ts");
const dialog = read("../src/app/backend/rencontres-passees/PastMeetingDialog.tsx");
const page = read("../src/app/backend/rencontres-passees/page.tsx");
const imageField = read("../src/components/ImageField.tsx");
const modal = read("../src/components/ModalShell.tsx");
test("aucune image n'est refusée pour son poids : elle est compressée", () => {
const code = imageField.replace(/\/\/[^\n]*/g, "");
assert.ok(!/\.size\s*>/.test(code), "ImageField refuse encore un fichier sur son poids");
assert.ok(!/trop lourde/i.test(code), "ImageField renvoie encore « image trop lourde »");
assert.ok(imageField.includes("prepareImageFile"), "ImageField ne compresse pas");
assert.ok(dialog.includes("prepareImageFile"), "la fenêtre des rencontres ne compresse pas");
});
test("les photos n'entrent en base qu'en data-URI", () => {
const body = actions.slice(actions.indexOf("export async function addPastMeetingPhotos"));
const scope = body.slice(0, body.indexOf("export async function savePastMeetingPhotos"));
assert.ok(scope.includes("IMAGE_DATA_URI.test"), "une photo pourrait entrer sous forme d'URL (SSRF)");
assert.ok(scope.includes("MAX_IMAGE_DATA_URI"), "aucune limite de taille côté serveur");
});
test("la synchronisation des photos ne peut pas vider une galerie par accident", () => {
const body = actions.slice(actions.indexOf("export async function savePastMeetingPhotos"));
assert.ok(body.includes('formData.get("syncPhotos") !== "1"'), "le drapeau de garde a disparu");
});
test("les refus d'enregistrement sont renvoyés, jamais levés", () => {
const body = actions.slice(
actions.indexOf("export async function savePastMeeting("),
actions.indexOf("export async function deletePastMeeting("),
);
assert.ok(body.includes('return { error: "Donnez un titre'), "un titre vide ne rend plus d'erreur affichable");
// Le seul `throw` admis est la violation d'accès, qui n'a pas à s'expliquer.
const throws = [...body.matchAll(/throw new Error\("([^"]+)"\)/g)].map((match) => match[1]);
assert.deepEqual(throws, ["Accès refusé"], `throw inattendu : ${throws.join(", ")}`);
});
test("la rencontre liée est vérifiée avant l'écriture", () => {
const body = actions.slice(actions.indexOf("export async function savePastMeeting("));
assert.ok(body.includes("n'existe plus"), "une rencontre supprimée casserait l'enregistrement sans explication");
});
test("la fenêtre ne se referme qu'une fois la page rafraîchie", () => {
// Sans cette attente, rouvrir la fiche montrerait l'état d'avant
// l'enregistrement : c'est ce qui fait croire qu'une rencontre liée « ne
// veut pas » être retenue.
assert.ok(dialog.includes("closing && !refreshing"), "la fermeture ne suit plus le rafraîchissement");
});
test("la fenêtre est rendue dans un portail", () => {
// Une carte survolée porte un `transform` : un `position: fixed` rendu à
// l'intérieur s'ouvrirait dans la carte, pas sur la page.
assert.ok(modal.includes("createPortal"), "la fenêtre n'est plus portée vers <body>");
});
test("le récit s'écrit dans un grand champ, et la page ne le ré-affiche pas en entier", () => {
assert.ok(/rows=\{1[0-9]\}/.test(dialog), "le champ de récit est redevenu minuscule");
assert.ok(dialog.includes("minHeight: 260"), "la hauteur minimale du récit a disparu");
assert.ok(page.includes("pm-card__text"), "la carte n'écrête plus le texte");
});
+204
View File
@@ -0,0 +1,204 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { serializeToRichText, type MinimalNode } from "../src/lib/rich-text-dom";
import { parseRichText, richTextToEmailHtml, richTextToPlain } from "../src/lib/rich-text";
/**
* Faux DOM minimal : le sérialiseur ne connaît du navigateur que `MinimalNode`,
* ce qui permet de le tester sans jsdom.
*/
function el(name: string, children: MinimalNode[] = [], attributes: Record<string, string> = {}): MinimalNode {
return {
nodeType: 1,
nodeName: name,
childNodes: children,
getAttribute: (key: string) => attributes[key] ?? null,
};
}
function txt(value: string): MinimalNode {
return { nodeType: 3, nodeName: "#text", textContent: value, childNodes: [] };
}
const root = (...children: MinimalNode[]) => el("DIV", children);
test("un paragraphe simple ressort tel quel", () => {
assert.equal(serializeToRichText(root(el("P", [txt("Bonjour à tous.")]))), "Bonjour à tous.");
});
test("deux paragraphes sont séparés par une ligne vide", () => {
const markup = serializeToRichText(root(el("P", [txt("Premier.")]), el("P", [txt("Second.")])));
assert.equal(markup, "Premier.\n\nSecond.");
assert.equal(parseRichText(markup).length, 2);
});
test("le gras et l'italique deviennent des marqueurs", () => {
assert.equal(
serializeToRichText(root(el("P", [txt("Rendez-vous "), el("STRONG", [txt("mardi")]), txt(" à "), el("EM", [txt("Pompey")])]))),
"Rendez-vous **mardi** à _Pompey_",
);
});
test("les balises B et I d'execCommand valent STRONG et EM", () => {
assert.equal(serializeToRichText(root(el("P", [el("B", [txt("a")]), el("I", [txt("b")])]))), "**a**_b_");
});
test("gras et italique imbriqués se relisent correctement", () => {
// C'est la raison du tiret bas : « ***x*** » serait ambigu pour l'analyseur.
const markup = serializeToRichText(root(el("P", [el("STRONG", [el("EM", [txt("très important")])])])));
assert.equal(markup, "**_très important_**");
const blocks = parseRichText(markup);
assert.equal(blocks[0].kind, "paragraph");
const strong = blocks[0].kind === "paragraph" ? blocks[0].children[0] : null;
assert.equal(strong?.kind, "strong");
assert.equal(strong?.kind === "strong" ? strong.children[0].kind : null, "em");
});
test("un saut de ligne dans un paragraphe est conservé", () => {
const markup = serializeToRichText(root(el("P", [txt("Une ligne"), el("BR"), txt("une autre")])));
assert.equal(markup, "Une ligne\nune autre");
assert.equal(parseRichText(markup).length, 1);
});
test("les listes à puces et numérotées prennent leur préfixe", () => {
assert.equal(
serializeToRichText(root(el("UL", [el("LI", [txt("un")]), el("LI", [txt("deux")])]))),
"- un\n- deux",
);
assert.equal(
serializeToRichText(root(el("OL", [el("LI", [txt("a")]), el("LI", [txt("b")]), el("LI", [txt("c")])]))),
"1. a\n2. b\n3. c",
);
});
test("la numérotation repart à un sur une seconde liste", () => {
const markup = serializeToRichText(
root(
el("OL", [el("LI", [txt("a")])]),
el("P", [txt("Entre les deux.")]),
el("OL", [el("LI", [txt("b")])]),
),
);
assert.equal(markup, "1. a\n\nEntre les deux.\n\n1. b");
});
test("un titre devient un sous-titre", () => {
assert.equal(serializeToRichText(root(el("H4", [txt("Ordre du jour")]))), "## Ordre du jour");
// Les autres niveaux de titre, venus d'un collage, se rabattent sur le même.
assert.equal(serializeToRichText(root(el("H1", [txt("Collé depuis le web")]))), "## Collé depuis le web");
});
test("un lien http(s) garde sa cible, les autres perdent la leur", () => {
assert.equal(
serializeToRichText(root(el("P", [el("A", [txt("le site")], { href: "https://pleinr.fr/" })]))),
"[le site](https://pleinr.fr/)",
);
assert.equal(
serializeToRichText(root(el("P", [el("A", [txt("piège")], { href: "javascript:alert(1)" })]))),
"piège",
);
assert.equal(serializeToRichText(root(el("P", [el("A", [txt("sans cible")])]))), "sans cible");
});
test("tout ce qui n'est pas reconnu perd sa balise et garde son texte", () => {
// Couleur, taille, police : rien de tout cela n'existe dans le format stocké.
const markup = serializeToRichText(
root(el("P", [el("SPAN", [txt("du texte")], { style: "color:red;font-size:32px" }), el("FONT", [txt(" coloré")], { color: "blue" })])),
);
assert.equal(markup, "du texte coloré");
});
test("le contenu exécutable est retiré, texte compris", () => {
const markup = serializeToRichText(
root(
el("SCRIPT", [txt("alert(1)")]),
el("STYLE", [txt("body{display:none}")]),
el("P", [txt("Bonjour"), el("IFRAME", [txt("piège")])]),
),
);
assert.equal(markup, "Bonjour");
assert.ok(!markup.includes("alert"));
assert.ok(!markup.includes("display:none"));
});
test("un collage de Word — div imbriqués et styles — se ramène au texte structuré", () => {
const pasted = root(
el("DIV", [
el("P", [el("SPAN", [txt("Compte rendu")], { style: "font-size:24px" })]),
el("UL", [el("LI", [el("SPAN", [el("B", [txt("Point 1")])])]), el("LI", [txt("Point 2")])]),
]),
);
assert.equal(serializeToRichText(pasted), "Compte rendu\n\n- **Point 1**\n- Point 2");
});
test("les espaces insécables de l'éditeur redeviennent des espaces", () => {
assert.equal(serializeToRichText(root(el("P", [txt("19\u00a0h\u00a0précises")]))), "19 h précises");
});
test("une mise en forme appliquée à une sélection vide ne produit pas de marqueur", () => {
// Chrome laisse un `<b></b>` quand on active le gras sans rien taper.
assert.equal(serializeToRichText(root(el("P", [el("B", []), txt("texte")]))), "texte");
assert.ok(!serializeToRichText(root(el("P", [el("B", [txt(" ")]), txt("x")]))).includes("**"));
});
test("un éditeur vide produit une chaîne vide", () => {
assert.equal(serializeToRichText(root()), "");
assert.equal(serializeToRichText(root(el("P", [el("BR")]))), "");
assert.equal(serializeToRichText(root(el("P", [txt(" ")]))), "");
});
test("l'aller-retour édition → stockage → rendu conserve le sens", () => {
const markup = serializeToRichText(
root(
el("H4", [txt("Assemblée générale")]),
el("P", [txt("Elle se tiendra "), el("STRONG", [txt("salle Jean-Jaurès")]), txt(".")]),
el("UL", [el("LI", [txt("comptes 2026")]), el("LI", [txt("budget")])]),
el("P", [el("A", [txt("le compte rendu")], { href: "https://pleinr.fr/association" })]),
),
);
assert.equal(
markup,
"## Assemblée générale\n\nElle se tiendra **salle Jean-Jaurès**.\n\n- comptes 2026\n- budget\n\n[le compte rendu](https://pleinr.fr/association)",
);
const html = richTextToEmailHtml(markup);
assert.ok(html.includes("<strong"));
assert.ok(html.includes("<ul"));
assert.ok(html.includes('href="https://pleinr.fr/association"'));
assert.ok(richTextToPlain(markup).includes("Assemblée générale"));
});
test("le gras codé en style CSS est reconnu — cas Google Docs", () => {
assert.equal(
serializeToRichText(root(el("P", [el("SPAN", [txt("important")], { style: "font-weight:700" })]))),
"**important**",
);
assert.equal(
serializeToRichText(root(el("P", [el("SPAN", [txt("nuance")], { style: "font-style: italic" })]))),
"_nuance_",
);
});
test("le style explicite l'emporte sur la balise", () => {
// Google Docs enveloppe tout le document dans un `<b style="font-weight:normal">`.
assert.equal(
serializeToRichText(root(el("B", [el("P", [txt("tout le document")])], { style: "font-weight:normal" }))),
"tout le document",
);
});
test("un marqueur n'est jamais doublé", () => {
assert.equal(
serializeToRichText(root(el("P", [el("B", [el("SPAN", [txt("x")], { style: "font-weight:bold" })])]))),
"**x**",
);
});
test("gras et italique combinés s'imbriquent dans le bon ordre", () => {
assert.equal(
serializeToRichText(root(el("P", [el("SPAN", [txt("les deux")], { style: "font-weight:700;font-style:italic" })]))),
"**_les deux_**",
);
});
+32
View File
@@ -5,6 +5,7 @@ import {
parseRichText,
richTextExcerpt,
richTextNodes,
richTextToEditorHtml,
richTextToEmailHtml,
richTextToPlain,
safeHttpUrl,
@@ -164,3 +165,34 @@ test("une saisie vide ne produit aucun bloc", () => {
assert.equal(richTextToEmailHtml(""), "");
assert.equal(richTextExcerpt(""), "");
});
test("le tiret bas vaut italique, mais seulement en bord de mot", () => {
assert.ok(richTextToEmailHtml("_doux_").includes("<em>doux</em>"));
assert.ok(richTextToEmailHtml("**_gras italique_**").includes("<em>gras italique</em>"));
// Un nom de fichier ne doit pas devenir italique en son milieu.
assert.equal(richTextToPlain("fichier_de_sauvegarde.pdf"), "fichier_de_sauvegarde.pdf");
assert.ok(!richTextToEmailHtml("fichier_de_sauvegarde.pdf").includes("<em>"));
assert.equal(richTextToPlain("un _ seul"), "un _ seul");
});
test("le rendu vers l'éditeur reprend les balises que le sérialiseur sait relire", () => {
const html = richTextToEditorHtml("## Titre\n\nDu **gras** et un [lien](https://pleinr.fr)\n\n- un\n- deux\n\n1. a");
assert.ok(html.includes("<h4>Titre</h4>"));
assert.ok(html.includes("<strong>gras</strong>"));
assert.ok(html.includes('<a href="https://pleinr.fr/">lien</a>'));
assert.ok(html.includes("<ul><li>un</li><li>deux</li></ul>"));
assert.ok(html.includes("<ol><li>a</li></ol>"));
});
test("un contenu vide offre une ligne où écrire", () => {
assert.equal(richTextToEditorHtml(""), "<p><br></p>");
assert.equal(richTextToEditorHtml(" "), "<p><br></p>");
});
test("le rendu vers l'éditeur échappe le texte et refuse les cibles hostiles", () => {
const html = richTextToEditorHtml('<script>alert(1)</script> & "guillemets"');
assert.ok(html.includes("&lt;script&gt;"));
assert.ok(html.includes("&amp;"));
assert.ok(!html.includes("<script"));
assert.ok(!richTextToEditorHtml("[x](javascript:alert(1))").includes("<a "));
});
+44
View File
@@ -277,6 +277,7 @@ describe("Informations — rien n'est rendu en HTML brut", () => {
"../src/lib/rich-text.ts",
"../src/components/InformationCard.tsx",
"../src/app/backend/informations/InformationForm.tsx",
"../src/components/RichTextEditor.tsx",
"../src/app/backend/espace/informations/page.tsx",
];
@@ -320,3 +321,46 @@ describe("Boucles de fond — jamais dans le bundle edge", () => {
assert.ok(worker.includes('process.env.MAIL_WORKER !== "off"'));
});
});
describe("Éditeur visuel — le HTML ne quitte jamais la page", () => {
const editor = readFileSync(new URL("../src/components/RichTextEditor.tsx", import.meta.url), "utf8");
const form = readFileSync(new URL("../src/app/backend/informations/InformationForm.tsx", import.meta.url), "utf8");
// L'édition est visuelle, mais ce qui part au serveur reste le format
// balisé restreint : c'est tout l'arrangement qui permet un éditeur
// confortable sans stocker de HTML tiers.
it("le formulaire envoie le balisage sérialisé, jamais du HTML", () => {
assert.ok(form.includes('<input type="hidden" name="body"'), "le corps doit voyager en champ caché");
assert.ok(!form.includes("innerHTML"), "le formulaire ne manipule pas de HTML");
assert.ok(editor.includes("serializeToRichText("), "l'éditeur doit resérialiser ce qu'il édite");
});
it("seul du HTML produit par nous est injecté dans la zone d'édition", () => {
// Toute écriture de HTML dans l'éditeur passe par notre générateur : jamais
// une chaîne venue du presse-papier ou du serveur.
for (const call of editor.match(/innerHTML\s*=\s*[^;\n]+/g) ?? []) {
assert.ok(call.includes("richTextToEditorHtml("), `HTML injecté sans générateur : ${call}`);
}
for (const call of editor.match(/insertHTML"[^)]*\)/g) ?? []) {
assert.ok(call.includes("richTextToEditorHtml("), `HTML inséré sans générateur : ${call}`);
}
});
it("le collage est analysé dans un document inerte", () => {
// `DOMParser` n'exécute rien et ne charge aucune ressource, contrairement
// à une affectation d'`innerHTML` — même sur un nœud détaché, un
// `<img onerror>` peut s'y déclencher.
assert.ok(editor.includes('getData("text/html")'), "le collage HTML doit être traité");
assert.ok(editor.includes("new DOMParser().parseFromString("), "le HTML collé doit passer par DOMParser");
const pasteIndex = editor.indexOf('getData("text/html")');
const parserIndex = editor.indexOf("new DOMParser().parseFromString(");
assert.ok(parserIndex > pasteIndex, "le HTML collé doit être analysé avant toute réinjection");
});
it("le sérialiseur écarte les balises exécutables", () => {
const serializer = readFileSync(new URL("../src/lib/rich-text-dom.ts", import.meta.url), "utf8");
for (const tag of ["SCRIPT", "STYLE", "IFRAME", "OBJECT"]) {
assert.ok(serializer.includes(`"${tag}"`), `${tag} devrait être explicitement écarté`);
}
});
});
+76
View File
@@ -0,0 +1,76 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
IMPLICIT_TLS_PORT,
STARTTLS_PORTS,
describeSmtpError,
impliedSecure,
tlsMismatch,
} from "../src/lib/smtp-config";
test("smtp — seul le port 465 chiffre dès l'ouverture", () => {
assert.equal(impliedSecure(IMPLICIT_TLS_PORT), true);
for (const port of STARTTLS_PORTS) assert.equal(impliedSecure(port), false);
assert.equal(impliedSecure(1025), false);
});
test("smtp — le couple port / chiffrement cohérent ne dit rien", () => {
assert.equal(tlsMismatch(465, true), null);
assert.equal(tlsMismatch(587, false), null);
assert.equal(tlsMismatch(25, false), null);
});
test("smtp — case cochée sur un port STARTTLS : on prévient avec le port", () => {
const warning = tlsMismatch(587, true);
assert.ok(warning);
assert.match(warning, /587/);
assert.match(warning, /STARTTLS/);
});
test("smtp — case décochée sur le port 465 : on prévient aussi", () => {
const warning = tlsMismatch(465, false);
assert.ok(warning);
assert.match(warning, /465/);
});
test("smtp — « wrong version number » désigne la case à décocher", () => {
const message = describeSmtpError(
"140342:error:0A00010B:SSL routines:ssl3_get_record:wrong version number",
{ host: "mail.infomaniak.com", port: 587, secure: true },
);
assert.match(message, /Décochez/);
assert.match(message, /587/);
});
test("smtp — délai dépassé sur le 465 sans chiffrement : on désigne la case à cocher", () => {
const message = describeSmtpError("Greeting never received", { host: "mail.infomaniak.com", port: 465, secure: false });
assert.match(message, /Cochez/);
});
test("smtp — délai dépassé sur un port STARTTLS : on évoque le filtrage sortant", () => {
const message = describeSmtpError("connect ETIMEDOUT 1.2.3.4:587", { host: "mail.infomaniak.com", port: 587, secure: false });
assert.match(message, /filtré/);
assert.doesNotMatch(message, /Cochez/);
});
test("smtp — authentification refusée : on oriente vers le mot de passe d'application", () => {
const message = describeSmtpError("Invalid login: 535 5.7.8 Authentication failed", { host: "smtp.gmail.com", port: 587 });
assert.match(message, /mot de passe d'application/);
});
test("smtp — nom de serveur introuvable : on cite le nom saisi", () => {
const message = describeSmtpError("getaddrinfo ENOTFOUND mail.exemple.invalid", { host: "mail.exemple.invalid" });
assert.match(message, /mail\.exemple\.invalid/);
});
test("smtp — l'explication précède toujours la trace technique", () => {
const raw = "140342:error:0A00010B:SSL routines:ssl3_get_record:wrong version number";
const message = describeSmtpError(raw, { host: "mail.infomaniak.com", port: 587, secure: true });
assert.ok(message.indexOf("détail") > 0);
assert.ok(!message.startsWith(raw));
});
test("smtp — un échec inconnu est rendu tel quel, jamais vide", () => {
assert.equal(describeSmtpError("Panne inattendue"), "Panne inattendue");
assert.equal(describeSmtpError(""), "Serveur SMTP injoignable");
});