added image-uploader component and refactored profile settings page
This commit is contained in:
parent
b5cd38b77a
commit
0c3ec98062
7 changed files with 301 additions and 21 deletions
|
@ -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} />;
|
||||
}
|
||||
|
|
230
components/ImageUploader.tsx
Normal file
230
components/ImageUploader.tsx
Normal 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">
|
||||
​
|
||||
</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>
|
||||
|
||||
)
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
18
yarn.lock
18
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue