import React, { useMemo, useRef, useState } from 'react'; import axios from 'axios'; const MAX_BYTES = 2 * 1024 * 1024; const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); function readImage(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(new Error('Failed to read avatar file.')); reader.onload = () => { const image = new Image(); image.onerror = () => reject(new Error('Invalid image data.')); image.onload = () => resolve(image); image.src = String(reader.result || ''); }; reader.readAsDataURL(file); }); } function canvasToBlob(canvas, mimeType, quality) { return new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (!blob) { reject(new Error('Failed to prepare avatar preview.')); return; } resolve(blob); }, mimeType, quality); }); } async function cropToSquareWebp(file) { const image = await readImage(file); const side = Math.min(image.width, image.height); const sourceX = Math.floor((image.width - side) / 2); const sourceY = Math.floor((image.height - side) / 2); const outputSize = Math.min(1024, side); const canvas = document.createElement('canvas'); canvas.width = outputSize; canvas.height = outputSize; const context = canvas.getContext('2d', { alpha: false }); if (!context) { throw new Error('Browser canvas is unavailable.'); } context.fillStyle = '#ffffff'; context.fillRect(0, 0, outputSize, outputSize); context.drawImage(image, sourceX, sourceY, side, side, 0, 0, outputSize, outputSize); const blob = await canvasToBlob(canvas, 'image/webp', 0.9); return new File([blob], 'avatar.webp', { type: 'image/webp' }); } export default function AvatarUploader({ uploadUrl, initialSrc, csrfToken }) { const inputRef = useRef(null); const [error, setError] = useState(''); const [isDragging, setIsDragging] = useState(false); const [isUploading, setIsUploading] = useState(false); const [progress, setProgress] = useState(0); const [avatarSrc, setAvatarSrc] = useState(initialSrc || ''); const helperText = useMemo(() => { if (isUploading) { return `Uploading ${progress}%...`; } return 'JPG, PNG, or WebP up to 2MB. Image is center-cropped to square.'; }, [isUploading, progress]); const validateClientFile = (file) => { if (!file) { throw new Error('No file selected.'); } if (!ALLOWED_TYPES.has(file.type)) { throw new Error('Only JPG, PNG, and WebP are allowed.'); } if (file.size > MAX_BYTES) { throw new Error('Avatar file must be 2MB or smaller.'); } }; const upload = async (file) => { validateClientFile(file); setError(''); setProgress(0); setIsUploading(true); try { const squaredFile = await cropToSquareWebp(file); const previewUrl = URL.createObjectURL(squaredFile); setAvatarSrc(previewUrl); const formData = new FormData(); formData.append('avatar', squaredFile); const response = await axios.post(uploadUrl, formData, { headers: { 'X-CSRF-TOKEN': csrfToken, Accept: 'application/json', 'Content-Type': 'multipart/form-data', }, onUploadProgress: (event) => { if (!event.total) { return; } const next = Math.round((event.loaded * 100) / event.total); setProgress(next); }, }); const data = response?.data || {}; if (typeof data.url === 'string' && data.url.length > 0) { setAvatarSrc(data.url); } } catch (uploadError) { const message = uploadError?.response?.data?.message || uploadError?.message || 'Avatar upload failed.'; setError(message); } finally { setIsUploading(false); } }; const onDrop = async (event) => { event.preventDefault(); setIsDragging(false); const file = event.dataTransfer?.files?.[0]; if (file) { await upload(file); } }; const onPick = async (event) => { const file = event.target.files?.[0]; if (file) { await upload(file); } if (event.target) { event.target.value = ''; } }; return (
Avatar
Drag & drop avatar here, or click to choose a file.
{helperText}
{error}
: null}