Files
SkinbaseNova/resources/js/components/profile/AvatarUploader.jsx
2026-02-17 17:14:43 +01:00

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>
);
}