Merge pull request #42668 from nextcloud/enh/refactor-profile

enh(profile): Refactor code to use vue components and styles where possible
pull/42402/head
Eduardo Morales 5 months ago committed by GitHub
commit 5e6f6b2633
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -29,7 +29,7 @@ namespace OC\Core\Controller;
use OC\Profile\ProfileManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\IgnoreOpenAPI;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\EventDispatcher\IEventDispatcher;
@ -42,7 +42,7 @@ use OCP\Profile\BeforeTemplateRenderedEvent;
use OCP\Share\IManager as IShareManager;
use OCP\UserStatus\IManager as IUserStatusManager;
#[IgnoreOpenAPI]
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
class ProfilePageController extends Controller {
public function __construct(
string $appName,

@ -20,17 +20,14 @@
*
*/
import Vue from 'vue'
import { getRequestToken } from '@nextcloud/auth'
import { translate as t } from '@nextcloud/l10n'
import VTooltip from 'v-tooltip'
import logger from './logger.js'
import Vue from 'vue'
import Profile from './views/Profile.vue'
import ProfileSections from './profile/ProfileSections.js'
__webpack_nonce__ = btoa(getRequestToken())
// @ts-expect-error Script nonce required for webpack loading additional scripts
__webpack_nonce__ = btoa(getRequestToken() ?? '')
if (!window.OCA) {
window.OCA = {}
@ -41,19 +38,8 @@ if (!window.OCA.Core) {
}
Object.assign(window.OCA.Core, { ProfileSections: new ProfileSections() })
Vue.use(VTooltip)
Vue.mixin({
props: {
logger,
},
methods: {
t,
},
})
const View = Vue.extend(Profile)
window.addEventListener('DOMContentLoaded', () => {
new View().$mount('#vue-profile')
new View().$mount('#content')
})

@ -22,197 +22,185 @@
-->
<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"
: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 }}
<NcContent app-name="profile">
<NcAppContent>
<div class="profile__header">
<div class="profile__header__container">
<div class="profile__header__container__placeholder" />
<div class="profile__header__container__displayname">
<h2>{{ displayname || userId }}</h2>
<NcButton v-if="isCurrentUser"
type="primary"
:href="settingsUrl">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ t('core', 'Edit Profile') }}
</NcButton>
</div>
<NcButton v-if="status.icon || status.message"
:disabled="!isCurrentUser"
:type="isCurrentUser ? 'tertiary' : 'tertiary-no-background'"
@click="openStatusModal">
{{ status.icon }} {{ status.message }}
</NcButton>
</div>
</div>
</div>
<div class="profile__wrapper">
<div class="profile__content">
<div class="profile__sidebar">
<NcAvatar 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 -->
<NcActions 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)' })
}">
<NcActionLink :close-after-click="true"
:icon="action.icon"
<div class="profile__wrapper">
<div class="profile__content">
<div class="profile__sidebar">
<NcAvatar 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 -->
<NcButton v-if="primaryAction"
type="primary"
class="user-actions__primary"
:href="primaryAction.target"
:icon="primaryAction.icon"
:target="primaryAction.id === 'phone' ? '_self' :'_blank'">
<template #icon>
<!-- Fix for https://github.com/nextcloud-libraries/nextcloud-vue/issues/2315 -->
<img :src="primaryAction.icon" alt="" class="user-actions__primary__icon">
</template>
{{ primaryAction.title }}
</NcButton>
<NcActions class="user-actions__other" :inline="4">
<NcActionLink v-for="action in otherActions"
:key="action.id"
:close-after-click="true"
:href="action.target"
:target="action.id === 'phone' ? '_self' :'_blank'">
<template #icon>
<!-- Fix for https://github.com/nextcloud-libraries/nextcloud-vue/issues/2315 -->
<img :src="action.icon" alt="" class="user-actions__other__icon">
</template>
{{ action.title }}
</NcActionLink>
</NcActions>
<template v-if="otherActions">
<NcActions :force-menu="true">
<NcActionLink v-for="action in otherActions"
:key="action.id"
:class="{ 'icon-invert': colorMainBackground === '#181818' }"
:close-after-click="true"
:icon="action.icon"
:href="action.target"
:target="action.id === 'phone' ? '_self' :'_blank'">
{{ action.title }}
</NcActionLink>
</NcActions>
</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 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"
:size="16" />
{{ address }}
</p>
</div>
</div>
<div v-if="address" class="detail">
<p>
<MapMarkerIcon class="map-icon"
:size="16" />
{{ address }}
<template v-if="headline || biography || sections.length > 0">
<h3 v-if="headline" class="profile__blocks-headline">
{{ headline }}
</h3>
<p v-if="biography" class="profile__blocks-biography">
{{ biography }}
</p>
</div>
</div>
<template v-if="headline || biography || sections.length > 0">
<div v-if="headline" class="profile__blocks-headline">
<h3>{{ headline }}</h3>
</div>
<div v-if="biography" class="profile__blocks-biography">
<p>{{ biography }}</p>
</div>
<!-- additional entries, use it with cautious -->
<div v-for="(section, index) in sections"
:ref="'section-' + index"
:key="index"
class="profile__additionalContent">
<component :is="section($refs['section-'+index], userId)" :userId="userId" />
</div>
</template>
<template v-else>
<div class="profile__blocks-empty-info">
<AccountIcon :size="60"
fill-color="var(--color-text-maxcontrast)" />
<h3>{{ emptyProfileMessage }}</h3>
<p>{{ t('core', 'The headline and about sections will show up here') }}</p>
</div>
</template>
<!-- additional entries, use it with cautious -->
<div v-for="(section, index) in sections"
:ref="'section-' + index"
:key="index"
class="profile__additionalContent">
<component :is="section($refs['section-'+index], userId)" :user-id="userId" />
</div>
</template>
<NcEmptyContent v-else
class="profile__blocks-empty-info"
:name="emptyProfileMessage"
:description="t('core', 'The headline and about sections will show up here')">
<template #icon>
<AccountIcon :size="60" />
</template>
</NcEmptyContent>
</div>
</div>
</div>
</div>
</div>
</NcAppContent>
</NcContent>
</template>
<script>
<script lang="ts">
import { getCurrentUser } from '@nextcloud/auth'
import { showError } from '@nextcloud/dialogs'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { showError } from '@nextcloud/dialogs'
import { defineComponent } from 'vue'
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js'
import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcContent from '@nextcloud/vue/dist/Components/NcContent.js'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import AccountIcon from 'vue-material-design-icons/Account.vue'
import MapMarkerIcon from 'vue-material-design-icons/MapMarker.vue'
import PencilIcon from 'vue-material-design-icons/Pencil.vue'
import AccountIcon from 'vue-material-design-icons/Account.vue'
import PrimaryActionButton from '../components/Profile/PrimaryActionButton.vue'
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,
})
interface IProfileAction {
target: string
icon: string
id: string
title: string
}
interface IStatus {
icon: string,
message: string,
userId: string,
}
export default {
export default defineComponent({
name: 'Profile',
components: {
AccountIcon,
MapMarkerIcon,
NcActionLink,
NcActions,
NcAppContent,
NcAvatar,
MapMarkerIcon,
NcButton,
NcContent,
NcEmptyContent,
PencilIcon,
PrimaryActionButton,
},
data() {
const profileParameters = loadState('core', 'profileParameters', {
userId: null as string|null,
displayname: null as string|null,
address: null as string|null,
organisation: null as string|null,
role: null as string|null,
headline: null as string|null,
biography: null as string|null,
actions: [] as IProfileAction[],
isUserAvatarVisible: false,
})
return {
status,
userId,
displayname,
address,
organisation,
role,
headline,
biography,
actions,
isUserAvatarVisible,
sections: OCA.Core.ProfileSections.getSections(),
...profileParameters,
status: loadState<Partial<IStatus>>('core', 'status', {}),
sections: window.OCA.Core.ProfileSections.getSections(),
}
},
@ -232,33 +220,22 @@ export default {
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)
console.warn(this.allActions)
if (this.allActions.length > 1) {
return this.allActions.slice(1)
}
return null
return []
},
settingsUrl() {
return generateUrl('/settings/user')
},
colorMainBackground() {
// For some reason the returned string has prepended whitespace
return getComputedStyle(document.body).getPropertyValue('--color-main-background').trim()
},
emptyProfileMessage() {
return this.isCurrentUser
? t('core', 'You have not added any info yet')
: t('core', '{user} has not added any info yet', { user: (this.displayname || this.userId) })
: t('core', '{user} has not added any info yet', { user: (this.displayname || this.userId!) })
},
},
@ -273,14 +250,16 @@ export default {
},
methods: {
handleStatusUpdate(status) {
t,
handleStatusUpdate(status: IStatus) {
if (this.isCurrentUser && status.userId === this.userId) {
this.status = status
}
},
openStatusModal() {
const statusMenuItem = document.querySelector('.user-status-menu-item__toggle')
const statusMenuItem = document.querySelector<HTMLButtonElement>('.user-status-menu-item')
// Changing the user status is only enabled if you are the current user
if (this.isCurrentUser) {
if (statusMenuItem) {
@ -291,25 +270,17 @@ export default {
}
},
},
}
})
</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;
:deep(#app-content-vue) {
background-color: unset;
}
.profile {
width: 100%;
overflow-y: auto;
@ -336,74 +307,17 @@ $content-max-width: 640px;
grid-row: 1 / 3;
}
&__displayname, &__status-text {
color: var(--color-main-text);
}
&__displayname {
padding-inline: 16px; // same as the status text button, see NcButton
width: $content-max-width;
height: 45px;
margin-top: 128px;
// Override the global style declaration
margin-bottom: 0;
font-size: 30px;
margin-block: 100px 0;
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-text);
background-color: var(--color-primary-element);
box-shadow: 0 0 0 2px var(--color-primary-element);
border-radius: var(--border-radius-pill);
padding: 0 18px;
font-size: var(--default-font-size);
height: 44px;
line-height: 44px;
font-weight: bold;
&:hover,
&:focus,
&:active {
color: var(--color-primary-element-light-text);
background-color: var(--color-primary-element-light);
}
gap: 18px;
.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: -12px;
margin-top: 2px;
&.interactive {
cursor: pointer;
&:hover,
&:focus,
&:active {
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);
}
h2 {
font-size: 30px;
}
}
}
@ -411,26 +325,26 @@ $content-max-width: 640px;
&__sidebar {
position: sticky;
top: var(--header-height);
top: 0;
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 {
:deep(.avatar.avatardiv) {
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;
}
&.interactive {
.avatardiv__user-status {
// Show that the status is interactive
cursor: pointer;
}
}
&::v-deep .avatar.avatardiv {
.avatardiv__user-status {
right: 14px;
bottom: 14px;
@ -444,18 +358,6 @@ $content-max-width: 640px;
font-size: 20px;
}
}
&::v-deep .avatar.interactive.avatardiv {
.avatardiv__user-status {
cursor: pointer;
&:hover,
&:focus,
&:active {
box-shadow: 0 3px 6px var(--color-box-shadow);
}
}
}
}
&__wrapper {
@ -477,6 +379,7 @@ $content-max-width: 640px;
width: $content-max-width;
p, h3 {
cursor: text;
overflow-wrap: anywhere;
}
@ -497,36 +400,15 @@ $content-max-width: 640px;
}
&-headline {
margin-top: 10px;
h3 {
font-weight: bold;
font-size: 20px;
margin: 0;
}
margin-inline: 0;
margin-block: 10px 0;
font-weight: bold;
font-size: 20px;
}
&-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;
}
}
}
}
@ -568,10 +450,6 @@ $content-max-width: 640px;
max-width: 600px;
margin: 0 auto;
padding: 20px 50px 50px 50px;
&-empty-info {
margin: 0;
}
}
&__sidebar {
@ -589,21 +467,25 @@ $content-max-width: 640px;
&__primary {
margin: 0 auto;
&__icon {
filter: var(--primary-invert-if-dark);
}
}
&__other {
display: flex;
justify-content: center;
gap: 0 4px;
a {
&__icon {
height: 20px;
width: 20px;
object-fit: contain;
filter: var(--background-invert-if-dark);
align-self: center;
margin: 12px; // so we get 44px x 44px
}
}
}
.icon-invert {
&::v-deep .action-link__icon {
filter: invert(1);
}
}
</style>

@ -1,5 +1 @@
<div
id="vue-profile"
class="icon-loading"
style="width: 100%;">
</div>
<div id="content"></div>

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

@ -1,25 +1,3 @@
/**
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @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/>.
*
*/
/**
* @copyright 2021, Christopher Ng <chrng8@gmail.com>
*

File diff suppressed because one or more lines are too long

@ -34,7 +34,7 @@ module.exports = {
login: path.join(__dirname, 'core/src', 'login.js'),
main: path.join(__dirname, 'core/src', 'main.js'),
maintenance: path.join(__dirname, 'core/src', 'maintenance.js'),
profile: path.join(__dirname, 'core/src', 'profile.js'),
profile: path.join(__dirname, 'core/src', 'profile.ts'),
recommendedapps: path.join(__dirname, 'core/src', 'recommendedapps.js'),
systemtags: path.resolve(__dirname, 'core/src', 'systemtags/merged-systemtags.js'),
'unified-search': path.join(__dirname, 'core/src', 'unified-search.js'),

Loading…
Cancel
Save