From 59818eb321e0c74089050dea0936aa3a5db1e879 Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 3 May 2024 20:22:58 +0200 Subject: [PATCH] Profile picture frontend + backend --- Core/API/Parameter/FloatType.class.php | 57 +++++ Core/API/UserAPI.class.php | 74 ++---- Core/Localization/de_DE/account.php | 5 + Core/Localization/en_US/account.php | 5 + react/admin-panel/package.json | 5 +- .../src/views/profile/edit-picture.js | 210 +++++++++++------- react/shared/api.js | 6 +- react/shared/elements/profile-picture.js | 2 + react/yarn.lock | 5 + 9 files changed, 234 insertions(+), 135 deletions(-) create mode 100644 Core/API/Parameter/FloatType.class.php diff --git a/Core/API/Parameter/FloatType.class.php b/Core/API/Parameter/FloatType.class.php new file mode 100644 index 0000000..d3f8c41 --- /dev/null +++ b/Core/API/Parameter/FloatType.class.php @@ -0,0 +1,57 @@ +minValue = $minValue; + $this->maxValue = $maxValue; + parent::__construct($name, Parameter::TYPE_FLOAT, $optional, $defaultValue, $choices); + } + + public function parseParam($value): bool { + if (!parent::parseParam($value)) { + return false; + } + + $this->value = $value; + if ($this->value < $this->minValue || $this->value > $this->maxValue) { + return false; + } + + return true; + } + + public function getTypeName(): string { + $typeName = parent::getTypeName(); + $hasMin = $this->minValue > PHP_FLOAT_MIN; + $hasMax = $this->maxValue < PHP_FLOAT_MAX; + + if ($hasMin || $hasMax) { + if ($hasMin && $hasMax) { + $typeName .= " ($this->minValue - $this->maxValue)"; + } else if ($hasMin) { + $typeName .= " (> $this->minValue)"; + } else if ($hasMax) { + $typeName .= " (< $this->maxValue)"; + } + } + + return $typeName; + } + + public function toString(): string { + $typeName = $this->getTypeName(); + $str = "$typeName $this->name"; + $defaultValue = (is_null($this->value) ? 'NULL' : $this->value); + if ($this->optional) { + $str = "[$str = $defaultValue]"; + } + + return $str; + } +} \ No newline at end of file diff --git a/Core/API/UserAPI.class.php b/Core/API/UserAPI.class.php index c0b3d11..5a26c4f 100644 --- a/Core/API/UserAPI.class.php +++ b/Core/API/UserAPI.class.php @@ -121,6 +121,8 @@ namespace Core\API { namespace Core\API\User { use Core\API\Parameter\ArrayType; + use Core\API\Parameter\FloatType; + use Core\API\Parameter\IntegerType; use Core\API\Parameter\Parameter; use Core\API\Parameter\StringType; use Core\API\Template\Render; @@ -1311,10 +1313,15 @@ namespace Core\API\User { } class UploadPicture extends UserAPI { + + const MIN_SIZE = 150; + const MAX_SIZE = 800; + public function __construct(Context $context, bool $externalCall = false) { - // TODO: we should optimize the process here, we need an offset and size parameter to get a quadratic crop of the uploaded image parent::__construct($context, $externalCall, [ - "scale" => new Parameter("scale", Parameter::TYPE_FLOAT, true, NULL), + "x" => new FloatType("x", 0, PHP_FLOAT_MAX, true, NULL), + "y" => new FloatType("y", 0, PHP_FLOAT_MAX, true, NULL), + "size" => new FloatType("size", self::MIN_SIZE, self::MAX_SIZE, true, NULL), ]); $this->loginRequired = true; $this->forbidMethod("GET"); @@ -1325,64 +1332,31 @@ namespace Core\API\User { */ protected function onTransform(\Imagick $im, $uploadDir): bool|string { - $minSize = 75; - $maxSize = 500; - $width = $im->getImageWidth(); $height = $im->getImageHeight(); - $doResize = false; + $maxPossibleSize = min($width, $height); - if ($width < $minSize || $height < $minSize) { - if ($width < $height) { - $newWidth = $minSize; - $newHeight = intval(($minSize / $width) * $height); - } else { - $newHeight = $minSize; - $newWidth = intval(($minSize / $height) * $width); - } + $cropX = $this->getParam("x"); + $cropY = $this->getParam("y"); + $cropSize = $this->getParam("size") ?? $maxPossibleSize; - $doResize = true; - } else if ($width > $maxSize || $height > $maxSize) { - if ($width > $height) { - $newWidth = $maxSize; - $newHeight = intval($height * ($maxSize / $width)); - } else { - $newHeight = $maxSize; - $newWidth = intval($width * ($maxSize / $height)); - } - - $doResize = true; - } else { - $newWidth = $width; - $newHeight = $height; + if ($maxPossibleSize < self::MIN_SIZE) { + return $this->createError("Image must be at least " . self::MIN_SIZE . "x" . self::MIN_SIZE); + } else if ($cropSize > self::MAX_SIZE) { + return $this->createError("Crop must be at most " . self::MAX_SIZE . "x" . self::MAX_SIZE); + } else if ($cropSize > $maxPossibleSize) { + return $this->createError("Invalid crop size"); } - if ($width < $minSize || $height < $minSize) { - return $this->createError("Error processing image. Bad dimensions."); + if ($cropX === null) { + $cropX = ($width > $height) ? ($width - $height) / 2 : 0; } - if ($doResize) { - $width = $newWidth; - $height = $newHeight; - $im->resizeImage($width, $height, \Imagick::FILTER_SINC, 1); - } - - $size = $this->getParam("size"); - if (is_null($size)) { - $size = min($width, $height); - } - - $offset = [$this->getParam("offsetX"), $this->getParam("offsetY")]; - if ($size < $minSize or $size > $maxSize) { - return $this->createError("Invalid size. Must be in range of $minSize-$maxSize."); - }/* else if ($offset[0] < 0 || $offset[1] < 0 || $offset[0]+$size > $width || $offset[1]+$size > $height) { - return $this->createError("Offsets out of bounds."); - }*/ - - if ($offset[0] !== 0 || $offset[1] !== 0 || $size !== $width || $size !== $height) { - $im->cropImage($size, $size, $offset[0], $offset[1]); + if ($cropY === null) { + $cropY = ($height > $width) ? ($height - $width) / 2 : 0; } + $im->cropImage($cropSize, $cropSize, $cropX, $cropY); $fileName = uuidv4() . ".jpg"; $im->writeImage("$uploadDir/$fileName"); $im->destroy(); diff --git a/Core/Localization/de_DE/account.php b/Core/Localization/de_DE/account.php index 603b938..7e6cb5d 100644 --- a/Core/Localization/de_DE/account.php +++ b/Core/Localization/de_DE/account.php @@ -58,8 +58,13 @@ return [ "user_list_placeholder" => "Keine Benutzer zum Anzeigen", # profile picture + "remove_picture" => "Profilbild entfernen", + "remove_picture_text" => "Möchten Sie wirklich Ihr aktuelles Profilbild entfernen?", "change_picture" => "Profilbild ändern", "profile_picture_of" => "Profilbild von", + "change_picture_title" => "Profilbild ändern", + "change_picture_text" => "Wähle ein Profilbild aus und passe die sichtbare Fläche an.", + "profile_picture_invalid_dimensions" => "Das Profilbild muss mindestens 150x150px groß sein.", # dialogs "fetch_group_members_error" => "Fehler beim Holen der Gruppenmitglieder", diff --git a/Core/Localization/en_US/account.php b/Core/Localization/en_US/account.php index 4454a39..31a8b8b 100644 --- a/Core/Localization/en_US/account.php +++ b/Core/Localization/en_US/account.php @@ -60,8 +60,13 @@ return [ "user_list_placeholder" => "No users to display", # profile picture + "remove_picture" => "Remove profile picture", + "remove_picture_text" => "Do you really want to remove your current profile picture?", "change_picture" => "Change profile picture", "profile_picture_of" => "Profile Picture of", + "change_picture_title" => "Change Profile picture", + "change_picture_text" => "Choose a profile picture and adjust the visible area.", + "profile_picture_invalid_dimensions" => "The profile picture must have at least a size of 150x150.", # dialogs "fetch_group_members_error" => "Error fetching group members", diff --git a/react/admin-panel/package.json b/react/admin-panel/package.json index 782a195..b323fb9 100644 --- a/react/admin-panel/package.json +++ b/react/admin-panel/package.json @@ -2,8 +2,9 @@ "name": "admin-panel", "version": "1.0.0", "dependencies": { - "shared": "link:../shared", - "react": "^18.2.0" + "react": "^18.2.0", + "react-image-crop": "^11.0.5", + "shared": "link:../shared" }, "scripts": { "dev": "HTTPS=true react-app-rewired start" diff --git a/react/admin-panel/src/views/profile/edit-picture.js b/react/admin-panel/src/views/profile/edit-picture.js index add3b3c..b15e28c 100644 --- a/react/admin-panel/src/views/profile/edit-picture.js +++ b/react/admin-panel/src/views/profile/edit-picture.js @@ -1,16 +1,32 @@ -import {Box, Button, CircularProgress, Slider, styled} from "@mui/material"; -import {useCallback, useContext, useRef, useState} from "react"; +import { + Box, + Button, + CircularProgress, + Dialog, DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + styled, TextField +} from "@mui/material"; +import {useCallback, useContext, useState} from "react"; import {LocaleContext} from "shared/locale"; -import PreviewProfilePicture from "./preview-picture"; -import {Delete, Edit} from "@mui/icons-material"; +import {Delete, Edit, Upload} from "@mui/icons-material"; import ProfilePicture from "shared/elements/profile-picture"; +import ReactCrop from 'react-image-crop' + +import 'react-image-crop/dist/ReactCrop.css'; const ProfilePictureBox = styled(Box)((props) => ({ - padding: props.theme.spacing(2), + padding: props.theme.spacing(1), display: "grid", - gridTemplateRows: "auto 60px", - gridGap: props.theme.spacing(2), + gridTemplateRows: "auto calc(110px - " + props.theme.spacing(1) + ")", textAlign: "center", + alignItems: "center", + justifyItems: "center", + "& img": { + maxHeight: 150, + width: "auto", + } })); const VerticalButtonBar = styled(Box)((props) => ({ @@ -24,67 +40,46 @@ export default function EditProfilePicture(props) { // meta const {translate: L} = useContext(LocaleContext); - // const [scale, setScale] = useState(100); - const scale = useRef(100); const {api, showDialog, setProfile, profile, setDialogData, ...other} = props - const onUploadPicture = useCallback((data) => { - api.uploadPicture(data, scale.current / 100.0).then((res) => { - if (!res.success) { - showDialog(res.msg, L("Error uploading profile picture")); - } else { - setProfile({...profile, profilePicture: res.profilePicture}); - } - }) - }, [api, scale.current, showDialog, profile]); + // data + const [crop, setCrop] = useState({ unit: 'px' }); + const [image, setImage] = useState({ loading: false, data: null, file: null }); + + // ui + const [isUploading, setUploading] = useState(false); + + const onCloseDialog = useCallback((event = null, reason = null) => { + if (!reason || !["backdropClick", "escapeKeyDown"].includes(reason)) { + setImage({loading: false, data: null, file: null}); + } + }, []); + + const onUploadPicture = useCallback(() => { + if (!isUploading) { + setUploading(true); + api.uploadPicture(image.file, crop.width, crop.x, crop.y).then(res => { + setUploading(false); + if (res.success) { + onCloseDialog(); + setProfile({...profile, profilePicture: res.profilePicture}); + } else { + showDialog(res.msg, L("account.upload_profile_picture_error")); + } + }) + } + }, [api, image, crop, isUploading, showDialog, profile, onCloseDialog]); const onRemoveImage = useCallback(() => { api.removePicture().then((res) => { if (!res.success) { - showDialog(res.msg, L("Error removing profile picture")); + showDialog(res.msg, L("account.remove_profile_picture_error")); } else { setProfile({...profile, profilePicture: null}); } }); }, [api, showDialog, profile]); - const onOpenDialog = useCallback((file = null, data = null) => { - - let img = null; - if (data !== null) { - img = new Image(); - img.src = data; - } - - setDialogData({ - show: true, - title: L("account.change_picture_title"), - text: L("account.change_picture_text"), - options: data === null ? [L("general.cancel")] : [L("general.apply"), L("general.cancel")], - inputs: data === null ? [{ - key: "pfp-loading", - type: "custom", - element: CircularProgress, - }] : [ - { - key: "pfp-preview", - type: "custom", - element: PreviewProfilePicture, - img: img, - scale: scale.current, - setScale: (v) => scale.current = v, - }, - ], - onOption: (option) => { - if (option === 1 && file) { - onUploadPicture(file) - } - - // scale.current = 100; - } - }) - }, [setDialogData, onUploadPicture]); - const onSelectImage = useCallback(() => { let fileInput = document.createElement("input"); fileInput.type = "file"; @@ -94,38 +89,91 @@ export default function EditProfilePicture(props) { if (file) { let reader = new FileReader(); reader.onload = function (e) { - onOpenDialog(file, e.target.result); + const imageData = e.target.result; + const img = new Image(); + img.src = imageData; + img.onload = () => { + let croppedSize; + if (img.width > img.height) { + croppedSize = Math.min(800, img.height); + setCrop({ x: (img.width - img.height) / 2, y: 0, unit: "px", width: croppedSize, height: croppedSize }); + } else if (img.width < img.height) { + croppedSize = Math.min(800, img.width); + setCrop({ x: 0, y: (img.height - img.width) / 2, unit: "px", width: croppedSize, height: croppedSize }); + } else { + croppedSize = Math.min(800, img.width); + setCrop({ x: 0, y: 0, unit: "px", width: croppedSize, height: croppedSize }); + } + + if (croppedSize < 150) { + setImage({ loading: false, file: null, data: null }); + showDialog(L("account.profile_picture_invalid_dimensions"), L("general.error")); + } else { + setImage({ loading: false, file: file, data: imageData }); + } + } } - onOpenDialog(); + setImage({ file: null, data: null, loading: true }); reader.readAsDataURL(file); } }; fileInput.click(); - }, [onOpenDialog]); + }, [showDialog]); - - return - - - - {profile.profilePicture && + return <> + + + - } - - + {profile.profilePicture && + + } + + + + + {L("account.change_picture_title")} + + + + {L("account.change_picture_text")} + + {image.data ? + setCrop(c)} crop={crop} keepSelection={true} + aspect={1} circularCrop={true} disabled={isUploading} + maxWidth={800} maxHeight={800} minWidth={150} minHeight={150}> + {"preview"} + : + + } + + + + + + + } \ No newline at end of file diff --git a/react/shared/api.js b/react/shared/api.js index 4955bef..7a5d21b 100644 --- a/react/shared/api.js +++ b/react/shared/api.js @@ -187,9 +187,11 @@ export default class API { return res; } - async uploadPicture(file, scale=1.0) { + async uploadPicture(file, size, x = 0, y = 0) { const formData = new FormData(); - formData.append("scale", scale); + formData.append("size", size); + formData.append("x", x); + formData.append("y", y); formData.append("picture", file, file.name); let res = await this.apiCall("user/uploadPicture", formData); if (res.success) { diff --git a/react/shared/elements/profile-picture.js b/react/shared/elements/profile-picture.js index 0d99327..b96a74f 100644 --- a/react/shared/elements/profile-picture.js +++ b/react/shared/elements/profile-picture.js @@ -13,6 +13,8 @@ const PicturePlaceholderBox = styled(Box)((props) => ({ alignItems: "center", background: "radial-gradient(circle closest-side, gray 98%, transparent 100%);", containerType: "inline-size", + width: "100%", + height: "100%", "& > span": { textAlign: "center", fontSize: "30cqw", diff --git a/react/yarn.lock b/react/yarn.lock index 5bcf891..db9dc75 100644 --- a/react/yarn.lock +++ b/react/yarn.lock @@ -9112,6 +9112,11 @@ react-error-overlay@^6.0.11: resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb" integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg== +react-image-crop@^11.0.5: + version "11.0.5" + resolved "https://registry.yarnpkg.com/react-image-crop/-/react-image-crop-11.0.5.tgz#c7abcf9cae28305d253d55d481158a594a937867" + integrity sha512-A/Y/kspOzki1zDL/bSgwWIY1X3CQ9F1QwpdnncWLBVAktnKfAZDIQnWmjXzuzEjZHDMsBlArytIcPBVi6DNklg== + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"