diff --git a/src/app/profile/profile-form.tsx b/src/app/profile/profile-form.tsx index 4241c8f..3858075 100644 --- a/src/app/profile/profile-form.tsx +++ b/src/app/profile/profile-form.tsx @@ -64,14 +64,18 @@ export function ProfileForm({ let nextAvatarUrl = avatarUrl; if (avatarBlob) { - const path = `${userId}.webp`; + // Extension alignée sur le vrai type du blob (pas ".webp" en dur) : + // certains navigateurs (Safari) retombent sur du PNG si le WebP + // n'est pas supporté à l'encodage — voir cropImageToBlob. + const extension = avatarBlob.type === "image/jpeg" ? "jpg" : avatarBlob.type === "image/png" ? "png" : "webp"; + const path = `${userId}.${extension}`; const { error: uploadError } = await supabase.storage .from("avatars") - .upload(path, avatarBlob, { upsert: true, contentType: "image/webp" }); + .upload(path, avatarBlob, { upsert: true, contentType: avatarBlob.type || "image/webp" }); if (uploadError) { setLoading(false); - setError("Impossible d'envoyer la photo, réessaie."); + setError(`Impossible d'envoyer la photo (${uploadError.message}). Réessaie.`); return; } diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index da68ce2..0f44dcb 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -120,14 +120,23 @@ export default function SignupPage() { let avatarUrl: string | null = null; if (avatarBlob) { - const path = `${userId}.webp`; + // Extension alignée sur le vrai type du blob (pas ".webp" en dur) : + // certains navigateurs (Safari) retombent sur du PNG si le WebP + // n'est pas supporté à l'encodage — voir cropImageToBlob. + const extension = avatarBlob.type === "image/jpeg" ? "jpg" : avatarBlob.type === "image/png" ? "png" : "webp"; + const path = `${userId}.${extension}`; const { error: uploadError } = await supabase.storage .from("avatars") - .upload(path, avatarBlob, { upsert: true, contentType: "image/webp" }); + .upload(path, avatarBlob, { upsert: true, contentType: avatarBlob.type || "image/webp" }); if (!uploadError) { const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path); avatarUrl = publicUrlData.publicUrl; + } else { + // Ne bloque pas la finalisation du compte pour une photo optionnelle + // — juste de quoi diagnostiquer si quelqu'un signale l'absence + // d'avatar après inscription. + console.error("Échec de l'envoi de l'avatar à l'inscription :", uploadError); } } diff --git a/src/lib/image.ts b/src/lib/image.ts index 3759ebf..8dbc3c4 100644 --- a/src/lib/image.ts +++ b/src/lib/image.ts @@ -9,8 +9,28 @@ function loadImage(src: string): Promise { }); } +// Certains navigateurs (Safari, historiquement) ne savent pas encoder du +// WebP via canvas.toBlob/toDataURL : la spec impose alors un repli +// silencieux sur du PNG, un format quasi sans perte — beaucoup plus lourd +// qu'un JPEG pour une photo, au point de dépasser la limite de taille du +// bucket malgré la "compression" (vécu en prod avec une photo iPhone de +// 4 Mo). Détecter le support réel et demander explicitement du JPEG dans +// ce cas — plutôt que de laisser le navigateur retomber sur du PNG — +// garantit une vraie compression avec perte dans tous les cas. +let cachedMimeType: string | null = null; + +function preferredMimeType(): string { + if (cachedMimeType) return cachedMimeType; + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + cachedMimeType = canvas.toDataURL("image/webp").startsWith("data:image/webp") ? "image/webp" : "image/jpeg"; + return cachedMimeType; +} + // Découpe la zone sélectionnée, redimensionne (sans jamais agrandir) à -// maxSize et compresse en WebP côté client avant l'envoi vers le bucket. +// maxSize et compresse (WebP si le navigateur sait l'encoder, sinon JPEG) +// côté client avant l'envoi vers le bucket. export async function cropImageToBlob( imageSrc: string, crop: PixelCrop, @@ -33,7 +53,7 @@ export async function cropImageToBlob( return new Promise((resolve, reject) => { canvas.toBlob( (blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))), - "image/webp", + preferredMimeType(), quality, ); }); @@ -41,10 +61,10 @@ export async function cropImageToBlob( // Redimensionne (sans jamais agrandir) au format d'origine — contrairement // à cropImageToBlob qui force un carré pour les avatars — à maxDimension -// sur le plus grand côté, et compresse en WebP avant l'envoi vers le -// bucket. Le bucket a aussi sa propre limite de taille côté serveur -// (défense en profondeur) : cette compression n'est qu'un premier filtre, -// pas la seule garantie. +// sur le plus grand côté, et compresse (WebP si supporté, sinon JPEG — +// voir preferredMimeType) avant l'envoi vers le bucket. Le bucket a aussi +// sa propre limite de taille côté serveur (défense en profondeur) : cette +// compression n'est qu'un premier filtre, pas la seule garantie. export async function resizeImageToBlob(file: File, maxDimension = 1600, quality = 0.82): Promise { const src = URL.createObjectURL(file); try { @@ -65,7 +85,7 @@ export async function resizeImageToBlob(file: File, maxDimension = 1600, quality return new Promise((resolve, reject) => { canvas.toBlob( (blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))), - "image/webp", + preferredMimeType(), quality, ); });