diff options
Diffstat (limited to 'core/src/views')
-rw-r--r-- | core/src/views/AccountMenu.vue | 247 | ||||
-rw-r--r-- | core/src/views/ContactsMenu.vue | 45 | ||||
-rw-r--r-- | core/src/views/LegacyUnifiedSearch.vue | 61 | ||||
-rw-r--r-- | core/src/views/Login.vue | 103 | ||||
-rw-r--r-- | core/src/views/Profile.vue | 473 | ||||
-rw-r--r-- | core/src/views/PublicPageMenu.vue | 131 | ||||
-rw-r--r-- | core/src/views/PublicPageUserMenu.vue | 138 | ||||
-rw-r--r-- | core/src/views/Setup.cy.ts | 369 | ||||
-rw-r--r-- | core/src/views/Setup.vue | 460 | ||||
-rw-r--r-- | core/src/views/UnifiedSearch.vue | 191 | ||||
-rw-r--r-- | core/src/views/UnifiedSearchModal.vue | 700 | ||||
-rw-r--r-- | core/src/views/UnsupportedBrowser.vue | 9 | ||||
-rw-r--r-- | core/src/views/UserMenu.vue | 261 |
13 files changed, 1590 insertions, 1598 deletions
diff --git a/core/src/views/AccountMenu.vue b/core/src/views/AccountMenu.vue new file mode 100644 index 00000000000..5b7ead636bd --- /dev/null +++ b/core/src/views/AccountMenu.vue @@ -0,0 +1,247 @@ +<!-- + - 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/components/NcAvatar' +import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu' +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 { + &__avatar { + --account-menu-outline: var(--border-width-input) solid color-mix(in srgb, var(--color-background-plain-text), transparent 75%); + outline: var(--account-menu-outline); + position: fixed; + // do not apply the alpha mask on the avatar div + mask: none !important; + + &:hover { + --account-menu-outline: none; + // 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; + } + } + + // Ensure we do not waste space, as the header menu sets a default width of 350px + :deep(.header-menu__content) { + width: fit-content !important; + } + + :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 { + --account-menu-outline: none; + border: var(--border-width-input-focused) solid var(--color-background-plain-text); + } + } + } +} +</style> diff --git a/core/src/views/ContactsMenu.vue b/core/src/views/ContactsMenu.vue index 51eea0a0fb1..924ddcea56b 100644 --- a/core/src/views/ContactsMenu.vue +++ b/core/src/views/ContactsMenu.vue @@ -9,25 +9,25 @@ :aria-label="t('core', 'Search contacts')" @open="handleOpen"> <template #trigger> - <Contacts class="contactsmenu__trigger-icon" :size="20" /> + <NcIconSvgWrapper class="contactsmenu__trigger-icon" :path="mdiContacts" /> </template> <div class="contactsmenu__menu"> <div class="contactsmenu__menu__input-wrapper"> - <NcTextField :value.sync="searchTerm" - trailing-button-icon="close" + <NcTextField id="contactsmenu__menu__search" ref="contactsMenuInput" + :value.sync="searchTerm" + trailing-button-icon="close" :label="t('core', 'Search contacts')" :trailing-button-label="t('core','Reset search')" :show-trailing-button="searchTerm !== ''" :placeholder="t('core', 'Search contacts …')" - id="contactsmenu__menu__search" class="contactsmenu__menu__search" @input="onInputDebounced" @trailing-button-click="onReset" /> </div> <NcEmptyContent v-if="error" :name="t('core', 'Could not load your contacts')"> <template #icon> - <Magnify /> + <NcIconSvgWrapper :path="mdiMagnify" /> </template> </NcEmptyContent> <NcEmptyContent v-else-if="loadingText" :name="loadingText"> @@ -37,7 +37,7 @@ </NcEmptyContent> <NcEmptyContent v-else-if="contacts.length === 0" :name="t('core', 'No contacts found')"> <template #icon> - <Magnify /> + <NcIconSvgWrapper :path="mdiMagnify" /> </template> </NcEmptyContent> <div v-else class="contactsmenu__menu__content"> @@ -62,39 +62,46 @@ </template> <script> +import { mdiContacts, mdiMagnify } from '@mdi/js' +import { generateUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import { t } from '@nextcloud/l10n' import axios from '@nextcloud/axios' -import Contacts from 'vue-material-design-icons/Contacts.vue' import debounce from 'debounce' -import { getCurrentUser } from '@nextcloud/auth' -import { generateUrl } from '@nextcloud/router' -import Magnify from 'vue-material-design-icons/Magnify.vue' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' -import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' -import { translate as t } from '@nextcloud/l10n' + +import NcButton from '@nextcloud/vue/components/NcButton' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import NcTextField from '@nextcloud/vue/components/NcTextField' import Contact from '../components/ContactsMenu/Contact.vue' import logger from '../logger.js' import Nextcloud from '../mixins/Nextcloud.js' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' export default { name: 'ContactsMenu', components: { Contact, - Contacts, - Magnify, NcButton, NcEmptyContent, NcHeaderMenu, + NcIconSvgWrapper, NcLoadingIcon, NcTextField, }, mixins: [Nextcloud], + setup() { + return { + mdiContacts, + mdiMagnify, + } + }, + data() { const user = getCurrentUser() return { @@ -185,7 +192,7 @@ export default { label[for="contactsmenu__menu__search"] { font-weight: bold; font-size: 19px; - margin-left: 13px; + margin-inline-start: 13px; } &__input-wrapper { diff --git a/core/src/views/LegacyUnifiedSearch.vue b/core/src/views/LegacyUnifiedSearch.vue index 72a30b5b708..1277970ba0e 100644 --- a/core/src/views/LegacyUnifiedSearch.vue +++ b/core/src/views/LegacyUnifiedSearch.vue @@ -108,11 +108,11 @@ import debounce from 'debounce' import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' import { showError } from '@nextcloud/dialogs' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' -import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu' +import NcTextField from '@nextcloud/vue/components/NcTextField' import Magnify from 'vue-material-design-icons/Magnify.vue' @@ -270,7 +270,7 @@ export default { return n('core', 'Please enter {minSearchLength} character or more to search', - 'Please enter {minSearchLength} characters or more to search', + 'Please enter {minSearchLength} characters or more to search', this.minSearchLength, { minSearchLength: this.minSearchLength }) }, @@ -724,17 +724,7 @@ $input-padding: 10px; align-self: flex-start; font-weight: bold; font-size: 19px; - margin-left: 13px; - } - } - - &__form-input { - margin: 0 !important; - &:focus, - &:focus-visible, - &:active { - border-color: 2px solid var(--color-main-text) !important; - box-shadow: 0 0 0 2px var(--color-main-background) !important; + margin-inline-start: 13px; } } @@ -745,7 +735,8 @@ $input-padding: 10px; } &__filters { - margin: $margin 0 $margin math.div($margin, 2); + margin-block: $margin; + margin-inline: math.div($margin, 2) 0; padding-top: 5px; ul { display: inline-flex; @@ -760,8 +751,7 @@ $input-padding: 10px; // Loading spinner &::after { - right: $input-padding; - left: auto; + inset-inline-start: auto $input-padding; } &-input, @@ -774,6 +764,13 @@ $input-padding: 10px; height: $input-height; padding: $input-padding; + &:focus, + &:focus-visible, + &:active { + border-color: 2px solid var(--color-main-text) !important; + box-shadow: 0 0 0 2px var(--color-main-background) !important; + } + &, &[placeholder], &::placeholder { @@ -791,10 +788,11 @@ $input-padding: 10px; } } - &-reset, &-submit { + &-reset, + &-submit { position: absolute; top: 0; - right: 4px; + inset-inline-end: 4px; width: $input-height - $input-padding; height: $input-height - $input-padding; min-height: 30px; @@ -802,7 +800,7 @@ $input-padding: 10px; opacity: .5; border: none; background-color: transparent; - margin-right: 0; + margin-inline-end: 0; &:hover, &:focus, @@ -812,35 +810,36 @@ $input-padding: 10px; } &-submit { - right: 28px; + inset-inline-end: 28px; } } &__results { + display: flex; + flex-direction: column; + gap: 4px; + &-header { display: block; margin: $margin; margin-bottom: $margin - 4px; - margin-left: 13px; + margin-inline-start: 13px; color: var(--color-primary-element); font-size: 19px; font-weight: bold; } - display: flex; - flex-direction: column; - gap: 4px; } - .unified-search__result-more::v-deep { + :deep(.unified-search__result-more) { color: var(--color-text-maxcontrast); } .empty-content { margin: 10vh 0; - ::v-deep .empty-content__title { + :deep(.empty-content__title) { font-weight: normal; - font-size: var(--default-font-size); + font-size: var(--default-font-size); text-align: center; } } diff --git a/core/src/views/Login.vue b/core/src/views/Login.vue index d6c88d607ad..a6fe8442779 100644 --- a/core/src/views/Login.vue +++ b/core/src/views/Login.vue @@ -7,7 +7,7 @@ <div class="guest-box login-box"> <template v-if="!hideLoginForm || directLogin"> <transition name="fade" mode="out-in"> - <div v-if="!passwordlessLogin && !resetPassword && resetPasswordTarget === ''"> + <div v-if="!passwordlessLogin && !resetPassword && resetPasswordTarget === ''" class="login-box__wrapper"> <LoginForm :username.sync="user" :redirect-url="redirectUrl" :direct-login="directLogin" @@ -17,40 +17,30 @@ :auto-complete-allowed="autoCompleteAllowed" :email-states="emailStates" @submit="loading = true" /> - <a v-if="canResetPassword && resetPasswordLink !== ''" + <NcButton v-if="hasPasswordless" + type="tertiary" + wide + @click.prevent="passwordlessLogin = true"> + {{ t('core', 'Log in with a device') }} + </NcButton> + <NcButton v-if="canResetPassword && resetPasswordLink !== ''" id="lost-password" - class="login-box__link" - :href="resetPasswordLink"> + :href="resetPasswordLink" + type="tertiary-no-background" + wide> {{ t('core', 'Forgot password?') }} - </a> - <a v-else-if="canResetPassword && !resetPassword" + </NcButton> + <NcButton v-else-if="canResetPassword && !resetPassword" id="lost-password" - class="login-box__link" - :href="resetPasswordLink" + type="tertiary" + wide @click.prevent="resetPassword = true"> {{ t('core', 'Forgot password?') }} - </a> - <template v-if="hasPasswordless"> - <div v-if="countAlternativeLogins" - class="alternative-logins"> - <a v-if="hasPasswordless" - class="button" - :class="{ 'single-alt-login-option': countAlternativeLogins }" - href="#" - @click.prevent="passwordlessLogin = true"> - {{ t('core', 'Log in with a device') }} - </a> - </div> - <a v-else - href="#" - @click.prevent="passwordlessLogin = true"> - {{ t('core', 'Log in with a device') }} - </a> - </template> + </NcButton> </div> <div v-else-if="!loading && passwordlessLogin" key="reset-pw-less" - class="login-additional login-passwordless"> + class="login-additional login-box__wrapper"> <PasswordLessLoginForm :username.sync="user" :redirect-url="redirectUrl" :auto-complete-allowed="autoCompleteAllowed" @@ -89,7 +79,7 @@ </transition> </template> - <div id="alternative-logins" class="alternative-logins"> + <div id="alternative-logins" class="login-box__alternative-logins"> <NcButton v-for="(alternativeLogin, index) in alternativeLogins" :key="index" type="secondary" @@ -105,24 +95,21 @@ <script> import { loadState } from '@nextcloud/initial-state' +import { generateUrl } from '@nextcloud/router' + import queryString from 'query-string' import LoginForm from '../components/login/LoginForm.vue' import PasswordLessLoginForm from '../components/login/PasswordLessLoginForm.vue' import ResetPassword from '../components/login/ResetPassword.vue' import UpdatePassword from '../components/login/UpdatePassword.vue' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import { wipeBrowserStorages } from '../utils/xhr-request.js' const query = queryString.parse(location.search) if (query.clear === '1') { - try { - window.localStorage.clear() - window.sessionStorage.clear() - console.debug('Browser storage cleared') - } catch (e) { - console.error('Could not clear browser storage', e) - } + wipeBrowserStorages() } export default { @@ -167,52 +154,36 @@ export default { methods: { passwordResetFinished() { - this.resetPasswordTarget = '' - this.directLogin = true + window.location.href = generateUrl('login') }, }, } </script> -<style lang="scss"> -body { - font-size: var(--default-font-size); -} - +<style scoped lang="scss"> .login-box { // Same size as dashboard panels width: 320px; box-sizing: border-box; - &__link { - display: block; - padding: 1rem; - font-size: var(--default-font-size); - text-align: center; - font-weight: normal !important; + &__wrapper { + display: flex; + flex-direction: column; + gap: calc(2 * var(--default-grid-baseline)); + } + + &__alternative-logins { + display: flex; + flex-direction: column; + gap: 0.75rem; } } .fade-enter-active, .fade-leave-active { transition: opacity .3s; } + .fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ { opacity: 0; } - -.alternative-logins { - display: flex; - flex-direction: column; - gap: 0.75rem; - - .button-vue { - box-sizing: border-box; - } -} - -.login-passwordless { - .button-vue { - margin-top: 0.5rem; - } -} </style> diff --git a/core/src/views/Profile.vue b/core/src/views/Profile.vue deleted file mode 100644 index ab63cadc57d..00000000000 --- a/core/src/views/Profile.vue +++ /dev/null @@ -1,473 +0,0 @@ -<!-- - - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors - - SPDX-License-Identifier: AGPL-3.0-or-later ---> - -<template> - <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 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> - </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" - :size="16" /> - {{ address }} - </p> - </div> - </div> - <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> - - <!-- 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> - </NcAppContent> - </NcContent> -</template> - -<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 { defineComponent } from 'vue' - -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' - -interface IProfileAction { - target: string - icon: string - id: string - title: string -} - -interface IStatus { - icon: string, - message: string, - userId: string, -} - -export default defineComponent({ - name: 'Profile', - - components: { - AccountIcon, - MapMarkerIcon, - NcActionLink, - NcActions, - NcAppContent, - NcAvatar, - NcButton, - NcContent, - NcEmptyContent, - PencilIcon, - }, - - 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 { - ...profileParameters, - status: loadState<Partial<IStatus>>('core', 'status', {}), - sections: window.OCA.Core.ProfileSections.getSections(), - } - }, - - computed: { - isCurrentUser() { - return getCurrentUser()?.uid === this.userId - }, - - allActions() { - return this.actions - }, - - primaryAction() { - if (this.allActions.length) { - return this.allActions[0] - } - return null - }, - - otherActions() { - console.warn(this.allActions) - if (this.allActions.length > 1) { - return this.allActions.slice(1) - } - return [] - }, - - settingsUrl() { - return generateUrl('/settings/user') - }, - - 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!) }) - }, - }, - - mounted() { - // Set the user's displayname or userId in the page title and preserve the default title of "Nextcloud" at the end - document.title = `${this.displayname || this.userId} - ${document.title}` - subscribe('user_status:status.updated', this.handleStatusUpdate) - }, - - beforeDestroy() { - unsubscribe('user_status:status.updated', this.handleStatusUpdate) - }, - - methods: { - t, - - handleStatusUpdate(status: IStatus) { - if (this.isCurrentUser && status.userId === this.userId) { - this.status = status - } - }, - - openStatusModal() { - 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) { - statusMenuItem.click() - } else { - showError(t('core', 'Error opening the user status modal, try hard refreshing the page')) - } - } - }, - }, -}) -</script> - -<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; - - &__header { - position: sticky; - height: 190px; - top: -40px; - background-color: var(--color-main-background-blur); - backdrop-filter: var(--filter-background-blur); - -webkit-backdrop-filter: var(--filter-background-blur); - - &__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 { - padding-inline: 16px; // same as the status text button, see NcButton - width: $content-max-width; - height: 45px; - margin-block: 100px 0; - display: flex; - align-items: center; - gap: 18px; - - h2 { - font-size: 30px; - } - } - } - } - - &__sidebar { - position: sticky; - 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 - :deep(.avatar.avatardiv) { - text-align: center; - margin: auto; - display: block; - padding: 8px; - - &.interactive { - .avatardiv__user-status { - // Show that the status is interactive - cursor: pointer; - } - } - - .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; - } - } - } - - &__wrapper { - background-color: var(--color-main-background); - min-height: 100%; - } - - &__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 { - cursor: text; - 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-inline: 0; - margin-block: 10px 0; - font-weight: bold; - font-size: 20px; - } - - &-biography { - white-space: pre-line; - } - } -} - -@media only screen and (max-width: 1024px) { - .profile { - &__header { - height: 250px; - position: unset; - - &__container { - grid-template-columns: unset; - - &__displayname { - margin: 80px 20px 0px!important; - height: 1em; - width: unset; - display: unset; - text-align: center; - } - - &__edit-button { - width: fit-content; - display: block; - margin: 60px auto; - } - - &__status-text { - margin: 4px auto; - } - } - } - - &__content { - display: block; - } - - &__blocks { - width: unset; - max-width: 600px; - margin: 0 auto; - padding: 20px 50px 50px 50px; - } - - &__sidebar { - margin: unset; - position: unset; - } - } -} - -.user-actions { - display: flex; - flex-direction: column; - gap: 8px 0; - margin-top: 20px; - - &__primary { - margin: 0 auto; - - &__icon { - filter: var(--primary-invert-if-dark); - } - } - - &__other { - display: flex; - justify-content: center; - gap: 0 4px; - - &__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 - } - } -} -</style> diff --git a/core/src/views/PublicPageMenu.vue b/core/src/views/PublicPageMenu.vue new file mode 100644 index 00000000000..a05f3a6b889 --- /dev/null +++ b/core/src/views/PublicPageMenu.vue @@ -0,0 +1,131 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <div class="public-page-menu__wrapper"> + <NcButton v-if="primaryAction" + id="public-page-menu--primary" + class="public-page-menu__primary" + :href="primaryAction.href" + type="primary" + @click="openDialogIfNeeded"> + <template v-if="primaryAction.icon" #icon> + <div :class="['icon', primaryAction.icon, 'public-page-menu__primary-icon']" /> + </template> + {{ primaryAction.label }} + </NcButton> + + <NcHeaderMenu v-if="secondaryActions.length > 0" + id="public-page-menu" + :aria-label="t('core', 'More actions')" + :open.sync="showMenu"> + <template #trigger> + <IconMore :size="20" /> + </template> + <ul :aria-label="t('core', 'More actions')" + class="public-page-menu" + role="menu"> + <component :is="getComponent(entry)" + v-for="entry, index in secondaryActions" + :key="index" + v-bind="entry" + @click="showMenu = false" /> + </ul> + </NcHeaderMenu> + </div> +</template> + +<script setup lang="ts"> +import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import { useIsSmallMobile } from '@nextcloud/vue/composables/useIsMobile' +import { spawnDialog } from '@nextcloud/vue/functions/dialog' +import { computed, ref, type Ref } from 'vue' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu' +import IconMore from 'vue-material-design-icons/DotsHorizontal.vue' +import PublicPageMenuEntry from '../components/PublicPageMenu/PublicPageMenuEntry.vue' +import PublicPageMenuCustomEntry from '../components/PublicPageMenu/PublicPageMenuCustomEntry.vue' +import PublicPageMenuExternalEntry from '../components/PublicPageMenu/PublicPageMenuExternalEntry.vue' +import PublicPageMenuExternalDialog from '../components/PublicPageMenu/PublicPageMenuExternalDialog.vue' +import PublicPageMenuLinkEntry from '../components/PublicPageMenu/PublicPageMenuLinkEntry.vue' + +interface IPublicPageMenu { + id: string + label: string + href: string + icon?: string + html?: string + details?: string +} + +const menuEntries = loadState<Array<IPublicPageMenu>>('core', 'public-page-menu') + +/** used to conditionally close the menu when clicking entry */ +const showMenu = ref(false) + +const isMobile = useIsSmallMobile() as Readonly<Ref<boolean>> +/** The primary menu action - only showed when not on mobile */ +const primaryAction = computed(() => isMobile.value ? undefined : menuEntries[0]) +/** All other secondary actions (including primary action on mobile) */ +const secondaryActions = computed(() => isMobile.value ? menuEntries : menuEntries.slice(1)) + +/** + * Get the render component for an entry + * @param entry The entry to get the component for + */ +function getComponent(entry: IPublicPageMenu) { + if ('html' in entry) { + return PublicPageMenuCustomEntry + } + switch (entry.id) { + case 'save': + return PublicPageMenuExternalEntry + case 'directLink': + return PublicPageMenuLinkEntry + default: + return PublicPageMenuEntry + } +} + +/** + * Open the "federated share" dialog if needed + */ +function openDialogIfNeeded() { + if (primaryAction.value?.id !== 'save') { + return + } + spawnDialog(PublicPageMenuExternalDialog, { label: primaryAction.value.label }) +} +</script> + +<style scoped lang="scss"> +.public-page-menu { + box-sizing: border-box; + + > :deep(*) { + box-sizing: border-box; + } + + &__wrapper { + display: flex; + flex-direction: row; + gap: var(--default-grid-baseline); + } + + &__primary { + height: var(--default-clickable-area); + margin-block: calc((var(--header-height) - var(--default-clickable-area)) / 2); + + // Ensure the correct focus-visible color is used (as this is rendered directly on the background(-image)) + &:focus-visible { + border-color: var(--color-background-plain-text) !important; + } + } + + &__primary-icon { + filter: var(--primary-invert-if-bright); + } +} +</style> diff --git a/core/src/views/PublicPageUserMenu.vue b/core/src/views/PublicPageUserMenu.vue new file mode 100644 index 00000000000..7bd6521e7aa --- /dev/null +++ b/core/src/views/PublicPageUserMenu.vue @@ -0,0 +1,138 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <NcHeaderMenu id="public-page-user-menu" + class="public-page-user-menu" + is-nav + :aria-label="t('core', 'User menu')" + :description="avatarDescription"> + <template #trigger> + <NcAvatar class="public-page-user-menu__avatar" + disable-menu + disable-tooltip + is-guest + :user="displayName || '?'" /> + </template> + + <!-- Privacy notice --> + <NcNoteCard class="public-page-user-menu__list-note" + :text="privacyNotice" + type="info" /> + + <ul class="public-page-user-menu__list"> + <!-- Nickname dialog --> + <AccountMenuEntry id="set-nickname" + :name="!displayName ? t('core', 'Set public name') : t('core', 'Change public name')" + href="#" + @click.prevent.stop="setNickname"> + <template #icon> + <IconAccount /> + </template> + </AccountMenuEntry> + </ul> + </NcHeaderMenu> +</template> + +<script lang="ts"> +import type { NextcloudUser } from '@nextcloud/auth' + +import '@nextcloud/dialogs/style.css' +import { defineComponent } from 'vue' +import { getGuestUser } from '@nextcloud/auth' +import { showGuestUserPrompt } from '@nextcloud/dialogs' +import { subscribe } from '@nextcloud/event-bus' +import { t } from '@nextcloud/l10n' + +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import IconAccount from 'vue-material-design-icons/AccountOutline.vue' + +import AccountMenuEntry from '../components/AccountMenu/AccountMenuEntry.vue' + +export default defineComponent({ + name: 'PublicPageUserMenu', + components: { + AccountMenuEntry, + IconAccount, + NcAvatar, + NcHeaderMenu, + NcNoteCard, + }, + + setup() { + return { + t, + } + }, + + data() { + return { + displayName: getGuestUser().displayName, + } + }, + + computed: { + avatarDescription(): string { + return t('core', 'User menu') + }, + + privacyNotice(): string { + return this.displayName + ? t('core', 'You will be identified as {user} by the account owner.', { user: this.displayName }) + : t('core', 'You are currently not identified.') + }, + }, + + mounted() { + subscribe('user:info:changed', (user: NextcloudUser) => { + this.displayName = user.displayName || '' + }) + }, + + methods: { + setNickname() { + showGuestUserPrompt({ + nickname: this.displayName, + cancellable: true, + }) + }, + }, +}) +</script> + +<style scoped lang="scss"> +.public-page-user-menu { + &, * { + box-sizing: border-box; + } + + // Ensure we do not waste space, as the header menu sets a default width of 350px + :deep(.header-menu__content) { + width: fit-content !important; + } + + &__list-note { + padding-block: 5px !important; + padding-inline: 5px !important; + max-width: 300px; + margin: 5px !important; + margin-bottom: 0 !important; + } + + &__list { + display: inline-flex; + flex-direction: column; + padding-block: var(--default-grid-baseline) 0; + width: 100%; + + > :deep(li) { + box-sizing: border-box; + // basically "fit-content" + flex: 0 1; + } + } +} +</style> diff --git a/core/src/views/Setup.cy.ts b/core/src/views/Setup.cy.ts new file mode 100644 index 00000000000..f252801c4d8 --- /dev/null +++ b/core/src/views/Setup.cy.ts @@ -0,0 +1,369 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { SetupConfig, SetupLinks } from '../install' +import SetupView from './Setup.vue' + +import '../../css/guest.css' + +const defaultConfig = Object.freeze({ + adminlogin: '', + adminpass: '', + dbuser: '', + dbpass: '', + dbname: '', + dbtablespace: '', + dbhost: '', + dbtype: '', + databases: { + sqlite: 'SQLite', + mysql: 'MySQL/MariaDB', + pgsql: 'PostgreSQL', + }, + directory: '', + hasAutoconfig: false, + htaccessWorking: true, + serverRoot: '/var/www/html', + errors: [], +}) as SetupConfig + +const links = { + adminInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-install', + adminSourceInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-source_install', + adminDBConfiguration: 'https://docs.nextcloud.com/server/32/go.php?to=admin-db-configuration', +} as SetupLinks + +describe('Default setup page', () => { + beforeEach(() => { + cy.mockInitialState('core', 'links', links) + }) + + afterEach(() => cy.unmockInitialState()) + + it('Renders default config', () => { + cy.mockInitialState('core', 'config', defaultConfig) + cy.mount(SetupView) + + cy.get('[data-cy-setup-form]').scrollIntoView() + cy.get('[data-cy-setup-form]').should('be.visible') + + // Single note is the footer help + cy.get('[data-cy-setup-form-note]') + .should('have.length', 1) + .should('be.visible') + cy.get('[data-cy-setup-form-note]').should('contain', 'See the documentation') + + // DB radio selectors + cy.get('[data-cy-setup-form-field^="dbtype"]') + .should('exist') + .find('input') + .should('be.checked') + + cy.get('[data-cy-setup-form-field="dbtype-mysql"]').should('exist') + cy.get('[data-cy-setup-form-field="dbtype-pgsql"]').should('exist') + cy.get('[data-cy-setup-form-field="dbtype-oci"]').should('not.exist') + + // Sqlite warning + cy.get('[data-cy-setup-form-db-note="sqlite"]') + .should('be.visible') + + // admin login, password, data directory and 3 DB radio selectors + cy.get('[data-cy-setup-form-field]') + .should('be.visible') + .should('have.length', 6) + }) + + it('Renders single DB sqlite', () => { + const config = { + ...defaultConfig, + databases: { + sqlite: 'SQLite', + }, + } + cy.mockInitialState('core', 'config', config) + cy.mount(SetupView) + + // No DB radio selectors if only sqlite + cy.get('[data-cy-setup-form-field^="dbtype"]') + .should('not.exist') + + // Two warnings: sqlite and single db support + cy.get('[data-cy-setup-form-db-note="sqlite"]') + .should('be.visible') + cy.get('[data-cy-setup-form-db-note="single-db"]') + .should('be.visible') + + // Admin login, password and data directory + cy.get('[data-cy-setup-form-field]') + .should('be.visible') + .should('have.length', 3) + }) + + it('Renders single DB mysql', () => { + const config = { + ...defaultConfig, + databases: { + mysql: 'MySQL/MariaDB', + }, + } + cy.mockInitialState('core', 'config', config) + cy.mount(SetupView) + + // No DB radio selectors if only mysql + cy.get('[data-cy-setup-form-field^="dbtype"]') + .should('not.exist') + + // Single db support warning + cy.get('[data-cy-setup-form-db-note="single-db"]') + .should('be.visible') + .invoke('html') + .should('contains', links.adminSourceInstall) + + // No SQLite warning + cy.get('[data-cy-setup-form-db-note="sqlite"]') + .should('not.exist') + + // Admin login, password, data directory, db user, + // db password, db name and db host + cy.get('[data-cy-setup-form-field]') + .should('be.visible') + .should('have.length', 7) + }) + + it('Changes fields from sqlite to mysql then oci', () => { + const config = { + ...defaultConfig, + databases: { + sqlite: 'SQLite', + mysql: 'MySQL/MariaDB', + pgsql: 'PostgreSQL', + oci: 'Oracle', + }, + } + cy.mockInitialState('core', 'config', config) + cy.mount(SetupView) + + // SQLite selected + cy.get('[data-cy-setup-form-field="dbtype-sqlite"]') + .should('be.visible') + .find('input') + .should('be.checked') + + // Admin login, password, data directory and 4 DB radio selectors + cy.get('[data-cy-setup-form-field]') + .should('be.visible') + .should('have.length', 7) + + // Change to MySQL + cy.get('[data-cy-setup-form-field="dbtype-mysql"]').click() + cy.get('[data-cy-setup-form-field="dbtype-mysql"] input').should('be.checked') + + // Admin login, password, data directory, db user, db password, + // db name, db host and 4 DB radio selectors + cy.get('[data-cy-setup-form-field]') + .should('be.visible') + .should('have.length', 11) + + // Change to Oracle + cy.get('[data-cy-setup-form-field="dbtype-oci"]').click() + cy.get('[data-cy-setup-form-field="dbtype-oci"] input').should('be.checked') + + // Admin login, password, data directory, db user, db password, + // db name, db table space, db host and 4 DB radio selectors + cy.get('[data-cy-setup-form-field]') + .should('be.visible') + .should('have.length', 12) + cy.get('[data-cy-setup-form-field="dbtablespace"]') + .should('be.visible') + }) +}) + +describe('Setup page with errors and warning', () => { + beforeEach(() => { + cy.mockInitialState('core', 'links', links) + }) + + afterEach(() => cy.unmockInitialState()) + + it('Renders error from backend', () => { + const config = { + ...defaultConfig, + errors: [ + { + error: 'Error message', + hint: 'Error hint', + }, + ], + } + cy.mockInitialState('core', 'config', config) + cy.mount(SetupView) + + // Error message and hint + cy.get('[data-cy-setup-form-note="error"]') + .should('be.visible') + .should('have.length', 1) + .should('contain', 'Error message') + .should('contain', 'Error hint') + }) + + it('Renders errors from backend', () => { + const config = { + ...defaultConfig, + errors: [ + 'Error message 1', + { + error: 'Error message', + hint: 'Error hint', + }, + ], + } + cy.mockInitialState('core', 'config', config) + cy.mount(SetupView) + + // Error message and hint + cy.get('[data-cy-setup-form-note="error"]') + .should('be.visible') + .should('have.length', 2) + cy.get('[data-cy-setup-form-note="error"]').eq(0) + .should('contain', 'Error message 1') + cy.get('[data-cy-setup-form-note="error"]').eq(1) + .should('contain', 'Error message') + .should('contain', 'Error hint') + }) + + it('Renders all the submitted fields on error', () => { + const config = { + ...defaultConfig, + adminlogin: 'admin', + adminpass: 'password', + dbname: 'nextcloud', + dbtype: 'mysql', + dbuser: 'nextcloud', + dbpass: 'password', + dbhost: 'localhost', + directory: '/var/www/html/nextcloud', + } as SetupConfig + cy.mockInitialState('core', 'config', config) + cy.mount(SetupView) + + cy.get('input[data-cy-setup-form-field="adminlogin"]') + .should('have.value', 'admin') + cy.get('input[data-cy-setup-form-field="adminpass"]') + .should('have.value', 'password') + cy.get('[data-cy-setup-form-field="dbtype-mysql"] input') + .should('be.checked') + cy.get('input[data-cy-setup-form-field="dbname"]') + .should('have.value', 'nextcloud') + cy.get('input[data-cy-setup-form-field="dbuser"]') + .should('have.value', 'nextcloud') + cy.get('input[data-cy-setup-form-field="dbpass"]') + .should('have.value', 'password') + cy.get('input[data-cy-setup-form-field="dbhost"]') + .should('have.value', 'localhost') + cy.get('input[data-cy-setup-form-field="directory"]') + .should('have.value', '/var/www/html/nextcloud') + }) + + it('Renders the htaccess warning', () => { + const config = { + ...defaultConfig, + htaccessWorking: false, + } + cy.mockInitialState('core', 'config', config) + cy.mount(SetupView) + + cy.get('[data-cy-setup-form-note="htaccess"]') + .should('be.visible') + .should('contain', 'Security warning') + .invoke('html') + .should('contains', links.adminInstall) + }) +}) + +describe('Setup page with autoconfig', () => { + beforeEach(() => { + cy.mockInitialState('core', 'links', links) + }) + + afterEach(() => cy.unmockInitialState()) + + it('Renders autoconfig', () => { + const config = { + ...defaultConfig, + hasAutoconfig: true, + dbname: 'nextcloud', + dbtype: 'mysql', + dbuser: 'nextcloud', + dbpass: 'password', + dbhost: 'localhost', + directory: '/var/www/html/nextcloud', + } as SetupConfig + cy.mockInitialState('core', 'config', config) + cy.mount(SetupView) + + // Autoconfig info note + cy.get('[data-cy-setup-form-note="autoconfig"]') + .should('be.visible') + .should('contain', 'Autoconfig file detected') + + // Database and storage section is hidden as already set in autoconfig + cy.get('[data-cy-setup-form-advanced-config]').should('be.visible') + .invoke('attr', 'open') + .should('equal', undefined) + + // Oracle tablespace is hidden + cy.get('[data-cy-setup-form-field="dbtablespace"]') + .should('not.exist') + }) +}) + +describe('Submit a full form sends the data', () => { + beforeEach(() => { + cy.mockInitialState('core', 'links', links) + }) + + afterEach(() => cy.unmockInitialState()) + + it('Submits a full form', () => { + const config = { + ...defaultConfig, + adminlogin: 'admin', + adminpass: 'password', + dbname: 'nextcloud', + dbtype: 'mysql', + dbuser: 'nextcloud', + dbpass: 'password', + dbhost: 'localhost', + dbtablespace: 'tablespace', + directory: '/var/www/html/nextcloud', + } as SetupConfig + + cy.intercept('POST', '**', { + delay: 2000, + }).as('setup') + + cy.mockInitialState('core', 'config', config) + cy.mount(SetupView) + + // Not chaining breaks the test as the POST prevents the element from being retrieved twice + // eslint-disable-next-line cypress/unsafe-to-chain-command + cy.get('[data-cy-setup-form-submit]') + .click() + .invoke('attr', 'disabled') + .should('equal', 'disabled', { timeout: 500 }) + + cy.wait('@setup') + .its('request.body') + .should('deep.equal', new URLSearchParams({ + adminlogin: 'admin', + adminpass: 'password', + directory: '/var/www/html/nextcloud', + dbtype: 'mysql', + dbuser: 'nextcloud', + dbpass: 'password', + dbname: 'nextcloud', + dbhost: 'localhost', + }).toString()) + }) +}) diff --git a/core/src/views/Setup.vue b/core/src/views/Setup.vue new file mode 100644 index 00000000000..50ec0da9035 --- /dev/null +++ b/core/src/views/Setup.vue @@ -0,0 +1,460 @@ +<!-- + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <form ref="form" + class="setup-form" + :class="{ 'setup-form--loading': loading }" + action="" + data-cy-setup-form + method="POST" + @submit="onSubmit"> + <!-- Autoconfig info --> + <NcNoteCard v-if="config.hasAutoconfig" + :heading="t('core', 'Autoconfig file detected')" + data-cy-setup-form-note="autoconfig" + type="success"> + {{ t('core', 'The setup form below is pre-filled with the values from the config file.') }} + </NcNoteCard> + + <!-- Htaccess warning --> + <NcNoteCard v-if="config.htaccessWorking === false" + :heading="t('core', 'Security warning')" + data-cy-setup-form-note="htaccess" + type="warning"> + <p v-html="htaccessWarning" /> + </NcNoteCard> + + <!-- Various errors --> + <NcNoteCard v-for="(error, index) in errors" + :key="index" + :heading="error.heading" + data-cy-setup-form-note="error" + type="error"> + {{ error.message }} + </NcNoteCard> + + <!-- Admin creation --> + <fieldset class="setup-form__administration"> + <legend>{{ t('core', 'Create administration account') }}</legend> + + <!-- Username --> + <NcTextField v-model="config.adminlogin" + :label="t('core', 'Administration account name')" + data-cy-setup-form-field="adminlogin" + name="adminlogin" + required /> + + <!-- Password --> + <NcPasswordField v-model="config.adminpass" + :label="t('core', 'Administration account password')" + data-cy-setup-form-field="adminpass" + name="adminpass" + required /> + + <!-- Password entropy --> + <NcNoteCard v-show="config.adminpass !== ''" :type="passwordHelperType"> + {{ passwordHelperText }} + </NcNoteCard> + </fieldset> + + <!-- Autoconfig toggle --> + <details :open="!isValidAutoconfig" data-cy-setup-form-advanced-config> + <summary>{{ t('core', 'Storage & database') }}</summary> + + <!-- Data folder --> + <fieldset class="setup-form__data-folder"> + <NcTextField v-model="config.directory" + :label="t('core', 'Data folder')" + :placeholder="config.serverRoot + '/data'" + required + autocomplete="off" + autocapitalize="none" + data-cy-setup-form-field="directory" + name="directory" + spellcheck="false" /> + </fieldset> + + <!-- Database --> + <fieldset class="setup-form__database"> + <legend>{{ t('core', 'Database configuration') }}</legend> + + <!-- Database type select --> + <fieldset class="setup-form__database-type"> + <p v-if="!firstAndOnlyDatabase" :class="`setup-form__database-type-select--${DBTypeGroupDirection}`" class="setup-form__database-type-select"> + <NcCheckboxRadioSwitch v-for="(name, db) in config.databases" + :key="db" + v-model="config.dbtype" + :button-variant="true" + :data-cy-setup-form-field="`dbtype-${db}`" + :value="db" + :button-variant-grouped="DBTypeGroupDirection" + name="dbtype" + type="radio"> + {{ name }} + </NcCheckboxRadioSwitch> + </p> + + <NcNoteCard v-else data-cy-setup-form-db-note="single-db" type="warning"> + {{ t('core', 'Only {firstAndOnlyDatabase} is available.', { firstAndOnlyDatabase }) }}<br> + {{ t('core', 'Install and activate additional PHP modules to choose other database types.') }}<br> + <a :href="links.adminSourceInstall" target="_blank" rel="noreferrer noopener"> + {{ t('core', 'For more details check out the documentation.') }} ↗ + </a> + </NcNoteCard> + + <NcNoteCard v-if="config.dbtype === 'sqlite'" + :heading="t('core', 'Performance warning')" + data-cy-setup-form-db-note="sqlite" + type="warning"> + {{ t('core', 'You chose SQLite as database.') }}<br> + {{ t('core', 'SQLite should only be used for minimal and development instances. For production we recommend a different database backend.') }}<br> + {{ t('core', 'If you use clients for file syncing, the use of SQLite is highly discouraged.') }} + </NcNoteCard> + </fieldset> + + <!-- Database configuration --> + <fieldset v-if="config.dbtype !== 'sqlite'"> + <NcTextField v-model="config.dbuser" + :label="t('core', 'Database user')" + autocapitalize="none" + autocomplete="off" + data-cy-setup-form-field="dbuser" + name="dbuser" + spellcheck="false" + required /> + + <NcPasswordField v-model="config.dbpass" + :label="t('core', 'Database password')" + autocapitalize="none" + autocomplete="off" + data-cy-setup-form-field="dbpass" + name="dbpass" + spellcheck="false" + required /> + + <NcTextField v-model="config.dbname" + :label="t('core', 'Database name')" + autocapitalize="none" + autocomplete="off" + data-cy-setup-form-field="dbname" + name="dbname" + pattern="[0-9a-zA-Z\$_\-]+" + spellcheck="false" + required /> + + <NcTextField v-if="config.dbtype === 'oci'" + v-model="config.dbtablespace" + :label="t('core', 'Database tablespace')" + autocapitalize="none" + autocomplete="off" + data-cy-setup-form-field="dbtablespace" + name="dbtablespace" + spellcheck="false" /> + + <NcTextField v-model="config.dbhost" + :helper-text="t('core', 'Please specify the port number along with the host name (e.g., localhost:5432).')" + :label="t('core', 'Database host')" + :placeholder="t('core', 'localhost')" + autocapitalize="none" + autocomplete="off" + data-cy-setup-form-field="dbhost" + name="dbhost" + spellcheck="false" /> + </fieldset> + </fieldset> + </details> + + <!-- Submit --> + <NcButton class="setup-form__button" + :class="{ 'setup-form__button--loading': loading }" + :disabled="loading" + :loading="loading" + :wide="true" + alignment="center-reverse" + data-cy-setup-form-submit + native-type="submit" + type="primary"> + <template #icon> + <NcLoadingIcon v-if="loading" /> + <IconArrowRight v-else /> + </template> + {{ loading ? t('core', 'Installing …') : t('core', 'Install') }} + </NcButton> + + <!-- Help note --> + <NcNoteCard data-cy-setup-form-note="help" type="info"> + {{ t('core', 'Need help?') }} + <a target="_blank" rel="noreferrer noopener" :href="links.adminInstall">{{ t('core', 'See the documentation') }} ↗</a> + </NcNoteCard> + </form> +</template> +<script lang="ts"> +import type { DbType, SetupConfig, SetupLinks } from '../install' + +import { defineComponent } from 'vue' +import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import DomPurify from 'dompurify' + +import NcButton from '@nextcloud/vue/components/NcButton' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import NcPasswordField from '@nextcloud/vue/components/NcPasswordField' +import NcTextField from '@nextcloud/vue/components/NcTextField' + +import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue' + +enum PasswordStrength { + VeryWeak, + Weak, + Moderate, + Strong, + VeryStrong, + ExtremelyStrong, +} + +const checkPasswordEntropy = (password: string = ''): PasswordStrength => { + const uniqueCharacters = new Set(password) + const entropy = parseInt(Math.log2(Math.pow(parseInt(uniqueCharacters.size.toString()), password.length)).toFixed(2)) + if (entropy < 16) { + return PasswordStrength.VeryWeak + } else if (entropy < 31) { + return PasswordStrength.Weak + } else if (entropy < 46) { + return PasswordStrength.Moderate + } else if (entropy < 61) { + return PasswordStrength.Strong + } else if (entropy < 76) { + return PasswordStrength.VeryStrong + } + + return PasswordStrength.ExtremelyStrong +} + +export default defineComponent({ + name: 'Setup', + + components: { + IconArrowRight, + NcButton, + NcCheckboxRadioSwitch, + NcLoadingIcon, + NcNoteCard, + NcPasswordField, + NcTextField, + }, + + setup() { + return { + t, + } + }, + + data() { + return { + config: {} as SetupConfig, + links: {} as SetupLinks, + isValidAutoconfig: false, + loading: false, + } + }, + + computed: { + passwordHelperText(): string { + if (this.config?.adminpass === '') { + return '' + } + + const passwordStrength = checkPasswordEntropy(this.config?.adminpass) + switch (passwordStrength) { + case PasswordStrength.VeryWeak: + return t('core', 'Password is too weak') + case PasswordStrength.Weak: + return t('core', 'Password is weak') + case PasswordStrength.Moderate: + return t('core', 'Password is average') + case PasswordStrength.Strong: + return t('core', 'Password is strong') + case PasswordStrength.VeryStrong: + return t('core', 'Password is very strong') + case PasswordStrength.ExtremelyStrong: + return t('core', 'Password is extremely strong') + } + + return t('core', 'Unknown password strength') + }, + passwordHelperType() { + if (checkPasswordEntropy(this.config?.adminpass) < PasswordStrength.Moderate) { + return 'error' + } + if (checkPasswordEntropy(this.config?.adminpass) < PasswordStrength.Strong) { + return 'warning' + } + return 'success' + }, + + firstAndOnlyDatabase(): string|null { + const dbNames = Object.values(this.config?.databases || {}) + if (dbNames.length === 1) { + return dbNames[0] + } + + return null + }, + + DBTypeGroupDirection() { + const databases = Object.keys(this.config?.databases || {}) + // If we have more than 3 databases, we want to display them vertically + if (databases.length > 3) { + return 'vertical' + } + return 'horizontal' + }, + + htaccessWarning(): string { + // We use v-html, let's make sure we're safe + const message = [ + t('core', 'Your data directory and files are probably accessible from the internet because the <code>.htaccess</code> file does not work.'), + t('core', 'For information how to properly configure your server, please {linkStart}see the documentation{linkEnd}', { + linkStart: '<a href="' + this.links.adminInstall + '" target="_blank" rel="noreferrer noopener">', + linkEnd: '</a>', + }, { escape: false }), + ].join('<br>') + return DomPurify.sanitize(message) + }, + + errors() { + return (this.config?.errors || []).map(error => { + if (typeof error === 'string') { + return { + heading: '', + message: error, + } + } + + // f no hint is set, we don't want to show a heading + if (error.hint === '') { + return { + heading: '', + message: error.error, + } + } + + return { + heading: error.error, + message: error.hint, + } + }) + }, + }, + + beforeMount() { + // Needs to only read the state once we're mounted + // for Cypress to be properly initialized. + this.config = loadState<SetupConfig>('core', 'config') + this.links = loadState<SetupLinks>('core', 'links') + + }, + + mounted() { + // Set the first database type as default if none is set + if (this.config.dbtype === '') { + this.config.dbtype = Object.keys(this.config.databases).at(0) as DbType + } + + // Validate the legitimacy of the autoconfig + if (this.config.hasAutoconfig) { + const form = this.$refs.form as HTMLFormElement + + // Check the form without the administration account fields + form.querySelectorAll('input[name="adminlogin"], input[name="adminpass"]').forEach(input => { + input.removeAttribute('required') + }) + + if (form.checkValidity() && this.config.errors.length === 0) { + this.isValidAutoconfig = true + } else { + this.isValidAutoconfig = false + } + + // Restore the required attribute + // Check the form without the administration account fields + form.querySelectorAll('input[name="adminlogin"], input[name="adminpass"]').forEach(input => { + input.setAttribute('required', 'true') + }) + } + }, + + methods: { + async onSubmit() { + this.loading = true + }, + }, +}) +</script> +<style lang="scss"> +form { + padding: calc(3 * var(--default-grid-baseline)); + color: var(--color-main-text); + border-radius: var(--border-radius-container); + background-color: var(--color-main-background-blur); + box-shadow: 0 0 10px var(--color-box-shadow); + -webkit-backdrop-filter: var(--filter-background-blur); + backdrop-filter: var(--filter-background-blur); + + max-width: 300px; + margin-bottom: 30px; + + > fieldset:first-child, + > .notecard:first-child { + margin-top: 0; + } + + > .notecard:last-child { + margin-bottom: 0; + } + + fieldset, + details { + margin-block: 1rem; + } + + .setup-form__button:not(.setup-form__button--loading) { + .material-design-icon { + transition: all linear var(--animation-quick); + } + + &:hover .material-design-icon { + transform: translateX(0.2em); + } + } + + // Db select required styling + .setup-form__database-type-select { + display: flex; + &--vertical { + flex-direction: column; + } + } + +} + +code { + background-color: var(--color-background-dark); + margin-top: 1rem; + padding: 0 0.3em; + border-radius: var(--border-radius); +} + +// Various overrides +.input-field { + margin-block-start: 1rem !important; +} + +.notecard__heading { + font-size: inherit !important; +} +</style> diff --git a/core/src/views/UnifiedSearch.vue b/core/src/views/UnifiedSearch.vue index 2adf818b181..103e47b0425 100644 --- a/core/src/views/UnifiedSearch.vue +++ b/core/src/views/UnifiedSearch.vue @@ -3,77 +3,180 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> - <div class="header-menu unified-search-menu"> - <NcButton class="header-menu__trigger" + <div class="unified-search-menu"> + <NcHeaderButton v-show="!showLocalSearch" :aria-label="t('core', 'Unified search')" - type="tertiary-no-background" @click="toggleUnifiedSearch"> <template #icon> - <Magnify class="header-menu__trigger-icon" :size="20" /> + <NcIconSvgWrapper :path="mdiMagnify" /> </template> - </NcButton> - <UnifiedSearchModal :is-visible="showUnifiedSearch" @update:isVisible="handleModalVisibilityChange" /> + </NcHeaderButton> + <UnifiedSearchLocalSearchBar v-if="supportsLocalSearch" + :open.sync="showLocalSearch" + :query.sync="queryText" + @global-search="openModal" /> + <UnifiedSearchModal :local-search="supportsLocalSearch" + :query.sync="queryText" + :open.sync="showUnifiedSearch" /> </div> </template> -<script> -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import Magnify from 'vue-material-design-icons/Magnify.vue' -import UnifiedSearchModal from './UnifiedSearchModal.vue' +<script lang="ts"> +import { mdiMagnify } from '@mdi/js' +import { emit, subscribe } from '@nextcloud/event-bus' +import { t } from '@nextcloud/l10n' +import { useBrowserLocation } from '@vueuse/core' +import debounce from 'debounce' +import { defineComponent } from 'vue' +import NcHeaderButton from '@nextcloud/vue/components/NcHeaderButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import UnifiedSearchModal from '../components/UnifiedSearch/UnifiedSearchModal.vue' +import UnifiedSearchLocalSearchBar from '../components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue' +import logger from '../logger.js' -export default { +export default defineComponent({ name: 'UnifiedSearch', + components: { - NcButton, - Magnify, + NcHeaderButton, + NcIconSvgWrapper, UnifiedSearchModal, + UnifiedSearchLocalSearchBar, + }, + + setup() { + const currentLocation = useBrowserLocation() + + return { + currentLocation, + + mdiMagnify, + t, + } }, + data() { return { + /** The current search query */ + queryText: '', + /** Open state of the modal */ showUnifiedSearch: false, + /** Open state of the local search bar */ + showLocalSearch: false, } }, + + computed: { + /** + * Debounce emitting the search query by 250ms + */ + debouncedQueryUpdate() { + return debounce(this.emitUpdatedQuery, 250) + }, + + /** + * Current page (app) supports local in-app search + */ + supportsLocalSearch() { + // TODO: Make this an API + const providerPaths = ['/settings/users', '/apps/deck', '/settings/apps'] + return providerPaths.some((path) => this.currentLocation.pathname?.includes?.(path)) + }, + }, + + watch: { + /** + * Emit the updated query as eventbus events + * (This is debounced) + */ + queryText() { + this.debouncedQueryUpdate() + }, + }, + mounted() { - console.debug('Unified search initialized!') + // register keyboard listener for search shortcut + if (window.OCP.Accessibility.disableKeyboardShortcuts() === false) { + window.addEventListener('keydown', this.onKeyDown) + } + + // Allow external reset of the search / close local search + subscribe('nextcloud:unified-search:reset', () => { + this.showLocalSearch = false + this.queryText = '' + }) + + // Deprecated events to be removed + subscribe('nextcloud:unified-search:reset', () => { + emit('nextcloud:unified-search.reset', { query: '' }) + }) + subscribe('nextcloud:unified-search:search', ({ query }) => { + emit('nextcloud:unified-search.search', { query }) + }) + + // all done + logger.debug('Unified search initialized!') + }, + + beforeDestroy() { + // keep in mind to remove the event listener + window.removeEventListener('keydown', this.onKeyDown) }, + methods: { + /** + * Handle the key down event to open search on `ctrl + F` + * @param event The keyboard event + */ + onKeyDown(event: KeyboardEvent) { + if (event.ctrlKey && event.key === 'f') { + // only handle search if not already open - in this case the browser native search should be used + if (!this.showLocalSearch && !this.showUnifiedSearch) { + event.preventDefault() + } + this.toggleUnifiedSearch() + } + }, + + /** + * Toggle the local search if available - otherwise open the unified search modal + */ toggleUnifiedSearch() { - this.showUnifiedSearch = !this.showUnifiedSearch + if (this.supportsLocalSearch) { + this.showLocalSearch = !this.showLocalSearch + } else { + this.showUnifiedSearch = !this.showUnifiedSearch + this.showLocalSearch = false + } }, - handleModalVisibilityChange(newVisibilityVal) { - this.showUnifiedSearch = newVisibilityVal + + /** + * Open the unified search modal + */ + openModal() { + this.showUnifiedSearch = true + this.showLocalSearch = false + }, + + /** + * Emit the updated search query as eventbus events + */ + emitUpdatedQuery() { + if (this.queryText === '') { + emit('nextcloud:unified-search:reset') + } else { + emit('nextcloud:unified-search:search', { query: this.queryText }) + } }, }, -} +}) </script> <style lang="scss" scoped> // this is needed to allow us overriding component styles (focus-visible) -#header { - .header-menu { - display: flex; - align-items: center; - justify-content: center; - - &__trigger { - height: var(--header-height); - width: var(--header-height) !important; - - &:focus-visible { - // align with other header menu entries - outline: none !important; - box-shadow: none !important; - } - - &:not(:hover,:focus,:focus-visible) { - opacity: .85; - } - - &-icon { - // ensure the icon has the correct color - color: var(--color-background-plain-text) !important; - } - } - } +.unified-search-menu { + display: flex; + align-items: center; + justify-content: center; } </style> diff --git a/core/src/views/UnifiedSearchModal.vue b/core/src/views/UnifiedSearchModal.vue deleted file mode 100644 index a925ea4bf65..00000000000 --- a/core/src/views/UnifiedSearchModal.vue +++ /dev/null @@ -1,700 +0,0 @@ -<!-- - - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - - SPDX-License-Identifier: AGPL-3.0-or-later ---> -<template> - <NcModal id="unified-search" - ref="unifiedSearchModal" - :show.sync="internalIsVisible" - :clear-view-delay="0" - @close="closeModal"> - <CustomDateRangeModal :is-open="showDateRangeModal" - class="unified-search__date-range" - @set:custom-date-range="setCustomDateRange" - @update:is-open="showDateRangeModal = $event" /> - <!-- Unified search form --> - <div ref="unifiedSearch" class="unified-search-modal"> - <div class="unified-search-modal__header"> - <h2>{{ t('core', 'Unified search') }}</h2> - <NcInputField ref="searchInput" - data-cy-unified-search-input - :value.sync="searchQuery" - type="text" - :label="t('core', 'Search apps, files, tags, messages') + '...'" - @update:value="debouncedFind" /> - <div class="unified-search-modal__filters" data-cy-unified-search-filters> - <NcActions :menu-name="t('core', 'Places')" :open.sync="providerActionMenuIsOpen" data-cy-unified-search-filter="places"> - <template #icon> - <ListBox :size="20" /> - </template> - <!-- Provider id's may be duplicated since, plugin filters could depend on a provider that is already in the defaults. - provider.id concatenated to provider.name is used to create the item id, if same then, there should be an issue. --> - <NcActionButton v-for="provider in providers" - :key="`${provider.id}-${provider.name.replace(/\s/g, '')}`" - @click="addProviderFilter(provider)"> - <template #icon> - <img :src="provider.icon" class="filter-button__icon" alt=""> - </template> - {{ provider.name }} - </NcActionButton> - </NcActions> - <NcActions :menu-name="t('core', 'Date')" :open.sync="dateActionMenuIsOpen" data-cy-unified-search-filter="date"> - <template #icon> - <CalendarRangeIcon :size="20" /> - </template> - <NcActionButton :close-after-click="true" @click="applyQuickDateRange('today')"> - {{ t('core', 'Today') }} - </NcActionButton> - <NcActionButton :close-after-click="true" @click="applyQuickDateRange('7days')"> - {{ t('core', 'Last 7 days') }} - </NcActionButton> - <NcActionButton :close-after-click="true" @click="applyQuickDateRange('30days')"> - {{ t('core', 'Last 30 days') }} - </NcActionButton> - <NcActionButton :close-after-click="true" @click="applyQuickDateRange('thisyear')"> - {{ t('core', 'This year') }} - </NcActionButton> - <NcActionButton :close-after-click="true" @click="applyQuickDateRange('lastyear')"> - {{ t('core', 'Last year') }} - </NcActionButton> - <NcActionButton :close-after-click="true" @click="applyQuickDateRange('custom')"> - {{ t('core', 'Custom date range') }} - </NcActionButton> - </NcActions> - <SearchableList :label-text="t('core', 'Search people')" - :search-list="userContacts" - :empty-content-text="t('core', 'Not found')" - data-cy-unified-search-filter="people" - @search-term-change="debouncedFilterContacts" - @item-selected="applyPersonFilter"> - <template #trigger> - <NcButton> - <template #icon> - <AccountGroup :size="20" /> - </template> - {{ t('core', 'People') }} - </NcButton> - </template> - </SearchableList> - <NcButton v-if="supportFiltering" data-cy-unified-search-filter="current-view" @click="closeModal"> - {{ t('core', 'Filter in current view') }} - <template #icon> - <FilterIcon :size="20" /> - </template> - </NcButton> - </div> - <div class="unified-search-modal__filters-applied"> - <FilterChip v-for="filter in filters" - :key="filter.id" - :text="filter.name ?? filter.text" - :pretext="''" - @delete="removeFilter(filter)"> - <template #icon> - <NcAvatar v-if="filter.type === 'person'" - :user="filter.user" - :size="24" - :disable-menu="true" - :show-user-status="false" - :hide-favorite="false" /> - <CalendarRangeIcon v-else-if="filter.type === 'date'" /> - <img v-else :src="filter.icon" alt=""> - </template> - </FilterChip> - </div> - </div> - <div v-if="noContentInfo.show" class="unified-search-modal__no-content"> - <NcEmptyContent :name="noContentInfo.text"> - <template #icon> - <component :is="noContentInfo.icon" /> - </template> - </NcEmptyContent> - </div> - <div v-else class="unified-search-modal__results"> - <div v-for="providerResult in results" :key="providerResult.id" class="result"> - <div class="result-title"> - <span>{{ providerResult.provider }}</span> - </div> - <ul class="result-items"> - <SearchResult v-for="(result, index) in providerResult.results" :key="index" v-bind="result" /> - </ul> - <div class="result-footer"> - <NcButton type="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult.id)"> - {{ t('core', 'Load more results') }} - <template #icon> - <DotsHorizontalIcon :size="20" /> - </template> - </NcButton> - <NcButton v-if="providerResult.inAppSearch" alignment="end-reverse" type="tertiary-no-background"> - {{ t('core', 'Search in') }} {{ providerResult.provider }} - <template #icon> - <ArrowRight :size="20" /> - </template> - </NcButton> - </div> - </div> - </div> - </div> - </NcModal> -</template> - -<script> -import ArrowRight from 'vue-material-design-icons/ArrowRight.vue' -import AccountGroup from 'vue-material-design-icons/AccountGroup.vue' -import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue' -import CustomDateRangeModal from '../components/UnifiedSearch/CustomDateRangeModal.vue' -import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue' -import FilterIcon from 'vue-material-design-icons/Filter.vue' -import FilterChip from '../components/UnifiedSearch/SearchFilterChip.vue' -import ListBox from 'vue-material-design-icons/ListBox.vue' -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' -import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js' -import NcModal from '@nextcloud/vue/dist/Components/NcModal.js' -import MagnifyIcon from 'vue-material-design-icons/Magnify.vue' -import SearchableList from '../components/UnifiedSearch/SearchableList.vue' -import SearchResult from '../components/UnifiedSearch/SearchResult.vue' - -import debounce from 'debounce' -import { emit, subscribe } from '@nextcloud/event-bus' -import { useBrowserLocation } from '@vueuse/core' -import { getProviders, search as unifiedSearch, getContacts } from '../services/UnifiedSearchService.js' -import { useSearchStore } from '../store/unified-search-external-filters.js' - -export default { - name: 'UnifiedSearchModal', - components: { - ArrowRight, - AccountGroup, - CalendarRangeIcon, - CustomDateRangeModal, - DotsHorizontalIcon, - FilterIcon, - FilterChip, - ListBox, - NcActions, - NcActionButton, - NcAvatar, - NcButton, - NcEmptyContent, - NcModal, - NcInputField, - MagnifyIcon, - SearchableList, - SearchResult, - }, - props: { - isVisible: { - type: Boolean, - required: true, - }, - }, - setup() { - /** - * Reactive version of window.location - */ - const currentLocation = useBrowserLocation() - const searchStore = useSearchStore() - return { - currentLocation, - externalFilters: searchStore.externalFilters, - } - }, - data() { - return { - providers: [], - providerActionMenuIsOpen: false, - dateActionMenuIsOpen: false, - providerResultLimit: 5, - dateFilter: { id: 'date', type: 'date', text: '', startFrom: null, endAt: null }, - personFilter: { id: 'person', type: 'person', name: '' }, - dateFilterIsApplied: false, - personFilterIsApplied: false, - filteredProviders: [], - searching: false, - searchQuery: '', - placessearchTerm: '', - dateTimeFilter: null, - filters: [], - results: [], - contacts: [], - debouncedFind: debounce(this.find, 300), - debouncedFilterContacts: debounce(this.filterContacts, 300), - showDateRangeModal: false, - internalIsVisible: false, - } - }, - - computed: { - userContacts() { - return this.contacts - }, - noContentInfo() { - const isEmptySearch = this.searchQuery.length === 0 - const hasNoResults = this.searchQuery.length > 0 && this.results.length === 0 - return { - show: isEmptySearch || hasNoResults, - text: this.searching && hasNoResults ? t('core', 'Searching …') : (isEmptySearch ? t('core', 'Start typing to search') : t('core', 'No matching results')), - icon: MagnifyIcon, - } - }, - supportFiltering() { - /* Hard coded apps for the moment this would be improved in coming updates. */ - const providerPaths = ['/settings/users', '/apps/files', '/apps/deck'] - return providerPaths.some((path) => this.currentLocation.pathname?.includes?.(path)) - }, - }, - watch: { - isVisible(value) { - if (value) { - /* - * Before setting the search UI to visible, reset previous search event emissions. - * This allows apps to restore defaults after "Filter in current view" if the user opens the search interface once more. - * Additionally, it's a new search, so it's better to reset all previous events emitted. - */ - emit('nextcloud:unified-search.reset', { query: '' }) - } - this.internalIsVisible = value - }, - internalIsVisible(value) { - this.$emit('update:isVisible', value) - this.$nextTick(() => { - if (value) { - this.focusInput() - } - }) - }, - - }, - mounted() { - subscribe('nextcloud:unified-search:add-filter', this.handlePluginFilter) - getProviders().then((providers) => { - this.providers = providers - this.externalFilters.forEach(filter => { - this.providers.push(filter) - }) - this.providers = this.groupProvidersByApp(this.providers) - console.debug('Search providers', this.providers) - }) - getContacts({ searchTerm: '' }).then((contacts) => { - this.contacts = this.mapContacts(contacts) - console.debug('Contacts', this.contacts) - }) - }, - methods: { - find(query) { - this.searching = true - if (query.length === 0) { - this.results = [] - this.searching = false - emit('nextcloud:unified-search.reset', { query }) - return - } - emit('nextcloud:unified-search.search', { query }) - const newResults = [] - const providersToSearch = this.filteredProviders.length > 0 ? this.filteredProviders : this.providers - const searchProvider = (provider, filters) => { - const params = { - type: provider.id, - query, - cursor: null, - extraQueries: provider.extraParams, - } - - if (filters.dateFilterIsApplied) { - if (provider.filters.since && provider.filters.until) { - params.since = this.dateFilter.startFrom - params.until = this.dateFilter.endAt - } else { - // Date filter is applied but provider does not support it, no need to search provider - return - } - } - - if (filters.personFilterIsApplied) { - if (provider.filters.person) { - params.person = this.personFilter.user - } else { - // Person filter is applied but provider does not support it, no need to search provider - return - } - } - - if (this.providerResultLimit > 5) { - params.limit = this.providerResultLimit - } - - const request = unifiedSearch(params).request - - request().then((response) => { - newResults.push({ - id: provider.id, - provider: provider.name, - inAppSearch: provider.inAppSearch, - results: response.data.ocs.data.entries, - }) - - console.debug('New results', newResults) - console.debug('Unified search results:', this.results) - - this.updateResults(newResults) - this.searching = false - }) - } - providersToSearch.forEach(provider => { - const dateFilterIsApplied = this.dateFilterIsApplied - const personFilterIsApplied = this.personFilterIsApplied - searchProvider(provider, { dateFilterIsApplied, personFilterIsApplied }) - }) - - }, - updateResults(newResults) { - let updatedResults = [...this.results] - // If filters are applied, remove any previous results for providers that are not in current filters - if (this.filters.length > 0) { - updatedResults = updatedResults.filter(result => { - return this.filters.some(filter => filter.id === result.id) - }) - } - // Process the new results - newResults.forEach(newResult => { - const existingResultIndex = updatedResults.findIndex(result => result.id === newResult.id) - if (existingResultIndex !== -1) { - if (newResult.results.length === 0) { - // If the new results data has no matches for and existing result, remove the existing result - updatedResults.splice(existingResultIndex, 1) - } else { - // If input triggered a change in existing results, update existing result - updatedResults.splice(existingResultIndex, 1, newResult) - } - } else if (newResult.results.length > 0) { - // Push the new result to the array only if its results array is not empty - updatedResults.push(newResult) - } - }) - const sortedResults = updatedResults.slice(0) - // Order results according to provider preference - sortedResults.sort((a, b) => { - const aProvider = this.providers.find(provider => provider.id === a.id) - const bProvider = this.providers.find(provider => provider.id === b.id) - const aOrder = aProvider ? aProvider.order : 0 - const bOrder = bProvider ? bProvider.order : 0 - return aOrder - bOrder - }) - this.results = sortedResults - }, - mapContacts(contacts) { - return contacts.map(contact => { - return { - // id: contact.id, - // name: '', - displayName: contact.fullName, - isNoUser: false, - subname: contact.emailAddresses[0] ? contact.emailAddresses[0] : '', - icon: '', - user: contact.id, - isUser: contact.isUser, - } - }) - }, - filterContacts(query) { - getContacts({ searchTerm: query }).then((contacts) => { - this.contacts = this.mapContacts(contacts) - console.debug(`Contacts filtered by ${query}`, this.contacts) - }) - }, - applyPersonFilter(person) { - this.personFilterIsApplied = true - const existingPersonFilter = this.filters.findIndex(filter => filter.id === person.id) - if (existingPersonFilter === -1) { - this.personFilter.id = person.id - this.personFilter.user = person.user - this.personFilter.name = person.displayName - this.filters.push(this.personFilter) - } else { - this.filters[existingPersonFilter].id = person.id - this.filters[existingPersonFilter].user = person.user - this.filters[existingPersonFilter].name = person.displayName - } - - this.debouncedFind(this.searchQuery) - console.debug('Person filter applied', person) - }, - loadMoreResultsForProvider(providerId) { - this.providerResultLimit += 5 - this.filters = this.filters.filter(filter => filter.type !== 'provider') - const provider = this.providers.find(provider => provider.id === providerId) - this.addProviderFilter(provider, true) - }, - addProviderFilter(providerFilter, loadMoreResultsForProvider = false) { - if (!providerFilter.id) return - if (providerFilter.isPluginFilter) { - providerFilter.callback() - } - this.providerResultLimit = loadMoreResultsForProvider ? this.providerResultLimit : 5 - this.providerActionMenuIsOpen = false - // With the possibility for other apps to add new filters - // Resulting in a possible id/provider collision - // If a user tries to apply a filter that seems to already exist, we remove the current one and add the new one. - const existingFilterIndex = this.filteredProviders.findIndex(existing => existing.id === providerFilter.id) - if (existingFilterIndex > -1) { - this.filteredProviders.splice(existingFilterIndex, 1) - this.filters = this.syncProviderFilters(this.filters, this.filteredProviders) - } - this.filteredProviders.push({ - id: providerFilter.id, - name: providerFilter.name, - icon: providerFilter.icon, - type: providerFilter.type || 'provider', - filters: providerFilter.filters, - isPluginFilter: providerFilter.isPluginFilter || false, - }) - this.filters = this.syncProviderFilters(this.filters, this.filteredProviders) - console.debug('Search filters (newly added)', this.filters) - this.debouncedFind(this.searchQuery) - }, - removeFilter(filter) { - if (filter.type === 'provider') { - for (let i = 0; i < this.filteredProviders.length; i++) { - if (this.filteredProviders[i].id === filter.id) { - this.filteredProviders.splice(i, 1) - break - } - } - this.filters = this.syncProviderFilters(this.filters, this.filteredProviders) - console.debug('Search filters (recently removed)', this.filters) - - } else { - for (let i = 0; i < this.filters.length; i++) { - // Remove date and person filter - if (this.filters[i].id === 'date' || this.filters[i].id === filter.id) { - this.dateFilterIsApplied = false - this.filters.splice(i, 1) - if (filter.type === 'person') { - this.personFilterIsApplied = false - } - break - } - } - } - this.debouncedFind(this.searchQuery) - }, - syncProviderFilters(firstArray, secondArray) { - // Create a copy of the first array to avoid modifying it directly. - const synchronizedArray = firstArray.slice() - // Remove items from the synchronizedArray that are not in the secondArray. - synchronizedArray.forEach((item, index) => { - const itemId = item.id - if (item.type === 'provider') { - if (!secondArray.some(secondItem => secondItem.id === itemId)) { - synchronizedArray.splice(index, 1) - } - } - }) - // Add items to the synchronizedArray that are in the secondArray but not in the firstArray. - secondArray.forEach(secondItem => { - const itemId = secondItem.id - if (secondItem.type === 'provider') { - if (!synchronizedArray.some(item => item.id === itemId)) { - synchronizedArray.push(secondItem) - } - } - }) - - return synchronizedArray - }, - updateDateFilter() { - const currFilterIndex = this.filters.findIndex(filter => filter.id === 'date') - if (currFilterIndex !== -1) { - this.filters[currFilterIndex] = this.dateFilter - } else { - this.filters.push(this.dateFilter) - } - this.dateFilterIsApplied = true - this.debouncedFind(this.searchQuery) - }, - applyQuickDateRange(range) { - this.dateActionMenuIsOpen = false - const today = new Date() - let startDate - let endDate - - switch (range) { - case 'today': - // For 'Today', both start and end are set to today - startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0) - endDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999) - this.dateFilter.text = t('core', 'Today') - break - case '7days': - // For 'Last 7 days', start date is 7 days ago, end is today - startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6, 0, 0, 0, 0) - this.dateFilter.text = t('core', 'Last 7 days') - break - case '30days': - // For 'Last 30 days', start date is 30 days ago, end is today - startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 29, 0, 0, 0, 0) - this.dateFilter.text = t('core', 'Last 30 days') - break - case 'thisyear': - // For 'This year', start date is the first day of the year, end is the last day of the year - startDate = new Date(today.getFullYear(), 0, 1, 0, 0, 0, 0) - endDate = new Date(today.getFullYear(), 11, 31, 23, 59, 59, 999) - this.dateFilter.text = t('core', 'This year') - break - case 'lastyear': - // For 'Last year', start date is the first day of the previous year, end is the last day of the previous year - startDate = new Date(today.getFullYear() - 1, 0, 1, 0, 0, 0, 0) - endDate = new Date(today.getFullYear() - 1, 11, 31, 23, 59, 59, 999) - this.dateFilter.text = t('core', 'Last year') - break - case 'custom': - this.showDateRangeModal = true - return - default: - return - } - this.dateFilter.startFrom = startDate - this.dateFilter.endAt = endDate - this.updateDateFilter() - - }, - setCustomDateRange(event) { - console.debug('Custom date range', event) - this.dateFilter.startFrom = event.startFrom - this.dateFilter.endAt = event.endAt - this.dateFilter.text = t('core', `Between ${this.dateFilter.startFrom.toLocaleDateString()} and ${this.dateFilter.endAt.toLocaleDateString()}`) - this.updateDateFilter() - }, - handlePluginFilter(addFilterEvent) { - for (let i = 0; i < this.filteredProviders.length; i++) { - const provider = this.filteredProviders[i] - if (provider.id === addFilterEvent.id) { - provider.name = addFilterEvent.filterUpdateText - // Filters attached may only make sense with certain providers, - // So, find the provider attached, add apply the extra parameters to those providers only - const compatibleProviderIndex = this.providers.findIndex(provider => provider.id === addFilterEvent.id) - if (compatibleProviderIndex > -1) { - provider.extraParams = addFilterEvent.filterParams - this.filteredProviders[i] = provider - } - break - } - } - this.debouncedFind(this.searchQuery) - }, - groupProvidersByApp(filters) { - const groupedByProviderApp = {} - - filters.forEach(filter => { - const provider = filter.appId ? filter.appId : 'general' - if (!groupedByProviderApp[provider]) { - groupedByProviderApp[provider] = [] - } - groupedByProviderApp[provider].push(filter) - }) - - const flattenedArray = [] - Object.values(groupedByProviderApp).forEach(group => { - flattenedArray.push(...group) - }) - - return flattenedArray - }, - focusInput() { - this.$refs.searchInput.$el.children[0].children[0].focus() - }, - closeModal() { - this.internalIsVisible = false - this.searchQuery = '' - }, - }, -} -</script> - -<style lang="scss" scoped> -.unified-search-modal { - box-sizing: border-box; - height: 100%; - min-height: 80vh; - - display: flex; - flex-direction: column; - padding-block: 10px 0; - - // inline padding on direct children to make sure the scrollbar is on the modal container - >* { - padding-inline: 20px; - } - - &__header { - padding-block-end: 8px; - } - - &__heading { - font-size: 16px; - font-weight: bolder; - line-height: 2em; - margin-bottom: 0; - } - - &__filters { - display: flex; - flex-wrap: wrap; - gap: 4px; - justify-content: start; - padding-top: 4px; - } - - &__filters-applied { - padding-top: 4px; - display: flex; - flex-wrap: wrap; - } - - &__no-content { - display: flex; - align-items: center; - height: 100%; - } - - &__results { - overflow: hidden scroll; - padding-block: 0 10px; - - .result { - &-title { - span { - color: var(--color-primary-element); - font-weight: bolder; - font-size: 16px; - } - } - - &-footer { - justify-content: space-between; - align-items: center; - display: flex; - } - } - - } -} - -.filter-button__icon { - height: 20px; - width: 20px; - object-fit: contain; - filter: var(--background-invert-if-bright); - padding: 11px; // align with text to fit at least 44px -} - -// Ensure modal is accessible on small devices -@media only screen and (max-height: 400px) { - .unified-search-modal__results { - overflow: unset; - } -} -</style> diff --git a/core/src/views/UnsupportedBrowser.vue b/core/src/views/UnsupportedBrowser.vue index e760ef71a81..408cccf61e9 100644 --- a/core/src/views/UnsupportedBrowser.vue +++ b/core/src/views/UnsupportedBrowser.vue @@ -36,8 +36,8 @@ import { agents } from 'caniuse-lite/dist/unpacker/agents.js' import { generateUrl, getRootUrl } from '@nextcloud/router' import { translate as t, translatePlural as n } from '@nextcloud/l10n' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' import Web from 'vue-material-design-icons/Web.vue' import { browserStorageKey } from '../utils/RedirectUnsupportedBrowsers.js' @@ -164,7 +164,8 @@ $spacing: 30px; .empty-content { margin: 0; - &::v-deep .empty-content__icon { + + :deep(.empty-content__icon) { opacity: 1; } } @@ -178,7 +179,7 @@ $spacing: 30px; margin-top: 2 * $spacing; margin-bottom: $spacing; li { - text-align: left; + text-align: start; } } } diff --git a/core/src/views/UserMenu.vue b/core/src/views/UserMenu.vue deleted file mode 100644 index de6cac51165..00000000000 --- a/core/src/views/UserMenu.vue +++ /dev/null @@ -1,261 +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 { - margin-right: 12px; - - &: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> |