Responsive mobile / tablette / desktop

En-tête (onglets sur une seconde ligne en tablette, menu déroulant en
mobile), panneau d'affectation et fiche bénévole en tiroir par-dessus sous
lg: (plein écran en mobile), colonne des postes de la grille rétrécie,
liste des bénévoles réduite en mobile, écran « Postes & créneaux » empilé,
aperçu de l'éditeur de badges mis à l'échelle (glisser-déposer tactile),
ligne des types de paiement repliée, marges réduites — desktop inchangé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-27 18:05:16 +01:00
co-authored by Claude Opus 5.5
parent 0f904f5e3d
commit 85e841ae4a
13 changed files with 269 additions and 129 deletions
+43 -21
View File
@@ -23,7 +23,25 @@ import { cx } from '@/lib/cx';
// 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
const SCALE = 4.6; // px par mm à l'écran (au plus)
// Marges horizontales autour de l'aperçu (page + panneau + bordures), pour
// le faire tenir dans un écran étroit.
const PREVIEW_GUTTER_PX = 90;
/** Échelle d'édition : `SCALE`, réduite sur un écran trop étroit pour un
* badge à pleine taille (téléphone, tablette en portrait). Les coordonnées
* enregistrées restent en mm, seule la conversion px ↔ mm suit l'échelle. */
function useEditorScale(badgeWidthMm: number): number {
const [viewport, setViewport] = useState(() =>
typeof window === 'undefined' ? Number.POSITIVE_INFINITY : window.innerWidth,
);
useEffect(() => {
const onResize = () => setViewport(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return Math.min(SCALE, Math.max(1, (viewport - PREVIEW_GUTTER_PX) / badgeWidthMm));
}
type FieldState = BadgeFieldIn & { enabled: boolean };
@@ -126,11 +144,12 @@ function BadgeCustomizeTab() {
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 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));
@@ -157,7 +176,7 @@ function BadgeCustomizeTab() {
};
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 min-h-0 flex-1 flex-col gap-4 overflow-auto p-3 sm:p-4 lg:flex-row lg:items-start">
<div className="flex flex-none flex-col gap-4">
<Panel>
<PanelHeader>
@@ -182,8 +201,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: badgeW * SCALE,
height: badgeH * SCALE,
width: badgeW * scale,
height: badgeH * scale,
backgroundImage: frontUrl ? `url(${frontUrl})` : undefined,
}}
onPointerDown={() => setSelected(null)}
@@ -194,6 +213,7 @@ function BadgeCustomizeTab() {
field={fields[type]}
badgeW={badgeW}
badgeH={badgeH}
scale={scale}
selected={selected === type}
onSelect={() => setSelected(type)}
onChange={(patch) => updateField(type, patch)}
@@ -223,13 +243,13 @@ function BadgeCustomizeTab() {
/>
<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 }}
style={{ width: badgeW * scale, height: badgeH * scale, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
/>
</div>
</Panel>
</div>
<Panel className="min-w-[320px] flex-1">
<Panel className="min-w-0 flex-1 lg:min-w-[320px]">
<PanelHeader>{t('badges.fields')}</PanelHeader>
<div className="flex flex-col divide-y-2 divide-ink/10">
{FIELD_TYPES.map((type) => (
@@ -353,6 +373,7 @@ function FieldBox({
field,
badgeW,
badgeH,
scale,
selected,
onSelect,
onChange,
@@ -360,6 +381,7 @@ function FieldBox({
field: FieldState;
badgeW: number;
badgeH: number;
scale: number;
selected: boolean;
onSelect: () => void;
onChange: (patch: Partial<FieldState>) => void;
@@ -381,8 +403,8 @@ function FieldBox({
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;
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),
@@ -399,8 +421,8 @@ function FieldBox({
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;
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),
@@ -413,20 +435,20 @@ function FieldBox({
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]',
'absolute flex cursor-move touch-none 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,
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
fontSize: field.font_size * (scale / 2.83), // pt -> px approx. à l'échelle du badge
fontWeight: field.bold ? 800 : 400,
color: field.color,
width: '100%',
@@ -439,7 +461,7 @@ function FieldBox({
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"
className="absolute bottom-0 right-0 h-[14px] w-[14px] cursor-nwse-resize touch-none lg:h-[10px] lg:w-[10px] border-l-[1.5px] border-t-[1.5px] border-ink bg-yellow"
/>
</div>
);
@@ -570,7 +592,7 @@ function BadgeGenerateTab() {
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">
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-3 sm:p-4">
<Panel className="max-w-[560px]">
<PanelHeader>{t('badges.generate.title')}</PanelHeader>
<div className="flex flex-col gap-3 p-3">