Formulaire public : photo recadrée en carré, aperçu agrandi

La photo ajoutée (fichier ou prise depuis l'appareil) est recadrée en carré
côté navigateur avant l'envoi (centré, tronque le surplus, réencodée en
JPEG ≤ 1024 px de côté) — elle est donc toujours carrée en base, sans
recadrage serveur, ce qui profite aussi aux vignettes des badges et des
feuilles de poste. Un aperçu agrandi (180 px) s'affiche dès que la photo
est choisie.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-23 20:26:01 +02:00
co-authored by Claude Sonnet 5
parent f6fbfaf82a
commit 48d3deec65
3 changed files with 81 additions and 2 deletions
+1
View File
@@ -400,6 +400,7 @@
"publicForm.artistWishes.none": "— Aucun —",
"publicForm.field.available": "Je suis disponible et prêt·e à filer un coup de main !",
"publicForm.field.willingToPayFee": "Ça ne me dérange pas de participer à la cotisation de l'association",
"publicForm.photo.squareHint": "Recadrée automatiquement en carré.",
"publicForm.captcha.disabled": "(Vérification anti-robot non configurée sur cette instance.)",
"publicForm.submitting": "Envoi…",
"publicForm.submit": "Envoyer mon inscription",
+75 -1
View File
@@ -55,6 +55,50 @@ function HCaptchaWidget({ onToken }: { onToken: (token: string) => void }) {
);
}
// Taille max du côté exporté (px) : assez grand pour rester net sur la
// vignette imprimée (badges, feuilles de poste), sans gonfler l'upload avec
// la pleine résolution d'une photo de smartphone.
const PHOTO_EXPORT_SIDE_PX = 1024;
/** Recadre au carré (centré, tronque le surplus) et réencode en JPEG — le
* format brut d'un appareil photo (HEIC, résolution énorme, ratio libre)
* n'est ni carré ni forcément accepté par le serveur (`image/png`/`image/jpeg`
* seulement, voir `app/api/forms.py`). Le carré obtenu est réutilisé tel
* quel par les badges et feuilles de poste, sans recadrage côté serveur. */
async function cropToSquareJpeg(file: File): Promise<File> {
const bitmap = await createImageBitmap(file);
try {
const side = Math.min(bitmap.width, bitmap.height);
const exportSide = Math.min(side, PHOTO_EXPORT_SIDE_PX);
const canvas = document.createElement('canvas');
canvas.width = exportSide;
canvas.height = exportSide;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas 2d indisponible');
ctx.drawImage(
bitmap,
(bitmap.width - side) / 2,
(bitmap.height - side) / 2,
side,
side,
0,
0,
exportSide,
exportSide,
);
const blob: Blob = await new Promise((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('recadrage impossible'))),
'image/jpeg',
0.9,
);
});
return new File([blob], 'photo.jpg', { type: 'image/jpeg' });
} finally {
bitmap.close();
}
}
function errorMessage(error: unknown): string {
if (error instanceof ApiError) return t(error.code);
return t('error.generic');
@@ -85,6 +129,7 @@ export function PublicSignupPage() {
const [positionIds, setPositionIds] = useState<string[]>([]);
const [artistByDay, setArtistByDay] = useState<Record<string, string>>({});
const [photo, setPhoto] = useState<File | null>(null);
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string | null>(null);
const [captchaToken, setCaptchaToken] = useState('');
const [submitError, setSubmitError] = useState<string | null>(null);
const [volunteerId, setVolunteerId] = useState<string | null>(null);
@@ -94,6 +139,25 @@ export function PublicSignupPage() {
[config.data?.artists],
);
// Révoque l'URL de l'aperçu précédent à chaque changement, et à la
// destruction du composant.
useEffect(() => {
return () => {
if (photoPreviewUrl) URL.revokeObjectURL(photoPreviewUrl);
};
}, [photoPreviewUrl]);
const handlePhotoChange = async (file: File | null) => {
if (!file) {
setPhoto(null);
setPhotoPreviewUrl(null);
return;
}
const squared = await cropToSquareJpeg(file).catch(() => file);
setPhoto(squared);
setPhotoPreviewUrl(URL.createObjectURL(squared));
};
if (config.isLoading) return <Loading />;
if (config.error) return <ErrorNote error={config.error} />;
if (!config.data) return null;
@@ -305,13 +369,23 @@ export function PublicSignupPage() {
{fields.has('PHOTO') ? (
<label className="block">
<FieldLabel>{t('form.field.photo')}</FieldLabel>
{photoPreviewUrl ? (
<img
src={photoPreviewUrl}
alt=""
className="mb-[8px] h-[180px] w-[180px] border-2 border-ink object-cover shadow-brutal-sm"
/>
) : null}
<input
type="file"
accept="image/*"
capture="environment"
onChange={(e) => setPhoto(e.target.files?.[0] ?? null)}
onChange={(e) => handlePhotoChange(e.target.files?.[0] ?? null)}
className="w-full border-2 border-ink bg-cream p-[8px] font-mono text-[11px]"
/>
<p className="mt-[4px] font-mono text-[9.5px] opacity-50">
{t('publicForm.photo.squareHint')}
</p>
</label>
) : null}