199 lines
6.7 KiB
JavaScript
199 lines
6.7 KiB
JavaScript
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 (
|
|
<div className="space-y-3">
|
|
<p className="text-sm font-medium text-gray-900">Avatar</p>
|
|
<div className="flex items-center gap-4">
|
|
<img
|
|
src={avatarSrc || '/img/default-avatar.webp'}
|
|
alt="Current avatar preview"
|
|
width="96"
|
|
height="96"
|
|
className="h-24 w-24 rounded-full border border-gray-300 object-cover"
|
|
loading="lazy"
|
|
decoding="async"
|
|
/>
|
|
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onDragOver={(event) => {
|
|
event.preventDefault();
|
|
setIsDragging(true);
|
|
}}
|
|
onDragLeave={() => setIsDragging(false)}
|
|
onDrop={onDrop}
|
|
onClick={() => inputRef.current?.click()}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
inputRef.current?.click();
|
|
}
|
|
}}
|
|
className={`w-full rounded-lg border-2 border-dashed p-4 text-sm transition ${isDragging ? 'border-indigo-500 bg-indigo-50' : 'border-gray-300 bg-white'}`}
|
|
aria-label="Upload avatar"
|
|
>
|
|
<p className="text-gray-700">Drag & drop avatar here, or click to choose a file.</p>
|
|
<p className="mt-1 text-xs text-gray-500">{helperText}</p>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp"
|
|
className="hidden"
|
|
onChange={onPick}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
|
</div>
|
|
);
|
|
}
|