mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-03 14:22:41 +02:00
Refactor avatar implementation
This commit is contained in:
@@ -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
|
||||||
|
BIN
public/images/default-avatar.png
Normal file
BIN
public/images/default-avatar.png
Normal file
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,
|
type UploadProfilePictureProps = {
|
||||||
}: {
|
avatarUrl: string;
|
||||||
user: {
|
};
|
||||||
image: string;
|
|
||||||
};
|
function getDimensions(file: File) {
|
||||||
}) {
|
return new Promise<{
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}>((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
resolve({ width: img.width, height: img.height });
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
resolve({ width: 0, height: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateImage(file: File): Promise<string | null> {
|
||||||
|
const dimensions = await getDimensions(file);
|
||||||
|
|
||||||
|
if (dimensions.width > 3000 || dimensions.height > 3000) {
|
||||||
|
return 'Image dimensions are too big. Maximum 3000x3000 pixels.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dimensions.width < 100 || dimensions.height < 100) {
|
||||||
|
return 'Image dimensions are too small. Minimum 100x100 pixels.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 1024 * 1024) {
|
||||||
|
return 'Image size is too big. Maximum 1MB.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UploadProfilePicture(props: UploadProfilePictureProps) {
|
||||||
|
const { avatarUrl } = props;
|
||||||
|
|
||||||
const [file, setFile] = useState<PreviewFile | null>(null);
|
const [file, setFile] = useState<PreviewFile | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleFileChange = async (e: Event) => {
|
const onImageChange = async (e: Event) => {
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
if (!file) return;
|
if (!file) {
|
||||||
|
|
||||||
// Check file size and dimension
|
|
||||||
const dimensions = await new Promise<{
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}>((resolve) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
resolve({ width: img.width, height: img.height });
|
|
||||||
};
|
|
||||||
img.src = URL.createObjectURL(file);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Image can't be larger than 3000x3000 pixels
|
|
||||||
if (dimensions.width > 3000 || dimensions.height > 3000) {
|
|
||||||
setError('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) {
|
|
||||||
setError('Image dimensions are too small. Minimum 100x100 pixels.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image can't be larger than 1MB
|
const error = await validateImage(file);
|
||||||
if (file.size > 1024 * 1024) {
|
if (error) {
|
||||||
setError('Image size is too big. Maximum 1MB.');
|
setError(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError('');
|
|
||||||
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
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 {
|
||||||
|
Reference in New Issue
Block a user