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")); + }); +});