Remake profile picture saving with Vue

Signed-off-by: Christopher Ng <chrng8@gmail.com>
pull/33409/head
Christopher Ng 2 years ago
parent f167fe0ceb
commit f44d2586b1

@ -93,69 +93,6 @@ input#openid, input#webdav {
background-image: var(--icon-password-dark);
}
#avatarform .avatardiv {
margin: 10px auto;
}
#avatarform .warning {
width: 100%;
}
#avatarform .jcrop-keymgr {
display: none !important;
}
#displayavatar {
text-align: center;
}
#uploadavatarbutton, #selectavatar, #removeavatar {
padding: 21px;
}
#selectavatar, #removeavatar {
vertical-align: top;
}
.jcrop-holder {
z-index: 500;
}
#cropper {
float: left;
z-index: 500;
/* float cropper above settings page to prevent unexpected flowing from dynamically sized element */
position: fixed;
background-color: rgba(0, 0, 0, 0.2);
box-sizing: border-box;
top: 45px;
left: 0;
width: 100%;
height: calc(100% - 45px);
}
#cropper .inner-container {
z-index: 2001;
/* above the top bar if needed */
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #fff;
color: #333;
border-radius: var(--border-radius-large);
box-shadow: 0 0 10px var(--color-box-shadow);
padding: 15px;
}
#cropper .inner-container .jcrop-holder,
#cropper .inner-container .jcrop-holder img,
#cropper .inner-container img.jcrop-preview {
border-radius: var(--border-radius);
}
#cropper .inner-container .button {
margin-top: 15px;
}
#cropper .inner-container .primary {
float: right;
}
#personal-settings-avatar-container {
display: inline-grid;
grid-template-columns: 1fr;

File diff suppressed because one or more lines are too long

@ -36,76 +36,6 @@ input {
@include icon-color('password', 'settings', variables.$color-black);
}
#avatarform {
.avatardiv {
margin: 10px auto;
}
.warning {
width: 100%;
}
.jcrop-keymgr {
display: none !important;
}
}
#displayavatar {
text-align: center;
}
#uploadavatarbutton, #selectavatar, #removeavatar {
padding: 21px;
}
#selectavatar, #removeavatar {
vertical-align: top;
}
.jcrop-holder {
z-index: 500;
}
#cropper {
float: left;
z-index: 500;
/* float cropper above settings page to prevent unexpected flowing from dynamically sized element */
position: fixed;
background-color: rgba(0, 0, 0, 0.2);
box-sizing: border-box;
top: 45px;
left: 0;
width: 100%;
height: calc(100% - 45px);
.inner-container {
z-index: 2001;
/* above the top bar if needed */
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #fff;
color: #333;
border-radius: var(--border-radius-large);
box-shadow: 0 0 10px var(--color-box-shadow);
padding: 15px;
.jcrop-holder,
.jcrop-holder img,
img.jcrop-preview {
border-radius: var(--border-radius);
}
.button {
margin-top: 15px;
}
.primary {
float: right;
}
}
}
#personal-settings-avatar-container {
display: inline-grid;
grid-template-columns: 1fr;

@ -41,107 +41,6 @@ jQuery.fn.keyUpDelayedOrEnter = function (callback, allowEmptyValue) {
});
};
function updateAvatar (hidedefault) {
var $headerdiv = $('#header .avatardiv'),
$displaydiv = $('#displayavatar .avatardiv'),
user = OC.getCurrentUser();
//Bump avatar avatarversion
oc_userconfig.avatar.version = -(Math.floor(Math.random() * 1000));
if (hidedefault) {
$headerdiv.hide();
$('#header .avatardiv').removeClass('avatardiv-shown');
} else {
$headerdiv.css({'background-color': ''});
$headerdiv.avatar(user.uid, 32, true, false, undefined, user.displayName);
$('#header .avatardiv').addClass('avatardiv-shown');
}
$displaydiv.css({'background-color': ''});
$displaydiv.avatar(user.uid, 145, true, null, function() {
$displaydiv.removeClass('loading');
$('#displayavatar img').show();
if($('#displayavatar img').length === 0 || oc_userconfig.avatar.generated) {
$('#removeavatar').removeClass('inlineblock').addClass('hidden');
} else {
$('#removeavatar').removeClass('hidden').addClass('inlineblock');
}
}, user.displayName);
$('#uploadavatar').prop('disabled', false);
}
function showAvatarCropper () {
var $cropper = $('#cropper');
var $cropperImage = $('<img/>');
$cropperImage.css('opacity', 0); // prevent showing the unresized image
$cropper.children('.inner-container').prepend($cropperImage);
$cropperImage.attr('src',
OC.generateUrl('/avatar/tmp') + '?requesttoken=' + encodeURIComponent(OC.requestToken) + '#' + Math.floor(Math.random() * 1000));
$cropperImage.load(function () {
var img = $cropperImage.get()[0];
var selectSize = Math.min(img.width, img.height);
var offsetX = (img.width - selectSize) / 2;
var offsetY = (img.height - selectSize) / 2;
$cropperImage.Jcrop({
onChange: saveCoords,
onSelect: saveCoords,
aspectRatio: 1,
boxHeight: Math.min(500, $('#app-content').height() -100),
boxWidth: Math.min(500, $('#app-content').width()),
setSelect: [offsetX, offsetY, selectSize, selectSize]
}, function() {
$cropper.show();
});
});
}
function sendCropData () {
cleanCropper();
var cropperData = $('#cropper').data();
var data = {
x: cropperData.x,
y: cropperData.y,
w: cropperData.w,
h: cropperData.h
};
$.post(OC.generateUrl('/avatar/cropped'), {crop: data}, avatarResponseHandler);
}
function saveCoords (c) {
$('#cropper').data(c);
}
function cleanCropper () {
var $cropper = $('#cropper');
$('#displayavatar').show();
$cropper.hide();
$('.jcrop-holder').remove();
$('#cropper img').removeData('Jcrop').removeAttr('style').removeAttr('src');
$('#cropper img').remove();
}
function avatarResponseHandler (data) {
if (typeof data === 'string') {
data = JSON.parse(data);
}
var $warning = $('#avatarform .warning');
$warning.hide();
if (data.status === "success") {
$('#displayavatar .avatardiv').removeClass('icon-loading');
oc_userconfig.avatar.generated = false;
updateAvatar();
} else if (data.data === "notsquare") {
cleanCropper();
showAvatarCropper();
} else {
$warning.show();
$warning.text(data.data.message);
}
}
window.addEventListener('DOMContentLoaded', function () {
if($('#pass2').length) {
$('#pass2').showPassword().keyup();
@ -208,9 +107,6 @@ window.addEventListener('DOMContentLoaded', function () {
showPublishedScope: !!settingsEl.data('lookup-server-upload-enabled'),
});
userSettings.on("sync", function() {
updateAvatar(false);
});
federationSettingsView.render();
var updateLanguage = function () {
@ -264,125 +160,6 @@ window.addEventListener('DOMContentLoaded', function () {
});
};
$("#localeinput").change(updateLocale);
var uploadparms = {
pasteZone: null,
done: function (e, data) {
var response = data;
if (typeof data.result === 'string') {
response = JSON.parse(data.result);
} else if (data.result && data.result.length) {
// fetch response from iframe
response = JSON.parse(data.result[0].body.innerText);
} else {
response = data.result;
}
avatarResponseHandler(response);
},
submit: function(e, data) {
$('#displayavatar img').hide();
$('#displayavatar .avatardiv').addClass('icon-loading');
$('#uploadavatar').prop('disabled', true)
data.formData = _.extend(data.formData || {}, {
requesttoken: OC.requestToken
});
},
fail: function (e, data) {
$('#displayavatar .avatardiv').removeClass('icon-loading');
$('#uploadavatar').prop('disabled', false)
var msg = data.jqXHR.statusText + ' (' + data.jqXHR.status + ')';
if (!_.isUndefined(data.jqXHR.responseJSON) &&
!_.isUndefined(data.jqXHR.responseJSON.data) &&
!_.isUndefined(data.jqXHR.responseJSON.data.message)
) {
msg = data.jqXHR.responseJSON.data.message;
}
avatarResponseHandler({
data: {
message: msg
}
});
}
};
$('#uploadavatar').fileupload(uploadparms);
// Trigger upload action also with keyboard navigation on enter
$('#uploadavatarbutton').on('keyup', function(event) {
if (event.key === ' ' || event.key === 'Enter') {
$('#uploadavatar').trigger('click');
}
});
$('#selectavatar').click(function (event) {
event.stopPropagation();
event.preventDefault();
OC.dialogs.filepicker(
t('settings', "Select a profile picture"),
function (path) {
$('#displayavatar img').hide();
$('#displayavatar .avatardiv').addClass('icon-loading');
$('#uploadavatar').prop('disabled', true);
$.ajax({
type: "POST",
url: OC.generateUrl('/avatar/'),
data: { path: path }
}).done(avatarResponseHandler)
.fail(function(jqXHR) {
var msg = jqXHR.statusText + ' (' + jqXHR.status + ')';
if (!_.isUndefined(jqXHR.responseJSON) &&
!_.isUndefined(jqXHR.responseJSON.data) &&
!_.isUndefined(jqXHR.responseJSON.data.message)
) {
msg = jqXHR.responseJSON.data.message;
}
avatarResponseHandler({
data: {
message: msg
}
});
});
},
false,
["image/png", "image/jpeg"]
);
});
$('#removeavatar').click(function (event) {
event.stopPropagation();
event.preventDefault();
$.ajax({
type: 'DELETE',
url: OC.generateUrl('/avatar/'),
success: function () {
oc_userconfig.avatar.generated = true;
updateAvatar(true);
}
});
});
$('#abortcropperbutton').click(function () {
$('#displayavatar .avatardiv').removeClass('icon-loading');
$('#displayavatar img').show();
$('#uploadavatar').prop('disabled', false);
cleanCropper();
});
$('#sendcropperbutton').click(function () {
sendCropData();
});
// Load the big avatar
var user = OC.getCurrentUser();
$('#avatarform .avatardiv').avatar(user.uid, 145, true, null, function() {
if($('#displayavatar img').length === 0 || oc_userconfig.avatar.generated) {
$('#removeavatar').removeClass('inlineblock').addClass('hidden');
} else {
$('#removeavatar').removeClass('hidden').addClass('inlineblock');
}
}, user.displayName);
});
window.setInterval(function() {
@ -390,5 +167,3 @@ window.setInterval(function() {
$('#localeexample-date').text(moment().format('L'))
$('#localeexample-fdow').text(t('settings', 'Week starts on {fdow}', { fdow: dayNames[firstDay] }))
}, 1000)
OC.Settings.updateAvatar = updateAvatar;

@ -143,10 +143,8 @@ class PersonalInfo implements ISettings {
'usage' => \OC_Helper::humanFileSize($storageInfo['used']),
'usage_relative' => round($storageInfo['relative']),
'quota' => $storageInfo['quota'],
'avatarChangeSupported' => $user->canChangeAvatar(),
'federationEnabled' => $federationEnabled,
'lookupServerUploadEnabled' => $lookupServerUploadEnabled,
'avatarScope' => $account->getProperty(IAccountManager::PROPERTY_AVATAR)->getScope(),
'groups' => $this->getGroups($user),
'isFairUseOfFreePushService' => $this->isFairUseOfFreePushService(),
'profileEnabledGlobally' => $this->profileManager->isProfileEnabled(),
@ -154,6 +152,7 @@ class PersonalInfo implements ISettings {
$personalInfoParameters = [
'userId' => $uid,
'avatar' => $this->getProperty($account, IAccountManager::PROPERTY_AVATAR),
'displayName' => $this->getProperty($account, IAccountManager::PROPERTY_DISPLAYNAME),
'emailMap' => $this->getEmailMap($account),
'phone' => $this->getProperty($account, IAccountManager::PROPERTY_PHONE),
@ -170,6 +169,7 @@ class PersonalInfo implements ISettings {
];
$accountParameters = [
'avatarChangeSupported' => $user->canChangeAvatar(),
'displayNameChangeSupported' => $user->canChangeDisplayName(),
'lookupServerUploadEnabled' => $lookupServerUploadEnabled,
];

@ -0,0 +1,333 @@
<!--
- @copyright 2022 Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<section>
<HeaderBar :input-id="avatarChangeSupported ? inputId : null"
:readable="avatar.readable"
:scope.sync="avatar.scope" />
<div v-if="!showCropper" class="avatar__container">
<div class="avatar__preview">
<NcAvatar v-if="!loading"
:user="userId"
:aria-label="t('settings', 'Your profile picture')"
:disabled-menu="true"
:disabled-tooltip="true"
:show-user-status="false"
:size="180"
:key="version" />
<div v-else class="icon-loading" />
</div>
<template v-if="avatarChangeSupported">
<div class="avatar__buttons">
<NcButton :aria-label="t('settings', 'Upload profile picture')"
@click="activateLocalFilePicker">
<template #icon>
<Upload :size="20" />
</template>
</NcButton>
<NcButton :aria-label="t('settings', 'Choose profile picture from files')"
@click="openFilePicker">
<template #icon>
<Folder :size="20" />
</template>
</NcButton>
<NcButton v-if="!isGenerated"
:aria-label="t('settings', 'Remove profile picture')"
@click="removeAvatar">
<template #icon>
<Delete :size="20" />
</template>
</NcButton>
</div>
<span>{{ t('settings', 'png or jpg, max. 20 MB') }}</span>
<input ref="input"
:id="inputId"
type="file"
:accept="validMimeTypes.join(',')"
@change="onChange">
</template>
<span v-else>
{{ t('settings', 'Picture provided by original account') }}
</span>
</div>
<!-- Use v-show to ensure early cropper ref availability -->
<div v-show="showCropper" class="avatar__container">
<VueCropper ref="cropper"
class="avatar__cropper"
v-bind="cropperOptions" />
<div class="avatar__cropper-buttons">
<NcButton @click="cancel">
{{ t('settings', 'Cancel') }}
</NcButton>
<NcButton type="primary"
@click="saveAvatar">
{{ t('settings', 'Set as profile picture') }}
</NcButton>
</div>
<span>{{ t('settings', 'Please note that it can take up to 24 hours for your profile picture to be updated everywhere.') }}</span>
</div>
</section>
</template>
<script>
import axios from '@nextcloud/axios'
import { loadState } from '@nextcloud/initial-state'
import { generateUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import { getFilePickerBuilder, showError } from '@nextcloud/dialogs'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar'
import NcButton from '@nextcloud/vue/dist/Components/NcButton'
import VueCropper from 'vue-cropperjs'
// eslint-disable-next-line node/no-extraneous-import
import 'cropperjs/dist/cropper.css'
import Upload from 'vue-material-design-icons/Upload'
import Folder from 'vue-material-design-icons/Folder'
import Delete from 'vue-material-design-icons/Delete'
import HeaderBar from './shared/HeaderBar.vue'
import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js'
const { avatar } = loadState('settings', 'personalInfoParameters', {})
const { avatarChangeSupported } = loadState('settings', 'accountParameters', {})
const VALID_MIME_TYPES = ['image/png', 'image/jpeg']
const picker = getFilePickerBuilder(t('settings', 'Choose your profile picture'))
.setMultiSelect(false)
.setMimeTypeFilter(VALID_MIME_TYPES)
.setModal(true)
.setType(1)
.allowDirectories(false)
.build()
export default {
name: 'AvatarSection',
components: {
Delete,
Folder,
HeaderBar,
NcAvatar,
NcButton,
Upload,
VueCropper,
},
data() {
return {
avatar: { ...avatar, readable: NAME_READABLE_ENUM[avatar.name] },
avatarChangeSupported,
showCropper: false,
loading: false,
userId: getCurrentUser().uid,
displayName: getCurrentUser().displayName,
version: oc_userconfig.avatar.version,
isGenerated: oc_userconfig.avatar.generated,
validMimeTypes: VALID_MIME_TYPES,
cropperOptions: {
aspectRatio: 1 / 1,
viewMode: 1,
guides: false,
center: false,
highlight: false,
autoCropArea: 1,
minContainerWidth: 300,
minContainerHeight: 300,
},
}
},
created() {
subscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
},
beforeDestroy() {
unsubscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
},
computed: {
inputId() {
return `account-property-${this.avatar.name}`
},
},
methods: {
activateLocalFilePicker() {
// Set to null so that selecting the same file will trigger the change event
this.$refs.input.value = null
this.$refs.input.click()
},
onChange(e) {
this.loading = true
const file = e.target.files[0]
if (!this.validMimeTypes.includes(file.type)) {
showError(t('settings', 'Please select a valid png or jpg file'))
this.cancel()
return
}
const reader = new FileReader()
reader.onload = (e) => {
this.$refs.cropper.replace(e.target.result)
this.showCropper = true
}
reader.readAsDataURL(file)
},
async openFilePicker() {
const path = await picker.pick()
this.loading = true
try {
const { data } = await axios.post(generateUrl('/avatar'), { path })
if (data.status === 'success') {
this.handleAvatarUpdate(false)
} else if (data.data === 'notsquare') {
const tempAvatar = generateUrl('/avatar/tmp') + '?requesttoken=' + encodeURIComponent(OC.requestToken) + '#' + Math.floor(Math.random() * 1000)
this.$refs.cropper.replace(tempAvatar)
this.showCropper = true
} else {
showError(data.data.message)
this.cancel()
}
} catch (e) {
showError(t('settings', 'Error setting profile picture'))
this.cancel()
}
},
saveAvatar() {
this.showCropper = false
this.loading = true
this.$refs.cropper.getCroppedCanvas().toBlob(async (blob) => {
if (blob === null) {
showError(t('settings', 'Error cropping profile picture'))
this.cancel()
return
}
const formData = new FormData()
formData.append('files[]', blob)
try {
await axios.post(generateUrl('/avatar'), formData)
this.handleAvatarUpdate(false)
} catch (e) {
showError(t('settings', 'Error saving profile picture'))
this.handleAvatarUpdate(this.isGenerated)
}
})
},
async removeAvatar() {
this.loading = true
try {
await axios.delete(generateUrl('/avatar'))
this.handleAvatarUpdate(true)
} catch (e) {
showError(t('settings', 'Error removing profile picture'))
this.handleAvatarUpdate(this.isGenerated)
}
},
cancel() {
this.showCropper = false
this.loading = false
},
handleAvatarUpdate(isGenerated) {
// Update the avatar version so that avatar update handlers refresh correctly
this.version = oc_userconfig.avatar.version = Date.now()
this.isGenerated = oc_userconfig.avatar.generated = isGenerated
this.loading = false
emit('settings:avatar:updated', oc_userconfig.avatar.version)
/**
* FIXME refresh all other avatars on the page when updated,
* the NcAvatar component itself should listen to the
* global events and optionally live refresh with a prop toggle
* https://github.com/nextcloud/nextcloud-vue/issues/2975
*/
},
handleDisplayNameUpdate() {
this.version = oc_userconfig.avatar.version
},
},
}
</script>
<style lang="scss" scoped>
.avatar {
&__container {
margin: 0 auto;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px 0;
width: 300px;
span {
color: var(--color-text-lighter);
}
}
&__preview {
display: flex;
justify-content: center;
align-items: center;
width: 180px;
height: 180px;
}
&__buttons {
display: flex;
gap: 0 10px;
}
&__cropper {
width: 300px;
height: 300px;
overflow: hidden;
&-buttons {
width: 100%;
display: flex;
justify-content: space-between;
}
&::v-deep .cropper-view-box {
border-radius: 50%;
}
}
}
input[type="file"] {
display: none;
}
</style>

@ -59,6 +59,10 @@ export default {
},
onSave(value) {
if (oc_userconfig.avatar.generated) {
// Update the avatar version so that avatar update handlers refresh correctly
oc_userconfig.avatar.version = Date.now()
}
emit('settings:display-name:updated', value)
},
}

@ -47,7 +47,7 @@ export const ACCOUNT_PROPERTY_ENUM = Object.freeze({
/** Enum of account properties to human readable account property names */
export const ACCOUNT_PROPERTY_READABLE_ENUM = Object.freeze({
ADDRESS: t('settings', 'Location'),
AVATAR: t('settings', 'Avatar'),
AVATAR: t('settings', 'Profile picture'),
BIOGRAPHY: t('settings', 'About'),
DISPLAYNAME: t('settings', 'Full name'),
EMAIL_COLLECTION: t('settings', 'Additional email'),

@ -26,6 +26,7 @@ import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import '@nextcloud/dialogs/styles/toast.scss'
import AvatarSection from './components/PersonalInfo/AvatarSection.vue'
import DisplayNameSection from './components/PersonalInfo/DisplayNameSection.vue'
import EmailSection from './components/PersonalInfo/EmailSection/EmailSection.vue'
import PhoneSection from './components/PersonalInfo/PhoneSection.vue'
@ -50,6 +51,7 @@ Vue.mixin({
},
})
const AvatarView = Vue.extend(AvatarSection)
const DisplayNameView = Vue.extend(DisplayNameSection)
const EmailView = Vue.extend(EmailSection)
const PhoneView = Vue.extend(PhoneSection)
@ -58,6 +60,7 @@ const WebsiteView = Vue.extend(WebsiteSection)
const TwitterView = Vue.extend(TwitterSection)
const LanguageView = Vue.extend(LanguageSection)
new AvatarView().$mount('#vue-avatar-section')
new DisplayNameView().$mount('#vue-displayname-section')
new EmailView().$mount('#vue-email-section')
new PhoneView().$mount('#vue-phone-section')

@ -47,42 +47,7 @@ script('settings', [
data-lookup-server-upload-enabled="<?php p($_['lookupServerUploadEnabled'] ? 'true' : 'false') ?>">
<h2 class="hidden-visually"><?php p($l->t('Personal info')); ?></h2>
<div id="personal-settings-avatar-container" class="personal-settings-container">
<div>
<form id="avatarform" class="section" method="post" action="<?php p(\OC::$server->getURLGenerator()->linkToRoute('core.avatar.postAvatar')); ?>">
<h3>
<?php p($l->t('Profile picture')); ?>
<a href="#" class="federation-menu" aria-label="<?php p($l->t('Change privacy level of profile picture')); ?>">
<span class="icon-federation-menu icon-password">
<span class="icon-triangle-s"></span>
</span>
</a>
</h3>
<div id="displayavatar">
<div class="avatardiv"></div>
<div class="warning hidden"></div>
<?php if ($_['avatarChangeSupported']) : ?>
<label for="uploadavatar" class="inlineblock button icon-upload svg" id="uploadavatarbutton" title="<?php p($l->t('Upload new')); ?>" tabindex="0"></label>
<button class="inlineblock button icon-folder svg" id="selectavatar" title="<?php p($l->t('Select from Files')); ?>"></button>
<button class="hidden button icon-delete svg" id="removeavatar" title="<?php p($l->t('Remove image')); ?>"></button>
<input type="file" name="files[]" id="uploadavatar" class="hiddenuploadfield" accept="image/*">
<p><em><?php p($l->t('png or jpg, max. 20 MB')); ?></em></p>
<?php else : ?>
<?php p($l->t('Picture provided by original account')); ?>
<?php endif; ?>
</div>
<div id="cropper" class="hidden">
<div class="inner-container">
<p style="width: 300px; margin-top: 0.5rem"><?php p($l->t('Please note that it can take up to 24 hours for the avatar to get updated everywhere.')); ?></p>
<div class="inlineblock button" id="abortcropperbutton"><?php p($l->t('Cancel')); ?></div>
<div class="inlineblock button primary" id="sendcropperbutton"><?php p($l->t('Choose as profile picture')); ?></div>
</div>
</div>
<span class="icon-checkmark hidden"></span>
<span class="icon-error hidden"></span>
<input type="hidden" id="avatarscope" value="<?php p($_['avatarScope']) ?>">
</form>
</div>
<div id="vue-avatar-section"></div>
<div class="personal-settings-setting-box personal-settings-group-box section">
<h3><?php p($l->t('Details')); ?></h3>
<div id="groups" class="personal-info icon-user">

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

@ -21,20 +21,20 @@ Feature: avatar
Scenario: get temporary user avatar before cropping it
Scenario: get temporary non-square user avatar before cropping it
Given Logging in using web as "user0"
And logged in user posts temporary avatar from file "data/green-square-256.png"
And logged in user posts temporary avatar from file "data/coloured-pattern-non-square.png"
When logged in user gets temporary avatar
Then The following headers should be set
| Content-Type | image/png |
# "last avatar" also includes the last temporary avatar
And last avatar is a square of size 256
And last avatar is a single "#00FF00" color
And last avatar is not a square
And last avatar is not a single color
Scenario: get user avatar before cropping it
Scenario: get non-square user avatar before cropping it
Given Logging in using web as "user0"
And logged in user posts temporary avatar from file "data/green-square-256.png"
# Avatar needs to be cropped to finish setting it even if it is squared
And logged in user posts temporary avatar from file "data/coloured-pattern-non-square.png"
# Avatar needs to be cropped to finish setting it
When user "user0" gets avatar for user "user0"
Then The following headers should be set
| Content-Type | image/png |
@ -42,11 +42,43 @@ Feature: avatar
And last avatar is a square of size 512
And last avatar is not a single color
Scenario: set square user avatar from file
Given Logging in using web as "user0"
When logged in user posts temporary avatar from file "data/green-square-256.png"
And user "user0" gets avatar for user "user0"
And The following headers should be set
| Content-Type | image/png |
| X-NC-IsCustomAvatar | 1 |
# Last avatar size is 512 by default when getting avatar without size parameter
And last avatar is a square of size 512
And last avatar is a single "#00FF00" color
And user "anonymous" gets avatar for user "user0"
And The following headers should be set
| Content-Type | image/png |
| X-NC-IsCustomAvatar | 1 |
And last avatar is a square of size 512
And last avatar is a single "#00FF00" color
Scenario: set square user avatar from internal path
Given user "user0" uploads file "data/green-square-256.png" to "/internal-green-square-256.png"
And Logging in using web as "user0"
When logged in user posts temporary avatar from internal path "internal-green-square-256.png"
And user "user0" gets avatar for user "user0" with size "64"
And The following headers should be set
| Content-Type | image/png |
| X-NC-IsCustomAvatar | 1 |
And last avatar is a square of size 64
And last avatar is a single "#00FF00" color
And user "anonymous" gets avatar for user "user0" with size "64"
And The following headers should be set
| Content-Type | image/png |
| X-NC-IsCustomAvatar | 1 |
And last avatar is a square of size 64
And last avatar is a single "#00FF00" color
Scenario: set user avatar from file
Scenario: set non-square user avatar from file
Given Logging in using web as "user0"
When logged in user posts temporary avatar from file "data/coloured-pattern.png"
When logged in user posts temporary avatar from file "data/coloured-pattern-non-square.png"
And logged in user crops temporary avatar
| x | 384 |
| y | 256 |
@ -66,10 +98,10 @@ Feature: avatar
And last avatar is a square of size 512
And last avatar is a single "#FF0000" color
Scenario: set user avatar from internal path
Given user "user0" uploads file "data/coloured-pattern.png" to "/internal-coloured-pattern.png"
Scenario: set non-square user avatar from internal path
Given user "user0" uploads file "data/coloured-pattern-non-square.png" to "/internal-coloured-pattern-non-square.png"
And Logging in using web as "user0"
When logged in user posts temporary avatar from internal path "internal-coloured-pattern.png"
When logged in user posts temporary avatar from internal path "internal-coloured-pattern-non-square.png"
And logged in user crops temporary avatar
| x | 704 |
| y | 320 |
@ -91,7 +123,7 @@ Feature: avatar
Scenario: cropped user avatar needs to be squared
Given Logging in using web as "user0"
And logged in user posts temporary avatar from file "data/coloured-pattern.png"
And logged in user posts temporary avatar from file "data/coloured-pattern-non-square.png"
When logged in user crops temporary avatar with 400
| x | 384 |
| y | 256 |
@ -102,7 +134,7 @@ Feature: avatar
Scenario: delete user avatar
Given Logging in using web as "user0"
And logged in user posts temporary avatar from file "data/coloured-pattern.png"
And logged in user posts temporary avatar from file "data/coloured-pattern-non-square.png"
And logged in user crops temporary avatar
| x | 384 |
| y | 256 |
@ -138,7 +170,7 @@ Feature: avatar
Scenario: get user avatar with a larger size than the original one
Given Logging in using web as "user0"
And logged in user posts temporary avatar from file "data/coloured-pattern.png"
And logged in user posts temporary avatar from file "data/coloured-pattern-non-square.png"
And logged in user crops temporary avatar
| x | 384 |
| y | 256 |
@ -153,7 +185,7 @@ Feature: avatar
Scenario: get user avatar with a smaller size than the original one
Given Logging in using web as "user0"
And logged in user posts temporary avatar from file "data/coloured-pattern.png"
And logged in user posts temporary avatar from file "data/coloured-pattern-non-square.png"
And logged in user crops temporary avatar
| x | 384 |
| y | 256 |

@ -174,10 +174,19 @@ trait Avatar {
public function lastAvatarIsASquareOfSize(string $size) {
[$width, $height] = getimagesizefromstring($this->lastAvatar);
Assert::assertEquals($width, $height, 'Avatar is not a square');
Assert::assertEquals($width, $height, 'Expected avatar to be a square');
Assert::assertEquals($size, $width);
}
/**
* @Then last avatar is not a square
*/
public function lastAvatarIsNotASquare() {
[$width, $height] = getimagesizefromstring($this->lastAvatar);
Assert::assertNotEquals($width, $height, 'Expected avatar to not be a square');
}
/**
* @Then last avatar is not a single color
*/

@ -215,6 +215,19 @@ class AvatarController extends Controller {
);
}
if ($image->width() === $image->height()) {
try {
$avatar = $this->avatarManager->getAvatar($this->userId);
$avatar->set($image);
// Clean up
$this->cache->remove('tmpAvatar');
return new JSONResponse(['status' => 'success']);
} catch (\Throwable $e) {
$this->logger->error($e->getMessage(), ['exception' => $e, 'app' => 'core']);
return new JSONResponse(['data' => ['message' => $this->l->t('An error occurred. Please contact your admin.')]], Http::STATUS_BAD_REQUEST);
}
}
$this->cache->set('tmpAvatar', $image->data(), 7200);
return new JSONResponse(
['data' => 'notsquare'],

@ -42,8 +42,6 @@ import './Polyfill/tooltip'
import ClipboardJS from 'clipboard'
import { dav } from 'davclient.js'
import Handlebars from 'handlebars'
import '@nextcloud/jcrop/js/jquery.Jcrop'
import '@nextcloud/jcrop/css/jquery.Jcrop.css'
import md5 from 'blueimp-md5'
import moment from 'moment'
import 'select2'

File diff suppressed because one or more lines are too long

@ -16,6 +16,16 @@
* @license MIT
*/
/*!
* Cropper.js v1.5.12
* https://fengyuanchen.github.io/cropperjs
*
* Copyright 2015-present Chen Fengyuan
* Released under the MIT license
*
* Date: 2021-06-12T08:00:17.411Z
*/
/*!
* Determine if an object is a Buffer
*

File diff suppressed because one or more lines are too long

4
dist/core-main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

51
package-lock.json generated

@ -12,13 +12,12 @@
"@chenfengyuan/vue-qrcode": "^1.0.2",
"@nextcloud/auth": "^1.3.0",
"@nextcloud/axios": "^1.10.0",
"@nextcloud/calendar-availability-vue": "^0.5.0-beta.1",
"@nextcloud/calendar-availability-vue": "^0.5.0-beta.2",
"@nextcloud/capabilities": "^1.0.4",
"@nextcloud/dialogs": "^3.1.4",
"@nextcloud/event-bus": "^2.1.1",
"@nextcloud/files": "^2.1.0",
"@nextcloud/initial-state": "^1.2.1",
"@nextcloud/jcrop": "^0.10.0",
"@nextcloud/l10n": "^1.4.1",
"@nextcloud/logger": "^2.1.0",
"@nextcloud/moment": "^1.2.0",
@ -71,6 +70,7 @@
"vue": "^2.7.10",
"vue-click-outside": "^1.1.0",
"vue-clipboard2": "^0.3.3",
"vue-cropperjs": "^4.2.0",
"vue-infinite-loading": "^2.4.5",
"vue-localstorage": "^0.6.2",
"vue-material-design-icons": "^5.0.0",
@ -3137,19 +3137,6 @@
"core-js": "^3.6.4"
}
},
"node_modules/@nextcloud/jcrop": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@nextcloud/jcrop/-/jcrop-0.10.0.tgz",
"integrity": "sha512-8grlksc0gI739aBbTMVtP0wbwH5V8qiAgY+qsr+7dyTIshiDJHmhwvnUT9aOLNrLMuvvqAf4/prCLh/Xa/4Xfg==",
"deprecated": "This software is not maintained anymore",
"dependencies": {
"jquery": "~3"
},
"engines": {
"node": ">=14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@nextcloud/l10n": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@nextcloud/l10n/-/l10n-1.4.1.tgz",
@ -12101,6 +12088,11 @@
"sha.js": "^2.4.8"
}
},
"node_modules/cropperjs": {
"version": "1.5.12",
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.5.12.tgz",
"integrity": "sha512-re7UdjE5UnwdrovyhNzZ6gathI4Rs3KGCBSc8HCIjUo5hO42CtzyblmWLj6QWVw7huHyDMfpKxhiO2II77nhDw=="
},
"node_modules/cross-fetch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz",
@ -30871,6 +30863,14 @@
"tinycolor2": "^1.1.2"
}
},
"node_modules/vue-cropperjs": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/vue-cropperjs/-/vue-cropperjs-4.2.0.tgz",
"integrity": "sha512-dvwCBtjGMiznkNIK2GFd1SQm1x+wmtWg4g4t+NrJSPj/fpHnubXxAUOIvY7lMFeR2lawRLsigCaGZrcXCzuTKA==",
"dependencies": {
"cropperjs": "^1.5.6"
}
},
"node_modules/vue-eslint-parser": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-8.0.1.tgz",
@ -34378,14 +34378,6 @@
"core-js": "^3.6.4"
}
},
"@nextcloud/jcrop": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@nextcloud/jcrop/-/jcrop-0.10.0.tgz",
"integrity": "sha512-8grlksc0gI739aBbTMVtP0wbwH5V8qiAgY+qsr+7dyTIshiDJHmhwvnUT9aOLNrLMuvvqAf4/prCLh/Xa/4Xfg==",
"requires": {
"jquery": "~3"
}
},
"@nextcloud/l10n": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@nextcloud/l10n/-/l10n-1.4.1.tgz",
@ -41659,6 +41651,11 @@
"sha.js": "^2.4.8"
}
},
"cropperjs": {
"version": "1.5.12",
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.5.12.tgz",
"integrity": "sha512-re7UdjE5UnwdrovyhNzZ6gathI4Rs3KGCBSc8HCIjUo5hO42CtzyblmWLj6QWVw7huHyDMfpKxhiO2II77nhDw=="
},
"cross-fetch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz",
@ -56282,6 +56279,14 @@
"tinycolor2": "^1.1.2"
}
},
"vue-cropperjs": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/vue-cropperjs/-/vue-cropperjs-4.2.0.tgz",
"integrity": "sha512-dvwCBtjGMiznkNIK2GFd1SQm1x+wmtWg4g4t+NrJSPj/fpHnubXxAUOIvY7lMFeR2lawRLsigCaGZrcXCzuTKA==",
"requires": {
"cropperjs": "^1.5.6"
}
},
"vue-eslint-parser": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-8.0.1.tgz",

@ -38,7 +38,6 @@
"@nextcloud/event-bus": "^2.1.1",
"@nextcloud/files": "^2.1.0",
"@nextcloud/initial-state": "^1.2.1",
"@nextcloud/jcrop": "^0.10.0",
"@nextcloud/l10n": "^1.4.1",
"@nextcloud/logger": "^2.1.0",
"@nextcloud/moment": "^1.2.0",
@ -91,6 +90,7 @@
"vue": "^2.7.10",
"vue-click-outside": "^1.1.0",
"vue-clipboard2": "^0.3.3",
"vue-cropperjs": "^4.2.0",
"vue-infinite-loading": "^2.4.5",
"vue-localstorage": "^0.6.2",
"vue-material-design-icons": "^5.0.0",

Loading…
Cancel
Save