Profile picture frontend + backend

This commit is contained in:
Roman 2024-05-03 20:22:58 +02:00
parent 76cd92ee0e
commit 59818eb321
9 changed files with 234 additions and 135 deletions

@ -0,0 +1,57 @@
<?php
namespace Core\API\Parameter;
class FloatType extends Parameter {
public float $minValue;
public float $maxValue;
public function __construct(string $name, float $minValue = PHP_FLOAT_MIN, float $maxValue = PHP_FLOAT_MAX,
bool $optional = FALSE, ?float $defaultValue = NULL, ?array $choices = NULL) {
$this->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;
}
}

@ -121,6 +121,8 @@ namespace Core\API {
namespace Core\API\User { namespace Core\API\User {
use Core\API\Parameter\ArrayType; use Core\API\Parameter\ArrayType;
use Core\API\Parameter\FloatType;
use Core\API\Parameter\IntegerType;
use Core\API\Parameter\Parameter; use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType; use Core\API\Parameter\StringType;
use Core\API\Template\Render; use Core\API\Template\Render;
@ -1311,10 +1313,15 @@ namespace Core\API\User {
} }
class UploadPicture extends UserAPI { class UploadPicture extends UserAPI {
const MIN_SIZE = 150;
const MAX_SIZE = 800;
public function __construct(Context $context, bool $externalCall = false) { 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, [ 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->loginRequired = true;
$this->forbidMethod("GET"); $this->forbidMethod("GET");
@ -1325,64 +1332,31 @@ namespace Core\API\User {
*/ */
protected function onTransform(\Imagick $im, $uploadDir): bool|string { protected function onTransform(\Imagick $im, $uploadDir): bool|string {
$minSize = 75;
$maxSize = 500;
$width = $im->getImageWidth(); $width = $im->getImageWidth();
$height = $im->getImageHeight(); $height = $im->getImageHeight();
$doResize = false; $maxPossibleSize = min($width, $height);
if ($width < $minSize || $height < $minSize) { $cropX = $this->getParam("x");
if ($width < $height) { $cropY = $this->getParam("y");
$newWidth = $minSize; $cropSize = $this->getParam("size") ?? $maxPossibleSize;
$newHeight = intval(($minSize / $width) * $height);
} else { if ($maxPossibleSize < self::MIN_SIZE) {
$newHeight = $minSize; return $this->createError("Image must be at least " . self::MIN_SIZE . "x" . self::MIN_SIZE);
$newWidth = intval(($minSize / $height) * $width); } 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");
} }
$doResize = true; if ($cropX === null) {
} else if ($width > $maxSize || $height > $maxSize) { $cropX = ($width > $height) ? ($width - $height) / 2 : 0;
if ($width > $height) {
$newWidth = $maxSize;
$newHeight = intval($height * ($maxSize / $width));
} else {
$newHeight = $maxSize;
$newWidth = intval($width * ($maxSize / $height));
} }
$doResize = true; if ($cropY === null) {
} else { $cropY = ($height > $width) ? ($height - $width) / 2 : 0;
$newWidth = $width;
$newHeight = $height;
}
if ($width < $minSize || $height < $minSize) {
return $this->createError("Error processing image. Bad dimensions.");
}
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]);
} }
$im->cropImage($cropSize, $cropSize, $cropX, $cropY);
$fileName = uuidv4() . ".jpg"; $fileName = uuidv4() . ".jpg";
$im->writeImage("$uploadDir/$fileName"); $im->writeImage("$uploadDir/$fileName");
$im->destroy(); $im->destroy();

@ -58,8 +58,13 @@ return [
"user_list_placeholder" => "Keine Benutzer zum Anzeigen", "user_list_placeholder" => "Keine Benutzer zum Anzeigen",
# profile picture # profile picture
"remove_picture" => "Profilbild entfernen",
"remove_picture_text" => "Möchten Sie wirklich Ihr aktuelles Profilbild entfernen?",
"change_picture" => "Profilbild ändern", "change_picture" => "Profilbild ändern",
"profile_picture_of" => "Profilbild von", "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 # dialogs
"fetch_group_members_error" => "Fehler beim Holen der Gruppenmitglieder", "fetch_group_members_error" => "Fehler beim Holen der Gruppenmitglieder",

@ -60,8 +60,13 @@ return [
"user_list_placeholder" => "No users to display", "user_list_placeholder" => "No users to display",
# profile picture # 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", "change_picture" => "Change profile picture",
"profile_picture_of" => "Profile Picture of", "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 # dialogs
"fetch_group_members_error" => "Error fetching group members", "fetch_group_members_error" => "Error fetching group members",

@ -2,8 +2,9 @@
"name": "admin-panel", "name": "admin-panel",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"shared": "link:../shared", "react": "^18.2.0",
"react": "^18.2.0" "react-image-crop": "^11.0.5",
"shared": "link:../shared"
}, },
"scripts": { "scripts": {
"dev": "HTTPS=true react-app-rewired start" "dev": "HTTPS=true react-app-rewired start"

@ -1,16 +1,32 @@
import {Box, Button, CircularProgress, Slider, styled} from "@mui/material"; import {
import {useCallback, useContext, useRef, useState} from "react"; 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 {LocaleContext} from "shared/locale";
import PreviewProfilePicture from "./preview-picture"; import {Delete, Edit, Upload} from "@mui/icons-material";
import {Delete, Edit} from "@mui/icons-material";
import ProfilePicture from "shared/elements/profile-picture"; 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) => ({ const ProfilePictureBox = styled(Box)((props) => ({
padding: props.theme.spacing(2), padding: props.theme.spacing(1),
display: "grid", display: "grid",
gridTemplateRows: "auto 60px", gridTemplateRows: "auto calc(110px - " + props.theme.spacing(1) + ")",
gridGap: props.theme.spacing(2),
textAlign: "center", textAlign: "center",
alignItems: "center",
justifyItems: "center",
"& img": {
maxHeight: 150,
width: "auto",
}
})); }));
const VerticalButtonBar = styled(Box)((props) => ({ const VerticalButtonBar = styled(Box)((props) => ({
@ -24,67 +40,46 @@ export default function EditProfilePicture(props) {
// meta // meta
const {translate: L} = useContext(LocaleContext); const {translate: L} = useContext(LocaleContext);
// const [scale, setScale] = useState(100);
const scale = useRef(100);
const {api, showDialog, setProfile, profile, setDialogData, ...other} = props const {api, showDialog, setProfile, profile, setDialogData, ...other} = props
const onUploadPicture = useCallback((data) => { // data
api.uploadPicture(data, scale.current / 100.0).then((res) => { const [crop, setCrop] = useState({ unit: 'px' });
if (!res.success) { const [image, setImage] = useState({ loading: false, data: null, file: null });
showDialog(res.msg, L("Error uploading profile picture"));
} else { // 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}); setProfile({...profile, profilePicture: res.profilePicture});
} else {
showDialog(res.msg, L("account.upload_profile_picture_error"));
} }
}) })
}, [api, scale.current, showDialog, profile]); }
}, [api, image, crop, isUploading, showDialog, profile, onCloseDialog]);
const onRemoveImage = useCallback(() => { const onRemoveImage = useCallback(() => {
api.removePicture().then((res) => { api.removePicture().then((res) => {
if (!res.success) { if (!res.success) {
showDialog(res.msg, L("Error removing profile picture")); showDialog(res.msg, L("account.remove_profile_picture_error"));
} else { } else {
setProfile({...profile, profilePicture: null}); setProfile({...profile, profilePicture: null});
} }
}); });
}, [api, showDialog, profile]); }, [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(() => { const onSelectImage = useCallback(() => {
let fileInput = document.createElement("input"); let fileInput = document.createElement("input");
fileInput.type = "file"; fileInput.type = "file";
@ -94,18 +89,40 @@ export default function EditProfilePicture(props) {
if (file) { if (file) {
let reader = new FileReader(); let reader = new FileReader();
reader.onload = function (e) { 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 });
} }
onOpenDialog(); 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 });
}
}
}
setImage({ file: null, data: null, loading: true });
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
}; };
fileInput.click(); fileInput.click();
}, [onOpenDialog]); }, [showDialog]);
return <>
return <ProfilePictureBox {...other}> <ProfilePictureBox {...other}>
<ProfilePicture user={profile} onClick={onSelectImage} /> <ProfilePicture user={profile} onClick={onSelectImage} />
<VerticalButtonBar> <VerticalButtonBar>
<Button variant="outlined" size="small" <Button variant="outlined" size="small"
@ -118,9 +135,9 @@ export default function EditProfilePicture(props) {
startIcon={<Delete />} color={"error"} startIcon={<Delete />} color={"error"}
onClick={() => setDialogData({ onClick={() => setDialogData({
show: true, show: true,
title: L("account.picture_remove_title"), title: L("account.remove_picture"),
message: L("account.picture_remove_text"), message: L("account.remove_picture_text"),
options: [L("general.confirm"), L("general.cancel")], options: [L("general.cancel"), L("general.confirm")],
onOption: (option) => option === 1 ? onRemoveImage() : true onOption: (option) => option === 1 ? onRemoveImage() : true
})}> })}>
{L("account.remove_picture")} {L("account.remove_picture")}
@ -128,4 +145,35 @@ export default function EditProfilePicture(props) {
} }
</VerticalButtonBar> </VerticalButtonBar>
</ProfilePictureBox> </ProfilePictureBox>
<Dialog open={image.loading || image.data !== null} maxWidth={"lg"}
onClose={onCloseDialog}>
<DialogTitle>
{L("account.change_picture_title")}
</DialogTitle>
<DialogContent>
<DialogContentText>
{L("account.change_picture_text")}
</DialogContentText>
{image.data ?
<ReactCrop onChange={c => setCrop(c)} crop={crop} keepSelection={true}
aspect={1} circularCrop={true} disabled={isUploading}
maxWidth={800} maxHeight={800} minWidth={150} minHeight={150}>
<img src={image?.data} alt={"preview"} />
</ReactCrop> :
<CircularProgress />
}
</DialogContent>
<DialogActions>
<Button variant={"outlined"} color={"error"} onClick={onCloseDialog}
disabled={isUploading}>
{L("general.cancel")}
</Button>
<Button variant={"outlined"} type={"submit"} onClick={onUploadPicture}
disabled={isUploading}
startIcon={isUploading ? <CircularProgress size={12} /> : <Upload />}>
{L(isUploading ? "general.uploading" : "general.submit")}
</Button>
</DialogActions>
</Dialog>
</>
} }

@ -187,9 +187,11 @@ export default class API {
return res; return res;
} }
async uploadPicture(file, scale=1.0) { async uploadPicture(file, size, x = 0, y = 0) {
const formData = new FormData(); 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); formData.append("picture", file, file.name);
let res = await this.apiCall("user/uploadPicture", formData); let res = await this.apiCall("user/uploadPicture", formData);
if (res.success) { if (res.success) {

@ -13,6 +13,8 @@ const PicturePlaceholderBox = styled(Box)((props) => ({
alignItems: "center", alignItems: "center",
background: "radial-gradient(circle closest-side, gray 98%, transparent 100%);", background: "radial-gradient(circle closest-side, gray 98%, transparent 100%);",
containerType: "inline-size", containerType: "inline-size",
width: "100%",
height: "100%",
"& > span": { "& > span": {
textAlign: "center", textAlign: "center",
fontSize: "30cqw", fontSize: "30cqw",

@ -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" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb"
integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg== 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: react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"