mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 22:58:04 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d72016bfb | ||
|
|
e59ae3a908 | ||
|
|
146250f973 |
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,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { auth } from "@/auth";
|
||||
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 { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
@@ -232,40 +233,31 @@ export default async function AssociationPage() {
|
||||
const remaining = Math.max(0, meeting.capacity - registered);
|
||||
const isRegistered = myMeetingIds.has(meeting.id);
|
||||
return (
|
||||
<article id={`rencontre-${meeting.id}`} key={meeting.id} className="lift" style={{ background: "#fff", border: isRegistered ? "2px solid #1f8a5b" : "1px solid #e6dcc6", borderRadius: 12, overflow: "hidden", scrollMarginTop: 100 }}>
|
||||
<div style={{ position: "relative", aspectRatio: "16 / 10", background: meeting.imageUrl ? `center/cover no-repeat url(${meeting.imageUrl})` : "repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)" }}>
|
||||
{isRegistered && (
|
||||
<span style={{ position: "absolute", top: 12, right: 12, display: "inline-flex", alignItems: "center", gap: 6, background: "#1f8a5b", color: "#fff", borderRadius: 999, padding: "7px 12px", fontSize: 12, fontWeight: 800, boxShadow: "0 4px 14px rgba(22,72,49,0.28)" }}>
|
||||
✓ Inscrit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: 18 }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>
|
||||
{formatDate(meeting.startsAt)} · {formatTime(meeting.startsAt)}
|
||||
</div>
|
||||
<h3 className="font-display" style={{ margin: "7px 0 7px", color: "#26201a", fontSize: 21 }}>
|
||||
{meeting.title}
|
||||
</h3>
|
||||
{meeting.location && <div style={{ fontSize: 13, color: "#6c6150", fontWeight: 700 }}>{meeting.location}</div>}
|
||||
<p style={{ color: "#8c8068", fontSize: 13.5, lineHeight: 1.6, minHeight: 64 }}>
|
||||
{meeting.description}
|
||||
</p>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 12 }}>
|
||||
<span>{registered}/{meeting.capacity} inscrits</span>
|
||||
<span>{remaining} place(s)</span>
|
||||
</div>
|
||||
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 10 }}>
|
||||
Jusqu'à {meeting.participantsPerAccount} participant{meeting.participantsPerAccount > 1 ? "s" : ""} par compte
|
||||
</div>
|
||||
<Link
|
||||
href={`/inscription/${meeting.id}`}
|
||||
style={{ display: "block", textAlign: "center", textDecoration: "none", background: remaining <= 0 && !isRegistered ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 11, fontWeight: 800 }}
|
||||
>
|
||||
{isRegistered ? "Modifier mon inscription" : remaining <= 0 ? "Complet" : memberId ? "S'inscrire" : "Connexion et inscription"}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
<MeetingCard
|
||||
key={meeting.id}
|
||||
meeting={{
|
||||
id: meeting.id,
|
||||
title: meeting.title,
|
||||
description: meeting.description,
|
||||
location: meeting.location,
|
||||
imageUrl: meeting.imageUrl,
|
||||
// Formatées ici : côté client, le fuseau du visiteur
|
||||
// donnerait une autre heure et casserait l'hydratation.
|
||||
dateLabel: formatDate(meeting.startsAt),
|
||||
timeLabel: formatTime(meeting.startsAt),
|
||||
capacity: meeting.capacity,
|
||||
registered,
|
||||
participantsPerAccount: meeting.participantsPerAccount,
|
||||
isRegistered,
|
||||
ctaLabel: isRegistered
|
||||
? "Modifier mon inscription"
|
||||
: remaining <= 0
|
||||
? "Complet"
|
||||
: memberId
|
||||
? "S'inscrire"
|
||||
: "Connexion et inscription",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -246,7 +246,9 @@ export default async function PromotionsPage() {
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#a99c82", marginTop: 7, lineHeight: 1.45 }}>
|
||||
Publié dès la validation. Ce choix n'est plus modifiable ensuite.
|
||||
Diffusé en même temps que la mise en ligne : tout de suite si le
|
||||
champ Publication est vide, à la date indiquée sinon. Ce choix
|
||||
n'est plus modifiable ensuite.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Modal } from "@/components/Modal";
|
||||
|
||||
/**
|
||||
* Carte d'une rencontre à venir, de hauteur identique à ses voisines.
|
||||
*
|
||||
* Le texte d'une rencontre est libre : une description longue étirait sa carte,
|
||||
* donc toute la rangée de la grille, et laissait les autres avec un grand vide
|
||||
* sous leur bouton. Titre et description occupent désormais une hauteur figée
|
||||
* et sont tronqués en fin de ligne ; le texte complet reste lisible dans une
|
||||
* fenêtre ouverte par « Voir le détail ».
|
||||
*
|
||||
* Les dates arrivent **déjà formatées** depuis la page serveur : les formater
|
||||
* ici les rendrait dans le fuseau du visiteur, différent de celui du serveur,
|
||||
* et provoquerait une erreur d'hydratation.
|
||||
*/
|
||||
|
||||
export type UpcomingMeetingCardData = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
location: string | null;
|
||||
imageUrl: string | null;
|
||||
/** « vendredi 3 octobre 2026 ». */
|
||||
dateLabel: string;
|
||||
/** « 18:30 ». */
|
||||
timeLabel: string;
|
||||
capacity: number;
|
||||
registered: number;
|
||||
participantsPerAccount: number;
|
||||
isRegistered: boolean;
|
||||
/** Libellé du bouton d'inscription, décidé côté serveur (session, places). */
|
||||
ctaLabel: string;
|
||||
};
|
||||
|
||||
const STRIPE =
|
||||
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
|
||||
|
||||
/**
|
||||
* Hauteurs figées des deux blocs de texte, même principe que `MemberCard` :
|
||||
* elles alignent les cartes quel que soit le texte saisi par le staff.
|
||||
*/
|
||||
const TITLE_FONT = 21;
|
||||
const TITLE_LINE = 1.25;
|
||||
const TITLE_LINES = 2;
|
||||
const DESC_FONT = 13.5;
|
||||
const DESC_LINE = 1.6;
|
||||
const DESC_LINES = 3;
|
||||
|
||||
const clampLines = (lines: number) =>
|
||||
({
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: lines,
|
||||
WebkitBoxOrient: "vertical" as const,
|
||||
overflow: "hidden",
|
||||
}) as const;
|
||||
|
||||
export function MeetingCard({ meeting }: { meeting: UpcomingMeetingCardData }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const remaining = Math.max(0, meeting.capacity - meeting.registered);
|
||||
const full = remaining <= 0 && !meeting.isRegistered;
|
||||
const perAccount = `Jusqu'à ${meeting.participantsPerAccount} participant${meeting.participantsPerAccount > 1 ? "s" : ""} par compte`;
|
||||
const cover = meeting.imageUrl ? `center/cover no-repeat url(${meeting.imageUrl})` : STRIPE;
|
||||
|
||||
return (
|
||||
<article
|
||||
id={`rencontre-${meeting.id}`}
|
||||
className="lift"
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: meeting.isRegistered ? "2px solid #1f8a5b" : "1px solid #e6dcc6",
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
scrollMarginTop: 100,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative", aspectRatio: "16 / 10", background: cover, flexShrink: 0 }}>
|
||||
{meeting.isRegistered && (
|
||||
<span style={{ position: "absolute", top: 12, right: 12, display: "inline-flex", alignItems: "center", gap: 6, background: "#1f8a5b", color: "#fff", borderRadius: 999, padding: "7px 12px", fontSize: 12, fontWeight: 800, boxShadow: "0 4px 14px rgba(22,72,49,0.28)" }}>
|
||||
✓ Inscrit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 18, display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
<div style={{ color: "#9a6638", fontSize: 12.5, fontWeight: 800 }}>
|
||||
{meeting.dateLabel} · {meeting.timeLabel}
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className="font-display"
|
||||
style={{
|
||||
margin: "7px 0 7px",
|
||||
color: "#26201a",
|
||||
fontSize: TITLE_FONT,
|
||||
lineHeight: TITLE_LINE,
|
||||
height: TITLE_FONT * TITLE_LINE * TITLE_LINES,
|
||||
...clampLines(TITLE_LINES),
|
||||
}}
|
||||
>
|
||||
{meeting.title}
|
||||
</h3>
|
||||
|
||||
{/* Le lieu est facultatif : sa ligne est réservée dans tous les cas,
|
||||
sinon une carte sans lieu remonterait tout son bas. */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "#6c6150",
|
||||
fontWeight: 700,
|
||||
height: 13 * 1.4,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{meeting.location}
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
color: "#8c8068",
|
||||
fontSize: DESC_FONT,
|
||||
lineHeight: DESC_LINE,
|
||||
height: DESC_FONT * DESC_LINE * DESC_LINES,
|
||||
margin: "8px 0 0",
|
||||
...clampLines(DESC_LINES),
|
||||
}}
|
||||
>
|
||||
{meeting.description}
|
||||
</p>
|
||||
|
||||
{/* Espaceur : colle le bas de carte au bas du cadre, quelle que soit la
|
||||
hauteur imposée par la plus haute carte de la rangée. */}
|
||||
<div style={{ flex: 1, minHeight: 12 }} />
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", color: "#6c6150", fontSize: 12.5, fontWeight: 800, marginBottom: 8 }}>
|
||||
<span>{meeting.registered}/{meeting.capacity} inscrits</span>
|
||||
<span>{remaining} place(s)</span>
|
||||
</div>
|
||||
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 10 }}>{perAccount}</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
style={{ flex: "0 0 auto", border: "1px solid #d6c9ad", background: "#fff", color: "#6f6450", borderRadius: 8, padding: "11px 14px", fontWeight: 800, fontSize: 13.5, cursor: "pointer", fontFamily: "inherit" }}
|
||||
>
|
||||
Voir le détail
|
||||
</button>
|
||||
<Link
|
||||
href={`/inscription/${meeting.id}`}
|
||||
style={{ flex: 1, textAlign: "center", textDecoration: "none", background: full ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 11, fontWeight: 800 }}
|
||||
>
|
||||
{meeting.ctaLabel}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={open} onClose={() => setOpen(false)} title={meeting.title} maxWidth={560}>
|
||||
{meeting.imageUrl && (
|
||||
<div style={{ aspectRatio: "16 / 10", background: cover, borderRadius: 12, marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<div style={{ color: "#9a6638", fontSize: 13, fontWeight: 800 }}>
|
||||
{meeting.dateLabel} · {meeting.timeLabel}
|
||||
</div>
|
||||
{meeting.location && (
|
||||
<div style={{ fontSize: 13.5, color: "#6c6150", fontWeight: 700, marginTop: 4 }}>{meeting.location}</div>
|
||||
)}
|
||||
|
||||
{meeting.description && (
|
||||
<p style={{ color: "#6c6150", fontSize: 14, lineHeight: 1.7, whiteSpace: "pre-line", margin: "14px 0 0" }}>
|
||||
{meeting.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 12, padding: "12px 14px", margin: "18px 0 6px", fontSize: 13, fontWeight: 800, color: "#6c6150" }}>
|
||||
<span>{meeting.registered}/{meeting.capacity} inscrits</span>
|
||||
<span>{remaining} place(s) restante(s)</span>
|
||||
</div>
|
||||
<div style={{ color: "#8c8068", fontSize: 12.5, marginBottom: 16 }}>{perAccount}</div>
|
||||
|
||||
<Link
|
||||
href={`/inscription/${meeting.id}`}
|
||||
style={{ display: "block", textAlign: "center", textDecoration: "none", background: full ? "#a99c82" : "#13324F", color: "#fff", borderRadius: 8, padding: 12, fontWeight: 800 }}
|
||||
>
|
||||
{meeting.ctaLabel}
|
||||
</Link>
|
||||
</Modal>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -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