aboutsummaryrefslogtreecommitdiffstats
path: root/core/src/views
diff options
context:
space:
mode:
Diffstat (limited to 'core/src/views')
-rw-r--r--core/src/views/AccountMenu.vue247
-rw-r--r--core/src/views/ContactsMenu.vue45
-rw-r--r--core/src/views/LegacyUnifiedSearch.vue61
-rw-r--r--core/src/views/Login.vue103
-rw-r--r--core/src/views/Profile.vue473
-rw-r--r--core/src/views/PublicPageMenu.vue131
-rw-r--r--core/src/views/PublicPageUserMenu.vue138
-rw-r--r--core/src/views/Setup.cy.ts369
-rw-r--r--core/src/views/Setup.vue460
-rw-r--r--core/src/views/UnifiedSearch.vue191
-rw-r--r--core/src/views/UnifiedSearchModal.vue700
-rw-r--r--core/src/views/UnsupportedBrowser.vue9
-rw-r--r--core/src/views/UserMenu.vue261
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>