Add global result frontend
Signed-off-by: Julius Härtl <jus@bitgrid.net>
This commit is contained in:
@@ -65,6 +65,7 @@
|
||||
<p />
|
||||
</div>
|
||||
</transition>
|
||||
<GlobalSearchResults />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -75,10 +76,12 @@ import { mapState, mapGetters } from 'vuex'
|
||||
import Controls from '../Controls'
|
||||
import Stack from './Stack'
|
||||
import { EmptyContent } from '@nextcloud/vue'
|
||||
import GlobalSearchResults from '../search/GlobalSearchResults'
|
||||
|
||||
export default {
|
||||
name: 'Board',
|
||||
components: {
|
||||
GlobalSearchResults,
|
||||
Controls,
|
||||
Container,
|
||||
Draggable,
|
||||
@@ -178,13 +181,17 @@ export default {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: calc(100vh - 50px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.board {
|
||||
padding-left: $board-spacing;
|
||||
position: relative;
|
||||
height: calc(100% - 44px);
|
||||
overflow-x: scroll;
|
||||
max-height: calc(100% - 44px);
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,12 +26,16 @@
|
||||
|
||||
<template>
|
||||
<AttachmentDragAndDrop v-if="card" :card-id="card.id" class="drop-upload--card">
|
||||
<div :class="{'compact': compactMode, 'current-card': currentCard, 'has-labels': card.labels && card.labels.length > 0, 'is-editing': editing, 'card__editable': canEdit}"
|
||||
<div :class="{'compact': compactMode, 'current-card': currentCard, 'has-labels': card.labels && card.labels.length > 0, 'is-editing': editing, 'card__editable': canEdit, 'card__archived': card.archived }"
|
||||
tag="div"
|
||||
class="card"
|
||||
@click="openCard">
|
||||
<div v-if="standalone" class="card-related">
|
||||
<div :style="{backgroundColor: '#' + board.color}" class="board-bullet" />
|
||||
{{ board.title }} » {{ stack.title }}
|
||||
</div>
|
||||
<div class="card-upper">
|
||||
<h3 v-if="compactMode || isArchived || showArchived || !canEdit">
|
||||
<h3 v-if="compactMode || isArchived || showArchived || !canEdit || standalone">
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<h3 v-else-if="!editing">
|
||||
@@ -98,6 +102,10 @@ export default {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
standalone: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -114,6 +122,12 @@ export default {
|
||||
...mapGetters([
|
||||
'isArchived',
|
||||
]),
|
||||
board() {
|
||||
return this.$store.getters.boardById(this?.stack?.boardId)
|
||||
},
|
||||
stack() {
|
||||
return this.$store.getters.stackById(this?.card?.stackId)
|
||||
},
|
||||
canEdit() {
|
||||
if (this.currentBoard) {
|
||||
return !this.currentBoard.archived && this.$store.getters.canEdit
|
||||
@@ -233,6 +247,9 @@ export default {
|
||||
&.card__editable .card-controls {
|
||||
margin-right: 0;
|
||||
}
|
||||
&.card__archived {
|
||||
background-color: var(--color-background-dark);
|
||||
}
|
||||
}
|
||||
|
||||
.duedate {
|
||||
@@ -244,6 +261,24 @@ export default {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.card-related {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
padding-bottom: 0px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
|
||||
.board-bullet {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background-color: transparent;
|
||||
margin-top: 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.compact {
|
||||
min-height: 44px;
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<template>
|
||||
<div v-if="card">
|
||||
<div @click.stop.prevent>
|
||||
<Actions v-if="canEdit && !isArchived">
|
||||
<Actions>
|
||||
<ActionButton v-if="showArchived === false && !isCurrentUserAssigned"
|
||||
icon="icon-user"
|
||||
:close-after-click="true"
|
||||
@@ -43,7 +43,7 @@
|
||||
{{ t('deck', 'Card details') }}
|
||||
</ActionButton>
|
||||
<ActionButton icon="icon-archive" :close-after-click="true" @click="archiveUnarchiveCard()">
|
||||
{{ showArchived ? t('deck', 'Unarchive card') : t('deck', 'Archive card') }}
|
||||
{{ card.archived ? t('deck', 'Unarchive card') : t('deck', 'Archive card') }}
|
||||
</ActionButton>
|
||||
<ActionButton v-if="showArchived === false"
|
||||
icon="icon-delete"
|
||||
|
||||
@@ -73,6 +73,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GlobalSearchResults />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -82,6 +84,7 @@ import Controls from '../Controls'
|
||||
import CardItem from '../cards/CardItem'
|
||||
import { mapGetters } from 'vuex'
|
||||
import moment from '@nextcloud/moment'
|
||||
import GlobalSearchResults from '../search/GlobalSearchResults'
|
||||
|
||||
const FILTER_UPCOMING = 'upcoming'
|
||||
|
||||
@@ -92,6 +95,7 @@ const SUPPORTED_FILTERS = [
|
||||
export default {
|
||||
name: 'Overview',
|
||||
components: {
|
||||
GlobalSearchResults,
|
||||
Controls,
|
||||
CardItem,
|
||||
},
|
||||
|
||||
199
src/components/search/GlobalSearchResults.vue
Normal file
199
src/components/search/GlobalSearchResults.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<!--
|
||||
- @copyright Copyright (c) 2021 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/>.
|
||||
-
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div v-if="searchQuery!==''" class="global-search">
|
||||
<h2><RichText :text="t('deck', 'Search for {searchQuery} in all boards')" :arguments="queryStringArgs" /></h2>
|
||||
<Actions>
|
||||
<ActionButton icon="icon-close" @click="$store.commit('setSearchQuery', '')" />
|
||||
</Actions>
|
||||
<div class="search-wrapper">
|
||||
<div v-if="loading || filteredResults.length > 0" class="search-results">
|
||||
<CardItem v-for="card in filteredResults"
|
||||
:id="card.id"
|
||||
:key="card.id"
|
||||
:standalone="true" />
|
||||
<Placeholder v-if="loading" />
|
||||
<InfiniteLoading :identifier="searchQuery" @infinite="infiniteHandler">
|
||||
<div slot="spinner" />
|
||||
<div slot="no-more" />
|
||||
<div slot="no-results">
|
||||
{{ t('deck', 'No results found') }}
|
||||
</div>
|
||||
</InfiniteLoading>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p>{{ t('deck', 'No results found') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CardItem from '../cards/CardItem'
|
||||
import { mapState } from 'vuex'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
import InfiniteLoading from 'vue-infinite-loading'
|
||||
import RichText from '@juliushaertl/vue-richtext'
|
||||
import Placeholder from './Placeholder'
|
||||
import { Actions, ActionButton } from '@nextcloud/vue'
|
||||
|
||||
const createCancelToken = () => axios.CancelToken.source()
|
||||
|
||||
function search({ query, cursor }) {
|
||||
const cancelToken = createCancelToken()
|
||||
|
||||
const request = async() => axios.get(generateOcsUrl('apps/deck/api/v1.0', 2) + '/search', {
|
||||
cancelToken: cancelToken.token,
|
||||
params: {
|
||||
term: query,
|
||||
limit: 20,
|
||||
cursor,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
request,
|
||||
cancel: cancelToken.cancel,
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'GlobalSearchResults',
|
||||
components: { CardItem, InfiniteLoading, RichText, Placeholder, Actions, ActionButton },
|
||||
data() {
|
||||
return {
|
||||
results: [],
|
||||
cancel: null,
|
||||
loading: false,
|
||||
cursor: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
searchQuery: state => state.searchQuery,
|
||||
}),
|
||||
filteredResults() {
|
||||
const sortFn = (a, b) => a.archived - b.archived || b.lastModified - a.lastModified
|
||||
if (this.$route.params.id) {
|
||||
return this.results.filter((result) => result.relatedBoard.id.toString() !== this.$route.params.id.toString()).sort(sortFn)
|
||||
}
|
||||
return [...this.results].sort(sortFn)
|
||||
},
|
||||
queryStringArgs() {
|
||||
return {
|
||||
searchQuery: this.searchQuery,
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
searchQuery() {
|
||||
this.cursor = null
|
||||
this.loading = true
|
||||
this.search()
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
infiniteHandler($state) {
|
||||
this.loading = true
|
||||
this.search().then((data) => {
|
||||
if (data.length) {
|
||||
$state.loaded()
|
||||
} else {
|
||||
$state.complete()
|
||||
}
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
async search() {
|
||||
if (this.cancel) {
|
||||
this.cancel()
|
||||
}
|
||||
const { request, cancel } = await search({ query: this.searchQuery, cursor: this.cursor })
|
||||
this.cancel = cancel
|
||||
const { data } = await request()
|
||||
|
||||
if (this.cursor === null) {
|
||||
this.results = []
|
||||
}
|
||||
if (data.ocs.data.length > 0) {
|
||||
data.ocs.data.forEach((card) => {
|
||||
this.$store.dispatch('addCardData', card)
|
||||
})
|
||||
this.results = [...this.results, ...data.ocs.data]
|
||||
this.cursor = data.ocs.data[data.ocs.data.length - 1].lastModified
|
||||
}
|
||||
return data.ocs.data
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../../css/variables.scss';
|
||||
|
||||
.global-search {
|
||||
width: 100%;
|
||||
padding: $board-spacing + $stack-spacing;
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
min-height: 35vh;
|
||||
max-height: 50vh;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
border-top: 1px solid var(--color-border);
|
||||
z-index: 1010;
|
||||
position: relative;
|
||||
|
||||
.action-item.icon-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
.search-wrapper {
|
||||
overflow: scroll;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
h2::v-deep span {
|
||||
background-color: var(--color-background-dark);
|
||||
padding: 3px;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
& > div {
|
||||
flex-grow: 0;
|
||||
}
|
||||
}
|
||||
&::v-deep .card {
|
||||
width: $stack-width;
|
||||
margin-right: $stack-spacing;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
115
src/components/search/Placeholder.vue
Normal file
115
src/components/search/Placeholder.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="card--placeholder">
|
||||
<svg class="card-placeholder__gradient">
|
||||
<defs>
|
||||
<linearGradient id="card-placeholder__gradient">
|
||||
<stop offset="0%" :stop-color="light">
|
||||
<animate attributeName="stop-color"
|
||||
:values="`${light}; ${light}; ${dark}; ${dark}; ${light}`"
|
||||
dur="2s"
|
||||
repeatCount="indefinite" />
|
||||
</stop>
|
||||
<stop offset="100%" :stop-color="dark">
|
||||
<animate attributeName="stop-color"
|
||||
:values="`${dark}; ${light}; ${light}; ${dark}; ${dark}`"
|
||||
dur="2s"
|
||||
repeatCount="indefinite" />
|
||||
</stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<svg
|
||||
class="card-placeholder__placeholder"
|
||||
:class="{ 'standalone': standalone }"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="url(#card-placeholder__gradient)">
|
||||
<rect class="card-placeholder__placeholder-line-header" :style="{width: `calc(${randWidth()}%)`}" />
|
||||
<rect class="card-placeholder__placeholder-line-one" />
|
||||
<rect class="card-placeholder__placeholder-line-two" :style="{width: `calc(${randWidth()}%)`}" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Placeholder',
|
||||
data() {
|
||||
return {
|
||||
light: null,
|
||||
dark: null,
|
||||
standalone: true,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const styles = getComputedStyle(document.documentElement)
|
||||
this.dark = styles.getPropertyValue('--color-placeholder-dark')
|
||||
this.light = styles.getPropertyValue('--color-placeholder-light')
|
||||
},
|
||||
|
||||
methods: {
|
||||
randWidth() {
|
||||
return Math.floor(Math.random() * 20) + 40
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../../css/variables.scss';
|
||||
$clickable-area: 44px;
|
||||
|
||||
.card--placeholder {
|
||||
width: $stack-width;
|
||||
margin-right: $stack-spacing;
|
||||
padding: $card-padding;
|
||||
transition: box-shadow 0.1s ease-in-out;
|
||||
box-shadow: 0 0 2px 0 var(--color-box-shadow);
|
||||
border-radius: var(--border-radius-large);
|
||||
font-size: 100%;
|
||||
margin-bottom: $card-spacing;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.card-placeholder__gradient {
|
||||
position: fixed;
|
||||
height: 0;
|
||||
width: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.card-placeholder__placeholder {
|
||||
width: 100%;
|
||||
&-line-header,
|
||||
&-line-one,
|
||||
&-line-two {
|
||||
width: 100%;
|
||||
height: 1em;
|
||||
x: 0;
|
||||
}
|
||||
&-line-header {
|
||||
visibility: hidden;
|
||||
}
|
||||
&-line-one {
|
||||
y: 5px;
|
||||
}
|
||||
|
||||
&-line-two {
|
||||
y: 25px;
|
||||
}
|
||||
|
||||
&.standalone {
|
||||
.card-placeholder__placeholder-line-header {
|
||||
visibility: visible;
|
||||
y: 5px;
|
||||
}
|
||||
.card-placeholder__placeholder-line-one {
|
||||
y: 40px;
|
||||
}
|
||||
|
||||
.card-placeholder__placeholder-line-two {
|
||||
y: 60px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
Reference in New Issue
Block a user