mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 14:48:04 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d31935641 | ||
|
|
0e340dde58 | ||
|
|
2da125eca2 | ||
|
|
cc33cf5fbc | ||
|
|
90b121a3c1 | ||
|
|
b860eeaf29 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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." };
|
||||
|
||||
|
||||
@@ -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'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'ouverture (port 465). Décochez pour STARTTLS (port 587).
|
||||
</label>
|
||||
<p className="field-hint" style={{ marginTop: 8 }}>
|
||||
L'adresse d'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}>
|
||||
|
||||
@@ -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'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}
|
||||
|
||||
@@ -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'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'est
|
||||
posée sur le site. La mesure d'audience ne couvre que les pages publiques — ni le
|
||||
backoffice, ni l'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'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: [],
|
||||
};
|
||||
}
|
||||
@@ -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'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'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" };
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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'adresse d'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>
|
||||
);
|
||||
}
|
||||
@@ -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 : "";
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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("");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 } });
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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_**",
|
||||
);
|
||||
});
|
||||
@@ -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("<script>"));
|
||||
assert.ok(html.includes("&"));
|
||||
assert.ok(!html.includes("<script"));
|
||||
assert.ok(!richTextToEditorHtml("[x](javascript:alert(1))").includes("<a "));
|
||||
});
|
||||
@@ -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é`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user