diff options
Diffstat (limited to 'core/src/components')
31 files changed, 3244 insertions, 946 deletions
diff --git a/core/src/components/AccountMenu/AccountMenuEntry.vue b/core/src/components/AccountMenu/AccountMenuEntry.vue new file mode 100644 index 00000000000..d983226d273 --- /dev/null +++ b/core/src/components/AccountMenu/AccountMenuEntry.vue @@ -0,0 +1,117 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcListItem :id="href ? undefined : id" + :anchor-id="id" + :active="active" + class="account-menu-entry" + compact + :href="href" + :name="name" + target="_self" + @click="onClick"> + <template #icon> + <NcLoadingIcon v-if="loading" :size="20" class="account-menu-entry__loading" /> + <slot v-else-if="$scopedSlots.icon" name="icon" /> + <img v-else + class="account-menu-entry__icon" + :class="{ 'account-menu-entry__icon--active': active }" + :src="iconSource" + alt=""> + </template> + </NcListItem> +</template> + +<script lang="ts"> +import { loadState } from '@nextcloud/initial-state' +import { defineComponent } from 'vue' + +import NcListItem from '@nextcloud/vue/components/NcListItem' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +const versionHash = loadState('core', 'versionHash', '') + +export default defineComponent({ + name: 'AccountMenuEntry', + + components: { + NcListItem, + NcLoadingIcon, + }, + + props: { + id: { + type: String, + required: true, + }, + name: { + type: String, + required: true, + }, + href: { + type: String, + required: true, + }, + active: { + type: Boolean, + default: false, + }, + icon: { + type: String, + default: '', + }, + }, + + data() { + return { + loading: false, + } + }, + + computed: { + iconSource() { + return `${this.icon}?v=${versionHash}` + }, + }, + + methods: { + onClick(e: MouseEvent) { + this.$emit('click', e) + + // Allow to not show the loading indicator + // in case the click event was already handled + if (!e.defaultPrevented) { + this.loading = true + } + }, + }, +}) +</script> + +<style lang="scss" scoped> +.account-menu-entry { + &__icon { + height: 16px; + width: 16px; + margin: calc((var(--default-clickable-area) - 16px) / 2); // 16px icon size + filter: var(--background-invert-if-dark); + + &--active { + filter: var(--primary-invert-if-dark); + } + } + + &__loading { + height: 20px; + width: 20px; + margin: calc((var(--default-clickable-area) - 20px) / 2); // 20px icon size + } + + :deep(.list-item-content__main) { + width: fit-content; + } +} +</style> diff --git a/core/src/components/AccountMenu/AccountMenuProfileEntry.vue b/core/src/components/AccountMenu/AccountMenuProfileEntry.vue new file mode 100644 index 00000000000..8b895b8ca31 --- /dev/null +++ b/core/src/components/AccountMenu/AccountMenuProfileEntry.vue @@ -0,0 +1,100 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcListItem :id="profileEnabled ? undefined : id" + :anchor-id="id" + :active="active" + compact + :href="profileEnabled ? href : undefined" + :name="displayName" + target="_self"> + <template v-if="profileEnabled" #subname> + {{ name }} + </template> + <template v-if="loading" #indicator> + <NcLoadingIcon /> + </template> + </NcListItem> +</template> + +<script lang="ts"> +import { loadState } from '@nextcloud/initial-state' +import { getCurrentUser } from '@nextcloud/auth' +import { subscribe, unsubscribe } from '@nextcloud/event-bus' +import { defineComponent } from 'vue' + +import NcListItem from '@nextcloud/vue/components/NcListItem' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +const { profileEnabled } = loadState('user_status', 'profileEnabled', { profileEnabled: false }) + +export default defineComponent({ + name: 'AccountMenuProfileEntry', + + components: { + NcListItem, + NcLoadingIcon, + }, + + props: { + id: { + type: String, + required: true, + }, + name: { + type: String, + required: true, + }, + href: { + type: String, + required: true, + }, + active: { + type: Boolean, + required: true, + }, + }, + + setup() { + return { + profileEnabled, + displayName: getCurrentUser()!.displayName, + } + }, + + data() { + return { + loading: false, + } + }, + + mounted() { + subscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate) + subscribe('settings:display-name:updated', this.handleDisplayNameUpdate) + }, + + beforeDestroy() { + unsubscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate) + unsubscribe('settings:display-name:updated', this.handleDisplayNameUpdate) + }, + + methods: { + handleClick() { + if (this.profileEnabled) { + this.loading = true + } + }, + + handleProfileEnabledUpdate(profileEnabled: boolean) { + this.profileEnabled = profileEnabled + }, + + handleDisplayNameUpdate(displayName: string) { + this.displayName = displayName + }, + }, +}) +</script> diff --git a/core/src/components/AppMenu.vue b/core/src/components/AppMenu.vue index ed3a6293c57..88f626ff569 100644 --- a/core/src/components/AppMenu.vue +++ b/core/src/components/AppMenu.vue @@ -1,298 +1,161 @@ <!-- - - @copyright Copyright (c) 2022 Julius Härtl <jus@bitgrid.net> - - - - @author Julius Härtl <jus@bitgrid.net> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - --> + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <nav class="app-menu"> - <ul class="app-menu-main"> - <li v-for="app in mainAppList" + <nav ref="appMenu" + class="app-menu" + :aria-label="t('core', 'Applications menu')"> + <ul :aria-label="t('core', 'Apps')" + class="app-menu__list"> + <AppMenuEntry v-for="app in mainAppList" :key="app.id" - :data-app-id="app.id" - class="app-menu-entry" - :class="{ 'app-menu-entry__active': app.active }"> - <a :href="app.href" - :class="{ 'has-unread': app.unread > 0 }" - :aria-label="appLabel(app)" - :title="app.name" - :aria-current="app.active ? 'page' : false" - :target="app.target ? '_blank' : undefined" - :rel="app.target ? 'noopener noreferrer' : undefined"> - <img :src="app.icon" alt=""> - <div class="app-menu-entry--label"> - {{ app.name }} - <span v-if="app.unread > 0" class="hidden-visually unread-counter">{{ app.unread }}</span> - </div> - </a> - </li> + :app="app" /> </ul> - <NcActions class="app-menu-more" :aria-label="t('core', 'More apps')"> + <NcActions class="app-menu__overflow" :aria-label="t('core', 'More apps')"> <NcActionLink v-for="app in popoverAppList" :key="app.id" - :aria-label="appLabel(app)" :aria-current="app.active ? 'page' : false" :href="app.href" - class="app-menu-popover-entry"> - <template #icon> - <div class="app-icon" :class="{ 'has-unread': app.unread > 0 }"> - <img :src="app.icon" alt=""> - </div> - </template> + :icon="app.icon" + class="app-menu__overflow-entry"> {{ app.name }} - <span v-if="app.unread > 0" class="hidden-visually unread-counter">{{ app.unread }}</span> </NcActionLink> </NcActions> </nav> </template> -<script> -import { loadState } from '@nextcloud/initial-state' +<script lang="ts"> +import type { INavigationEntry } from '../types/navigation' + import { subscribe, unsubscribe } from '@nextcloud/event-bus' -import NcActions from '@nextcloud/vue/dist/Components/NcActions' -import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink' +import { loadState } from '@nextcloud/initial-state' +import { n, t } from '@nextcloud/l10n' +import { useElementSize } from '@vueuse/core' +import { defineComponent, ref } from 'vue' + +import AppMenuEntry from './AppMenuEntry.vue' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionLink from '@nextcloud/vue/components/NcActionLink' +import logger from '../logger' -export default { +export default defineComponent({ name: 'AppMenu', + components: { - NcActions, NcActionLink, + AppMenuEntry, + NcActions, + NcActionLink, + }, + + setup() { + const appMenu = ref() + const { width: appMenuWidth } = useElementSize(appMenu) + return { + t, + n, + appMenu, + appMenuWidth, + } }, + data() { + const appList = loadState<INavigationEntry[]>('core', 'apps', []) return { - apps: loadState('core', 'apps', {}), - appLimit: 0, - observer: null, + appList, } }, + computed: { - appList() { - return Object.values(this.apps) + appLimit() { + const maxApps = Math.floor(this.appMenuWidth / 50) + if (maxApps < this.appList.length) { + // Ensure there is space for the overflow menu + return Math.max(maxApps - 1, 0) + } + return maxApps }, + mainAppList() { return this.appList.slice(0, this.appLimit) }, + popoverAppList() { return this.appList.slice(this.appLimit) }, - appLabel() { - return (app) => app.name - + (app.active ? ' (' + t('core', 'Currently open') + ')' : '') - + (app.unread > 0 ? ' (' + n('core', '{count} notification', '{count} notifications', app.unread, { count: app.unread }) + ')' : '') - }, }, + mounted() { - this.observer = new ResizeObserver(this.resize) - this.observer.observe(this.$el) - this.resize() subscribe('nextcloud:app-menu.refresh', this.setApps) }, + beforeDestroy() { - this.observer.disconnect() unsubscribe('nextcloud:app-menu.refresh', this.setApps) }, + methods: { - setNavigationCounter(id, counter) { - this.$set(this.apps[id], 'unread', counter) - }, - setApps({ apps }) { - this.apps = apps - }, - resize() { - const availableWidth = this.$el.offsetWidth - let appCount = Math.floor(availableWidth / 50) - 1 - const popoverAppCount = this.appList.length - appCount - if (popoverAppCount === 1) { - appCount-- + setNavigationCounter(id: string, counter: number) { + const app = this.appList.find(({ app }) => app === id) + if (app) { + this.$set(app, 'unread', counter) + } else { + logger.warn(`Could not find app "${id}" for setting navigation count`) } - if (appCount < 1) { - appCount = 0 - } - this.appLimit = appCount + }, + + setApps({ apps }: { apps: INavigationEntry[]}) { + this.appList = apps }, }, -} +}) </script> -<style lang="scss" scoped> -$header-icon-size: 20px; - +<style scoped lang="scss"> .app-menu { - width: 100%; - display: flex; - flex-shrink: 1; - flex-wrap: wrap; -} -.app-menu-main { + // The size the currently focussed entry will grow to show the full name + --app-menu-entry-growth: calc(var(--default-grid-baseline) * 4); display: flex; - flex-wrap: nowrap; + flex: 1 1; + width: 0; - .app-menu-entry { - width: 50px; - height: 50px; - position: relative; + &__list { display: flex; - opacity: .7; - filter: var(--background-image-invert-if-bright); - - &.app-menu-entry__active { - opacity: 1; - - &::before { - content: " "; - position: absolute; - pointer-events: none; - border-bottom-color: var(--color-main-background); - transform: translateX(-50%); - width: 12px; - height: 5px; - border-radius: 3px; - background-color: var(--color-primary-text); - left: 50%; - bottom: 6px; - display: block; - transition: all 0.1s ease-in-out; - opacity: 1; - } + flex-wrap: nowrap; + margin-inline: calc(var(--app-menu-entry-growth) / 2); + } - .app-menu-entry--label { - font-weight: bold; - } - } + &__overflow { + margin-block: auto; - a { - width: calc(100% - 4px); - height: calc(100% - 4px); - margin: 2px; - color: var(--color-primary-text); - position: relative; - } + // Adjust the overflow NcActions styles as they are directly rendered on the background + :deep(.button-vue--vue-tertiary) { + opacity: .7; + margin: 3px; + filter: var(--background-image-invert-if-bright); - img { - transition: margin 0.1s ease-in-out; - width: $header-icon-size; - height: $header-icon-size; - padding: calc((100% - $header-icon-size) / 2); - box-sizing: content-box; - } + /* Remove all background and align text color if not expanded */ + &:not([aria-expanded="true"]) { + color: var(--color-background-plain-text); - .app-menu-entry--label { - opacity: 0; - position: absolute; - font-size: 12px; - color: var(--color-primary-text); - text-align: center; - bottom: -5px; - left: 50%; - top: 45%; - display: block; - min-width: 100%; - transform: translateX(-50%); - transition: all 0.1s ease-in-out; - width: 100%; - text-overflow: ellipsis; - overflow: hidden; - letter-spacing: -0.5px; - } + &:hover { + opacity: 1; + background-color: transparent !important; + } + } - &:hover, - &:focus-within { - opacity: 1; - .app-menu-entry--label { + &:focus-visible { opacity: 1; - font-weight: bolder; - bottom: 0; - width: 100%; - text-overflow: ellipsis; - overflow: hidden; + outline: none !important; } } - - } - - // Show labels - &:hover, - &:focus-within, - .app-menu-entry:hover, - .app-menu-entry:focus { - opacity: 1; - - img { - margin-top: -8px; - } - - .app-menu-entry--label { - opacity: 1; - bottom: 0; - } - - &::before, .app-menu-entry::before { - opacity: 0; - } - } -} - -::v-deep .app-menu-more .button-vue--vue-tertiary { - color: var(--color-primary-text); - opacity: .7; - margin: 3px; - filter: var(--background-image-invert-if-bright); - - &:hover { - opacity: 1; - background-color: transparent !important; - } - - &:focus-visible { - opacity: 1; - outline: none !important; } -} -.app-menu-popover-entry { - .app-icon { - position: relative; - height: 44px; - - &.has-unread::after { - background-color: var(--color-main-text); - } - - img { - width: $header-icon-size; - height: $header-icon-size; - padding: calc((50px - $header-icon-size) / 2); + &__overflow-entry { + :deep(.action-link__icon) { + // Icons are bright so invert them if bright color theme == bright background is used + filter: var(--background-invert-if-bright) !important; } } } - -.has-unread::after { - content: ""; - width: 8px; - height: 8px; - background-color: var(--color-primary-text); - border-radius: 50%; - position: absolute; - display: block; - top: 10px; - right: 10px; -} - -.unread-counter { - display: none; -} </style> diff --git a/core/src/components/AppMenuEntry.vue b/core/src/components/AppMenuEntry.vue new file mode 100644 index 00000000000..4c5acb7e9c8 --- /dev/null +++ b/core/src/components/AppMenuEntry.vue @@ -0,0 +1,189 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> + +<template> + <li ref="containerElement" + class="app-menu-entry" + :class="{ + 'app-menu-entry--active': app.active, + 'app-menu-entry--truncated': needsSpace, + }"> + <a class="app-menu-entry__link" + :href="app.href" + :title="app.name" + :aria-current="app.active ? 'page' : false" + :target="app.target ? '_blank' : undefined" + :rel="app.target ? 'noopener noreferrer' : undefined"> + <AppMenuIcon class="app-menu-entry__icon" :app="app" /> + <span ref="labelElement" class="app-menu-entry__label"> + {{ app.name }} + </span> + </a> + </li> +</template> + +<script setup lang="ts"> +import type { INavigationEntry } from '../types/navigation' +import { onMounted, ref, watch } from 'vue' +import AppMenuIcon from './AppMenuIcon.vue' + +const props = defineProps<{ + app: INavigationEntry +}>() + +const containerElement = ref<HTMLLIElement>() +const labelElement = ref<HTMLSpanElement>() +const needsSpace = ref(false) + +/** Update the space requirements of the app label */ +function calculateSize() { + const maxWidth = containerElement.value!.clientWidth + // Also keep the 0.5px letter spacing in mind + needsSpace.value = (maxWidth - props.app.name.length * 0.5) < (labelElement.value!.scrollWidth) +} +// Update size on mounted and when the app name changes +onMounted(calculateSize) +watch(() => props.app.name, calculateSize) +</script> + +<style scoped lang="scss"> +.app-menu-entry { + --app-menu-entry-font-size: 12px; + width: var(--header-height); + height: var(--header-height); + position: relative; + + &__link { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + // Set color as this is shown directly on the background + color: var(--color-background-plain-text); + // Make space for focus-visible outline + width: calc(100% - 4px); + height: calc(100% - 4px); + margin: 2px; + } + + &__label { + opacity: 0; + position: absolute; + font-size: var(--app-menu-entry-font-size); + // this is shown directly on the background + color: var(--color-background-plain-text); + text-align: center; + bottom: 0; + inset-inline-start: 50%; + top: 50%; + display: block; + transform: translateX(-50%); + max-width: 100%; + text-overflow: ellipsis; + overflow: hidden; + letter-spacing: -0.5px; + } + body[dir=rtl] &__label { + transform: translateX(50%) !important; + } + + &__icon { + font-size: var(--app-menu-entry-font-size); + } + + &--active { + // When hover or focus, show the label and make it bolder than the other entries + .app-menu-entry__label { + font-weight: bolder; + } + + // When active show a line below the entry as an "active" indicator + &::before { + content: " "; + position: absolute; + pointer-events: none; + border-bottom-color: var(--color-main-background); + transform: translateX(-50%); + width: 10px; + height: 5px; + border-radius: 3px; + background-color: var(--color-background-plain-text); + inset-inline-start: 50%; + bottom: 8px; + display: block; + transition: all var(--animation-quick) ease-in-out; + opacity: 1; + } + body[dir=rtl] &::before { + transform: translateX(50%) !important; + } + } + + &__icon, + &__label { + transition: all var(--animation-quick) ease-in-out; + } + + // Make the hovered entry bold to see that it is hovered + &:hover .app-menu-entry__label, + &:focus-within .app-menu-entry__label { + font-weight: bold; + } + + // Adjust the width when an entry is focussed + // The focussed / hovered entry should grow, while both neighbors need to shrink + &--truncated:hover, + &--truncated:focus-within { + .app-menu-entry__label { + max-width: calc(var(--header-height) + var(--app-menu-entry-growth)); + } + + // The next entry needs to shrink half the growth + + .app-menu-entry { + .app-menu-entry__label { + font-weight: normal; + max-width: calc(var(--header-height) - var(--app-menu-entry-growth)); + } + } + } + + // The previous entry needs to shrink half the growth + &:has(+ .app-menu-entry--truncated:hover), + &:has(+ .app-menu-entry--truncated:focus-within) { + .app-menu-entry__label { + font-weight: normal; + max-width: calc(var(--header-height) - var(--app-menu-entry-growth)); + } + } +} +</style> + +<style lang="scss"> +// Showing the label +.app-menu-entry:hover, +.app-menu-entry:focus-within, +.app-menu__list:hover, +.app-menu__list:focus-within { + // Move icon up so that the name does not overflow the icon + .app-menu-entry__icon { + margin-block-end: 1lh; + } + + // Make the label visible + .app-menu-entry__label { + opacity: 1; + } + + // Hide indicator when the text is shown + .app-menu-entry--active::before { + opacity: 0; + } + + .app-menu-icon__unread { + opacity: 0; + } +} +</style> diff --git a/core/src/components/AppMenuIcon.vue b/core/src/components/AppMenuIcon.vue new file mode 100644 index 00000000000..1b0d48daf8c --- /dev/null +++ b/core/src/components/AppMenuIcon.vue @@ -0,0 +1,67 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> + +<template> + <span class="app-menu-icon" + role="img" + :aria-hidden="ariaHidden" + :aria-label="ariaLabel"> + <img class="app-menu-icon__icon" :src="app.icon" alt=""> + <IconDot v-if="app.unread" class="app-menu-icon__unread" :size="10" /> + </span> +</template> + +<script setup lang="ts"> +import type { INavigationEntry } from '../types/navigation.ts' + +import { n } from '@nextcloud/l10n' +import { computed } from 'vue' +import IconDot from 'vue-material-design-icons/CircleOutline.vue' + +const props = defineProps<{ + app: INavigationEntry +}>() + +// only hide if there are no unread notifications +const ariaHidden = computed(() => !props.app.unread ? 'true' : undefined) + +const ariaLabel = computed(() => { + if (!props.app.unread) { + return undefined + } + + return `${props.app.name} (${n('core', '{count} notification', '{count} notifications', props.app.unread, { count: props.app.unread })})` +}) +</script> + +<style scoped lang="scss"> +$icon-size: 20px; +$unread-indicator-size: 10px; + +.app-menu-icon { + box-sizing: border-box; + position: relative; + + height: $icon-size; + width: $icon-size; + + &__icon { + transition: margin 0.1s ease-in-out; + height: $icon-size; + width: $icon-size; + filter: var(--background-image-invert-if-bright); + mask: var(--header-menu-icon-mask); + } + + &__unread { + color: var(--color-error); + position: absolute; + // Align the dot to the top right corner of the icon + inset-block-end: calc($icon-size + ($unread-indicator-size / -2)); + inset-inline-end: calc($unread-indicator-size / -2); + transition: all 0.1s ease-in-out; + } +} +</style> diff --git a/core/src/components/ContactsMenu.js b/core/src/components/ContactsMenu.js index 1b7b25873d0..e07a699ab9f 100644 --- a/core/src/components/ContactsMenu.js +++ b/core/src/components/ContactsMenu.js @@ -1,25 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Christopher Ng <chrng8@gmail.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import Vue from 'vue' @@ -34,6 +15,7 @@ export const setUp = () => { if (mountPoint) { // eslint-disable-next-line no-new new Vue({ + name: 'ContactsMenuRoot', el: mountPoint, render: h => h(ContactsMenu), }) diff --git a/core/src/components/ContactsMenu/Contact.vue b/core/src/components/ContactsMenu/Contact.vue new file mode 100644 index 00000000000..322f53647b1 --- /dev/null +++ b/core/src/components/ContactsMenu/Contact.vue @@ -0,0 +1,193 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <li class="contact"> + <NcAvatar class="contact__avatar" + :size="44" + :user="contact.isUser ? contact.uid : undefined" + :is-no-user="!contact.isUser" + :disable-menu="true" + :display-name="contact.avatarLabel" + :preloaded-user-status="preloadedUserStatus" /> + <a class="contact__body" + :href="contact.profileUrl || contact.topAction?.hyperlink"> + <div class="contact__body__full-name">{{ contact.fullName }}</div> + <div v-if="contact.lastMessage" class="contact__body__last-message">{{ contact.lastMessage }}</div> + <div v-if="contact.statusMessage" class="contact__body__status-message">{{ contact.statusMessage }}</div> + <div v-else class="contact__body__email-address">{{ contact.emailAddresses[0] }}</div> + </a> + <NcActions v-if="actions.length" + :inline="contact.topAction ? 1 : 0"> + <template v-for="(action, idx) in actions"> + <NcActionLink v-if="action.hyperlink !== '#'" + :key="`${idx}-link`" + :href="action.hyperlink" + class="other-actions"> + <template #icon> + <img aria-hidden="true" class="contact__action__icon" :src="action.icon"> + </template> + {{ action.title }} + </NcActionLink> + <NcActionText v-else :key="`${idx}-text`" class="other-actions"> + <template #icon> + <img aria-hidden="true" class="contact__action__icon" :src="action.icon"> + </template> + {{ action.title }} + </NcActionText> + </template> + <NcActionButton v-for="action in jsActions" + :key="action.id" + :close-after-click="true" + class="other-actions" + @click="action.callback(contact)"> + <template #icon> + <NcIconSvgWrapper class="contact__action__icon-svg" + :svg="action.iconSvg(contact)" /> + </template> + {{ action.displayName(contact) }} + </NcActionButton> + </NcActions> + </li> +</template> + +<script> +import NcActionLink from '@nextcloud/vue/components/NcActionLink' +import NcActionText from '@nextcloud/vue/components/NcActionText' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import { getEnabledContactsMenuActions } from '@nextcloud/vue/functions/contactsMenu' + +export default { + name: 'Contact', + components: { + NcActionLink, + NcActionText, + NcActionButton, + NcActions, + NcAvatar, + NcIconSvgWrapper, + }, + props: { + contact: { + required: true, + type: Object, + }, + }, + computed: { + actions() { + if (this.contact.topAction) { + return [this.contact.topAction, ...this.contact.actions] + } + return this.contact.actions + }, + jsActions() { + return getEnabledContactsMenuActions(this.contact) + }, + preloadedUserStatus() { + if (this.contact.status) { + return { + status: this.contact.status, + message: this.contact.statusMessage, + icon: this.contact.statusIcon, + } + } + return undefined + }, + }, +} +</script> + +<style scoped lang="scss"> +.contact { + display: flex; + position: relative; + align-items: center; + padding: 3px; + padding-inline-start: 10px; + + &__action { + &__icon { + width: 20px; + height: 20px; + padding: 12px; + filter: var(--background-invert-if-dark); + } + + &__icon-svg { + padding: 5px; + } + } + + &__avatar { + display: inherit; + } + + &__body { + flex-grow: 1; + padding-inline-start: 10px; + margin-inline-start: 10px; + min-width: 0; + + div { + position: relative; + width: 100%; + overflow-x: hidden; + text-overflow: ellipsis; + margin: -1px 0; + } + div:first-of-type { + margin-top: 0; + } + div:last-of-type { + margin-bottom: 0; + } + + &__last-message, &__status-message, &__email-address { + color: var(--color-text-maxcontrast); + } + + &:focus-visible { + box-shadow: 0 0 0 4px var(--color-main-background) !important; + outline: 2px solid var(--color-main-text) !important; + } + } + + .other-actions { + width: 16px; + height: 16px; + cursor: pointer; + + img { + filter: var(--background-invert-if-dark); + } + } + + button.other-actions { + width: 44px; + + &:focus { + border-color: transparent; + box-shadow: 0 0 0 2px var(--color-main-text); + } + + &:focus-visible { + border-radius: var(--border-radius-pill); + } + } + + /* actions menu */ + .menu { + top: 47px; + margin-inline-end: 13px; + } + + .popovermenu::after { + inset-inline-end: 2px; + } +} +</style> diff --git a/core/src/components/LegacyDialogPrompt.vue b/core/src/components/LegacyDialogPrompt.vue new file mode 100644 index 00000000000..f2ee4be9151 --- /dev/null +++ b/core/src/components/LegacyDialogPrompt.vue @@ -0,0 +1,111 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcDialog dialog-classes="legacy-prompt__dialog" + :buttons="buttons" + :name="name" + @update:open="$emit('close', false, inputValue)"> + <p class="legacy-prompt__text" v-text="text" /> + <NcPasswordField v-if="isPassword" + ref="input" + autocomplete="new-password" + class="legacy-prompt__input" + :label="name" + :name="inputName" + :value.sync="inputValue" /> + <NcTextField v-else + ref="input" + class="legacy-prompt__input" + :label="name" + :name="inputName" + :value.sync="inputValue" /> + </NcDialog> +</template> + +<script lang="ts"> +import { translate as t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' + +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcTextField from '@nextcloud/vue/components/NcTextField' +import NcPasswordField from '@nextcloud/vue/components/NcPasswordField' + +export default defineComponent({ + name: 'LegacyDialogPrompt', + + components: { + NcDialog, + NcTextField, + NcPasswordField, + }, + + props: { + name: { + type: String, + required: true, + }, + + text: { + type: String, + required: true, + }, + + isPassword: { + type: Boolean, + required: true, + }, + + inputName: { + type: String, + default: 'prompt-input', + }, + }, + + emits: ['close'], + + data() { + return { + inputValue: '', + } + }, + + computed: { + buttons() { + return [ + { + label: t('core', 'No'), + callback: () => this.$emit('close', false, this.inputValue), + }, + { + label: t('core', 'Yes'), + type: 'primary', + callback: () => this.$emit('close', true, this.inputValue), + }, + ] + }, + }, + + mounted() { + this.$nextTick(() => this.$refs.input?.focus?.()) + }, +}) +</script> + +<style scoped lang="scss"> +.legacy-prompt { + &__text { + margin-block: 0 .75em; + } + + &__input { + margin-block: 0 1em; + } +} + +:deep(.legacy-prompt__dialog .dialog__actions) { + min-width: calc(100% - 12px); + justify-content: space-between; +} +</style> diff --git a/core/src/components/MainMenu.js b/core/src/components/MainMenu.js index 46e0e5c510b..21a0b6a772f 100644 --- a/core/src/components/MainMenu.js +++ b/core/src/components/MainMenu.js @@ -1,25 +1,6 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { translate as t, translatePlural as n } from '@nextcloud/l10n' @@ -36,7 +17,7 @@ export const setUp = () => { }, }) - const container = document.getElementById('header-left__appmenu') + const container = document.getElementById('header-start__appmenu') if (!container) { // no container, possibly we're on a public page return diff --git a/core/src/components/Profile/PrimaryActionButton.vue b/core/src/components/Profile/PrimaryActionButton.vue index 7a1f031b60c..dbc446b3d90 100644 --- a/core/src/components/Profile/PrimaryActionButton.vue +++ b/core/src/components/Profile/PrimaryActionButton.vue @@ -1,43 +1,36 @@ <!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> - <a class="profile__primary-action-button" - :class="{ 'disabled': disabled }" + <NcButton type="primary" :href="href" + alignment="center" :target="target" - rel="noopener noreferrer nofollow" - v-on="$listeners"> - <img class="icon" - :class="[icon, { 'icon-invert': colorPrimaryText === '#ffffff' }]" - :src="icon"> + :disabled="disabled"> + <template #icon> + <img class="icon" + aria-hidden="true" + :src="icon" + alt=""> + </template> <slot /> - </a> + </NcButton> </template> <script> -export default { +import { defineComponent } from 'vue' +import { t } from '@nextcloud/l10n' +import NcButton from '@nextcloud/vue/components/NcButton' + +export default defineComponent({ name: 'PrimaryActionButton', + components: { + NcButton, + }, + props: { disabled: { type: Boolean, @@ -58,46 +51,14 @@ export default { }, }, - computed: { - colorPrimaryText() { - // For some reason the returned string has prepended whitespace - return getComputedStyle(document.body).getPropertyValue('--color-primary-text').trim() - }, + methods: { + t, }, -} +}) </script> <style lang="scss" scoped> - .profile__primary-action-button { - font-size: var(--default-font-size); - font-weight: bold; - width: 188px; - height: 44px; - padding: 0 16px; - line-height: 44px; - text-align: center; - border-radius: var(--border-radius-pill); - color: var(--color-primary-text); - background-color: var(--color-primary-element); - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - - .icon { - display: inline-block; - vertical-align: middle; - margin-bottom: 2px; - margin-right: 4px; - - &.icon-invert { - filter: invert(1); - } - } - - &:hover, - &:focus, - &:active { - background-color: var(--color-primary-element-light); - } + .icon { + filter: var(--primary-invert-if-dark); } </style> diff --git a/core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue new file mode 100644 index 00000000000..f3c57a12042 --- /dev/null +++ b/core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue @@ -0,0 +1,36 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <!-- eslint-disable-next-line vue/no-v-html --> + <li ref="listItem" :role="itemRole" v-html="html" /> +</template> + +<script setup lang="ts"> +import { onMounted, ref } from 'vue' + +defineProps<{ + id: string + html: string +}>() + +const listItem = ref<HTMLLIElement>() +const itemRole = ref('presentation') + +onMounted(() => { + // check for proper roles + const menuitem = listItem.value?.querySelector('[role="menuitem"]') + if (menuitem) { + return + } + // check if a button is available + const button = listItem.value?.querySelector('button') ?? listItem.value?.querySelector('a') + if (button) { + button.role = 'menuitem' + } else { + // if nothing is available set role on `<li>` + itemRole.value = 'menuitem' + } +}) +</script> diff --git a/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue new file mode 100644 index 00000000000..413806c7089 --- /dev/null +++ b/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue @@ -0,0 +1,51 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <NcListItem :anchor-id="`${id}--link`" + compact + :details="details" + :href="href" + :name="label" + role="presentation" + @click="$emit('click')"> + <template #icon> + <slot v-if="$scopedSlots.icon" name="icon" /> + <div v-else role="presentation" :class="['icon', icon, 'public-page-menu-entry__icon']" /> + </template> + </NcListItem> +</template> + +<script setup lang="ts"> +import { onMounted } from 'vue' + +import NcListItem from '@nextcloud/vue/components/NcListItem' + +const props = defineProps<{ + /** Only emit click event but do not open href */ + clickOnly?: boolean + // menu entry props + id: string + label: string + icon?: string + href: string + details?: string +}>() + +onMounted(() => { + const anchor = document.getElementById(`${props.id}--link`) as HTMLAnchorElement + // Make the `<a>` a menuitem + anchor.role = 'menuitem' + // Prevent native click handling if required + if (props.clickOnly) { + anchor.onclick = (event) => event.preventDefault() + } +}) +</script> + +<style scoped> +.public-page-menu-entry__icon { + padding-inline-start: var(--default-grid-baseline); +} +</style> diff --git a/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue b/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue new file mode 100644 index 00000000000..0f02bdf7524 --- /dev/null +++ b/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue @@ -0,0 +1,90 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <NcDialog is-form + :name="label" + :open.sync="open" + @submit="createFederatedShare"> + <NcTextField ref="input" + :label="t('core', 'Federated user')" + :placeholder="t('core', 'user@your-nextcloud.org')" + required + :value.sync="remoteUrl" /> + <template #actions> + <NcButton :disabled="loading" type="primary" native-type="submit"> + <template v-if="loading" #icon> + <NcLoadingIcon /> + </template> + {{ t('core', 'Create share') }} + </NcButton> + </template> + </NcDialog> +</template> + +<script setup lang="ts"> +import type Vue from 'vue' + +import { t } from '@nextcloud/l10n' +import { showError } from '@nextcloud/dialogs' +import { generateUrl } from '@nextcloud/router' +import { getSharingToken } from '@nextcloud/sharing/public' +import { nextTick, onMounted, ref, watch } from 'vue' +import axios from '@nextcloud/axios' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import NcTextField from '@nextcloud/vue/components/NcTextField' +import logger from '../../logger' + +defineProps<{ + label: string +}>() + +const loading = ref(false) +const remoteUrl = ref('') +// Todo: @nextcloud/vue should expose the types correctly +const input = ref<Vue & { focus: () => void }>() +const open = ref(true) + +// Focus when mounted +onMounted(() => nextTick(() => input.value!.focus())) + +// Check validity +watch(remoteUrl, () => { + let validity = '' + if (!remoteUrl.value.includes('@')) { + validity = t('core', 'The remote URL must include the user.') + } else if (!remoteUrl.value.match(/@(.+\..{2,}|localhost)(:\d\d+)?$/)) { + validity = t('core', 'Invalid remote URL.') + } + input.value!.$el.querySelector('input')!.setCustomValidity(validity) + input.value!.$el.querySelector('input')!.reportValidity() +}) + +/** + * Create a federated share for the current share + */ +async function createFederatedShare() { + loading.value = true + + try { + const url = generateUrl('/apps/federatedfilesharing/createFederatedShare') + const { data } = await axios.post<{ remoteUrl: string }>(url, { + shareWith: remoteUrl.value, + token: getSharingToken(), + }) + if (data.remoteUrl.includes('://')) { + window.location.href = data.remoteUrl + } else { + window.location.href = `${window.location.protocol}//${data.remoteUrl}` + } + } catch (error) { + logger.error('Failed to create federated share', { error }) + showError(t('files_sharing', 'Failed to add the public link to your Nextcloud')) + } finally { + loading.value = false + } +} +</script> diff --git a/core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue new file mode 100644 index 00000000000..a4451a38bbe --- /dev/null +++ b/core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue @@ -0,0 +1,36 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <PublicPageMenuEntry :id="id" + :icon="icon" + href="#" + :label="label" + @click="openDialog" /> +</template> + +<script setup lang="ts"> +import { spawnDialog } from '@nextcloud/dialogs' +import PublicPageMenuEntry from './PublicPageMenuEntry.vue' +import PublicPageMenuExternalDialog from './PublicPageMenuExternalDialog.vue' + +const props = defineProps<{ + id: string + label: string + icon: string + href: string +}>() + +const emit = defineEmits<{ + (e: 'click'): void +}>() + +/** + * Open the "create federated share" dialog + */ +function openDialog() { + spawnDialog(PublicPageMenuExternalDialog, { label: props.label }) + emit('click') +} +</script> diff --git a/core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue new file mode 100644 index 00000000000..5f3a4883d6d --- /dev/null +++ b/core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue @@ -0,0 +1,51 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> +<template> + <PublicPageMenuEntry :id="id" + click-only + :icon="icon" + :href="href" + :label="label" + @click="onClick" /> +</template> + +<script setup lang="ts"> +import { showSuccess } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' +import PublicPageMenuEntry from './PublicPageMenuEntry.vue' + +const props = defineProps<{ + id: string + label: string + icon: string + href: string +}>() + +const emit = defineEmits<{ + (e: 'click'): void +}>() + +/** + * Copy the href to the clipboard + */ +async function copyLink() { + try { + await window.navigator.clipboard.writeText(props.href) + showSuccess(t('core', 'Direct link copied')) + } catch { + // No secure context -> fallback to dialog + window.prompt(t('core', 'Please copy the link manually:'), props.href) + } +} + +/** + * onclick handler to trigger the "copy link" action + * and emit the event so the menu can be closed + */ +function onClick() { + copyLink() + emit('click') +} +</script> diff --git a/core/src/components/UnifiedSearch/CustomDateRangeModal.vue b/core/src/components/UnifiedSearch/CustomDateRangeModal.vue new file mode 100644 index 00000000000..d86192d156e --- /dev/null +++ b/core/src/components/UnifiedSearch/CustomDateRangeModal.vue @@ -0,0 +1,107 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcModal v-if="isModalOpen" + id="unified-search" + :name="t('core', 'Custom date range')" + :show.sync="isModalOpen" + :size="'small'" + :clear-view-delay="0" + :title="t('core', 'Custom date range')" + @close="closeModal"> + <!-- Custom date range --> + <div class="unified-search-custom-date-modal"> + <h1>{{ t('core', 'Custom date range') }}</h1> + <div class="unified-search-custom-date-modal__pickers"> + <NcDateTimePicker :id="'unifiedsearch-custom-date-range-start'" + v-model="dateFilter.startFrom" + :label="t('core', 'Pick start date')" + type="date" /> + <NcDateTimePicker :id="'unifiedsearch-custom-date-range-end'" + v-model="dateFilter.endAt" + :label="t('core', 'Pick end date')" + type="date" /> + </div> + <div class="unified-search-custom-date-modal__footer"> + <NcButton @click="applyCustomRange"> + {{ t('core', 'Search in date range') }} + <template #icon> + <CalendarRangeIcon :size="20" /> + </template> + </NcButton> + </div> + </div> + </NcModal> +</template> + +<script> +import NcButton from '@nextcloud/vue/components/NcButton' +import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePickerNative' +import NcModal from '@nextcloud/vue/components/NcModal' +import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue' + +export default { + name: 'CustomDateRangeModal', + components: { + NcButton, + NcModal, + CalendarRangeIcon, + NcDateTimePicker, + }, + props: { + isOpen: { + type: Boolean, + required: true, + }, + }, + data() { + return { + dateFilter: { startFrom: null, endAt: null }, + } + }, + computed: { + isModalOpen: { + get() { + return this.isOpen + }, + set(value) { + this.$emit('update:is-open', value) + }, + }, + }, + methods: { + closeModal() { + this.isModalOpen = false + }, + applyCustomRange() { + this.$emit('set:custom-date-range', this.dateFilter) + this.closeModal() + }, + }, +} +</script> + +<style lang="scss" scoped> +.unified-search-custom-date-modal { + padding: 10px 20px 10px 20px; + + h1 { + font-size: 16px; + font-weight: bolder; + line-height: 2em; + } + + &__pickers { + display: flex; + flex-direction: column; + } + + &__footer { + display: flex; + justify-content: end; + } + +} +</style> diff --git a/core/src/components/UnifiedSearch/LegacySearchResult.vue b/core/src/components/UnifiedSearch/LegacySearchResult.vue new file mode 100644 index 00000000000..4592adf08c9 --- /dev/null +++ b/core/src/components/UnifiedSearch/LegacySearchResult.vue @@ -0,0 +1,242 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <a :href="resourceUrl || '#'" + class="unified-search__result" + :class="{ + 'unified-search__result--focused': focused, + }" + @click="reEmitEvent" + @focus="reEmitEvent"> + + <!-- Icon describing the result --> + <div class="unified-search__result-icon" + :class="{ + 'unified-search__result-icon--rounded': rounded, + 'unified-search__result-icon--no-preview': !hasValidThumbnail && !loaded, + 'unified-search__result-icon--with-thumbnail': hasValidThumbnail && loaded, + [icon]: !loaded && !isIconUrl, + }" + :style="{ + backgroundImage: isIconUrl ? `url(${icon})` : '', + }"> + + <img v-if="hasValidThumbnail" + v-show="loaded" + :src="thumbnailUrl" + alt="" + @error="onError" + @load="onLoad"> + </div> + + <!-- Title and sub-title --> + <span class="unified-search__result-content"> + <span class="unified-search__result-line-one" :title="title"> + <NcHighlight :text="title" :search="query" /> + </span> + <span v-if="subline" class="unified-search__result-line-two" :title="subline">{{ subline }}</span> + </span> + </a> +</template> + +<script> +import NcHighlight from '@nextcloud/vue/components/NcHighlight' + +export default { + name: 'LegacySearchResult', + + components: { + NcHighlight, + }, + + props: { + thumbnailUrl: { + type: String, + default: null, + }, + title: { + type: String, + required: true, + }, + subline: { + type: String, + default: null, + }, + resourceUrl: { + type: String, + default: null, + }, + icon: { + type: String, + default: '', + }, + rounded: { + type: Boolean, + default: false, + }, + query: { + type: String, + default: '', + }, + + /** + * Only used for the first result as a visual feedback + * so we can keep the search input focused but pressing + * enter still opens the first result + */ + focused: { + type: Boolean, + default: false, + }, + }, + + data() { + return { + hasValidThumbnail: this.thumbnailUrl && this.thumbnailUrl.trim() !== '', + loaded: false, + } + }, + + computed: { + isIconUrl() { + // If we're facing an absolute url + if (this.icon.startsWith('/')) { + return true + } + + // Otherwise, let's check if this is a valid url + try { + // eslint-disable-next-line no-new + new URL(this.icon) + } catch { + return false + } + return true + }, + }, + + watch: { + // Make sure to reset state on change even when vue recycle the component + thumbnailUrl() { + this.hasValidThumbnail = this.thumbnailUrl && this.thumbnailUrl.trim() !== '' + this.loaded = false + }, + }, + + methods: { + reEmitEvent(e) { + this.$emit(e.type, e) + }, + + /** + * If the image fails to load, fallback to iconClass + */ + onError() { + this.hasValidThumbnail = false + }, + + onLoad() { + this.loaded = true + }, + }, +} +</script> + +<style lang="scss" scoped> +@use "sass:math"; + +$clickable-area: 44px; +$margin: 10px; + +.unified-search__result { + display: flex; + align-items: center; + height: $clickable-area; + padding: $margin; + border: 2px solid transparent; + border-radius: var(--border-radius-large) !important; + + &--focused { + background-color: var(--color-background-hover); + } + + &:active, + &:hover, + &:focus { + background-color: var(--color-background-hover); + border: 2px solid var(--color-border-maxcontrast); + } + + * { + cursor: pointer; + } + + &-icon { + overflow: hidden; + width: $clickable-area; + height: $clickable-area; + border-radius: var(--border-radius); + background-repeat: no-repeat; + background-position: center center; + background-size: 32px; + &--rounded { + border-radius: math.div($clickable-area, 2); + } + &--no-preview { + background-size: 32px; + } + &--with-thumbnail { + background-size: cover; + } + &--with-thumbnail:not(&--rounded) { + // compensate for border + max-width: $clickable-area - 2px; + max-height: $clickable-area - 2px; + border: 1px solid var(--color-border); + } + + img { + // Make sure to keep ratio + width: 100%; + height: 100%; + + object-fit: cover; + object-position: center; + } + } + + &-icon, + &-actions { + flex: 0 0 $clickable-area; + } + + &-content { + display: flex; + align-items: center; + flex: 1 1 100%; + flex-wrap: wrap; + // Set to minimum and gro from it + min-width: 0; + padding-inline-start: $margin; + } + + &-line-one, + &-line-two { + overflow: hidden; + flex: 1 1 100%; + margin: 1px 0; + white-space: nowrap; + text-overflow: ellipsis; + // Use the same color as the `a` + color: inherit; + font-size: inherit; + } + &-line-two { + opacity: .7; + font-size: var(--default-font-size); + } +} + +</style> diff --git a/core/src/components/UnifiedSearch/SearchFilterChip.vue b/core/src/components/UnifiedSearch/SearchFilterChip.vue new file mode 100644 index 00000000000..e08ddd58a4b --- /dev/null +++ b/core/src/components/UnifiedSearch/SearchFilterChip.vue @@ -0,0 +1,79 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div class="chip"> + <span class="icon"> + <slot name="icon" /> + <span v-if="pretext.length"> {{ pretext }} : </span> + </span> + <span class="text">{{ text }}</span> + <span class="close-icon" @click="deleteChip"> + <CloseIcon :size="18" /> + </span> + </div> +</template> + +<script> +import CloseIcon from 'vue-material-design-icons/Close.vue' + +export default { + name: 'SearchFilterChip', + components: { + CloseIcon, + }, + props: { + text: { + type: String, + required: true, + }, + pretext: { + type: String, + required: true, + }, + }, + methods: { + deleteChip() { + this.$emit('delete', this.filter) + }, + }, +} +</script> + +<style lang="scss" scoped> +.chip { + display: flex; + align-items: center; + padding: 2px 4px; + border: 1px solid var(--color-primary-element-light); + border-radius: 20px; + background-color: var(--color-primary-element-light); + margin: 2px; + + .icon { + display: flex; + align-items: center; + padding-inline-end: 5px; + + img { + width: 20px; + padding: 2px; + border-radius: 20px; + filter: var(--background-invert-if-bright); + } + } + + .text { + margin: 0 2px; + } + + .close-icon { + cursor: pointer ; + + :hover { + filter: invert(20%); + } + } +} +</style> diff --git a/core/src/components/UnifiedSearch/SearchResult.vue b/core/src/components/UnifiedSearch/SearchResult.vue index 0b8b6c8b33e..4f33fbd54cc 100644 --- a/core/src/components/UnifiedSearch/SearchResult.vue +++ b/core/src/components/UnifiedSearch/SearchResult.vue @@ -1,73 +1,44 @@ - <!-- - - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <a :href="resourceUrl || '#'" - class="unified-search__result" - :class="{ - 'unified-search__result--focused': focused, - }" - @click="reEmitEvent" - @focus="reEmitEvent"> - - <!-- Icon describing the result --> - <div class="unified-search__result-icon" - :class="{ - 'unified-search__result-icon--rounded': rounded, - 'unified-search__result-icon--no-preview': !hasValidThumbnail && !loaded, - 'unified-search__result-icon--with-thumbnail': hasValidThumbnail && loaded, - [icon]: !loaded && !isIconUrl, - }" - :style="{ - backgroundImage: isIconUrl ? `url(${icon})` : '', - }"> - - <img v-if="hasValidThumbnail" - v-show="loaded" - :src="thumbnailUrl" - alt="" - @error="onError" - @load="onLoad"> - </div> - - <!-- Title and sub-title --> - <span class="unified-search__result-content"> - <span class="unified-search__result-line-one" :title="title"> - <NcHighlight :text="title" :search="query" /> - </span> - <span v-if="subline" class="unified-search__result-line-two" :title="subline">{{ subline }}</span> - </span> - </a> + <NcListItem class="result-item" + :name="title" + :bold="false" + :href="resourceUrl" + target="_self"> + <template #icon> + <div aria-hidden="true" + class="result-item__icon" + :class="{ + 'result-item__icon--rounded': rounded, + 'result-item__icon--no-preview': !isValidIconOrPreviewUrl(thumbnailUrl), + 'result-item__icon--with-thumbnail': isValidIconOrPreviewUrl(thumbnailUrl), + [icon]: !isValidIconOrPreviewUrl(icon), + }" + :style="{ + backgroundImage: isValidIconOrPreviewUrl(icon) ? `url(${icon})` : '', + }"> + <img v-if="isValidIconOrPreviewUrl(thumbnailUrl) && !thumbnailHasError" + :src="thumbnailUrl" + @error="thumbnailErrorHandler"> + </div> + </template> + <template #subname> + {{ subline }} + </template> + </NcListItem> </template> <script> -import NcHighlight from '@nextcloud/vue/dist/Components/NcHighlight' +import NcListItem from '@nextcloud/vue/components/NcListItem' export default { name: 'SearchResult', - components: { - NcHighlight, + NcListItem, }, - props: { thumbnailUrl: { type: String, @@ -108,111 +79,71 @@ export default { default: false, }, }, - data() { return { - hasValidThumbnail: this.thumbnailUrl && this.thumbnailUrl.trim() !== '', - loaded: false, + thumbnailHasError: false, } }, - - computed: { - isIconUrl() { - // If we're facing an absolute url - if (this.icon.startsWith('/')) { - return true - } - - // Otherwise, let's check if this is a valid url - try { - // eslint-disable-next-line no-new - new URL(this.icon) - } catch { - return false - } - return true - }, - }, - watch: { - // Make sure to reset state on change even when vue recycle the component thumbnailUrl() { - this.hasValidThumbnail = this.thumbnailUrl && this.thumbnailUrl.trim() !== '' - this.loaded = false + this.thumbnailHasError = false }, }, - methods: { - reEmitEvent(e) { - this.$emit(e.type, e) - }, - - /** - * If the image fails to load, fallback to iconClass - */ - onError() { - this.hasValidThumbnail = false + isValidIconOrPreviewUrl(url) { + return /^https?:\/\//.test(url) || url.startsWith('/') }, - - onLoad() { - this.loaded = true + thumbnailErrorHandler() { + this.thumbnailHasError = true }, }, } </script> <style lang="scss" scoped> -@use "sass:math"; - -$clickable-area: 44px; -$margin: 10px; - -.unified-search__result { - display: flex; - align-items: center; - height: $clickable-area; - padding: $margin; - border-bottom: 1px solid var(--color-border); - border-radius: var(--border-radius-large) !important; - - // Load more entry, - &:last-child { - border-bottom: none; - } - - &--focused, - &:active, - &:hover, - &:focus { - background-color: var(--color-background-hover); - } +.result-item { + :deep(a) { + border: 2px solid transparent; + border-radius: var(--border-radius-large) !important; + + &:active, + &:hover, + &:focus { + background-color: var(--color-background-hover); + border: 2px solid var(--color-border-maxcontrast); + } - * { - cursor: pointer; + * { + cursor: pointer; + } } - &-icon { + &__icon { overflow: hidden; - width: $clickable-area; - height: $clickable-area; + width: var(--default-clickable-area); + height: var(--default-clickable-area); border-radius: var(--border-radius); background-repeat: no-repeat; background-position: center center; background-size: 32px; + &--rounded { - border-radius: math.div($clickable-area, 2); + border-radius: calc(var(--default-clickable-area) / 2); } + &--no-preview { background-size: 32px; } + &--with-thumbnail { background-size: cover; } - &--with-thumbnail:not(&--rounded) { - // compensate for border - max-width: $clickable-area - 2px; - max-height: $clickable-area - 2px; + + &--with-thumbnail:not(#{&}--rounded) { border: 1px solid var(--color-border); + // compensate for border + max-height: calc(var(--default-clickable-area) - 2px); + max-width: calc(var(--default-clickable-area) - 2px); } img { @@ -224,37 +155,5 @@ $margin: 10px; object-position: center; } } - - &-icon, - &-actions { - flex: 0 0 $clickable-area; - } - - &-content { - display: flex; - align-items: center; - flex: 1 1 100%; - flex-wrap: wrap; - // Set to minimum and gro from it - min-width: 0; - padding-left: $margin; - } - - &-line-one, - &-line-two { - overflow: hidden; - flex: 1 1 100%; - margin: 1px 0; - white-space: nowrap; - text-overflow: ellipsis; - // Use the same color as the `a` - color: inherit; - font-size: inherit; - } - &-line-two { - opacity: .7; - font-size: var(--default-font-size); - } } - </style> diff --git a/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue b/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue index d2a297a0a37..aec2791d8e4 100644 --- a/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue +++ b/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue @@ -1,3 +1,7 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <ul> <!-- Placeholder animation --> diff --git a/core/src/components/UnifiedSearch/SearchableList.vue b/core/src/components/UnifiedSearch/SearchableList.vue new file mode 100644 index 00000000000..d7abb6ffdbb --- /dev/null +++ b/core/src/components/UnifiedSearch/SearchableList.vue @@ -0,0 +1,157 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcPopover :shown="opened" + @show="opened = true" + @hide="opened = false"> + <template #trigger> + <slot ref="popoverTrigger" name="trigger" /> + </template> + <div class="searchable-list__wrapper"> + <NcTextField :value.sync="searchTerm" + :label="labelText" + trailing-button-icon="close" + :show-trailing-button="searchTerm !== ''" + @update:value="searchTermChanged" + @trailing-button-click="clearSearch"> + <IconMagnify :size="20" /> + </NcTextField> + <ul v-if="filteredList.length > 0" class="searchable-list__list"> + <li v-for="element in filteredList" + :key="element.id" + :title="element.displayName" + role="button"> + <NcButton alignment="start" + type="tertiary" + :wide="true" + @click="itemSelected(element)"> + <template #icon> + <NcAvatar v-if="element.isUser" :user="element.user" :show-user-status="false" /> + <NcAvatar v-else + :is-no-user="true" + :display-name="element.displayName" + :show-user-status="false" /> + </template> + {{ element.displayName }} + </NcButton> + </li> + </ul> + <div v-else class="searchable-list__empty-content"> + <NcEmptyContent :name="emptyContentText"> + <template #icon> + <IconAlertCircleOutline /> + </template> + </NcEmptyContent> + </div> + </div> + </NcPopover> +</template> + +<script> +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcPopover from '@nextcloud/vue/components/NcPopover' +import NcTextField from '@nextcloud/vue/components/NcTextField' + +import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue' +import IconMagnify from 'vue-material-design-icons/Magnify.vue' + +export default { + name: 'SearchableList', + + components: { + IconMagnify, + IconAlertCircleOutline, + NcAvatar, + NcButton, + NcEmptyContent, + NcPopover, + NcTextField, + }, + + props: { + labelText: { + type: String, + default: 'this is a label', + }, + + searchList: { + type: Array, + required: true, + }, + + emptyContentText: { + type: String, + required: true, + }, + }, + + data() { + return { + opened: false, + error: false, + searchTerm: '', + } + }, + + computed: { + filteredList() { + return this.searchList.filter((element) => { + if (!this.searchTerm.toLowerCase().length) { + return true + } + return ['displayName'].some(prop => element[prop].toLowerCase().includes(this.searchTerm.toLowerCase())) + }) + }, + }, + + methods: { + clearSearch() { + this.searchTerm = '' + }, + itemSelected(element) { + this.$emit('item-selected', element) + this.clearSearch() + this.opened = false + }, + searchTermChanged(term) { + this.$emit('search-term-change', term) + }, + }, +} +</script> + +<style lang="scss" scoped> +.searchable-list { + &__wrapper { + padding: calc(var(--default-grid-baseline) * 3); + display: flex; + flex-direction: column; + align-items: center; + width: 250px; + } + + &__list { + width: 100%; + max-height: 284px; + overflow-y: auto; + margin-top: var(--default-grid-baseline); + padding: var(--default-grid-baseline); + + :deep(.button-vue) { + border-radius: var(--border-radius-large) !important; + span { + font-weight: initial; + } + } + } + + &__empty-content { + margin-top: calc(var(--default-grid-baseline) * 3); + } +} +</style> diff --git a/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue b/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue new file mode 100644 index 00000000000..171eada8a06 --- /dev/null +++ b/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue @@ -0,0 +1,166 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <Transition> + <div v-if="open" + class="local-unified-search animated-width" + :class="{ 'local-unified-search--open': open }"> + <!-- We can not use labels as it breaks the header layout so only aria-label and placeholder --> + <NcInputField ref="searchInput" + class="local-unified-search__input animated-width" + :aria-label="t('core', 'Search in current app')" + :placeholder="t('core', 'Search in current app')" + show-trailing-button + :trailing-button-label="t('core', 'Clear search')" + :value="query" + @update:value="$emit('update:query', $event)" + @trailing-button-click="clearAndCloseSearch"> + <template #trailing-button-icon> + <NcIconSvgWrapper :path="mdiClose" /> + </template> + </NcInputField> + + <NcButton ref="searchGlobalButton" + class="local-unified-search__global-search" + :aria-label="t('core', 'Search everywhere')" + :title="t('core', 'Search everywhere')" + type="tertiary-no-background" + @click="$emit('global-search')"> + <template v-if="!isMobile" #default> + {{ t('core', 'Search everywhere') }} + </template> + <template #icon> + <NcIconSvgWrapper :path="mdiCloudSearchOutline" /> + </template> + </NcButton> + </div> + </Transition> +</template> + +<script lang="ts" setup> +import type { ComponentPublicInstance } from 'vue' +import { mdiCloudSearchOutline, mdiClose } from '@mdi/js' +import { translate as t } from '@nextcloud/l10n' +import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile' +import { useElementSize } from '@vueuse/core' +import { computed, ref, watchEffect } from 'vue' + +import NcButton from '@nextcloud/vue/components/NcButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcInputField from '@nextcloud/vue/components/NcInputField' + +const props = defineProps<{ + query: string, + open: boolean +}>() + +const emit = defineEmits<{ + (e: 'update:open', open: boolean): void + (e: 'update:query', query: string): void + (e: 'global-search'): void +}>() + +// Hacky type until the library provides real Types +type FocusableComponent = ComponentPublicInstance<object, object, object, Record<string, never>, { focus: () => void }> +/** The input field component */ +const searchInput = ref<FocusableComponent>() +/** When the search bar is opened we focus the input */ +watchEffect(() => { + if (props.open && searchInput.value) { + searchInput.value.focus() + } +}) + +/** Current window size is below the "mobile" breakpoint (currently 1024px) */ +const isMobile = useIsMobile() + +const searchGlobalButton = ref<ComponentPublicInstance>() +/** Width of the search global button, used to resize the input field */ +const { width: searchGlobalButtonWidth } = useElementSize(searchGlobalButton) +const searchGlobalButtonCSSWidth = computed(() => searchGlobalButtonWidth.value ? `${searchGlobalButtonWidth.value}px` : 'var(--default-clickable-area)') + +/** + * Clear the search query and close the search bar + */ +function clearAndCloseSearch() { + emit('update:query', '') + emit('update:open', false) +} +</script> + +<style scoped lang="scss"> +.local-unified-search { + --local-search-width: min(calc(250px + v-bind('searchGlobalButtonCSSWidth')), 95vw); + box-sizing: border-box; + position: relative; + height: var(--header-height); + width: var(--local-search-width); + display: flex; + align-items: center; + // Ensure it overlays the other entries + z-index: 10; + // add some padding for the focus visible outline + padding-inline: var(--border-width-input-focused); + // hide the overflow - needed for the transition + overflow: hidden; + // Ensure the position is fixed also during "position: absolut" (transition) + inset-inline-end: 0; + + #{&} &__global-search { + position: absolute; + inset-inline-end: var(--default-clickable-area); + } + + #{&} &__input { + box-sizing: border-box; + // override some nextcloud-vue styles + margin: 0; + width: var(--local-search-width); + + // Fixup the spacing so we can fit in the "search globally" button + // this can break at any time the component library changes + :deep(input) { + // search global width + close button width + padding-inline-end: calc(v-bind('searchGlobalButtonCSSWidth') + var(--default-clickable-area)); + } + } +} + +.animated-width { + transition: width var(--animation-quick) linear; +} + +// Make the position absolute during the transition +// this is needed to "hide" the button behind it +.v-leave-active { + position: absolute !important; +} + +.v-enter, +.v-leave-to { + &.local-unified-search { + // Start with only the overlay button + --local-search-width: var(--clickable-area-large); + } +} + +@media screen and (max-width: 500px) { + .local-unified-search.local-unified-search--open { + // 100% but still show the menu toggle on the very right + --local-search-width: 100vw; + padding-inline: var(--default-grid-baseline); + } + + // when open we need to position it absolute to allow overlay the full bar + :global(.unified-search-menu:has(.local-unified-search--open)) { + position: absolute !important; + inset-inline: 0; + } + // Hide all other entries, especially the user menu as it might leak pixels + :global(.header-end:has(.local-unified-search--open) > :not(.unified-search-menu)) { + display: none; + } +} +</style> diff --git a/core/src/components/UnifiedSearch/UnifiedSearchModal.vue b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue new file mode 100644 index 00000000000..e59058bc0f0 --- /dev/null +++ b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue @@ -0,0 +1,838 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcDialog id="unified-search" + ref="unifiedSearchModal" + content-classes="unified-search-modal__content" + dialog-classes="unified-search-modal" + :name="t('core', 'Unified search')" + :open="open" + size="normal" + @update:open="onUpdateOpen"> + <!-- Modal for picking custom time range --> + <CustomDateRangeModal :is-open="showDateRangeModal" + class="unified-search__date-range" + @set:custom-date-range="setCustomDateRange" + @update:is-open="showDateRangeModal = $event" /> + + <!-- Unified search form --> + <div class="unified-search-modal__header"> + <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> + <IconListBox :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, '')}`" + :disabled="provider.disabled" + @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> + <IconCalendarRange :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> + <IconAccountGroup :size="20" /> + </template> + {{ t('core', 'People') }} + </NcButton> + </template> + </SearchableList> + <NcButton v-if="localSearch" data-cy-unified-search-filter="current-view" @click="searchLocally"> + {{ t('core', 'Filter in current view') }} + <template #icon> + <IconFilter :size="20" /> + </template> + </NcButton> + <NcCheckboxRadioSwitch v-if="hasExternalResources" + v-model="searchExternalResources" + type="switch" + class="unified-search-modal__search-external-resources" + :class="{'unified-search-modal__search-external-resources--aligned': localSearch}"> + {{ t('core', 'Search connected services') }} + </NcCheckboxRadioSwitch> + </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" /> + <IconCalendarRange v-else-if="filter.type === 'date'" /> + <img v-else :src="filter.icon" alt=""> + </template> + </FilterChip> + </div> + </div> + + <div v-if="showEmptyContentInfo" class="unified-search-modal__no-content"> + <NcEmptyContent :name="emptyContentMessage"> + <template #icon> + <IconMagnify :size="64" /> + </template> + </NcEmptyContent> + </div> + + <div v-else class="unified-search-modal__results"> + <h3 class="hidden-visually"> + {{ t('core', 'Results') }} + </h3> + <div v-for="providerResult in results" :key="providerResult.id" class="result"> + <h4 :id="`unified-search-result-${providerResult.id}`" class="result-title"> + {{ providerResult.name }} + </h4> + <ul class="result-items" :aria-labelledby="`unified-search-result-${providerResult.id}`"> + <SearchResult v-for="(result, index) in providerResult.results" + :key="index" + v-bind="result" /> + </ul> + <div class="result-footer"> + <NcButton v-if="providerResult.results.length === providerResult.limit" type="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult)"> + {{ t('core', 'Load more results') }} + <template #icon> + <IconDotsHorizontal :size="20" /> + </template> + </NcButton> + <NcButton v-if="providerResult.inAppSearch" alignment="end-reverse" type="tertiary-no-background"> + {{ t('core', 'Search in') }} {{ providerResult.name }} + <template #icon> + <IconArrowRight :size="20" /> + </template> + </NcButton> + </div> + </div> + </div> + </NcDialog> +</template> + +<script lang="ts"> +import { subscribe } from '@nextcloud/event-bus' +import { translate as t } from '@nextcloud/l10n' +import { useBrowserLocation } from '@vueuse/core' +import { defineComponent } from 'vue' +import { getProviders, search as unifiedSearch, getContacts } from '../../services/UnifiedSearchService.js' +import { useSearchStore } from '../../store/unified-search-external-filters.js' + +import debounce from 'debounce' +import { unifiedSearchLogger } from '../../logger' + +import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue' +import IconAccountGroup from 'vue-material-design-icons/AccountGroupOutline.vue' +import IconCalendarRange from 'vue-material-design-icons/CalendarRangeOutline.vue' +import IconDotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue' +import IconFilter from 'vue-material-design-icons/Filter.vue' +import IconListBox from 'vue-material-design-icons/ListBox.vue' +import IconMagnify from 'vue-material-design-icons/Magnify.vue' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcInputField from '@nextcloud/vue/components/NcInputField' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' + +import CustomDateRangeModal from './CustomDateRangeModal.vue' +import FilterChip from './SearchFilterChip.vue' +import SearchableList from './SearchableList.vue' +import SearchResult from './SearchResult.vue' + +export default defineComponent({ + name: 'UnifiedSearchModal', + components: { + IconArrowRight, + IconAccountGroup, + IconCalendarRange, + IconDotsHorizontal, + IconFilter, + IconListBox, + IconMagnify, + + CustomDateRangeModal, + FilterChip, + NcActions, + NcActionButton, + NcAvatar, + NcButton, + NcEmptyContent, + NcDialog, + NcInputField, + NcCheckboxRadioSwitch, + SearchableList, + SearchResult, + }, + + props: { + /** + * Open state of the modal + */ + open: { + type: Boolean, + required: true, + }, + + /** + * The current query string + */ + query: { + type: String, + default: '', + }, + + /** + * If the current page / app supports local search + */ + localSearch: { + type: Boolean, + default: false, + }, + }, + + emits: ['update:open', 'update:query'], + + setup() { + /** + * Reactive version of window.location + */ + const currentLocation = useBrowserLocation() + const searchStore = useSearchStore() + return { + t, + + 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: '' }, + filteredProviders: [], + searching: false, + searchQuery: '', + lastSearchQuery: '', + placessearchTerm: '', + dateTimeFilter: null, + filters: [], + results: [], + contacts: [], + showDateRangeModal: false, + internalIsVisible: this.open, + initialized: false, + searchExternalResources: false, + } + }, + + computed: { + isEmptySearch() { + return this.searchQuery.length === 0 + }, + + hasNoResults() { + return !this.isEmptySearch && this.results.length === 0 + }, + + showEmptyContentInfo() { + return this.isEmptySearch || this.hasNoResults + }, + + emptyContentMessage() { + if (this.searching && this.hasNoResults) { + return t('core', 'Searching …') + } + if (this.isEmptySearch) { + return t('core', 'Start typing to search') + } + return t('core', 'No matching results') + }, + + userContacts() { + return this.contacts + }, + + debouncedFind() { + return debounce(this.find, 300) + }, + + debouncedFilterContacts() { + return debounce(this.filterContacts, 300) + }, + + hasExternalResources() { + return this.providers.some(provider => provider.isExternalProvider) + }, + }, + + watch: { + open() { + // Load results when opened with already filled query + if (this.open) { + this.focusInput() + if (!this.initialized) { + Promise.all([getProviders(), getContacts({ searchTerm: '' })]) + .then(([providers, contacts]) => { + this.providers = this.groupProvidersByApp([...providers, ...this.externalFilters]) + this.contacts = this.mapContacts(contacts) + unifiedSearchLogger.debug('Search providers and contacts initialized:', { providers: this.providers, contacts: this.contacts }) + this.initialized = true + }) + .catch((error) => { + unifiedSearchLogger.error(error) + }) + } + if (this.searchQuery) { + this.find(this.searchQuery) + } + } + }, + + query: { + immediate: true, + handler() { + this.searchQuery = this.query + }, + }, + + searchQuery: { + handler() { + this.$emit('update:query', this.searchQuery) + }, + }, + + searchExternalResources() { + if (this.searchQuery) { + this.find(this.searchQuery) + } + }, + }, + + mounted() { + subscribe('nextcloud:unified-search:add-filter', this.handlePluginFilter) + }, + methods: { + /** + * On close the modal is closed and the query is reset + * @param open The new open state + */ + onUpdateOpen(open: boolean) { + if (!open) { + this.$emit('update:open', false) + this.$emit('update:query', '') + } + }, + + /** + * Only close the modal but keep the query for in-app search + */ + searchLocally() { + this.$emit('update:query', this.searchQuery) + this.$emit('update:open', false) + }, + focusInput() { + this.$nextTick(() => { + this.$refs.searchInput?.focus() + }) + }, + find(query: string, providersToSearchOverride = null) { + if (query.length === 0) { + this.results = [] + this.searching = false + return + } + + // Reset the provider result limit when performing a new search + if (query !== this.lastSearchQuery) { + this.providerResultLimit = 5 + } + this.lastSearchQuery = query + + this.searching = true + const newResults = [] + const providersToSearch = providersToSearchOverride || (this.filteredProviders.length > 0 ? this.filteredProviders : this.providers) + const searchProvider = (provider) => { + const params = { + type: provider.searchFrom ?? provider.id, + query, + cursor: null, + extraQueries: provider.extraParams, + } + + // This block of filter checks should be dynamic somehow and should be handled in + // nextcloud/search lib + const activeFilters = this.filters.filter(filter => { + return filter.type !== 'provider' && this.providerIsCompatibleWithFilters(provider, [filter.type]) + }) + + activeFilters.forEach(filter => { + switch (filter.type) { + case 'date': + if (provider.filters?.since && provider.filters?.until) { + params.since = this.dateFilter.startFrom + params.until = this.dateFilter.endAt + } + break + case 'person': + if (provider.filters?.person) { + params.person = this.personFilter.user + } + break + } + }) + + if (this.providerResultLimit > 5) { + params.limit = this.providerResultLimit + unifiedSearchLogger.debug('Limiting search to', params.limit) + } + + const shouldSkipSearch = !this.searchExternalResources && provider.isExternalProvider + const wasManuallySelected = this.filteredProviders.some(filteredProvider => filteredProvider.id === provider.id) + // if the provider is an external resource and the user has not manually selected it, skip the search + if (shouldSkipSearch && !wasManuallySelected) { + this.searching = false + return + } + + const request = unifiedSearch(params).request + + request().then((response) => { + newResults.push({ + ...provider, + results: response.data.ocs.data.entries, + limit: params.limit ?? 5, + }) + + unifiedSearchLogger.debug('Unified search results:', { results: this.results, newResults }) + + this.updateResults(newResults) + this.searching = false + }) + } + + providersToSearch.forEach(searchProvider) + }, + 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) + unifiedSearchLogger.debug(`Contacts filtered by ${query}`, { contacts: this.contacts }) + }) + }, + applyPersonFilter(person) { + + 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.providers.forEach(async (provider, index) => { + this.providers[index].disabled = !(await this.providerIsCompatibleWithFilters(provider, ['person'])) + }) + + this.debouncedFind(this.searchQuery) + unifiedSearchLogger.debug('Person filter applied', { person }) + }, + async loadMoreResultsForProvider(provider) { + this.providerResultLimit += 5 + this.find(this.searchQuery, [provider]) + }, + addProviderFilter(providerFilter, loadMoreResultsForProvider = false) { + unifiedSearchLogger.debug('Applying provider filter', { providerFilter, loadMoreResultsForProvider }) + if (!providerFilter.id) return + if (providerFilter.isPluginFilter) { + // There is no way to know what should go into the callback currently + // Here we are passing isProviderFilterApplied (boolean) which is a flag sent to the plugin + // This is sent to the plugin so that depending on whether the filter is applied or not, the plugin can decide what to do + // TODO : In nextcloud/search, this should be a proper interface that the plugin can implement + const isProviderFilterApplied = this.filteredProviders.some(provider => provider.id === providerFilter.id) + providerFilter.callback(!isProviderFilterApplied) + } + 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({ + ...providerFilter, + type: providerFilter.type || 'provider', + isPluginFilter: providerFilter.isPluginFilter || false, + }) + this.filters = this.syncProviderFilters(this.filters, this.filteredProviders) + unifiedSearchLogger.debug('Search filters (newly added)', { filters: 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) + unifiedSearchLogger.debug('Search filters (recently removed)', { filters: this.filters }) + + } else { + // Remove non provider filters such as date and person filters + for (let i = 0; i < this.filters.length; i++) { + if (this.filters[i].id === filter.id) { + this.filters.splice(i, 1) + this.enableAllProviders() + 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.providers.forEach(async (provider, index) => { + this.providers[index].disabled = !(await this.providerIsCompatibleWithFilters(provider, ['since', 'until'])) + }) + 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) { + unifiedSearchLogger.debug('Custom date range', { 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) { + unifiedSearchLogger.debug('Handling plugin filter', { 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 + }, + async providerIsCompatibleWithFilters(provider, filterIds) { + return filterIds.every(filterId => provider.filters?.[filterId] !== undefined) + }, + async enableAllProviders() { + this.providers.forEach(async (_, index) => { + this.providers[index].disabled = false + }) + }, + }, +}) +</script> + +<style lang="scss" scoped> +:deep(.unified-search-modal .unified-search-modal__content) { + --dialog-height: min(80vh, 800px); + box-sizing: border-box; + height: var(--dialog-height); + max-height: var(--dialog-height); + min-height: var(--dialog-height); + + display: flex; + flex-direction: column; + // No padding to prevent scrollbar misplacement + padding-inline: 0; +} + +.unified-search-modal { + &__header { + // Add background to prevent leaking scrolled content (because of sticky position) + background-color: var(--color-main-background); + // Fix padding to have the input centered + padding-inline-end: 12px; + // Some padding to make elements scrolled under sticky position look nicer + padding-block-end: 12px; + // Make it sticky with the input margin for the label + position: sticky; + top: 6px; + } + + &__filters { + display: flex; + flex-wrap: wrap; + gap: 4px; + justify-content: start; + padding-top: 4px; + } + + &__search-external-resources { + :deep(span.checkbox-content) { + padding-top: 0; + padding-bottom: 0; + } + + :deep(.checkbox-content__icon) { + margin: auto !important; + } + + &--aligned { + margin-inline-start: auto; + } + } + + &__filters-applied { + padding-top: 4px; + display: flex; + flex-wrap: wrap; + } + + &__no-content { + display: flex; + align-items: center; + margin-top: 0.5em; + height: 70%; + } + + &__results { + overflow: hidden scroll; + // Adjust padding to match container but keep the scrollbar on the very end + padding-inline: 0 12px; + padding-block: 0 12px; + + .result { + &-title { + color: var(--color-primary-element); + font-size: 16px; + margin-block: 8px 4px; + } + + &-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/components/UserMenu.js b/core/src/components/UserMenu.js index f82a303d1fd..5c488f2341e 100644 --- a/core/src/components/UserMenu.js +++ b/core/src/components/UserMenu.js @@ -1,60 +1,20 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import OC from '../OC' +import Vue from 'vue' -import $ from 'jquery' +import AccountMenu from '../views/AccountMenu.vue' export const setUp = () => { - const $menu = $('#header #settings') - // Using page terminoogy as below - const $excludedPageClasses = [ - 'user-status-menu-item__header', - ] - - // show loading feedback - $menu.delegate('a', 'click', event => { - let $page = $(event.target) - if (!$page.is('a')) { - $page = $page.closest('a') - } - if (event.which === 1 && !event.ctrlKey && !event.metaKey) { - if (!$excludedPageClasses.includes($page.attr('class'))) { - $page.find('img').remove() - $page.find('div').remove() // prevent odd double-clicks - $page.prepend($('<div></div>').addClass('icon-loading-small')) - } - } else { - // Close navigation when opening menu entry in - // a new tab - OC.hideMenus(() => false) - } - }) - - $menu.delegate('a', 'mouseup', event => { - if (event.which === 2) { - // Close navigation when opening app in - // a new tab via middle click - OC.hideMenus(() => false) - } - }) + const mountPoint = document.getElementById('user-menu') + if (mountPoint) { + // eslint-disable-next-line no-new + new Vue({ + name: 'AccountMenuRoot', + el: mountPoint, + render: h => h(AccountMenu), + }) + } } diff --git a/core/src/components/login/LoginButton.vue b/core/src/components/login/LoginButton.vue index 3d3ac25de6d..da387df0ff6 100644 --- a/core/src/components/login/LoginButton.vue +++ b/core/src/components/login/LoginButton.vue @@ -1,28 +1,13 @@ <!-- - - @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - --> + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcButton type="primary" native-type="submit" :wide="true" + :disabled="loading" @click="$emit('click')"> {{ !loading ? value : valueLoading }} <template #icon> @@ -33,7 +18,9 @@ </template> <script> -import NcButton from '@nextcloud/vue/dist/Components/NcButton' +import { translate as t } from '@nextcloud/l10n' + +import NcButton from '@nextcloud/vue/components/NcButton' import ArrowRight from 'vue-material-design-icons/ArrowRight.vue' export default { diff --git a/core/src/components/login/LoginForm.cy.ts b/core/src/components/login/LoginForm.cy.ts new file mode 100644 index 00000000000..1b1aeda6306 --- /dev/null +++ b/core/src/components/login/LoginForm.cy.ts @@ -0,0 +1,76 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import LoginForm from './LoginForm.vue' + +describe('core: LoginForm', { testIsolation: true }, () => { + beforeEach(() => { + // Mock the required global state + cy.window().then(($window) => { + $window.OC = { + theme: { + name: 'J\'s cloud', + }, + requestToken: 'request-token', + } + }) + }) + + /** + * Ensure that characters like ' are not double HTML escaped. + * This was a bug in https://github.com/nextcloud/server/issues/34990 + */ + it('does not double escape special characters in product name', () => { + cy.mount(LoginForm, { + propsData: { + username: 'test-user', + }, + }) + + cy.get('h2').contains('J\'s cloud') + }) + + it('fills username from props into form', () => { + cy.mount(LoginForm, { + propsData: { + username: 'test-user', + }, + }) + + cy.get('input[name="user"]') + .should('exist') + .and('have.attr', 'id', 'user') + + cy.get('input[name="user"]') + .should('have.value', 'test-user') + }) + + it('clears password after timeout', () => { + // mock timeout of 5 seconds + cy.window().then(($window) => { + const state = $window.document.createElement('input') + state.type = 'hidden' + state.id = 'initial-state-core-loginTimeout' + state.value = btoa(JSON.stringify(5)) + $window.document.body.appendChild(state) + }) + + // mount forms + cy.mount(LoginForm) + + cy.get('input[name="password"]') + .should('exist') + .type('MyPassword') + + cy.get('input[name="password"]') + .should('have.value', 'MyPassword') + + // Wait for timeout + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(5100) + + cy.get('input[name="password"]') + .should('have.value', '') + }) +}) diff --git a/core/src/components/login/LoginForm.vue b/core/src/components/login/LoginForm.vue index c7b0a9259f9..8cbe55f1f68 100644 --- a/core/src/components/login/LoginForm.vue +++ b/core/src/components/login/LoginForm.vue @@ -1,23 +1,7 @@ <!-- - - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <form ref="loginForm" @@ -32,6 +16,11 @@ type="warning"> {{ t('core', 'Please contact your administrator.') }} </NcNoteCard> + <NcNoteCard v-if="csrfCheckFailed" + :heading="t('core', 'Session error')" + type="error"> + {{ t('core', 'It appears your session token has expired, please refresh the page and try again.') }} + </NcNoteCard> <NcNoteCard v-if="messages.length > 0"> <div v-for="(message, index) in messages" :key="index"> @@ -52,25 +41,28 @@ <!-- the following div ensures that the spinner is always inside the #message div --> <div style="clear: both;" /> </div> - <h2 class="login-form__headline" data-login-form-headline v-html="headline" /> + <h2 class="login-form__headline" data-login-form-headline> + {{ headlineText }} + </h2> <NcTextField id="user" ref="user" - :label="t('core', 'Account name or email')" - :label-visible="true" + :label="loginText" name="user" + :maxlength="255" :value.sync="user" :class="{shake: invalidPassword}" autocapitalize="none" :spellchecking="false" :autocomplete="autoCompleteAllowed ? 'username' : 'off'" required + :error="userNameInputLengthIs255" + :helper-text="userInputHelperText" data-login-form-input-user @change="updateUsername" /> <NcPasswordField id="password" ref="password" name="password" - :label-visible="true" :class="{shake: invalidPassword}" :value.sync="password" :spellchecking="false" @@ -96,7 +88,7 @@ :value="timezoneOffset"> <input type="hidden" name="requesttoken" - :value="OC.requestToken"> + :value="requestToken"> <input v-if="directLogin" type="hidden" name="direct" @@ -106,12 +98,16 @@ </template> <script> +import { loadState } from '@nextcloud/initial-state' +import { translate as t } from '@nextcloud/l10n' import { generateUrl, imagePath } from '@nextcloud/router' +import debounce from 'debounce' -import NcPasswordField from '@nextcloud/vue/dist/Components/NcPasswordField.js' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' -import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' +import NcPasswordField from '@nextcloud/vue/components/NcPasswordField' +import NcTextField from '@nextcloud/vue/components/NcTextField' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import AuthMixin from '../../mixins/auth.js' import LoginButton from './LoginButton.vue' export default { @@ -124,6 +120,8 @@ export default { NcNoteCard, }, + mixins: [AuthMixin], + props: { username: { type: String, @@ -153,30 +151,61 @@ export default { type: Boolean, default: false, }, + emailStates: { + type: Array, + default() { + return [] + }, + }, }, - data() { + setup() { + // non reactive props return { - loading: false, + t, + + // Disable escape and sanitize to prevent special characters to be html escaped + // For example "J's cloud" would be escaped to "J' cloud". But we do not need escaping as Vue does this in `v-text` automatically + headlineText: t('core', 'Log in to {productName}', { productName: OC.theme.name }, undefined, { sanitize: false, escape: false }), + + loginTimeout: loadState('core', 'loginTimeout', 300), + requestToken: window.OC.requestToken, timezone: (new Intl.DateTimeFormat())?.resolvedOptions()?.timeZone, timezoneOffset: (-new Date().getTimezoneOffset() / 60), - headline: t('core', 'Log in to {productName}', { productName: OC.theme.name }), + } + }, + + data() { + return { + loading: false, user: '', password: '', } }, computed: { + /** + * Reset the login form after a long idle time (debounced) + */ + resetFormTimeout() { + // Infinite timeout, do nothing + if (this.loginTimeout <= 0) { + return () => {} + } + // Debounce for given timeout (in seconds so convert to milli seconds) + return debounce(this.handleResetForm, this.loginTimeout * 1000) + }, + isError() { return this.invalidPassword || this.userDisabled || this.throttleDelay > 5000 }, errorLabel() { if (this.invalidPassword) { - return t('core', 'Wrong username or password.') + return t('core', 'Wrong login or password.') } if (this.userDisabled) { - return t('core', 'User disabled') + return t('core', 'This account is disabled') } if (this.throttleDelay > 5000) { return t('core', 'We have detected multiple invalid login attempts from your IP. Therefore your next login is throttled up to 30 seconds.') @@ -186,6 +215,9 @@ export default { apacheAuthFailed() { return this.errors.indexOf('apacheAuthFailed') !== -1 }, + csrfCheckFailed() { + return this.errors.indexOf('csrfCheckFailed') !== -1 + }, internalException() { return this.errors.indexOf('internalexception') !== -1 }, @@ -201,6 +233,24 @@ export default { loginActionUrl() { return generateUrl('login') }, + emailEnabled() { + return this.emailStates ? this.emailStates.every((state) => state === '1') : 1 + }, + loginText() { + if (this.emailEnabled) { + return t('core', 'Account name or email') + } + return t('core', 'Account name') + }, + }, + + watch: { + /** + * Reset form reset after the password was changed + */ + password() { + this.resetFormTimeout() + }, }, mounted() { @@ -213,10 +263,24 @@ export default { }, methods: { + /** + * Handle reset of the login form after a long IDLE time + * This is recommended security behavior to prevent password leak on public devices + */ + handleResetForm() { + this.password = '' + }, + updateUsername() { this.$emit('update:username', this.user) }, - submit() { + submit(event) { + if (this.loading) { + // Prevent the form from being submitted twice + event.preventDefault() + return + } + this.loading = true this.$emit('submit') }, @@ -226,8 +290,9 @@ export default { <style lang="scss" scoped> .login-form { - text-align: left; + text-align: start; font-size: 1rem; + margin: 0; &__fieldset { width: 100%; @@ -238,6 +303,12 @@ export default { &__headline { text-align: center; + overflow-wrap: anywhere; + } + + // Only show the error state if the user interacted with the login box + :deep(input:invalid:not(:user-invalid)) { + border-color: var(--color-border-maxcontrast) !important; } } </style> diff --git a/core/src/components/login/PasswordLessLoginForm.vue b/core/src/components/login/PasswordLessLoginForm.vue index 455017b8683..bc4d25bf70f 100644 --- a/core/src/components/login/PasswordLessLoginForm.vue +++ b/core/src/components/login/PasswordLessLoginForm.vue @@ -1,68 +1,75 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <form v-if="(isHttps || isLocalhost) && hasPublicKeyCredential" + <form v-if="(isHttps || isLocalhost) && supportsWebauthn" ref="loginForm" + aria-labelledby="password-less-login-form-title" + class="password-less-login-form" method="post" name="login" @submit.prevent="submit"> - <fieldset> - <p class="grouptop groupbottom"> - <label for="user" class="infield">{{ t('core', 'Username or email') }}</label> - <input id="user" - ref="user" - v-model="user" - type="text" - name="user" - :autocomplete="autoCompleteAllowed ? 'on' : 'off'" - :placeholder="t('core', 'Username or email')" - :aria-label="t('core', 'Username or email')" - required - @change="$emit('update:username', user)"> - </p> - - <div v-if="!validCredentials" class="body-login-container update form__message-box"> - {{ t('core', 'Your account is not setup for passwordless login.') }} - </div> - - <LoginButton v-if="validCredentials" - :loading="loading" - @click="authenticate" /> - </fieldset> + <h2 id="password-less-login-form-title"> + {{ t('core', 'Log in with a device') }} + </h2> + + <NcTextField required + :value="user" + :autocomplete="autoCompleteAllowed ? 'on' : 'off'" + :error="!validCredentials" + :label="t('core', 'Login or email')" + :placeholder="t('core', 'Login or email')" + :helper-text="!validCredentials ? t('core', 'Your account is not setup for passwordless login.') : ''" + @update:value="changeUsername" /> + + <LoginButton v-if="validCredentials" + :loading="loading" + @click="authenticate" /> </form> - <div v-else-if="!hasPublicKeyCredential" class="body-login-container update"> - <InformationIcon size="70" /> - <h2>{{ t('core', 'Browser not supported') }}</h2> - <p class="infogroup"> - {{ t('core', 'Passwordless authentication is not supported in your browser.') }} - </p> - </div> - <div v-else-if="!isHttps && !isLocalhost" class="body-login-container update"> - <LockOpenIcon size="70" /> - <h2>{{ t('core', 'Your connection is not secure') }}</h2> - <p class="infogroup"> - {{ t('core', 'Passwordless authentication is only available over a secure connection.') }} - </p> - </div> + + <NcEmptyContent v-else-if="!isHttps && !isLocalhost" + :name="t('core', 'Your connection is not secure')" + :description="t('core', 'Passwordless authentication is only available over a secure connection.')"> + <template #icon> + <LockOpenIcon /> + </template> + </NcEmptyContent> + + <NcEmptyContent v-else + :name="t('core', 'Browser not supported')" + :description="t('core', 'Passwordless authentication is not supported in your browser.')"> + <template #icon> + <InformationIcon /> + </template> + </NcEmptyContent> </template> -<script> +<script type="ts"> +import { browserSupportsWebAuthn } from '@simplewebauthn/browser' +import { defineComponent } from 'vue' import { + NoValidCredentials, startAuthentication, finishAuthentication, -} from '../../services/WebAuthnAuthenticationService' -import LoginButton from './LoginButton' -import InformationIcon from 'vue-material-design-icons/Information' -import LockOpenIcon from 'vue-material-design-icons/LockOpen' +} from '../../services/WebAuthnAuthenticationService.ts' -class NoValidCredentials extends Error { +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcTextField from '@nextcloud/vue/components/NcTextField' -} +import InformationIcon from 'vue-material-design-icons/InformationOutline.vue' +import LoginButton from './LoginButton.vue' +import LockOpenIcon from 'vue-material-design-icons/LockOpen.vue' +import logger from '../../logger' -export default { +export default defineComponent({ name: 'PasswordLessLoginForm', components: { LoginButton, InformationIcon, LockOpenIcon, + NcEmptyContent, + NcTextField, }, props: { username: { @@ -85,11 +92,14 @@ export default { type: Boolean, default: false, }, - hasPublicKeyCredential: { - type: Boolean, - default: false, - }, }, + + setup() { + return { + supportsWebauthn: browserSupportsWebAuthn(), + } + }, + data() { return { user: this.username, @@ -98,111 +108,33 @@ export default { } }, methods: { - authenticate() { - console.debug('passwordless login initiated') + async authenticate() { + // check required fields + if (!this.$refs.loginForm.checkValidity()) { + return + } - this.getAuthenticationData(this.user) - .then(publicKey => { - console.debug(publicKey) - return publicKey - }) - .then(this.sign) - .then(this.completeAuthentication) - .catch(error => { - if (error instanceof NoValidCredentials) { - this.validCredentials = false - return - } - console.debug(error) - }) - }, - getAuthenticationData(uid) { - const base64urlDecode = function(input) { - // Replace non-url compatible chars with base64 standard chars - input = input - .replace(/-/g, '+') - .replace(/_/g, '/') + console.debug('passwordless login initiated') - // Pad out with standard base64 required padding characters - const pad = input.length % 4 - if (pad) { - if (pad === 1) { - throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding') - } - input += new Array(5 - pad).join('=') + try { + const params = await startAuthentication(this.user) + await this.completeAuthentication(params) + } catch (error) { + if (error instanceof NoValidCredentials) { + this.validCredentials = false + return } - - return window.atob(input) + logger.debug(error) } - - return startAuthentication(uid) - .then(publicKey => { - console.debug('Obtained PublicKeyCredentialRequestOptions') - console.debug(publicKey) - - if (!Object.prototype.hasOwnProperty.call(publicKey, 'allowCredentials')) { - console.debug('No credentials found.') - throw new NoValidCredentials() - } - - publicKey.challenge = Uint8Array.from(base64urlDecode(publicKey.challenge), c => c.charCodeAt(0)) - publicKey.allowCredentials = publicKey.allowCredentials.map(function(data) { - return { - ...data, - id: Uint8Array.from(base64urlDecode(data.id), c => c.charCodeAt(0)), - } - }) - - console.debug('Converted PublicKeyCredentialRequestOptions') - console.debug(publicKey) - return publicKey - }) - .catch(error => { - console.debug('Error while obtaining data') - throw error - }) }, - sign(publicKey) { - const arrayToBase64String = function(a) { - return window.btoa(String.fromCharCode(...a)) - } - - const arrayToString = function(a) { - return String.fromCharCode(...a) - } - - return navigator.credentials.get({ publicKey }) - .then(data => { - console.debug(data) - console.debug(new Uint8Array(data.rawId)) - console.debug(arrayToBase64String(new Uint8Array(data.rawId))) - return { - id: data.id, - type: data.type, - rawId: arrayToBase64String(new Uint8Array(data.rawId)), - response: { - authenticatorData: arrayToBase64String(new Uint8Array(data.response.authenticatorData)), - clientDataJSON: arrayToBase64String(new Uint8Array(data.response.clientDataJSON)), - signature: arrayToBase64String(new Uint8Array(data.response.signature)), - userHandle: data.response.userHandle ? arrayToString(new Uint8Array(data.response.userHandle)) : null, - }, - } - }) - .then(challenge => { - console.debug(challenge) - return challenge - }) - .catch(error => { - console.debug('GOT AN ERROR!') - console.debug(error) // Example: timeout, interaction refused... - }) + changeUsername(username) { + this.user = username + this.$emit('update:username', this.user) }, completeAuthentication(challenge) { - console.debug('TIME TO COMPLETE') - const redirectUrl = this.redirectUrl - return finishAuthentication(JSON.stringify(challenge)) + return finishAuthentication(challenge) .then(({ defaultRedirectUrl }) => { console.debug('Logged in redirecting') // Redirect url might be false so || should be used instead of ??. @@ -217,16 +149,14 @@ export default { // noop }, }, -} +}) </script> <style lang="scss" scoped> - .body-login-container.update { - margin: 15px 0; - - &.form__message-box { - width: 240px; - margin: 5px; - } - } +.password-less-login-form { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 0; +} </style> diff --git a/core/src/components/login/ResetPassword.vue b/core/src/components/login/ResetPassword.vue index ad86281b301..fee1deacc36 100644 --- a/core/src/components/login/ResetPassword.vue +++ b/core/src/components/login/ResetPassword.vue @@ -1,75 +1,68 @@ <!-- - - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <form class="login-form" @submit.prevent="submit"> - <fieldset class="login-form__fieldset"> - <NcTextField id="user" - :value.sync="user" - name="user" - autocapitalize="off" - :label="t('core', 'Account name or email')" - :label-visible="true" - required - @change="updateUsername" /> - <LoginButton :value="t('core', 'Reset password')" /> - - <NcNoteCard v-if="message === 'send-success'" - type="success"> - {{ t('core', 'A password reset message has been sent to the email address of this account. If you do not receive it, check your spam/junk folders or ask your local administrator for help.') }} - <br> - {{ t('core', 'If it is not there ask your local administrator.') }} - </NcNoteCard> - <NcNoteCard v-else-if="message === 'send-error'" - type="error"> - {{ t('core', 'Couldn\'t send reset email. Please contact your administrator.') }} - </NcNoteCard> - <NcNoteCard v-else-if="message === 'reset-error'" - type="error"> - {{ t('core', 'Password cannot be changed. Please contact your administrator.') }} - </NcNoteCard> - - <a class="login-form__link" - href="#" - @click.prevent="$emit('abort')"> - {{ t('core', 'Back to login') }} - </a> - </fieldset> + <form class="reset-password-form" @submit.prevent="submit"> + <h2>{{ t('core', 'Reset password') }}</h2> + + <NcTextField id="user" + :value.sync="user" + name="user" + :maxlength="255" + autocapitalize="off" + :label="t('core', 'Login or email')" + :error="userNameInputLengthIs255" + :helper-text="userInputHelperText" + required + @change="updateUsername" /> + + <LoginButton :loading="loading" :value="t('core', 'Reset password')" /> + + <NcButton type="tertiary" wide @click="$emit('abort')"> + {{ t('core', 'Back to login') }} + </NcButton> + + <NcNoteCard v-if="message === 'send-success'" + type="success"> + {{ t('core', 'If this account exists, a password reset message has been sent to its email address. If you do not receive it, verify your email address and/or Login, check your spam/junk folders or ask your local administration for help.') }} + </NcNoteCard> + <NcNoteCard v-else-if="message === 'send-error'" + type="error"> + {{ t('core', 'Couldn\'t send reset email. Please contact your administrator.') }} + </NcNoteCard> + <NcNoteCard v-else-if="message === 'reset-error'" + type="error"> + {{ t('core', 'Password cannot be changed. Please contact your administrator.') }} + </NcNoteCard> </form> </template> -<script> -import axios from '@nextcloud/axios' +<script lang="ts"> import { generateUrl } from '@nextcloud/router' +import { defineComponent } from 'vue' + +import axios from '@nextcloud/axios' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcTextField from '@nextcloud/vue/components/NcTextField' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' + +import AuthMixin from '../../mixins/auth.js' import LoginButton from './LoginButton.vue' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' -import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' +import logger from '../../logger.js' -export default { +export default defineComponent({ name: 'ResetPassword', components: { LoginButton, + NcButton, NcNoteCard, NcTextField, }, + + mixins: [AuthMixin], + props: { username: { type: String, @@ -80,11 +73,12 @@ export default { required: true, }, }, + data() { return { error: false, loading: false, - message: undefined, + message: '', user: this.username, } }, @@ -97,57 +91,38 @@ export default { updateUsername() { this.$emit('update:username', this.user) }, - submit() { + + async submit() { this.loading = true this.error = false this.message = '' const url = generateUrl('/lostpassword/email') - const data = { - user: this.user, - } + try { + const { data } = await axios.post(url, { user: this.user }) + if (data.status !== 'success') { + throw new Error(`got status ${data.status}`) + } + + this.message = 'send-success' + } catch (error) { + logger.error('could not send reset email request', { error }) - return axios.post(url, data) - .then(resp => resp.data) - .then(data => { - if (data.status !== 'success') { - throw new Error(`got status ${data.status}`) - } - - this.message = 'send-success' - }) - .catch(e => { - console.error('could not send reset email request', e) - - this.error = true - this.message = 'send-error' - }) - .then(() => { this.loading = false }) + this.error = true + this.message = 'send-error' + } finally { + this.loading = false + } }, }, -} +}) </script> <style lang="scss" scoped> -.login-form { - text-align: left; - font-size: 1rem; - - &__fieldset { - width: 100%; - display: flex; - flex-direction: column; - gap: .5rem; - } - - &__link { - display: block; - font-weight: normal !important; - padding-bottom: 1rem; - cursor: pointer; - font-size: var(--default-font-size); - text-align: center; - padding: .5rem 1rem 1rem 1rem; - } +.reset-password-form { + display: flex; + flex-direction: column; + gap: .5rem; + width: 100%; } </style> diff --git a/core/src/components/login/UpdatePassword.vue b/core/src/components/login/UpdatePassword.vue index 36a63a6254a..b7b9ecccd0a 100644 --- a/core/src/components/login/UpdatePassword.vue +++ b/core/src/components/login/UpdatePassword.vue @@ -1,24 +1,7 @@ <!-- - - @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - - - - @author Julius Härtl <jus@bitgrid.net> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <form @submit.prevent="submit"> @@ -31,7 +14,7 @@ name="password" autocomplete="new-password" autocapitalize="none" - autocorrect="off" + spellcheck="false" required :placeholder="t('core', 'New password')"> </p> diff --git a/core/src/components/setup/RecommendedApps.vue b/core/src/components/setup/RecommendedApps.vue index 6b81106ff72..f2120c28402 100644 --- a/core/src/components/setup/RecommendedApps.vue +++ b/core/src/components/setup/RecommendedApps.vue @@ -1,26 +1,10 @@ <!-- - - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <div class="guest-box"> + <div class="guest-box" data-cy-setup-recommended-apps> <h2>{{ t('core', 'Recommended apps') }}</h2> <p v-if="loadingApps" class="loading text-center"> {{ t('core', 'Loading apps …') }} @@ -28,20 +12,13 @@ <p v-else-if="loadingAppsError" class="loading-error text-center"> {{ t('core', 'Could not fetch list of apps from the App Store.') }} </p> - <p v-else-if="installingApps" class="text-center"> - {{ t('core', 'Installing apps …') }} - </p> <div v-for="app in recommendedApps" :key="app.id" class="app"> <template v-if="!isHidden(app.id)"> <img :src="customIcon(app.id)" alt=""> <div class="info"> - <h3> - {{ customName(app) }} - <span v-if="app.loading" class="icon icon-loading-small-dark" /> - <span v-else-if="app.active" class="icon icon-checkmark-white" /> - </h3> - <p v-html="customDescription(app.id)" /> + <h3>{{ customName(app) }}</h3> + <p v-text="customDescription(app.id)" /> <p v-if="app.installationError"> <strong>{{ t('core', 'App download or installation failed') }}</strong> </p> @@ -52,37 +29,42 @@ <strong>{{ t('core', 'Cannot install this app') }}</strong> </p> </div> + <NcCheckboxRadioSwitch :checked="app.isSelected || app.active" + :disabled="!app.isCompatible || app.active" + :loading="app.loading" + @update:checked="toggleSelect(app.id)" /> </template> </div> <div class="dialog-row"> - <NcButton v-if="showInstallButton" - type="tertiary" - role="link" - href="defaultPageUrl" - @click="goTo(defaultPageUrl)"> + <NcButton v-if="showInstallButton && !installingApps" + data-cy-setup-recommended-apps-skip + :href="defaultPageUrl" + variant="tertiary"> {{ t('core', 'Skip') }} </NcButton> <NcButton v-if="showInstallButton" - type="primary" + data-cy-setup-recommended-apps-install + :disabled="installingApps || !isAnyAppSelected" + variant="primary" @click.stop.prevent="installApps"> - {{ t('core', 'Install recommended apps') }} + {{ installingApps ? t('core', 'Installing apps …') : t('core', 'Install recommended apps') }} </NcButton> </div> </div> </template> <script> -import axios from '@nextcloud/axios' -import { generateUrl, imagePath } from '@nextcloud/router' +import { t } from '@nextcloud/l10n' import { loadState } from '@nextcloud/initial-state' +import { generateUrl, imagePath } from '@nextcloud/router' +import axios from '@nextcloud/axios' import pLimit from 'p-limit' -import { translate as t } from '@nextcloud/l10n' +import logger from '../../logger.js' -import NcButton from '@nextcloud/vue/dist/Components/NcButton' - -import logger from '../../logger' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' const recommended = { calendar: { @@ -98,7 +80,7 @@ const recommended = { icon: imagePath('core', 'actions/mail.svg'), }, spreed: { - description: t('core', 'Chatting, video calls, screensharing, online meetings and web conferencing – in your browser and with mobile apps.'), + description: t('core', 'Chatting, video calls, screen sharing, online meetings and web conferencing – in your browser and with mobile apps.'), icon: imagePath('core', 'apps/spreed.svg'), }, richdocuments: { @@ -106,16 +88,20 @@ const recommended = { description: t('core', 'Collaborative documents, spreadsheets and presentations, built on Collabora Online.'), icon: imagePath('core', 'apps/richdocuments.svg'), }, + notes: { + description: t('core', 'Distraction free note taking app.'), + icon: imagePath('core', 'apps/notes.svg'), + }, richdocumentscode: { hidden: true, }, } const recommendedIds = Object.keys(recommended) -const defaultPageUrl = loadState('core', 'defaultPageUrl') export default { name: 'RecommendedApps', components: { + NcCheckboxRadioSwitch, NcButton, }, data() { @@ -125,20 +111,23 @@ export default { loadingApps: true, loadingAppsError: false, apps: [], - defaultPageUrl, + defaultPageUrl: loadState('core', 'defaultPageUrl'), } }, computed: { recommendedApps() { return this.apps.filter(app => recommendedIds.includes(app.id)) }, + isAnyAppSelected() { + return this.recommendedApps.some(app => app.isSelected) + }, }, async mounted() { try { const { data } = await axios.get(generateUrl('settings/apps/list')) logger.info(`${data.apps.length} apps fetched`) - this.apps = data.apps.map(app => Object.assign(app, { loading: false, installationError: false })) + this.apps = data.apps.map(app => Object.assign(app, { loading: false, installationError: false, isSelected: app.isCompatible })) logger.debug(`${this.recommendedApps.length} recommended apps found`, { apps: this.recommendedApps }) this.showInstallButton = true @@ -152,23 +141,24 @@ export default { }, methods: { installApps() { - this.showInstallButton = false this.installingApps = true const limit = pLimit(1) const installing = this.recommendedApps - .filter(app => !app.active && app.isCompatible && app.canInstall) - .map(app => limit(() => { + .filter(app => !app.active && app.isCompatible && app.canInstall && app.isSelected) + .map(app => limit(async () => { logger.info(`installing ${app.id}`) app.loading = true return axios.post(generateUrl('settings/apps/enable'), { appIds: [app.id], groups: [] }) .catch(error => { logger.error(`could not install ${app.id}`, { error }) + app.isSelected = false app.installationError = true }) .then(() => { logger.info(`installed ${app.id}`) app.loading = false + app.active = true }) })) logger.debug(`installing ${installing.length} recommended apps`) @@ -176,7 +166,7 @@ export default { .then(() => { logger.info('all recommended apps installed, redirecting …') - window.location = defaultPageUrl + window.location = this.defaultPageUrl }) .catch(error => logger.error('could not install recommended apps', { error })) }, @@ -206,8 +196,13 @@ export default { } return !!recommended[appId].hidden }, - goTo(href) { - window.location.href = href + toggleSelect(appId) { + // disable toggle when installButton is disabled + if (!(appId in recommended) || !this.showInstallButton) { + return + } + const index = this.apps.findIndex(app => app.id === appId) + this.$set(this.apps[index], 'isSelected', !this.apps[index].isSelected) }, }, } @@ -251,16 +246,17 @@ p { .info { h3, p { - text-align: left; + text-align: start; } h3 { margin-top: 0; } + } - h3 > span.icon { - display: inline-block; - } + .checkbox-radio-switch { + margin-inline-start: auto; + padding: 0 2px; } } </style> |