Badges : texte libre saisi dans l'éditeur, champs ramenés dans le badge

Texte libre commun à tous les badges, saisi dans l'éditeur
(BadgeField.text, migration 0025) ; le « Texte du badge » de la fiche le
remplace pour un bénévole. Champs ramenés à l'intérieur du badge et champ
sélectionné au premier plan : la photo restait bloquée hors d'un badge
plus étroit que le CR80 (bornes de glisser-déposer négatives).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-27 18:30:34 +01:00
co-authored by Claude Opus 5.5
parent 6892e0307d
commit cfed76c239
11 changed files with 134 additions and 22 deletions
+3
View File
@@ -444,6 +444,9 @@ export interface BadgeFieldIn {
align: BadgeAlign;
bold: boolean;
color: string;
/** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour
* un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */
text?: string | null;
}
export interface BadgeFieldsSet {
+3 -2
View File
@@ -231,7 +231,7 @@
"config.vol.field.phone": "Téléphone",
"config.vol.field.comment": "Commentaire",
"config.vol.field.badgeText": "Texte du badge",
"config.vol.field.badgeText.help": "Imprimé sur le badge si le champ « Texte libre » y est placé (écran Badges).",
"config.vol.field.badgeText.help": "Remplace, pour ce bénévole seulement, le texte libre commun saisi dans l'écran Badges. Vide = texte commun.",
"config.vol.photo.title": "Photo",
"config.vol.photo.hint": "Imprimée sur le badge (champ « Photo du bénévole ») et affichée sur les écrans bénévole et référent. Celle envoyée au formulaire d'inscription, ou ajoutée ici. PNG ou JPEG.",
"config.vol.photo.none": "Aucune photo",
@@ -366,7 +366,8 @@
"badges.field.photo.help": "La photo du bénévole (envoyée avec le formulaire d'inscription ou ajoutée sur sa fiche), recadrée au format du cadre. Rien n'est imprimé pour un bénévole sans photo.",
"badges.field.freeText": "Texte libre",
"badges.field.freeText.placeholder": "Accès backstage",
"badges.field.freeText.help": "Texte propre à chaque bénévole, à saisir sur sa fiche (Configuration › Bénévoles, « Texte du badge »). Vide = rien d'imprimé.",
"badges.field.freeText.help": "Texte saisi ci-dessous, imprimé sur tous les badges. Pour un bénévole en particulier, le « Texte du badge » de sa fiche (Configuration › Bénévoles) le remplace.",
"badges.field.freeText.input": "Texte à imprimer (ex. STAFF 2026)",
"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)",
+55 -10
View File
@@ -117,6 +117,24 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/** Ramène un champ à l'intérieur du badge (réduit s'il est plus grand,
* décalé s'il dépasse). Les positions par défaut sont pensées pour un CR80
* paysage (85,6 × 54 mm) : sur un badge plus étroit (portrait…), un champ
* activé pouvait tomber hors du badge — invisible, et les bornes négatives
* du glisser-déposer le bloquaient (bug corrigé une fois : photo
* impossible à déplacer ni redimensionner). */
function fitToBadge<F extends BadgeFieldIn>(field: F, badgeW: number, badgeH: number): F {
const width_mm = Math.min(field.width_mm, badgeW);
const height_mm = Math.min(field.height_mm, badgeH);
return {
...field,
width_mm,
height_mm,
x_mm: clamp(field.x_mm, 0, badgeW - width_mm),
y_mm: clamp(field.y_mm, 0, badgeH - height_mm),
};
}
function fieldsFromServer(saved: BadgeFieldIn[]): Record<BadgeFieldType, FieldState> {
const byType = new Map(saved.map((f) => [f.field_type, f]));
const out = {} as Record<BadgeFieldType, FieldState>;
@@ -168,19 +186,28 @@ function BadgeCustomizeTab() {
const [fields, setFieldsState] = useState<Record<BadgeFieldType, FieldState> | null>(null);
const [selected, setSelected] = useState<BadgeFieldType | null>(null);
useEffect(() => {
if (template.data) setFieldsState(fieldsFromServer(template.data.fields));
}, [template.data]);
const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM;
useEffect(() => {
if (!template.data) return;
const loaded = fieldsFromServer(template.data.fields);
for (const type of FIELD_TYPES) loaded[type] = fitToBadge(loaded[type], badgeW, badgeH);
setFieldsState(loaded);
}, [template.data, badgeW, badgeH]);
const scale = useEditorScale(badgeW);
if (template.isLoading || !fields) return <Loading />;
if (template.error) return <ErrorNote error={template.error} />;
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
setFieldsState((prev) => (prev ? { ...prev, [type]: { ...prev[type], ...patch } } : prev));
setFieldsState((prev) =>
prev
? { ...prev, [type]: fitToBadge({ ...prev[type], ...patch }, badgeW, badgeH) }
: prev,
);
// Un champ qu'on vient d'activer passe au premier plan, sélectionné.
if (patch.enabled) setSelected(type);
};
const save = () => {
@@ -197,6 +224,7 @@ function BadgeCustomizeTab() {
align: f.align,
bold: f.bold,
color: f.color,
text: f.field_type === 'FREE_TEXT' ? f.text?.trim() || null : null,
};
},
);
@@ -434,8 +462,8 @@ function FieldBox({
const dxMm = (e.clientX - d.pointerXPx) / scale;
const dyMm = (e.clientY - d.pointerYPx) / scale;
onChange({
x_mm: clamp(d.startXMm + dxMm, 0, badgeW - field.width_mm),
y_mm: clamp(d.startYMm + dyMm, 0, badgeH - field.height_mm),
x_mm: clamp(d.startXMm + dxMm, 0, Math.max(0, badgeW - field.width_mm)),
y_mm: clamp(d.startYMm + dyMm, 0, Math.max(0, badgeH - field.height_mm)),
});
};
@@ -451,9 +479,11 @@ function FieldBox({
const d = resizeRef.current;
const dwMm = (e.clientX - d.pointerXPx) / scale;
const dhMm = (e.clientY - d.pointerYPx) / scale;
const maxW = badgeW - field.x_mm;
const maxH = badgeH - field.y_mm;
onChange({
width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm),
height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm),
width_mm: clamp(d.startWMm + dwMm, Math.min(10, maxW), maxW),
height_mm: clamp(d.startHMm + dhMm, Math.min(5, maxH), maxH),
});
};
@@ -471,6 +501,9 @@ function FieldBox({
top: field.y_mm * scale,
width: field.width_mm * scale,
height: field.height_mm * scale,
// Le champ sélectionné passe devant les autres : sinon un champ
// recouvert (ex. la photo sous un texte) ne se saisit plus.
zIndex: selected ? 10 : undefined,
}}
>
{field.field_type === 'PHOTO' ? (
@@ -488,7 +521,9 @@ function FieldBox({
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
}}
>
{t(FIELD_PLACEHOLDER_KEY[field.field_type])}
{field.field_type === 'FREE_TEXT' && field.text?.trim()
? field.text
: t(FIELD_PLACEHOLDER_KEY[field.field_type])}
</span>
)}
<div
@@ -533,6 +568,16 @@ function FieldEditorRow({
) : null}
</div>
{field.enabled && type === 'FREE_TEXT' ? (
<input
type="text"
value={field.text ?? ''}
onChange={(e) => onChange({ text: e.target.value })}
placeholder={t('badges.field.freeText.input')}
className="ml-[24px] border-2 border-ink bg-cream px-[8px] py-[5px] font-display text-[12px] font-bold"
/>
) : null}
{/* La photo n'a pas de style de texte : seuls sa place et son cadre
(sur l'aperçu) comptent. */}
{field.enabled && type !== 'PHOTO' ? (