|
|
@@ -42,24 +42,22 @@ export function ProfilePictureUpload({ currentAvatarUrl, userName, token, onUplo
|
|
|
reader.onload = (ev) => {
|
|
|
const src = ev.target?.result as string;
|
|
|
setPreview(src);
|
|
|
- setCropData(null);
|
|
|
+
|
|
|
+ // Load image immediately so it's ready for handleConfirm
|
|
|
+ const img = new Image();
|
|
|
+ img.onload = () => {
|
|
|
+ originalImageRef.current = img;
|
|
|
+ const minDim = Math.min(img.naturalWidth, img.naturalHeight);
|
|
|
+ const cropSize = Math.floor(minDim * 0.8);
|
|
|
+ const x = Math.floor((img.naturalWidth - cropSize) / 2);
|
|
|
+ const y = Math.floor((img.naturalHeight - cropSize) / 2);
|
|
|
+ setCropData({ x, y, size: cropSize });
|
|
|
+ };
|
|
|
+ img.src = src;
|
|
|
};
|
|
|
reader.readAsDataURL(file);
|
|
|
};
|
|
|
|
|
|
- // ── Load image for crop interaction ──────────────────────────────────────
|
|
|
- const handleImageLoad = (e: React.ReactElement<HTMLImageElement>) => {
|
|
|
- const img = e as unknown as HTMLImageElement;
|
|
|
- if (!img) return;
|
|
|
- originalImageRef.current = img;
|
|
|
- // Auto-center square crop at 80% of the smaller dimension
|
|
|
- const minDim = Math.min(img.naturalWidth, img.naturalHeight);
|
|
|
- const cropSize = Math.floor(minDim * 0.8);
|
|
|
- const x = Math.floor((img.naturalWidth - cropSize) / 2);
|
|
|
- const y = Math.floor((img.naturalHeight - cropSize) / 2);
|
|
|
- setCropData({ x, y, size: cropSize });
|
|
|
- };
|
|
|
-
|
|
|
// ── Drag to reposition crop ────────────────────────────────────────────
|
|
|
const dragRef = useRef<{ startX: number; startY: number; startCrop: CropRect } | null>(null);
|
|
|
|
|
|
@@ -91,7 +89,7 @@ export function ProfilePictureUpload({ currentAvatarUrl, userName, token, onUplo
|
|
|
|
|
|
// ── Process & upload ──────────────────────────────────────────────────────
|
|
|
const handleConfirm = async () => {
|
|
|
- if (!preview || !cropData) return;
|
|
|
+ if (!preview || !cropData || !originalImageRef.current) return;
|
|
|
setUploading(true);
|
|
|
setError(null);
|
|
|
try {
|
|
|
@@ -100,33 +98,28 @@ export function ProfilePictureUpload({ currentAvatarUrl, userName, token, onUplo
|
|
|
canvas.width = 400;
|
|
|
canvas.height = 400;
|
|
|
|
|
|
- const img = originalImageRef.current!;
|
|
|
ctx.drawImage(
|
|
|
- img,
|
|
|
+ originalImageRef.current,
|
|
|
cropData.x, cropData.y, cropData.size, cropData.size,
|
|
|
0, 0, 400, 400
|
|
|
);
|
|
|
|
|
|
// Compress to JPEG at quality that yields ~30KB
|
|
|
- const blob: Blob = await new Promise(resolve =>
|
|
|
- canvas.toBlob(b => resolve(b!), 'image/jpeg', 0.75)
|
|
|
+ const blob: Blob = await new Promise((resolve) =>
|
|
|
+ canvas.toBlob((b) => resolve(b!), 'image/jpeg', 0.75)
|
|
|
);
|
|
|
|
|
|
// If still > 35KB, reduce quality iteratively
|
|
|
let quality = 0.75;
|
|
|
- while (blob.size > 35 * 1024 && quality > 0.1) {
|
|
|
+ let currentBlob = blob;
|
|
|
+ while (currentBlob.size > 35 * 1024 && quality > 0.1) {
|
|
|
quality -= 0.1;
|
|
|
- const b: Blob = await new Promise(resolve =>
|
|
|
- canvas.toBlob(b => resolve(b!), 'image/jpeg', quality)
|
|
|
+ currentBlob = await new Promise((resolve) =>
|
|
|
+ canvas.toBlob((b) => resolve(b!), 'image/jpeg', quality)
|
|
|
);
|
|
|
- if (b.size < blob.size) {
|
|
|
- blob as unknown as { size: number }; // keep track
|
|
|
- const newBlob = b as unknown as Blob & { size: number };
|
|
|
- void newBlob; // just to use the variable
|
|
|
- }
|
|
|
}
|
|
|
|
|
|
- const file = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
|
|
|
+ const file = new File([currentBlob], 'avatar.jpg', { type: 'image/jpeg' });
|
|
|
const { user } = await usersApi.uploadAvatar(token, file);
|
|
|
onUploaded(user.avatarUrl ?? '');
|
|
|
setPreview(null);
|
|
|
@@ -168,7 +161,7 @@ export function ProfilePictureUpload({ currentAvatarUrl, userName, token, onUplo
|
|
|
className="w-16 h-16 rounded-full flex items-center justify-center text-lg font-bold"
|
|
|
style={{ background: 'rgba(99,102,241,0.15)', color: '#A5B4FC', border: '2px solid rgba(99,102,241,0.25)' }}
|
|
|
>
|
|
|
- {userName.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase()}
|
|
|
+ {userName.split(' ').map((n) => n[0]).slice(0, 2).join('').toUpperCase()}
|
|
|
</div>
|
|
|
)}
|
|
|
<button
|
|
|
@@ -201,31 +194,30 @@ export function ProfilePictureUpload({ currentAvatarUrl, userName, token, onUplo
|
|
|
alt="Crop preview"
|
|
|
className="w-full h-full object-contain"
|
|
|
draggable={false}
|
|
|
- onLoad={(e) => handleImageLoad(e as unknown as React.ReactElement<HTMLImageElement>)}
|
|
|
/>
|
|
|
|
|
|
{/* Dimmed overlay */}
|
|
|
- {cropData && (
|
|
|
+ {cropData && originalImageRef.current && (
|
|
|
<>
|
|
|
{/* Top */}
|
|
|
<div className="absolute inset-0" style={{
|
|
|
background: 'rgba(0,0,0,0.5)',
|
|
|
- clipPath: `polygon(0 0, 100% 0, 100% ${(cropData.y / (originalImageRef.current?.naturalHeight || 1)) * 100}%, 0 ${(cropData.y / (originalImageRef.current?.naturalHeight || 1)) * 100}%)`,
|
|
|
+ clipPath: `polygon(0 0, 100% 0, 100% ${(cropData.y / originalImageRef.current.naturalHeight) * 100}%, 0 ${(cropData.y / originalImageRef.current.naturalHeight) * 100}%)`,
|
|
|
}} />
|
|
|
{/* Bottom */}
|
|
|
<div className="absolute inset-0" style={{
|
|
|
background: 'rgba(0,0,0,0.5)',
|
|
|
- clipPath: `polygon(0 ${((cropData.y + cropData.size) / (originalImageRef.current?.naturalHeight || 1)) * 100}%, 100% ${((cropData.y + cropData.size) / (originalImageRef.current?.naturalHeight || 1)) * 100}%, 100% 100%, 0 100%)`,
|
|
|
+ clipPath: `polygon(0 ${((cropData.y + cropData.size) / originalImageRef.current.naturalHeight) * 100}%, 100% ${((cropData.y + cropData.size) / originalImageRef.current.naturalHeight) * 100}%, 100% 100%, 0 100%)`,
|
|
|
}} />
|
|
|
{/* Left */}
|
|
|
<div className="absolute inset-0" style={{
|
|
|
background: 'rgba(0,0,0,0.5)',
|
|
|
- clipPath: `polygon(0 0, ${(cropData.x / (originalImageRef.current?.naturalWidth || 1)) * 100}% 0, ${(cropData.x / (originalImageRef.current?.naturalWidth || 1)) * 100}% ${((cropData.y + cropData.size) / (originalImageRef.current?.naturalHeight || 1)) * 100}%, 0 ${((cropData.y + cropData.size) / (originalImageRef.current?.naturalHeight || 1)) * 100}%)`,
|
|
|
+ clipPath: `polygon(0 0, ${(cropData.x / originalImageRef.current.naturalWidth) * 100}% 0, ${(cropData.x / originalImageRef.current.naturalWidth) * 100}% ${((cropData.y + cropData.size) / originalImageRef.current.naturalHeight) * 100}%, 0 ${((cropData.y + cropData.size) / originalImageRef.current.naturalHeight) * 100}%)`,
|
|
|
}} />
|
|
|
{/* Right */}
|
|
|
<div className="absolute inset-0" style={{
|
|
|
background: 'rgba(0,0,0,0.5)',
|
|
|
- clipPath: `polygon(${(cropData.x + cropData.size) / (originalImageRef.current?.naturalWidth || 1) * 100}% 0, 100% 0, 100% 100%, ${(cropData.x + cropData.size) / (originalImageRef.current?.naturalWidth || 1) * 100}% 100%)`,
|
|
|
+ clipPath: `polygon(${(cropData.x + cropData.size) / originalImageRef.current.naturalWidth * 100}% 0, 100% 0, 100% 100%, ${(cropData.x + cropData.size) / originalImageRef.current.naturalWidth * 100}% 100%)`,
|
|
|
}} />
|
|
|
</>
|
|
|
)}
|
|
|
@@ -245,11 +237,19 @@ export function ProfilePictureUpload({ currentAvatarUrl, userName, token, onUplo
|
|
|
>
|
|
|
{/* Grid lines */}
|
|
|
<div className="absolute inset-0 grid grid-rows-3 grid-cols-3 pointer-events-none">
|
|
|
- {[0,1,2,3].map(i => (
|
|
|
- <div key={`h${i}`} className="border-b border-white/20" style={{ borderBottomWidth: i < 3 ? '1px' : 0, borderColor: 'rgba(255,255,255,0.2)' }} />
|
|
|
+ {[0, 1, 2, 3].map((i) => (
|
|
|
+ <div
|
|
|
+ key={`h${i}`}
|
|
|
+ className="border-b border-white/20"
|
|
|
+ style={{ borderBottomWidth: i < 3 ? '1px' : 0 }}
|
|
|
+ />
|
|
|
))}
|
|
|
- {[0,1,2,3].map(i => (
|
|
|
- <div key={`v${i}`} className="border-r border-white/20" style={{ borderRightWidth: i < 3 ? '1px' : 0, borderColor: 'rgba(255,255,255,0.2)' }} />
|
|
|
+ {[0, 1, 2, 3].map((i) => (
|
|
|
+ <div
|
|
|
+ key={`v${i}`}
|
|
|
+ className="border-r border-white/20"
|
|
|
+ style={{ borderRightWidth: i < 3 ? '1px' : 0 }}
|
|
|
+ />
|
|
|
))}
|
|
|
</div>
|
|
|
</div>
|