|
|
@ -1,57 +1,76 @@ |
|
|
|
import { useEffect, useRef, useState } from 'preact/hooks'; |
|
|
|
import { useEffect, useRef, useState } from 'preact/hooks'; |
|
|
|
import { httpCall, httpPost } from '../../lib/http'; |
|
|
|
|
|
|
|
import Cookies from 'js-cookie'; |
|
|
|
import Cookies from 'js-cookie'; |
|
|
|
import { TOKEN_COOKIE_NAME } from '../../lib/jwt'; |
|
|
|
import { TOKEN_COOKIE_NAME } from '../../lib/jwt'; |
|
|
|
|
|
|
|
import { httpCall, httpPost } from '../../lib/http'; |
|
|
|
|
|
|
|
|
|
|
|
interface PreviewFile extends File { |
|
|
|
interface PreviewFile extends File { |
|
|
|
preview: string; |
|
|
|
preview: string; |
|
|
|
} |
|
|
|
} |
|
|
|
export default function UploadProfilePicture({ |
|
|
|
|
|
|
|
user, |
|
|
|
|
|
|
|
}: { |
|
|
|
|
|
|
|
user: { |
|
|
|
|
|
|
|
image: string; |
|
|
|
|
|
|
|
}; |
|
|
|
|
|
|
|
}) { |
|
|
|
|
|
|
|
const [file, setFile] = useState<PreviewFile | null>(null); |
|
|
|
|
|
|
|
const [error, setError] = useState(''); |
|
|
|
|
|
|
|
const [isLoading, setIsLoading] = useState(false); |
|
|
|
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const handleFileChange = async (e: Event) => { |
|
|
|
type UploadProfilePictureProps = { |
|
|
|
setError(''); |
|
|
|
avatarUrl: string; |
|
|
|
const file = (e.target as HTMLInputElement).files?.[0]; |
|
|
|
}; |
|
|
|
if (!file) return; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Check file size and dimension
|
|
|
|
function getDimensions(file: File) { |
|
|
|
const dimensions = await new Promise<{ |
|
|
|
return new Promise<{ |
|
|
|
width: number; |
|
|
|
width: number; |
|
|
|
height: number; |
|
|
|
height: number; |
|
|
|
}>((resolve) => { |
|
|
|
}>((resolve) => { |
|
|
|
const img = new Image(); |
|
|
|
const img = new Image(); |
|
|
|
|
|
|
|
|
|
|
|
img.onload = () => { |
|
|
|
img.onload = () => { |
|
|
|
resolve({ width: img.width, height: img.height }); |
|
|
|
resolve({ width: img.width, height: img.height }); |
|
|
|
}; |
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
img.onerror = () => { |
|
|
|
|
|
|
|
resolve({ width: 0, height: 0 }); |
|
|
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
img.src = URL.createObjectURL(file); |
|
|
|
img.src = URL.createObjectURL(file); |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function validateImage(file: File): Promise<string | null> { |
|
|
|
|
|
|
|
const dimensions = await getDimensions(file); |
|
|
|
|
|
|
|
|
|
|
|
// Image can't be larger than 3000x3000 pixels
|
|
|
|
|
|
|
|
if (dimensions.width > 3000 || dimensions.height > 3000) { |
|
|
|
if (dimensions.width > 3000 || dimensions.height > 3000) { |
|
|
|
setError('Image dimensions are too big. Maximum 3000x3000 pixels.'); |
|
|
|
return 'Image dimensions are too big. Maximum 3000x3000 pixels.'; |
|
|
|
return; |
|
|
|
} |
|
|
|
// Image can't be smaller than 100x100 pixels
|
|
|
|
|
|
|
|
} else if (dimensions.width < 100 || dimensions.height < 100) { |
|
|
|
if (dimensions.width < 100 || dimensions.height < 100) { |
|
|
|
setError('Image dimensions are too small. Minimum 100x100 pixels.'); |
|
|
|
return 'Image dimensions are too small. Minimum 100x100 pixels.'; |
|
|
|
return; |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Image can't be larger than 1MB
|
|
|
|
|
|
|
|
if (file.size > 1024 * 1024) { |
|
|
|
if (file.size > 1024 * 1024) { |
|
|
|
setError('Image size is too big. Maximum 1MB.'); |
|
|
|
return 'Image size is too big. Maximum 1MB.'; |
|
|
|
return; |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return null; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export default function UploadProfilePicture(props: UploadProfilePictureProps) { |
|
|
|
|
|
|
|
const { avatarUrl } = props; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const [file, setFile] = useState<PreviewFile | null>(null); |
|
|
|
|
|
|
|
const [error, setError] = useState(''); |
|
|
|
|
|
|
|
const [isLoading, setIsLoading] = useState(false); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const onImageChange = async (e: Event) => { |
|
|
|
setError(''); |
|
|
|
setError(''); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const file = (e.target as HTMLInputElement).files?.[0]; |
|
|
|
|
|
|
|
if (!file) { |
|
|
|
|
|
|
|
return; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const error = await validateImage(file); |
|
|
|
|
|
|
|
if (error) { |
|
|
|
|
|
|
|
setError(error); |
|
|
|
|
|
|
|
return; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
setFile( |
|
|
|
setFile( |
|
|
|
Object.assign(file, { |
|
|
|
Object.assign(file, { |
|
|
|
preview: URL.createObjectURL(file), |
|
|
|
preview: URL.createObjectURL(file), |
|
|
@ -63,11 +82,16 @@ export default function UploadProfilePicture({ |
|
|
|
e.preventDefault(); |
|
|
|
e.preventDefault(); |
|
|
|
setError(''); |
|
|
|
setError(''); |
|
|
|
setIsLoading(true); |
|
|
|
setIsLoading(true); |
|
|
|
if (!file) return; |
|
|
|
|
|
|
|
|
|
|
|
if (!file) { |
|
|
|
|
|
|
|
return; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const formData = new FormData(); |
|
|
|
const formData = new FormData(); |
|
|
|
formData.append('name', 'avatar'); |
|
|
|
formData.append('name', 'avatar'); |
|
|
|
formData.append('avatar', file); |
|
|
|
formData.append('avatar', file); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// FIXME: Use `httpCall` helper instead of fetch
|
|
|
|
const res = await fetch( |
|
|
|
const res = await fetch( |
|
|
|
`${import.meta.env.PUBLIC_API_URL}/v1-upload-profile-picture`, |
|
|
|
`${import.meta.env.PUBLIC_API_URL}/v1-upload-profile-picture`, |
|
|
|
{ |
|
|
|
{ |
|
|
@ -77,25 +101,29 @@ export default function UploadProfilePicture({ |
|
|
|
} |
|
|
|
} |
|
|
|
); |
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (res.ok) { |
|
|
|
|
|
|
|
window.location.reload(); |
|
|
|
|
|
|
|
return; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const data = await res.json(); |
|
|
|
const data = await res.json(); |
|
|
|
|
|
|
|
|
|
|
|
if (!res.ok) { |
|
|
|
setError(data?.message || 'Something went wrong'); |
|
|
|
setError(data.message || 'Something went wrong'); |
|
|
|
|
|
|
|
setIsLoading(false); |
|
|
|
setIsLoading(false); |
|
|
|
} |
|
|
|
|
|
|
|
// Logout user if token is invalid
|
|
|
|
// Logout user if token is invalid
|
|
|
|
if (data.status === 401) { |
|
|
|
if (data.status === 401) { |
|
|
|
Cookies.remove(TOKEN_COOKIE_NAME); |
|
|
|
Cookies.remove(TOKEN_COOKIE_NAME); |
|
|
|
window.location.reload(); |
|
|
|
window.location.reload(); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
window.location.reload(); |
|
|
|
|
|
|
|
}; |
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
useEffect(() => { |
|
|
|
// Necessary to revoke the preview URL when the component unmounts for avoiding memory leaks
|
|
|
|
// Necessary to revoke the preview URL when the component unmounts for avoiding memory leaks
|
|
|
|
return () => { |
|
|
|
return () => { |
|
|
|
if (file) URL.revokeObjectURL(file.preview); |
|
|
|
if (file) { |
|
|
|
|
|
|
|
URL.revokeObjectURL(file.preview); |
|
|
|
|
|
|
|
} |
|
|
|
}; |
|
|
|
}; |
|
|
|
}, [file]); |
|
|
|
}, [file]); |
|
|
|
|
|
|
|
|
|
|
@ -105,27 +133,20 @@ export default function UploadProfilePicture({ |
|
|
|
encType="multipart/form-data" |
|
|
|
encType="multipart/form-data" |
|
|
|
className="mt-8 flex flex-col gap-2" |
|
|
|
className="mt-8 flex flex-col gap-2" |
|
|
|
> |
|
|
|
> |
|
|
|
<label |
|
|
|
<label htmlFor="avatar" className="text-sm leading-none text-slate-500"> |
|
|
|
htmlFor="avatar" |
|
|
|
|
|
|
|
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]' |
|
|
|
|
|
|
|
> |
|
|
|
|
|
|
|
Profile Picture |
|
|
|
Profile Picture |
|
|
|
</label> |
|
|
|
</label> |
|
|
|
<div className="mt-2 flex items-center gap-2"> |
|
|
|
<div className="mb-2 mt-2 flex items-center gap-2"> |
|
|
|
<label |
|
|
|
<label |
|
|
|
htmlFor="avatar" |
|
|
|
htmlFor="avatar" |
|
|
|
title="Change profile picture" |
|
|
|
title="Change profile picture" |
|
|
|
className="relative cursor-pointer" |
|
|
|
className="relative cursor-pointer" |
|
|
|
> |
|
|
|
> |
|
|
|
<div className="relative block h-24 w-24 overflow-hidden rounded-full"> |
|
|
|
<div className="relative block h-24 w-24 items-center overflow-hidden rounded-full"> |
|
|
|
<img |
|
|
|
<img |
|
|
|
className="absolute inset-0 h-full w-full object-cover" |
|
|
|
className="absolute inset-0 h-full w-full bg-gray-100 object-cover text-sm leading-8 text-red-700" |
|
|
|
src={ |
|
|
|
src={file?.preview || avatarUrl} |
|
|
|
file?.preview || |
|
|
|
alt={file?.name ?? 'Error!'} |
|
|
|
user.image || |
|
|
|
|
|
|
|
'https://d22sqt16nof9dt.cloudfront.net/placeholder.png' |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
alt={file?.name ?? 'Profile picture'} |
|
|
|
|
|
|
|
loading="lazy" |
|
|
|
loading="lazy" |
|
|
|
decoding="async" |
|
|
|
decoding="async" |
|
|
|
onLoad={() => file && URL.revokeObjectURL(file.preview)} |
|
|
|
onLoad={() => file && URL.revokeObjectURL(file.preview)} |
|
|
@ -152,7 +173,7 @@ export default function UploadProfilePicture({ |
|
|
|
name="avatar" |
|
|
|
name="avatar" |
|
|
|
accept="image/png, image/jpeg, image/jpg, image/pjpeg" |
|
|
|
accept="image/png, image/jpeg, image/jpg, image/pjpeg" |
|
|
|
className="hidden" |
|
|
|
className="hidden" |
|
|
|
onChange={handleFileChange} |
|
|
|
onChange={onImageChange} |
|
|
|
/> |
|
|
|
/> |
|
|
|
|
|
|
|
|
|
|
|
{file && ( |
|
|
|
{file && ( |
|
|
@ -173,7 +194,7 @@ export default function UploadProfilePicture({ |
|
|
|
className="flex h-9 min-w-[96px] items-center justify-center rounded-md border border-gray-300 text-sm font-medium text-black disabled:cursor-not-allowed disabled:opacity-60" |
|
|
|
className="flex h-9 min-w-[96px] items-center justify-center rounded-md border border-gray-300 text-sm font-medium text-black disabled:cursor-not-allowed disabled:opacity-60" |
|
|
|
disabled={isLoading} |
|
|
|
disabled={isLoading} |
|
|
|
> |
|
|
|
> |
|
|
|
{isLoading ? 'Uploading' : 'Upload'} |
|
|
|
{isLoading ? 'Uploading..' : 'Upload'} |
|
|
|
</button> |
|
|
|
</button> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
)} |
|
|
|
)} |
|
|
|