Merge pull request #46880 from nextcloud/fix/account-menu
fix: Make account menu follow the design (use nextcloud-vue components)pull/45091/head
commit
f1cc819c17
@ -0,0 +1,239 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<NcHeaderMenu id="user-menu"
|
||||
class="account-menu"
|
||||
is-nav
|
||||
:aria-label="t('core', 'Settings menu')"
|
||||
:description="avatarDescription">
|
||||
<template #trigger>
|
||||
<!-- The `key` is a hack as NcAvatar does not handle updating the preloaded status on show status change -->
|
||||
<NcAvatar :key="String(showUserStatus)"
|
||||
class="account-menu__avatar"
|
||||
disable-menu
|
||||
disable-tooltip
|
||||
:show-user-status="showUserStatus"
|
||||
:user="currentUserId"
|
||||
:preloaded-user-status="userStatus" />
|
||||
</template>
|
||||
<ul class="account-menu__list">
|
||||
<AccountMenuProfileEntry :id="profileEntry.id"
|
||||
:name="profileEntry.name"
|
||||
:href="profileEntry.href"
|
||||
:active="profileEntry.active" />
|
||||
<AccountMenuEntry v-for="entry in otherEntries"
|
||||
:id="entry.id"
|
||||
:key="entry.id"
|
||||
:name="entry.name"
|
||||
:href="entry.href"
|
||||
:active="entry.active"
|
||||
:icon="entry.icon" />
|
||||
</ul>
|
||||
</NcHeaderMenu>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import { emit, subscribe } from '@nextcloud/event-bus'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
import { getCapabilities } from '@nextcloud/capabilities'
|
||||
import { defineComponent } from 'vue'
|
||||
import { getAllStatusOptions } from '../../../apps/user_status/src/services/statusOptionsService.js'
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
import logger from '../logger.js'
|
||||
|
||||
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
|
||||
import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
|
||||
import AccountMenuProfileEntry from '../components/AccountMenu/AccountMenuProfileEntry.vue'
|
||||
import AccountMenuEntry from '../components/AccountMenu/AccountMenuEntry.vue'
|
||||
|
||||
interface ISettingsNavigationEntry {
|
||||
/**
|
||||
* id of the entry, used as HTML ID, for example, "settings"
|
||||
*/
|
||||
id: string
|
||||
/**
|
||||
* Label of the entry, for example, "Personal Settings"
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* Icon of the entry, for example, "/apps/settings/img/personal.svg"
|
||||
*/
|
||||
icon: string
|
||||
/**
|
||||
* Type of the entry
|
||||
*/
|
||||
type: 'settings'|'link'|'guest'
|
||||
/**
|
||||
* Link of the entry, for example, "/settings/user"
|
||||
*/
|
||||
href: string
|
||||
/**
|
||||
* Whether the entry is active
|
||||
*/
|
||||
active: boolean
|
||||
/**
|
||||
* Order of the entry
|
||||
*/
|
||||
order: number
|
||||
/**
|
||||
* Number of unread pf this items
|
||||
*/
|
||||
unread: number
|
||||
/**
|
||||
* Classes for custom styling
|
||||
*/
|
||||
classes: string
|
||||
}
|
||||
|
||||
const USER_DEFINABLE_STATUSES = getAllStatusOptions()
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AccountMenu',
|
||||
|
||||
components: {
|
||||
AccountMenuEntry,
|
||||
AccountMenuProfileEntry,
|
||||
NcAvatar,
|
||||
NcHeaderMenu,
|
||||
},
|
||||
|
||||
setup() {
|
||||
const settingsNavEntries = loadState<Record<string, ISettingsNavigationEntry>>('core', 'settingsNavEntries', {})
|
||||
const { profile: profileEntry, ...otherEntries } = settingsNavEntries
|
||||
|
||||
return {
|
||||
currentDisplayName: getCurrentUser()?.displayName ?? getCurrentUser()!.uid,
|
||||
currentUserId: getCurrentUser()!.uid,
|
||||
|
||||
profileEntry,
|
||||
otherEntries,
|
||||
|
||||
t,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
showUserStatus: false,
|
||||
userStatus: {
|
||||
status: null,
|
||||
icon: null,
|
||||
message: null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
translatedUserStatus() {
|
||||
return {
|
||||
...this.userStatus,
|
||||
status: this.translateStatus(this.userStatus.status),
|
||||
}
|
||||
},
|
||||
|
||||
avatarDescription() {
|
||||
const description = [
|
||||
t('core', 'Avatar of {displayName}', { displayName: this.currentDisplayName }),
|
||||
...Object.values(this.translatedUserStatus).filter(Boolean),
|
||||
].join(' — ')
|
||||
return description
|
||||
},
|
||||
},
|
||||
|
||||
async created() {
|
||||
if (!getCapabilities()?.user_status?.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = generateOcsUrl('/apps/user_status/api/v1/user_status')
|
||||
try {
|
||||
const response = await axios.get(url)
|
||||
const { status, icon, message } = response.data.ocs.data
|
||||
this.userStatus = { status, icon, message }
|
||||
} catch (e) {
|
||||
logger.error('Failed to load user status')
|
||||
}
|
||||
this.showUserStatus = true
|
||||
},
|
||||
|
||||
mounted() {
|
||||
subscribe('user_status:status.updated', this.handleUserStatusUpdated)
|
||||
emit('core:user-menu:mounted')
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleUserStatusUpdated(state) {
|
||||
if (this.currentUserId === state.userId) {
|
||||
this.userStatus = {
|
||||
status: state.status,
|
||||
icon: state.icon,
|
||||
message: state.message,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
translateStatus(status) {
|
||||
const statusMap = Object.fromEntries(
|
||||
USER_DEFINABLE_STATUSES.map(({ type, label }) => [type, label]),
|
||||
)
|
||||
if (statusMap[status]) {
|
||||
return statusMap[status]
|
||||
}
|
||||
return status
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(#header-menu-user-menu) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.account-menu {
|
||||
:deep(button) {
|
||||
// Normally header menus are slightly translucent when not active
|
||||
// this is generally ok but for the avatar this is weird so fix the opacity
|
||||
opacity: 1 !important;
|
||||
|
||||
// The avatar is just the "icon" of the button
|
||||
// So we add the focus-visible manually
|
||||
&:focus-visible {
|
||||
.account-menu__avatar {
|
||||
border: var(--border-width-input-focused) solid var(--color-background-plain-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we do not wast space, as the header menu sets a default width of 350px
|
||||
:deep(.header-menu__content) {
|
||||
width: fit-content !important;
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
&:hover {
|
||||
// Add hover styles similar to the focus-visible style
|
||||
border: var(--border-width-input-focused) solid var(--color-background-plain-text);
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
padding-block: var(--default-grid-baseline) 0;
|
||||
padding-inline: 0 var(--default-grid-baseline);
|
||||
|
||||
> :deep(li) {
|
||||
box-sizing: border-box;
|
||||
// basically "fit-content"
|
||||
flex: 0 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,259 +0,0 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<NcHeaderMenu id="user-menu"
|
||||
class="user-menu"
|
||||
is-nav
|
||||
:aria-label="t('core', 'Settings menu')"
|
||||
:description="avatarDescription">
|
||||
<template #trigger>
|
||||
<NcAvatar v-if="!isLoadingUserStatus"
|
||||
class="user-menu__avatar"
|
||||
:disable-menu="true"
|
||||
:disable-tooltip="true"
|
||||
:user="userId"
|
||||
:preloaded-user-status="userStatus" />
|
||||
</template>
|
||||
<ul>
|
||||
<ProfileUserMenuEntry :id="profileEntry.id"
|
||||
:name="profileEntry.name"
|
||||
:href="profileEntry.href"
|
||||
:active="profileEntry.active" />
|
||||
<UserMenuEntry v-for="entry in otherEntries"
|
||||
:id="entry.id"
|
||||
:key="entry.id"
|
||||
:name="entry.name"
|
||||
:href="entry.href"
|
||||
:active="entry.active"
|
||||
:icon="entry.icon" />
|
||||
</ul>
|
||||
</NcHeaderMenu>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from '@nextcloud/axios'
|
||||
import { emit, subscribe } from '@nextcloud/event-bus'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import { getCapabilities } from '@nextcloud/capabilities'
|
||||
|
||||
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
|
||||
import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
|
||||
|
||||
import { getAllStatusOptions } from '../../../apps/user_status/src/services/statusOptionsService.js'
|
||||
import ProfileUserMenuEntry from '../components/UserMenu/ProfileUserMenuEntry.vue'
|
||||
import UserMenuEntry from '../components/UserMenu/UserMenuEntry.vue'
|
||||
|
||||
import logger from '../logger.js'
|
||||
|
||||
/**
|
||||
* @typedef SettingNavEntry
|
||||
* @property {string} id - id of the entry, used as HTML ID, for example, "settings"
|
||||
* @property {string} name - Label of the entry, for example, "Personal Settings"
|
||||
* @property {string} icon - Icon of the entry, for example, "/apps/settings/img/personal.svg"
|
||||
* @property {'settings'|'link'|'guest'} type - Type of the entry
|
||||
* @property {string} href - Link of the entry, for example, "/settings/user"
|
||||
* @property {boolean} active - Whether the entry is active
|
||||
* @property {number} order - Order of the entry
|
||||
* @property {number} unread - Number of unread pf this items
|
||||
* @property {string} classes - Classes for custom styling
|
||||
*/
|
||||
|
||||
/** @type {Record<string, SettingNavEntry>} */
|
||||
const settingsNavEntries = loadState('core', 'settingsNavEntries', [])
|
||||
const { profile: profileEntry, ...otherEntries } = settingsNavEntries
|
||||
|
||||
const translateStatus = (status) => {
|
||||
const statusMap = Object.fromEntries(
|
||||
getAllStatusOptions()
|
||||
.map(({ type, label }) => [type, label]),
|
||||
)
|
||||
if (statusMap[status]) {
|
||||
return statusMap[status]
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'UserMenu',
|
||||
|
||||
components: {
|
||||
NcAvatar,
|
||||
NcHeaderMenu,
|
||||
ProfileUserMenuEntry,
|
||||
UserMenuEntry,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
profileEntry,
|
||||
otherEntries,
|
||||
displayName: getCurrentUser()?.displayName,
|
||||
userId: getCurrentUser()?.uid,
|
||||
isLoadingUserStatus: true,
|
||||
userStatus: {
|
||||
status: null,
|
||||
icon: null,
|
||||
message: null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
translatedUserStatus() {
|
||||
return {
|
||||
...this.userStatus,
|
||||
status: translateStatus(this.userStatus.status),
|
||||
}
|
||||
},
|
||||
|
||||
avatarDescription() {
|
||||
const description = [
|
||||
t('core', 'Avatar of {displayName}', { displayName: this.displayName }),
|
||||
...Object.values(this.translatedUserStatus).filter(Boolean),
|
||||
].join(' — ')
|
||||
return description
|
||||
},
|
||||
},
|
||||
|
||||
async created() {
|
||||
if (!getCapabilities()?.user_status?.enabled) {
|
||||
this.isLoadingUserStatus = false
|
||||
return
|
||||
}
|
||||
|
||||
const url = generateOcsUrl('/apps/user_status/api/v1/user_status')
|
||||
try {
|
||||
const response = await axios.get(url)
|
||||
const { status, icon, message } = response.data.ocs.data
|
||||
this.userStatus = { status, icon, message }
|
||||
} catch (e) {
|
||||
logger.error('Failed to load user status')
|
||||
}
|
||||
this.isLoadingUserStatus = false
|
||||
},
|
||||
|
||||
mounted() {
|
||||
subscribe('user_status:status.updated', this.handleUserStatusUpdated)
|
||||
emit('core:user-menu:mounted')
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleUserStatusUpdated(state) {
|
||||
if (this.userId === state.userId) {
|
||||
this.userStatus = {
|
||||
status: state.status,
|
||||
icon: state.icon,
|
||||
message: state.message,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-menu {
|
||||
&:deep {
|
||||
.header-menu {
|
||||
&__trigger {
|
||||
opacity: 1 !important;
|
||||
&:focus-visible {
|
||||
.user-menu__avatar {
|
||||
border: 2px solid var(--color-primary-element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__carret {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&__content {
|
||||
width: fit-content !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
border: 2px solid var(--color-primary-element-text);
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
&:deep {
|
||||
li {
|
||||
a,
|
||||
button {
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: var(--header-menu-item-height);
|
||||
color: var(--color-main-text);
|
||||
padding: 10px 8px;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-hover);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
background-color: var(--color-background-hover) !important;
|
||||
box-shadow: inset 0 0 0 2px var(--color-primary-element) !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
&:active:not(:focus-visible),
|
||||
&.active:not(:focus-visible) {
|
||||
background-color: var(--color-primary-element);
|
||||
color: var(--color-primary-element-text);
|
||||
|
||||
img {
|
||||
filter: var(--primary-invert-if-dark);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
padding-bottom: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 210px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
img {
|
||||
filter: var(--background-invert-if-dark);
|
||||
}
|
||||
}
|
||||
|
||||
// Override global button styles
|
||||
button {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
font-weight: normal;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
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
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
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
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue