Feat: New UI global search

We are introducing a new search UI that providers a lot more space
for users via a large centralized modal and providers various filters
which can by applied by adding various chips on the UI.

For example, users can now filter their search or scope it by limiting the results
to specific apps, time period and people by apply the appropriate filters on the
new UI, previously filters where applied using text in the search box by prefixing
with `::`.

Resolves: #39162

Signed-off-by: fenn-cs <fenn25.fn@gmail.com>
pull/40823/head
fenn-cs 7 months ago
parent fa761b51cc
commit 20b3338288

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -0,0 +1,98 @@
<template>
<NcModal v-if="isModalOpen"
id="global-search"
:name="t('core', 'Date range filter')"
:show.sync="isModalOpen"
:size="'small'"
:clear-view-delay="0"
:title="t('Date range filter')"
@close="closeModal">
<!-- Custom date range -->
<div class="global-search-custom-date-modal">
<h1>{{ t('core', 'Date range filter') }}</h1>
<div class="global-search-custom-date-modal__pickers">
<NcDateTimePicker :id="'globalsearch-custom-date-range-start'"
v-model="dateFilter.startFrom"
:max="new Date()"
:label="t('core', 'Pick start date')"
type="date" />
<NcDateTimePicker :id="'globalsearch-custom-date-range-end'"
v-model="dateFilter.endAt"
:max="new Date()"
:label="t('core', 'Pick end date')"
type="date" />
</div>
<NcButton @click="applyCustomRange">
{{ t('core', 'Apply range') }}
<template #icon>
<CalendarRangeIcon :size="20" />
</template>
</NcButton>
</div>
</NcModal>
</template>
<script>
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcDateTimePicker from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js'
import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
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>
.global-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;
}
}
</style>

@ -0,0 +1,72 @@
<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="16" />
</span>
</div>
</template>
<script>
import CloseIcon from 'vue-material-design-icons/CloseThick.vue'
export default {
name: 'SearchFilterChip',
components: {
CloseIcon,
},
props: {
text: String,
pretext: String,
},
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;
font-size: 10px;
font-weight: bolder;
.icon {
display: flex;
align-items: center;
padding-right: 5px;
filter: grayscale(100%) invert(100%);
img {
width: 20px;
padding: 2px;
border-radius: 20px;
}
}
.text {
margin: 0 2px;
}
.close-icon {
cursor: pointer;
:hover {
border-radius: 4px;
padding: 1px;
}
}
}
</style>

@ -0,0 +1,55 @@
/**
* @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com>
*
* @author Fon E. Noel NFEBE <fenn25.fn@gmail.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/>.
*
*/
import { getLoggerBuilder } from '@nextcloud/logger'
import { getRequestToken } from '@nextcloud/auth'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import Vue from 'vue'
import GlobalSearch from './views/GlobalSearch.vue'
// eslint-disable-next-line camelcase
__webpack_nonce__ = btoa(getRequestToken())
const logger = getLoggerBuilder()
.setApp('global-search')
.detectUser()
.build()
Vue.mixin({
data() {
return {
logger,
}
},
methods: {
t,
n,
},
})
export default new Vue({
el: '#global-search',
// eslint-disable-next-line vue/match-component-file-name
name: 'GlobalSearchRoot',
render: h => h(GlobalSearch),
})

@ -0,0 +1,107 @@
/**
* @copyright 2023, Fon E. Noel NFEBE <fenn25.fn@gmail.com>
*
* @author Fon E. Noel NFEBE <fenn25.fn@gmail.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/>.
*
*/
import { generateOcsUrl, generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
/**
* Create a cancel token
*
* @return {import('axios').CancelTokenSource}
*/
const createCancelToken = () => axios.CancelToken.source()
/**
* Get the list of available search providers
*
* @return {Promise<Array>}
*/
export async function getProviders() {
try {
const { data } = await axios.get(generateOcsUrl('search/providers'), {
params: {
// Sending which location we're currently at
from: window.location.pathname.replace('/index.php', '') + window.location.search,
},
})
if ('ocs' in data && 'data' in data.ocs && Array.isArray(data.ocs.data) && data.ocs.data.length > 0) {
// Providers are sorted by the api based on their order key
return data.ocs.data
}
} catch (error) {
console.error(error)
}
return []
}
/**
* Get the list of available search providers
*
* @param {object} options destructuring object
* @param {string} options.type the type to search
* @param {string} options.query the search
* @param {number|string|undefined} options.cursor the offset for paginated searches
* @param {string} options.since the search
* @param {string} options.until the search
* @param {string} options.limit the search
* @param {string} options.person the search
* @return {object} {request: Promise, cancel: Promise}
*/
export function search({ type, query, cursor, since, until, limit, person }) {
/**
* Generate an axios cancel token
*/
const cancelToken = createCancelToken()
const request = async () => axios.get(generateOcsUrl('search/providers/{type}/search', { type }), {
cancelToken: cancelToken.token,
params: {
term: query,
cursor,
since,
until,
limit,
person,
// Sending which location we're currently at
from: window.location.pathname.replace('/index.php', '') + window.location.search,
},
})
return {
request,
cancel: cancelToken.cancel,
}
}
/**
* Get the list of active contacts
*
* @param {object} filter filter contacts by string
* @param filter.searchTerm
* @return {object} {request: Promise}
*/
export async function getContacts({ searchTerm }) {
const { data: { contacts } } = await axios.post(generateUrl('/contactsmenu/contacts'), {
filter: searchTerm,
})
return contacts
}

@ -0,0 +1,93 @@
<!--
- @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com>
-
- @author Fon E. Noel NFEBE <fenn25.fn@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/>.
-
-->
<template>
<div class="header-menu">
<NcButton class="global-search__button" :aria-label="t('core', 'Global search')" @click="toggleGlobalSearch">
<template #icon>
<Magnify class="global-search__trigger" :size="22" />
</template>
</NcButton>
<GlobalSearchModal :is-visible="showGlobalSearch" :class="'global-search-modal'" />
</div>
</template>
<script>
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import Magnify from 'vue-material-design-icons/Magnify.vue'
import GlobalSearchModal from './GlobalSearchModal.vue'
export default {
name: 'GlobalSearch',
components: {
NcButton,
Magnify,
GlobalSearchModal,
},
data() {
return {
showGlobalSearch: false,
}
},
mounted() {
console.debug('Global search initialized!')
},
methods: {
toggleGlobalSearch() {
this.showGlobalSearch = !this.showGlobalSearch
},
},
}
</script>
<style lang="scss" scoped>
.header-menu {
display: flex;
align-items: center;
justify-content: center;
.global-search__button {
display: flex;
align-items: center;
justify-content: center;
width: var(--header-height);
// height: var(--header-height);
margin: 0;
padding: 0;
cursor: pointer;
opacity: .85;
background-color: transparent;
border: none;
filter: none !important;
color: var(--color-primary-text) !important;
&:hover {
background-color: transparent !important;
}
}
}
.global-search-modal {
::v-deep .modal-container {
height: 80%;
}
}
</style>

@ -0,0 +1,651 @@
<template>
<NcModal v-if="isVisible"
id="global-search"
:name="t('core', 'Global search')"
:show.sync="isVisible"
:clear-view-delay="0"
:title="t('Global search')"
@close="closeModal">
<CustomDateRangeModal :is-open="showDateRangeModal"
:class="'global-search__date-range'"
@set:custom-date-range="setCustomDateRange"
@update:is-open="showDateRangeModal = $event" />
<!-- Global search form -->
<div ref="globalSearch" class="global-search-modal">
<h1>{{ t('core', 'Global search') }}</h1>
<NcInputField :value.sync="searchQuery"
type="text"
:label="t('core', 'Search apps, files, tags, messages') + '...'"
@update:value="debouncedFind" />
<div class="global-search-modal__filters">
<NcActions :menu-name="t('core', 'Apps and Settings')" :open.sync="providerActionMenuIsOpen">
<template #icon>
<ListBox :size="20" />
</template>
<NcActionButton v-for="provider in providers" :key="provider.id" @click="addProviderFilter(provider)">
<template #icon>
<img :src="provider.icon">
</template>
{{ t('core', provider.name) }}
</NcActionButton>
</NcActions>
<NcActions :menu-name="t('core', 'Modified')" :open.sync="dateActionMenuIsOpen">
<template #icon>
<CalendarRangeIcon :size="20" />
</template>
<NcActionButton @click="applyQuickDateRange('today')">
{{ t('core', 'Today') }}
</NcActionButton>
<NcActionButton @click="applyQuickDateRange('7days')">
{{ t('core', 'Last 7 days') }}
</NcActionButton>
<NcActionButton @click="applyQuickDateRange('30days')">
{{ t('core', 'Last 30 days') }}
</NcActionButton>
<NcActionButton @click="applyQuickDateRange('thisyear')">
{{ t('core', 'This year') }}
</NcActionButton>
<NcActionButton @click="applyQuickDateRange('lastyear')">
{{ t('core', 'Last year') }}
</NcActionButton>
<NcActionButton @click="applyQuickDateRange('custom')">
{{ t('core', 'Custom date range') }}
</NcActionButton>
</NcActions>
<NcSelect v-bind="peopleSeclectProps"
v-model="peopleSeclectProps.value"
@search="filterContacts"
@option:selected="applyPersonFilter" />
</div>
<div class="global-search-modal__filters-applied">
<FilterChip v-for="filter in filters"
:key="filter.id"
:text="filter.name ?? filter.text"
:pretext="''"
@delete="removeFilter(filter)">
<template #icon>
<AccountIcon v-if="filter.type === 'person'" />
<CalendarRangeIcon v-else-if="filter.type === 'date'" />
<img v-else :src="filter.icon" alt="">
</template>
</FilterChip>
</div>
<div v-if="searchQuery.length === 0">
<NcEmptyContent :name="t('core', 'Start typing in search')">
<template #icon>
<MagnifyIcon />
</template>
</NcEmptyContent>
</div>
<div v-for="providerResult in results" :key="providerResult.id" class="global-search-modal__results">
<div class="results">
<div class="result-title">
<span>{{ providerResult.provider }}</span>
</div>
<ul class="result-items">
<NcListItem v-for="(result, index) in providerResult.results"
:key="index"
class="result-items__item"
:name="result.title ?? ''"
:bold="false"
@click="openResult(result)">
<template #icon>
<div v-if="result.icon"
class="result-items__item-icon"
:class="{
'result-items__item-icon--no-preview': !isValidUrl(result.thumbnailUrl),
'result-items__item-icon--with-thumbnail': isValidUrl(result.thumbnailUrl),
[result.icon]: !isValidUrl(result.icon),
}"
:style="{
backgroundImage: isValidUrl(result.icon) ? `url(${result.icon})` : '',
}">
<img v-if="result.thumbnailUrl" :src="result.thumbnailUrl" class="">
</div>
</template>
<template #subname>
{{ result.subline }}
</template>
</NcListItem>
</ul>
<div class="result-footer">
<NcButton type="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult.id)">
Load more results
<template #icon>
<DotsHorizontalIcon :size="20" />
</template>
</NcButton>
<NcButton alignment="end-reverse" type="tertiary-no-background">
Search in {{ providerResult.provider }}
<template #icon>
<ArrowRight :size="20" />
</template>
</NcButton>
</div>
</div>
</div>
</div>
</NcModal>
</template>
<script>
import ArrowRight from 'vue-material-design-icons/ArrowRight.vue'
import AccountIcon from 'vue-material-design-icons/AccountCircle.vue'
import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue'
import CustomDateRangeModal from '../components/GlobalSearch/CustomDateRangeModal.vue'
import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue'
import FilterChip from '../components/GlobalSearch/SearchFilterChip.vue'
import ListBox from 'vue-material-design-icons/ListBox.vue'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js'
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
import MagnifyIcon from 'vue-material-design-icons/Magnify.vue'
import debounce from 'debounce'
import { getProviders, search as globalSearch, getContacts } from '../services/GlobalSearchService.js'
export default {
name: 'GlobalSearchModal',
components: {
AccountIcon,
ArrowRight,
CalendarRangeIcon,
CustomDateRangeModal,
DotsHorizontalIcon,
FilterChip,
ListBox,
NcActions,
NcActionButton,
NcButton,
NcEmptyContent,
NcModal,
NcListItem,
NcSelect,
NcInputField,
MagnifyIcon,
},
props: {
isVisible: {
type: Boolean,
required: true,
},
},
data() {
return {
providers: [],
providerActionMenuIsOpen: false,
dateActionMenuIsOpen: false,
providerResultLimit: 5,
dateFilter: { id: 'date', type: 'date', text: '', startFrom: null, endAt: null },
personFilter: { id: 'person', type: 'person', text: '' },
dateFilterIsApplied: false,
personFilterIsApplied: false,
filteredProviders: [],
searchQuery: '',
placesFilter: '',
dateTimeFilter: null,
filters: [],
results: [],
contacts: [],
debouncedFind: debounce(this.find, 300),
showDateRangeModal: false,
}
},
computed: {
peopleSeclectProps: {
get() {
return {
// inputId: getRandomId(),
userSelect: true,
label: t('core', 'People filter'),
placeholder: t('core', 'Search people'),
placement: 'top',
options: this.contacts,
value: null,
}
},
},
},
mounted() {
getProviders().then((providers) => {
this.providers = providers
console.debug('Search providers', this.providers)
})
getContacts({ filter: '' }).then((contacts) => {
this.contacts = this.mapContacts(contacts)
console.debug('Contacts', this.contacts)
})
},
methods: {
find(query) {
if (query.length === 0) {
this.results = []
return
}
const newResults = []
const providersToSearch = this.filteredProviders.length > 0 ? this.filteredProviders : this.providers
const searchProvider = (provider, filters) => {
const params = {
type: provider.id,
query,
cursor: null,
}
if (filters.dateFilterIsApplied) {
if (provider.filters.since && provider.filters.until) {
params.since = this.dateFilter.startFrom
params.until = this.dateFilter.endAt
} else {
// Date filter is applied but provider does not support it, no need to search provider
return
}
}
if (filters.personFilterIsApplied) {
if (provider.filters.person) {
params.person = this.personFilter.id
} else {
// Person filter is applied but provider does not support it, no need to search provider
return
}
}
if (this.providerResultLimit > 5) {
params.limit = this.providerResultLimit
}
const request = globalSearch(params).request
request().then((response) => {
newResults.push({
id: provider.id,
provider: provider.name,
results: response.data.ocs.data.entries,
})
console.debug('New results', newResults)
console.debug('Global search results:', this.results)
this.updateResults(newResults)
})
}
providersToSearch.forEach(provider => {
const dateFilterIsApplied = this.dateFilterIsApplied
const personFilterIsApplied = this.personFilterIsApplied
searchProvider(provider, { dateFilterIsApplied, personFilterIsApplied })
})
},
updateResults(newResults) {
let updatedResults = [...this.results]
// If filters are applied, remove any previous results for providers that are not in current filters
if (this.filters.length > 0) {
updatedResults = updatedResults.filter(result => {
return this.filters.some(filter => filter.id === result.id)
})
}
// Process the new results
newResults.forEach(newResult => {
const existingResultIndex = updatedResults.findIndex(result => result.id === newResult.id)
if (existingResultIndex !== -1) {
if (newResult.results.length === 0) {
// If the new results data has no matches for and existing result, remove the existing result
updatedResults.splice(existingResultIndex, 1)
} else {
// If input triggered a change in existing results, update existing result
updatedResults.splice(existingResultIndex, 1, newResult)
}
} else if (newResult.results.length > 0) {
// Push the new result to the array only if its results array is not empty
updatedResults.push(newResult)
}
})
const sortedResults = updatedResults.slice(0)
// Order results according to provider preference
sortedResults.sort((a, b) => {
const aProvider = this.providers.find(provider => provider.id === a.id)
const bProvider = this.providers.find(provider => provider.id === b.id)
const aOrder = aProvider ? aProvider.order : 0
const bOrder = bProvider ? bProvider.order : 0
return aOrder - bOrder
})
this.results = sortedResults
},
openResult(result) {
if (result.resourceUrl) {
window.location = result.resourceUrl
}
},
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,
}
})
},
filterContacts(query) {
getContacts({ filter: query }).then((contacts) => {
this.contacts = this.mapContacts(contacts)
console.debug(`Contacts filtered by ${query}`, this.contacts)
})
},
applyPersonFilter(person) {
this.personFilterIsApplied = true
this.personFilter.id = person.id
this.debouncedFind(this.searchQuery)
console.debug('Person filter applied', person)
},
loadMoreResultsForProvider(providerId) {
this.providerResultLimit += 5
this.filters = this.filters.filter(filter => filter.type !== 'provider')
const provider = this.providers.find(provider => provider.id === providerId)
this.addProviderFilter(provider, true)
},
addProviderFilter(providerFilter, loadMoreResultsForProvider = false) {
if (!providerFilter.id) return
this.providerResultLimit = loadMoreResultsForProvider ? this.providerResultLimit : 5
this.providerActionMenuIsOpen = false
const existingFilter = this.filteredProviders.find(existing => existing.id === providerFilter.id)
if (!existingFilter) {
this.filteredProviders.push({ id: providerFilter.id, name: providerFilter.name, icon: providerFilter.icon, type: 'provider' })
}
this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
console.debug('Search filters (newly added)', this.filters)
this.debouncedFind(this.searchQuery)
},
removeFilter(filter) {
if (filter.type === 'provider') {
for (let i = 0; i < this.filteredProviders.length; i++) {
if (this.filteredProviders[i].id === filter.id) {
this.filteredProviders.splice(i, 1)
break
}
}
this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
console.debug('Search filters (recently removed)', this.filters)
} else {
for (let i = 0; i < this.filters.length; i++) {
if (this.filters[i].id === 'date') {
this.dateFilterIsApplied = false
this.filters.splice(i, 1)
break
}
}
}
this.debouncedFind(this.searchQuery)
},
syncProviderFilters(firstArray, secondArray) {
// Create a copy of the first array to avoid modifying it directly.
const synchronizedArray = firstArray.slice()
// Remove items from the synchronizedArray that are not in the secondArray.
synchronizedArray.forEach((item, index) => {
const itemId = item.id
if (item.type === 'provider') {
if (!secondArray.some(secondItem => secondItem.id === itemId)) {
synchronizedArray.splice(index, 1)
}
}
})
// Add items to the synchronizedArray that are in the secondArray but not in the firstArray.
secondArray.forEach(secondItem => {
const itemId = secondItem.id
if (secondItem.type === 'provider') {
if (!synchronizedArray.some(item => item.id === itemId)) {
synchronizedArray.push(secondItem)
}
}
})
return synchronizedArray
},
updateDateFilter() {
const currFilterIndex = this.filters.findIndex(filter => filter.id === 'date')
if (currFilterIndex !== -1) {
this.filters[currFilterIndex] = this.dateFilter
} else {
this.filters.push(this.dateFilter)
}
this.dateFilterIsApplied = true
this.debouncedFind(this.searchQuery)
},
applyQuickDateRange(range) {
this.dateActionMenuIsOpen = false
const today = new Date()
let endDate = today
let startDate
switch (range) {
case 'today':
// For 'Today', both start and end are set to today
startDate = today
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)
startDate.setDate(today.getDate() - 7)
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)
startDate.setDate(today.getDate() - 30)
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 today
startDate = new Date(today.getFullYear(), 0, 1)
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)
endDate = new Date(today.getFullYear() - 1, 11, 31)
this.dateFilter.text = t('core', 'Last year')
break
case 'custom':
this.showDateRangeModal = true
return
default:
return
}
this.dateFilter.startFrom = startDate
this.dateFilter.endAt = endDate
this.updateDateFilter()
},
setCustomDateRange(event) {
console.debug('Custom date range', event)
this.dateFilter.startFrom = event.startFrom
this.dateFilter.endAt = event.endAt
this.dateFilter.text = t('core', `Between ${this.dateFilter.startFrom.toLocaleDateString()} and ${this.dateFilter.endAt.toLocaleDateString()}`)
this.updateDateFilter()
},
isValidUrl(icon) {
return /^https?:\/\//.test(icon) || icon.startsWith('//')
},
closeModal() {
this.searchQuery = ''
},
},
}
</script>
<style lang="scss" scoped>
@use "sass:math";
$clickable-area: 44px;
$margin: 10px;
.global-search-modal {
padding: 10px 20px 10px 20px;
height: 60%;
h1 {
font-size: 16px;
font-weight: bolder;
line-height: 2em;
}
&__filters {
display: flex;
padding-top: 5px;
align-items: center;
justify-content: space-between;
/* Overwrite NcSelect styles */
::v-deep div.v-select {
min-width: 0; // reset NcSelect min width
div.vs__dropdown-toggle {
height: 44px; // Overwrite height of NcSelect component to match button
}
}
::v-deep>* {
min-width: auto;
/* Reset hard set min widths */
min-height: 0;
/* Reset any min heights */
display: flex;
align-items: center;
flex: 1;
>* {
flex: 1;
min-width: auto;
/* Reset hard set min widths */
min-height: 0;
}
}
::v-deep>*:not(:last-child) {
margin: 0 2px;
}
}
&__filters-applied {
display: flex;
flex-wrap: wrap;
}
&__results {
padding: 10px;
.results {
.result-title {
span {
color: var(--color-primary-element);
font-weight: bolder;
font-size: 16px;
}
}
.result-items {
::v-deep &__item {
a {
border-radius: 12px;
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;
}
}
}
}
.result-footer {
justify-content: space-between;
align-items: center;
display: flex;
}
}
}
}
div.v-popper__wrapper {
ul {
li {
::v-deep button.action-button {
align-items: center !important;
img {
width: 24px;
margin: 0 4px;
// filter: invert(100%) grayscale(1) contrast(100) brightness(1);
filter: grayscale(100%);
}
}
}
}
}
</style>

@ -68,6 +68,7 @@ p($theme->getTitle());
</div>
<div class="header-right">
<div id="global-search"></div>
<div id="unified-search"></div>
<div id="notifications"></div>
<div id="contactsmenu"></div>

3
dist/2250-2250.js vendored

@ -0,0 +1,3 @@
/*! For license information please see 2250-2250.js.LICENSE.txt */
"use strict";(self.webpackChunknextcloud=self.webpackChunknextcloud||[]).push([[2250],{82250:(e,n,c)=>{c.d(n,{FilePickerVue:()=>s});const s=(0,c(20144).defineAsyncComponent)((()=>Promise.all([c.e(7874),c.e(3240),c.e(701)]).then(c.bind(c,91845))))}}]);
//# sourceMappingURL=2250-2250.js.map?v=62d7d19e457ac44372ef

@ -1 +1 @@
{"version":3,"file":"3998-3998.js?v=a49373c9d79e30e60f7b","mappings":";oIAsBA,MAAMA,GAAI,kCAAE,IAAM","sources":["webpack:///nextcloud/node_modules/@nextcloud/dialogs/dist/chunks/index-22ace80c.mjs"],"sourcesContent":["import { defineAsyncComponent as e } from \"vue\";\n/**\n * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>\n *\n * @author Ferdinand Thiessen <opensource@fthiessen.de>\n *\n * @license AGPL-3.0-or-later\n *\n * This program is free software: you can redistribute it and/or modify\n * it under the terms of the GNU Affero General Public License as\n * published by the Free Software Foundation, either version 3 of the\n * License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU Affero General Public License for more details.\n *\n * You should have received a copy of the GNU Affero General Public License\n * along with this program. If not, see <http://www.gnu.org/licenses/>.\n *\n */\nconst i = e(() => import(\"./FilePicker-5074f4ba.mjs\"));\nexport {\n i as FilePickerVue\n};\n"],"names":["i"],"sourceRoot":""}
{"version":3,"file":"2250-2250.js?v=62d7d19e457ac44372ef","mappings":";oIAsBA,MAAMA,GAAI,kCAAE,IAAM","sources":["webpack:///nextcloud/node_modules/@nextcloud/dialogs/dist/chunks/index-2b379907.mjs"],"sourcesContent":["import { defineAsyncComponent as e } from \"vue\";\n/**\n * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>\n *\n * @author Ferdinand Thiessen <opensource@fthiessen.de>\n *\n * @license AGPL-3.0-or-later\n *\n * This program is free software: you can redistribute it and/or modify\n * it under the terms of the GNU Affero General Public License as\n * published by the Free Software Foundation, either version 3 of the\n * License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU Affero General Public License for more details.\n *\n * You should have received a copy of the GNU Affero General Public License\n * along with this program. If not, see <http://www.gnu.org/licenses/>.\n *\n */\nconst i = e(() => import(\"./FilePicker-c55dc760.mjs\"));\nexport {\n i as FilePickerVue\n};\n"],"names":["i"],"sourceRoot":""}

3
dist/3998-3998.js vendored

@ -1,3 +0,0 @@
/*! For license information please see 3998-3998.js.LICENSE.txt */
"use strict";(self.webpackChunknextcloud=self.webpackChunknextcloud||[]).push([[3998],{83998:(e,n,c)=>{c.d(n,{FilePickerVue:()=>s});const s=(0,c(20144).defineAsyncComponent)((()=>Promise.all([c.e(7874),c.e(3240),c.e(9064)]).then(c.bind(c,39064))))}}]);
//# sourceMappingURL=3998-3998.js.map?v=a49373c9d79e30e60f7b

4
dist/4107-4107.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/6318-6318.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3
dist/701-701.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/7683-7683.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3
dist/9064-9064.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -74,6 +74,8 @@
/*! ieee754. BSD-3-Clause License. Feross Aboukhadijeh <https://feross.org/opensource> */
/*! safe-buffer. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
/**
* @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
*

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -0,0 +1,21 @@
/**
* @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com>
*
* @author Fon E. Noel NFEBE <fenn25.fn@gmail.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/>.
*
*/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/core-main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -1,5 +1,3 @@
/*! safe-buffer. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
/**
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
*

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save