diff options
| author | Eugene Sokolov <eug-vs@keemail.me> | 2020-08-22 14:56:07 +0300 | 
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-08-22 14:56:07 +0300 | 
| commit | 89f038c7a0ccf6de94516cba8499a0bc69f8dae1 (patch) | |
| tree | 5e6dbd7ae5a9d0ce1b79921b2b44986bb13f1874 /src | |
| parent | a42667af463b8c33a38b935b96d39582b543790b (diff) | |
| parent | 93319d38e904535ce33a7868b3c1e0a2a4f33d65 (diff) | |
| download | which-ui-89f038c7a0ccf6de94516cba8499a0bc69f8dae1.tar.gz | |
Merge pull request #93 from which-ecosystem/avatar-uploads
Avatar uploads
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/AttachLink/AttachLink.tsx | 2 | ||||
| -rw-r--r-- | src/components/FileUpload/FileUpload.tsx | 36 | ||||
| -rw-r--r-- | src/containers/Feed/PollSubmission.tsx | 102 | ||||
| -rw-r--r-- | src/containers/Feed/PollSubmissionImage.tsx | 95 | ||||
| -rw-r--r-- | src/containers/PollCreation/ImageInput.tsx | 10 | ||||
| -rw-r--r-- | src/containers/PollCreation/PollCreation.tsx | 25 | ||||
| -rw-r--r-- | src/containers/PollCreation/useS3Preupload.ts | 29 | ||||
| -rw-r--r-- | src/containers/Profile/ProfileInfo.tsx | 77 | ||||
| -rw-r--r-- | src/hooks/useS3Preupload.tsx | 85 | ||||
| -rw-r--r-- | src/utils/getLocalFileUrl.ts | 15 | ||||
| -rw-r--r-- | src/utils/uploadFileToS3.ts | 53 | 
11 files changed, 183 insertions, 346 deletions
diff --git a/src/components/AttachLink/AttachLink.tsx b/src/components/AttachLink/AttachLink.tsx index e73f5c1..742ba65 100644 --- a/src/components/AttachLink/AttachLink.tsx +++ b/src/components/AttachLink/AttachLink.tsx @@ -25,7 +25,7 @@ const AttachLink: React.FC<PropTypes> = ({ callback, children }) => {      </Button>    ); -  const child = children && React.Children.toArray(children)[0]; +  const child = children && React.Children.only(children);    return (      <> diff --git a/src/components/FileUpload/FileUpload.tsx b/src/components/FileUpload/FileUpload.tsx index 67d280d..961fa9a 100644 --- a/src/components/FileUpload/FileUpload.tsx +++ b/src/components/FileUpload/FileUpload.tsx @@ -1,28 +1,29 @@ -import React, { useEffect } from 'react'; -import { useFilePicker, utils } from 'react-sage'; +import React, { useRef } from 'react';  import Button from '@material-ui/core/Button';  import CloudUpload from '@material-ui/icons/CloudUpload';  interface PropTypes { -  callback: (fileUrl: string, file: File) => void; +  callback: (file: File) => void;  }  const FileUpload: React.FC<PropTypes> = ({ callback, children }) => { -  const { files, onClick, HiddenFileInput } = useFilePicker(); +  const inputRef = useRef<HTMLInputElement>(null); -  useEffect(() => { -    if (files?.length) { -      const file = files[0]; -      utils.loadFile(file).then(url => callback(url, file)); -    } -  }, [files, callback]); +  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { +    const files = event.target?.files; +    if (files?.length) callback(files[0]); +  }; -  const child = children && React.Children.toArray(children)[0]; +  const handleClick = () => { +    if (inputRef?.current) inputRef.current.click(); +  }; + +  const child = children && React.Children.only(children);    const defaultButton = (      <Button -      onClick={onClick} +      onClick={handleClick}        variant="contained"        color="primary"        size="large" @@ -34,10 +35,17 @@ const FileUpload: React.FC<PropTypes> = ({ callback, children }) => {    return (      <> -      <HiddenFileInput accept=".jpg, .jpeg, .png, .gif" multiple={false} /> +      <input +        type="file" +        ref={inputRef} +        multiple={false} +        accept=".jpg, .jpeg, .png, .gif" +        style={{ display: 'none' }} +        onChange={handleChange} +      />        {          React.isValidElement(child) -          ? React.cloneElement(child, { onClick }) +          ? React.cloneElement(child, { onClick: handleClick })            : defaultButton        }      </> diff --git a/src/containers/Feed/PollSubmission.tsx b/src/containers/Feed/PollSubmission.tsx deleted file mode 100644 index faaf2bd..0000000 --- a/src/containers/Feed/PollSubmission.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React, { useState } from 'react'; -import { makeStyles } from '@material-ui/core/styles'; -import Collapse from '@material-ui/core/Collapse'; -import { -  Button, -  Card, -  ClickAwayListener, -  Divider -} from '@material-ui/core'; -import { Poll } from 'which-types'; -import { useSnackbar } from 'notistack'; -import axios from 'axios'; - -import PollSubmissionImage from './PollSubmissionImage'; -import UserStrip from '../../components/UserStrip/UserStrip'; -import { get, post } from '../../requests'; -import { useAuth } from '../../hooks/useAuth'; - -interface PropTypes{ -  addPoll: (poll: Poll) => void; -} - -const useStyles = makeStyles(theme => ({ -  root: { -    marginBottom: theme.spacing(4) -  }, -  images: { -    height: theme.spacing(50), -    display: 'flex' -  } -})); - -const PollSubmission: React.FC<PropTypes> = ({ addPoll }) => { -  const classes = useStyles(); -  const [expanded, setExpanded] = useState(false); -  const [left, setLeft] = useState<File>(); -  const [right, setRight] = useState<File>(); -  const { enqueueSnackbar } = useSnackbar(); -  const { user } = useAuth(); - -  const readyToSubmit = left && right; - -  const handleClickAway = () => { -    setExpanded(false); -  }; - -  const uploadImage = (file?: File) => { -    const headers = { 'Content-Type': 'image/png' }; -    return get('/files') -      .then(response => response.data) -      .then(uploadUrl => axios.put(uploadUrl, file, { headers })) -      .then(response => { -        const { config: { url } } = response; -        return url && url.slice(0, url.indexOf('.png') + 4); -      }); -  }; - -  const handleClick = async () => { -    if (expanded && readyToSubmit) { -      const [leftUrl, rightUrl] = await Promise.all([uploadImage(left), uploadImage(right)]); - -      const contents = { -        left: { url: leftUrl }, -        right: { url: rightUrl } -      }; - -      post('/polls/', { contents }).then(response => { -        addPoll(response.data); -        enqueueSnackbar('Your poll has been successfully created!', { -          variant: 'success' -        }); -      }); -    } -    setExpanded(!expanded); -  }; - -  return ( -    <ClickAwayListener onClickAway={handleClickAway}> -      <Card className={classes.root}> -        <Collapse in={expanded} timeout="auto" unmountOnExit> -          {user && <UserStrip user={user} info="" />} -          <Divider /> -          <div className={classes.images}> -            <PollSubmissionImage file={left} setFile={setLeft} /> -            <PollSubmissionImage file={right} setFile={setRight} /> -          </div> -        </Collapse> -        <Button -          color="primary" -          disabled={expanded && !readyToSubmit} -          variant={expanded ? 'contained' : 'outlined'} -          onClick={handleClick} -          fullWidth -        > -          {expanded ? 'Submit' : 'Create a Poll'} -        </Button> -      </Card> -    </ClickAwayListener> -  ); -}; - -export default PollSubmission; diff --git a/src/containers/Feed/PollSubmissionImage.tsx b/src/containers/Feed/PollSubmissionImage.tsx deleted file mode 100644 index cd67847..0000000 --- a/src/containers/Feed/PollSubmissionImage.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useFilePicker, utils } from 'react-sage'; -import { makeStyles } from '@material-ui/core/styles'; -import CloudUploadIcon from '@material-ui/icons/CloudUpload'; -import { CardActionArea, CardMedia, Typography } from '@material-ui/core'; -import CancelOutlinedIcon from '@material-ui/icons/CancelOutlined'; - -interface PropTypes { -  file: File | undefined; -  setFile: (file: File | undefined) => void; -} - -const useStyles = makeStyles({ -  root: { -    display: 'flex', -    justifyContent: 'center', -    flexDirection: 'column', -    alignItems: 'center' -  }, -  clearIcon: { -    opacity: '.5', -    fontSize: 50 -  }, -  media: { -    height: '100%', -    width: '100%', -    display: 'flex', -    justifyContent: 'center', -    alignItems: 'center' -  }, -  text: { -    textAlign: 'center' -  } -}); - - -const PollSubmissionImage: React.FC<PropTypes> = ({ file, setFile }) => { -  const classes = useStyles(); -  const { files, onClick, HiddenFileInput } = useFilePicker(); -  const [url, setUrl] = useState<string>(); -  const [isMediaHover, setIsMediaHover] = useState(false); - -  const handleMouseEnter = (): void => { -    setIsMediaHover(true); -  }; - -  const handleMouseLeave = (): void => { -    setIsMediaHover(false); -  }; - -  useEffect(() => { -    if (files?.length) { -      const chosenFile = files[0]; -      setFile(chosenFile); -      utils.loadFile(chosenFile).then(result => setUrl(result)); -    } -  }, [files, setFile]); - - -  const handleClick = () => { -    if (file) { -      setFile(undefined); -    } else onClick(); -  }; - - -  const Upload = ( -    <> -      <CloudUploadIcon fontSize="large" color="primary" /> -      <Typography variant="h5" className={classes.text}> Upload an image </Typography> -      <HiddenFileInput accept=".jpg, .jpeg, .png, .gif" multiple={false} /> -    </> -  ); - -  const Media = ( -    <CardMedia -      image={url} -      className={classes.media} -      onMouseEnter={handleMouseEnter} -      onMouseLeave={handleMouseLeave} -    > -      {isMediaHover && <CancelOutlinedIcon className={classes.clearIcon} />} -    </CardMedia> -  ); - -  return ( -    <> -      <CardActionArea onClick={handleClick} className={classes.root}> -        {file ? (url && Media) : Upload} -      </CardActionArea> -    </> -  ); -}; - -export default PollSubmissionImage; diff --git a/src/containers/PollCreation/ImageInput.tsx b/src/containers/PollCreation/ImageInput.tsx index 475d527..e807865 100644 --- a/src/containers/PollCreation/ImageInput.tsx +++ b/src/containers/PollCreation/ImageInput.tsx @@ -10,6 +10,7 @@ import { Check, CancelOutlined } from '@material-ui/icons';  import AttachLink from '../../components/AttachLink/AttachLink';  import FileUpload from '../../components/FileUpload/FileUpload';  import BackgroundImage from '../../components/Image/BackgroundImage'; +import getLocalFileUrl from '../../utils/getLocalFileUrl';  interface PropTypes {    callback: (file?: File | string) => void; @@ -59,9 +60,12 @@ const ImageInput: React.FC<PropTypes> = ({ callback, progress }) => {      callback(undefined);    }; -  const childrenCallback = (fileUrl: string, file?: File) => { -    setUrl(fileUrl); -    callback(file || fileUrl); +  const childrenCallback = (value: File | string) => { +    if (value instanceof File) { +      getLocalFileUrl(value).then(localUrl => setUrl(localUrl)); +    } else setUrl(value); + +    callback(value);    };    const Upload = ( diff --git a/src/containers/PollCreation/PollCreation.tsx b/src/containers/PollCreation/PollCreation.tsx index 03ab905..ecc6757 100644 --- a/src/containers/PollCreation/PollCreation.tsx +++ b/src/containers/PollCreation/PollCreation.tsx @@ -1,4 +1,3 @@ -/* eslint-disable */  import React from 'react';  import Bluebird from 'bluebird';  import { useHistory } from 'react-router-dom'; @@ -12,12 +11,12 @@ import {  } from '@material-ui/core';  import { useSnackbar } from 'notistack'; +import useS3Preupload from './useS3Preupload';  import ImageInput from './ImageInput';  import UserStrip from '../../components/UserStrip/UserStrip';  import { post } from '../../requests';  import { useAuth } from '../../hooks/useAuth';  import { useFeed } from '../../hooks/APIClient'; -import useS3Preupload from '../../hooks/useS3Preupload';  const useStyles = makeStyles(theme => ({ @@ -38,23 +37,21 @@ const PollCreation: React.FC = () => {    const { user } = useAuth();    const { mutate: updateFeed } = useFeed();    const { -    setValue: setLeft, -    progress: progressLeft, +    file: left, +    setFile: setLeft,      resolve: resolveLeft, -    isReady: isLeftReady +    progress: leftProgress    } = useS3Preupload();    const { -    setValue: setRight, -    progress: progressRight, +    file: right, +    setFile: setRight,      resolve: resolveRight, -    isReady: isRightReady +    progress: rightProgress    } = useS3Preupload();    const handleClick = async () => {      try {        const [leftUrl, rightUrl] = await Bluebird.all([resolveLeft(), resolveRight()]); -      console.log('leftUrl', leftUrl); -      console.log('rightUrl', rightUrl);        const contents = {          left: { url: leftUrl }, @@ -79,16 +76,16 @@ const PollCreation: React.FC = () => {          {user && <UserStrip user={user} info="" />}          <Divider />          <div className={classes.images}> -          <ImageInput callback={setLeft} progress={progressLeft} /> -          <ImageInput callback={setRight} progress={progressRight} /> +          <ImageInput callback={setLeft} progress={leftProgress} /> +          <ImageInput callback={setRight} progress={rightProgress} />          </div>          { -          progressLeft || progressRight +          leftProgress || rightProgress              ? <LinearProgress color="primary" />              : (                <Button                  color="primary" -                disabled={!(isLeftReady && isRightReady)} +                disabled={!(left && right)}                  variant="contained"                  onClick={handleClick}                  fullWidth diff --git a/src/containers/PollCreation/useS3Preupload.ts b/src/containers/PollCreation/useS3Preupload.ts new file mode 100644 index 0000000..0e2a8c4 --- /dev/null +++ b/src/containers/PollCreation/useS3Preupload.ts @@ -0,0 +1,29 @@ +import { useState, useCallback } from 'react'; +import uploadFileToS3 from '../../utils/uploadFileToS3'; + +interface Hook { +  file: File | string | undefined; +  setFile: (value: File | string | undefined) => void; +  resolve: () => Promise<string>; +  progress: number; +} + +export default (): Hook => { +  const [file, setFile] = useState<File | string>(); +  const [progress, setProgress] = useState<number>(0); + +  const resolve = useCallback(async (quality?: number): Promise<string> => { +    // Indicate start +    setProgress(0.01); + +    if (file instanceof File) return uploadFileToS3(file, quality, setProgress); +    return file || ''; +  }, [file]); + +  return { +    file, +    setFile, +    resolve, +    progress +  }; +}; diff --git a/src/containers/Profile/ProfileInfo.tsx b/src/containers/Profile/ProfileInfo.tsx index c9831f3..e2fb0a9 100644 --- a/src/containers/Profile/ProfileInfo.tsx +++ b/src/containers/Profile/ProfileInfo.tsx @@ -1,16 +1,16 @@ -import React from 'react'; -import { Badge, Typography } from '@material-ui/core/'; +import React, { useState, useCallback } from 'react'; +import { Badge, Typography, CircularProgress } from '@material-ui/core/'; +import { CameraAlt, CheckCircleOutline } from '@material-ui/icons/';  import { makeStyles } from '@material-ui/core/styles'; -import { User } from 'which-types'; -import CameraAltIcon from '@material-ui/icons/CameraAlt'; -import VerifiedIcon from '@material-ui/icons/CheckCircleOutline';  import Skeleton from '@material-ui/lab/Skeleton'; +import { User } from 'which-types'; +  import Highlight from './Highlight'; -import AttachLink from '../../components/AttachLink/AttachLink'; +import FileUpload from '../../components/FileUpload/FileUpload';  import Avatar from '../../components/Avatar/Avatar';  import { patch } from '../../requests';  import { useAuth } from '../../hooks/useAuth'; - +import uploadFileToS3 from '../../utils/uploadFileToS3';  interface PropTypes {    savedPolls: number; @@ -66,7 +66,9 @@ const useStyles = makeStyles(theme => ({    },    avatarContainer: {      position: 'relative', -    textAlign: 'center' +    display: 'flex', +    justifyContent: 'center', +    alignItems: 'center'    },    menuNumber: {      fontWeight: 800, @@ -78,8 +80,11 @@ const useStyles = makeStyles(theme => ({    skeleton: {      margin: '10px auto',      borderRadius: 2 +  }, +  progress: { +    position: 'absolute', +    color: 'white'    } -  })); @@ -88,14 +93,18 @@ const ProfileInfo: React.FC<PropTypes> = ({  }) => {    const classes = useStyles();    const { user } = useAuth(); +  const [progress, setProgress] = useState<number>(0); +    const dateSince = new Date(userInfo?.createdAt || '').toLocaleDateString(); -  const patchAvatar = (url: string) => { -    const id = user?._id; -    patch(`/users/${id}`, { avatarUrl: url }).then(res => { -      setUserInfo(res.data); -    }); -  }; +  const handleUpdateAvatar = useCallback(async (file: File) => { +    if (user) { +      uploadFileToS3(file, 0.8, setProgress) +        .then(avatarUrl => patch(`/users/${user._id}`, { avatarUrl })) +        .then(response => setUserInfo(response.data)) +        .then(() => setProgress(0)); +    } +  }, [user, setUserInfo]);    return (      <div className={classes.root}> @@ -104,24 +113,28 @@ const ProfileInfo: React.FC<PropTypes> = ({            ? <Skeleton animation="wave" variant="circle" width={150} height={150} className={classes.avatar} />            : userInfo?._id === user?._id              ? ( -              <AttachLink callback={patchAvatar}> -                <div className={classes.avatarContainer}> -                  <Badge -                    overlap="circle" -                    anchorOrigin={{ -                      vertical: 'bottom', -                      horizontal: 'right' -                    }} -                    badgeContent={( +              <div className={classes.avatarContainer}> +                <Badge +                  overlap="circle" +                  anchorOrigin={{ +                    vertical: 'bottom', +                    horizontal: 'right' +                  }} +                  className={classes.avatarContainer} +                  badgeContent={( +                    <FileUpload callback={handleUpdateAvatar}>                        <div className={classes.badge}> -                        <CameraAltIcon /> +                        <CameraAlt />                        </div> -                    )} -                  > -                    <Avatar className={classes.avatar} user={userInfo} /> -                  </Badge> -                </div> -              </AttachLink> +                    </FileUpload> +                  )} +                > +                  <Avatar className={classes.avatar} user={userInfo} /> +                  {progress > 0 && ( +                    <CircularProgress variant="static" value={progress} className={classes.progress} /> +                  )} +                </Badge> +              </div>              )              : <Avatar className={classes.avatar} user={userInfo} />        } @@ -131,7 +144,7 @@ const ProfileInfo: React.FC<PropTypes> = ({            : (              <Typography variant="h5" className={classes.name}>                {userInfo?.username} -              {userInfo?.verified && <VerifiedIcon className={classes.verified} color="primary" />} +              {userInfo?.verified && <CheckCircleOutline className={classes.verified} color="primary" />}              </Typography>            )        } diff --git a/src/hooks/useS3Preupload.tsx b/src/hooks/useS3Preupload.tsx deleted file mode 100644 index 651bfb6..0000000 --- a/src/hooks/useS3Preupload.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { useState, useCallback, useMemo } from 'react'; -import axios from 'axios'; -import Bluebird from 'bluebird'; -import Compressor from 'compressorjs'; -import { get } from '../requests'; - - -interface ProgressEvent { -  loaded: number; -  total: number; -} - -interface Hook { -  setValue: (value: File | string | undefined) => void; -  isReady: boolean; -  resolve: () => Promise<string>; -  progress: number; -} - - -const compressFile = (file: File, quality = 0.6): Promise<Blob> => { -  return new Promise((resolve, reject) => { -    return new Compressor(file, { -      success: result => resolve(result), -      error: err => reject(err), -      quality -    }); -  }); -}; - -export default (): Hook => { -  const [url, setUrl] = useState<string>(); -  const [file, setFile] = useState<File>(); -  const [progress, setProgress] = useState<number>(0); - -  const isReady = useMemo(() => Boolean(file || url), [file, url]); - -  const setValue: Hook['setValue'] = useCallback(value => { -    if (value instanceof File) { -      setFile(value); -      setUrl(undefined); -    } else { -      setUrl(value); -      setFile(undefined); -    } -  }, [setUrl, setFile]); - -  const handleUploadProgress = useCallback((progressEvent: ProgressEvent): void => { -    // Only allow upload progress reach 95%, and set 100% when request is resolved -    setProgress(Math.round((progressEvent.loaded * 95) / progressEvent.total)); -  }, [setProgress]); - -  const resolve = useCallback(async (quality?: number): Promise<string> => { -    if (file) { -      const config = { -        headers: { 'Content-Type': 'image/png' }, -        onUploadProgress: handleUploadProgress -      }; - -      setProgress(0.01); - -      // Add querystring to avoid caching request in some browsers, see -      // https://stackoverflow.com/questions/59339561/safari-skipping-xmlhttprequests -      return Bluebird.all([get(`/files?key=${file.name}`), compressFile(file, quality)]) -        .then(([response, compressedFile]) => { -          const uploadUrl = response.data; -          return axios.put(uploadUrl, compressedFile, config); -        }) -        .then(response => { -          setProgress(100); -          const uri = response.config.url; -          return uri ? uri.slice(0, uri.indexOf('?')) : ''; -        }); -    } -    setProgress(100); -    return url || ''; -  }, [file, handleUploadProgress, url]); - -  return { -    setValue, -    isReady, -    resolve, -    progress -  }; -}; diff --git a/src/utils/getLocalFileUrl.ts b/src/utils/getLocalFileUrl.ts new file mode 100644 index 0000000..1469804 --- /dev/null +++ b/src/utils/getLocalFileUrl.ts @@ -0,0 +1,15 @@ +export default (file: File): Promise<string> => { +  return new Promise((resolve, reject) => { +    const reader = new FileReader(); + +    reader.addEventListener('load', () => { +      if (typeof reader.result === 'string') resolve(reader.result); +    }, false); + +    reader.addEventListener('error', () => { +      reject(new Error('Error reading the file')); +    }, false); + +    if (file) reader.readAsDataURL(file); +  }); +}; diff --git a/src/utils/uploadFileToS3.ts b/src/utils/uploadFileToS3.ts new file mode 100644 index 0000000..d91dac5 --- /dev/null +++ b/src/utils/uploadFileToS3.ts @@ -0,0 +1,53 @@ +import Compressor from 'compressorjs'; +import axios from 'axios'; +import Bluebird from 'bluebird'; +import { get } from '../requests'; + +interface ProgressEvent { +  loaded: number; +  total: number; +} + +type SetProgress = (progress: number) => void; + + +const compressFile = (file: File, quality = 0.6): Promise<File | Blob> => { +  return new Promise((resolve, reject) => { +    if (quality === 1) return resolve(file); +    return new Compressor(file, { +      success: result => resolve(result), +      error: err => reject(err), +      quality +    }); +  }); +}; + +export default (file: File, quality?: number, setProgress?: SetProgress): Promise<string> => { +  const onUploadProgress = (progressEvent: ProgressEvent): void => { +    if (setProgress) { +      const { loaded, total } = progressEvent; +      // Only allow upload progress reach 95%, and set 100% when request is resolved +      const progress = Math.round((loaded * 95) / total); +      setProgress(progress); +    } +  }; + +  const config = { +    headers: { 'Content-Type': 'image/png' }, +    onUploadProgress +  }; + +  // Add querystring to avoid caching request in some browsers, see +  // https://stackoverflow.com/questions/59339561/safari-skipping-xmlhttprequests +  return Bluebird.all([get(`/files?key=${file.name}`), compressFile(file, quality)]) +    .then(([response, compressedFile]) => { +      const uploadUrl = response.data; +      return axios.put(uploadUrl, compressedFile, config); +    }) +    .then(response => { +      if (setProgress) setProgress(100); +      const uri = response.config.url; +      return uri ? uri.slice(0, uri.indexOf('?')) : ''; +    }); +}; +  |