Espace adhérent : taille et format de l'image annoncés au dépôt

L'application publie le fichier tel quel sur Facebook et LinkedIn : aucun
recadrage, aucune marge. Un visuel qui n'est pas au format attendu est donc
recadré — ou entouré de bandes de couleur prises dans l'image — par la
plateforme, ce qui ne se voit nulle part côté site puisque les cartes
recadrent en `cover`.

Le formulaire de dépôt affiche maintenant les dimensions, le format et le
poids du fichier choisi, et dit ce que les réseaux en feront : format non
carré, image trop petite pour un fil d'actualité, fichier trop lourd pour
l'envoi. Purement informatif, rien n'est bloqué.

`src/lib/image-info.ts` est pur et verrouillé par `tests/image-info.test.ts`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MS61rkgTUGgPYH88tuVx9U
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-09-14 05:52:13 +00:00
1 parent 146250f973
commit e59ae3a908
4 files changed
+307 -1

No files matched your search

+6
View File
@@ -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,
+54 -1
View File
@@ -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 (
<div
style={{
marginTop: -8,
marginBottom: 18,
background: "#faf7ef",
border: "1px solid #f0e8d6",
borderRadius: 12,
padding: "10px 12px",
}}
>
<div style={{ fontSize: 12.5, fontWeight: 700, color: "#6c6150" }}>{imageSummary(info)}</div>
{notes.map((note, i) => (
<div
key={i}
style={{
fontSize: 11.5,
lineHeight: 1.45,
marginTop: 5,
color: note.tone === "warn" ? "#a8503c" : "#1f8a5b",
}}
>
{note.text}
</div>
))}
</div>
);
}
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<ImageInfo | null>(null);
const [shares, setShares] = useState<SocialNetwork[]>([]);
const [submitted, setSubmitted] = useState(false);
const [pending, setPending] = useState(false);
@@ -91,10 +131,20 @@ export function MemberSpaceForm({
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 = () => {
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({
<input ref={fileRef} type="file" accept="image/*" onChange={onFile} style={{ display: "none" }} />
</label>
{imgInfo && <ImageReport info={imgInfo} />}
<label className="field-label">Titre de la promotion</label>
<input
value={title}
+156
View File
@@ -0,0 +1,156 @@
/**
* Description d'une image déposée dans un formulaire : dimensions, format et
* poids, plus les remarques à montrer à l'adhérent avant l'envoi.
*
* Module **pur** (aucun accès DOM, base ou réseau) : il est appelé depuis un
* composant client, et verrouillé par `tests/image-info.test.ts`.
*
* Le besoin vient des réseaux sociaux. L'application publie le fichier tel
* quel — `loadPromoImage()` décode la data-URI et envoie les octets sans y
* toucher — donc un visuel qui n'est pas au format attendu par Facebook ou
* LinkedIn est recadré ou entouré de bandes de couleur *par la plateforme*,
* une couleur prise dans l'image. Rien ne se voit côté site, qui recadre en
* `cover` : d'où l'information au moment du dépôt, seul endroit où l'adhérent
* peut encore changer de fichier.
*/
export type ImageInfo = {
/** Largeur en pixels. */
width: number;
/** Hauteur en pixels. */
height: number;
/** Poids du fichier d'origine, en octets. */
bytes: number;
};
/** Écart toléré autour du carré parfait (2 %) : 1080×1078 reste « carré ». */
export const SQUARE_TOLERANCE = 0.02;
/** En dessous, l'image sera floue une fois agrandie dans un fil d'actualité. */
export const MIN_SIDE = 800;
/** Côté conseillé, celui qu'attendent les deux réseaux. */
export const IDEAL_SIDE = 1080;
/**
* Au delà, la data-URI (base64, ~+33 %) approche la limite de 4 Mo des server
* actions : l'envoi peut être refusé.
*/
export const MAX_BYTES = 2_500_000;
/** Formats courants reconnus, du plus au moins fréquent. */
const KNOWN_RATIOS: { ratio: number; label: string }[] = [
{ ratio: 1, label: "1:1" },
{ ratio: 4 / 5, label: "4:5" },
{ ratio: 5 / 4, label: "5:4" },
{ ratio: 3 / 4, label: "3:4" },
{ ratio: 4 / 3, label: "4:3" },
{ ratio: 2 / 3, label: "2:3" },
{ ratio: 3 / 2, label: "3:2" },
{ ratio: 9 / 16, label: "9:16" },
{ ratio: 16 / 9, label: "16:9" },
{ ratio: 1.91, label: "1,91:1" },
];
function isValid(info: ImageInfo): boolean {
return (
Number.isFinite(info.width) &&
Number.isFinite(info.height) &&
info.width > 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;
}
+91
View File
@@ -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"));
});
});