Merge pull request #274 from bluewave-labs/feat/image-upload
Refactor upload image code, resolves #260
This commit is contained in:
9 files changed
+177
-165
No files matched your search
@@ -1,12 +1,12 @@
|
||||
import { Avatar as MuiAvatar } from "@mui/material";
|
||||
import PropTypes from "prop-types";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* @component
|
||||
* @param {Object} props
|
||||
* @param {string} props.src - Path to image for avatar
|
||||
* @param {string} props.firstName - The users first name
|
||||
* @param {string} props.lastName - The users last name
|
||||
* @param {boolean} props.small - Specifies if avatar should be large
|
||||
* @param {Object} [props.sx] - Additional styles to apply to the button.
|
||||
* @returns {JSX.Element}
|
||||
@@ -15,29 +15,49 @@ import PropTypes from "prop-types";
|
||||
* <Avatar src="assets/img" first="Alex" last="Holliday" small />
|
||||
*/
|
||||
|
||||
const Avatar = ({ src, firstName, lastName, small, sx }) => {
|
||||
const borderColor = "#F0F2F4";
|
||||
const smallStyle = small ? { width: 32, height: 32 } : {};
|
||||
const Avatar = ({ src, small, sx }) => {
|
||||
const { user } = useSelector((state) => state.auth);
|
||||
|
||||
const style = small ? { width: 25, height: 25 } : { width: 64, height: 64 };
|
||||
const border = small ? 1 : 3;
|
||||
|
||||
const [image, setImage] = useState();
|
||||
useEffect(() => {
|
||||
if (user.avatarImage) {
|
||||
setImage(`data:image/png;base64,${user.avatarImage}`);
|
||||
}
|
||||
}, [user?.avatarImage]);
|
||||
|
||||
return (
|
||||
<MuiAvatar
|
||||
alt={`${firstName} ${lastName}`}
|
||||
src={src}
|
||||
alt={`${user?.firstname} ${user?.lastname}`}
|
||||
src={
|
||||
src ? src : user?.avatarImage ? image : "/static/images/avatar/2.jpg"
|
||||
}
|
||||
sx={{
|
||||
fontSize: small ? "12px" : "20px",
|
||||
display: "inline-flex",
|
||||
border: `0.2rem solid ${borderColor}`,
|
||||
...smallStyle,
|
||||
...sx
|
||||
"&::before": {
|
||||
content: `""`,
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: `calc(100% - ${border * 2}px)`,
|
||||
height: `calc(100% - ${border * 2}px)`,
|
||||
border: `${border}px solid rgba(255,255,255,0.2)`,
|
||||
borderRadius: "50%",
|
||||
},
|
||||
...style,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{src === undefined ? `${firstName[0]}${lastName[0]}` : null}
|
||||
{user.firstname?.charAt(0)}{user.lastname?.charAt(0)}
|
||||
</MuiAvatar>
|
||||
);
|
||||
};
|
||||
|
||||
Avatar.propTypes = {
|
||||
src: PropTypes.string,
|
||||
firstName: PropTypes.string.isRequired,
|
||||
lastName: PropTypes.string.isRequired,
|
||||
small: PropTypes.bool,
|
||||
sx: PropTypes.object,
|
||||
};
|
||||
|
||||
@@ -5,12 +5,6 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-button-avatar {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
margin-right: var(--env-var-spacing-1);
|
||||
}
|
||||
|
||||
.icon-button-toggle-title {
|
||||
font-size: var(--env-var-font-size-medium);
|
||||
color: var(--env-var-color-5);
|
||||
|
||||
@@ -8,7 +8,7 @@ import Typography from "@mui/material/Typography";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import Container from "@mui/material/Container";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Avatar from "../Avatar";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
@@ -161,12 +161,7 @@ function NavBar() {
|
||||
sx={{ p: 0 }}
|
||||
>
|
||||
<div className="icon-button-toggle">
|
||||
<Avatar
|
||||
alt="Remy Sharp"
|
||||
src="/static/images/avatar/2.jpg"
|
||||
className="icon-button-avatar"
|
||||
style={{ width: "25px", height: "25px" }}
|
||||
/>
|
||||
<Avatar small={true} sx={{ mr: "8px" }} />
|
||||
<div className="icon-button-toggle-title">
|
||||
{authState.user.firstname} {authState.user.lastname}
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
padding-left: 2px;
|
||||
}
|
||||
.progress-bar-container:has(p.input-error){
|
||||
border-color: var(--env-var-color-24);
|
||||
border-color: var(--env-var-color-19);
|
||||
background-color: var(--env-var-color-21);
|
||||
padding: 8px var(--env-var-spacing-1-plus);
|
||||
}
|
||||
.progress-bar-container p.input-error{
|
||||
|
||||
@@ -94,6 +94,9 @@ const ProgressUpload = ({
|
||||
}
|
||||
: {
|
||||
ml: "auto",
|
||||
"&:focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import TabPanel from "@mui/lab/TabPanel";
|
||||
import { Box, Divider, Modal, Stack, Typography } from "@mui/material";
|
||||
import ButtonSpinner from "../../ButtonSpinner";
|
||||
@@ -16,6 +16,7 @@ import { update } from "../../../Features/Auth/authSlice";
|
||||
import ImageField from "../../TextFields/Image";
|
||||
import ImageIcon from "@mui/icons-material/Image";
|
||||
import ProgressUpload from "../../ProgressBars";
|
||||
import { formatBytes } from "../../../Utils/fileUtils";
|
||||
|
||||
/**
|
||||
* ProfilePanel component displays a form for editing user profile information
|
||||
@@ -29,26 +30,28 @@ const ProfilePanel = () => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
//redux state
|
||||
const { user, authToken, isLoading } = useSelector((state) => state.auth);
|
||||
|
||||
const idToName = {
|
||||
"edit-first-name": "firstname",
|
||||
"edit-last-name": "lastname",
|
||||
//Disabled for now, will revisit in the future
|
||||
// Disabled for now, will revisit in the future
|
||||
// "edit-email": "email",
|
||||
};
|
||||
|
||||
// Local state for form data, errors, and file handling
|
||||
const [localData, setLocalData] = useState({
|
||||
firstname: user.firstname,
|
||||
lastname: user.lastname,
|
||||
//Disabled for now, will revisit in the future
|
||||
// email: user.email,
|
||||
//TODO - upload picture
|
||||
profilePicUrl: "placeholder",
|
||||
// email: user.email, // Disabled for now
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [isOpen, setIsOpen] = useState("");
|
||||
const isModalOpen = (name) => isOpen === name;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [file, setFile] = useState();
|
||||
const intervalRef = useRef(null);
|
||||
const [progress, setProgress] = useState({ value: 0, isLoading: false });
|
||||
|
||||
// Handles input field changes and performs validation
|
||||
const handleChange = (event) => {
|
||||
const { value, id } = event.target;
|
||||
const name = idToName[id];
|
||||
@@ -57,71 +60,51 @@ const ProfilePanel = () => {
|
||||
[name]: value,
|
||||
}));
|
||||
|
||||
const validation = editProfileValidation.validate(
|
||||
{ [name]: value },
|
||||
{ abortEarly: false }
|
||||
);
|
||||
|
||||
setErrors((prev) => {
|
||||
const updatedErrors = { ...prev };
|
||||
|
||||
if (validation.error) {
|
||||
updatedErrors[name] = validation.error.details[0].message;
|
||||
} else {
|
||||
delete updatedErrors[name];
|
||||
}
|
||||
return updatedErrors;
|
||||
});
|
||||
validateField({ [name]: value }, editProfileValidation, name);
|
||||
};
|
||||
|
||||
//TODO - add setPicture state to localData
|
||||
const [picture, setPicture] = useState();
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
// Handles image file
|
||||
const handlePicture = (event) => {
|
||||
clearError("picture");
|
||||
const pic = event.target.files[0];
|
||||
console.log(pic);
|
||||
//TODO - add setPicture state to localData
|
||||
setPicture({ name: pic.name, type: pic.type, size: formatBytes(pic.size) });
|
||||
setIsUploading(true);
|
||||
|
||||
const { error } = imageValidation.validate(
|
||||
{
|
||||
type: pic.type,
|
||||
size: pic.size,
|
||||
},
|
||||
{ abortEarly: false }
|
||||
let error = validateField(
|
||||
{ type: pic.type, size: pic.size },
|
||||
imageValidation
|
||||
);
|
||||
if (error) return;
|
||||
|
||||
if (error) {
|
||||
setErrors((prev) => {
|
||||
const updatedErrors = { ...prev };
|
||||
updatedErrors["picture"] = error.details[0].message;
|
||||
return updatedErrors;
|
||||
setProgress((prev) => ({ ...prev, isLoading: true }));
|
||||
setFile({
|
||||
src: URL.createObjectURL(pic),
|
||||
name: pic.name,
|
||||
size: formatBytes(pic.size),
|
||||
});
|
||||
|
||||
//TODO - potentitally remove once image compression functions are implemented
|
||||
intervalRef.current = setInterval(() => {
|
||||
const buffer = 12;
|
||||
setProgress((prev) => {
|
||||
if (prev.value + buffer >= 100) {
|
||||
clearInterval(intervalRef.current);
|
||||
return { value: 100, isLoading: false };
|
||||
}
|
||||
return { ...prev, value: prev.value + buffer };
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
//TODO - add setPicture state to localData
|
||||
setPicture((prev) => ({ ...prev, src: URL.createObjectURL(pic) }));
|
||||
}, 1500);
|
||||
|
||||
let progress = 0;
|
||||
const interval = setInterval(() => {
|
||||
progress += 10;
|
||||
setUploadProgress(progress);
|
||||
if (progress >= 100) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 150);
|
||||
}, 120);
|
||||
};
|
||||
const formatBytes = (bytes) => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const megabytes = bytes / (1024 * 1024);
|
||||
return megabytes.toFixed(2) + " MB";
|
||||
|
||||
// Validates input against provided schema and updates error state
|
||||
const validateField = (toValidate, schema, name = "picture") => {
|
||||
const { error } = schema.validate(toValidate, { abortEarly: false });
|
||||
setErrors((prev) => {
|
||||
const prevErrors = { ...prev };
|
||||
if (error) prevErrors[name] = error.details[0].message;
|
||||
else delete prevErrors[name];
|
||||
return prevErrors;
|
||||
});
|
||||
if (error) return true;
|
||||
};
|
||||
|
||||
// Clears specific error from errors state
|
||||
const clearError = (err) => {
|
||||
setErrors((prev) => {
|
||||
const updatedErrors = { ...prev };
|
||||
@@ -129,53 +112,59 @@ const ProfilePanel = () => {
|
||||
return updatedErrors;
|
||||
});
|
||||
};
|
||||
const handleCancelUpload = () => {
|
||||
//TODO - add setPicture state to localData
|
||||
setPicture();
|
||||
setUploadProgress(0);
|
||||
setIsUploading(false);
|
||||
clearError("picture");
|
||||
};
|
||||
const handleClosePictureModal = () => {
|
||||
setIsOpen("");
|
||||
setUploadProgress(0);
|
||||
setIsUploading(false);
|
||||
clearError("picture");
|
||||
|
||||
// Resets picture-related states and clears interval
|
||||
const removePicture = () => {
|
||||
errors["picture"] && clearError("picture");
|
||||
clearInterval(intervalRef.current); // interrupt interval if image upload is canceled prior to completing the process
|
||||
setProgress({ value: 0, isLoading: false });
|
||||
};
|
||||
|
||||
//TODO - revisit once localData is set up properly
|
||||
const handleDeletePicture = () => {
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
//TODO - add setPicture state to localData
|
||||
setPicture(null);
|
||||
setLoading(false);
|
||||
}, 2000);
|
||||
};
|
||||
//TODO - implement update profile function
|
||||
const handleUpdatePicture = () => {
|
||||
// Closes the picture update modal and resets related states
|
||||
const closePictureModal = () => {
|
||||
removePicture();
|
||||
setIsOpen("");
|
||||
setUploadProgress(0);
|
||||
setIsUploading(false);
|
||||
};
|
||||
//TODO - implement delete account function
|
||||
const handleDeleteAccount = () => {};
|
||||
//TODO - implement save profile function
|
||||
|
||||
// Updates profile image displayed on UI
|
||||
const handleUpdatePicture = () => {
|
||||
setProgress({ value: 0, isLoading: false });
|
||||
setLocalData((prev) => ({
|
||||
...prev,
|
||||
file: file.src,
|
||||
}));
|
||||
setIsOpen("");
|
||||
};
|
||||
|
||||
// Handles form submission to update user profile
|
||||
const handleSaveProfile = (event) => {
|
||||
event.preventDefault();
|
||||
if (
|
||||
localData.firstname === user.firstname &&
|
||||
localData.lastname === user.lastname &&
|
||||
localData.profilePicUrl === user.profilePicUrl
|
||||
) {
|
||||
//TODO - add toast(profile data is unchanged) and maybe disable button
|
||||
return;
|
||||
}
|
||||
// if (
|
||||
// localData.firstname === user.firstname &&
|
||||
// localData.lastname === user.lastname
|
||||
// ) {
|
||||
// // TODO: Add toast/notification for unchanged profile data
|
||||
// return;
|
||||
// }
|
||||
|
||||
dispatch(update({ authToken, localData }));
|
||||
//TODO - add toast confirmation
|
||||
// TODO: Add toast/notification for profile update success
|
||||
};
|
||||
|
||||
// Removes current profile image from UI
|
||||
const handleDeletePicture = () => {
|
||||
// TODO - implement delete picture function
|
||||
};
|
||||
|
||||
// Initiates the account deletion process
|
||||
const handleDeleteAccount = () => {
|
||||
//TODO - implement delete account function
|
||||
};
|
||||
|
||||
// Modal state and control functions
|
||||
const [isOpen, setIsOpen] = useState("");
|
||||
const isModalOpen = (name) => isOpen === name;
|
||||
|
||||
return (
|
||||
<TabPanel value="profile">
|
||||
<form className="edit-profile-form" noValidate spellCheck="false">
|
||||
@@ -260,22 +249,11 @@ const ProfilePanel = () => {
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack className="row-stack" direction="row" alignItems="center">
|
||||
<Avatar
|
||||
src={picture ? picture.src : "/static/images/avatar/2.jpg"}
|
||||
firstName={user?.firstname}
|
||||
lastName={user?.lastname}
|
||||
sx={{
|
||||
width: "64px",
|
||||
height: "64px",
|
||||
border: "none",
|
||||
mr: "8px",
|
||||
}}
|
||||
/>
|
||||
<ButtonSpinner
|
||||
<Avatar src={file?.src} sx={{ mr: "8px" }} />
|
||||
<Button
|
||||
level="tertiary"
|
||||
label="Delete"
|
||||
onClick={handleDeletePicture}
|
||||
isLoading={loading}
|
||||
/>
|
||||
<Button
|
||||
level="tertiary"
|
||||
@@ -301,7 +279,9 @@ const ProfilePanel = () => {
|
||||
onClick={handleSaveProfile}
|
||||
isLoading={isLoading}
|
||||
loadingText="Saving..."
|
||||
disabled={Object.keys(errors).length !== 0 && true}
|
||||
disabled={
|
||||
Object.keys(errors).length !== 0 && !errors?.picture && true
|
||||
}
|
||||
sx={{
|
||||
paddingX: "40px",
|
||||
}}
|
||||
@@ -381,7 +361,7 @@ const ProfilePanel = () => {
|
||||
aria-labelledby="modal-update-picture"
|
||||
aria-describedby="update-profile-picture"
|
||||
open={isModalOpen("picture")}
|
||||
onClose={handleClosePictureModal}
|
||||
onClose={closePictureModal}
|
||||
>
|
||||
<Stack
|
||||
sx={{
|
||||
@@ -405,16 +385,17 @@ const ProfilePanel = () => {
|
||||
</Typography>
|
||||
<ImageField
|
||||
id="update-profile-picture"
|
||||
picture={picture?.src}
|
||||
src={file?.src}
|
||||
loading={progress.isLoading && progress.value !== 100}
|
||||
onChange={handlePicture}
|
||||
/>
|
||||
{isUploading ? (
|
||||
{progress.isLoading || progress.value !== 0 || errors["picture"] ? (
|
||||
<ProgressUpload
|
||||
icon={<ImageIcon />}
|
||||
label={picture?.name}
|
||||
size={picture?.size}
|
||||
progress={uploadProgress}
|
||||
onClick={handleCancelUpload}
|
||||
label={file?.name}
|
||||
size={file?.size}
|
||||
progress={progress.value}
|
||||
onClick={removePicture}
|
||||
error={errors["picture"]}
|
||||
/>
|
||||
) : (
|
||||
@@ -427,17 +408,17 @@ const ProfilePanel = () => {
|
||||
disabled
|
||||
sx={{ mr: "auto" }}
|
||||
/>
|
||||
<ButtonSpinner
|
||||
level="tertiary"
|
||||
label="Remove"
|
||||
onClick={handleCancelUpload}
|
||||
isLoading={loading}
|
||||
/>
|
||||
<ButtonSpinner
|
||||
<Button level="tertiary" label="Remove" onClick={removePicture} />
|
||||
<Button
|
||||
level="primary"
|
||||
label="Update"
|
||||
onClick={handleUpdatePicture}
|
||||
isLoading={isLoading}
|
||||
disabled={
|
||||
(Object.keys(errors).length !== 0 && errors?.picture) ||
|
||||
progress.value !== 100
|
||||
? true
|
||||
: false
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -4,16 +4,17 @@ import { Box, IconButton, Stack, TextField, Typography } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import "./index.css";
|
||||
import { checkImage } from "../../../Utils/fileUtils";
|
||||
|
||||
/**
|
||||
* @param {Object} props - The component props.
|
||||
* @param {string} props.id - The unique identifier for the input field.
|
||||
* @param {string} props.picture - The URL of the image to display.
|
||||
* @param {string} props.src - The URL of the image to display.
|
||||
* @param {function} props.onChange - The function to handle file input change.
|
||||
* @returns {JSX.Element} The rendered component.
|
||||
*/
|
||||
|
||||
const ImageField = ({ id, picture, onChange }) => {
|
||||
const ImageField = ({ id, src, loading, onChange }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -26,7 +27,7 @@ const ImageField = ({ id, picture, onChange }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{!picture ? (
|
||||
{!checkImage(src) || loading ? (
|
||||
<>
|
||||
<Box
|
||||
className="image-field-wrapper"
|
||||
@@ -107,7 +108,7 @@ const ImageField = ({ id, picture, onChange }) => {
|
||||
height: "250px",
|
||||
borderRadius: "50%",
|
||||
overflow: "hidden",
|
||||
backgroundImage: `url(${picture})`,
|
||||
backgroundImage: `url(${src})`,
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
></Box>
|
||||
@@ -119,7 +120,7 @@ const ImageField = ({ id, picture, onChange }) => {
|
||||
|
||||
ImageField.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
picture: PropTypes.string,
|
||||
src: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -47,10 +47,16 @@ export const update = createAsyncThunk(
|
||||
//1.5s delay to show loading spinner
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
const res = await axios.post(`${BASE_URL}/auth/user/${user._id}`, form, {
|
||||
const fd = new FormData();
|
||||
const imageResult = await axios.get(form.file, { responseType: "blob" });
|
||||
fd.append("firstname", form.firstname);
|
||||
fd.append("lastname", form.lastname);
|
||||
fd.append("profileImage", imageResult.data);
|
||||
|
||||
const res = await axios.post(`${BASE_URL}/auth/user/${user._id}`, fd, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
return res.data;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const formatBytes = (bytes) => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const megabytes = bytes / (1024 * 1024);
|
||||
return megabytes.toFixed(2) + " MB";
|
||||
};
|
||||
|
||||
export const checkImage = (url) => {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
return img.naturalWidth !== 0;
|
||||
};
|
||||
Reference in new issue
Block a user