Refonte du créateur de badges et nombre de badges par bénévole
Créateur de badges refait d'après la maquette : champs sur le recto et le verso (BadgeField.face, verso placé en miroir dans le PDF pour le recto-verso), fond de zone, réduction automatique du texte, photo ovale, initiales sans photo, nom sur deux lignes (migration 0026) ; éditeur en trois colonnes (champs, aperçu recto/verso avec de vrais bénévoles, inspecteur), formats prédéfinis, annuler, flèches, zoom, repères de découpe, dépôt d'image sur l'aperçu, contrôle de tous les badges avant génération. Nombre de badges par bénévole (Volunteer.badge_count, 1 par défaut, migration 0027) : champ sur la fiche, autant d'exemplaires sur les planches, total affiché à la génération, colonne dans l'export Excel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1a23881e81
commit
ced0855d47
@@ -0,0 +1,460 @@
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import type { BadgeFace, VolunteerRead } from '@/api/schema';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
import {
|
||||
CELL_PAD_X_MM,
|
||||
CELL_PAD_Y_MM,
|
||||
FIRST_BASELINE,
|
||||
LINE_HEIGHT,
|
||||
MM_PER_PT,
|
||||
type TableLayout,
|
||||
type TextLayout,
|
||||
} from './layout';
|
||||
import {
|
||||
FACE_LABEL_KEY,
|
||||
fmt,
|
||||
initials,
|
||||
isVerticallyCentered,
|
||||
renderField,
|
||||
SAFE_MARGIN_MM,
|
||||
type BadgeData,
|
||||
type FieldState,
|
||||
} from './model';
|
||||
|
||||
/** Fond d'une photo absente (initiales à la place), comme le PDF. */
|
||||
export const PHOTO_PLACEHOLDER_BG = '#E7E0CE';
|
||||
/** Pas d'accroche du glisser-déposer (mm). */
|
||||
const DRAG_SNAP_MM = 0.5;
|
||||
/** Plus petit cadre possible au redimensionnement (mm). */
|
||||
const MIN_W_MM = 3;
|
||||
const MIN_H_MM = 2;
|
||||
/** Décalage entre la ligne de base d'une ligne CSS (interligne 1,25, police
|
||||
* Helvetica/Arial) et celle du PDF (0,85 × taille sous le haut du cadre). */
|
||||
const CSS_BASELINE = 0.97;
|
||||
const FONT_STACK = 'Helvetica, Arial, sans-serif';
|
||||
|
||||
function snap(value: number): number {
|
||||
return Math.round(value / DRAG_SNAP_MM) * DRAG_SNAP_MM;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export interface BadgeCanvasProps {
|
||||
face: BadgeFace;
|
||||
fields: FieldState[];
|
||||
data: BadgeData;
|
||||
volunteer: VolunteerRead | null;
|
||||
photoUrl: string | null;
|
||||
imageUrl: string | null;
|
||||
badgeW: number;
|
||||
badgeH: number;
|
||||
scale: number;
|
||||
showGuides: boolean;
|
||||
selectedType: string | null;
|
||||
uploading: boolean;
|
||||
onSelect: (field: FieldState) => void;
|
||||
/** `record` : enregistrer l'état actuel dans l'historique d'annulation
|
||||
* avant d'appliquer `patch` (une seule fois par glisser-déposer). */
|
||||
onChange: (field: FieldState, patch: Partial<FieldState>, record: boolean) => void;
|
||||
onDropImage: (file: File) => void;
|
||||
}
|
||||
|
||||
/** Aperçu d'une face du badge, éditable (glisser, redimensionner) et zone de
|
||||
* dépôt de son image de fond. */
|
||||
export function BadgeCanvas({
|
||||
face,
|
||||
fields,
|
||||
data,
|
||||
volunteer,
|
||||
photoUrl,
|
||||
imageUrl,
|
||||
badgeW,
|
||||
badgeH,
|
||||
scale,
|
||||
showGuides,
|
||||
selectedType,
|
||||
uploading,
|
||||
onSelect,
|
||||
onChange,
|
||||
onDropImage,
|
||||
}: BadgeCanvasProps) {
|
||||
const [dropping, setDropping] = useState(false);
|
||||
const shown = fields.filter((f) => f.enabled);
|
||||
const isFaceSelected = shown.some((f) => f.field_type === selectedType);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-[9px]">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span
|
||||
className={cx(
|
||||
'border-2 border-ink px-2 py-1 font-display text-[11px] font-black uppercase tracking-wider',
|
||||
isFaceSelected ? 'bg-ink text-yellow' : 'bg-paper text-ink',
|
||||
)}
|
||||
>
|
||||
{t(FACE_LABEL_KEY[face])}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] opacity-70">
|
||||
{t('badges.dims', { width: fmt(badgeW), height: fmt(badgeH) })}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
'relative flex-none select-none border-[3px] border-ink bg-sand-dark bg-contain bg-center bg-no-repeat shadow-brutal-lg',
|
||||
dropping && 'outline outline-[3px] outline-offset-2 outline-blue-strong',
|
||||
)}
|
||||
// + 6 : bordure de 3 px de chaque côté (les champs se placent dans
|
||||
// la boîte intérieure, à l'échelle exacte du badge).
|
||||
style={{
|
||||
width: badgeW * scale + 6,
|
||||
height: badgeH * scale + 6,
|
||||
backgroundImage: imageUrl ? `url(${imageUrl})` : undefined,
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!e.dataTransfer.types.includes('Files')) return;
|
||||
e.preventDefault();
|
||||
setDropping(true);
|
||||
}}
|
||||
onDragLeave={() => setDropping(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDropping(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) onDropImage(file);
|
||||
}}
|
||||
>
|
||||
{/* Invitation à déposer une image : seulement sur une face encore
|
||||
vide (elle passerait sous les champs sinon), ou par-dessus tout
|
||||
pendant un dépôt / un envoi. */}
|
||||
{uploading || dropping || (!imageUrl && shown.length === 0) ? (
|
||||
<div
|
||||
className={cx(
|
||||
'pointer-events-none absolute inset-0 flex items-center justify-center p-3 text-center font-mono text-[10px] font-medium leading-relaxed',
|
||||
uploading || dropping ? 'z-20 bg-blue/80 text-ink' : 'text-ink/60',
|
||||
)}
|
||||
>
|
||||
{uploading
|
||||
? t('badges.uploading')
|
||||
: t(face === 'FRONT' ? 'badges.drop.front' : 'badges.drop.back')}
|
||||
</div>
|
||||
) : null}
|
||||
{showGuides ? (
|
||||
<div
|
||||
className="pointer-events-none absolute border-[1.5px] border-dashed border-yellow opacity-90"
|
||||
style={{ inset: SAFE_MARGIN_MM * scale }}
|
||||
/>
|
||||
) : null}
|
||||
{shown.map((field) => (
|
||||
<FieldBox
|
||||
key={field.field_type}
|
||||
field={field}
|
||||
data={data}
|
||||
volunteer={volunteer}
|
||||
photoUrl={photoUrl}
|
||||
badgeW={badgeW}
|
||||
badgeH={badgeH}
|
||||
scale={scale}
|
||||
showGuides={showGuides}
|
||||
selected={selectedType === field.field_type}
|
||||
onSelect={() => onSelect(field)}
|
||||
onChange={(patch, record) => onChange(field, patch, record)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldBox({
|
||||
field,
|
||||
data,
|
||||
volunteer,
|
||||
photoUrl,
|
||||
badgeW,
|
||||
badgeH,
|
||||
scale,
|
||||
showGuides,
|
||||
selected,
|
||||
onSelect,
|
||||
onChange,
|
||||
}: {
|
||||
field: FieldState;
|
||||
data: BadgeData;
|
||||
volunteer: VolunteerRead | null;
|
||||
photoUrl: string | null;
|
||||
badgeW: number;
|
||||
badgeH: number;
|
||||
scale: number;
|
||||
showGuides: boolean;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onChange: (patch: Partial<FieldState>, record: boolean) => void;
|
||||
}) {
|
||||
const dragRef = useRef<{
|
||||
mode: 'move' | 'resize';
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
px: number;
|
||||
py: number;
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const start = (mode: 'move' | 'resize') => (e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
(e.currentTarget as Element).setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
mode,
|
||||
x: field.x_mm,
|
||||
y: field.y_mm,
|
||||
w: field.width_mm,
|
||||
h: field.height_mm,
|
||||
px: e.clientX,
|
||||
py: e.clientY,
|
||||
moved: false,
|
||||
};
|
||||
};
|
||||
|
||||
const move = (e: React.PointerEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
e.stopPropagation();
|
||||
const dx = (e.clientX - d.px) / scale;
|
||||
const dy = (e.clientY - d.py) / scale;
|
||||
if (!d.moved && Math.abs(dx) < 0.2 && Math.abs(dy) < 0.2) return;
|
||||
const record = !d.moved;
|
||||
d.moved = true;
|
||||
if (d.mode === 'move') {
|
||||
// Bornes jamais négatives : un champ plus grand que le badge reste
|
||||
// saisissable (voir `fitToBadge`).
|
||||
onChange(
|
||||
{
|
||||
x_mm: clamp(snap(d.x + dx), 0, Math.max(0, badgeW - d.w)),
|
||||
y_mm: clamp(snap(d.y + dy), 0, Math.max(0, badgeH - d.h)),
|
||||
},
|
||||
record,
|
||||
);
|
||||
} else {
|
||||
const maxW = Math.max(0, badgeW - d.x);
|
||||
const maxH = Math.max(0, badgeH - d.y);
|
||||
onChange(
|
||||
{
|
||||
width_mm: clamp(snap(d.w + dx), Math.min(MIN_W_MM, maxW), maxW),
|
||||
height_mm: clamp(snap(d.h + dy), Math.min(MIN_H_MM, maxH), maxH),
|
||||
},
|
||||
record,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const end = (e: React.PointerEvent) => {
|
||||
if (dragRef.current) e.stopPropagation();
|
||||
dragRef.current = null;
|
||||
};
|
||||
|
||||
const rendered = volunteer ? renderField(field, volunteer, data) : null;
|
||||
const layout = rendered && rendered.kind !== 'photo' ? rendered.layout : null;
|
||||
const truncated = layout?.truncated ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDown={start('move')}
|
||||
onPointerMove={move}
|
||||
onPointerUp={end}
|
||||
onPointerCancel={end}
|
||||
className="absolute cursor-move touch-none"
|
||||
style={{
|
||||
left: field.x_mm * scale,
|
||||
top: field.y_mm * scale,
|
||||
width: field.width_mm * scale,
|
||||
height: field.height_mm * scale,
|
||||
outline: selected
|
||||
? '2px solid #2A6BFF'
|
||||
: truncated
|
||||
? '2px dashed #FF5C39'
|
||||
: showGuides
|
||||
? '1px dashed rgba(20,17,14,.45)'
|
||||
: 'none',
|
||||
// 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,
|
||||
}}
|
||||
>
|
||||
{rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? (
|
||||
<PhotoContent field={field} volunteer={volunteer} photoUrl={photoUrl} scale={scale} />
|
||||
) : rendered?.kind === 'table' ? (
|
||||
<TableContent field={field} layout={rendered.layout} scale={scale} />
|
||||
) : rendered?.kind === 'text' ? (
|
||||
<TextContent field={field} layout={rendered.layout} scale={scale} />
|
||||
) : null}
|
||||
|
||||
{truncated ? (
|
||||
<span className="pointer-events-none absolute -left-[3px] -top-[19px] whitespace-nowrap border-2 border-ink bg-orange px-1 py-[2px] font-display text-[9px] font-extrabold leading-none">
|
||||
{t('badges.preview.truncated')}
|
||||
</span>
|
||||
) : null}
|
||||
{selected ? (
|
||||
<>
|
||||
<span className="pointer-events-none absolute -bottom-[19px] left-0 z-10 whitespace-nowrap bg-blue-strong px-[5px] py-[2px] font-mono text-[9px] font-semibold leading-tight text-white">
|
||||
{t('badges.preview.dims', { width: fmt(field.width_mm), height: fmt(field.height_mm) })}
|
||||
{layout?.reduced
|
||||
? t('badges.preview.reducedTo', { size: fmt(layout.fontSize) })
|
||||
: null}
|
||||
</span>
|
||||
<span
|
||||
onPointerDown={start('resize')}
|
||||
onPointerMove={move}
|
||||
onPointerUp={end}
|
||||
onPointerCancel={end}
|
||||
className="absolute -bottom-[7px] -right-[7px] h-[15px] w-[15px] cursor-nwse-resize touch-none border-2 border-ink bg-yellow lg:h-[13px] lg:w-[13px]"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextContent({ field, layout, scale }: { field: FieldState; layout: TextLayout; scale: number }) {
|
||||
const fontPx = layout.fontSize * MM_PER_PT * scale;
|
||||
const lineHeightPx = fontPx * LINE_HEIGHT;
|
||||
// Bandeaux (texte libre, rôle) centrés verticalement, les autres en haut —
|
||||
// même calcul que le PDF : bloc = interlignes + une hauteur de caractère.
|
||||
const blockPx = Math.max(0, layout.visible.length - 1) * lineHeightPx + fontPx;
|
||||
const boxPx = field.height_mm * scale;
|
||||
const offset = isVerticallyCentered(field.field_type) ? Math.max(0, (boxPx - blockPx) / 2) : 0;
|
||||
const top = offset + (FIRST_BASELINE - CSS_BASELINE) * fontPx;
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none relative h-full w-full overflow-hidden"
|
||||
style={{ background: field.bg_color ?? 'transparent' }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-x-0"
|
||||
style={{
|
||||
top,
|
||||
color: field.color,
|
||||
fontFamily: FONT_STACK,
|
||||
fontSize: fontPx,
|
||||
lineHeight: `${lineHeightPx}px`,
|
||||
fontWeight: field.bold ? 700 : 400,
|
||||
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
|
||||
}}
|
||||
>
|
||||
{layout.visible.map((line, i) => (
|
||||
<div key={i} className="whitespace-pre">
|
||||
{line || ' '}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tableau des créneaux, mêmes colonnes, hauteurs de ligne et lignes
|
||||
* retenues que le PDF (`_draw_table`). */
|
||||
function TableContent({ field, layout, scale }: { field: FieldState; layout: TableLayout; scale: number }) {
|
||||
const fontPx = layout.fontSize * MM_PER_PT * scale;
|
||||
const lineHeightPx = fontPx * LINE_HEIGHT;
|
||||
const rule = `${Math.max(0.5, 0.4 * MM_PER_PT * scale)}px solid ${field.color}`;
|
||||
const padX = CELL_PAD_X_MM * scale;
|
||||
const padY = CELL_PAD_Y_MM * scale;
|
||||
const fullWidth = layout.widthsMm.reduce((a, b) => a + b, 0) * scale;
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none h-full w-full overflow-hidden"
|
||||
style={{ background: field.bg_color ?? 'transparent' }}
|
||||
>
|
||||
{layout.rows.length ? (
|
||||
<div
|
||||
style={{
|
||||
width: fullWidth,
|
||||
border: rule,
|
||||
color: field.color,
|
||||
fontFamily: FONT_STACK,
|
||||
fontSize: fontPx,
|
||||
lineHeight: `${lineHeightPx}px`,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{layout.rows.map((row, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex"
|
||||
style={{
|
||||
height: row.heightMm * scale,
|
||||
borderTop: i > 0 ? rule : undefined,
|
||||
fontWeight: row.isTitle ? 700 : 400,
|
||||
}}
|
||||
>
|
||||
{row.cells.map((lines, col) => (
|
||||
<div
|
||||
key={col}
|
||||
className="flex-none overflow-hidden"
|
||||
style={{
|
||||
width: row.isTitle ? '100%' : layout.widthsMm[col] * scale,
|
||||
padding: `${padY}px ${padX}px`,
|
||||
borderLeft: col > 0 ? rule : undefined,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{lines.map((line, j) => (
|
||||
<div key={j} className="whitespace-pre">
|
||||
{line || ' '}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PhotoContent({
|
||||
field,
|
||||
volunteer,
|
||||
photoUrl,
|
||||
scale,
|
||||
}: {
|
||||
field: FieldState;
|
||||
volunteer: VolunteerRead | null;
|
||||
photoUrl: string | null;
|
||||
scale: number;
|
||||
}) {
|
||||
const radius = field.photo_shape === 'OVAL' ? '50%' : 0;
|
||||
if (volunteer?.has_photo && photoUrl) {
|
||||
return (
|
||||
<img
|
||||
src={photoUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="pointer-events-none h-full w-full object-cover"
|
||||
style={{ borderRadius: radius }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none flex h-full w-full items-center justify-center font-bold"
|
||||
style={{
|
||||
borderRadius: radius,
|
||||
background: PHOTO_PLACEHOLDER_BG,
|
||||
color: field.color,
|
||||
fontFamily: FONT_STACK,
|
||||
// Comme le PDF : 0,28 × la largeur du cadre, 4 pt au moins.
|
||||
fontSize: Math.max(4 * MM_PER_PT, field.width_mm * 0.28) * scale,
|
||||
}}
|
||||
>
|
||||
{volunteer ? initials(volunteer) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user