Badges : format dérivé de l'image importée, postes avec horaire et référent
- Le format du badge n'est plus fixé à CR80 : il est déduit des pixels de l'image du recto à l'import (résolution supposée 300 dpi, app/badges/pdf.py:image_size_mm), stocké sur BadgeTemplate (migration 0012). L'éditeur (BadgesPage.tsx) et la génération PDF s'alignent sur ce format réel ; CR80 reste le repère par défaut tant qu'aucune image n'est importée. Le verso est ajusté sans déformation (preserveAspectRatio). - Le champ « Postes affectés » liste désormais une ligne par créneau (triées chronologiquement) : nom du poste + horaire, et le référent du poste s'il y en a un et que ce n'est pas le bénévole badgé lui-même. Texte multi-lignes pris en charge par la génération PDF (découpe sur `\n`, hauteur de ligne calculée en mm) et par l'aperçu de l'éditeur. - Corrige au passage un bug d'unités dans le positionnement vertical du texte (police en points confondue avec des millimètres). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b7fc80a7dc
commit
579ea5d9db
@@ -43,6 +43,8 @@ export interface MockState {
|
||||
badgeTemplate: {
|
||||
frontImage: string | null; // data URL
|
||||
frontImageType: string | null;
|
||||
frontImageWidthMm: number | null;
|
||||
frontImageHeightMm: number | null;
|
||||
backImage: string | null; // data URL
|
||||
backImageType: string | null;
|
||||
fields: BadgeFieldRead[];
|
||||
@@ -423,6 +425,8 @@ export function buildMockState(): MockState {
|
||||
badgeTemplate: {
|
||||
frontImage: null,
|
||||
frontImageType: null,
|
||||
frontImageWidthMm: null,
|
||||
frontImageHeightMm: null,
|
||||
backImage: null,
|
||||
backImageType: null,
|
||||
fields: [],
|
||||
|
||||
@@ -86,6 +86,21 @@ const notFound = (): never => {
|
||||
throw new ApiError(404, 'error.not_found');
|
||||
};
|
||||
|
||||
// Format CR80 (85,6 × 54 mm) par défaut tant qu'aucune image de recto n'est
|
||||
// importée — sinon le format du badge est dérivé de ses pixels (voir
|
||||
// `imageSizeMm`), comme côté back-end (`app/badges/pdf.py:image_size_mm`).
|
||||
const DEFAULT_BADGE_WIDTH_MM = 85.6;
|
||||
const DEFAULT_BADGE_HEIGHT_MM = 54;
|
||||
const BADGE_ASSUMED_DPI = 300;
|
||||
|
||||
const imageSizeMm = async (file: File): Promise<{ width_mm: number; height_mm: number }> => {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const width_mm = (bitmap.width / BADGE_ASSUMED_DPI) * 25.4;
|
||||
const height_mm = (bitmap.height / BADGE_ASSUMED_DPI) * 25.4;
|
||||
bitmap.close();
|
||||
return { width_mm, height_mm };
|
||||
};
|
||||
|
||||
const fileToDataUrl = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -1333,15 +1348,22 @@ export function createMockApi(): PlanningApi {
|
||||
edition_id: state.edition.id,
|
||||
has_front_image: state.badgeTemplate.frontImage !== null,
|
||||
has_back_image: state.badgeTemplate.backImage !== null,
|
||||
badge_width_mm: state.badgeTemplate.frontImageWidthMm ?? DEFAULT_BADGE_WIDTH_MM,
|
||||
badge_height_mm: state.badgeTemplate.frontImageHeightMm ?? DEFAULT_BADGE_HEIGHT_MM,
|
||||
fields: state.badgeTemplate.fields,
|
||||
}),
|
||||
uploadFrontImage: async (_edition, file: File) => {
|
||||
const { width_mm, height_mm } = await imageSizeMm(file);
|
||||
state.badgeTemplate.frontImage = await fileToDataUrl(file);
|
||||
state.badgeTemplate.frontImageType = file.type;
|
||||
state.badgeTemplate.frontImageWidthMm = width_mm;
|
||||
state.badgeTemplate.frontImageHeightMm = height_mm;
|
||||
return delay<BadgeTemplateRead>({
|
||||
edition_id: state.edition.id,
|
||||
has_front_image: true,
|
||||
has_back_image: state.badgeTemplate.backImage !== null,
|
||||
badge_width_mm: width_mm,
|
||||
badge_height_mm: height_mm,
|
||||
fields: state.badgeTemplate.fields,
|
||||
});
|
||||
},
|
||||
@@ -1352,6 +1374,8 @@ export function createMockApi(): PlanningApi {
|
||||
edition_id: state.edition.id,
|
||||
has_front_image: state.badgeTemplate.frontImage !== null,
|
||||
has_back_image: true,
|
||||
badge_width_mm: state.badgeTemplate.frontImageWidthMm ?? DEFAULT_BADGE_WIDTH_MM,
|
||||
badge_height_mm: state.badgeTemplate.frontImageHeightMm ?? DEFAULT_BADGE_HEIGHT_MM,
|
||||
fields: state.badgeTemplate.fields,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -447,6 +447,10 @@ export interface BadgeTemplateRead {
|
||||
edition_id: string;
|
||||
has_front_image: boolean;
|
||||
has_back_image: boolean;
|
||||
/** Dérivée de l'image du recto (300 dpi supposés) ; format CR80 par défaut
|
||||
* tant qu'aucune image n'est importée. */
|
||||
badge_width_mm: number;
|
||||
badge_height_mm: number;
|
||||
fields: BadgeFieldRead[];
|
||||
}
|
||||
|
||||
|
||||
@@ -327,20 +327,20 @@
|
||||
"badges.tab.customize": "PERSONNALISER",
|
||||
"badges.tab.generate": "GÉNÉRER",
|
||||
"badges.front": "Recto",
|
||||
"badges.front.help": "Fond personnalisable + champs positionnables (nom, poste, catégorie, rôle). Propre à cette édition.",
|
||||
"badges.front.help": "Fond personnalisable + champs positionnables (nom, postes avec horaires, catégorie, rôle). Le format du badge est celui de cette image (300 dpi supposés). Propre à cette édition.",
|
||||
"badges.back": "Verso",
|
||||
"badges.back.help": "Une seule image, la même pour tous les badges de cette édition.",
|
||||
"badges.back.help": "Une seule image, la même pour tous les badges de cette édition — ajustée au format défini par le recto.",
|
||||
"badges.fields": "Champs du recto",
|
||||
"badges.field.fullName": "Prénom + nom",
|
||||
"badges.field.fullName.placeholder": "Prénom Nom",
|
||||
"badges.field.positions": "Poste(s) affecté(s)",
|
||||
"badges.field.positions.placeholder": "Bar central, Accueil",
|
||||
"badges.field.positions.placeholder": "Bar central 19:00–22:00\nAccueil 09:00–13:00 (Référent : Sam Nuit)",
|
||||
"badges.field.category": "Catégorie",
|
||||
"badges.field.category.placeholder": "Bar",
|
||||
"badges.field.role": "Rôle (référent / bénévole)",
|
||||
"badges.field.role.placeholder": "Référent",
|
||||
"badges.field.size": "Taille",
|
||||
"badges.editor.hint": "Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.",
|
||||
"badges.editor.hint": "Badge {width} × {height} mm. Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.",
|
||||
"badges.image.choose": "Choisir une image",
|
||||
"badges.image.replace": "Remplacer l'image",
|
||||
"badges.uploading": "Envoi…",
|
||||
|
||||
@@ -16,11 +16,12 @@ import { Btn, Chip, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
// Badge format CR80 (85,6 × 54 mm — cartes/badges d'événement standard), voir
|
||||
// `backend/app/badges/pdf.py` (même format, mêmes millimètres). `SCALE` fixe
|
||||
// la taille d'édition à l'écran ; les coordonnées enregistrées restent en mm.
|
||||
const BADGE_W_MM = 85.6;
|
||||
const BADGE_H_MM = 54;
|
||||
// Le format du badge est dérivé de l'image de fond du recto (voir
|
||||
// `backend/app/badges/pdf.py:image_size_mm`) — CR80 (85,6 × 54 mm) tant
|
||||
// qu'aucune image n'est importée. `SCALE` fixe la taille d'édition à
|
||||
// l'écran ; les coordonnées enregistrées restent en mm.
|
||||
const DEFAULT_BADGE_W_MM = 85.6;
|
||||
const DEFAULT_BADGE_H_MM = 54;
|
||||
const SCALE = 4.6; // px par mm à l'écran
|
||||
|
||||
type FieldState = BadgeFieldIn & { enabled: boolean };
|
||||
@@ -42,23 +43,26 @@ const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
|
||||
};
|
||||
|
||||
// Position/taille par défaut à l'activation d'un champ — empilés
|
||||
// verticalement pour ne pas se superposer, taille de police décroissante.
|
||||
// verticalement pour ne pas se superposer (repères pour un badge CR80 ;
|
||||
// l'utilisateur ajuste ensuite à la souris pour son propre format). Postes
|
||||
// affectés sur plusieurs lignes (un par créneau, avec horaire) : plus haut
|
||||
// que les autres champs.
|
||||
const FIELD_DEFAULTS: Record<BadgeFieldType, Omit<BadgeFieldIn, 'field_type'>> = {
|
||||
FULL_NAME: {
|
||||
x_mm: 5, y_mm: 6, width_mm: 70, height_mm: 12,
|
||||
x_mm: 5, y_mm: 4, width_mm: 70, height_mm: 10,
|
||||
font_size: 13, align: 'LEFT', bold: true, color: '#000000',
|
||||
},
|
||||
POSITIONS: {
|
||||
x_mm: 5, y_mm: 20, width_mm: 70, height_mm: 10,
|
||||
font_size: 9, align: 'LEFT', bold: false, color: '#000000',
|
||||
x_mm: 5, y_mm: 15, width_mm: 70, height_mm: 22,
|
||||
font_size: 8, align: 'LEFT', bold: false, color: '#000000',
|
||||
},
|
||||
CATEGORY: {
|
||||
x_mm: 5, y_mm: 32, width_mm: 70, height_mm: 9,
|
||||
font_size: 9, align: 'LEFT', bold: false, color: '#000000',
|
||||
x_mm: 5, y_mm: 39, width_mm: 70, height_mm: 7,
|
||||
font_size: 8, align: 'LEFT', bold: false, color: '#000000',
|
||||
},
|
||||
ROLE: {
|
||||
x_mm: 5, y_mm: 43, width_mm: 70, height_mm: 9,
|
||||
font_size: 9, align: 'LEFT', bold: true, color: '#a1442b',
|
||||
x_mm: 5, y_mm: 47, width_mm: 70, height_mm: 6,
|
||||
font_size: 8, align: 'LEFT', bold: true, color: '#a1442b',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -123,6 +127,9 @@ function BadgeCustomizeTab() {
|
||||
if (template.isLoading || !fields) return <Loading />;
|
||||
if (template.error) return <ErrorNote error={template.error} />;
|
||||
|
||||
const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
|
||||
const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM;
|
||||
|
||||
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
|
||||
setFieldsState((prev) => (prev ? { ...prev, [type]: { ...prev[type], ...patch } } : prev));
|
||||
};
|
||||
@@ -165,8 +172,8 @@ function BadgeCustomizeTab() {
|
||||
<div
|
||||
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
|
||||
style={{
|
||||
width: BADGE_W_MM * SCALE,
|
||||
height: BADGE_H_MM * SCALE,
|
||||
width: badgeW * SCALE,
|
||||
height: badgeH * SCALE,
|
||||
backgroundImage: frontUrl ? `url(${frontUrl})` : undefined,
|
||||
}}
|
||||
onPointerDown={() => setSelected(null)}
|
||||
@@ -175,6 +182,8 @@ function BadgeCustomizeTab() {
|
||||
<FieldBox
|
||||
key={type}
|
||||
field={fields[type]}
|
||||
badgeW={badgeW}
|
||||
badgeH={badgeH}
|
||||
selected={selected === type}
|
||||
onSelect={() => setSelected(type)}
|
||||
onChange={(patch) => updateField(type, patch)}
|
||||
@@ -182,7 +191,10 @@ function BadgeCustomizeTab() {
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 font-mono text-[9px] leading-relaxed opacity-60">
|
||||
{t('badges.editor.hint')}
|
||||
{t('badges.editor.hint', {
|
||||
width: Math.round(badgeW * 10) / 10,
|
||||
height: Math.round(badgeH * 10) / 10,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -201,7 +213,7 @@ function BadgeCustomizeTab() {
|
||||
/>
|
||||
<div
|
||||
className="mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center"
|
||||
style={{ width: BADGE_W_MM * SCALE, height: BADGE_H_MM * SCALE, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
|
||||
style={{ width: badgeW * SCALE, height: badgeH * SCALE, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -276,11 +288,15 @@ function ImageUploadRow({
|
||||
|
||||
function FieldBox({
|
||||
field,
|
||||
badgeW,
|
||||
badgeH,
|
||||
selected,
|
||||
onSelect,
|
||||
onChange,
|
||||
}: {
|
||||
field: FieldState;
|
||||
badgeW: number;
|
||||
badgeH: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onChange: (patch: Partial<FieldState>) => void;
|
||||
@@ -305,8 +321,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, BADGE_W_MM - field.width_mm),
|
||||
y_mm: clamp(d.startYMm + dyMm, 0, BADGE_H_MM - field.height_mm),
|
||||
x_mm: clamp(d.startXMm + dxMm, 0, badgeW - field.width_mm),
|
||||
y_mm: clamp(d.startYMm + dyMm, 0, badgeH - field.height_mm),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -323,8 +339,8 @@ function FieldBox({
|
||||
const dwMm = (e.clientX - d.pointerXPx) / SCALE;
|
||||
const dhMm = (e.clientY - d.pointerYPx) / SCALE;
|
||||
onChange({
|
||||
width_mm: clamp(d.startWMm + dwMm, 10, BADGE_W_MM - field.x_mm),
|
||||
height_mm: clamp(d.startHMm + dhMm, 5, BADGE_H_MM - field.y_mm),
|
||||
width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm),
|
||||
height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -334,7 +350,7 @@ function FieldBox({
|
||||
onPointerMove={onDragMove}
|
||||
onPointerUp={() => (dragRef.current = null)}
|
||||
className={cx(
|
||||
'absolute flex cursor-move items-center overflow-hidden border-[1.5px] bg-white/55 px-1',
|
||||
'absolute flex cursor-move items-start overflow-hidden border-[1.5px] bg-white/55 px-1 py-[1px]',
|
||||
selected ? 'border-blue-strong' : 'border-ink/40',
|
||||
)}
|
||||
style={{
|
||||
@@ -345,7 +361,7 @@ function FieldBox({
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none truncate font-sans"
|
||||
className="pointer-events-none whitespace-pre-line font-sans leading-tight"
|
||||
style={{
|
||||
fontSize: field.font_size * (SCALE / 2.83), // pt -> px approx. à l'échelle du badge
|
||||
fontWeight: field.bold ? 800 : 400,
|
||||
|
||||
Reference in New Issue
Block a user