cubash-archive/frontend/src/components/SearchBox.vue

541 lines
14 KiB
Vue

<template>
<div
class='search_box'
ref='root'
>
<div
class='search_box__input'
tabindex='0'
@keydown.enter='goToSearch'
>
<input
class='search_box__input__field'
:class='{ "search_box__input__field--header": headerBar }'
:placeholder='placeholder || "Search Kaverti"'
v-model='searchField'
type="text"
ref='input'
@focus='setShowResults'
@input='setShowResults'
@keydown='setKeyHighlight'
>
<button
class='search_box__input__button'
@click='goToSearch'
>
<font-awesome-icon :icon='["fas", "search"]' />
</button>
</div>
<div
class='search_box__results'
:class='{ "search_box__results--show": showResults }'
v-if='headerBar'
>
<div class='search_box__results__container' ref='results'>
<template v-if='threads.length'>
<div class='search_box__results__header'>Threads</div>
<div
class='search_box__results__search_all'
:class='{
"search_box__results--highlight": highlightIndex === getHighlightIndex("threads header")
}'
ref='threads header'
@mouseover='highlightIndex = getHighlightIndex("threads header")'
@click='goToSearch'
>
<div class='search_box__results__icon'><font-awesome-icon :icon='["fa", "search"]' fixed-width /></div>
<div>
Search all threads for '<strong>{{searchField}}</strong>'
</div>
</div>
<div
class='search_box__results__thread'
:class='{
"search_box__results--highlight": highlightIndex === getHighlightIndex("threads", index)
}'
v-for='(thread, index) in threads'
:key='"thread-result-" + index'
ref='threads'
@mouseover='highlightIndex = getHighlightIndex("threads", index)'
@click='goToSearch'
>
<div class='search_box__results__title'>{{thread.name | truncate(50)}}</div>
<div class='search_box__results__content'>{{thread.Posts[0].content | stripTags | truncate(75) }}</div>
</div>
</template>
<template v-if='users.length'>
<div class='search_box__results__header search_box__results__header--divider'>Users</div>
<div
class='search_box__results__search_all'
:class='{
"search_box__results--highlight": highlightIndex === getHighlightIndex("users header")
}'
ref='users header'
@mouseover='highlightIndex = getHighlightIndex("users header")'
@click='goToSearch'
>
<div class='search_box__results__icon'><font-awesome-icon :icon='["fa", "search"]' /></div>
<div>
Search all users containing '<strong>{{searchField}}</strong>'
</div>
</div>
<div
class='search_box__results__user'
:class='{
"search_box__results--highlight": highlightIndex === getHighlightIndex("users", index)
}'
v-for='(user, index) in users'
:key='"user-result-" + index'
ref='users'
@mouseover='highlightIndex = getHighlightIndex("users", index)'
@click='goToSearch'
>
<avatar-icon size='tiny' :user='user'></avatar-icon>
<div class='search_box__results__title'>{{user.username}}</div>
</div>
</template>
<template v-if='teams.length'>
<div class='search_box__results__header search_box__results__header--divider'>Teams</div>
<div
class='search_box__results__search_all'
:class='{
"search_box__results--highlight": highlightIndex === getHighlightIndex("teams header")
}'
ref='teams header'
@mouseover='highlightIndex = getHighlightIndex("teams header")'
@click='goToSearch'
>
<div class='search_box__results__icon'><font-awesome-icon :icon='["fa", "search"]' /></div>
<div>
Search all teams containing '<strong>{{searchField}}</strong>'
</div>
</div>
<div
class='search_box__results__team'
:class='{
"search_box__results--highlight": highlightIndex === getHighlightIndex("teams", index)
}'
v-for='(team, index) in teams'
:key='"team-result-" + index'
ref='teams'
@mouseover='highlightIndex = getHighlightIndex("teams", index)'
@click='goToSearch'
>
<div class='search_box__results__title'>{{team.name}}</div>
</div>
</template>
<div class='search_box__results__message' v-if='!threads.length && !users.length && !loading'>
Uh oh! There were no results for the query: '<strong>{{searchField}}</strong>'
</div>
<div class='search_box__results__message' v-if='loading'>
Please wait...
</div>
</div>
</div>
</div>
</template>
<script>
import AvatarIcon from './AvatarIcon';
import AjaxErrorHandler from '../assets/js/errorHandler'
export default {
name: 'SearchBox',
props: ['placeholder', 'header-bar'],
components: { AvatarIcon },
data () {
return {
searchField: '',
showResults: false,
loading: false,
highlightIndex: null,
MinQueryLength: 2,
threads: [],
users: [],
teams: []
}
},
computed: {
totalHighlightOptions () {
let totalHighlightOptions = 0;
//Add one to include the 'search all option'
if(this.threads.length) totalHighlightOptions += this.threads.length + 1;
if(this.users.length) totalHighlightOptions += this.users.length + 1;
return totalHighlightOptions;
}
},
methods: {
setShowResults () {
//Return if results should not show
if(!this.headerBar) return;
this.showResults = this.searchField.trim().length > (this.$store.state.MinQueryLength-1);
if(this.showResults) {
this.getResults();
} else {
this.resetResultsBox();
}
},
resetResultsBox () {
//Return if results should not show
if(!this.headerBar) return;
this.showResults = false;
//These changes alter ui within the box
//therefore wait until transition completed
setTimeout(() => {
this.highlightIndex = null;
this.$refs.results.scrollTop = 0;
this.threads = [];
this.users = [];
}, 200);
},
//Produces a 'global' highlight index from the
//relative index of each array group, dependent on
//whether or not other array groups are empty or not
getHighlightIndex (group, index) {
if (group === 'threads header') {
return 0;
} else if(group === 'threads') {
return 1 + index;
} else if (group === 'users' || group === 'users header') {
let ret = 0;
if(this.threads.length) {
ret += 1 + this.threads.length;
}
if(group === 'users') {
ret += 1 + index;
}
return ret;
} else if(group === 'teams' || group === 'teams header') {
let ret = 0;
if(this.threads.length) {
ret += 1 + this.threads.length;
}
if(group === 'teams') {
ret += 1 + index;
}
return ret;
}
},
//Produces relative group and index
//from overall highlight index
getGroupFromIndex (index) {
if(this.threads.length && index <= this.threads.length) {
if(index === 0) {
return { group: 'threads header', index: null };
} else {
return { group: 'threads', index: index-1 };
}
} else if (this.threads.length && index > this.threads.length) {
if(index === this.threads.length + 1) {
return { group: 'users header', index: null };
} else {
return { group: 'users', index: index-1-this.threads.length-1 };
}
} else if(this.users.length) {
if(index === 0) {
return { group: 'users header', index: null };
} else {
return { group: 'users', index: index-1 };
}
} else if(this.teams.length) {
if(index === 0) {
return { group: 'teams header', index: null };
} else {
return { group: 'teams', index: index-1 };
}
}
},
setKeyHighlight (e) {
//Return if results should not show
if(!this.headerBar) return;
//Return if not up or down arrow
if(![38, 40].includes(e.keyCode)) return;
//Increment or decrement
let sign = e.keyCode === 40 ? 1 : -1;
if(this.highlightIndex === null) {
//First highlight item
if(sign === 1) {
this.highlightIndex = 0;
//Last highlight item
} else {
this.highlightIndex = this.totalHighlightOptions - 1;
}
} else {
let updatedIndex = this.highlightIndex + sign;
//Do not highlight anything, return 'focus' to input box
if(
updatedIndex === this.totalHighlightOptions ||
updatedIndex < 0
) {
this.highlightIndex = null;
return;
}
this.highlightIndex = updatedIndex;
}
//Get the element for highlighted item
//and scroll into view if not visible
let { group, index } = this.getGroupFromIndex(this.highlightIndex);
let el = index === null ? this.$refs[group] : this.$refs[group][index];
if(
//Below fold
el.offsetHeight + el.offsetTop > this.$refs.results.offsetHeight ||
//Above fold
el.offsetTop < this.$refs.results.scrollTop
) {
el.scrollIntoView();
}
},
goToSearch () {
let searchEncoded = encodeURIComponent(this.searchField.trim());
if(this.highlightIndex === null && this.searchField.trim().length) {
this.showResults = false;
this.$router.push("/search/" + searchEncoded);
} else {
let { group, index } = this.getGroupFromIndex(this.highlightIndex);
if(group === 'users') {
this.$router.push('/user/' + this.users[index].username);
} else if (group === 'threads') {
let thread = this.threads[index];
this.$router.push('/thread/' + thread.slug + '/' + thread.id);
} else if (group === 'users header') {
this.$router.push('/search/users/' + searchEncoded);
} else if(group === 'teams') {
this.$router.push('/t/' + this.teams[index].username);
} else if(group === 'teams header') {
this.$router.push('/search/teams/' + searchEncoded);
} else {
this.$router.push('/search/threads/' + searchEncoded);
}
this.resetResultsBox();
}
this.$refs.input.blur();
},
getResults () {
let q = this.searchField.trim();
if(q.length < this.$store.state.MinQueryLength) return;
this.loading = true;
this.threads = [];
this.users = [];
this.teams = [];
this.axios
.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + '/' + 'kaverti/search/thread?q=' + q)
.then(res => {
this.threads = res.data.threads.slice(0, 3);
this.loading = false;
})
.catch(AjaxErrorHandler(this.$store));
this.axios
.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + '/' + 'kaverti/search/user?q=' + q)
.then(res => {
this.users = res.data.users.slice(0, 5);
this.loading = false;
})
.catch(AjaxErrorHandler(this.$store));
this.axios
.get(process.env.VUE_APP_APIENDPOINT + process.env.VUE_APP_APIVERSION + '/' + 'kaverti/search/team?q=' + q)
.then(res => {
this.teams = res.data.teams.slice(0, 5);
this.loading = false;
})
.catch(AjaxErrorHandler(this.$store));
}
},
mounted () {
document.body.addEventListener('click', e => {
//If results box is showing, the root element is loaded and the click target
//is not part of the search box, then hide the results box
if(this.showResults && this.$refs.root && !this.$refs.root.contains(e.target)) {
this.resetResultsBox();
}
});
}
}
</script>
<style lang='scss' scoped>
@import '../assets/scss/variables.scss';
@import '../assets/scss/elementStyles.scss';
.search_box {
position: relative;
@at-root #{&}__input {
border: 1.5px solid $color__gray--darkest;
border-right: 0;
border-radius: 0.25rem;
outline: none;
display: inline-block;
overflow: hidden;
@at-root #{&}__field {
outline: none;
height: 100%;
padding: 0 0.5rem;
border: 0;
transition: width 0.2s;
@include text;
color: $color__text--primary;
@include placeholder {
@include text;
color: $color__darkgray--primary;
}
}
@at-root #{&}__button {
@extend .button;
border: 0;
border-right: 1.5px solid $color__gray--darkest;
border-radius: 0 0.2rem 0.2rem 0;
&:hover, &:active {
border-color: $color__gray--darkest;
}
}
}
@at-root #{&}__results {
background-color: #fff;
border: 1.5px solid $color__gray--darkest;
border-radius: 0.25rem;
box-shadow: 0 0.25rem 1rem rgba(#000, 0.125);
opacity: 0;
overflow: hidden;
pointer-events: none;
position: absolute;
right: 0;
transform: translateY(-0.25rem);
transition: opacity 0.2s, transform 0.2s;
width: 100%;
@at-root #{&}__container {
max-height: 20rem;
overflow-y: auto;
overflow-x: hidden;
}
@at-root #{&}--show {
opacity: 1;
pointer-events: all;
transform: translateY(0rem);
}
@at-root #{&}--highlight {
background-color: $color__lightgray--darker;
}
@at-root #{&}__icon {
padding-right: 0.5rem;
}
@at-root #{&}__header {
cursor: default;
font-weight: 600;
font-size: 0.9rem;
padding: 0.5rem 1rem;
position: sticky;
@at-root #{&}--divider {
border-top: thin solid $color__gray--darker;
}
}
@at-root #{&}__search_all {
display: flex;
flex-direction: row;
span {
padding-top: 0.15rem;
margin-right: 0.5rem;
}
}
@at-root #{&}__thread, #{&}__user, #{&}__search_all {
cursor: pointer;
padding: 0.5rem 1rem;
transition: background-color 0.2s;
&:hover {
background-color: $color__lightgray--darker;
}
&:focus {
outline: none;
background-color: $color__lightgray--darker;
}
&:last-of-type {
margin-bottom: 0.5rem;
}
}
@at-root #{&}__user {
align-items: center;
display: flex;
flex-direction: row;
padding: 0.25rem 1rem;
.avatar_icon {
pointer-events: none;
}
&:last-of-type {
margin-bottom: 0.5rem;
}
}
@at-root #{&}__title, #{&}__search_all {
font-weight: 400;
font-size: 0.9rem;
}
@at-root #{&}__content {
color: $color__text--secondary;
font-size: 0.85rem;
}
@at-root #{&}__message {
cursor: default;
padding: 1rem;
}
}
}
@media (max-width: 950px) and (min-width: $breakpoint--tablet) {
.search_box__field--header {
width: 4rem;
}
}
</style>