added image-uploader component and refactored profile settings page

This commit is contained in:
Syed Ali Shahbaz 2021-08-12 10:14:11 +05:30
parent b5cd38b77a
commit 0c3ec98062
7 changed files with 301 additions and 21 deletions

View File

@ -1,13 +1,18 @@
import { useState } from "react";
import md5 from '../lib/md5';
export default function Avatar({ user, className = '', fallback }: {
export default function Avatar({ user, className = '', fallback, imageSrc = '' }: {
user: any;
className?: string;
fallback?: JSX.Element;
imageSrc?: string;
}) {
const [gravatarAvailable, setGravatarAvailable] = useState(true);
if (imageSrc) {
return <img src={imageSrc} alt="Avatar" className={className} />;
}
if (user.avatar) {
return <img src={user.avatar} alt="Avatar" className={className} />;
}

View File

@ -0,0 +1,230 @@
import Cropper from "react-easy-crop";
import { useState, useCallback, useRef } from "react";
export default function ImageUploader({target, id, buttonMsg, handleAvatarChange, imageRef}){
const imageFileRef = useRef<HTMLInputElement>();
const [imageDataUrl, setImageDataUrl] = useState<string>();
const [croppedAreaPixels, setCroppedAreaPixels] = useState();
const [rotation] = useState(1);
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [imageLoaded, setImageLoaded] = useState(false);
const [isImageShown, setIsImageShown] = useState(false);
const [shownImage, setShownImage] = useState<string>();
const [imageUploadModalOpen, setImageUploadModalOpen] = useState(false);
// TODO
// PUSH cropped image to the database in column = target
const openUploaderModal = () => {
imageRef ? (setIsImageShown(true), setShownImage(imageRef)) : setIsImageShown(false)
setImageUploadModalOpen(!imageUploadModalOpen)
}
const closeImageUploadModal = () => {
setImageUploadModalOpen(false);
};
async function ImageUploadHandler() {
console.log(imageFileRef.current.files[0]);
const img = await readFile(imageFileRef.current.files[0]);
console.log(img);
setImageDataUrl(img);
CropHandler();
}
const readFile = (file) => {
return new Promise((resolve) => {
const reader = new FileReader()
reader.addEventListener('load', () => resolve(reader.result), false)
reader.readAsDataURL(file)
})
}
const onCropComplete = useCallback((croppedArea, croppedAreaPixels) => {
setCroppedAreaPixels(croppedAreaPixels)
}, [])
const CropHandler = () => {
setCrop({ x: 0, y: 0 });
setZoom(1);
setImageLoaded(true);
}
const createImage = (url) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image()
image.addEventListener('load', () => resolve(image))
image.addEventListener('error', error => reject(error))
image.setAttribute('crossOrigin', 'anonymous') // needed to avoid cross-origin issues on CodeSandbox
image.src = url
})
function getRadianAngle(degreeValue) {
return (degreeValue * Math.PI) / 180
}
async function getCroppedImg(imageSrc, pixelCrop, rotation = 0) {
const image = await createImage(imageSrc)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const maxSize = Math.max(image.width, image.height)
const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2))
// set each dimensions to double largest dimension to allow for a safe area for the
// image to rotate in without being clipped by canvas context
canvas.width = safeArea
canvas.height = safeArea
// translate canvas context to a central location on image to allow rotating around the center.
ctx.translate(safeArea / 2, safeArea / 2)
ctx.rotate(getRadianAngle(rotation))
ctx.translate(-safeArea / 2, -safeArea / 2)
// draw rotated image and store data.
ctx.drawImage(
image,
safeArea / 2 - image.width * 0.5,
safeArea / 2 - image.height * 0.5
)
const data = ctx.getImageData(0, 0, safeArea, safeArea)
// set canvas width to final desired crop size - this will clear existing context
canvas.width = pixelCrop.width
canvas.height = pixelCrop.height
// paste generated rotate image with correct offsets for x,y crop values.
ctx.putImageData(
data,
Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x),
Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
)
// As Base64 string
return canvas.toDataURL('image/jpeg');
// As a blob
// return new Promise(resolve => {
// canvas.toBlob(file => {
// resolve(URL.createObjectURL(file))
// }, 'image/jpeg')
// })
}
const showCroppedImage = useCallback(async () => {
try {
const croppedImage = await getCroppedImg(
imageDataUrl,
croppedAreaPixels,
rotation
)
setIsImageShown(true)
setShownImage(croppedImage)
setImageLoaded(false)
handleAvatarChange(croppedImage)
closeImageUploadModal()
} catch (e) {
console.error(e)
}
}, [croppedAreaPixels, rotation])
return (
<div className="flex justify-center items-center">
<button
type="button"
className="ml-4 cursor-pointer inline-flex items-center px-4 py-1 border border-gray-300 shadow-sm text-xs font-medium rounded-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500;"
onClick={openUploaderModal}
>
{buttonMsg}
</button>
{
imageUploadModalOpen &&
<div
className="fixed z-10 inset-0 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-sm px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-md sm:w-full sm:p-6">
<div className="sm:flex sm:items-start mb-4">
<div className="mt-3 text-center sm:mt-0 sm:text-left">
<h3 className="text-lg leading-6 font-bold text-gray-900" id="modal-title">
Upload an avatar
</h3>
</div>
</div>
<div className="mb-4">
<div className="cropper mt-6 flex flex-col justify-center items-center p-8 bg-gray-50">
{!imageLoaded &&
<div className="flex justify-start items-center bg-gray-500 max-h-20 h-20 w-20 rounded-full">
{!isImageShown &&
<p className="sm:text-xs text-sm text-white w-full text-center">No {target}</p>
}
{isImageShown &&
<img
className="h-20 w-20 rounded-full"
src={shownImage}
alt={target}
/>
}
</div>
}
{imageLoaded &&
<div className="crop-container max-h-40 h-40 w-40 rounded-full">
<div className="relative h-40 w-40 rounded-full">
<Cropper
image={imageDataUrl}
crop={crop}
zoom={zoom}
aspect={1 / 1}
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
/>
</div>
</div>
}
<label htmlFor={id} className="mt-4 cursor-pointer inline-flex items-center px-4 py-1 border border-gray-300 shadow-sm text-xs font-medium rounded-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500;">Choose a file...</label>
<input
onChange={ImageUploadHandler}
ref={imageFileRef}
type="file"
id={id}
name={id}
placeholder="Upload image"
className="mt-4 cursor-pointer opacity-0 absolute"
/>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="button" className="btn btn-primary" onClick={showCroppedImage}>
Save
</button>
<button
onClick={closeImageUploadModal}
type="button"
className="btn btn-white mr-2">
Cancel
</button>
</div>
</div>
</div>
</div>
}
</div>
)
}

View File

@ -41,6 +41,7 @@
"react": "17.0.1",
"react-dates": "^21.8.0",
"react-dom": "17.0.1",
"react-easy-crop": "^3.5.2",
"react-multi-email": "^0.5.3",
"react-phone-number-input": "^3.1.21",
"react-select": "^4.3.0",

View File

@ -16,7 +16,6 @@ import Button from "../../components/ui/Button";
import { EventTypeCustomInputType } from "../../lib/eventTypeInput";
import Theme from "@components/Theme";
import { ReactMultiEmail } from "react-multi-email";
import "react-multi-email/style.css";
dayjs.extend(utc);
dayjs.extend(timezone);

View File

@ -11,6 +11,7 @@ import Select from "react-select";
import TimezoneSelect from "react-timezone-select";
import { UsernameInput } from "../../components/ui/UsernameInput";
import ErrorAlert from "../../components/ui/alerts/Error";
import ImageUploader from "../../components/ImageUploader";
const themeOptions = [
{ value: "light", label: "Light" },
@ -27,6 +28,7 @@ export default function Settings(props) {
const [selectedTheme, setSelectedTheme] = useState({ value: props.user.theme });
const [selectedTimeZone, setSelectedTimeZone] = useState({ value: props.user.timeZone });
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState({ value: props.user.weekStart });
const [imageSrc, setImageSrc] = useState<string>('');
const [hasErrors, setHasErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
@ -42,6 +44,16 @@ export default function Settings(props) {
setSuccessModalOpen(false);
};
const handleAvatarChange = (newAvatar) => {
avatarRef.current.value = newAvatar;
var nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set;
nativeInputValueSetter.call(avatarRef.current, newAvatar);
var ev2 = new Event('input', { bubbles: true});
avatarRef.current.dispatchEvent(ev2);
updateProfileHandler(ev2);
setImageSrc(newAvatar);
}
const handleError = async (resp) => {
if (!resp.ok) {
const error = await resp.json();
@ -138,6 +150,33 @@ export default function Settings(props) {
className="shadow-sm focus:ring-neutral-500 focus:border-neutral-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-sm"></textarea>
</div>
</div>
<div>
<div className="mt-1 flex">
<Avatar
user={props.user}
className="relative rounded-full w-10 h-10"
fallback={<div className="relative bg-neutral-900 rounded-full w-10 h-10"></div>}
imageSrc={imageSrc}
/>
<input
ref={avatarRef}
type="hidden"
name="avatar"
id="avatar"
placeholder="URL"
className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
defaultValue={props.user.avatar}
/>
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg="Change avatar"
handleAvatarChange={handleAvatarChange}
imageRef={imageSrc ? imageSrc : props.user.avatar}
/>
</div>
<hr className="mt-6" />
</div>
<div>
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
Timezone
@ -225,7 +264,7 @@ export default function Settings(props) {
</div>
</div>
<div className="mt-6 flex-grow lg:mt-0 lg:ml-6 lg:flex-grow-0 lg:flex-shrink-0">
{/*<div className="mt-6 flex-grow lg:mt-0 lg:ml-6 lg:flex-grow-0 lg:flex-shrink-0">
<p className="mb-2 text-sm font-medium text-gray-700" aria-hidden="true">
Photo
</p>
@ -236,15 +275,6 @@ export default function Settings(props) {
aria-hidden="true">
<Avatar user={props.user} className="rounded-full h-full w-full" />
</div>
{/* <div className="ml-5 rounded-sm shadow-sm">
<div className="group relative border border-gray-300 rounded-sm py-2 px-3 flex items-center justify-center hover:bg-gray-50 focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-neutral-500">
<label htmlFor="user_photo" className="relative text-sm leading-4 font-medium text-gray-700 pointer-events-none">
<span>Change</span>
<span className="sr-only"> user photo</span>
</label>
<input id="user_photo" name="user_photo" type="file" className="absolute w-full h-full opacity-0 cursor-pointer border-gray-300 rounded-sm" />
</div>
</div> */}
</div>
</div>
@ -254,11 +284,6 @@ export default function Settings(props) {
className="relative rounded-full w-40 h-40"
fallback={<div className="relative bg-neutral-900 rounded-full w-40 h-40"></div>}
/>
{/* <label htmlFor="user-photo" className="absolute inset-0 w-full h-full bg-black bg-opacity-75 flex items-center justify-center text-sm font-medium text-white opacity-0 hover:opacity-100 focus-within:opacity-100">
<span>Change</span>
<span className="sr-only"> user photo</span>
<input type="file" id="user-photo" name="user-photo" className="absolute inset-0 w-full h-full opacity-0 cursor-pointer border-gray-300 rounded-sm" />
</label> */}
</div>
<div className="mt-4">
<label htmlFor="avatar" className="block text-sm font-medium text-gray-700">
@ -274,7 +299,7 @@ export default function Settings(props) {
defaultValue={props.user.avatar}
/>
</div>
</div>
</div>*/}
</div>
<hr className="mt-8" />
<div className="py-4 flex justify-end">

View File

@ -118,7 +118,7 @@
align-content: flex-start;
padding-top: 0.1rem !important;
padding-bottom: 0.1rem !important;
padding-left: 0.75rem !important;
/* padding-left: 0.75rem !important; */
@apply dark:border-black border-white dark:bg-black bg-white;
}
@ -127,11 +127,13 @@
}
.react-multi-email.focused{
margin: -1px;
border: 2px solid #000 !important;
@apply dark:bg-black
}
.react-multi-email.focused > [type='text']{
border: 2px solid #000 !important;
}
.react-multi-email > [type='text']:focus{
box-shadow: none !important;
}

View File

@ -4912,6 +4912,11 @@ normalize-range@^0.1.2:
resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=
normalize-wheel@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/normalize-wheel/-/normalize-wheel-1.0.1.tgz#aec886affdb045070d856447df62ecf86146ec45"
integrity sha1-rsiGr/2wRQcNhWRH32Ls+GFG7EU=
npm-run-path@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
@ -5538,6 +5543,14 @@ react-dom@17.0.1:
object-assign "^4.1.1"
scheduler "^0.20.1"
react-easy-crop@^3.5.2:
version "3.5.2"
resolved "https://registry.yarnpkg.com/react-easy-crop/-/react-easy-crop-3.5.2.tgz#1fc65249e82db407c8c875159589a8029a9b7a06"
integrity sha512-cwSGO/wk42XDpEyrdAcnQ6OJetVDZZO2ry1i19+kSGZQ750aN06RU9y9z95B5QI6sW3SnaWQRKv5r5GSqVV//g==
dependencies:
normalize-wheel "^1.0.1"
tslib "2.0.1"
react-input-autosize@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-3.0.0.tgz#6b5898c790d4478d69420b55441fcc31d5c50a85"
@ -6487,6 +6500,11 @@ ts-pnp@^1.1.6:
resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92"
integrity sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==
tslib@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.1.tgz#410eb0d113e5b6356490eec749603725b021b43e"
integrity sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ==
tslib@^1.0.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"