enh(core): Refactor profile page to use vue components

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
pull/42986/head
Ferdinand Thiessen 5 months ago
parent 74f3d0fd45
commit e20a87bf7f
No known key found for this signature in database
GPG Key ID: 45FAE7268762B400

@ -20,17 +20,14 @@
* *
*/ */
import Vue from 'vue'
import { getRequestToken } from '@nextcloud/auth' import { getRequestToken } from '@nextcloud/auth'
import { translate as t } from '@nextcloud/l10n' import Vue from 'vue'
import VTooltip from 'v-tooltip'
import logger from './logger.js'
import Profile from './views/Profile.vue' import Profile from './views/Profile.vue'
import ProfileSections from './profile/ProfileSections.js' 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) { if (!window.OCA) {
window.OCA = {} window.OCA = {}
@ -41,19 +38,8 @@ if (!window.OCA.Core) {
} }
Object.assign(window.OCA.Core, { ProfileSections: new ProfileSections() }) Object.assign(window.OCA.Core, { ProfileSections: new ProfileSections() })
Vue.use(VTooltip)
Vue.mixin({
props: {
logger,
},
methods: {
t,
},
})
const View = Vue.extend(Profile) const View = Vue.extend(Profile)
window.addEventListener('DOMContentLoaded', () => { window.addEventListener('DOMContentLoaded', () => {
new View().$mount('#vue-profile') new View().$mount('#content')
}) })

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

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

@ -35,7 +35,7 @@ module.exports = {
login: path.join(__dirname, 'core/src', 'login.js'), login: path.join(__dirname, 'core/src', 'login.js'),
main: path.join(__dirname, 'core/src', 'main.js'), main: path.join(__dirname, 'core/src', 'main.js'),
maintenance: path.join(__dirname, 'core/src', 'maintenance.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'), recommendedapps: path.join(__dirname, 'core/src', 'recommendedapps.js'),
systemtags: path.resolve(__dirname, 'core/src', 'systemtags/merged-systemtags.js'), systemtags: path.resolve(__dirname, 'core/src', 'systemtags/merged-systemtags.js'),
'unified-search': path.join(__dirname, 'core/src', 'unified-search.js'), 'unified-search': path.join(__dirname, 'core/src', 'unified-search.js'),

Loading…
Cancel
Save