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:
co-authored by
Claude Sonnet 5
parent
f6fbfaf82a
commit
48d3deec65
@@ -75,7 +75,11 @@ les tables sauf les tables d'association.
|
|||||||
`wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
|
`wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
|
||||||
Renseignables aussi via le formulaire public (§ 9) : `photo`/
|
Renseignables aussi via le formulaire public (§ 9) : `photo`/
|
||||||
`photo_content_type` (image, servie séparément par
|
`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
|
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
|
forcément encore en base au moment de l'inscription, donc pas de lien
|
||||||
direct vers un `VolunteerCompanionWish` ; la coordination la résout à la
|
direct vers un `VolunteerCompanionWish` ; la coordination la résout à la
|
||||||
|
|||||||
@@ -400,6 +400,7 @@
|
|||||||
"publicForm.artistWishes.none": "— Aucun —",
|
"publicForm.artistWishes.none": "— Aucun —",
|
||||||
"publicForm.field.available": "Je suis disponible et prêt·e à filer un coup de main !",
|
"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.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.captcha.disabled": "(Vérification anti-robot non configurée sur cette instance.)",
|
||||||
"publicForm.submitting": "Envoi…",
|
"publicForm.submitting": "Envoi…",
|
||||||
"publicForm.submit": "Envoyer mon inscription",
|
"publicForm.submit": "Envoyer mon inscription",
|
||||||
|
|||||||
@@ -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 {
|
function errorMessage(error: unknown): string {
|
||||||
if (error instanceof ApiError) return t(error.code);
|
if (error instanceof ApiError) return t(error.code);
|
||||||
return t('error.generic');
|
return t('error.generic');
|
||||||
@@ -85,6 +129,7 @@ export function PublicSignupPage() {
|
|||||||
const [positionIds, setPositionIds] = useState<string[]>([]);
|
const [positionIds, setPositionIds] = useState<string[]>([]);
|
||||||
const [artistByDay, setArtistByDay] = useState<Record<string, string>>({});
|
const [artistByDay, setArtistByDay] = useState<Record<string, string>>({});
|
||||||
const [photo, setPhoto] = useState<File | null>(null);
|
const [photo, setPhoto] = useState<File | null>(null);
|
||||||
|
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string | null>(null);
|
||||||
const [captchaToken, setCaptchaToken] = useState('');
|
const [captchaToken, setCaptchaToken] = useState('');
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [volunteerId, setVolunteerId] = useState<string | null>(null);
|
const [volunteerId, setVolunteerId] = useState<string | null>(null);
|
||||||
@@ -94,6 +139,25 @@ export function PublicSignupPage() {
|
|||||||
[config.data?.artists],
|
[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.isLoading) return <Loading />;
|
||||||
if (config.error) return <ErrorNote error={config.error} />;
|
if (config.error) return <ErrorNote error={config.error} />;
|
||||||
if (!config.data) return null;
|
if (!config.data) return null;
|
||||||
@@ -305,13 +369,23 @@ export function PublicSignupPage() {
|
|||||||
{fields.has('PHOTO') ? (
|
{fields.has('PHOTO') ? (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('form.field.photo')}</FieldLabel>
|
<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
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
capture="environment"
|
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]"
|
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>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user