Remake profile picture saving with Vue

Signed-off-by: Christopher Ng <chrng8@gmail.com>
This commit is contained in:
Christopher Ng 2022-07-30 00:32:16 +00:00
parent f167fe0ceb
commit f44d2586b1
26 changed files with 447 additions and 443 deletions

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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,
];

View File

@ -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>

View File

@ -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)
},
}

View File

@ -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'),

View File

@ -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')

View File

@ -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">

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -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 user avatar from file
Scenario: set 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/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 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-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 |

View File

@ -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
*/

View File

@ -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'],

View File

@ -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'

BIN
dist/core-common.js vendored

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
dist/core-main.js vendored

Binary file not shown.

BIN
dist/core-main.js.map vendored

Binary file not shown.

Binary file not shown.

Binary file not shown.

51
package-lock.json generated
View File

@ -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",

View File

@ -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",