From 48d3deec655b318c1201786aaa9a6e8627221bae Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Wed, 23 Sep 2026 20:26:01 +0200 Subject: [PATCH] =?UTF-8?q?Formulaire=20public=20:=20photo=20recadr=C3=A9e?= =?UTF-8?q?=20en=20carr=C3=A9,=20aper=C3=A7u=20agrandi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 6 +- frontend/src/i18n/locales/fr.json | 1 + frontend/src/pages/PublicSignupPage.tsx | 76 ++++++++++++++++++++++++- 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 521b099..df8795c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,11 @@ les tables sauf les tables d'association. `wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider). Renseignables aussi via le formulaire public (§ 9) : `photo`/ `photo_content_type` (image, servie séparément par - `GET .../volunteers/{id}/photo`, COORD/LEAD), `companion_wish_note` (« je + `GET .../volunteers/{id}/photo`, COORD/LEAD) — recadrée en carré côté + navigateur avant l'envoi (`PublicSignupPage.tsx:cropToSquareJpeg`, centré, + tronque le surplus, réencodée en JPEG ≤ 1024 px de côté), donc toujours + carrée en base sans recadrage serveur ; aperçu agrandi (180 px) dès que la + photo est choisie. `companion_wish_note` (« je voudrais être avec » en texte libre — la personne visée n'existe pas forcément encore en base au moment de l'inscription, donc pas de lien direct vers un `VolunteerCompanionWish` ; la coordination la résout à la diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index e1eb7b5..9b48520 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -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", diff --git a/frontend/src/pages/PublicSignupPage.tsx b/frontend/src/pages/PublicSignupPage.tsx index 7335d7f..2f5f4ae 100644 --- a/frontend/src/pages/PublicSignupPage.tsx +++ b/frontend/src/pages/PublicSignupPage.tsx @@ -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 { + 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([]); const [artistByDay, setArtistByDay] = useState>({}); const [photo, setPhoto] = useState(null); + const [photoPreviewUrl, setPhotoPreviewUrl] = useState(null); const [captchaToken, setCaptchaToken] = useState(''); const [submitError, setSubmitError] = useState(null); const [volunteerId, setVolunteerId] = useState(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 ; if (config.error) return ; if (!config.data) return null; @@ -305,13 +369,23 @@ export function PublicSignupPage() { {fields.has('PHOTO') ? ( ) : null}