mirror of
https://github.com/bluewave-labs/Checkmate.git
synced 2026-01-25 11:19:16 -06:00
Merge pull request #2123 from bluewave-labs/feat/fe/img-upload-component
New reusable Image Upload Component
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<>
|
||||
<Box
|
||||
className="image-field-wrapper"
|
||||
mt={theme.spacing(8)}
|
||||
sx={{
|
||||
position: "relative",
|
||||
height: "fit-content",
|
||||
border: "dashed",
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
borderColor: isDragging
|
||||
? theme.palette.primary.main
|
||||
: theme.palette.primary.lowContrast,
|
||||
borderWidth: "2px",
|
||||
transition: "0.2s",
|
||||
"&:hover": {
|
||||
borderColor: theme.palette.primary.main,
|
||||
backgroundColor: "hsl(215, 87%, 51%, 0.05)",
|
||||
},
|
||||
...error_border_style,
|
||||
}}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDragLeave}
|
||||
>
|
||||
<TextField
|
||||
id={id}
|
||||
type="file"
|
||||
onChange={onChange}
|
||||
sx={{
|
||||
width: "100%",
|
||||
"& .MuiInputBase-input[type='file']": {
|
||||
opacity: 0,
|
||||
cursor: "pointer",
|
||||
maxWidth: "500px",
|
||||
minHeight: "175px",
|
||||
zIndex: 1,
|
||||
},
|
||||
"& fieldset": {
|
||||
padding: 0,
|
||||
border: "none",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack
|
||||
className="custom-file-text"
|
||||
alignItems="center"
|
||||
gap="4px"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
zIndex: 0,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
sx={{
|
||||
pointerEvents: "none",
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
border: `solid ${theme.shape.borderThick}px ${theme.palette.primary.lowContrast}`,
|
||||
boxShadow: theme.shape.boxShadow,
|
||||
}}
|
||||
>
|
||||
<CloudUploadIcon />
|
||||
</IconButton>
|
||||
<Typography
|
||||
component="h2"
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
fontSize="inherit"
|
||||
color="info"
|
||||
fontWeight={500}
|
||||
>
|
||||
Click to upload
|
||||
</Typography>{" "}
|
||||
or drag and drop
|
||||
</Typography>
|
||||
<Typography
|
||||
component="p"
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
sx={{ opacity: 0.6 }}
|
||||
>
|
||||
(maximum size: {maxSize ?? "3MB"})
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Typography
|
||||
component="p"
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
sx={{ opacity: 0.6 }}
|
||||
>
|
||||
Supported formats: JPG, PNG
|
||||
</Typography>
|
||||
{error && (
|
||||
<Typography
|
||||
component="span"
|
||||
className="input-error"
|
||||
color={theme.palette.error.main}
|
||||
mt={theme.spacing(2)}
|
||||
sx={{
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: "250px",
|
||||
height: "250px",
|
||||
overflow: "hidden",
|
||||
backgroundImage: `url(${src})`,
|
||||
backgroundSize: "cover",
|
||||
...roundShape,
|
||||
}}
|
||||
></Box>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ImageField.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
src: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
isRound: PropTypes.bool,
|
||||
maxSize: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ImageField;
|
||||
243
client/src/Components/Inputs/ImageUpload/index.jsx
Normal file
243
client/src/Components/Inputs/ImageUpload/index.jsx
Normal file
@@ -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<string>} [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 ? (
|
||||
<Stack direction="row" justifyContent="center">
|
||||
<Image
|
||||
alt="Uploaded preview"
|
||||
src={src}
|
||||
width="250px"
|
||||
height="250px"
|
||||
sx={{ ...roundStyle }}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<Box
|
||||
className="image-field-wrapper"
|
||||
mt={theme.spacing(8)}
|
||||
onDragEnter={() => 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)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
type="file"
|
||||
onChange={(e) => 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",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack
|
||||
alignItems="center"
|
||||
gap="4px"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
zIndex: 0,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
sx={{
|
||||
pointerEvents: "none",
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
border: `solid ${theme.shape.borderThick}px ${theme.palette.primary.lowContrast}`,
|
||||
boxShadow: theme.shape.boxShadow,
|
||||
}}
|
||||
>
|
||||
<CloudUploadIcon />
|
||||
</IconButton>
|
||||
<Typography
|
||||
component="h2"
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
fontSize="inherit"
|
||||
color="info"
|
||||
fontWeight={500}
|
||||
>
|
||||
{t('ClickUpload')}
|
||||
</Typography>{" "}
|
||||
or {t('DragandDrop')}
|
||||
</Typography>
|
||||
<Typography
|
||||
component="p"
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
sx={{ opacity: 0.6 }}
|
||||
>({t('MaxSize')}: {Math.round(maxSize / 1024 / 1024)}MB)
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
{(localError || progress.isLoading || progress.value !== 0) && (
|
||||
<ProgressUpload
|
||||
icon={<ImageIcon />}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
<Typography
|
||||
component="p"
|
||||
color={theme.palette.primary.contrastTextTertiary}
|
||||
sx={{ opacity: 0.6 }}
|
||||
>
|
||||
{t('SupportedFormats')}: {accept.join(", ").toUpperCase()}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ImageUpload.propTypes = {
|
||||
previewIsRound: PropTypes.bool,
|
||||
src: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
maxSize: PropTypes.number,
|
||||
accept: PropTypes.array,
|
||||
error: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ImageUpload;
|
||||
@@ -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 */}
|
||||
<Box flex={0.9}>
|
||||
<Typography component="h1">First name</Typography>
|
||||
<Typography component="h1">{t('FirstName')}</Typography>
|
||||
</Box>
|
||||
<TextInput
|
||||
id="edit-first-name"
|
||||
@@ -250,7 +223,7 @@ const ProfilePanel = () => {
|
||||
gap={SPACING_GAP}
|
||||
>
|
||||
<Box flex={0.9}>
|
||||
<Typography component="h1">Last name</Typography>
|
||||
<Typography component="h1">{t('LastName')}</Typography>
|
||||
</Box>
|
||||
<TextInput
|
||||
id="edit-last-name"
|
||||
@@ -268,12 +241,12 @@ const ProfilePanel = () => {
|
||||
gap={SPACING_GAP}
|
||||
>
|
||||
<Stack flex={0.9}>
|
||||
<Typography component="h1">Email</Typography>
|
||||
<Typography component="h1">{t('email')}</Typography>
|
||||
<Typography
|
||||
component="p"
|
||||
sx={{ opacity: 0.6 }}
|
||||
>
|
||||
This is your current email address — it cannot be changed.
|
||||
{t('EmailDescriptionText')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<TextInput
|
||||
@@ -291,12 +264,12 @@ const ProfilePanel = () => {
|
||||
gap={SPACING_GAP}
|
||||
>
|
||||
<Stack flex={0.9}>
|
||||
<Typography component="h1">Your photo</Typography>
|
||||
<Typography component="h1">{t('YourPhoto')}</Typography>
|
||||
<Typography
|
||||
component="p"
|
||||
sx={{ opacity: 0.6 }}
|
||||
>
|
||||
This photo will be displayed in your profile page.
|
||||
{t('PhotoDescriptionText')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
@@ -320,14 +293,14 @@ const ProfilePanel = () => {
|
||||
color="error"
|
||||
onClick={handleDeletePicture}
|
||||
>
|
||||
Delete
|
||||
{t('delete')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained" // CAIO_REVIEW
|
||||
color="accent"
|
||||
onClick={openPictureModal}
|
||||
>
|
||||
Update
|
||||
{t('update')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
@@ -352,7 +325,7 @@ const ProfilePanel = () => {
|
||||
disabled={Object.keys(errors).length !== 0 && !errors?.picture && true}
|
||||
sx={{ px: theme.spacing(12) }}
|
||||
>
|
||||
Save
|
||||
{t('save')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
@@ -371,13 +344,12 @@ const ProfilePanel = () => {
|
||||
spellCheck="false"
|
||||
>
|
||||
<Box mb={theme.spacing(6)}>
|
||||
<Typography component="h1">Delete account</Typography>
|
||||
<Typography component="h1">{t('DeleteAccount')}</Typography>
|
||||
<Typography
|
||||
component="p"
|
||||
sx={{ opacity: 0.6 }}
|
||||
>
|
||||
Note that deleting your account will remove all data from the server. This
|
||||
is permanent and non-recoverable.
|
||||
{t('DeleteDescriptionText')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
@@ -385,19 +357,17 @@ const ProfilePanel = () => {
|
||||
color="error"
|
||||
onClick={() => setIsOpen("delete")}
|
||||
>
|
||||
Delete account
|
||||
{t('DeleteAccount')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<Dialog
|
||||
open={isModalOpen("delete")}
|
||||
theme={theme}
|
||||
title={"Really delete this account?"}
|
||||
description={
|
||||
"If you delete your account, you will no longer be able to sign in, and all of your data will be deleted. Deleting your account is permanent and non-recoverable action."
|
||||
}
|
||||
title={t('DeleteWarningTitle')}
|
||||
description={t('DeleteAccountWarning')}
|
||||
onCancel={() => setIsOpen("")}
|
||||
confirmationButtonLabel={"Delete account"}
|
||||
confirmationButtonLabel={t('DeleteAccount')}
|
||||
onConfirm={handleDeleteAccount}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
@@ -408,34 +378,27 @@ const ProfilePanel = () => {
|
||||
onClose={closePictureModal}
|
||||
theme={theme}
|
||||
>
|
||||
<ImageField
|
||||
id="update-profile-picture"
|
||||
<ImageUpload
|
||||
src={
|
||||
file?.delete
|
||||
? ""
|
||||
: file?.src
|
||||
? file.src
|
||||
file?.src
|
||||
? file.src
|
||||
: localData?.deleteProfileImage
|
||||
? ""
|
||||
: localData?.file
|
||||
? localData.file
|
||||
: user?.avatarImage
|
||||
? `data:image/png;base64,${user.avatarImage}`
|
||||
: ""
|
||||
}
|
||||
loading={progress.isLoading && progress.value !== 100}
|
||||
onChange={handlePicture}
|
||||
}
|
||||
onChange={(newFile) => {
|
||||
if (newFile) {
|
||||
setFile(newFile);
|
||||
clearError("unchanged");
|
||||
}
|
||||
}}
|
||||
previewIsRound
|
||||
maxSize={3 * 1024 * 1024}
|
||||
/>
|
||||
{progress.isLoading || progress.value !== 0 || errors["picture"] ? (
|
||||
<ProgressUpload
|
||||
icon={<ImageIcon />}
|
||||
label={file?.name}
|
||||
size={file?.size}
|
||||
progress={progress.value}
|
||||
onClick={removePicture}
|
||||
error={errors["picture"]}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<Stack
|
||||
direction="row"
|
||||
mt={theme.spacing(10)}
|
||||
@@ -447,20 +410,15 @@ const ProfilePanel = () => {
|
||||
color="info"
|
||||
onClick={removePicture}
|
||||
>
|
||||
Remove
|
||||
{t('remove')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="accent"
|
||||
onClick={handleUpdatePicture}
|
||||
disabled={
|
||||
(Object.keys(errors).length !== 0 && errors?.picture) ||
|
||||
progress.value !== 100
|
||||
? true
|
||||
: false
|
||||
}
|
||||
disabled={!!errors.picture || !file?.src}
|
||||
>
|
||||
Update
|
||||
{t('update')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</GenericDialog>
|
||||
|
||||
@@ -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 = ({
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack gap={theme.spacing(6)}>
|
||||
<ImageField
|
||||
id="logo"
|
||||
<ImageUpload
|
||||
src={form?.logo?.src}
|
||||
isRound={false}
|
||||
onChange={handleImageChange}
|
||||
previewIsRound={false}
|
||||
/>
|
||||
<Progress
|
||||
isLoading={progress.isLoading}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Stack, Button, Typography } from "@mui/material";
|
||||
import Tabs from "./Components/Tabs";
|
||||
import GenericFallback from "../../../Components/GenericFallback";
|
||||
import SkeletonLayout from "./Components/Skeleton";
|
||||
import Breadcrumbs from "../../../Components/Breadcrumbs/index.jsx";
|
||||
//Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
@@ -59,19 +58,6 @@ const CreateStatusPage = () => {
|
||||
|
||||
const [statusPage, statusPageMonitors, statusPageIsLoading, statusPageNetworkError] =
|
||||
useStatusPageFetch(isCreate, url);
|
||||
|
||||
// Breadcrumbs
|
||||
const crumbs = [
|
||||
{ name: t("statusBreadCrumbsStatusPages"), path: "/status" },
|
||||
];
|
||||
if (isCreate) {
|
||||
crumbs.push({ name: t("statusBreadCrumbsCreate"), path: "/status/uptime/create" });
|
||||
} else {
|
||||
crumbs.push(
|
||||
{ name: t("statusBreadCrumbsDetails"), path: `/status/uptime/${statusPage?.url}` },
|
||||
{ name: t("configure"), path: `/status/uptime/configure/${statusPage?.url}` }
|
||||
);
|
||||
}
|
||||
|
||||
// Handlers
|
||||
const handleFormChange = (e) => {
|
||||
@@ -101,30 +87,31 @@ const CreateStatusPage = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleImageChange = useCallback((event) => {
|
||||
const img = event.target?.files?.[0];
|
||||
const newLogo = {
|
||||
src: URL.createObjectURL(img),
|
||||
name: img.name,
|
||||
type: img.type,
|
||||
size: img.size,
|
||||
};
|
||||
const handleImageChange = useCallback((fileObj) => {
|
||||
if (!fileObj || !fileObj.file) return;
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
logo: newLogo,
|
||||
...prev,
|
||||
logo: {
|
||||
src: fileObj.src,
|
||||
name: fileObj.name,
|
||||
type: fileObj.file.type,
|
||||
size: fileObj.file.size,
|
||||
},
|
||||
}));
|
||||
|
||||
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 };
|
||||
});
|
||||
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);
|
||||
}, []);
|
||||
|
||||
|
||||
const removeLogo = () => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
@@ -232,7 +219,6 @@ const CreateStatusPage = () => {
|
||||
// Load fields
|
||||
return (
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<Breadcrumbs list={crumbs} />
|
||||
<Tabs
|
||||
form={form}
|
||||
errors={errors}
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
"signUP": "Sign Up",
|
||||
"now": "Now",
|
||||
"delete": "Delete",
|
||||
"update": "Update",
|
||||
"configure": "Configure",
|
||||
"networkError": "Network error",
|
||||
"responseTime": "Response time:",
|
||||
@@ -376,6 +377,22 @@
|
||||
"errorInvalidTypeId": "Invalid notification type provided",
|
||||
"errorInvalidFieldId": "Invalid field ID provided",
|
||||
"inviteNoTokenFound": "No invite token found",
|
||||
"invalidFileFormat": "Unsupported file format!",
|
||||
"invalidFileSize": "File size is too large!",
|
||||
"ClickUpload": "Click to upload",
|
||||
"DragandDrop": "drag and drop",
|
||||
"MaxSize": "Maximum Size",
|
||||
"SupportedFormats": "Supported formats",
|
||||
"FirstName": "First name",
|
||||
"LastName": "Last name",
|
||||
"EmailDescriptionText": "This is your current email address — it cannot be changed.",
|
||||
"YourPhoto": "Your photo",
|
||||
"PhotoDescriptionText": "This photo will be displayed in your profile page.",
|
||||
"save": "Save",
|
||||
"DeleteAccount": "Delete account",
|
||||
"DeleteDescriptionText": "Note that deleting your account will remove all data from the server. This is permanent and non-recoverable.",
|
||||
"DeleteAccountWarning": "If you delete your account, you will no longer be able to sign in, and all of your data will be deleted. Deleting your account is permanent and non-recoverable action.",
|
||||
"DeleteWarningTitle": "Really delete this account?",
|
||||
"pageSpeedWarning": "Warning: You haven't added a Google PageSpeed API key. Without it, the PageSpeed monitor won't function.",
|
||||
"pageSpeedLearnMoreLink": "Click here to learn",
|
||||
"pageSpeedAddApiKey": "how to add your API key."
|
||||
|
||||
Reference in New Issue
Block a user