Files
benevoles/frontend/src/pages/badges/BadgeCanvas.tsx
T
AdamThomasandClaude Opus 5.5 c6ea7378c7
ci-cd / build-backend (push) Successful in 34s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 11s
Fiches de poste rapides, badges sans initiales, vue par bénévole sur l'axe des temps
Étape 62 : les fiches de poste intégraient la photo d'origine de chaque
bénévole pour chaque créneau (CPU à 300 %, puis erreur) — photos réduites
à la vignette une fois par PDF ; badges : photo recadrée une fois par
génération et décodée à taille réduite.

Étape 61 : sans photo, le cadre de la photo reste vide sur le badge.

Étape 60 : vue par bénévole en tranches de 30 min, affectations étirées
sur toute leur durée, postes simultanés empilés, ligne de l'heure actuelle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:01:48 +01:00

478 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
isVerticallyCentered,
renderField,
SAFE_MARGIN_MM,
stackOrder,
type BadgeData,
type FieldState,
} from './model';
/** 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);
// Du dessous vers le dessus, comme le PDF (`z_order`).
const shown = stackOrder(fields, face);
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, i) => (
<FieldBox
key={field.field_type}
field={field}
layer={i + 1}
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,
layer,
data,
volunteer,
photoUrl,
badgeW,
badgeH,
scale,
showGuides,
selected,
onSelect,
onChange,
}: {
field: FieldState;
/** Rang dans la pile de la face (`z-index`). */
layer: number;
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;
const geometry = {
left: field.x_mm * scale,
top: field.y_mm * scale,
width: field.width_mm * scale,
height: field.height_mm * scale,
};
const handlers = {
onPointerDown: start('move'),
onPointerMove: move,
onPointerUp: end,
onPointerCancel: end,
};
return (
<>
{/* Contenu à son rang dans la pile, comme sur le badge imprimé. */}
<div
{...(selected ? {} : handlers)}
className={cx('absolute touch-none', selected ? 'pointer-events-none' : 'cursor-move')}
style={{
...geometry,
outline: selected
? 'none'
: truncated
? '2px dashed #FF5C39'
: showGuides
? '1px dashed rgba(20,17,14,.45)'
: 'none',
zIndex: layer,
}}
>
{rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? (
<PhotoContent field={field} volunteer={volunteer} photoUrl={photoUrl} />
) : rendered?.kind === 'table' ? (
<TableContent field={field} layout={rendered.layout} scale={scale} />
) : rendered?.kind === 'text' ? (
<TextContent field={field} layout={rendered.layout} scale={scale} />
) : null}
{truncated && !selected ? <TruncatedTag /> : null}
</div>
{/* Cadre de sélection par-dessus toute la pile : un champ recouvert
(ex. la photo sous un texte) reste saisissable, sans que l'aperçu
change l'ordre d'impression. */}
{selected ? (
<div
{...handlers}
className="absolute z-[15] cursor-move touch-none"
style={{ ...geometry, outline: '2px solid #2A6BFF' }}
>
{truncated ? <TruncatedTag /> : null}
<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]"
/>
</div>
) : null}
</>
);
}
function TruncatedTag() {
return (
<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>
);
}
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,
}: {
field: FieldState;
volunteer: VolunteerRead | null;
photoUrl: string | null;
}) {
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 }}
/>
);
}
// Sans photo, rien n'est imprimé : juste un repère pointillé dans
// l'éditeur (pas sur le badge) pour savoir où est le cadre.
return (
<div
className="pointer-events-none flex h-full w-full items-center justify-center border border-dashed border-ink/40 text-center font-mono text-[9px] leading-tight text-ink/50"
style={{ borderRadius: radius }}
>
{t('badges.preview.noPhoto')}
</div>
);
}