diff --git a/client/src/Components/Inputs/Image/index.css b/client/src/Components/Inputs/Image/index.css
deleted file mode 100644
index 7492f8636..000000000
--- a/client/src/Components/Inputs/Image/index.css
+++ /dev/null
@@ -1,18 +0,0 @@
-.MuiStack-root:has(#modal-update-picture) h1.MuiTypography-root {
- font-weight: 600;
-}
-.image-field-wrapper h2.MuiTypography-root,
-.MuiStack-root:has(#modal-update-picture) button,
-.MuiStack-root:has(#modal-update-picture) h1.MuiTypography-root {
- font-size: var(--env-var-font-size-medium);
-}
-.image-field-wrapper h2.MuiTypography-root {
- margin-top: 10px;
-}
-.image-field-wrapper + p.MuiTypography-root {
- margin-top: 8px;
-}
-.image-field-wrapper + p.MuiTypography-root,
-.image-field-wrapper p.MuiTypography-root {
- font-size: var(--env-var-font-size-small-plus);
-}
diff --git a/client/src/Components/Inputs/Image/index.jsx b/client/src/Components/Inputs/Image/index.jsx
deleted file mode 100644
index 971f17c00..000000000
--- a/client/src/Components/Inputs/Image/index.jsx
+++ /dev/null
@@ -1,175 +0,0 @@
-import React, { useState } from "react";
-import PropTypes from "prop-types";
-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.src - The URL of the image to display.
- * @param {function} props.onChange - The function to handle file input change.
- * @param {boolean} props.isRound - Whether the shape of the image to display is round.
- * @param {string} props.maxSize - Custom message for the max uploaded file size
- * @returns {JSX.Element} The rendered component.
- */
-
-const ImageField = ({ id, src, loading, onChange, error, isRound = true, maxSize }) => {
- const theme = useTheme();
- const error_border_style = error ? { borderColor: theme.palette.error.main } : {};
-
- const roundShape = isRound ? { borderRadius: "50%" } : {};
-
- const [isDragging, setIsDragging] = useState(false);
- const handleDragEnter = () => {
- setIsDragging(true);
- };
- const handleDragLeave = () => {
- setIsDragging(false);
- };
-
- return (
- <>
- {!checkImage(src) || loading ? (
- <>
-
-
-
-
-
-
-
-
- Click to upload
- {" "}
- or drag and drop
-
-
- (maximum size: {maxSize ?? "3MB"})
-
-
-
-
- Supported formats: JPG, PNG
-
- {error && (
-
- {error}
-
- )}
- >
- ) : (
-
-
-
- )}
- >
- );
-};
-
-ImageField.propTypes = {
- id: PropTypes.string.isRequired,
- src: PropTypes.string,
- onChange: PropTypes.func.isRequired,
- isRound: PropTypes.bool,
- maxSize: PropTypes.string,
-};
-
-export default ImageField;
diff --git a/client/src/Components/Inputs/ImageUpload/index.jsx b/client/src/Components/Inputs/ImageUpload/index.jsx
new file mode 100644
index 000000000..a49dc4860
--- /dev/null
+++ b/client/src/Components/Inputs/ImageUpload/index.jsx
@@ -0,0 +1,243 @@
+// Components
+import { Box, Stack, Typography } from "@mui/material";
+import CloudUploadIcon from "@mui/icons-material/CloudUpload";
+import Image from "../../../Components/Image";
+import TextField from "@mui/material/TextField";
+import IconButton from "@mui/material/IconButton";
+import ProgressUpload from "../../ProgressBars";
+import ImageIcon from "@mui/icons-material/Image";
+
+// Utils
+import PropTypes from "prop-types";
+import { useCallback, useState, useRef, useEffect } from "react";
+import { useTheme } from "@emotion/react";
+import { useTranslation } from "react-i18next";
+
+/**
+ * ImageUpload component allows users to upload images with drag-and-drop functionality.
+ * It supports file size and format validation.
+ *
+ * @component
+ * @param {Object} props - Component props
+ * @param {boolean} [props.previewIsRound=false] - Determines if the image preview should be round
+ * @param {string} [props.src] - Source URL of the image to display
+ * @param {function} props.onChange - Callback function to handle file change, takes a file as an argument
+ * @param {number} [props.maxSize=3145728] - Maximum file size allowed in bytes (default is 3MB)
+ * @param {Array} [props.accept=['jpg', 'jpeg', 'png']] - Array of accepted file formats
+ * @param {Object} [props.errors] - Object containing error messages
+ * @returns {JSX.Element} The rendered component
+ */
+const ImageUpload = ({
+ previewIsRound = false,
+ src,
+ onChange,
+ maxSize = 3 * 1024 * 1024,
+ accept = ["jpg", "jpeg", "png"],
+ error,
+}) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const [uploadComplete, setUploadComplete] = useState(false);
+ const [completedFile, setCompletedFile] = useState(null);
+ const [file, setFile] = useState(null);
+ const [progress, setProgress] = useState({ value: 0, isLoading: false });
+ const intervalRef = useRef(null);
+ const [localError, setLocalError] = useState(null);
+ const [isDragging, setIsDragging] = useState(false);
+
+ const roundStyle = previewIsRound ? { borderRadius: "50%" } : {};
+
+ const handleImageChange = useCallback(
+ (file) => {
+ if (!file) return;
+
+ const isValidType = accept.some((type) =>
+ file.type.includes(type)
+ );
+ const isValidSize = file.size <= maxSize;
+
+ if (!isValidType) {
+ setLocalError(t('invalidFileFormat'));
+ return;
+ }
+ if (!isValidSize) {
+ setLocalError(t('invalidFileSize'));
+ return;
+ }
+
+ setLocalError(null);
+
+ const previewFile = {
+ src: URL.createObjectURL(file),
+ name: file.name,
+ file,
+ };
+
+ setFile(previewFile);
+ setProgress({ value: 0, isLoading: true });
+
+ intervalRef.current = setInterval(() => {
+ setProgress((prev) => {
+ const buffer = 12;
+ if (prev.value + buffer >= 100) {
+ clearInterval(intervalRef.current);
+ setUploadComplete(true);
+ setCompletedFile(previewFile);
+ return { value: 100, isLoading: false };
+ }
+ return { value: prev.value + buffer, isLoading: true };
+ });
+ }, 120);
+ },
+ [maxSize, accept]
+ );
+
+ useEffect(() => {
+ if (uploadComplete && completedFile) {
+ onChange?.(completedFile);
+ setUploadComplete(false);
+ setCompletedFile(null);
+ }
+ }, [uploadComplete, completedFile, onChange]);
+
+
+ return (
+ <>
+ {src ? (
+
+
+
+ ) : (
+ <>
+ setIsDragging(true)}
+ onDragLeave={() => setIsDragging(false)}
+ onDrop={() => setIsDragging(false)}
+ sx={{
+ position: "relative",
+ height: "fit-content",
+ border: "dashed",
+ borderRadius: theme.shape.borderRadius,
+ borderColor: isDragging
+ ? theme.palette.primary.main
+ : theme.palette.primary.lowContrast,
+ backgroundColor: isDragging
+ ? "hsl(215, 87%, 51%, 0.05)"
+ : "transparent",
+ borderWidth: "2px",
+ transition: "0.2s",
+ "&:hover": {
+ borderColor: theme.palette.primary.main,
+ backgroundColor: "hsl(215, 87%, 51%, 0.05)",
+ },
+ }}
+ >
+ handleImageChange(e?.target?.files?.[0])}
+ sx={{
+ width: "100%",
+ "& .MuiInputBase-input[type='file']": {
+ opacity: 0,
+ cursor: "pointer",
+ maxWidth: "500px",
+ minHeight: "175px",
+ zIndex: 1,
+ },
+ "& fieldset": {
+ padding: 0,
+ border: "none",
+ },
+ }}
+ />
+
+
+
+
+
+
+ {t('ClickUpload')}
+ {" "}
+ or {t('DragandDrop')}
+
+ ({t('MaxSize')}: {Math.round(maxSize / 1024 / 1024)}MB)
+
+
+
+ {(localError || progress.isLoading || progress.value !== 0) && (
+ }
+ label={file?.name || "Upload failed"}
+ size={file?.size}
+ progress={progress.value}
+ onClick={() => {
+ clearInterval(intervalRef.current);
+ setFile(null);
+ setProgress({ value: 0, isLoading: false });
+ setLocalError(null);
+ onChange(undefined);
+ }}
+ error={localError || error}
+ />
+ )}
+
+ {t('SupportedFormats')}: {accept.join(", ").toUpperCase()}
+
+ >
+ )}
+ >
+ );
+};
+
+ImageUpload.propTypes = {
+ previewIsRound: PropTypes.bool,
+ src: PropTypes.string,
+ onChange: PropTypes.func,
+ maxSize: PropTypes.number,
+ accept: PropTypes.array,
+ error: PropTypes.string,
+};
+
+export default ImageUpload;
\ No newline at end of file
diff --git a/client/src/Components/TabPanels/Account/ProfilePanel.jsx b/client/src/Components/TabPanels/Account/ProfilePanel.jsx
index 7cf123667..aa6c11f3c 100644
--- a/client/src/Components/TabPanels/Account/ProfilePanel.jsx
+++ b/client/src/Components/TabPanels/Account/ProfilePanel.jsx
@@ -1,21 +1,19 @@
import { useTheme } from "@emotion/react";
-import { useRef, useState } from "react";
+import { useState } from "react";
import TabPanel from "@mui/lab/TabPanel";
import { Box, Button, Divider, Stack, Typography } from "@mui/material";
import Avatar from "../../Avatar";
import TextInput from "../../Inputs/TextInput";
-import ImageField from "../../Inputs/Image";
-import { credentials, imageValidation } from "../../../Validation/validation";
+import ImageUpload from "../../Inputs/ImageUpload";
+import { credentials } from "../../../Validation/validation";
import { useDispatch, useSelector } from "react-redux";
import { clearAuthState, deleteUser, update } from "../../../Features/Auth/authSlice";
-import ImageIcon from "@mui/icons-material/Image";
-import ProgressUpload from "../../ProgressBars";
-import { formatBytes } from "../../../Utils/fileUtils";
import { clearUptimeMonitorState } from "../../../Features/UptimeMonitors/uptimeMonitorsSlice";
import { createToast } from "../../../Utils/toastUtils";
import { logger } from "../../../Utils/Logger";
import { GenericDialog } from "../../Dialog/genericDialog";
import Dialog from "../../Dialog";
+import { useTranslation } from "react-i18next";
/**
* ProfilePanel component displays a form for editing user profile information
@@ -28,7 +26,7 @@ import Dialog from "../../Dialog";
const ProfilePanel = () => {
const theme = useTheme();
const dispatch = useDispatch();
-
+ const { t } = useTranslation();
const SPACING_GAP = theme.spacing(12);
//redux state
@@ -49,8 +47,6 @@ const ProfilePanel = () => {
});
const [errors, setErrors] = useState({});
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) => {
@@ -65,33 +61,6 @@ const ProfilePanel = () => {
validateField({ [name]: value }, credentials, name);
};
- // Handles image file
- const handlePicture = (event) => {
- const pic = event.target.files[0];
- let error = validateField({ type: pic.type, size: pic.size }, imageValidation);
- if (error) return;
-
- setProgress((prev) => ({ ...prev, isLoading: true }));
- setFile({
- src: URL.createObjectURL(pic),
- name: pic.name,
- size: formatBytes(pic.size),
- delete: false,
- });
-
- //TODO - potentitally remove, will revisit in the future
- 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 };
- });
- }, 120);
- };
-
// Validates input against provided schema and updates error state
const validateField = (toValidate, schema, name = "picture") => {
const { error } = schema.validate(toValidate, { abortEarly: false });
@@ -116,52 +85,55 @@ const ProfilePanel = () => {
// Resets picture-related states and clears interval
const removePicture = () => {
errors["picture"] && clearError("picture");
- setFile({ delete: true });
- clearInterval(intervalRef.current); // interrupt interval if image upload is canceled prior to completing the process
- setProgress({ value: 0, isLoading: false });
- };
+ setFile(undefined);
+ setLocalData((prev) => ({
+ ...prev,
+ file: undefined,
+ deleteProfileImage: true,
+ }));
+ };
// Opens the picture update modal
const openPictureModal = () => {
setIsOpen("picture");
- setFile({ delete: localData.deleteProfileImage });
+ setFile(undefined);
};
// Closes the picture update modal and resets related states
const closePictureModal = () => {
- errors["picture"] && clearError("picture");
- setFile(); //reset file
- clearInterval(intervalRef.current); // interrupt interval if image upload is canceled prior to completing the process
- setProgress({ value: 0, isLoading: false });
- setIsOpen("");
- };
+ if (errors["picture"]) clearError("picture");
+ setFile(undefined);
+ setIsOpen("");
+ };
// Updates profile image displayed on UI
const handleUpdatePicture = () => {
- setProgress({ value: 0, isLoading: false });
setLocalData((prev) => ({
...prev,
- file: file.src,
+ file: file?.src,
deleteProfileImage: false,
}));
setIsOpen("");
- errors["unchanged"] && clearError("unchanged");
+ if (errors["unchanged"]) clearError("unchanged");
};
-
+
// Handles form submission to update user profile
const handleSaveProfile = async (event) => {
event.preventDefault();
- if (
- localData.firstName === user.firstName &&
- localData.lastName === user.lastName &&
- localData.deleteProfileImage === undefined &&
- localData.file === undefined
- ) {
- createToast({
- body: "Unable to update profile — no changes detected.",
- });
- setErrors({ unchanged: "unable to update profile" });
- return;
+ const nameChanged =
+ localData.firstName !== user.firstName ||
+ localData.lastName !== user.lastName;
+
+ const avatarChanged =
+ localData.deleteProfileImage === true ||
+ (localData.file && localData.file !== `data:image/png;base64,${user.avatarImage}`);
+
+ if (!nameChanged && !avatarChanged) {
+ createToast({
+ body: "Unable to update profile — no changes detected.",
+ });
+ setErrors({ unchanged: "unable to update profile" });
+ return;
}
const action = await dispatch(update({ localData }));
@@ -182,6 +154,7 @@ const ProfilePanel = () => {
...prev,
deleteProfileImage: true,
}));
+ setFile(undefined);
errors["unchanged"] && clearError("unchanged");
};
@@ -232,7 +205,7 @@ const ProfilePanel = () => {
>
{/* This 0.9 is a bit magic numbering, refactor */}
- First name
+ {t('FirstName')}
{
gap={SPACING_GAP}
>
- Last name
+ {t('LastName')}
{
gap={SPACING_GAP}
>
- Email
+ {t('email')}
- This is your current email address — it cannot be changed.
+ {t('EmailDescriptionText')}
{
gap={SPACING_GAP}
>
- Your photo
+ {t('YourPhoto')}
- This photo will be displayed in your profile page.
+ {t('PhotoDescriptionText')}
{
color="error"
onClick={handleDeletePicture}
>
- Delete
+ {t('delete')}
@@ -352,7 +325,7 @@ const ProfilePanel = () => {
disabled={Object.keys(errors).length !== 0 && !errors?.picture && true}
sx={{ px: theme.spacing(12) }}
>
- Save
+ {t('save')}
@@ -371,13 +344,12 @@ const ProfilePanel = () => {
spellCheck="false"
>
- Delete account
+ {t('DeleteAccount')}
- Note that deleting your account will remove all data from the server. This
- is permanent and non-recoverable.
+ {t('DeleteDescriptionText')}
)}