mirror of https://github.com/nextcloud/server.git
Fix unified search
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> Signed-off-by: npmbuildbot[bot] <npmbuildbot[bot]@users.noreply.github.com>pull/22088/head
parent
4987fe9a51
commit
1a1b3e20e4
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
@ -0,0 +1,206 @@
|
||||
<!--
|
||||
- @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/>.
|
||||
-
|
||||
-->
|
||||
<template>
|
||||
<div v-click-outside="closeMenu" :class="{ 'header-menu--opened': opened }" class="header-menu">
|
||||
<a class="header-menu__trigger"
|
||||
href="#"
|
||||
:aria-controls="`header-menu-${id}`"
|
||||
:aria-expanded="opened"
|
||||
aria-haspopup="true"
|
||||
@click.prevent="toggleMenu">
|
||||
<slot name="trigger" />
|
||||
</a>
|
||||
<div v-if="opened"
|
||||
:id="`header-menu-${id}`"
|
||||
class="header-menu__wrapper"
|
||||
role="menu">
|
||||
<div class="header-menu__carret" />
|
||||
<div class="header-menu__content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { directive as ClickOutside } from 'v-click-outside'
|
||||
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
|
||||
|
||||
export default {
|
||||
name: 'HeaderMenu',
|
||||
|
||||
directives: {
|
||||
ClickOutside,
|
||||
},
|
||||
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
opened: this.open,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
open(newVal) {
|
||||
this.opened = newVal
|
||||
this.$nextTick(() => {
|
||||
if (this.opened) {
|
||||
this.openMenu()
|
||||
} else {
|
||||
this.closeMenu()
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
document.addEventListener('keydown', this.onKeyDown)
|
||||
},
|
||||
|
||||
beforeMount() {
|
||||
subscribe(`header-menu-${this.id}-close`, this.closeMenu)
|
||||
subscribe(`header-menu-${this.id}-open`, this.openMenu)
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
unsubscribe(`header-menu-${this.id}-close`, this.closeMenu)
|
||||
unsubscribe(`header-menu-${this.id}-open`, this.openMenu)
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Toggle the current menu open state
|
||||
*/
|
||||
toggleMenu() {
|
||||
// Toggling current state
|
||||
if (!this.opened) {
|
||||
this.openMenu()
|
||||
} else {
|
||||
this.closeMenu()
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Close the current menu
|
||||
*/
|
||||
closeMenu() {
|
||||
if (!this.opened) {
|
||||
return
|
||||
}
|
||||
|
||||
this.opened = false
|
||||
this.$emit('close')
|
||||
this.$emit('update:open', false)
|
||||
emit(`header-menu-${this.id}-close`)
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the current menu
|
||||
*/
|
||||
openMenu() {
|
||||
if (this.opened) {
|
||||
return
|
||||
}
|
||||
|
||||
this.opened = true
|
||||
this.$emit('open')
|
||||
this.$emit('update:open', true)
|
||||
emit(`header-menu-${this.id}-open`)
|
||||
},
|
||||
|
||||
onKeyDown(event) {
|
||||
// If opened and escape pressed, close
|
||||
if (event.key === 'Escape' && this.opened) {
|
||||
event.preventDefault()
|
||||
this.closeMenu()
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.header-menu {
|
||||
&__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: .6;
|
||||
}
|
||||
|
||||
&--opened &__trigger,
|
||||
&__trigger:hover,
|
||||
&__trigger:focus,
|
||||
&__trigger:active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&__wrapper {
|
||||
position: absolute;
|
||||
z-index: 2000;
|
||||
top: 50px;
|
||||
right: 5px;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
background-color: var(--color-main-background);
|
||||
|
||||
filter: drop-shadow(0 1px 5px var(--color-box-shadow));
|
||||
}
|
||||
|
||||
&__carret {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 100%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
content: ' ';
|
||||
pointer-events: none;
|
||||
border: 10px solid transparent;
|
||||
border-bottom-color: var(--color-main-background);
|
||||
}
|
||||
|
||||
&__content {
|
||||
overflow: auto;
|
||||
width: 350px;
|
||||
max-width: 350px;
|
||||
min-height: calc(44px * 1.5);
|
||||
max-height: calc(100vh - 50px * 2);
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
@ -0,0 +1,211 @@
|
||||
<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,
|
||||
[iconClass]: true
|
||||
}"
|
||||
role="img">
|
||||
<img v-if="hasValidThumbnail"
|
||||
:src="thumbnailUrl"
|
||||
:alt="t('core', 'Thumbnail for {result}', {result: title})"
|
||||
@error="onError"
|
||||
@load="onLoad">
|
||||
</div>
|
||||
|
||||
<!-- Title and sub-title -->
|
||||
<span class="unified-search__result-content">
|
||||
<h3 class="unified-search__result-line-one">
|
||||
<Highlight :text="title" :search="query" />
|
||||
</h3>
|
||||
<h4 v-if="subline" class="unified-search__result-line-two">{{ subline }}</h4>
|
||||
</span>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Highlight from '@nextcloud/vue/dist/Components/Highlight'
|
||||
|
||||
export default {
|
||||
name: 'SearchResult',
|
||||
|
||||
components: {
|
||||
Highlight,
|
||||
},
|
||||
|
||||
props: {
|
||||
thumbnailUrl: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
subline: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
resourceUrl: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
iconClass: {
|
||||
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,
|
||||
}
|
||||
},
|
||||
|
||||
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>
|
||||
$clickable-area: 44px;
|
||||
$margin: 10px;
|
||||
|
||||
.unified-search__result {
|
||||
display: flex;
|
||||
height: $clickable-area;
|
||||
padding: $margin;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
// Load more entry,
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&--focused,
|
||||
&:active,
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--color-background-hover);
|
||||
}
|
||||
|
||||
* {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
overflow: hidden;
|
||||
width: $clickable-area;
|
||||
height: $clickable-area;
|
||||
border-radius: var(--border-radius);
|
||||
background-position: center center;
|
||||
background-size: 32px;
|
||||
&--rounded {
|
||||
border-radius: $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-left: $margin;
|
||||
}
|
||||
|
||||
&-line-one,
|
||||
&-line-two {
|
||||
overflow: hidden;
|
||||
flex: 1 1 100%;
|
||||
margin: 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: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @copyright 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/>.
|
||||
*/
|
||||
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import axios from '@nextcloud/axios'
|
||||
|
||||
export const defaultLimit = loadState('unified-search', 'limit-default')
|
||||
|
||||
/**
|
||||
* Get the list of available search providers
|
||||
*/
|
||||
export async function getTypes() {
|
||||
try {
|
||||
const { data } = await axios.get(generateUrl('/search/providers'))
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
return data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of available search providers
|
||||
*
|
||||
* @param {string} type the type to search
|
||||
* @param {string} query the search
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function search(type, query) {
|
||||
return axios.get(generateUrl(`/search/providers/${type}/search?term=${query}`))
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @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/>.
|
||||
*/
|
||||
|
||||
import { getRequestToken } from '@nextcloud/auth'
|
||||
import { generateFilePath } from '@nextcloud/router'
|
||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
||||
import Vue from 'vue'
|
||||
|
||||
import UnifiedSearch from './views/UnifiedSearch.vue'
|
||||
|
||||
// eslint-disable-next-line camelcase
|
||||
__webpack_nonce__ = btoa(getRequestToken())
|
||||
|
||||
// eslint-disable-next-line camelcase
|
||||
__webpack_public_path__ = generateFilePath('core', '', 'js/')
|
||||
|
||||
Vue.mixin({
|
||||
methods: {
|
||||
t,
|
||||
n,
|
||||
},
|
||||
})
|
||||
|
||||
export default new Vue({
|
||||
el: '#unified-search',
|
||||
// eslint-disable-next-line vue/match-component-file-name
|
||||
name: 'UnifiedSearchRoot',
|
||||
render: h => h(UnifiedSearch),
|
||||
})
|
Loading…
Reference in New Issue