diff --git a/CLAUDE.md b/CLAUDE.md
index 39da4a6..106bd4e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -139,6 +139,12 @@ 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.
- 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,
diff --git a/src/app/backend/espace/MemberSpaceForm.tsx b/src/app/backend/espace/MemberSpaceForm.tsx
index 20596b8..5b2cd9c 100644
--- a/src/app/backend/espace/MemberSpaceForm.tsx
+++ b/src/app/backend/espace/MemberSpaceForm.tsx
@@ -10,6 +10,7 @@ import type { SocialNetwork } from "@/lib/social";
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";
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
@@ -51,6 +52,41 @@ function SummaryRow({
);
}
+/**
+ * Taille, format et poids de l'image déposée, avec ce que les réseaux en
+ * feront. Purement informatif : aucune remarque n'empêche l'envoi.
+ */
+function ImageReport({ info }: { info: ImageInfo }) {
+ const notes = imageNotes(info);
+ return (
+
+
{imageSummary(info)}
+ {notes.map((note, i) => (
+
+ {note.text}
+
+ ))}
+
+ );
+}
+
export function MemberSpaceForm({
memberName,
memberLogoUrl,
@@ -78,6 +114,10 @@ export function MemberSpaceForm({
const [endsOn, setEndsOn] = useState("");
const [publishAt, setPublishAt] = useState("");
const [imgData, setImgData] = useState("");
+ // Dimensions et poids du fichier déposé : rien n'est retouché à l'envoi, donc
+ // 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(null);
const [shares, setShares] = useState([]);
const [submitted, setSubmitted] = useState(false);
const [pending, setPending] = useState(false);
@@ -91,10 +131,20 @@ export function MemberSpaceForm({
function onFile(e: React.ChangeEvent) {
const f = e.target.files?.[0];
if (!f) return;
+ const bytes = f.size;
+ setImgInfo(null);
const reader = new FileReader();
reader.onload = () => {
- setImgData(String(reader.result));
+ const dataUrl = String(reader.result);
+ setImgData(dataUrl);
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);
}
@@ -107,6 +157,7 @@ export function MemberSpaceForm({
setEndsOn("");
setPublishAt("");
setImgData("");
+ setImgInfo(null);
setShares([]);
setSubmitted(false);
if (fileRef.current) fileRef.current.value = "";
@@ -194,6 +245,8 @@ export function MemberSpaceForm({
+ {imgInfo && }
+
0 &&
+ info.height > 0
+ );
+}
+
+/** Poids lisible : « 840 Ko », « 1,2 Mo ». */
+export function formatBytes(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;
+ // Une décimale jusqu'à 10 Mo, virgule française.
+ const value = mo < 10 ? mo.toFixed(1).replace(".", ",") : String(Math.round(mo));
+ return `${value} Mo`;
+}
+
+/**
+ * Libellé du rapport largeur/hauteur : un format courant quand il en approche
+ * un à 2 % près, sinon une approximation décimale plutôt qu'un « 1083:721 »
+ * illisible.
+ */
+export function aspectLabel(width: number, height: number): string {
+ if (!isValid({ width, height, bytes: 0 })) return "";
+ const ratio = width / height;
+ for (const known of KNOWN_RATIOS) {
+ if (Math.abs(ratio - known.ratio) / known.ratio <= SQUARE_TOLERANCE) return known.label;
+ }
+ return ratio >= 1
+ ? `${ratio.toFixed(2).replace(".", ",")}:1`
+ : `1:${(1 / ratio).toFixed(2).replace(".", ",")}`;
+}
+
+/** `true` si l'image est carrée à la tolérance près. */
+export function isSquare(info: ImageInfo): boolean {
+ if (!isValid(info)) return false;
+ return Math.abs(info.width / info.height - 1) <= SQUARE_TOLERANCE;
+}
+
+/** Orientation en toutes lettres, pour accompagner le ratio. */
+export function orientationLabel(info: ImageInfo): string {
+ if (!isValid(info)) return "";
+ if (isSquare(info)) return "carré";
+ return info.width > info.height ? "paysage" : "portrait";
+}
+
+/** « 1080 × 1350 px · portrait 4:5 · 420 Ko ». */
+export function imageSummary(info: ImageInfo): string {
+ if (!isValid(info)) return "";
+ const parts = [
+ `${Math.round(info.width)} × ${Math.round(info.height)} px`,
+ `${orientationLabel(info)} ${aspectLabel(info.width, info.height)}`.trim(),
+ ];
+ const weight = formatBytes(info.bytes);
+ if (weight) parts.push(weight);
+ return parts.join(" · ");
+}
+
+export type ImageNote = { tone: "warn" | "info"; text: string };
+
+/**
+ * Remarques à afficher sous l'aperçu. Elles **informent**, elles ne bloquent
+ * pas : un visuel non carré reste publiable, l'adhérent doit seulement savoir
+ * ce que les réseaux en feront.
+ */
+export function imageNotes(info: ImageInfo): ImageNote[] {
+ if (!isValid(info)) return [];
+ const notes: ImageNote[] = [];
+
+ if (!isSquare(info)) {
+ notes.push({
+ tone: "warn",
+ text:
+ `Format ${aspectLabel(info.width, info.height)} : sur Facebook et LinkedIn, l'image sera ` +
+ `recadrée ou entourée de bandes de couleur. Une image carrée (${IDEAL_SIDE} × ${IDEAL_SIDE} px) ` +
+ `s'affiche entière.`,
+ });
+ }
+
+ if (Math.min(info.width, info.height) < MIN_SIDE) {
+ notes.push({
+ tone: "warn",
+ text: `Image de petite taille : elle paraîtra floue une fois agrandie. Visez ${IDEAL_SIDE} px de côté.`,
+ });
+ }
+
+ if (info.bytes > MAX_BYTES) {
+ notes.push({
+ tone: "warn",
+ text: `Fichier lourd (${formatBytes(info.bytes)}) : l'envoi peut être refusé. Compressez l'image avant de la déposer.`,
+ });
+ }
+
+ if (notes.length === 0) {
+ notes.push({ tone: "info", text: "Format idéal : l'image s'affichera entière sur le site et sur les réseaux." });
+ }
+
+ return notes;
+}
diff --git a/tests/image-info.test.ts b/tests/image-info.test.ts
new file mode 100644
index 0000000..3c43345
--- /dev/null
+++ b/tests/image-info.test.ts
@@ -0,0 +1,91 @@
+import { strict as assert } from "node:assert";
+import { describe, it } from "node:test";
+
+import {
+ aspectLabel,
+ formatBytes,
+ imageNotes,
+ imageSummary,
+ isSquare,
+ orientationLabel,
+ IDEAL_SIDE,
+} from "../src/lib/image-info";
+
+describe("Poids d'un fichier", () => {
+ it("passe des octets aux kilo-octets puis aux méga-octets", () => {
+ assert.equal(formatBytes(640), "640 o");
+ assert.equal(formatBytes(420_000), "420 Ko");
+ assert.equal(formatBytes(1_200_000), "1,2 Mo");
+ });
+
+ it("ne rend rien pour une valeur absurde", () => {
+ assert.equal(formatBytes(-1), "");
+ assert.equal(formatBytes(Number.NaN), "");
+ });
+});
+
+describe("Format d'une image", () => {
+ it("reconnaît les formats courants", () => {
+ assert.equal(aspectLabel(1080, 1080), "1:1");
+ assert.equal(aspectLabel(1080, 1350), "4:5");
+ assert.equal(aspectLabel(1920, 1080), "16:9");
+ });
+
+ it("tolère un écart de 2 % autour d'un format connu", () => {
+ assert.equal(aspectLabel(1080, 1078), "1:1");
+ assert.ok(isSquare({ width: 1080, height: 1078, bytes: 0 }));
+ });
+
+ it("approche les formats inhabituels plutôt que de rendre une fraction illisible", () => {
+ assert.equal(aspectLabel(1000, 730), "1,37:1");
+ assert.equal(aspectLabel(730, 1000), "1:1,37");
+ });
+
+ it("nomme l'orientation", () => {
+ assert.equal(orientationLabel({ width: 1080, height: 1080, bytes: 0 }), "carré");
+ assert.equal(orientationLabel({ width: 1080, height: 1350, bytes: 0 }), "portrait");
+ assert.equal(orientationLabel({ width: 1350, height: 1080, bytes: 0 }), "paysage");
+ });
+
+ it("résume dimensions, format et poids", () => {
+ assert.equal(
+ imageSummary({ width: 1080, height: 1350, bytes: 420_000 }),
+ "1080 × 1350 px · portrait 4:5 · 420 Ko"
+ );
+ });
+
+ it("ne résume rien sans dimensions exploitables", () => {
+ assert.equal(imageSummary({ width: 0, height: 0, bytes: 1000 }), "");
+ assert.deepEqual(imageNotes({ width: 0, height: 0, bytes: 1000 }), []);
+ });
+});
+
+describe("Remarques sur l'image déposée", () => {
+ it("félicite une image carrée assez grande et assez légère", () => {
+ const notes = imageNotes({ width: IDEAL_SIDE, height: IDEAL_SIDE, bytes: 300_000 });
+ assert.equal(notes.length, 1);
+ assert.equal(notes[0].tone, "info");
+ });
+
+ it("prévient des bandes de couleur ajoutées par les réseaux hors du carré", () => {
+ const notes = imageNotes({ width: 1080, height: 1350, bytes: 300_000 });
+ assert.ok(notes.some((n) => n.tone === "warn" && /bandes de couleur/.test(n.text)));
+ assert.ok(notes.some((n) => /4:5/.test(n.text)));
+ });
+
+ it("signale une image trop petite", () => {
+ const notes = imageNotes({ width: 400, height: 400, bytes: 60_000 });
+ assert.ok(notes.some((n) => /floue/.test(n.text)));
+ });
+
+ it("signale un fichier trop lourd", () => {
+ const notes = imageNotes({ width: 1080, height: 1080, bytes: 3_000_000 });
+ assert.ok(notes.some((n) => /Fichier lourd/.test(n.text)));
+ });
+
+ it("cumule les remarques quand tout se ligue", () => {
+ const notes = imageNotes({ width: 600, height: 900, bytes: 3_000_000 });
+ assert.equal(notes.length, 3);
+ assert.ok(notes.every((n) => n.tone === "warn"));
+ });
+});