Upload beautify
This commit is contained in:
@@ -1,7 +1,24 @@
|
||||
import './bootstrap';
|
||||
|
||||
import Alpine from 'alpinejs';
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import AvatarUploader from './components/profile/AvatarUploader';
|
||||
|
||||
window.Alpine = Alpine;
|
||||
|
||||
Alpine.start();
|
||||
|
||||
document.querySelectorAll('[data-avatar-uploader="true"]').forEach((element) => {
|
||||
const uploadUrl = element.getAttribute('data-upload-url') || '';
|
||||
const initialSrc = element.getAttribute('data-initial-src') || '';
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
||||
|
||||
createRoot(element).render(
|
||||
React.createElement(AvatarUploader, {
|
||||
uploadUrl,
|
||||
initialSrc,
|
||||
csrfToken,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
198
resources/js/components/profile/AvatarUploader.jsx
Normal file
198
resources/js/components/profile/AvatarUploader.jsx
Normal file
@@ -0,0 +1,198 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user