Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/server.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/core/src
diff options
context:
space:
mode:
authorChristopher Ng <chrng8@gmail.com>2021-10-14 11:28:54 +0300
committerChristopher Ng <chrng8@gmail.com>2021-10-19 07:59:36 +0300
commit3be9d3ca8fca4fb743a4d2f2ffe44a45fa9ffa6e (patch)
tree5519fb218db5daa3d0e16198f600d5646f7d0b1a /core/src
parent309354852f12ae88d5eef05d311d6ebcba8ee762 (diff)
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.handlebars40
-rw-r--r--core/src/components/Profile/PrimaryActionButton.vue103
-rw-r--r--core/src/components/UserMenu.js12
-rw-r--r--core/src/profile.js48
-rw-r--r--core/src/views/Profile.vue531
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>