Refactor avatar implementation

pull/3907/head
Kamran Ahmed 2 years ago
parent 4d3ebb0ac6
commit 7441f1a203
  1. 1
      .env.example
  2. BIN
      public/images/default-avatar.png
  3. 121
      src/components/Profile/UploadProfilePicture.tsx
  4. 20
      src/components/Setting/UpdateProfileForm.tsx
  5. 1
      src/env.d.ts

@ -1 +1,2 @@
PUBLIC_API_URL=http://api.roadmap.sh PUBLIC_API_URL=http://api.roadmap.sh
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

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

@ -1,13 +1,11 @@
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import { httpGet, httpPost } from '../../lib/http'; import { httpGet, httpPost } from '../../lib/http';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
import { pageLoadingMessage } from '../../stores/page'; import { pageLoadingMessage } from '../../stores/page';
import UploadProfilePicture from '../Profile/UploadProfilePicture'; import UploadProfilePicture from '../Profile/UploadProfilePicture';
export function UpdateProfileForm() { export function UpdateProfileForm() {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [image, setImage] = useState(''); const [avatar, setAvatar] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [github, setGithub] = useState(''); const [github, setGithub] = useState('');
const [twitter, setTwitter] = useState(''); const [twitter, setTwitter] = useState('');
@ -61,7 +59,7 @@ export function UpdateProfileForm() {
return; return;
} }
const { name, email, links, image } = response; const { name, email, links, avatar } = response;
setName(name); setName(name);
setEmail(email); setEmail(email);
@ -69,7 +67,7 @@ export function UpdateProfileForm() {
setLinkedin(links?.linkedin || ''); setLinkedin(links?.linkedin || '');
setTwitter(links?.twitter || ''); setTwitter(links?.twitter || '');
setWebsite(links?.website || ''); setWebsite(links?.website || '');
setImage(image || ''); setAvatar(avatar || '');
setIsLoading(false); setIsLoading(false);
}; };
@ -86,9 +84,15 @@ export function UpdateProfileForm() {
<div> <div>
<h2 className="text-3xl font-bold sm:text-4xl">Profile</h2> <h2 className="text-3xl font-bold sm:text-4xl">Profile</h2>
<p className="mt-2">Update your profile details below.</p> <p className="mt-2">Update your profile details below.</p>
<UploadProfilePicture user={{ image }} /> <UploadProfilePicture
<form className="mt-4 space-y-4"> avatarUrl={
<div className="flex w-full flex-col" onSubmit={handleSubmit}> avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
: '/images/default-avatar.png'
}
/>
<form className="mt-4 space-y-4" onSubmit={handleSubmit}>
<div className="flex w-full flex-col">
<label <label
for="name" for="name"
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]' className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'

1
src/env.d.ts vendored

@ -3,6 +3,7 @@
interface ImportMetaEnv { interface ImportMetaEnv {
GITHUB_SHA: string; GITHUB_SHA: string;
PUBLIC_API_URL: string; PUBLIC_API_URL: string;
PUBLIC_AVATAR_BASE_URL: string;
} }
interface ImportMeta { interface ImportMeta {

Loading…
Cancel
Save