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 ? ( + + Uploaded preview + + ) : ( + <> + 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')} )} setIsOpen("")} - confirmationButtonLabel={"Delete account"} + confirmationButtonLabel={t('DeleteAccount')} onConfirm={handleDeleteAccount} isLoading={isLoading} /> @@ -408,34 +378,27 @@ const ProfilePanel = () => { onClose={closePictureModal} theme={theme} > - { + if (newFile) { + setFile(newFile); + clearError("unchanged"); + } + }} + previewIsRound + maxSize={3 * 1024 * 1024} /> - {progress.isLoading || progress.value !== 0 || errors["picture"] ? ( - } - label={file?.name} - size={file?.size} - progress={progress.value} - onClick={removePicture} - error={errors["picture"]} - /> - ) : ( - "" - )} { color="info" onClick={removePicture} > - Remove + {t('remove')} diff --git a/client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx b/client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx index 4d197ad56..e3d0a0bc6 100644 --- a/client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx +++ b/client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx @@ -5,7 +5,7 @@ import ConfigBox from "../../../../../Components/ConfigBox"; import Checkbox from "../../../../../Components/Inputs/Checkbox"; import TextInput from "../../../../../Components/Inputs/TextInput"; import Select from "../../../../../Components/Inputs/Select"; -import ImageField from "../../../../../Components/Inputs/Image"; +import ImageUpload from "../../../../../Components/Inputs/ImageUpload"; import ColorPicker from "../../../../../Components/Inputs/ColorPicker"; import Progress from "../Progress"; @@ -106,11 +106,10 @@ const TabSettings = ({ -