Badges : permettre d'agrandir le badge proportionnellement à l'image

Le format déduit de l'image importée (300 dpi supposés, étape
précédente) donne un badge minuscule pour une image à résolution web —
ajout d'un contrôle « largeur du badge (mm) » dans l'écran de
personnalisation : la hauteur suit proportionnellement (ratio de
l'image). PUT /editions/{id}/badges/template/size écrase la taille
déduite de l'image ; la réimporter la recalcule à nouveau.

Vérifié : pytest (250 passés), ruff, tsc --noEmit, eslint, vite build,
smoke — tous verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-20 20:51:01 +02:00
co-authored by Claude Sonnet 5
parent bc165757ca
commit 55c5d539a8
11 changed files with 174 additions and 0 deletions
+11
View File
@@ -19,6 +19,7 @@ import type {
AvailabilitySet,
BadgeFieldsSet,
BadgeGenerateRequest,
BadgeSizeUpdate,
DayPartCreate,
DayPartUpdate,
EditionUpdate,
@@ -535,6 +536,16 @@ export function useSetBadgeFields() {
});
}
/** Redimensionne le badge indépendamment de l'image (agrandissement
* proportionnel d'une image importée trop petite). */
export function useSetBadgeSize() {
const invalidate = useInvalidateBadgeTemplate();
return useMutation({
mutationFn: (body: BadgeSizeUpdate) => api.badges.setSize(edition, body),
onSuccess: invalidate,
});
}
/** Génère la planche PDF ; ne fait que retourner le fichier (le composant
* appelant déclenche le téléchargement). */
export function useGenerateBadges() {
+6
View File
@@ -9,6 +9,7 @@ import type {
AvailabilitySet,
BadgeFieldsSet,
BadgeGenerateRequest,
BadgeSizeUpdate,
DayPartCreate,
DayPartUpdate,
EditionUpdate,
@@ -247,6 +248,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
method: 'PUT',
body: JSON.stringify(body),
}),
setSize: (editionId, body: BadgeSizeUpdate) =>
request(`${base(editionId)}/badges/template/size`, {
method: 'PUT',
body: JSON.stringify(body),
}),
generate: async (editionId, body: BadgeGenerateRequest) => {
const blob = await requestBlob(`${base(editionId)}/badges/generate`, {
method: 'POST',
+16
View File
@@ -13,6 +13,7 @@ import type {
BadgeFieldRead,
BadgeFieldsSet,
BadgeGenerateRequest,
BadgeSizeUpdate,
BadgeTemplateRead,
DayPartCreate,
DayPartRead,
@@ -1401,6 +1402,21 @@ export function createMockApi(): PlanningApi {
state.badgeTemplate.fields = fields;
return delay(fields);
},
setSize: (_edition, body: BadgeSizeUpdate) => {
if (body.badge_width_mm <= 0 || body.badge_height_mm <= 0) {
throw new ApiError(422, 'badge.invalid_size');
}
state.badgeTemplate.frontImageWidthMm = body.badge_width_mm;
state.badgeTemplate.frontImageHeightMm = body.badge_height_mm;
return delay<BadgeTemplateRead>({
edition_id: state.edition.id,
has_front_image: state.badgeTemplate.frontImage !== null,
has_back_image: state.badgeTemplate.backImage !== null,
badge_width_mm: body.badge_width_mm,
badge_height_mm: body.badge_height_mm,
fields: state.badgeTemplate.fields,
});
},
generate: (_edition, body: BadgeGenerateRequest) => {
if (state.badgeTemplate.fields.length === 0) {
throw new ApiError(422, 'badge.no_fields');
+7
View File
@@ -458,6 +458,13 @@ export interface BadgeGenerateRequest {
volunteer_ids?: string[] | null;
}
/** Redimensionne le badge indépendamment d'un nouvel import d'image — ex.
* agrandir proportionnellement une image importée à basse résolution. */
export interface BadgeSizeUpdate {
badge_width_mm: number;
badge_height_mm: number;
}
// --------------------------------------------------------------------------- //
// Authentification //
// --------------------------------------------------------------------------- //
+4
View File
@@ -16,6 +16,7 @@ import type {
BadgeFieldRead,
BadgeFieldsSet,
BadgeGenerateRequest,
BadgeSizeUpdate,
BadgeTemplateRead,
DayPartCreate,
DayPartRead,
@@ -146,6 +147,9 @@ export interface PlanningApi {
backImageBlob(editionId: string): Promise<Blob | null>;
/** Remplace l'intégralité des champs du recto. */
setFields(editionId: string, body: BadgeFieldsSet): Promise<BadgeFieldRead[]>;
/** Redimensionne le badge indépendamment de l'image (agrandissement
* proportionnel d'une image importée trop petite). */
setSize(editionId: string, body: BadgeSizeUpdate): Promise<BadgeTemplateRead>;
/** Génère la planche PDF (recto personnalisé + verso fixe). */
generate(editionId: string, body: BadgeGenerateRequest): Promise<Blob>;
};
+3
View File
@@ -341,6 +341,8 @@
"badges.field.role.placeholder": "Référent",
"badges.field.size": "Taille",
"badges.editor.hint": "Badge {width} × {height} mm. Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.",
"badges.size.width": "Largeur du badge (mm)",
"badges.size.height": "× {height} mm (hauteur proportionnelle)",
"badges.image.choose": "Choisir une image",
"badges.image.replace": "Remplacer l'image",
"badges.uploading": "Envoi…",
@@ -356,6 +358,7 @@
"badge.empty_image": "L'image est vide.",
"badge.image_too_large": "L'image est trop volumineuse.",
"badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois.",
"badge.invalid_size": "La taille du badge doit être strictement positive.",
"badge.no_fields": "Activez au moins un champ sur le recto avant de générer.",
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection."
}
+63
View File
@@ -5,6 +5,7 @@ import {
useBadgeTemplate,
useGenerateBadges,
useSetBadgeFields,
useSetBadgeSize,
useUploadBadgeBackImage,
useUploadBadgeFrontImage,
useVolunteers,
@@ -116,6 +117,7 @@ function BadgeCustomizeTab() {
const uploadFront = useUploadBadgeFrontImage();
const uploadBack = useUploadBadgeBackImage();
const setFields = useSetBadgeFields();
const setSize = useSetBadgeSize();
const [fields, setFieldsState] = useState<Record<BadgeFieldType, FieldState> | null>(null);
const [selected, setSelected] = useState<BadgeFieldType | null>(null);
@@ -169,6 +171,14 @@ function BadgeCustomizeTab() {
error={uploadFront.error}
onPick={(file) => uploadFront.mutate(file)}
/>
<BadgeSizeControl
badgeW={badgeW}
badgeH={badgeH}
pending={setSize.isPending}
onApply={(width_mm, height_mm) =>
setSize.mutate({ badge_width_mm: width_mm, badge_height_mm: height_mm })
}
/>
<div
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
style={{
@@ -286,6 +296,59 @@ function ImageUploadRow({
);
}
/** Largeur du badge en mm — la hauteur suit proportionnellement (ratio de
* l'image importée). Sert à agrandir un badge devenu minuscule parce que
* l'image de fond n'était pas à une résolution d'impression (300 dpi
* supposés à l'import, voir `useBadgeTemplate`). */
function BadgeSizeControl({
badgeW,
badgeH,
pending,
onApply,
}: {
badgeW: number;
badgeH: number;
pending: boolean;
onApply: (width_mm: number, height_mm: number) => void;
}) {
const rounded = Math.round(badgeW * 10) / 10;
const [width, setWidth] = useState(String(rounded));
useEffect(() => setWidth(String(rounded)), [rounded]);
const apply = () => {
const w = Number(width);
if (!Number.isFinite(w) || w <= 0) {
setWidth(String(rounded));
return;
}
const ratio = badgeH / badgeW;
onApply(w, Math.round(w * ratio * 10) / 10);
};
return (
<div className="mt-2 flex items-center gap-[8px]">
<FieldLabel>{t('badges.size.width')}</FieldLabel>
<input
type="number"
min={5}
step={1}
value={width}
disabled={pending}
onChange={(e) => setWidth(e.target.value)}
onBlur={apply}
onKeyDown={(e) => {
if (e.key === 'Enter') apply();
}}
className="w-[64px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]"
/>
<span className="font-mono text-[9.5px] opacity-60">
{t('badges.size.height', { height: Math.round(badgeH * 10) / 10 })}
</span>
</div>
);
}
function FieldBox({
field,
badgeW,