Files
benevoles/frontend/src/pages/BadgesPage.tsx
T
AdamThomasandClaude Sonnet 5 55c5d539a8 Badges : permettre d'agrandir le badge proportionnellement à l'image
Le format déduit de l'image importée (300 dpi supposés, étape
précédente) donne un badge minuscule pour une image à résolution web —
ajout d'un contrôle « largeur du badge (mm) » dans l'écran de
personnalisation : la hauteur suit proportionnellement (ratio de
l'image). PUT /editions/{id}/badges/template/size écrase la taille
déduite de l'image ; la réimporter la recalcule à nouveau.

Vérifié : pytest (250 passés), ruff, tsc --noEmit, eslint, vite build,
smoke — tous verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 20:51:01 +02:00

625 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { useEffect, useMemo, useRef, useState } from 'react';
import {
useBadgeImageUrl,
useBadgeTemplate,
useGenerateBadges,
useSetBadgeFields,
useSetBadgeSize,
useUploadBadgeBackImage,
useUploadBadgeFrontImage,
useVolunteers,
} from '@/api/hooks';
import type { BadgeAlign, BadgeFieldIn, BadgeFieldType } from '@/api/schema';
import { ApiError } from '@/api';
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
import { Btn, Chip, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
// 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 };
const FIELD_TYPES: BadgeFieldType[] = ['FULL_NAME', 'POSITIONS', 'CATEGORY', 'ROLE'];
const FIELD_LABEL_KEY: Record<BadgeFieldType, string> = {
FULL_NAME: 'badges.field.fullName',
POSITIONS: 'badges.field.positions',
CATEGORY: 'badges.field.category',
ROLE: 'badges.field.role',
};
const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
FULL_NAME: 'badges.field.fullName.placeholder',
POSITIONS: 'badges.field.positions.placeholder',
CATEGORY: 'badges.field.category.placeholder',
ROLE: 'badges.field.role.placeholder',
};
// Position/taille par défaut à l'activation d'un champ — empilés
// 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: 4, width_mm: 70, height_mm: 10,
font_size: 13, align: 'LEFT', bold: true, color: '#000000',
},
POSITIONS: {
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: 39, width_mm: 70, height_mm: 7,
font_size: 8, align: 'LEFT', bold: false, color: '#000000',
},
ROLE: {
x_mm: 5, y_mm: 47, width_mm: 70, height_mm: 6,
font_size: 8, align: 'LEFT', bold: true, color: '#a1442b',
},
};
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function fieldsFromServer(saved: BadgeFieldIn[]): Record<BadgeFieldType, FieldState> {
const byType = new Map(saved.map((f) => [f.field_type, f]));
const out = {} as Record<BadgeFieldType, FieldState>;
for (const type of FIELD_TYPES) {
const existing = byType.get(type);
out[type] = existing
? { ...existing, enabled: true }
: { field_type: type, enabled: false, ...FIELD_DEFAULTS[type] };
}
return out;
}
export function BadgesPage() {
const [tab, setTab] = useState<'customize' | 'generate'>('customize');
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-wrap items-center gap-2 border-b-2 border-ink bg-sand px-4 py-[9px]">
<span className="mr-1 font-mono text-[9px] opacity-60">{t('badges.admin')}</span>
<Chip active={tab === 'customize'} onClick={() => setTab('customize')}>
{t('badges.tab.customize')}
</Chip>
<Chip active={tab === 'generate'} onClick={() => setTab('generate')}>
{t('badges.tab.generate')}
</Chip>
</div>
{tab === 'customize' ? <BadgeCustomizeTab /> : <BadgeGenerateTab />}
</div>
);
}
// --------------------------------------------------------------------------- //
// Personnalisation //
// --------------------------------------------------------------------------- //
function BadgeCustomizeTab() {
const template = useBadgeTemplate();
const frontUrl = useBadgeImageUrl(
'front', template.data?.has_front_image ?? false, template.dataUpdatedAt,
);
const backUrl = useBadgeImageUrl(
'back', template.data?.has_back_image ?? false, template.dataUpdatedAt,
);
const uploadFront = useUploadBadgeFrontImage();
const uploadBack = useUploadBadgeBackImage();
const setFields = useSetBadgeFields();
const setSize = useSetBadgeSize();
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]);
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));
};
const save = () => {
const payload: BadgeFieldIn[] = FIELD_TYPES.filter((type) => fields[type].enabled).map(
(type) => {
const f = fields[type];
return {
field_type: f.field_type,
x_mm: f.x_mm,
y_mm: f.y_mm,
width_mm: f.width_mm,
height_mm: f.height_mm,
font_size: f.font_size,
align: f.align,
bold: f.bold,
color: f.color,
};
},
);
setFields.mutate({ fields: payload });
};
return (
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-4 lg:flex-row lg:items-start">
<div className="flex flex-none flex-col gap-4">
<Panel>
<PanelHeader>
{t('badges.front')}
<HelpTip text={t('badges.front.help')} className="ml-[6px]" />
</PanelHeader>
<div className="p-3">
<ImageUploadRow
url={frontUrl}
pending={uploadFront.isPending}
error={uploadFront.error}
onPick={(file) => uploadFront.mutate(file)}
/>
<BadgeSizeControl
badgeW={badgeW}
badgeH={badgeH}
pending={setSize.isPending}
onApply={(width_mm, height_mm) =>
setSize.mutate({ badge_width_mm: width_mm, badge_height_mm: height_mm })
}
/>
<div
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
style={{
width: badgeW * SCALE,
height: badgeH * SCALE,
backgroundImage: frontUrl ? `url(${frontUrl})` : undefined,
}}
onPointerDown={() => setSelected(null)}
>
{FIELD_TYPES.filter((type) => fields[type].enabled).map((type) => (
<FieldBox
key={type}
field={fields[type]}
badgeW={badgeW}
badgeH={badgeH}
selected={selected === type}
onSelect={() => setSelected(type)}
onChange={(patch) => updateField(type, patch)}
/>
))}
</div>
<p className="mt-2 font-mono text-[9px] leading-relaxed opacity-60">
{t('badges.editor.hint', {
width: Math.round(badgeW * 10) / 10,
height: Math.round(badgeH * 10) / 10,
})}
</p>
</div>
</Panel>
<Panel>
<PanelHeader>
{t('badges.back')}
<HelpTip text={t('badges.back.help')} className="ml-[6px]" />
</PanelHeader>
<div className="p-3">
<ImageUploadRow
url={backUrl}
pending={uploadBack.isPending}
error={uploadBack.error}
onPick={(file) => uploadBack.mutate(file)}
/>
<div
className="mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center"
style={{ width: badgeW * SCALE, height: badgeH * SCALE, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
/>
</div>
</Panel>
</div>
<Panel className="min-w-[320px] flex-1">
<PanelHeader>{t('badges.fields')}</PanelHeader>
<div className="flex flex-col divide-y-2 divide-ink/10">
{FIELD_TYPES.map((type) => (
<FieldEditorRow
key={type}
type={type}
field={fields[type]}
active={selected === type}
onFocus={() => setSelected(type)}
onChange={(patch) => updateField(type, patch)}
/>
))}
</div>
<div className="flex items-center gap-3 border-t-2 border-ink p-3">
<Btn onClick={save} disabled={setFields.isPending}>
{setFields.isPending ? t('badges.saving') : t('badges.save')}
</Btn>
{setFields.isSuccess ? (
<span className="font-mono text-[9.5px] font-bold text-green">{t('badges.saved')}</span>
) : null}
{setFields.isError ? (
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
{errorMessage(setFields.error)}
</span>
) : null}
</div>
</Panel>
</div>
);
}
function ImageUploadRow({
url,
pending,
error,
onPick,
}: {
url: string | null;
pending: boolean;
error: unknown;
onPick: (file: File) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<div className="flex items-center gap-[10px]">
<Btn variant="ghost" onClick={() => inputRef.current?.click()} disabled={pending}>
{pending ? t('badges.uploading') : url ? t('badges.image.replace') : t('badges.image.choose')}
</Btn>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onPick(file);
e.target.value = '';
}}
/>
{error ? (
<span className="font-mono text-[9.5px] font-bold text-orange-dark">{errorMessage(error)}</span>
) : null}
</div>
);
}
/** Largeur du badge en mm — la hauteur suit proportionnellement (ratio de
* l'image importée). Sert à agrandir un badge devenu minuscule parce que
* l'image de fond n'était pas à une résolution d'impression (300 dpi
* supposés à l'import, voir `useBadgeTemplate`). */
function BadgeSizeControl({
badgeW,
badgeH,
pending,
onApply,
}: {
badgeW: number;
badgeH: number;
pending: boolean;
onApply: (width_mm: number, height_mm: number) => void;
}) {
const rounded = Math.round(badgeW * 10) / 10;
const [width, setWidth] = useState(String(rounded));
useEffect(() => setWidth(String(rounded)), [rounded]);
const apply = () => {
const w = Number(width);
if (!Number.isFinite(w) || w <= 0) {
setWidth(String(rounded));
return;
}
const ratio = badgeH / badgeW;
onApply(w, Math.round(w * ratio * 10) / 10);
};
return (
<div className="mt-2 flex items-center gap-[8px]">
<FieldLabel>{t('badges.size.width')}</FieldLabel>
<input
type="number"
min={5}
step={1}
value={width}
disabled={pending}
onChange={(e) => setWidth(e.target.value)}
onBlur={apply}
onKeyDown={(e) => {
if (e.key === 'Enter') apply();
}}
className="w-[64px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]"
/>
<span className="font-mono text-[9.5px] opacity-60">
{t('badges.size.height', { height: Math.round(badgeH * 10) / 10 })}
</span>
</div>
);
}
function FieldBox({
field,
badgeW,
badgeH,
selected,
onSelect,
onChange,
}: {
field: FieldState;
badgeW: number;
badgeH: number;
selected: boolean;
onSelect: () => void;
onChange: (patch: Partial<FieldState>) => void;
}) {
const dragRef = useRef<{ startXMm: number; startYMm: number; pointerXPx: number; pointerYPx: number } | null>(
null,
);
const resizeRef = useRef<{ startWMm: number; startHMm: number; pointerXPx: number; pointerYPx: number } | null>(
null,
);
const startDrag = (e: React.PointerEvent) => {
e.stopPropagation();
onSelect();
(e.target as Element).setPointerCapture(e.pointerId);
dragRef.current = { startXMm: field.x_mm, startYMm: field.y_mm, pointerXPx: e.clientX, pointerYPx: e.clientY };
};
const onDragMove = (e: React.PointerEvent) => {
if (!dragRef.current) return;
const d = dragRef.current;
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),
});
};
const startResize = (e: React.PointerEvent) => {
e.stopPropagation();
onSelect();
(e.target as Element).setPointerCapture(e.pointerId);
resizeRef.current = { startWMm: field.width_mm, startHMm: field.height_mm, pointerXPx: e.clientX, pointerYPx: e.clientY };
};
const onResizeMove = (e: React.PointerEvent) => {
if (!resizeRef.current) return;
const d = resizeRef.current;
const dwMm = (e.clientX - d.pointerXPx) / SCALE;
const dhMm = (e.clientY - d.pointerYPx) / SCALE;
onChange({
width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm),
height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm),
});
};
return (
<div
onPointerDown={startDrag}
onPointerMove={onDragMove}
onPointerUp={() => (dragRef.current = null)}
className={cx(
'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={{
left: field.x_mm * SCALE,
top: field.y_mm * SCALE,
width: field.width_mm * SCALE,
height: field.height_mm * SCALE,
}}
>
<span
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,
color: field.color,
width: '100%',
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
}}
>
{t(FIELD_PLACEHOLDER_KEY[field.field_type])}
</span>
<div
onPointerDown={startResize}
onPointerMove={onResizeMove}
onPointerUp={() => (resizeRef.current = null)}
className="absolute bottom-0 right-0 h-[10px] w-[10px] cursor-nwse-resize border-l-[1.5px] border-t-[1.5px] border-ink bg-yellow"
/>
</div>
);
}
function FieldEditorRow({
type,
field,
active,
onFocus,
onChange,
}: {
type: BadgeFieldType;
field: FieldState;
active: boolean;
onFocus: () => void;
onChange: (patch: Partial<FieldState>) => void;
}) {
return (
<div className={cx('flex flex-col gap-2 p-3', active && 'bg-yellow-soft')} onClick={onFocus}>
<label className="flex items-center gap-[8px]">
<input
type="checkbox"
checked={field.enabled}
onChange={(e) => onChange({ enabled: e.target.checked })}
className="h-[16px] w-[16px] accent-ink"
/>
<span className="font-display text-[12px] font-extrabold uppercase tracking-wide">
{t(FIELD_LABEL_KEY[type])}
</span>
</label>
{field.enabled ? (
<div className="ml-[24px] flex flex-wrap items-center gap-[10px]">
<label className="flex items-center gap-[5px]">
<FieldLabel>{t('badges.field.size')}</FieldLabel>
<input
type="number"
min={6}
max={36}
value={field.font_size}
onChange={(e) => onChange({ font_size: Number(e.target.value) })}
className="w-[52px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]"
/>
</label>
<div className="flex items-center gap-[4px]">
{(['LEFT', 'CENTER', 'RIGHT'] as BadgeAlign[]).map((align) => (
<button
key={align}
type="button"
onClick={() => onChange({ align })}
className={cx(
'h-[24px] w-[24px] cursor-pointer border-2 border-ink font-mono text-[10px] font-bold',
field.align === align ? 'bg-ink text-cream' : 'bg-cream hover:bg-yellow',
)}
>
{align === 'LEFT' ? '⟵' : align === 'CENTER' ? '↔' : '⟶'}
</button>
))}
</div>
<button
type="button"
onClick={() => onChange({ bold: !field.bold })}
className={cx(
'h-[24px] w-[24px] cursor-pointer border-2 border-ink font-display text-[11px] font-black',
field.bold ? 'bg-ink text-cream' : 'bg-cream hover:bg-yellow',
)}
>
G
</button>
<input
type="color"
value={field.color}
onChange={(e) => onChange({ color: e.target.value })}
className="h-[24px] w-[30px] cursor-pointer border-2 border-ink p-0"
/>
</div>
) : null}
</div>
);
}
// --------------------------------------------------------------------------- //
// Génération //
// --------------------------------------------------------------------------- //
function BadgeGenerateTab() {
const volunteers = useVolunteers();
const generate = useGenerateBadges();
const [mode, setMode] = useState<'all' | 'pick'>('all');
const [picked, setPicked] = useState<Set<string>>(new Set());
const sorted = useMemo(
() => [...(volunteers.data ?? [])].sort((a, b) => a.last_name.localeCompare(b.last_name, 'fr')),
[volunteers.data],
);
if (volunteers.isLoading) return <Loading />;
if (volunteers.error) return <ErrorNote error={volunteers.error} />;
if (sorted.length === 0) return <EmptyNote>{t('volunteers.noData')}</EmptyNote>;
const toggle = (id: string) => {
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const download = async () => {
const volunteer_ids = mode === 'pick' ? [...picked] : undefined;
try {
const blob = await generate.mutateAsync({ volunteer_ids });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'badges.pdf';
a.click();
URL.revokeObjectURL(url);
} catch {
// L'erreur est déjà exposée via `generate.isError` / `generate.error`.
}
};
const disabled = generate.isPending || (mode === 'pick' && picked.size === 0);
return (
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-4">
<Panel className="max-w-[560px]">
<PanelHeader>{t('badges.generate.title')}</PanelHeader>
<div className="flex flex-col gap-3 p-3">
<div className="flex gap-2">
<Chip active={mode === 'all'} onClick={() => setMode('all')}>
{t('badges.generate.all', { count: sorted.length })}
</Chip>
<Chip active={mode === 'pick'} onClick={() => setMode('pick')}>
{t('badges.generate.pick', { count: picked.size })}
</Chip>
</div>
{mode === 'pick' ? (
<div className="max-h-[320px] overflow-auto border-2 border-ink">
{sorted.map((v) => (
<label
key={v.id}
className="flex cursor-pointer items-center gap-[8px] border-b border-ink/15 px-[10px] py-[7px] font-mono text-[11px] hover:bg-yellow-soft"
>
<input
type="checkbox"
checked={picked.has(v.id)}
onChange={() => toggle(v.id)}
className="h-[14px] w-[14px] accent-ink"
/>
{v.first_name} {v.last_name}
</label>
))}
</div>
) : null}
<div className="flex items-center gap-3">
<Btn onClick={download} disabled={disabled}>
{generate.isPending ? t('badges.generating') : t('badges.generate.action')}
</Btn>
{generate.isError ? (
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
{errorMessage(generate.error)}
</span>
) : null}
</div>
</div>
</Panel>
</div>
);
}
function errorMessage(error: unknown): string {
if (error instanceof ApiError) return t(error.code);
return t('error.generic');
}