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
-
-
- }
- onClick={onSelectImage}>
- {L("account.change_picture")}
-
- {profile.profilePicture &&
+ return <>
+
+
+
} color={"error"}
- onClick={() => setDialogData({
- show: true,
- title: L("account.picture_remove_title"),
- message: L("account.picture_remove_text"),
- options: [L("general.confirm"), L("general.cancel")],
- onOption: (option) => option === 1 ? onRemoveImage() : true
- })}>
- {L("account.remove_picture")}
+ startIcon={}
+ onClick={onSelectImage}>
+ {L("account.change_picture")}
- }
-
-
+ {profile.profilePicture &&
+ } color={"error"}
+ onClick={() => setDialogData({
+ show: true,
+ title: L("account.remove_picture"),
+ message: L("account.remove_picture_text"),
+ options: [L("general.cancel"), L("general.confirm")],
+ onOption: (option) => option === 1 ? onRemoveImage() : true
+ })}>
+ {L("account.remove_picture")}
+
+ }
+
+
+
+ >
}
\ 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"