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:
co-authored by
Claude Opus 5.5
parent
6892e0307d
commit
cfed76c239
@@ -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 {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
Reference in New Issue
Block a user