diff options
author | Christopher Ng <chrng8@gmail.com> | 2021-10-14 08:28:54 +0000 |
---|---|---|
committer | Christopher Ng <chrng8@gmail.com> | 2021-10-19 04:59:36 +0000 |
commit | 3be9d3ca8fca4fb743a4d2f2ffe44a45fa9ffa6e (patch) | |
tree | 5519fb218db5daa3d0e16198f600d5646f7d0b1a /core/src | |
parent | 309354852f12ae88d5eef05d311d6ebcba8ee762 (diff) | |
download | nextcloud-server-3be9d3ca8fca4fb743a4d2f2ffe44a45fa9ffa6e.tar.gz nextcloud-server-3be9d3ca8fca4fb743a4d2f2ffe44a45fa9ffa6e.zip |
Profile frontend
Signed-off-by: Christopher Ng <chrng8@gmail.com>
Signed-off-by: nextcloud-command <nextcloud-command@users.noreply.github.com>
Diffstat (limited to 'core/src')
-rw-r--r-- | core/src/OC/contactsmenu/contact.handlebars | 40 | ||||
-rw-r--r-- | core/src/components/Profile/PrimaryActionButton.vue | 103 | ||||
-rw-r--r-- | core/src/components/UserMenu.js | 12 | ||||
-rw-r--r-- | core/src/profile.js | 48 | ||||
-rw-r--r-- | core/src/views/Profile.vue | 531 |
5 files changed, 724 insertions, 10 deletions
diff --git a/core/src/OC/contactsmenu/contact.handlebars b/core/src/OC/contactsmenu/contact.handlebars index e773550107f..e178469bd94 100644 --- a/core/src/OC/contactsmenu/contact.handlebars +++ b/core/src/OC/contactsmenu/contact.handlebars @@ -1,13 +1,39 @@ {{#if contact.avatar}} -<img src="{{contact.avatar}}&size=32" class="avatar" srcset="{{contact.avatar}}&size=32 1x, {{contact.avatar}}&size=64 2x, {{contact.avatar}}&size=128 4x" alt=""> + {{#if contact.profileUrl}} + {{#if contact.profileTitle}} + <a class="profile-link--avatar" href="{{contact.profileUrl}}"> + <img src="{{contact.avatar}}&size=32" class="avatar" srcset="{{contact.avatar}}&size=32 1x, {{contact.avatar}}&size=64 2x, {{contact.avatar}}&size=128 4x" alt=""> + </a> + {{/if}} + {{else}} + <img src="{{contact.avatar}}&size=32" class="avatar" srcset="{{contact.avatar}}&size=32 1x, {{contact.avatar}}&size=64 2x, {{contact.avatar}}&size=128 4x" alt=""> + {{/if}} {{else}} -<div class="avatar"></div> + {{#if contact.profileUrl}} + {{#if contact.profileTitle}} + <a class="profile-link--avatar" href="{{contact.profileUrl}}"> + <div class="avatar"></div> + </a> + {{/if}} + {{else}} + <div class="avatar"></div> + {{/if}} +{{/if}} +{{#if contact.profileUrl}} + {{#if contact.profileTitle}} + <a class="body profile-link--full-name" href="{{contact.profileUrl}}"> + <div class="full-name">{{contact.fullName}}</div> + <div class="last-message">{{contact.lastMessage}}</div> + <div class="email-address">{{contact.emailAddresses}}</div> + </a> + {{/if}} +{{else}} + <div class="body"> + <div class="full-name">{{contact.fullName}}</div> + <div class="last-message">{{contact.lastMessage}}</div> + <div class="email-address">{{contact.emailAddresses}}</div> + </div> {{/if}} -<div class="body"> - <div class="full-name">{{contact.fullName}}</div> - <div class="last-message">{{contact.lastMessage}}</div> - <div class="email-address">{{contact.emailAddresses}}</div> -</div> {{#if contact.topAction}} <a class="top-action" href="{{contact.topAction.hyperlink}}" title="{{contact.topAction.title}}"> <img src="{{contact.topAction.icon}}" alt="{{contact.topAction.title}}"> diff --git a/core/src/components/Profile/PrimaryActionButton.vue b/core/src/components/Profile/PrimaryActionButton.vue new file mode 100644 index 00000000000..069463b060d --- /dev/null +++ b/core/src/components/Profile/PrimaryActionButton.vue @@ -0,0 +1,103 @@ +<!-- + - @copyright 2021, Christopher Ng <chrng8@gmail.com> + - + - @author Christopher Ng <chrng8@gmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - 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> + <a + class="profile__primary-action-button" + :class="{ 'disabled': disabled }" + :href="href" + :target="target" + rel="noopener noreferrer nofollow" + v-on="$listeners"> + <img + class="icon" + :class="[icon, { 'icon-invert': colorPrimaryText === '#ffffff' }]" + :src="icon"> + <slot /> + </a> +</template> + +<script> +export default { + name: 'PrimaryActionButton', + + props: { + disabled: { + type: Boolean, + default: false, + }, + href: { + type: String, + required: true, + }, + icon: { + type: String, + required: true, + }, + target: { + type: String, + required: true, + validator: (value) => ['_self', '_blank', '_parent', '_top'].includes(value), + }, + }, + + computed: { + colorPrimaryText() { + // For some reason the returned string has prepended whitespace + return getComputedStyle(document.body).getPropertyValue('--color-primary-text').trim() + }, + }, +} +</script> + +<style lang="scss" scoped> + .profile__primary-action-button { + font-size: var(--default-font-size); + font-weight: bold; + width: 188px; + height: 44px; + padding: 0 16px; + line-height: 44px; + text-align: center; + border-radius: var(--border-radius-pill); + color: var(--color-primary-text); + background-color: var(--color-primary-element); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + .icon { + display: inline-block; + vertical-align: middle; + margin-bottom: 2px; + margin-right: 4px; + + &.icon-invert { + filter: invert(1); + } + } + + &:hover { + background-color: var(--color-primary-element-light); + } + } +</style> diff --git a/core/src/components/UserMenu.js b/core/src/components/UserMenu.js index c4cb6527da3..aaecec546b9 100644 --- a/core/src/components/UserMenu.js +++ b/core/src/components/UserMenu.js @@ -26,6 +26,10 @@ import $ from 'jquery' export const setUp = () => { const $menu = $('#header #settings') + // Using page terminoogy as below + const $excludedPageClasses = [ + 'user-status-menu-item__header', + ] // show loading feedback $menu.delegate('a', 'click', event => { @@ -34,9 +38,11 @@ export const setUp = () => { $page = $page.closest('a') } if (event.which === 1 && !event.ctrlKey && !event.metaKey) { - $page.find('img').remove() - $page.find('div').remove() // prevent odd double-clicks - $page.prepend($('<div/>').addClass('icon-loading-small')) + if (!$excludedPageClasses.includes($page.attr('class'))) { + $page.find('img').remove() + $page.find('div').remove() // prevent odd double-clicks + $page.prepend($('<div/>').addClass('icon-loading-small')) + } } else { // Close navigation when opening menu entry in // a new tab diff --git a/core/src/profile.js b/core/src/profile.js new file mode 100644 index 00000000000..f0ff5923a61 --- /dev/null +++ b/core/src/profile.js @@ -0,0 +1,48 @@ +/** + * @copyright 2021, Christopher Ng <chrng8@gmail.com> + * + * @author Christopher Ng <chrng8@gmail.com> + * + * @license GNU AGPL version 3 or any later version + * + * 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/>. + * + */ + +import Vue from 'vue' +import { generateFilePath } from '@nextcloud/router' +import { getRequestToken } from '@nextcloud/auth' +import { translate as t } from '@nextcloud/l10n' +import VTooltip from 'v-tooltip' + +import logger from './logger' + +import Profile from './views/Profile' + +__webpack_nonce__ = btoa(getRequestToken()) +__webpack_public_path__ = generateFilePath('core', '', 'js/') + +Vue.use(VTooltip) + +Vue.mixin({ + props: { + logger, + }, + methods: { + t, + }, +}) + +const View = Vue.extend(Profile) +new View().$mount('#vue-profile') diff --git a/core/src/views/Profile.vue b/core/src/views/Profile.vue new file mode 100644 index 00000000000..d16a3661b22 --- /dev/null +++ b/core/src/views/Profile.vue @@ -0,0 +1,531 @@ +<!-- + - @copyright Copyright (c) 2021 Christopher Ng <chrng8@gmail.com> + - + - @author Christopher Ng <chrng8@gmail.com> + - @author Julius Härtl <jus@bitgrid.net> + - + - @license GNU AGPL version 3 or any later version + - + - 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> + <div class="profile"> + <div class="profile__header"> + <div class="profile__header__container"> + <div class="profile__header__container__placeholder" /> + <h2 class="profile__header__container__displayname"> + {{ displayname || userId }} + <a v-if="isCurrentUser" + class="primary profile__header__container__edit-button" + :href="settingsUrl"> + <PencilIcon + class="pencil-icon" + decorative + title="" + :size="16" /> + {{ t('core', 'Edit Profile') }} + </a> + </h2> + <div v-if="status.icon || status.message" + class="profile__header__container__status-text" + :class="{ interactive: isCurrentUser }" + @click.prevent.stop="openStatusModal"> + {{ status.icon }} {{ status.message }} + </div> + </div> + </div> + + <div class="profile__content"> + <div class="profile__sidebar"> + <Avatar + class="avatar" + :class="{ interactive: isCurrentUser }" + :user="userId" + :size="180" + :show-user-status="true" + :show-user-status-compact="false" + :disable-menu="true" + :disable-tooltip="true" + :is-no-user="!isUserAvatarVisible" + @click.native.prevent.stop="openStatusModal" /> + + <div class="user-actions"> + <!-- When a tel: URL is opened with target="_blank", a blank new tab is opened which is inconsistent with the handling of other URLs so we set target="_self" for the phone action --> + <PrimaryActionButton v-if="primaryAction" + class="user-actions__primary" + :href="primaryAction.target" + :icon="primaryAction.icon" + :target="primaryAction.id === 'phone' ? '_self' :'_blank'"> + {{ primaryAction.title }} + </PrimaryActionButton> + <div class="user-actions__other"> + <!-- FIXME Remove inline styles after https://github.com/nextcloud/nextcloud-vue/issues/2315 is fixed --> + <Actions v-for="action in middleActions" + :key="action.id" + :default-icon="action.icon" + style=" + background-position: 14px center; + background-size: 16px; + background-repeat: no-repeat;" + :style="{ + backgroundImage: `url(${action.icon})`, + ...(colorMainBackground === '#181818' && { filter: 'invert(1)' }) + }"> + <ActionLink + :close-after-click="true" + :icon="action.icon" + :href="action.target" + :target="action.id === 'phone' ? '_self' :'_blank'"> + {{ action.title }} + </ActionLink> + </Actions> + <template v-if="otherActions"> + <Actions v-for="action in otherActions" + :key="action.id" + :force-menu="true"> + <ActionLink + :class="{ 'icon-invert': colorMainBackground === '#181818' }" + :close-after-click="true" + :icon="action.icon" + :href="action.target" + :target="action.id === 'phone' ? '_self' :'_blank'"> + {{ action.title }} + </ActionLink> + </Actions> + </template> + </div> + </div> + </div> + + <div class="profile__blocks"> + <div v-if="organisation || role || address" class="profile__blocks-details"> + <div v-if="organisation || role" class="detail"> + <p>{{ organisation }} <span v-if="organisation && role">•</span> {{ role }}</p> + </div> + <div v-if="address" class="detail"> + <p> + <MapMarkerIcon + class="map-icon" + decorative + title="" + :size="16" /> + {{ address }} + </p> + </div> + </div> + <template v-if="headline || biography"> + <div v-if="headline" class="profile__blocks-headline"> + <h3>{{ headline }}</h3> + </div> + <div v-if="biography" class="profile__blocks-biography"> + <p>{{ biography }}</p> + </div> + </template> + <template v-else> + <div class="profile__blocks-empty-info"> + <AccountIcon + decorative + title="" + fill-color="var(--color-text-maxcontrast)" + :size="60" /> + <h3>{{ displayname || userId }} {{ t('core', 'hasn\'t added any info yet') }}</h3> + <p>{{ t('core', 'The headline and about section will show up here') }}</p> + </div> + </template> + </div> + </div> + </div> +</template> + +<script> +import { getCurrentUser } from '@nextcloud/auth' +import { subscribe, unsubscribe } from '@nextcloud/event-bus' +import { loadState } from '@nextcloud/initial-state' +import { generateUrl } from '@nextcloud/router' +import { showError } from '@nextcloud/dialogs' + +import Avatar from '@nextcloud/vue/dist/Components/Avatar' +import Actions from '@nextcloud/vue/dist/Components/Actions' +import ActionLink from '@nextcloud/vue/dist/Components/ActionLink' +import MapMarkerIcon from 'vue-material-design-icons/MapMarker' +import PencilIcon from 'vue-material-design-icons/Pencil' +import AccountIcon from 'vue-material-design-icons/Account' + +import PrimaryActionButton from '../components/Profile/PrimaryActionButton' + +const status = loadState('core', 'status', {}) +const { + userId, + displayname, + address, + organisation, + role, + headline, + biography, + actions, + isUserAvatarVisible, +} = loadState('core', 'profileParameters', { + userId: null, + displayname: null, + address: null, + organisation: null, + role: null, + headline: null, + biography: null, + actions: [], + isUserAvatarVisible: false, +}) + +export default { + name: 'Profile', + + components: { + AccountIcon, + ActionLink, + Actions, + Avatar, + MapMarkerIcon, + PencilIcon, + PrimaryActionButton, + }, + + data() { + return { + status, + userId, + displayname, + address, + organisation, + role, + headline, + biography, + actions, + isUserAvatarVisible, + } + }, + + computed: { + isCurrentUser() { + return getCurrentUser()?.uid === this.userId + }, + + allActions() { + return this.actions + }, + + primaryAction() { + if (this.allActions.length) { + return this.allActions[0] + } + return null + }, + + middleActions() { + if (this.allActions.slice(1, 4).length) { + return this.allActions.slice(1, 4) + } + return null + }, + + otherActions() { + if (this.allActions.slice(4).length) { + return this.allActions.slice(4) + } + return null + }, + + settingsUrl() { + return generateUrl('/settings/user') + }, + + colorMainBackground() { + // For some reason the returned string has prepended whitespace + return getComputedStyle(document.body).getPropertyValue('--color-main-background').trim() + }, + }, + + mounted() { + subscribe('user_status:status.updated', this.handleStatusUpdate) + }, + + beforeDestroy() { + unsubscribe('user_status:status.updated', this.handleStatusUpdate) + }, + + methods: { + handleStatusUpdate(status) { + this.status = status + }, + + openStatusModal() { + const statusMenuItem = document.querySelector('.user-status-menu-item__toggle') + // Changing the user status is only enabled if you are the current user + if (this.isCurrentUser) { + if (statusMenuItem) { + statusMenuItem.click() + } else { + showError(t('core', 'Error opening the user status modal, try hard refreshing the page')) + } + } + }, + }, +} +</script> + +<style lang="scss"> +// Override header styles +#header { + background-color: transparent !important; + background-image: none !important; +} + +#content { + padding-top: 0px; +} +</style> + +<style lang="scss" scoped> +$profile-max-width: 1024px; +$content-max-width: 640px; + +.profile { + width: 100%; + + &__header { + position: sticky; + height: 190px; + top: -40px; + + &__container { + align-self: flex-end; + width: 100%; + max-width: $profile-max-width; + margin: 0 auto; + display: grid; + grid-template-rows: max-content max-content; + grid-template-columns: 240px 1fr; + justify-content: center; + + &__placeholder { + grid-row: 1 / 3; + } + + &__displayname, &__status-text { + color: var(--color-primary-text); + } + + &__displayname { + width: $content-max-width; + height: 45px; + margin-top: 128px; + // Override the global style declaration + margin-bottom: 0; + font-size: 30px; + display: flex; + align-items: center; + cursor: text; + + &:not(:last-child) { + margin-top: 100px; + margin-bottom: 4px; + } + } + + &__edit-button { + border: none; + margin-left: 18px; + margin-top: 2px; + color: var(--color-primary-element); + background-color: var(--color-primary-text); + box-shadow: 0 0 0 2px var(--color-primary-text); + border-radius: var(--border-radius-pill); + padding: 0 18px; + font-size: var(--default-font-size); + height: 44px; + line-height: 44px; + font-weight: bold; + + &:hover { + color: var(--color-primary-text); + background-color: var(--color-primary-element-light); + } + + .pencil-icon { + display: inline-block; + vertical-align: middle; + margin-top: 2px; + } + } + + &__status-text { + width: max-content; + max-width: $content-max-width; + padding: 5px 10px; + margin-left: -14px; + margin-top: 2px; + + &.interactive { + cursor: pointer; + + &:hover { + background-color: var(--color-main-background); + color: var(--color-main-text); + border-radius: var(--border-radius-pill); + font-weight: bold; + box-shadow: 0 3px 6px var(--color-box-shadow); + } + } + } + } + } + + &__sidebar { + position: sticky; + top: var(--header-height); + align-self: flex-start; + padding-top: 20px; + min-width: 220px; + margin: -150px 20px 0 0; + + // Specificity hack is needed to override Avatar component styles + &::v-deep .avatar.avatardiv, h2 { + text-align: center; + margin: auto; + display: block; + padding: 8px; + } + + &::v-deep .avatar.avatardiv:not(.avatardiv--unknown) { + background-color: var(--color-main-background) !important; + box-shadow: none; + } + + &::v-deep .avatar.avatardiv { + .avatardiv__user-status { + right: 14px; + bottom: 14px; + width: 34px; + height: 34px; + background-size: 28px; + border: none; + // Styles when custom status icon and status text are set + background-color: var(--color-main-background); + line-height: 34px; + font-size: 20px; + } + } + + &::v-deep .avatar.interactive.avatardiv { + .avatardiv__user-status { + cursor: pointer; + + &:hover { + box-shadow: 0 3px 6px var(--color-box-shadow); + } + } + } + } + + &__content { + max-width: $profile-max-width; + margin: 0 auto; + display: flex; + width: 100%; + } + + &__blocks { + margin: 18px 0 80px 0; + display: grid; + gap: 16px 0; + width: $content-max-width; + + p, h3 { + overflow-wrap: anywhere; + } + + &-details { + display: flex; + flex-direction: column; + gap: 2px 0; + + .detail { + display: inline-block; + color: var(--color-text-maxcontrast); + + p .map-icon { + display: inline-block; + vertical-align: middle; + } + } + } + + &-headline { + margin-top: 10px; + + h3 { + font-weight: bold; + font-size: 20px; + margin: 0; + } + } + + &-biography { + white-space: pre-line; + } + + h3, p { + cursor: text; + } + + &-empty-info { + margin-top: 80px; + margin-right: 100px; + display: flex; + flex-direction: column; + text-align: center; + + h3 { + font-weight: bold; + font-size: 18px; + margin: 8px 0; + } + } + } +} + +.user-actions { + display: flex; + flex-direction: column; + gap: 8px 0; + margin-top: 20px; + + &__primary { + margin: 0 auto; + } + + &__other { + display: flex; + justify-content: center; + gap: 0 4px; + } +} + +.icon-invert { + &::v-deep .action-link__icon { + filter: invert(1); + } +} +</style> |