Add global search, update filters for new filter model

This commit is contained in:
advplyr 2021-09-05 15:22:30 -05:00
parent 4420375f2a
commit 2c27fb3108
10 changed files with 164 additions and 33 deletions

View file

@ -10,8 +10,8 @@ android {
applicationId "com.audiobookshelf.app" applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 6 versionCode 7
versionName "0.2.2-beta" versionName "0.3.0-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -31,13 +31,13 @@
</li> </li>
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option"> <li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<span class="font-normal block truncate py-3">No {{ sublist }}</span> <span class="font-normal block truncate py-5 text-lg">No {{ sublist }} items</span>
</div> </div>
</li> </li>
<template v-for="item in sublistItems"> <template v-for="item in sublistItems">
<li :key="item" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item}` === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item)"> <li :key="item.value" class="text-gray-50 select-none relative px-4 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal truncate py-3 text-base">{{ snakeToNormal(item) }}</span> <span class="font-normal truncate py-3 text-base">{{ item.text }}</span>
</div> </div>
</li> </li>
</template> </template>
@ -75,6 +75,11 @@ export default {
text: 'Series', text: 'Series',
value: 'series', value: 'series',
sublist: true sublist: true
},
{
text: 'Authors',
value: 'authors',
sublist: true
} }
] ]
} }
@ -108,7 +113,7 @@ export default {
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
}, },
genres() { genres() {
return this.$store.state.audiobooks.genres return this.$store.getters['audiobooks/getGenresUsed']
}, },
tags() { tags() {
return this.$store.state.audiobooks.tags return this.$store.state.audiobooks.tags
@ -116,8 +121,16 @@ export default {
series() { series() {
return this.$store.state.audiobooks.series return this.$store.state.audiobooks.series
}, },
authors() {
return this.$store.getters['audiobooks/getUniqueAuthors']
},
sublistItems() { sublistItems() {
return this[this.sublist] || [] return (this[this.sublist] || []).map((item) => {
return {
text: item,
value: this.$encode(item)
}
})
} }
}, },
methods: { methods: {
@ -126,15 +139,6 @@ export default {
this.show = false this.show = false
this.$nextTick(() => this.$emit('change', 'all')) this.$nextTick(() => this.$emit('change', 'all'))
}, },
snakeToNormal(kebab) {
if (!kebab) {
return 'err'
}
return String(kebab)
.split('_')
.map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
.join(' ')
},
clickedSublistOption(item) { clickedSublistOption(item) {
this.clickedOption({ value: `${this.sublist}.${item}` }) this.clickedOption({ value: `${this.sublist}.${item}` })
}, },

View file

@ -0,0 +1,100 @@
<template>
<modals-modal v-model="show" width="90%" height="100%">
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20 p-8" style="max-height: 75%" @click.stop>
<ui-text-input ref="input" v-model="search" @input="updateSearch" placeholder="Search" class="w-full text-lg" />
<div v-show="isFetching" class="w-full py-8 flex justify-center">
<p class="text-lg text-gray-400">Fetching...</p>
</div>
<div v-if="!isFetching && lastSearch && !items.length" class="w-full py-8 flex justify-center">
<p class="text-lg text-gray-400">Nothing found</p>
</div>
<template v-for="item in items">
<div class="py-2 border-b border-bg flex" :key="item.id" @click="clickItem(item)">
<cards-book-cover :audiobook="item.data" :width="50" />
<div class="flex-grow px-4 h-full">
<div class="w-full h-full">
<p class="text-base truncate">{{ item.data.book.title }}</p>
<p class="text-sm text-gray-400 truncate">{{ item.data.book.author }}</p>
</div>
</div>
</div>
</template>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean
},
data() {
return {
search: null,
searchTimeout: null,
lastSearch: null,
isFetching: false,
items: []
}
},
watch: {
value(newVal) {
if (newVal) {
this.$nextTick(this.setFocus())
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
clickItem(item) {
this.show = false
this.$router.push(`/audiobook/${item.id}`)
},
async runSearch(value) {
this.lastSearch = value
if (!this.lastSearch) {
this.items = []
return
}
this.isFetching = true
var results = await this.$axios.$get(`/api/audiobooks?q=${value}`).catch((error) => {
console.error('Search error', error)
return []
})
this.isFetching = false
this.items = results.map((res) => {
return {
id: res.id,
data: res,
type: 'audiobook'
}
})
},
updateSearch(val) {
clearTimeout(this.searchTimeout)
this.searchTimeout = setTimeout(() => {
this.runSearch(val)
}, 500)
},
setFocus() {
setTimeout(() => {
if (this.$refs.input) {
this.$refs.input.focus()
}
}, 100)
}
},
mounted() {}
}
</script>

View file

@ -1,5 +1,5 @@
<template> <template>
<input v-model="input" :type="type" :disabled="disabled" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="px-2 py-1 bg-bg border border-gray-600 outline-none rounded-sm" :class="disabled ? 'text-gray-300' : 'text-white'" /> <input v-model="input" ref="input" autofocus :type="type" :disabled="disabled" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="px-2 py-1 bg-bg border border-gray-600 outline-none rounded-sm" :class="disabled ? 'text-gray-300' : 'text-white'" @keyup="keyup" />
</template> </template>
<script> <script>
@ -23,7 +23,19 @@ export default {
} }
} }
}, },
methods: {}, methods: {
focus() {
if (this.$refs.input) {
this.$refs.input.focus()
this.$refs.input.click()
}
},
keyup() {
if (this.$refs.input) {
this.input = this.$refs.input.value
}
}
},
mounted() {} mounted() {}
} }
</script> </script>

View file

@ -67,11 +67,11 @@ export default {
} }
this.$store.commit('setAppUpdateInfo', result) this.$store.commit('setAppUpdateInfo', result)
if (result.updateAvailability === 2) { // if (result.updateAvailability === 2) {
setTimeout(() => { // setTimeout(() => {
this.showUpdateToast(result.availableVersion, !!result.immediateUpdateAllowed) // this.showUpdateToast(result.availableVersion, !!result.immediateUpdateAllowed)
}, 5000) // }, 5000)
} // }
} }
// parseSemver(ver) { // parseSemver(ver) {
// if (!ver) return null // if (!ver) return null

View file

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "v0.2.2-beta", "version": "v0.3.0-beta",
"author": "advplyr", "author": "advplyr",
"scripts": { "scripts": {
"dev": "nuxt --hostname localhost --port 1337", "dev": "nuxt --hostname localhost --port 1337",

View file

@ -22,7 +22,7 @@
<p class="font-mono pt-1 pb-4">{{ $config.version }}</p> <p class="font-mono pt-1 pb-4">{{ $config.version }}</p>
<ui-btn v-if="isUpdateAvailable" class="w-full my-4" color="success" @click="clickUpdate"> Version {{ availableVersion }} is available! {{ immediateUpdateAllowed ? 'Update now' : 'Get update from app store' }} </ui-btn> <ui-btn v-if="isUpdateAvailable" class="w-full my-4" color="success" @click="clickUpdate"> Version {{ availableVersion }} is available! Open App Store</ui-btn>
</div> </div>
</template> </template>
@ -68,11 +68,7 @@ export default {
this.$router.push('/connect') this.$router.push('/connect')
}, },
async clickUpdate() { async clickUpdate() {
if (this.immediateUpdateAllowed) { await AppUpdate.openAppStore()
await AppUpdate.performImmediateUpdate()
} else {
await AppUpdate.openAppStore()
}
} }
}, },
mounted() {} mounted() {}

View file

@ -2,7 +2,9 @@
<div class="w-full h-full"> <div class="w-full h-full">
<div class="w-full h-12 relative z-20"> <div class="w-full h-12 relative z-20">
<div id="toolbar" class="asolute top-0 left-0 w-full h-full bg-bg flex items-center px-2"> <div id="toolbar" class="asolute top-0 left-0 w-full h-full bg-bg flex items-center px-2">
<span class="material-icons px-2" @click="showSearchModal = true">search</span>
<p class="font-book">{{ numAudiobooks }} Audiobooks</p> <p class="font-book">{{ numAudiobooks }} Audiobooks</p>
<div class="flex-grow" /> <div class="flex-grow" />
<span class="material-icons px-2" @click="showFilterModal = true">filter_alt</span> <span class="material-icons px-2" @click="showFilterModal = true">filter_alt</span>
<span class="material-icons px-2" @click="showSortModal = true">sort</span> <span class="material-icons px-2" @click="showSortModal = true">sort</span>
@ -12,6 +14,7 @@
<modals-order-modal v-model="showSortModal" :order-by.sync="settings.orderBy" :descending.sync="settings.orderDesc" @change="updateOrder" /> <modals-order-modal v-model="showSortModal" :order-by.sync="settings.orderBy" :descending.sync="settings.orderDesc" @change="updateOrder" />
<modals-filter-modal v-model="showFilterModal" :filter-by.sync="settings.filterBy" @change="updateFilter" /> <modals-filter-modal v-model="showFilterModal" :filter-by.sync="settings.filterBy" @change="updateFilter" />
<modals-search-modal v-model="showSearchModal" />
</div> </div>
</template> </template>
@ -21,6 +24,7 @@ export default {
return { return {
showSortModal: false, showSortModal: false,
showFilterModal: false, showFilterModal: false,
showSearchModal: false,
settings: {} settings: {}
} }
}, },

View file

@ -38,7 +38,6 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}` return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
} }
function isClickedOutsideEl(clickEvent, elToCheckOutside, ignoreSelectors = [], ignoreElems = []) { function isClickedOutsideEl(clickEvent, elToCheckOutside, ignoreSelectors = [], ignoreElems = []) {
const isDOMElement = (element) => { const isDOMElement = (element) => {
return element instanceof Element || element instanceof HTMLDocument return element instanceof Element || element instanceof HTMLDocument
@ -74,4 +73,14 @@ Vue.directive('click-outside', {
document.removeEventListener('click', el['__click_outside__'], false) document.removeEventListener('click', el['__click_outside__'], false)
delete el['__click_outside__'] delete el['__click_outside__']
} }
}) })
const encode = (text) => encodeURIComponent(Buffer.from(text).toString('base64'))
Vue.prototype.$encode = encode
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()
Vue.prototype.$decode = decode
export {
encode,
decode
}

View file

@ -1,4 +1,5 @@
import { sort } from '@/assets/fastSort' import { sort } from '@/assets/fastSort'
import { decode } from '@/plugins/init.client'
const STANDARD_GENRES = ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult'] const STANDARD_GENRES = ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult']
@ -19,7 +20,7 @@ export const getters = {
var searchGroups = ['genres', 'tags', 'series', 'authors'] var searchGroups = ['genres', 'tags', 'series', 'authors']
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.')) var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) { if (group) {
var filter = filterBy.replace(`${group}.`, '') var filter = decode(filterBy.replace(`${group}.`, ''))
if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter)) if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter)) else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter) else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
@ -40,6 +41,11 @@ export const getters = {
getUniqueAuthors: (state) => { getUniqueAuthors: (state) => {
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author) var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
return [...new Set(_authors)] return [...new Set(_authors)]
},
getGenresUsed: (state) => {
var _genres = []
state.audiobooks.filter(ab => !!(ab.book && ab.book.genres)).forEach(ab => _genres = _genres.concat(ab.book.genres))
return [...new Set(_genres)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
} }
} }