diff options
-rw-r--r-- | package-lock.json | 7 | ||||
-rw-r--r-- | package.json | 3 | ||||
-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 |
13 files changed, 185 insertions, 354 deletions
diff --git a/package-lock.json b/package-lock.json index 0e86c15..9b4ef60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "which", - "version": "1.2.3", + "version": "1.2.4", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -10970,11 +10970,6 @@ "tiny-warning": "^1.0.0" } }, - "react-sage": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/react-sage/-/react-sage-0.1.2.tgz", - "integrity": "sha512-tJk2SATS0Czg5a3Sae4ilvv2W0FedBuvPFKi81qgAO7cIzn5/p1EArOYcRvGzinJVFP3/4l0Uz4gF95cx3cVhg==" - }, "react-scripts": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-3.4.1.tgz", diff --git a/package.json b/package.json index 457fa4f..abfcc76 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "which", - "version": "1.2.3", + "version": "1.2.4", "homepage": "https://which-ecosystem.github.io/", "dependencies": { "@material-ui/core": "^4.10.1", @@ -16,7 +16,6 @@ "react-error-boundary": "^2.3.1", "react-icons": "^3.10.0", "react-router-dom": "^5.2.0", - "react-sage": "^0.1.2", "react-scripts": "3.4.1", "react-virtualized": "^9.21.2", "swr": "^0.3.0", 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('?')) : ''; + }); +}; + |