Merge pull request #274 from bluewave-labs/feat/image-upload

Refactor upload image code, resolves #260
This commit is contained in:
Alexander Holliday authored and GitHub committed 2024-07-08 13:43:50 -07:00
commit 71703a9a1d
9 files changed
+177 -165

No files matched your search

+33 -13
View File
@@ -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,
};
-6
View File
@@ -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);
+2 -7
View File
@@ -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>
+2 -1
View File
@@ -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,
};
+8 -2
View File
@@ -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;
+11
View File
@@ -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;
};