Sorting and filtering

This commit is contained in:
Mark Cooper 2021-08-18 20:18:44 -05:00
parent a89d2e71cc
commit 7532b756a7
14 changed files with 450 additions and 42 deletions

View File

@ -7,6 +7,8 @@ export { default as AppBookShelfToolbar } from '../..\\components\\app\\BookShel
export { default as AppStreamContainer } from '../..\\components\\app\\StreamContainer.vue'
export { default as CardsBookCard } from '../..\\components\\cards\\BookCard.vue'
export { default as CardsBookCover } from '../..\\components\\cards\\BookCover.vue'
export { default as ControlsFilterSelect } from '../..\\components\\controls\\FilterSelect.vue'
export { default as ControlsOrderSelect } from '../..\\components\\controls\\OrderSelect.vue'
export { default as ControlsVolumeControl } from '../..\\components\\controls\\VolumeControl.vue'
export { default as ModalsEditModal } from '../..\\components\\modals\\EditModal.vue'
export { default as ModalsModal } from '../..\\components\\modals\\Modal.vue'
@ -34,6 +36,8 @@ export const LazyAppBookShelfToolbar = import('../..\\components\\app\\BookShelf
export const LazyAppStreamContainer = import('../..\\components\\app\\StreamContainer.vue' /* webpackChunkName: "components/app-stream-container" */).then(c => wrapFunctional(c.default || c))
export const LazyCardsBookCard = import('../..\\components\\cards\\BookCard.vue' /* webpackChunkName: "components/cards-book-card" */).then(c => wrapFunctional(c.default || c))
export const LazyCardsBookCover = import('../..\\components\\cards\\BookCover.vue' /* webpackChunkName: "components/cards-book-cover" */).then(c => wrapFunctional(c.default || c))
export const LazyControlsFilterSelect = import('../..\\components\\controls\\FilterSelect.vue' /* webpackChunkName: "components/controls-filter-select" */).then(c => wrapFunctional(c.default || c))
export const LazyControlsOrderSelect = import('../..\\components\\controls\\OrderSelect.vue' /* webpackChunkName: "components/controls-order-select" */).then(c => wrapFunctional(c.default || c))
export const LazyControlsVolumeControl = import('../..\\components\\controls\\VolumeControl.vue' /* webpackChunkName: "components/controls-volume-control" */).then(c => wrapFunctional(c.default || c))
export const LazyModalsEditModal = import('../..\\components\\modals\\EditModal.vue' /* webpackChunkName: "components/modals-edit-modal" */).then(c => wrapFunctional(c.default || c))
export const LazyModalsModal = import('../..\\components\\modals\\Modal.vue' /* webpackChunkName: "components/modals-modal" */).then(c => wrapFunctional(c.default || c))

View File

@ -9,6 +9,8 @@ const components = {
AppStreamContainer: () => import('../..\\components\\app\\StreamContainer.vue' /* webpackChunkName: "components/app-stream-container" */).then(c => wrapFunctional(c.default || c)),
CardsBookCard: () => import('../..\\components\\cards\\BookCard.vue' /* webpackChunkName: "components/cards-book-card" */).then(c => wrapFunctional(c.default || c)),
CardsBookCover: () => import('../..\\components\\cards\\BookCover.vue' /* webpackChunkName: "components/cards-book-cover" */).then(c => wrapFunctional(c.default || c)),
ControlsFilterSelect: () => import('../..\\components\\controls\\FilterSelect.vue' /* webpackChunkName: "components/controls-filter-select" */).then(c => wrapFunctional(c.default || c)),
ControlsOrderSelect: () => import('../..\\components\\controls\\OrderSelect.vue' /* webpackChunkName: "components/controls-order-select" */).then(c => wrapFunctional(c.default || c)),
ControlsVolumeControl: () => import('../..\\components\\controls\\VolumeControl.vue' /* webpackChunkName: "components/controls-volume-control" */).then(c => wrapFunctional(c.default || c)),
ModalsEditModal: () => import('../..\\components\\modals\\EditModal.vue' /* webpackChunkName: "components/modals-edit-modal" */).then(c => wrapFunctional(c.default || c)),
ModalsModal: () => import('../..\\components\\modals\\Modal.vue' /* webpackChunkName: "components/modals-modal" */).then(c => wrapFunctional(c.default || c)),

View File

@ -13,6 +13,8 @@ You can directly use them in pages and other components without the need to impo
- `<AppStreamContainer>` | `<app-stream-container>` (components/app/StreamContainer.vue)
- `<CardsBookCard>` | `<cards-book-card>` (components/cards/BookCard.vue)
- `<CardsBookCover>` | `<cards-book-cover>` (components/cards/BookCover.vue)
- `<ControlsFilterSelect>` | `<controls-filter-select>` (components/controls/FilterSelect.vue)
- `<ControlsOrderSelect>` | `<controls-order-select>` (components/controls/OrderSelect.vue)
- `<ControlsVolumeControl>` | `<controls-volume-control>` (components/controls/VolumeControl.vue)
- `<ModalsEditModal>` | `<modals-edit-modal>` (components/modals/EditModal.vue)
- `<ModalsModal>` | `<modals-modal>` (components/modals/Modal.vue)

View File

@ -20,6 +20,12 @@
"CardsBookCover": {
"description": "Auto imported from components/cards/BookCover.vue"
},
"ControlsFilterSelect": {
"description": "Auto imported from components/controls/FilterSelect.vue"
},
"ControlsOrderSelect": {
"description": "Auto imported from components/controls/OrderSelect.vue"
},
"ControlsVolumeControl": {
"description": "Auto imported from components/controls/VolumeControl.vue"
},

158
client/assets/fastSort.js Normal file
View File

@ -0,0 +1,158 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global['fast-sort'] = {}));
}(this, (function (exports) {
'use strict';
// >>> INTERFACES <<<
// >>> HELPERS <<<
var castComparer = function (comparer) { return function (a, b, order) { return comparer(a, b, order) * order; }; };
var throwInvalidConfigErrorIfTrue = function (condition, context) {
if (condition)
throw Error("Invalid sort config: " + context);
};
var unpackObjectSorter = function (sortByObj) {
var _a = sortByObj || {}, asc = _a.asc, desc = _a.desc;
var order = asc ? 1 : -1;
var sortBy = (asc || desc);
// Validate object config
throwInvalidConfigErrorIfTrue(!sortBy, 'Expected `asc` or `desc` property');
throwInvalidConfigErrorIfTrue(asc && desc, 'Ambiguous object with `asc` and `desc` config properties');
var comparer = sortByObj.comparer && castComparer(sortByObj.comparer);
return { order: order, sortBy: sortBy, comparer: comparer };
};
// >>> SORTERS <<<
var multiPropertySorterProvider = function (defaultComparer) {
return function multiPropertySorter(sortBy, sortByArr, depth, order, comparer, a, b) {
var valA;
var valB;
if (typeof sortBy === 'string') {
valA = a[sortBy];
valB = b[sortBy];
}
else if (typeof sortBy === 'function') {
valA = sortBy(a);
valB = sortBy(b);
}
else {
var objectSorterConfig = unpackObjectSorter(sortBy);
return multiPropertySorter(objectSorterConfig.sortBy, sortByArr, depth, objectSorterConfig.order, objectSorterConfig.comparer || defaultComparer, a, b);
}
var equality = comparer(valA, valB, order);
if ((equality === 0 || (valA == null && valB == null)) &&
sortByArr.length > depth) {
return multiPropertySorter(sortByArr[depth], sortByArr, depth + 1, order, comparer, a, b);
}
return equality;
};
};
function getSortStrategy(sortBy, comparer, order) {
// Flat array sorter
if (sortBy === undefined || sortBy === true) {
return function (a, b) { return comparer(a, b, order); };
}
// Sort list of objects by single object key
if (typeof sortBy === 'string') {
throwInvalidConfigErrorIfTrue(sortBy.includes('.'), 'String syntax not allowed for nested properties.');
return function (a, b) { return comparer(a[sortBy], b[sortBy], order); };
}
// Sort list of objects by single function sorter
if (typeof sortBy === 'function') {
return function (a, b) { return comparer(sortBy(a), sortBy(b), order); };
}
// Sort by multiple properties
if (Array.isArray(sortBy)) {
var multiPropSorter_1 = multiPropertySorterProvider(comparer);
return function (a, b) { return multiPropSorter_1(sortBy[0], sortBy, 1, order, comparer, a, b); };
}
// Unpack object config to get actual sorter strategy
var objectSorterConfig = unpackObjectSorter(sortBy);
return getSortStrategy(objectSorterConfig.sortBy, objectSorterConfig.comparer || comparer, objectSorterConfig.order);
}
var sortArray = function (order, ctx, sortBy, comparer) {
var _a;
if (!Array.isArray(ctx)) {
return ctx;
}
// Unwrap sortBy if array with only 1 value to get faster sort strategy
if (Array.isArray(sortBy) && sortBy.length < 2) {
_a = sortBy, sortBy = _a[0];
}
return ctx.sort(getSortStrategy(sortBy, comparer, order));
};
// >>> Public <<<
var createNewSortInstance = function (opts) {
var comparer = castComparer(opts.comparer);
return function (_ctx) {
var ctx = Array.isArray(_ctx) && !opts.inPlaceSorting
? _ctx.slice()
: _ctx;
return {
/**
* Sort array in ascending order.
* @example
* sort([3, 1, 4]).asc();
* sort(users).asc(u => u.firstName);
* sort(users).asc([
* U => u.firstName
* u => u.lastName,
* ]);
*/
asc: function (sortBy) {
return sortArray(1, ctx, sortBy, comparer);
},
/**
* Sort array in descending order.
* @example
* sort([3, 1, 4]).desc();
* sort(users).desc(u => u.firstName);
* sort(users).desc([
* U => u.firstName
* u => u.lastName,
* ]);
*/
desc: function (sortBy) {
return sortArray(-1, ctx, sortBy, comparer);
},
/**
* Sort array in ascending or descending order. It allows sorting on multiple props
* in different order for each of them.
* @example
* sort(users).by([
* { asc: u => u.score }
* { desc: u => u.age }
* ]);
*/
by: function (sortBy) {
return sortArray(1, ctx, sortBy, comparer);
},
};
};
};
var defaultComparer = function (a, b, order) {
if (a == null)
return order;
if (b == null)
return -order;
if (a < b)
return -1;
if (a === b)
return 0;
return 1;
};
var sort = createNewSortInstance({
comparer: defaultComparer,
});
var inPlaceSort = createNewSortInstance({
comparer: defaultComparer,
inPlaceSorting: true,
});
exports.createNewSortInstance = createNewSortInstance;
exports.inPlaceSort = inPlaceSort;
exports.sort = sort;
Object.defineProperty(exports, '__esModule', { value: true });
})));

View File

@ -20,13 +20,17 @@
</template>
<script>
import { sort } from '@/assets/fastSort'
console.log('SORT', sort)
export default {
data() {
return {
width: 0,
bookWidth: 176,
booksPerRow: 0,
groupedBooks: []
groupedBooks: [],
currFilterOrderKey: null
}
},
computed: {
@ -36,34 +40,19 @@ export default {
audiobooks() {
return this.$store.state.audiobooks.audiobooks
},
orderBy() {
return this.$store.state.settings.orderBy
},
orderDesc() {
return this.$store.state.settings.orderDesc
filterOrderKey() {
return this.$store.getters['settings/getFilterOrderKey']
}
},
methods: {
sortAudiobooks() {
var audiobooks = this.audiobooks.map((ab) => ({ ...ab }))
audiobooks.sort((a, b) => {
var bookA = a.book || {}
var bookB = b.book || {}
if (this.orderDesc) {
return bookB[this.orderBy] > bookA[this.orderBy] ? 1 : -1
} else {
// ASCENDING A -> Z
return bookA[this.orderBy] > bookB[this.orderBy] ? 1 : -1
}
})
return audiobooks
},
setGroupedBooks() {
var groups = []
var currentRow = 0
var currentGroup = []
var audiobooksSorted = this.sortAudiobooks()
console.log('AB SORTED', audiobooksSorted)
var audiobooksSorted = this.$store.getters['audiobooks/getFilteredAndSorted']()
this.currFilterOrderKey = this.filterOrderKey
for (let i = 0; i < audiobooksSorted.length; i++) {
var row = Math.floor(i / this.booksPerRow)
if (row > currentRow) {
@ -102,18 +91,27 @@ export default {
console.log('[AudioBookshelf] Audiobooks Updated')
this.setGroupedBooks()
},
settingsUpdated() {
// var newSortKey = `${this.orderBy}-${this.orderDesc}`
if (this.currFilterOrderKey !== this.filterOrderKey) {
this.setGroupedBooks()
}
},
scan() {
this.$root.socket.emit('scan')
}
},
mounted() {
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
this.$store.commit('settings/addListener', { id: 'bookshelf', meth: this.settingsUpdated })
this.$store.dispatch('audiobooks/load')
this.init()
window.addEventListener('resize', this.resize)
},
beforeDestroy() {
this.$store.commit('audiobooks/removeListener', 'bookshelf')
this.$store.commit('settings/removeListener', 'bookshelf')
window.removeEventListener('resize', this.resize)
}
}

View File

@ -1,8 +1,13 @@
<template>
<div class="w-full h-10 relative">
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-10 flex items-center px-8">
<p>Order By: {{ orderBy }}</p>
<p class="px-4">Desc: {{ orderDesc ? 'Desc' : 'Asc' }}</p>
<!-- <p>Order By: {{ settings.orderBy }}</p>
<p class="px-4">Desc: {{ settings.orderDesc ? 'Desc' : 'Asc' }}</p> -->
<p class="font-book">{{ numShowing }} Audiobooks</p>
<div class="flex-grow" />
<controls-filter-select v-model="settings.filterBy" class="w-28 h-7.5" @change="updateFilter" />
<span class="px-4 text-sm">by</span>
<controls-order-select v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-40 h-7.5" @change="updateOrder" />
</div>
</div>
</template>
@ -10,18 +15,33 @@
<script>
export default {
data() {
return {}
},
computed: {
orderBy() {
return this.$store.state.settings.orderBy
},
orderDesc() {
return this.$store.state.settings.orderDesc
return {
settings: {}
}
},
methods: {},
mounted() {}
computed: {
numShowing() {
return this.$store.getters['audiobooks/getFiltered']().length
}
},
methods: {
updateOrder() {
this.saveSettings()
},
updateFilter() {
this.saveSettings()
},
saveSettings() {
// Send to server
this.$store.commit('settings/setSettings', this.settings)
},
init() {
this.settings = { ...this.$store.state.settings.settings }
}
},
mounted() {
this.init()
}
}
</script>

View File

@ -0,0 +1,79 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-300 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</span>
</button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-labelledby="listbox-label" aria-activedescendant="listbox-option-3">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
</li>
</template>
</ul>
</div>
</template>
<script>
export default {
props: {
value: String
},
data() {
return {
showMenu: false,
items: [
{
text: 'All',
value: 'all'
},
{
text: 'Genre',
value: 'genre'
},
{
text: 'Tag',
value: 'tag'
}
]
}
},
computed: {
selected: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
selectedText() {
var _sel = this.items.find((i) => i.value === this.selected)
if (!_sel) return ''
return _sel.text
}
},
methods: {
clickOutside() {
this.showMenu = false
},
clickedOption(val) {
if (this.selected === val) {
return
}
this.selected = val
this.showMenu = false
this.$nextTick(() => this.$emit('change', val))
}
}
}
</script>

View File

@ -0,0 +1,101 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-300 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
<span class="material-icons text-xl text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span>
<!-- <span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</span> -->
</button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-labelledby="listbox-label" aria-activedescendant="listbox-option-3">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span>
</li>
</template>
</ul>
</div>
</template>
<script>
export default {
props: {
value: String,
descending: Boolean
},
data() {
return {
showMenu: false,
items: [
{
text: 'Title',
value: 'book.title'
},
{
text: 'Author',
value: 'book.author'
},
{
text: 'Added At',
value: 'addedAt'
},
{
text: 'Duration',
value: 'duration'
},
{
text: 'Size',
value: 'size'
}
]
}
},
computed: {
selected: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
selectedDesc: {
get() {
return this.descending
},
set(val) {
this.$emit('update:descending', val)
}
},
selectedText() {
var _sel = this.items.find((i) => i.value === this.selected)
if (!_sel) return ''
return _sel.text
}
},
methods: {
clickOutside() {
this.showMenu = false
},
clickedOption(val) {
if (this.selected === val) {
this.selectedDesc = !this.selectedDesc
} else {
this.selected = val
}
this.showMenu = false
this.$nextTick(() => this.$emit('change', val))
}
}
}
</script>

View File

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "0.9.4",
"version": "0.9.5",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {

View File

@ -1,3 +1,4 @@
import { sort } from '@/assets/fastSort'
export const state = () => ({
audiobooks: [],
@ -5,7 +6,21 @@ export const state = () => ({
})
export const getters = {
getFiltered: (state, getters, rootState) => () => {
var filtered = state.audiobooks
// TODO: Add filters
return filtered
},
getFilteredAndSorted: (state, getters, rootState) => () => {
var settings = rootState.settings.settings
var direction = settings.orderDesc ? 'desc' : 'asc'
var filtered = getters.getFiltered()
return sort(filtered)[direction]((ab) => {
// Supports dot notation strings i.e. "book.title"
return settings.orderBy.split('.').reduce((a, b) => a[b], ab)
})
}
}
export const actions = {
@ -19,7 +34,8 @@ export const actions = {
console.error('Failed', error)
commit('set', [])
})
}
},
}
export const mutations = {

View File

@ -1,11 +1,18 @@
export const state = () => ({
orderBy: 'title',
orderDesc: false
settings: {
orderBy: 'book.title',
orderDesc: false,
filterBy: 'all'
},
listeners: []
})
export const getters = {
getFilterOrderKey: (state) => {
return Object.values(state.settings).join('-')
}
}
export const actions = {
@ -14,7 +21,19 @@ export const actions = {
export const mutations = {
setSettings(state, settings) {
state.orderBy = settings.orderBy
state.orderDesc = settings.orderDesc
state.settings = {
...settings
}
state.listeners.forEach((listener) => {
listener.meth()
})
},
addListener(state, listener) {
var index = state.listeners.findIndex(l => l.id === listener.id)
if (index >= 0) state.listeners.splice(index, 1, listener)
else state.listeners.push(listener)
},
removeListener(state, listenerId) {
state.listeners = state.listeners.filter(l => l.id !== listenerId)
}
}

View File

@ -11,6 +11,9 @@ module.exports = {
darkMode: false,
theme: {
extend: {
height: {
'7.5': '1.75rem'
},
colors: {
bg: '#373838',
primary: '#262626',

View File

@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "0.9.4",
"version": "0.9.5",
"description": "",
"main": "index.js",
"scripts": {