Profile picture frontend + backend
This commit is contained in:
parent
76cd92ee0e
commit
59818eb321
57
Core/API/Parameter/FloatType.class.php
Normal file
57
Core/API/Parameter/FloatType.class.php
Normal file
@ -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 {
|
||||
|
||||
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();
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
|
@ -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 <ProfilePictureBox {...other}>
|
||||
<ProfilePicture user={profile} onClick={onSelectImage} />
|
||||
<VerticalButtonBar>
|
||||
<Button variant="outlined" size="small"
|
||||
startIcon={<Edit />}
|
||||
onClick={onSelectImage}>
|
||||
{L("account.change_picture")}
|
||||
</Button>
|
||||
{profile.profilePicture &&
|
||||
return <>
|
||||
<ProfilePictureBox {...other}>
|
||||
<ProfilePicture user={profile} onClick={onSelectImage} />
|
||||
<VerticalButtonBar>
|
||||
<Button variant="outlined" size="small"
|
||||
startIcon={<Delete />} 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={<Edit />}
|
||||
onClick={onSelectImage}>
|
||||
{L("account.change_picture")}
|
||||
</Button>
|
||||
}
|
||||
</VerticalButtonBar>
|
||||
</ProfilePictureBox>
|
||||
{profile.profilePicture &&
|
||||
<Button variant="outlined" size="small"
|
||||
startIcon={<Delete />} 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")}
|
||||
</Button>
|
||||
}
|
||||
</VerticalButtonBar>
|
||||
</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;
|
||||
}
|
||||
|
||||
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) {
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user