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}