mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
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:
4 files changed
+307
-1
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user