mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Lazy bookshelf, api routes for categories and filter data
This commit is contained in:
		
							parent
							
								
									4587916c8e
								
							
						
					
					
						commit
						5c92aef048
					
				| @ -40,7 +40,7 @@ | ||||
|       <div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center"> | ||||
|         <h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1> | ||||
|         <ui-btn v-show="!isHome" small class="text-sm mx-2" @click="toggleSelectAll" | ||||
|           >{{ isAllSelected ? 'Select None' : 'Select All' }}<span class="pl-2">({{ audiobooksShowing.length }})</span></ui-btn | ||||
|           >{{ isAllSelected ? 'Select None' : 'Select All' }}<span class="pl-2">({{ totalBooks }})</span></ui-btn | ||||
|         > | ||||
| 
 | ||||
|         <div class="flex-grow" /> | ||||
| @ -65,7 +65,9 @@ | ||||
| export default { | ||||
|   data() { | ||||
|     return { | ||||
|       processingBatchDelete: false | ||||
|       processingBatchDelete: false, | ||||
|       totalBooks: 0, | ||||
|       isAllSelected: false | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
| @ -96,9 +98,9 @@ export default { | ||||
|     selectedAudiobooks() { | ||||
|       return this.$store.state.selectedAudiobooks | ||||
|     }, | ||||
|     isAllSelected() { | ||||
|       return this.audiobooksShowing.length === this.selectedAudiobooks.length | ||||
|     }, | ||||
|     // isAllSelected() { | ||||
|     //   return this.audiobooksShowing.length === this.selectedAudiobooks.length | ||||
|     // }, | ||||
|     userAudiobooks() { | ||||
|       return this.$store.state.user.user.audiobooks || {} | ||||
|     }, | ||||
| @ -145,13 +147,17 @@ export default { | ||||
|     cancelSelectionMode() { | ||||
|       if (this.processingBatchDelete) return | ||||
|       this.$store.commit('setSelectedAudiobooks', []) | ||||
|       this.$eventBus.$emit('bookshelf-clear-selection') | ||||
|       this.isAllSelected = false | ||||
|     }, | ||||
|     toggleSelectAll() { | ||||
|       if (this.isAllSelected) { | ||||
|         this.cancelSelectionMode() | ||||
|       } else { | ||||
|         var audiobookIds = this.audiobooksShowing.map((a) => a.id) | ||||
|         this.$store.commit('setSelectedAudiobooks', audiobookIds) | ||||
|         this.$eventBus.$emit('bookshelf-select-all') | ||||
|         this.isAllSelected = true | ||||
|         // var audiobookIds = this.audiobooksShowing.map((a) => a.id) | ||||
|         // this.$store.commit('setSelectedAudiobooks', audiobookIds) | ||||
|       } | ||||
|     }, | ||||
|     toggleBatchRead() { | ||||
| @ -205,9 +211,17 @@ export default { | ||||
|     }, | ||||
|     batchAddToCollectionClick() { | ||||
|       this.$store.commit('globals/setShowBatchUserCollectionsModal', true) | ||||
|     }, | ||||
|     setBookshelfTotalBooks(totalBooks) { | ||||
|       this.totalBooks = totalBooks | ||||
|     } | ||||
|   }, | ||||
|   mounted() {} | ||||
|   mounted() { | ||||
|     this.$eventBus.$on('bookshelf-total-books', this.setBookshelfTotalBooks) | ||||
|   }, | ||||
|   beforeDestroy() { | ||||
|     this.$eventBus.$off('bookshelf-total-books', this.setBookshelfTotalBooks) | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
|  | ||||
| @ -13,7 +13,7 @@ | ||||
|       <div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div> | ||||
|     </div> | ||||
| 
 | ||||
|     <div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12"> | ||||
|     <div v-if="loaded && !shelves.length" class="w-full flex flex-col items-center justify-center py-12"> | ||||
|       <p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p> | ||||
|       <div class="flex"> | ||||
|         <ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn> | ||||
| @ -30,89 +30,36 @@ | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     search: Boolean, | ||||
|     results: { | ||||
|       type: Object, | ||||
|       default: () => {} | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       loaded: false, | ||||
|       availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220], | ||||
|       selectedSizeIndex: 3, | ||||
|       rowPaddingX: 40, | ||||
|       keywordFilterTimeout: null, | ||||
|       scannerParseSubtitle: false, | ||||
|       wrapperClientWidth: 0, | ||||
|       overflowingShelvesRight: {}, | ||||
|       overflowingShelvesLeft: {} | ||||
|       shelves: [] | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     showExperimentalFeatures() { | ||||
|       return this.$store.state.showExperimentalFeatures | ||||
|     }, | ||||
|     userAudiobooks() { | ||||
|       return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {} | ||||
|     }, | ||||
|     audiobooks() { | ||||
|       return this.$store.state.audiobooks.audiobooks | ||||
|     currentLibraryId() { | ||||
|       return this.$store.state.libraries.currentLibraryId | ||||
|     }, | ||||
|     bookCoverWidth() { | ||||
|       return this.availableSizes[this.selectedSizeIndex] | ||||
|     }, | ||||
|     sizeMultiplier() { | ||||
|       return this.bookCoverWidth / 120 | ||||
|     }, | ||||
|     paddingX() { | ||||
|       return 16 * this.sizeMultiplier | ||||
|     }, | ||||
|     bookWidth() { | ||||
|       return this.bookCoverWidth + this.paddingX * 2 | ||||
|     }, | ||||
|     mostRecentPlayed() { | ||||
|       var audiobooks = this.audiobooks.filter((ab) => this.userAudiobooks[ab.id] && this.userAudiobooks[ab.id].lastUpdate > 0 && this.userAudiobooks[ab.id].progress > 0 && !this.userAudiobooks[ab.id].isRead).map((ab) => ({ ...ab })) | ||||
|       audiobooks.sort((a, b) => { | ||||
|         return this.userAudiobooks[b.id].lastUpdate - this.userAudiobooks[a.id].lastUpdate | ||||
|       }) | ||||
|       return audiobooks.slice(0, 10) | ||||
|     }, | ||||
|     mostRecentAdded() { | ||||
|       var audiobooks = this.audiobooks.map((ab) => ({ ...ab })).sort((a, b) => b.addedAt - a.addedAt) | ||||
|       return audiobooks.slice(0, 10) | ||||
|     }, | ||||
|     seriesGroups() { | ||||
|       return this.$store.getters['audiobooks/getSeriesGroups']() | ||||
|     }, | ||||
|     recentlyUpdatedSeries() { | ||||
|       var mostRecentTime = 0 | ||||
|       var mostRecentSeries = null | ||||
|       this.seriesGroups.forEach((series) => { | ||||
|         if ((series.books.length && mostRecentSeries === null) || series.lastUpdate > mostRecentTime) { | ||||
|           mostRecentTime = series.lastUpdate | ||||
|           mostRecentSeries = series | ||||
|         } | ||||
|       }) | ||||
|       if (!mostRecentSeries) return null | ||||
|       return mostRecentSeries.books | ||||
|     }, | ||||
|     booksRecentlyRead() { | ||||
|       var audiobooks = this.audiobooks.filter((ab) => this.userAudiobooks[ab.id] && this.userAudiobooks[ab.id].isRead).map((ab) => ({ ...ab })) | ||||
|       audiobooks.sort((a, b) => { | ||||
|         return this.userAudiobooks[b.id].finishedAt - this.userAudiobooks[a.id].finishedAt | ||||
|       }) | ||||
|       return audiobooks.slice(0, 10) | ||||
|     }, | ||||
|     shelves() { | ||||
|       var shelves = [] | ||||
|       if (this.mostRecentPlayed.length) { | ||||
|         shelves.push({ books: this.mostRecentPlayed, label: 'Continue Reading' }) | ||||
|       } | ||||
| 
 | ||||
|       shelves.push({ books: this.mostRecentAdded, label: 'Recently Added' }) | ||||
| 
 | ||||
|       if (this.recentlyUpdatedSeries) { | ||||
|         shelves.push({ books: this.recentlyUpdatedSeries, label: 'Newest Series' }) | ||||
|       } | ||||
| 
 | ||||
|       if (this.booksRecentlyRead.length) { | ||||
|         shelves.push({ books: this.booksRecentlyRead, label: 'Read Again' }) | ||||
|       } | ||||
|       return shelves | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
| @ -136,10 +83,73 @@ export default { | ||||
|       var sizeIndex = this.availableSizes.findIndex((s) => s === bookshelfCoverSize) | ||||
|       if (!isNaN(sizeIndex)) this.selectedSizeIndex = sizeIndex | ||||
| 
 | ||||
|       await this.$store.dispatch('audiobooks/load') | ||||
|       // await this.$store.dispatch('audiobooks/load') | ||||
|       if (this.search) { | ||||
|         this.setShelvesFromSearch() | ||||
|       } else { | ||||
|         var categories = await this.$axios | ||||
|           .$get(`/api/libraries/${this.currentLibraryId}/categories`) | ||||
|           .then((data) => { | ||||
|             console.log('Category data', data) | ||||
|             return data | ||||
|           }) | ||||
|           .catch((error) => { | ||||
|             console.error('Failed to fetch cats', error) | ||||
|           }) | ||||
|         this.shelves = categories | ||||
|       } | ||||
| 
 | ||||
|       this.loaded = true | ||||
|     }, | ||||
|     async setShelvesFromSearch() { | ||||
|       var shelves = [] | ||||
|       if (this.results.audiobooks) { | ||||
|         shelves.push({ | ||||
|           id: 'audiobooks', | ||||
|           label: 'Books', | ||||
|           type: 'books', | ||||
|           entities: this.results.audiobooks.map((ab) => ab.audiobook) | ||||
|         }) | ||||
|       } | ||||
|       if (this.results.authors) { | ||||
|         shelves.push({ | ||||
|           id: 'authors', | ||||
|           label: 'Authors', | ||||
|           type: 'authors', | ||||
|           entities: this.results.authors.map((a) => a.author) | ||||
|         }) | ||||
|       } | ||||
|       if (this.results.series) { | ||||
|         shelves.push({ | ||||
|           id: 'series', | ||||
|           label: 'Series', | ||||
|           type: 'series', | ||||
|           entities: this.results.series.map((seriesObj) => { | ||||
|             return { | ||||
|               name: seriesObj.series, | ||||
|               books: seriesObj.audiobooks, | ||||
|               type: 'series' | ||||
|             } | ||||
|           }) | ||||
|         }) | ||||
|       } | ||||
|       if (this.results.tags) { | ||||
|         shelves.push({ | ||||
|           id: 'tags', | ||||
|           label: 'Tags', | ||||
|           type: 'tags', | ||||
|           entities: this.results.tags.map((tagObj) => { | ||||
|             return { | ||||
|               name: tagObj.tag, | ||||
|               books: tagObj.audiobooks, | ||||
|               type: 'tags' | ||||
|             } | ||||
|           }) | ||||
|         }) | ||||
|       } | ||||
|       this.shelves = shelves | ||||
|     }, | ||||
|     resize() {}, | ||||
|     audiobooksUpdated() {}, | ||||
|     settingsUpdated(settings) { | ||||
|       if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) { | ||||
|         var index = this.availableSizes.indexOf(settings.bookshelfCoverSize) | ||||
| @ -154,15 +164,11 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   mounted() { | ||||
|     window.addEventListener('resize', this.resize) | ||||
|     this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated }) | ||||
|     this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated }) | ||||
| 
 | ||||
|     this.init() | ||||
|   }, | ||||
|   beforeDestroy() { | ||||
|     window.removeEventListener('resize', this.resize) | ||||
|     this.$store.commit('audiobooks/removeListener', 'bookshelf') | ||||
|     this.$store.commit('user/removeSettingsListener', 'bookshelf') | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -2,11 +2,23 @@ | ||||
|   <div class="relative"> | ||||
|     <div ref="shelf" class="w-full max-w-full categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem' }" @scroll="scrolled"> | ||||
|       <div class="w-full h-full" :style="{ marginTop: sizeMultiplier + 'rem' }"> | ||||
|         <div v-if="shelf.books" class="flex items-center -mb-2"> | ||||
|           <template v-for="entity in shelf.books"> | ||||
|         <div v-if="shelf.type === 'books'" class="flex items-center -mb-2"> | ||||
|           <template v-for="entity in shelf.entities"> | ||||
|             <cards-book-card :key="entity.id" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @hook:updated="updatedBookCard" :padding-y="24" @edit="editBook" /> | ||||
|           </template> | ||||
|         </div> | ||||
|         <div v-if="shelf.type === 'series'" class="flex items-center -mb-2"> | ||||
|           <template v-for="entity in shelf.entities"> | ||||
|             <cards-group-card :key="entity.name" :width="bookCoverWidth" :group="entity" @click="$emit('clickSeries', entity)" /> | ||||
|           </template> | ||||
|         </div> | ||||
|         <div v-if="shelf.type === 'tags'" class="flex items-center -mb-2"> | ||||
|           <template v-for="entity in shelf.entities"> | ||||
|             <nuxt-link :key="entity.name" :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(entity.name)}`"> | ||||
|               <cards-group-card is-search :width="bookCoverWidth" :group="entity" /> | ||||
|             </nuxt-link> | ||||
|           </template> | ||||
|         </div> | ||||
|         <div v-else-if="shelf.series" class="flex items-center -mb-2"> | ||||
|           <template v-for="entity in shelf.series"> | ||||
|             <cards-group-card is-search :key="entity.name" :width="bookCoverWidth" :group="entity" @click="$emit('clickSeries', entity)" /> | ||||
| @ -70,7 +82,7 @@ export default { | ||||
|   }, | ||||
|   methods: { | ||||
|     editBook(audiobook) { | ||||
|       var bookIds = this.shelf.books.map((e) => e.id) | ||||
|       var bookIds = this.shelf.entities.map((e) => e.id) | ||||
|       this.$store.commit('setBookshelfBookIds', bookIds) | ||||
|       this.$store.commit('showEditModal', audiobook) | ||||
|     }, | ||||
|  | ||||
| @ -28,7 +28,7 @@ | ||||
|         </div> | ||||
|         <div class="flex-grow hidden md:inline-block" /> | ||||
| 
 | ||||
|         <ui-text-input v-show="showSortFilters" v-model="_keywordFilter" placeholder="Keyword Filter" :padding-y="1.5" clearable class="text-xs w-40 hidden md:block" /> | ||||
|         <!-- <ui-text-input v-show="showSortFilters" v-model="keywordFilter" @input="keywordFilterInput" placeholder="Keyword Filter" :padding-y="1.5" clearable class="text-xs w-40 hidden md:block" /> --> | ||||
|         <controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-48 h-7.5 ml-4" @change="updateFilter" /> | ||||
|         <controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5 ml-4" @change="updateOrder" /> | ||||
|         <div v-show="showSortFilters" class="h-7 ml-4 flex border border-white border-opacity-25 rounded-md"> | ||||
| @ -69,7 +69,10 @@ export default { | ||||
|   data() { | ||||
|     return { | ||||
|       settings: {}, | ||||
|       hasInit: false | ||||
|       hasInit: false, | ||||
|       totalEntities: 0, | ||||
|       keywordFilter: null, | ||||
|       keywordTimeout: null | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
| @ -80,8 +83,11 @@ export default { | ||||
|       return this.page === '' | ||||
|     }, | ||||
|     numShowing() { | ||||
|       return this.totalEntities | ||||
| 
 | ||||
|       if (this.page === '') { | ||||
|         return this.$store.getters['audiobooks/getFiltered']().length | ||||
|         // return this.$store.getters['audiobooks/getFiltered']().length | ||||
|         return this.totalEntities | ||||
|       } else if (this.page === 'search') { | ||||
|         var audiobookSearchResults = this.searchResults ? this.searchResults.audiobooks || [] : [] | ||||
|         return audiobookSearchResults.length | ||||
| @ -103,14 +109,14 @@ export default { | ||||
|       if (this.page === 'collections') return 'Collections' | ||||
|       return '' | ||||
|     }, | ||||
|     _keywordFilter: { | ||||
|       get() { | ||||
|         return this.$store.state.audiobooks.keywordFilter | ||||
|       }, | ||||
|       set(val) { | ||||
|         this.$store.commit('audiobooks/setKeywordFilter', val) | ||||
|       } | ||||
|     }, | ||||
|     // _keywordFilter: { | ||||
|     //   get() { | ||||
|     //     return this.$store.state.audiobooks.keywordFilter | ||||
|     //   }, | ||||
|     //   set(val) { | ||||
|     //     this.$store.commit('audiobooks/setKeywordFilter', val) | ||||
|     //   } | ||||
|     // }, | ||||
|     paramId() { | ||||
|       return this.$route.params ? this.$route.params.id || '' : '' | ||||
|     }, | ||||
| @ -151,14 +157,28 @@ export default { | ||||
|       for (const key in settings) { | ||||
|         this.settings[key] = settings[key] | ||||
|       } | ||||
|     }, | ||||
|     setBookshelfTotalEntities(totalEntities) { | ||||
|       this.totalEntities = totalEntities | ||||
|     }, | ||||
|     keywordFilterInput() { | ||||
|       clearTimeout(this.keywordTimeout) | ||||
|       this.keywordTimeout = setTimeout(() => { | ||||
|         this.keywordUpdated(this.keywordFilter) | ||||
|       }, 1000) | ||||
|     }, | ||||
|     keywordUpdated() { | ||||
|       this.$eventBus.$emit('bookshelf-keyword-filter', this.keywordFilter) | ||||
|     } | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.init() | ||||
|     this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated }) | ||||
|     this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities) | ||||
|   }, | ||||
|   beforeDestroy() { | ||||
|     this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar') | ||||
|     this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities) | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| @ -2,47 +2,77 @@ | ||||
|   <div id="bookshelf" class="w-full overflow-y-auto"> | ||||
|     <template v-for="shelf in totalShelves"> | ||||
|       <div :key="shelf" class="w-full px-8 bookshelfRow relative" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }"> | ||||
|         <div class="absolute top-0 left-0 bottom-0 p-4 z-10"> | ||||
|         <!-- <div class="absolute top-0 left-0 bottom-0 p-4 z-10"> | ||||
|           <p class="text-white text-2xl">{{ shelf }}</p> | ||||
|         </div> | ||||
|         </div> --> | ||||
|         <div class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-10" :class="`h-${shelfDividerHeightIndex}`" /> | ||||
|       </div> | ||||
|     </template> | ||||
| 
 | ||||
|     <div v-if="!totalShelves && initialized" class="w-full py-16"> | ||||
|       <p class="text-xl text-center">{{ emptyMessage }}</p> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| import Vue from 'vue' | ||||
| import LazyBookCard from '../cards/LazyBookCard' | ||||
| import bookshelfCardsHelpers from '@/mixins/bookshelfCardsHelpers' | ||||
| 
 | ||||
| export default { | ||||
|   props: { | ||||
|     page: String | ||||
|   }, | ||||
|   mixins: [bookshelfCardsHelpers], | ||||
|   data() { | ||||
|     return { | ||||
|       initialized: false, | ||||
|       bookshelfHeight: 0, | ||||
|       bookshelfWidth: 0, | ||||
|       shelvesPerPage: 0, | ||||
|       booksPerShelf: 8, | ||||
|       entitiesPerShelf: 8, | ||||
|       currentPage: 0, | ||||
|       totalBooks: 0, | ||||
|       books: [], | ||||
|       totalEntities: 0, | ||||
|       entities: [], | ||||
|       pagesLoaded: {}, | ||||
|       bookIndexesMounted: [], | ||||
|       bookComponentRefs: {}, | ||||
|       entityIndexesMounted: [], | ||||
|       entityComponentRefs: {}, | ||||
|       bookWidth: 120, | ||||
|       pageLoadQueue: [], | ||||
|       isFetchingBooks: false, | ||||
|       isFetchingEntities: false, | ||||
|       scrollTimeout: null, | ||||
|       booksPerFetch: 100, | ||||
|       booksPerFetch: 250, | ||||
|       totalShelves: 0, | ||||
|       bookshelfMarginLeft: 0 | ||||
|       bookshelfMarginLeft: 0, | ||||
|       isSelectionMode: false, | ||||
|       isSelectAll: false, | ||||
|       currentSFQueryString: null, | ||||
|       pendingReset: false, | ||||
|       keywordFilter: null | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     sortBy() { | ||||
|     // booksFiltered() { | ||||
|     //   const keywordFilterKeys = ['title', 'subtitle', 'author', 'series', 'narrator'] | ||||
|     //   const keyworkFilter = state.keywordFilter.toLowerCase() | ||||
|     //   return this.books.filter((ab) => { | ||||
|     //     if (!ab.book) return false | ||||
|     //     return !!keywordFilterKeys.find((key) => ab.book[key] && ab.book[key].toLowerCase().includes(keyworkFilter)) | ||||
|     //   }) | ||||
|     // }, | ||||
|     emptyMessage() { | ||||
|       if (this.page === 'series') return `You have no series` | ||||
|       if (this.page === 'collections') return "You haven't made any collections yet" | ||||
|       return 'No results' | ||||
|     }, | ||||
|     entityName() { | ||||
|       if (this.page === 'series') return 'series' | ||||
|       if (this.page === 'collections') return 'collections' | ||||
|       return 'books' | ||||
|     }, | ||||
|     orderBy() { | ||||
|       return this.$store.getters['user/getUserSetting']('orderBy') | ||||
|     }, | ||||
|     sortDesc() { | ||||
|     orderDesc() { | ||||
|       return this.$store.getters['user/getUserSetting']('orderDesc') | ||||
|     }, | ||||
|     filterBy() { | ||||
| @ -51,6 +81,11 @@ export default { | ||||
|     currentLibraryId() { | ||||
|       return this.$store.state.libraries.currentLibraryId | ||||
|     }, | ||||
|     entityWidth() { | ||||
|       if (this.entityName === 'series') return this.bookWidth * 1.6 | ||||
|       if (this.entityName === 'collections') return this.bookWidth * 2 | ||||
|       return this.bookWidth | ||||
|     }, | ||||
|     bookHeight() { | ||||
|       return this.bookWidth * 1.6 | ||||
|     }, | ||||
| @ -60,119 +95,133 @@ export default { | ||||
|     shelfHeight() { | ||||
|       return this.bookHeight + 40 | ||||
|     }, | ||||
|     totalBookCardWidth() { | ||||
|     totalEntityCardWidth() { | ||||
|       // Includes margin | ||||
|       return this.bookWidth + 24 | ||||
|       return this.entityWidth + 24 | ||||
|     }, | ||||
|     booksPerPage() { | ||||
|       return this.shelvesPerPage * this.booksPerShelf | ||||
|       return this.shelvesPerPage * this.entitiesPerShelf | ||||
|     }, | ||||
|     selectedAudiobooks() { | ||||
|       return this.$store.state.selectedAudiobooks || [] | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     async fetchBooks(page = 0) { | ||||
|     editEntity(entity) { | ||||
|       if (this.entityName === 'books') { | ||||
|         var bookIds = this.entities.map((e) => e.id) | ||||
|         this.$store.commit('setBookshelfBookIds', bookIds) | ||||
|         this.$store.commit('showEditModal', entity) | ||||
|       } | ||||
|     }, | ||||
|     clearSelectedBooks() { | ||||
|       this.updateBookSelectionMode(false) | ||||
|       this.isSelectionMode = false | ||||
|       this.isSelectAll = false | ||||
|     }, | ||||
|     selectAllBooks() { | ||||
|       this.isSelectAll = true | ||||
|       for (const key in this.entityComponentRefs) { | ||||
|         if (this.entityIndexesMounted.includes(Number(key))) { | ||||
|           this.entityComponentRefs[key].selected = true | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     selectEntity(entity) { | ||||
|       if (this.entityName === 'books') { | ||||
|         this.$store.commit('toggleAudiobookSelected', entity.id) | ||||
| 
 | ||||
|         var newIsSelectionMode = !!this.selectedAudiobooks.length | ||||
|         if (this.isSelectionMode !== newIsSelectionMode) { | ||||
|           this.isSelectionMode = newIsSelectionMode | ||||
|           this.updateBookSelectionMode(newIsSelectionMode) | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     updateBookSelectionMode(isSelectionMode) { | ||||
|       for (const key in this.entityComponentRefs) { | ||||
|         if (this.entityIndexesMounted.includes(Number(key))) { | ||||
|           this.entityComponentRefs[key].setSelectionMode(isSelectionMode) | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     async fetchEntites(page = 0) { | ||||
|       var startIndex = page * this.booksPerFetch | ||||
| 
 | ||||
|       this.isFetchingBooks = true | ||||
|       var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/books/all?sort=${this.sortBy}&desc=${this.sortDesc}&filter=${this.filterBy}&limit=${this.booksPerFetch}&page=${page}`).catch((error) => { | ||||
|       this.isFetchingEntities = true | ||||
| 
 | ||||
|       if (!this.initialized) { | ||||
|         this.currentSFQueryString = this.buildSearchParams() | ||||
|       } | ||||
| 
 | ||||
|       var entityPath = this.entityName === 'books' ? `books/all` : this.entityName | ||||
|       var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : '' | ||||
|       var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}?${sfQueryString}limit=${this.booksPerFetch}&page=${page}`).catch((error) => { | ||||
|         console.error('failed to fetch books', error) | ||||
|         return null | ||||
|       }) | ||||
|       this.isFetchingEntities = false | ||||
|       if (this.pendingReset) { | ||||
|         this.pendingReset = false | ||||
|         this.resetEntities() | ||||
|         return | ||||
|       } | ||||
|       if (payload) { | ||||
|         console.log('Received payload with start index', startIndex, 'pages loaded', this.pagesLoaded) | ||||
|         console.log('Received payload', payload) | ||||
|         if (!this.initialized) { | ||||
|           this.initialized = true | ||||
|           this.totalBooks = payload.total | ||||
|           this.totalShelves = Math.ceil(this.totalBooks / this.booksPerShelf) | ||||
|           this.books = new Array(this.totalBooks) | ||||
|           this.totalEntities = payload.total | ||||
|           this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf) | ||||
|           this.entities = new Array(this.totalEntities) | ||||
|           this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities) | ||||
|         } | ||||
| 
 | ||||
|         for (let i = 0; i < payload.results.length; i++) { | ||||
|           var bookIndex = i + startIndex | ||||
|           this.books[bookIndex] = payload.results[i] | ||||
|           var index = i + startIndex | ||||
|           this.entities[index] = payload.results[i] | ||||
| 
 | ||||
|           if (this.bookComponentRefs[bookIndex]) { | ||||
|             this.bookComponentRefs[bookIndex].setBook(this.books[bookIndex]) | ||||
|           if (this.entityComponentRefs[index]) { | ||||
|             this.entityComponentRefs[index].setEntity(this.entities[index]) | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     loadPage(page) { | ||||
|       this.pagesLoaded[page] = true | ||||
|       this.fetchBooks(page) | ||||
|     }, | ||||
|     async mountBookCard(index) { | ||||
|       var shelf = Math.floor(index / this.booksPerShelf) | ||||
|       var shelfEl = document.getElementById(`shelf-${shelf}`) | ||||
|       if (!shelfEl) { | ||||
|         console.error('invalid shelf', shelf) | ||||
|         return | ||||
|       } | ||||
|       this.bookIndexesMounted.push(index) | ||||
|       if (this.bookComponentRefs[index] && !this.bookIndexesMounted.includes(index)) { | ||||
|         shelfEl.appendChild(this.bookComponentRefs[index].$el) | ||||
|         return | ||||
|       } | ||||
| 
 | ||||
|       var shelfOffsetY = 16 | ||||
|       var row = index % this.booksPerShelf | ||||
|       var shelfOffsetX = row * this.totalBookCardWidth + this.bookshelfMarginLeft | ||||
| 
 | ||||
|       var ComponentClass = Vue.extend(LazyBookCard) | ||||
| 
 | ||||
|       var _this = this | ||||
|       var instance = new ComponentClass({ | ||||
|         propsData: { | ||||
|           index: index, | ||||
|           bookWidth: this.bookWidth | ||||
|         }, | ||||
|         created() { | ||||
|           // this.$on('action', (func) => { | ||||
|           //   if (_this[func]) _this[func]() | ||||
|           // }) | ||||
|         } | ||||
|       }) | ||||
|       this.bookComponentRefs[index] = instance | ||||
| 
 | ||||
|       instance.$mount() | ||||
|       instance.$el.style.transform = `translate3d(${shelfOffsetX}px, ${shelfOffsetY}px, 0px)` | ||||
|       shelfEl.appendChild(instance.$el) | ||||
| 
 | ||||
|       if (this.books[index]) { | ||||
|         instance.setBook(this.books[index]) | ||||
|       } | ||||
|       this.fetchEntites(page) | ||||
|     }, | ||||
|     showHideBookPlaceholder(index, show) { | ||||
|       var el = document.getElementById(`book-${index}-placeholder`) | ||||
|       if (el) el.style.display = show ? 'flex' : 'none' | ||||
|     }, | ||||
|     unmountBookCard(index) { | ||||
|       if (this.bookComponentRefs[index]) { | ||||
|         this.bookComponentRefs[index].detach() | ||||
|       } | ||||
|     }, | ||||
|     mountBooks(fromIndex, toIndex) { | ||||
|     mountEntites(fromIndex, toIndex) { | ||||
|       for (let i = fromIndex; i < toIndex; i++) { | ||||
|         this.mountBookCard(i) | ||||
|         if (!this.entityIndexesMounted.includes(i)) { | ||||
|           this.cardsHelpers.mountEntityCard(i) | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     handleScroll(scrollTop) { | ||||
|       var firstShelfIndex = Math.floor(scrollTop / this.shelfHeight) | ||||
|       var lastShelfIndex = Math.ceil((scrollTop + this.bookshelfHeight) / this.shelfHeight) | ||||
|       lastShelfIndex = Math.min(this.totalShelves - 1, lastShelfIndex) | ||||
| 
 | ||||
|       var topShelfPage = Math.floor(firstShelfIndex / this.shelvesPerPage) | ||||
|       var bottomShelfPage = Math.floor(lastShelfIndex / this.shelvesPerPage) | ||||
|       if (!this.pagesLoaded[topShelfPage]) { | ||||
|         this.loadPage(topShelfPage) | ||||
|       var firstBookIndex = firstShelfIndex * this.entitiesPerShelf | ||||
|       var lastBookIndex = lastShelfIndex * this.entitiesPerShelf + this.entitiesPerShelf | ||||
|       lastBookIndex = Math.min(this.totalEntities, lastBookIndex) | ||||
| 
 | ||||
|       var firstBookPage = Math.floor(firstBookIndex / this.booksPerFetch) | ||||
|       var lastBookPage = Math.floor(lastBookIndex / this.booksPerFetch) | ||||
|       if (!this.pagesLoaded[firstBookPage]) { | ||||
|         console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex) | ||||
|         this.loadPage(firstBookPage) | ||||
|       } | ||||
|       if (!this.pagesLoaded[bottomShelfPage]) { | ||||
|         this.loadPage(bottomShelfPage) | ||||
|       if (!this.pagesLoaded[lastBookPage]) { | ||||
|         console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex) | ||||
|         this.loadPage(lastBookPage) | ||||
|       } | ||||
|       console.log('Shelves in view', firstShelfIndex, 'to', lastShelfIndex) | ||||
| 
 | ||||
|       var firstBookIndex = firstShelfIndex * this.booksPerShelf | ||||
|       var lastBookIndex = lastShelfIndex * this.booksPerShelf + this.booksPerShelf | ||||
| 
 | ||||
|       this.bookIndexesMounted = this.bookIndexesMounted.filter((_index) => { | ||||
|       this.entityIndexesMounted = this.entityIndexesMounted.filter((_index) => { | ||||
|         if (_index < firstBookIndex || _index >= lastBookIndex) { | ||||
|           var el = document.getElementById(`book-card-${_index}`) | ||||
|           if (el) el.remove() | ||||
| @ -180,7 +229,68 @@ export default { | ||||
|         } | ||||
|         return true | ||||
|       }) | ||||
|       this.mountBooks(firstBookIndex, lastBookIndex) | ||||
|       this.mountEntites(firstBookIndex, lastBookIndex) | ||||
|     }, | ||||
|     async resetEntities() { | ||||
|       if (this.isFetchingEntities) { | ||||
|         console.warn('RESET BOOKS BUT ALREADY FETCHING, WAIT FOR FETCH') | ||||
|         this.pendingReset = true | ||||
|         return | ||||
|       } | ||||
| 
 | ||||
|       this.destroyEntityComponents() | ||||
|       this.entityIndexesMounted = [] | ||||
|       this.entityComponentRefs = {} | ||||
|       this.pagesLoaded = {} | ||||
|       this.entities = [] | ||||
|       this.totalShelves = 0 | ||||
|       this.totalEntities = 0 | ||||
|       this.currentPage = 0 | ||||
|       this.isSelectionMode = false | ||||
|       this.isSelectAll = false | ||||
|       this.initialized = false | ||||
| 
 | ||||
|       this.pagesLoaded[0] = true | ||||
|       await this.fetchEntites(0) | ||||
|       var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf) | ||||
|       this.mountEntites(0, lastBookIndex) | ||||
|     }, | ||||
|     buildSearchParams() { | ||||
|       if (this.page === 'search' || this.page === 'series' || this.page === 'collections') { | ||||
|         return '' | ||||
|       } | ||||
| 
 | ||||
|       let searchParams = new URLSearchParams() | ||||
|       if (this.filterBy && this.filterBy !== 'all') { | ||||
|         searchParams.set('filter', this.filterBy) | ||||
|       } | ||||
|       if (this.orderBy) { | ||||
|         searchParams.set('sort', this.orderBy) | ||||
|         searchParams.set('desc', this.orderDesc ? 1 : 0) | ||||
|       } | ||||
|       return searchParams.toString() | ||||
|     }, | ||||
|     checkUpdateSearchParams() { | ||||
|       var newSearchParams = this.buildSearchParams() | ||||
|       var currentQueryString = window.location.search | ||||
| 
 | ||||
|       if (newSearchParams === '') { | ||||
|         return false | ||||
|       } | ||||
| 
 | ||||
|       if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) { | ||||
|         let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams | ||||
|         window.history.replaceState({ path: newurl }, '', newurl) | ||||
|         return true | ||||
|       } | ||||
| 
 | ||||
|       return false | ||||
|     }, | ||||
|     settingsUpdated(settings) { | ||||
|       var wasUpdated = this.checkUpdateSearchParams() | ||||
|       if (wasUpdated) { | ||||
|         this.resetEntities() | ||||
|       } | ||||
|     }, | ||||
|     scroll(e) { | ||||
|       if (!e || !e.target) return | ||||
| @ -191,32 +301,57 @@ export default { | ||||
|       // }, 250) | ||||
|     }, | ||||
|     async init(bookshelf) { | ||||
|       this.checkUpdateSearchParams() | ||||
| 
 | ||||
|       var { clientHeight, clientWidth } = bookshelf | ||||
|       this.bookshelfHeight = clientHeight | ||||
|       this.bookshelfWidth = clientWidth | ||||
|       this.booksPerShelf = Math.floor((this.bookshelfWidth - 64) / this.totalBookCardWidth) | ||||
|       this.entitiesPerShelf = Math.floor((this.bookshelfWidth - 64) / this.totalEntityCardWidth) | ||||
|       this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2 | ||||
|       this.bookshelfMarginLeft = (this.bookshelfWidth - this.booksPerShelf * this.totalBookCardWidth) / 2 | ||||
|       console.log('Shelves per page', this.shelvesPerPage, 'books per page =', this.booksPerShelf * this.shelvesPerPage) | ||||
|       this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2 | ||||
| 
 | ||||
|       this.pagesLoaded[0] = true | ||||
|       await this.fetchBooks(0) | ||||
|       var lastBookIndex = this.shelvesPerPage * this.booksPerShelf | ||||
|       this.mountBooks(0, lastBookIndex) | ||||
|       await this.fetchEntites(0) | ||||
|       var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf) | ||||
|       this.mountEntites(0, lastBookIndex) | ||||
|     }, | ||||
|     initListeners() { | ||||
|       var bookshelf = document.getElementById('bookshelf') | ||||
|       if (bookshelf) { | ||||
|         this.init(bookshelf) | ||||
|         bookshelf.addEventListener('scroll', this.scroll) | ||||
|       } | ||||
|       this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedBooks) | ||||
|       this.$eventBus.$on('bookshelf-select-all', this.selectAllBooks) | ||||
|       this.$eventBus.$on('bookshelf-keyword-filter', this.updateKeywordFilter) | ||||
| 
 | ||||
|       this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated }) | ||||
|     }, | ||||
|     removeListeners() { | ||||
|       var bookshelf = document.getElementById('bookshelf') | ||||
|       if (bookshelf) { | ||||
|         bookshelf.removeEventListener('scroll', this.scroll) | ||||
|       } | ||||
|       this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedBooks) | ||||
|       this.$eventBus.$off('bookshelf-select-all', this.selectAllBooks) | ||||
|       this.$eventBus.$off('bookshelf-keyword-filter', this.updateKeywordFilter) | ||||
| 
 | ||||
|       this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf') | ||||
|     }, | ||||
|     destroyEntityComponents() { | ||||
|       for (const key in this.entityComponentRefs) { | ||||
|         if (this.entityComponentRefs[key] && this.entityComponentRefs[key].destroy) { | ||||
|           this.entityComponentRefs[key].destroy() | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   mounted() { | ||||
|     var bookshelf = document.getElementById('bookshelf') | ||||
|     if (bookshelf) { | ||||
|       this.init(bookshelf) | ||||
|       bookshelf.addEventListener('scroll', this.scroll) | ||||
|     } | ||||
|     this.initListeners() | ||||
|   }, | ||||
|   beforeDestroy() { | ||||
|     var bookshelf = document.getElementById('bookshelf') | ||||
|     if (bookshelf) { | ||||
|       bookshelf.removeEventListener('scroll', this.scroll) | ||||
|     } | ||||
|     this.destroyEntityComponents() | ||||
|     this.removeListeners() | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| @ -22,7 +22,7 @@ | ||||
|             <covers-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" /> | ||||
| 
 | ||||
|             <!-- Hidden SM and DOWN --> | ||||
|             <div v-show="isHovering || isSelectionMode || isMoreMenuOpen" class="absolute top-0 left-0 w-full h-full bg-black rounded hidden md:block" :class="overlayWrapperClasslist"> | ||||
|             <div v-show="isHovering || isSelectionMode || isMoreMenuOpen" class="absolute top-0 left-0 w-full h-full bg-black rounded hidden md:block z-20" :class="overlayWrapperClasslist"> | ||||
|               <div v-show="showPlayButton" class="h-full flex items-center justify-center"> | ||||
|                 <div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play"> | ||||
|                   <span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span> | ||||
| @ -63,7 +63,7 @@ | ||||
|               <span class="material-icons text-white" :style="{ fontSize: sizeMultiplier * 1 + 'rem' }">auto_stories</span> | ||||
|             </div> | ||||
| 
 | ||||
|             <div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div> | ||||
|             <div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div> | ||||
| 
 | ||||
|             <ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0"> | ||||
|               <div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300"> | ||||
|  | ||||
| @ -1,15 +1,43 @@ | ||||
| <template> | ||||
|   <div ref="card" :id="`book-card-${index}`" :style="{ width: bookWidth + 'px', height: bookHeight + 'px' }" class="absolute top-0 left-0 rounded-sm z-20"> | ||||
|     <div class="w-full h-full bg-primary relative rounded-sm"> | ||||
|       <div class="absolute top-0 left-0 w-full flex items-center justify-center"> | ||||
|         <p>{{ title }}/{{ index }}</p> | ||||
|   <div ref="card" :id="`book-card-${index}`" :style="{ width: width + 'px', height: bookHeight + 'px' }" class="absolute top-0 left-0 rounded-sm z-10 cursor-pointer box-shadow-book" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> | ||||
|     <div class="w-full h-full bg-primary relative rounded overflow-hidden"> | ||||
|       <div v-show="audiobook && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }"> | ||||
|         <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p> | ||||
|       </div> | ||||
|       <img v-show="audiobook" :src="bookCoverSrc" class="w-full h-full object-contain transition-opacity duration-300" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" /> | ||||
|       <!-- <covers-book-cover v-show="audiobook" :audiobook="audiobook" :width="width" /> --> | ||||
|     </div> | ||||
| 
 | ||||
|     <div class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div> | ||||
| 
 | ||||
|     <div v-show="audiobook && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded" :class="overlayWrapperClasslist"> | ||||
|       <div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none"> | ||||
|         <div class="hover:text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play"> | ||||
|           <span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span> | ||||
|         </div> | ||||
|       </div> | ||||
| 
 | ||||
|       <div v-show="showReadButton" class="h-full flex items-center justify-center pointer-events-none"> | ||||
|         <div class="hover:text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="clickReadEBook"> | ||||
|           <span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">auto_stories</span> | ||||
|         </div> | ||||
|       </div> | ||||
| 
 | ||||
|       <div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick"> | ||||
|         <span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span> | ||||
|       </div> | ||||
| 
 | ||||
|       <div class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick"> | ||||
|         <span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span> | ||||
|       </div> | ||||
| 
 | ||||
|       <div ref="moreIcon" v-show="!isSelectionMode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore"> | ||||
|         <span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span> | ||||
|       </div> | ||||
|       <img v-show="audiobook" :src="bookCoverSrc" class="w-full h-full object-contain" /> | ||||
|       <!-- <covers-book-cover v-show="audiobook" :audiobook="audiobook" :width="bookWidth" /> --> | ||||
|     </div> | ||||
| 
 | ||||
|     <!-- <div ref="overlay-wrapper" class="w-full h-full relative box-shadow-book cursor-pointer" @click="clickCard" @mouseover="mouseover" @mouseleave="isHovering = false"> | ||||
|       <covers-book-cover :audiobook="audiobook" :width="bookWidth" /> | ||||
|       <covers-book-cover :audiobook="audiobook" :width="width" /> | ||||
|       <div v-if="false" ref="overlay"> | ||||
|         <div v-show="isHovering || isSelectionMode || isMoreMenuOpen" class="absolute top-0 left-0 w-full h-full bg-black rounded hidden md:block z-20" :class="overlayWrapperClasslist"> | ||||
|           <div v-show="showPlayButton" class="h-full flex items-center justify-center"> | ||||
| @ -47,7 +75,7 @@ | ||||
|           <span class="material-icons text-white" :style="{ fontSize: sizeMultiplier * 1 + 'rem' }">auto_stories</span> | ||||
|         </div> | ||||
| 
 | ||||
|         <div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: bookWidth * userProgressPercent + 'px' }"></div> | ||||
|         <div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div> | ||||
| 
 | ||||
|         <ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0"> | ||||
|           <div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300"> | ||||
| @ -60,10 +88,13 @@ | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| import Vue from 'vue' | ||||
| import MoreMenu from '@/components/widgets/MoreMenu' | ||||
| 
 | ||||
| export default { | ||||
|   props: { | ||||
|     index: Number, | ||||
|     bookWidth: { | ||||
|     width: { | ||||
|       type: Number, | ||||
|       default: 120 | ||||
|     } | ||||
| @ -75,7 +106,11 @@ export default { | ||||
|       isMoreMenuOpen: false, | ||||
|       isProcessingReadUpdate: false, | ||||
|       overlayEl: null, | ||||
|       audiobook: null | ||||
|       audiobook: null, | ||||
|       imageReady: false, | ||||
|       rescanning: false, | ||||
|       selected: false, | ||||
|       isSelectionMode: false | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
| @ -94,15 +129,15 @@ export default { | ||||
|     hasTracks() { | ||||
|       return this._audiobook.numTracks | ||||
|     }, | ||||
|     isSelectionMode() { | ||||
|       return !!this.selectedAudiobooks.length | ||||
|     }, | ||||
|     selectedAudiobooks() { | ||||
|       return this.store.state.selectedAudiobooks | ||||
|     }, | ||||
|     selected() { | ||||
|       return this.store.getters['getIsAudiobookSelected'](this.audiobookId) | ||||
|     }, | ||||
|     // isSelectionMode() { | ||||
|     //   return !!this.selectedAudiobooks.length | ||||
|     // }, | ||||
|     // selectedAudiobooks() { | ||||
|     //   return this.store.state.selectedAudiobooks | ||||
|     // }, | ||||
|     // selected() { | ||||
|     //   return this.store.getters['getIsAudiobookSelected'](this.audiobookId) | ||||
|     // }, | ||||
|     processingBatch() { | ||||
|       return this.store.state.processingBatch | ||||
|     }, | ||||
| @ -110,13 +145,13 @@ export default { | ||||
|       return this._audiobook.book || {} | ||||
|     }, | ||||
|     bookHeight() { | ||||
|       return this.bookWidth * 1.6 | ||||
|       return this.width * 1.6 | ||||
|     }, | ||||
|     sizeMultiplier() { | ||||
|       return this.bookWidth / 120 | ||||
|       return this.width / 120 | ||||
|     }, | ||||
|     title() { | ||||
|       return this.book.title | ||||
|       return this.book.title || '' | ||||
|     }, | ||||
|     playIconFontSize() { | ||||
|       return Math.max(2, 3 * this.sizeMultiplier) | ||||
| @ -160,16 +195,16 @@ export default { | ||||
|       return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook | ||||
|     }, | ||||
|     isMissing() { | ||||
|       return this.audiobook.isMissing | ||||
|       return this._audiobook.isMissing | ||||
|     }, | ||||
|     isIncomplete() { | ||||
|       return this.audiobook.isIncomplete | ||||
|       return this._audiobook.isIncomplete | ||||
|     }, | ||||
|     hasMissingParts() { | ||||
|       return this.audiobook.hasMissingParts | ||||
|       return this._audiobook.hasMissingParts | ||||
|     }, | ||||
|     hasInvalidParts() { | ||||
|       return this.audiobook.hasInvalidParts | ||||
|       return this._audiobook.hasInvalidParts | ||||
|     }, | ||||
|     errorText() { | ||||
|       if (this.isMissing) return 'Audiobook directory is missing!' | ||||
| @ -247,7 +282,11 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     setBook(audiobook) { | ||||
|     setSelectionMode(val) { | ||||
|       this.isSelectionMode = val | ||||
|       if (!val) this.selected = false | ||||
|     }, | ||||
|     setEntity(audiobook) { | ||||
|       this.audiobook = audiobook | ||||
|     }, | ||||
|     clickCard(e) { | ||||
| @ -255,14 +294,125 @@ export default { | ||||
|         e.stopPropagation() | ||||
|         e.preventDefault() | ||||
|         this.selectBtnClick() | ||||
|       } else { | ||||
|         var router = this.$router || this.$nuxt.$router | ||||
|         if (router) router.push(`/audiobook/${this.audiobookId}`) | ||||
|       } | ||||
|     }, | ||||
|     clickShowMore() {}, | ||||
|     editClick() { | ||||
|       this.$emit('edit', this.audiobook) | ||||
|     }, | ||||
|     toggleRead() { | ||||
|       // More menu func | ||||
|       var updatePayload = { | ||||
|         isRead: !this.userIsRead | ||||
|       } | ||||
|       this.isProcessingReadUpdate = true | ||||
|       var toast = this.$toast || this.$nuxt.$toast | ||||
|       var axios = this.$axios || this.$nuxt.$axios | ||||
|       axios | ||||
|         .$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload) | ||||
|         .then(() => { | ||||
|           this.isProcessingReadUpdate = false | ||||
|           toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`) | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error('Failed', error) | ||||
|           this.isProcessingReadUpdate = false | ||||
|           toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`) | ||||
|         }) | ||||
|     }, | ||||
|     audiobookScanComplete(result) { | ||||
|       this.rescanning = false | ||||
|       var toast = this.$toast || this.$nuxt.$toast | ||||
|       if (!result) { | ||||
|         toast.error(`Re-Scan Failed for "${this.title}"`) | ||||
|       } else if (result === 'UPDATED') { | ||||
|         toast.success(`Re-Scan complete audiobook was updated`) | ||||
|       } else if (result === 'UPTODATE') { | ||||
|         toast.success(`Re-Scan complete audiobook was up to date`) | ||||
|       } else if (result === 'REMOVED') { | ||||
|         toast.error(`Re-Scan complete audiobook was removed`) | ||||
|       } | ||||
|     }, | ||||
|     rescan() { | ||||
|       var socket = this.$root.socket || this.$nuxt.$root.socket | ||||
|       this.rescanning = true | ||||
|       socket.once('audiobook_scan_complete', this.audiobookScanComplete) | ||||
|       socket.emit('scan_audiobook', this.audiobookId) | ||||
|     }, | ||||
|     showEditModalTracks() { | ||||
|       // More menu func | ||||
|       this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'tracks' }) | ||||
|     }, | ||||
|     showEditModalMatch() { | ||||
|       // More menu func | ||||
|       this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'match' }) | ||||
|     }, | ||||
|     showEditModalDownload() { | ||||
|       // More menu func | ||||
|       this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'download' }) | ||||
|     }, | ||||
|     openCollections() { | ||||
|       this.store.commit('setSelectedAudiobook', this.audiobook) | ||||
|       this.store.commit('globals/setShowUserCollectionsModal', true) | ||||
|     }, | ||||
|     createMoreMenu() { | ||||
|       if (!this.$refs.moreIcon) return | ||||
| 
 | ||||
|       var ComponentClass = Vue.extend(MoreMenu) | ||||
| 
 | ||||
|       var _this = this | ||||
|       var instance = new ComponentClass({ | ||||
|         propsData: { | ||||
|           items: this.moreMenuItems | ||||
|         }, | ||||
|         created() { | ||||
|           this.$on('action', (func) => { | ||||
|             if (_this[func]) _this[func]() | ||||
|           }) | ||||
|           this.$on('close', () => { | ||||
|             _this.isMoreMenuOpen = false | ||||
|           }) | ||||
|         } | ||||
|       }) | ||||
|       instance.$mount() | ||||
| 
 | ||||
|       var wrapperBox = this.$refs.moreIcon.getBoundingClientRect() | ||||
|       var el = instance.$el | ||||
| 
 | ||||
|       var elHeight = this.moreMenuItems.length * 28 + 2 | ||||
|       var elWidth = 130 | ||||
| 
 | ||||
|       var bottomOfIcon = wrapperBox.top + wrapperBox.height | ||||
|       var rightOfIcon = wrapperBox.left + wrapperBox.width | ||||
| 
 | ||||
|       var elTop = bottomOfIcon | ||||
|       var elLeft = rightOfIcon | ||||
|       if (bottomOfIcon + elHeight > window.innerHeight - 100) { | ||||
|         elTop = wrapperBox.top - elHeight | ||||
|         elLeft = wrapperBox.left | ||||
|       } | ||||
| 
 | ||||
|       if (rightOfIcon + elWidth > window.innerWidth - 100) { | ||||
|         elLeft = rightOfIcon - elWidth | ||||
|       } | ||||
| 
 | ||||
|       el.style.top = elTop + 'px' | ||||
|       el.style.left = elLeft + 'px' | ||||
| 
 | ||||
|       this.isMoreMenuOpen = true | ||||
|       document.body.appendChild(el) | ||||
|     }, | ||||
|     clickShowMore() { | ||||
|       this.createMoreMenu() | ||||
|     }, | ||||
|     clickReadEBook() {}, | ||||
|     editBtnClick() {}, | ||||
|     selectBtnClick() { | ||||
|       if (this.processingBatch) return | ||||
|       this.store.commit('toggleAudiobookSelected', this.audiobookId) | ||||
|       this.selected = !this.selected | ||||
|       this.$emit('select', this.audiobook) | ||||
|     }, | ||||
|     play() {}, | ||||
|     detach() { | ||||
| @ -286,15 +436,22 @@ export default { | ||||
|     mouseover() { | ||||
|       this.isHovering = true | ||||
|     }, | ||||
|     // mouseleave() { | ||||
|     //   this.isHovering = false | ||||
|     // }, | ||||
|     mouseleave() { | ||||
|       this.isHovering = false | ||||
|     }, | ||||
|     destroy() { | ||||
|       // destroy the vue listeners, etc | ||||
|       this.$destroy() | ||||
| 
 | ||||
|       // remove the element from the DOM | ||||
|       this.$el.parentNode.removeChild(this.$el) | ||||
|       if (this.$el && this.$el.parentNode) { | ||||
|         this.$el.parentNode.removeChild(this.$el) | ||||
|       } else if (this.$el && this.$el.remove) { | ||||
|         this.$el.remove() | ||||
|       } | ||||
|     }, | ||||
|     imageLoaded() { | ||||
|       this.imageReady = true | ||||
|     } | ||||
|   }, | ||||
|   mounted() {} | ||||
|  | ||||
							
								
								
									
										87
									
								
								client/components/cards/LazyCollectionCard.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								client/components/cards/LazyCollectionCard.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,87 @@ | ||||
| <template> | ||||
|   <div ref="card" :id="`collection-card-${index}`" :style="{ width: width + 'px', height: cardHeight + 'px' }" class="absolute top-0 left-0 rounded-sm z-10 cursor-pointer box-shadow-book" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> | ||||
|     <div class="w-full h-full bg-primary relative rounded overflow-hidden"> | ||||
|       <covers-collection-cover ref="cover" :book-items="books" :width="width" :height="width" /> | ||||
|     </div> | ||||
| 
 | ||||
|     <!-- <div v-if="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40"> | ||||
|     </div> --> | ||||
|     <div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }"> | ||||
|       <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${1 * sizeMultiplier}rem` }"> | ||||
|         <p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     index: Number, | ||||
|     width: Number | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       collection: null, | ||||
|       isSelectionMode: false, | ||||
|       selected: false, | ||||
|       isHovering: false | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     labelFontSize() { | ||||
|       if (this.width < 160) return 0.75 | ||||
|       return 0.875 | ||||
|     }, | ||||
|     cardHeight() { | ||||
|       return (this.width / 2) * 1.6 | ||||
|     }, | ||||
|     sizeMultiplier() { | ||||
|       return this.width / 120 | ||||
|     }, | ||||
|     title() { | ||||
|       return this.collection ? this.collection.name : '' | ||||
|     }, | ||||
|     books() { | ||||
|       return this.collection ? this.collection.books || [] : [] | ||||
|     }, | ||||
|     store() { | ||||
|       return this.$store || this.$nuxt.$store | ||||
|     }, | ||||
|     currentLibraryId() { | ||||
|       return this.store.state.libraries.currentLibraryId | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     setEntity(_collection) { | ||||
|       this.collection = _collection | ||||
|     }, | ||||
|     setSelectionMode(val) { | ||||
|       this.isSelectionMode = val | ||||
|     }, | ||||
|     mouseover() { | ||||
|       this.isHovering = true | ||||
|     }, | ||||
|     mouseleave() { | ||||
|       this.isHovering = false | ||||
|     }, | ||||
|     clickCard() { | ||||
|       if (!this.collection) return | ||||
|       var router = this.$router || this.$nuxt.$router | ||||
|       router.push(`/collection/${this.collection.id}`) | ||||
|     }, | ||||
|     destroy() { | ||||
|       // destroy the vue listeners, etc | ||||
|       this.$destroy() | ||||
| 
 | ||||
|       // remove the element from the DOM | ||||
|       if (this.$el && this.$el.parentNode) { | ||||
|         this.$el.parentNode.removeChild(this.$el) | ||||
|       } else if (this.$el && this.$el.remove) { | ||||
|         this.$el.remove() | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   mounted() {} | ||||
| } | ||||
| </script> | ||||
							
								
								
									
										102
									
								
								client/components/cards/LazySeriesCard.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								client/components/cards/LazySeriesCard.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,102 @@ | ||||
| <template> | ||||
|   <div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: cardHeight + 'px' }" class="absolute top-0 left-0 rounded-sm z-10 cursor-pointer box-shadow-book" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> | ||||
|     <div class="w-full h-full bg-primary relative rounded overflow-hidden"> | ||||
|       <covers-group-cover ref="cover" :name="title" :book-items="books" :width="width" :height="width" /> | ||||
|       <!-- <div v-show="series && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }"> | ||||
|         <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p> | ||||
|       </div> | ||||
|       <img v-show="series" :src="bookCoverSrc" class="w-full h-full object-contain transition-opacity duration-300" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" /> --> | ||||
|     </div> | ||||
| 
 | ||||
|     <!-- <div v-if="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40"> | ||||
|     </div> --> | ||||
|     <div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }"> | ||||
|       <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${1 * sizeMultiplier}rem` }"> | ||||
|         <p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     index: Number, | ||||
|     width: Number | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       series: null, | ||||
|       isSelectionMode: false, | ||||
|       selected: false, | ||||
|       isHovering: false, | ||||
|       imageReady: false | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     labelFontSize() { | ||||
|       if (this.width < 160) return 0.75 | ||||
|       return 0.875 | ||||
|     }, | ||||
|     cardHeight() { | ||||
|       return this.width | ||||
|     }, | ||||
|     sizeMultiplier() { | ||||
|       return this.width / 120 | ||||
|     }, | ||||
|     title() { | ||||
|       return this.series ? this.series.name : '' | ||||
|     }, | ||||
|     books() { | ||||
|       return this.series ? this.series.books || [] : [] | ||||
|     }, | ||||
|     store() { | ||||
|       return this.$store || this.$nuxt.$store | ||||
|     }, | ||||
|     firstBookInSeries() { | ||||
|       if (!this.series || !this.series.books.length) return null | ||||
|       return this.series.books[0] | ||||
|     }, | ||||
|     bookCoverSrc() { | ||||
|       return this.store.getters['audiobooks/getBookCoverSrc'](this.firstBookInSeries) | ||||
|     }, | ||||
|     currentLibraryId() { | ||||
|       return this.store.state.libraries.currentLibraryId | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     setEntity(_series) { | ||||
|       this.series = _series | ||||
|     }, | ||||
|     setSelectionMode(val) { | ||||
|       this.isSelectionMode = val | ||||
|     }, | ||||
|     mouseover() { | ||||
|       this.isHovering = true | ||||
|     }, | ||||
|     mouseleave() { | ||||
|       this.isHovering = false | ||||
|     }, | ||||
|     clickCard() { | ||||
|       if (!this.series) return | ||||
|       var router = this.$router || this.$nuxt.$router | ||||
|       router.push(`/library/${this.currentLibraryId}/bookshelf/series?series=${this.$encode(this.series.id)}`) | ||||
|     }, | ||||
|     imageLoaded() { | ||||
|       this.imageReady = true | ||||
|     }, | ||||
|     destroy() { | ||||
|       // destroy the vue listeners, etc | ||||
|       this.$destroy() | ||||
| 
 | ||||
|       // remove the element from the DOM | ||||
|       if (this.$el && this.$el.parentNode) { | ||||
|         this.$el.parentNode.removeChild(this.$el) | ||||
|       } else if (this.$el && this.$el.remove) { | ||||
|         this.$el.remove() | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   mounted() {} | ||||
| } | ||||
| </script> | ||||
| @ -141,19 +141,24 @@ export default { | ||||
|       return _sel.text | ||||
|     }, | ||||
|     genres() { | ||||
|       return this.$store.getters['audiobooks/getGenresUsed'] | ||||
|       // return this.$store.getters['audiobooks/getGenresUsed'] | ||||
|       return this.filterData.genres || [] | ||||
|     }, | ||||
|     tags() { | ||||
|       return this.$store.state.audiobooks.tags | ||||
|       // return this.$store.state.audiobooks.tags | ||||
|       return this.filterData.tags || [] | ||||
|     }, | ||||
|     series() { | ||||
|       return this.$store.state.audiobooks.series | ||||
|       // return this.$store.state.audiobooks.series | ||||
|       return this.filterData.series || [] | ||||
|     }, | ||||
|     authors() { | ||||
|       return this.$store.getters['audiobooks/getUniqueAuthors'] | ||||
|       // return this.$store.getters['audiobooks/getUniqueAuthors'] | ||||
|       return this.filterData.authors || [] | ||||
|     }, | ||||
|     narrators() { | ||||
|       return this.$store.getters['audiobooks/getUniqueNarrators'] | ||||
|       // return this.$store.getters['audiobooks/getUniqueNarrators'] | ||||
|       return this.filterData.narrators || [] | ||||
|     }, | ||||
|     progress() { | ||||
|       return ['Read', 'Unread', 'In Progress'] | ||||
| @ -165,6 +170,9 @@ export default { | ||||
|           value: this.$encode(item) | ||||
|         } | ||||
|       }) | ||||
|     }, | ||||
|     filterData() { | ||||
|       return this.$store.state.libraries.filterData || {} | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|  | ||||
| @ -94,7 +94,7 @@ export default { | ||||
|       if (!this.search) return | ||||
|       var search = this.search | ||||
|       this.clearResults() | ||||
|       this.$router.push(`/library/${this.currentLibraryId}/bookshelf/search?query=${search}`) | ||||
|       this.$router.push(`/library/${this.currentLibraryId}/search?q=${search}`) | ||||
|     }, | ||||
|     clearResults() { | ||||
|       this.search = null | ||||
|  | ||||
| @ -4,7 +4,7 @@ | ||||
|       <div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full"> | ||||
|         <div class="w-full h-full z-0" ref="coverBg" /> | ||||
|       </div> | ||||
|       <img v-if="audiobook" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10" :class="showCoverBg ? 'object-contain' : 'object-cover'" /> | ||||
|       <img v-if="audiobook" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-cover'" /> | ||||
|       <div v-show="loading && audiobook" class="absolute top-0 left-0 h-full w-full flex items-center justify-center"> | ||||
|         <p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p> | ||||
|         <div class="absolute top-2 right-2"> | ||||
| @ -57,7 +57,8 @@ export default { | ||||
|     return { | ||||
|       loading: true, | ||||
|       imageFailed: false, | ||||
|       showCoverBg: false | ||||
|       showCoverBg: false, | ||||
|       imageReady: false | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
| @ -135,6 +136,9 @@ export default { | ||||
|     hideCoverBg() {}, | ||||
|     imageLoaded() { | ||||
|       this.loading = false | ||||
|       this.$nextTick(() => { | ||||
|         this.imageReady = true | ||||
|       }) | ||||
|       if (this.$refs.cover && this.cover !== this.placeholderUrl) { | ||||
|         var { naturalWidth, naturalHeight } = this.$refs.cover | ||||
|         var aspectRatio = naturalHeight / naturalWidth | ||||
|  | ||||
| @ -17,7 +17,6 @@ export default { | ||||
|     width: Number, | ||||
|     height: Number, | ||||
|     groupTo: String, | ||||
|     type: String, | ||||
|     isSearch: Boolean | ||||
|   }, | ||||
|   data() { | ||||
| @ -51,10 +50,16 @@ export default { | ||||
|       return this.width / 192 | ||||
|     }, | ||||
|     showExperimentalFeatures() { | ||||
|       return this.$store.state.showExperimentalFeatures | ||||
|       return this.store.state.showExperimentalFeatures | ||||
|     }, | ||||
|     showCoverFan() { | ||||
|       return this.showExperimentalFeatures && this.windowWidth > 1024 && !this.isSearch | ||||
|     }, | ||||
|     store() { | ||||
|       return this.$store || this.$nuxt.$store | ||||
|     }, | ||||
|     router() { | ||||
|       return this.$router || this.$nuxt.$router | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
| @ -167,14 +172,14 @@ export default { | ||||
|         if (coverEl.dataset.audiobookId) { | ||||
|           let audiobookId = coverEl.dataset.audiobookId | ||||
|           coverOverlay.addEventListener('click', (e) => { | ||||
|             this.$router.push(`/audiobook/${audiobookId}`) | ||||
|             this.router.push(`/audiobook/${audiobookId}`) | ||||
|             e.stopPropagation() | ||||
|             e.preventDefault() | ||||
|           }) | ||||
|         } else { | ||||
|           // Is Series | ||||
|           coverOverlay.addEventListener('click', (e) => { | ||||
|             this.$router.push(this.groupTo) | ||||
|             this.router.push(this.groupTo) | ||||
|             e.stopPropagation() | ||||
|             e.preventDefault() | ||||
|           }) | ||||
| @ -193,7 +198,7 @@ export default { | ||||
|       } | ||||
|     }, | ||||
|     getCoverUrl(book) { | ||||
|       return this.$store.getters['audiobooks/getBookCoverSrc'](book, '') | ||||
|       return this.store.getters['audiobooks/getBookCoverSrc'](book, '') | ||||
|     }, | ||||
|     async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) { | ||||
|       var src = coverData.coverUrl | ||||
|  | ||||
| @ -1,8 +1,8 @@ | ||||
| <template> | ||||
|   <modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="75"> | ||||
|     <template #outer> | ||||
|       <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden"> | ||||
|         <p class="font-book text-3xl text-white truncate">{{ title }}</p> | ||||
|       <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none"> | ||||
|         <p class="font-book text-3xl text-white truncate pointer-events-none">{{ title }}</p> | ||||
|       </div> | ||||
|     </template> | ||||
|     <div class="absolute -top-10 left-0 w-full flex"> | ||||
|  | ||||
| @ -74,9 +74,18 @@ export default { | ||||
|       this.showMenu = false | ||||
|     }, | ||||
|     async updateLibrary(library) { | ||||
|       var currLibraryId = this.currentLibraryId | ||||
| 
 | ||||
|       this.disabled = true | ||||
|       await this.$store.dispatch('libraries/fetch', library.id) | ||||
|       this.$router.push(`/library/${library.id}`) | ||||
| 
 | ||||
|       if (this.$route.name.startsWith('library')) { | ||||
|         var newRoute = this.$route.path.replace(currLibraryId, library.id) | ||||
|         this.$router.push(newRoute) | ||||
|       } else { | ||||
|         this.$router.push(`/library/${library.id}`) | ||||
|       } | ||||
| 
 | ||||
|       this.disabled = false | ||||
|     } | ||||
|   }, | ||||
|  | ||||
							
								
								
									
										82
									
								
								client/mixins/bookshelfCardsHelpers.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								client/mixins/bookshelfCardsHelpers.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,82 @@ | ||||
| import Vue from 'vue' | ||||
| import LazyBookCard from '@/components/cards/LazyBookCard' | ||||
| import LazySeriesCard from '@/components/cards/LazySeriesCard' | ||||
| import LazyCollectionCard from '@/components/cards/LazyCollectionCard' | ||||
| 
 | ||||
| export default { | ||||
|   data() { | ||||
|     return { | ||||
|       cardsHelpers: { | ||||
|         mountEntityCard: this.mountEntityCard | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     getComponentClass() { | ||||
|       if (this.entityName === 'series') return Vue.extend(LazySeriesCard) | ||||
|       if (this.entityName === 'collections') return Vue.extend(LazyCollectionCard) | ||||
|       return Vue.extend(LazyBookCard) | ||||
|     }, | ||||
|     async mountEntityCard(index) { | ||||
|       var shelf = Math.floor(index / this.entitiesPerShelf) | ||||
|       var shelfEl = document.getElementById(`shelf-${shelf}`) | ||||
|       if (!shelfEl) { | ||||
|         console.error('invalid shelf', shelf, 'book index', index) | ||||
|         return | ||||
|       } | ||||
|       this.entityIndexesMounted.push(index) | ||||
|       if (this.entityComponentRefs[index]) { | ||||
|         var bookComponent = this.entityComponentRefs[index] | ||||
|         shelfEl.appendChild(bookComponent.$el) | ||||
|         if (this.isSelectionMode) { | ||||
|           bookComponent.setSelectionMode(true) | ||||
|           if (this.selectedAudiobooks.includes(bookComponent.audiobookId) || this.isSelectAll) { | ||||
|             bookComponent.selected = true | ||||
|           } else { | ||||
|             bookComponent.selected = false | ||||
|           } | ||||
|         } else { | ||||
|           bookComponent.setSelectionMode(false) | ||||
|         } | ||||
|         bookComponent.isHovering = false | ||||
|         return | ||||
|       } | ||||
|       var shelfOffsetY = 16 | ||||
|       var row = index % this.entitiesPerShelf | ||||
|       var shelfOffsetX = row * this.totalEntityCardWidth + this.bookshelfMarginLeft | ||||
| 
 | ||||
|       var ComponentClass = this.getComponentClass() | ||||
| 
 | ||||
|       var _this = this | ||||
|       var instance = new ComponentClass({ | ||||
|         propsData: { | ||||
|           index: index, | ||||
|           width: this.entityWidth | ||||
|         }, | ||||
|         created() { | ||||
|           this.$on('edit', (entity) => { | ||||
|             if (_this.editEntity) _this.editEntity(entity) | ||||
|           }) | ||||
|           this.$on('select', (entity) => { | ||||
|             if (_this.selectEntity) _this.selectEntity(entity) | ||||
|           }) | ||||
|         } | ||||
|       }) | ||||
|       this.entityComponentRefs[index] = instance | ||||
| 
 | ||||
|       instance.$mount() | ||||
|       instance.$el.style.transform = `translate3d(${shelfOffsetX}px, ${shelfOffsetY}px, 0px)` | ||||
|       shelfEl.appendChild(instance.$el) | ||||
| 
 | ||||
|       if (this.entities[index]) { | ||||
|         instance.setEntity(this.entities[index]) | ||||
|       } | ||||
|       if (this.isSelectionMode) { | ||||
|         instance.setSelectionMode(true) | ||||
|         if (this.selectedAudiobooks.includes(instance.audiobookId) || this.isSelectAll) { | ||||
|           instance.selected = true | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|   } | ||||
| } | ||||
| @ -4,7 +4,7 @@ | ||||
|       <app-side-rail class="hidden md:block" /> | ||||
|       <div class="flex-grow"> | ||||
|         <app-book-shelf-toolbar :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" :view-mode.sync="viewMode" /> | ||||
|         <app-lazy-bookshelf /> | ||||
|         <app-lazy-bookshelf :page="id || ''" /> | ||||
|         <!-- <app-book-shelf :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" :view-mode="viewMode" /> --> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
							
								
								
									
										62
									
								
								client/pages/library/_library/search.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								client/pages/library/_library/search.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,62 @@ | ||||
| <template> | ||||
|   <div class="page" :class="streamAudiobook ? 'streaming' : ''"> | ||||
|     <div class="flex h-full"> | ||||
|       <app-side-rail class="hidden md:block" /> | ||||
|       <div class="flex-grow"> | ||||
|         <app-book-shelf-toolbar is-home /> | ||||
|         <app-book-shelf-categorized v-if="hasResults" search :results="results" /> | ||||
|         <div v-else class="w-full py-16"> | ||||
|           <p class="text-xl text-center">No Search results for "{{ query }}"</p> | ||||
|           <div class="flex justify-center"> | ||||
|             <ui-btn class="w-52 my-4" @click="back">Back</ui-btn> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   async asyncData({ store, params, redirect, query, app }) { | ||||
|     var libraryId = params.library | ||||
|     var query = query.q | ||||
|     var results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${query}`).catch((error) => { | ||||
|       console.error('Failed to search library', error) | ||||
|       return null | ||||
|     }) | ||||
|     results = { | ||||
|       audiobooks: results && results.audiobooks.length ? results.audiobooks : null, | ||||
|       authors: results && results.authors.length ? results.authors : null, | ||||
|       series: results && results.series.length ? results.series : null, | ||||
|       tags: results && results.tags.length ? results.tags : null | ||||
|     } | ||||
|     console.log('SEARCH RESULTS', results) | ||||
|     return { | ||||
|       results, | ||||
|       query | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return {} | ||||
|   }, | ||||
|   computed: { | ||||
|     streamAudiobook() { | ||||
|       return this.$store.state.streamAudiobook | ||||
|     }, | ||||
|     hasResults() { | ||||
|       return Object.values(this.results).find((r) => !!r) | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     async back() { | ||||
|       var popped = await this.$store.dispatch('popRoute') | ||||
|       if (popped) this.$store.commit('setIsRoutingBack', true) | ||||
|       var backTo = popped || '/' | ||||
|       this.$router.push(backTo) | ||||
|     } | ||||
|   }, | ||||
|   mounted() {}, | ||||
|   beforeDestroy() {} | ||||
| } | ||||
| </script> | ||||
| @ -155,6 +155,7 @@ export const getters = { | ||||
|     return [...new Set(_genres)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1) | ||||
|   }, | ||||
|   getBookCoverSrc: (state, getters, rootState, rootGetters) => (bookItem, placeholder = '/book_placeholder.jpg') => { | ||||
|     if (!bookItem) return placeholder | ||||
|     var book = bookItem.book | ||||
|     if (!book || !book.cover || book.cover === placeholder) return placeholder | ||||
|     var cover = book.cover | ||||
|  | ||||
| @ -33,6 +33,5 @@ export const mutations = { | ||||
|   }, | ||||
|   setShowBookshelfTextureModal(state, val) { | ||||
|     state.showBookshelfTextureModal = val | ||||
|     console.log('shopw', val) | ||||
|   } | ||||
| } | ||||
| @ -4,7 +4,8 @@ export const state = () => ({ | ||||
|   listeners: [], | ||||
|   currentLibraryId: 'main', | ||||
|   folders: [], | ||||
|   folderLastUpdate: 0 | ||||
|   folderLastUpdate: 0, | ||||
|   filterData: null | ||||
| }) | ||||
| 
 | ||||
| export const getters = { | ||||
| @ -53,16 +54,19 @@ export const actions = { | ||||
|       return false | ||||
|     } | ||||
| 
 | ||||
|     var library = state.libraries.find(lib => lib.id === libraryId) | ||||
|     if (library) { | ||||
|       commit('setCurrentLibrary', libraryId) | ||||
|       return library | ||||
|     } | ||||
|     // var library = state.libraries.find(lib => lib.id === libraryId)
 | ||||
|     // if (library) {
 | ||||
|     //   commit('setCurrentLibrary', libraryId)
 | ||||
|     //   return library
 | ||||
|     // }
 | ||||
| 
 | ||||
|     return this.$axios | ||||
|       .$get(`/api/libraries/${libraryId}`) | ||||
|       .$get(`/api/libraries/${libraryId}?include=filterdata`) | ||||
|       .then((data) => { | ||||
|         commit('addUpdate', data) | ||||
|         var library = data.library | ||||
|         var filterData = data.filterdata | ||||
|         commit('addUpdate', library) | ||||
|         commit('setLibraryFilterData', filterData) | ||||
|         commit('setCurrentLibrary', libraryId) | ||||
|         return data | ||||
|       }) | ||||
| @ -97,7 +101,22 @@ export const actions = { | ||||
|       }) | ||||
|     return true | ||||
|   }, | ||||
|   loadLibraryFilterData({ state, commit, rootState }) { | ||||
|     if (!rootState.user || !rootState.user.user) { | ||||
|       console.error('libraries/loadLibraryFilterData - User not set') | ||||
|       return false | ||||
|     } | ||||
| 
 | ||||
|     this.$axios | ||||
|       .$get(`/api/libraries/${state.currentLibraryId}/filters`) | ||||
|       .then((data) => { | ||||
|         commit('setLibraryFilterData', data) | ||||
|       }) | ||||
|       .catch((error) => { | ||||
|         console.error('Failed', error) | ||||
|         commit('setLibraryFilterData', null) | ||||
|       }) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export const mutations = { | ||||
| @ -145,5 +164,8 @@ export const mutations = { | ||||
|   }, | ||||
|   removeListener(state, listenerId) { | ||||
|     state.listeners = state.listeners.filter(l => l.id !== listenerId) | ||||
|   }, | ||||
|   setLibraryFilterData(state, filterData) { | ||||
|     state.filterData = filterData | ||||
|   } | ||||
| } | ||||
| @ -25,7 +25,7 @@ export const getters = { | ||||
|     return state.user && state.user.audiobooks ? state.user.audiobooks[audiobookId] || null : null | ||||
|   }, | ||||
|   getUserSetting: (state) => (key) => { | ||||
|     return state.settings ? state.settings[key] || null : null | ||||
|     return state.settings ? state.settings[key] : null | ||||
|   }, | ||||
|   getUserCanUpdate: (state) => { | ||||
|     return state.user && state.user.permissions ? !!state.user.permissions.update : false | ||||
|  | ||||
| @ -49,13 +49,17 @@ class ApiController { | ||||
|     //
 | ||||
|     this.router.post('/libraries', LibraryController.create.bind(this)) | ||||
|     this.router.get('/libraries', LibraryController.findAll.bind(this)) | ||||
|     this.router.get('/libraries/:id', LibraryController.findOne.bind(this)) | ||||
|     this.router.patch('/libraries/:id', LibraryController.update.bind(this)) | ||||
|     this.router.delete('/libraries/:id', LibraryController.delete.bind(this)) | ||||
|     this.router.get('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.findOne.bind(this)) | ||||
|     this.router.patch('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.update.bind(this)) | ||||
|     this.router.delete('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.delete.bind(this)) | ||||
| 
 | ||||
|     this.router.get('/libraries/:id/books/all', LibraryController.getBooksForLibrary2.bind(this)) | ||||
|     this.router.get('/libraries/:id/books', LibraryController.getBooksForLibrary.bind(this)) | ||||
|     this.router.get('/libraries/:id/search', LibraryController.search.bind(this)) | ||||
|     this.router.get('/libraries/:id/books/all', LibraryController.middleware.bind(this), LibraryController.getBooksForLibrary2.bind(this)) | ||||
|     this.router.get('/libraries/:id/books', LibraryController.middleware.bind(this), LibraryController.getBooksForLibrary.bind(this)) | ||||
|     this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getSeriesForLibrary.bind(this)) | ||||
|     this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this)) | ||||
|     this.router.get('/libraries/:id/categories', LibraryController.middleware.bind(this), LibraryController.getLibraryCategories.bind(this)) | ||||
|     this.router.get('/libraries/:id/filters', LibraryController.middleware.bind(this), LibraryController.getLibraryFilters.bind(this)) | ||||
|     this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this)) | ||||
|     this.router.patch('/libraries/order', LibraryController.reorder.bind(this)) | ||||
| 
 | ||||
|     // TEMP: Support old syntax for mobile app
 | ||||
| @ -491,43 +495,103 @@ class ApiController { | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
|   decode(text) { | ||||
|     return Buffer.from(decodeURIComponent(text), 'base64').toString() | ||||
|   } | ||||
|   // decode(text) {
 | ||||
|   //   return Buffer.from(decodeURIComponent(text), 'base64').toString()
 | ||||
|   // }
 | ||||
| 
 | ||||
|   getFiltered(audiobooks, filterBy, user) { | ||||
|     var filtered = audiobooks | ||||
|   // getFiltered(audiobooks, filterBy, user) {
 | ||||
|   //   var filtered = audiobooks
 | ||||
| 
 | ||||
|     var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators'] | ||||
|     var group = searchGroups.find(_group => filterBy.startsWith(_group + '.')) | ||||
|     if (group) { | ||||
|       var filterVal = filterBy.replace(`${group}.`, '') | ||||
|       var filter = this.decode(filterVal) | ||||
|       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 === 'series') { | ||||
|         if (filter === 'No Series') filtered = filtered.filter(ab => ab.book && !ab.book.series) | ||||
|         else filtered = filtered.filter(ab => ab.book && ab.book.series === filter) | ||||
|       } | ||||
|       else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.authorFL && ab.book.authorFL.split(', ').includes(filter)) | ||||
|       else if (group === 'narrators') filtered = filtered.filter(ab => ab.book && ab.book.narratorFL && ab.book.narratorFL.split(', ').includes(filter)) | ||||
|       else if (group === 'progress') { | ||||
|         filtered = filtered.filter(ab => { | ||||
|           var userAudiobook = user.getAudiobookJSON(ab.id) | ||||
|           var isRead = userAudiobook && userAudiobook.isRead | ||||
|           if (filter === 'Read' && isRead) return true | ||||
|           if (filter === 'Unread' && !isRead) return true | ||||
|           if (filter === 'In Progress' && (userAudiobook && !userAudiobook.isRead && userAudiobook.progress > 0)) return true | ||||
|           return false | ||||
|         }) | ||||
|       } | ||||
|     } else if (filterBy === 'issues') { | ||||
|       filtered = filtered.filter(ab => { | ||||
|         return ab.hasMissingParts || ab.hasInvalidParts || ab.isMissing || ab.isIncomplete | ||||
|       }) | ||||
|     } | ||||
|   //   var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators']
 | ||||
|   //   var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
 | ||||
|   //   if (group) {
 | ||||
|   //     var filterVal = filterBy.replace(`${group}.`, '')
 | ||||
|   //     var filter = this.decode(filterVal)
 | ||||
|   //     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 === 'series') {
 | ||||
|   //       if (filter === 'No Series') filtered = filtered.filter(ab => ab.book && !ab.book.series)
 | ||||
|   //       else filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
 | ||||
|   //     }
 | ||||
|   //     else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.authorFL && ab.book.authorFL.split(', ').includes(filter))
 | ||||
|   //     else if (group === 'narrators') filtered = filtered.filter(ab => ab.book && ab.book.narratorFL && ab.book.narratorFL.split(', ').includes(filter))
 | ||||
|   //     else if (group === 'progress') {
 | ||||
|   //       filtered = filtered.filter(ab => {
 | ||||
|   //         var userAudiobook = user.getAudiobookJSON(ab.id)
 | ||||
|   //         var isRead = userAudiobook && userAudiobook.isRead
 | ||||
|   //         if (filter === 'Read' && isRead) return true
 | ||||
|   //         if (filter === 'Unread' && !isRead) return true
 | ||||
|   //         if (filter === 'In Progress' && (userAudiobook && !userAudiobook.isRead && userAudiobook.progress > 0)) return true
 | ||||
|   //         return false
 | ||||
|   //       })
 | ||||
|   //     }
 | ||||
|   //   } else if (filterBy === 'issues') {
 | ||||
|   //     filtered = filtered.filter(ab => {
 | ||||
|   //       return ab.hasMissingParts || ab.hasInvalidParts || ab.isMissing || ab.isIncomplete
 | ||||
|   //     })
 | ||||
|   //   }
 | ||||
| 
 | ||||
|     return filtered | ||||
|   } | ||||
|   //   return filtered
 | ||||
|   // }
 | ||||
| 
 | ||||
|   // getDistinctFilterData(audiobooks) {
 | ||||
|   //   var data = {
 | ||||
|   //     authors: [],
 | ||||
|   //     genres: [],
 | ||||
|   //     tags: [],
 | ||||
|   //     series: [],
 | ||||
|   //     narrators: []
 | ||||
|   //   }
 | ||||
|   //   audiobooks.forEach((ab) => {
 | ||||
|   //     if (ab.book._authorsList.length) {
 | ||||
|   //       ab.book._authorsList.forEach((author) => {
 | ||||
|   //         if (author && !data.authors.includes(author)) data.authors.push(author)
 | ||||
|   //       })
 | ||||
|   //     }
 | ||||
|   //     if (ab.book._genres.length) {
 | ||||
|   //       ab.book._genres.forEach((genre) => {
 | ||||
|   //         if (genre && !data.genres.includes(genre)) data.genres.push(genre)
 | ||||
|   //       })
 | ||||
|   //     }
 | ||||
|   //     if (ab.tags.length) {
 | ||||
|   //       ab.tags.forEach((tag) => {
 | ||||
|   //         if (tag && !data.tags.includes(tag)) data.tags.push(tag)
 | ||||
|   //       })
 | ||||
|   //     }
 | ||||
|   //     if (ab.book._series && !data.series.includes(ab.book._series)) data.series.push(ab.book._series)
 | ||||
|   //     if (ab.book._narratorsList.length) {
 | ||||
|   //       ab.book._narratorsList.forEach((narrator) => {
 | ||||
|   //         if (narrator && !data.narrators.includes(narrator)) data.narrators.push(narrator)
 | ||||
|   //       })
 | ||||
|   //     }
 | ||||
|   //   })
 | ||||
|   //   return data
 | ||||
|   // }
 | ||||
| 
 | ||||
|   // getBooksMostRecentlyRead(user, books, limit) {
 | ||||
|   //   var booksWithProgress = books.map(book => {
 | ||||
|   //     return {
 | ||||
|   //       userAudiobook: user.getAudiobookJSON(book.id),
 | ||||
|   //       book
 | ||||
|   //     }
 | ||||
|   //   }).filter((data) => data.userAudiobook && !data.userAudiobook.isRead)
 | ||||
|   //   booksWithProgress.sort((a, b) => {
 | ||||
|   //     return b.userAudiobook.lastUpdate - a.userAudiobook.lastUpdate
 | ||||
|   //   })
 | ||||
|   //   return booksWithProgress.map(b => b.book).slice(0, limit)
 | ||||
|   // }
 | ||||
| 
 | ||||
|   // getBooksMostRecentlyAdded(user, books, limit) {
 | ||||
|   //   var booksWithProgress = books.map(book => {
 | ||||
|   //     return {
 | ||||
|   //       userAudiobook: user.getAudiobookJSON(book.id),
 | ||||
|   //       book
 | ||||
|   //     }
 | ||||
|   //   }).filter((data) => data.userAudiobook && !data.userAudiobook.isRead)
 | ||||
|   //   booksWithProgress.sort((a, b) => {
 | ||||
|   //     return b.userAudiobook.lastUpdate - a.userAudiobook.lastUpdate
 | ||||
|   //   })
 | ||||
|   //   return booksWithProgress.map(b => b.book).slice(0, limit)
 | ||||
|   // }
 | ||||
| } | ||||
| module.exports = ApiController | ||||
| @ -1,6 +1,7 @@ | ||||
| const Logger = require('../Logger') | ||||
| const Library = require('../objects/Library') | ||||
| const { sort } = require('fast-sort') | ||||
| const libraryHelpers = require('../utils/libraryHelpers') | ||||
| 
 | ||||
| class LibraryController { | ||||
|   constructor() { } | ||||
| @ -29,21 +30,19 @@ class LibraryController { | ||||
|     res.json(this.db.libraries.map(lib => lib.toJSON())) | ||||
|   } | ||||
| 
 | ||||
|   findOne(req, res) { | ||||
|     if (!req.params.id) return res.status(500).send('Invalid id parameter') | ||||
| 
 | ||||
|     var library = this.db.libraries.find(lib => lib.id === req.params.id) | ||||
|     if (!library) { | ||||
|       return res.status(404).send('Library not found') | ||||
|   async findOne(req, res) { | ||||
|     if (req.query.include && req.query.include === 'filterdata') { | ||||
|       var books = this.db.audiobooks.filter(ab => ab.libraryId === req.library.id) | ||||
|       return res.json({ | ||||
|         filterdata: libraryHelpers.getDistinctFilterData(books), | ||||
|         library: req.library | ||||
|       }) | ||||
|     } | ||||
|     return res.json(library.toJSON()) | ||||
|     return res.json(req.library) | ||||
|   } | ||||
| 
 | ||||
|   async update(req, res) { | ||||
|     var library = this.db.libraries.find(lib => lib.id === req.params.id) | ||||
|     if (!library) { | ||||
|       return res.status(404).send('Library not found') | ||||
|     } | ||||
|     var library = req.library | ||||
|     var hasUpdates = library.update(req.body) | ||||
|     if (hasUpdates) { | ||||
|       // Update watcher
 | ||||
| @ -64,10 +63,7 @@ class LibraryController { | ||||
|   } | ||||
| 
 | ||||
|   async delete(req, res) { | ||||
|     var library = this.db.libraries.find(lib => lib.id === req.params.id) | ||||
|     if (!library) { | ||||
|       return res.status(404).send('Library not found') | ||||
|     } | ||||
|     var library = req.library | ||||
| 
 | ||||
|     // Remove library watcher
 | ||||
|     this.watcher.removeLibrary(library) | ||||
| @ -87,11 +83,7 @@ class LibraryController { | ||||
| 
 | ||||
|   // api/libraries/:id/books
 | ||||
|   getBooksForLibrary(req, res) { | ||||
|     var libraryId = req.params.id | ||||
|     var library = this.db.libraries.find(lib => lib.id === libraryId) | ||||
|     if (!library) { | ||||
|       return res.status(400).send('Library does not exist') | ||||
|     } | ||||
|     var libraryId = req.library.id | ||||
|     var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId) | ||||
|     // if (req.query.q) {
 | ||||
|     //   audiobooks = this.db.audiobooks.filter(ab => {
 | ||||
| @ -102,7 +94,7 @@ class LibraryController { | ||||
|     // }
 | ||||
| 
 | ||||
|     if (req.query.filter) { | ||||
|       audiobooks = this.getFiltered(this.db.audiobooks, req.query.filter, req.user) | ||||
|       audiobooks = libraryHelpers.getFiltered(audiobooks, req.query.filter, req.user) | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| @ -126,13 +118,9 @@ class LibraryController { | ||||
|     res.json(audiobooks) | ||||
|   } | ||||
| 
 | ||||
|   // api/libraries/:id/books/fs
 | ||||
|   // api/libraries/:id/books/all
 | ||||
|   getBooksForLibrary2(req, res) { | ||||
|     var libraryId = req.params.id | ||||
|     var library = this.db.libraries.find(lib => lib.id === libraryId) | ||||
|     if (!library) { | ||||
|       return res.status(400).send('Library does not exist') | ||||
|     } | ||||
|     var libraryId = req.library.id | ||||
| 
 | ||||
|     var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId) | ||||
|     var payload = { | ||||
| @ -146,7 +134,8 @@ class LibraryController { | ||||
|     } | ||||
| 
 | ||||
|     if (payload.filterBy) { | ||||
|       audiobooks = this.getFiltered(this.db.audiobooks, payload.filterBy, req.user) | ||||
|       audiobooks = libraryHelpers.getFiltered(audiobooks, payload.filterBy, req.user) | ||||
|       payload.total = audiobooks.length | ||||
|     } | ||||
| 
 | ||||
|     if (payload.sortBy) { | ||||
| @ -170,6 +159,110 @@ class LibraryController { | ||||
|     res.json(payload) | ||||
|   } | ||||
| 
 | ||||
|   // api/libraries/:id/series
 | ||||
|   async getSeriesForLibrary(req, res) { | ||||
|     var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === req.library.id) | ||||
| 
 | ||||
|     var payload = { | ||||
|       results: [], | ||||
|       total: 0, | ||||
|       limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0, | ||||
|       page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0, | ||||
|       sortBy: req.query.sort, | ||||
|       sortDesc: req.query.desc === '1', | ||||
|       filterBy: req.query.filter | ||||
|     } | ||||
| 
 | ||||
|     var series = libraryHelpers.getSeriesFromBooks(audiobooks) | ||||
|     payload.total = series.length | ||||
| 
 | ||||
|     if (payload.limit) { | ||||
|       var startIndex = payload.page * payload.limit | ||||
|       series = series.slice(startIndex, startIndex + payload.limit) | ||||
|     } | ||||
| 
 | ||||
|     payload.results = series | ||||
|     console.log('returning series', series.length) | ||||
| 
 | ||||
|     res.json(payload) | ||||
|   } | ||||
| 
 | ||||
|   // api/libraries/:id/series
 | ||||
|   async getCollectionsForLibrary(req, res) { | ||||
|     var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === req.library.id) | ||||
| 
 | ||||
|     var payload = { | ||||
|       results: [], | ||||
|       total: 0, | ||||
|       limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0, | ||||
|       page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0, | ||||
|       sortBy: req.query.sort, | ||||
|       sortDesc: req.query.desc === '1', | ||||
|       filterBy: req.query.filter | ||||
|     } | ||||
| 
 | ||||
|     var collections = this.db.collections.filter(c => c.libraryId === req.library.id).map(c => c.toJSONExpanded(audiobooks)) | ||||
|     payload.total = collections.length | ||||
| 
 | ||||
|     if (payload.limit) { | ||||
|       var startIndex = payload.page * payload.limit | ||||
|       collections = collections.slice(startIndex, startIndex + payload.limit) | ||||
|     } | ||||
| 
 | ||||
|     payload.results = collections | ||||
|     console.log('returning collections', collections.length) | ||||
| 
 | ||||
|     res.json(payload) | ||||
|   } | ||||
| 
 | ||||
|   // api/libraries/:id/books/filters
 | ||||
|   async getLibraryFilters(req, res) { | ||||
|     var library = req.library | ||||
|     var books = this.db.audiobooks.filter(ab => ab.libraryId === library.id) | ||||
|     res.json(libraryHelpers.getDistinctFilterData(books)) | ||||
|   } | ||||
| 
 | ||||
|   // api/libraries/:id/books/categories
 | ||||
|   async getLibraryCategories(req, res) { | ||||
|     var library = req.library | ||||
|     var books = this.db.audiobooks.filter(ab => ab.libraryId === library.id) | ||||
|     var limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12 | ||||
| 
 | ||||
|     var booksWithUserAb = libraryHelpers.getBooksWithUserAudiobook(req.user, books) | ||||
|     var series = libraryHelpers.getSeriesFromBooks(books) | ||||
| 
 | ||||
|     var categories = [ | ||||
|       { | ||||
|         id: 'continue-reading', | ||||
|         label: 'Continue Reading', | ||||
|         type: 'books', | ||||
|         entities: libraryHelpers.getBooksMostRecentlyRead(booksWithUserAb, limitPerShelf) | ||||
|       }, | ||||
|       { | ||||
|         id: 'recently-added', | ||||
|         label: 'Recently Added', | ||||
|         type: 'books', | ||||
|         entities: libraryHelpers.getBooksMostRecentlyAdded(books, limitPerShelf) | ||||
|       }, | ||||
|       { | ||||
|         id: 'read-again', | ||||
|         label: 'Read Again', | ||||
|         type: 'books', | ||||
|         entities: libraryHelpers.getBooksMostRecentlyFinished(booksWithUserAb, limitPerShelf) | ||||
|       }, | ||||
|       { | ||||
|         id: 'recent-series', | ||||
|         label: 'Recent Series', | ||||
|         type: 'series', | ||||
|         entities: libraryHelpers.getSeriesMostRecentlyAdded(series, limitPerShelf) | ||||
|       } | ||||
|     ].filter(cats => { // Remove categories with no items
 | ||||
|       return cats.entities.length | ||||
|     }) | ||||
| 
 | ||||
|     res.json(categories) | ||||
|   } | ||||
| 
 | ||||
|   // PATCH: Change the order of libraries
 | ||||
|   async reorder(req, res) { | ||||
|     if (!req.user.isRoot) { | ||||
| @ -203,10 +296,7 @@ class LibraryController { | ||||
| 
 | ||||
|   // GET: Global library search
 | ||||
|   search(req, res) { | ||||
|     var library = this.db.libraries.find(lib => lib.id === req.params.id) | ||||
|     if (!library) { | ||||
|       return res.status(404).send('Library not found') | ||||
|     } | ||||
|     var library = req.library | ||||
|     if (!req.query.q) { | ||||
|       return res.status(400).send('No query string') | ||||
|     } | ||||
| @ -268,5 +358,14 @@ class LibraryController { | ||||
|       series: Object.values(seriesMatches).slice(0, maxResults) | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   middleware(req, res, next) { | ||||
|     var library = this.db.libraries.find(lib => lib.id === req.params.id) | ||||
|     if (!library) { | ||||
|       return res.status(404).send('Library not found') | ||||
|     } | ||||
|     req.library = library | ||||
|     next() | ||||
|   } | ||||
| } | ||||
| module.exports = new LibraryController() | ||||
| @ -11,6 +11,7 @@ class Book { | ||||
|     this.authorLF = null | ||||
|     this.authors = [] | ||||
|     this.narrator = null | ||||
|     this.narratorFL = null | ||||
|     this.series = null | ||||
|     this.volumeNumber = null | ||||
|     this.publishYear = null | ||||
| @ -40,6 +41,7 @@ class Book { | ||||
|   get _author() { return this.authorFL || '' } | ||||
|   get _series() { return this.series || '' } | ||||
|   get _authorsList() { return this._author.split(', ') } | ||||
|   get _narratorsList() { return this._narrator.split(', ') } | ||||
|   get _genres() { return this.genres || [] } | ||||
| 
 | ||||
|   get shouldSearchForCover() { | ||||
|  | ||||
							
								
								
									
										132
									
								
								server/utils/libraryHelpers.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								server/utils/libraryHelpers.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,132 @@ | ||||
| const { sort } = require('fast-sort') | ||||
| 
 | ||||
| module.exports = { | ||||
|   decode(text) { | ||||
|     return Buffer.from(decodeURIComponent(text), 'base64').toString() | ||||
|   }, | ||||
| 
 | ||||
|   getFiltered(audiobooks, filterBy, user) { | ||||
|     var filtered = audiobooks | ||||
| 
 | ||||
|     var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators'] | ||||
|     var group = searchGroups.find(_group => filterBy.startsWith(_group + '.')) | ||||
|     if (group) { | ||||
|       var filterVal = filterBy.replace(`${group}.`, '') | ||||
|       var filter = this.decode(filterVal) | ||||
|       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 === 'series') { | ||||
|         if (filter === 'No Series') filtered = filtered.filter(ab => ab.book && !ab.book.series) | ||||
|         else filtered = filtered.filter(ab => ab.book && ab.book.series === filter) | ||||
|       } | ||||
|       else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.authorFL && ab.book.authorFL.split(', ').includes(filter)) | ||||
|       else if (group === 'narrators') filtered = filtered.filter(ab => ab.book && ab.book.narratorFL && ab.book.narratorFL.split(', ').includes(filter)) | ||||
|       else if (group === 'progress') { | ||||
|         filtered = filtered.filter(ab => { | ||||
|           var userAudiobook = user.getAudiobookJSON(ab.id) | ||||
|           var isRead = userAudiobook && userAudiobook.isRead | ||||
|           if (filter === 'Read' && isRead) return true | ||||
|           if (filter === 'Unread' && !isRead) return true | ||||
|           if (filter === 'In Progress' && (userAudiobook && !userAudiobook.isRead && userAudiobook.progress > 0)) return true | ||||
|           return false | ||||
|         }) | ||||
|       } | ||||
|     } else if (filterBy === 'issues') { | ||||
|       filtered = filtered.filter(ab => { | ||||
|         return ab.hasMissingParts || ab.hasInvalidParts || ab.isMissing || ab.isIncomplete | ||||
|       }) | ||||
|     } | ||||
| 
 | ||||
|     return filtered | ||||
|   }, | ||||
| 
 | ||||
|   getDistinctFilterData(audiobooks) { | ||||
|     var data = { | ||||
|       authors: [], | ||||
|       genres: [], | ||||
|       tags: [], | ||||
|       series: [], | ||||
|       narrators: [] | ||||
|     } | ||||
|     audiobooks.forEach((ab) => { | ||||
|       if (ab.book._authorsList.length) { | ||||
|         ab.book._authorsList.forEach((author) => { | ||||
|           if (author && !data.authors.includes(author)) data.authors.push(author) | ||||
|         }) | ||||
|       } | ||||
|       if (ab.book._genres.length) { | ||||
|         ab.book._genres.forEach((genre) => { | ||||
|           if (genre && !data.genres.includes(genre)) data.genres.push(genre) | ||||
|         }) | ||||
|       } | ||||
|       if (ab.tags.length) { | ||||
|         ab.tags.forEach((tag) => { | ||||
|           if (tag && !data.tags.includes(tag)) data.tags.push(tag) | ||||
|         }) | ||||
|       } | ||||
|       if (ab.book._series && !data.series.includes(ab.book._series)) data.series.push(ab.book._series) | ||||
|       if (ab.book._narratorsList.length) { | ||||
|         ab.book._narratorsList.forEach((narrator) => { | ||||
|           if (narrator && !data.narrators.includes(narrator)) data.narrators.push(narrator) | ||||
|         }) | ||||
|       } | ||||
|     }) | ||||
|     return data | ||||
|   }, | ||||
| 
 | ||||
|   getSeriesFromBooks(books) { | ||||
|     var _series = {} | ||||
|     books.forEach((audiobook) => { | ||||
|       if (audiobook.book.series) { | ||||
|         if (!_series[audiobook.book.series]) { | ||||
|           _series[audiobook.book.series] = { | ||||
|             id: audiobook.book.series, | ||||
|             name: audiobook.book.series, | ||||
|             books: [audiobook.toJSONExpanded()] | ||||
|           } | ||||
|         } else { | ||||
|           _series[audiobook.book.series].books.push(audiobook.toJSONExpanded()) | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
|     return Object.values(_series) | ||||
|   }, | ||||
| 
 | ||||
|   getBooksWithUserAudiobook(user, books) { | ||||
|     return books.map(book => { | ||||
|       return { | ||||
|         userAudiobook: user.getAudiobookJSON(book.id), | ||||
|         book | ||||
|       } | ||||
|     }) | ||||
|   }, | ||||
| 
 | ||||
|   getBooksMostRecentlyRead(booksWithUserAb, limit) { | ||||
|     var booksWithProgress = booksWithUserAb.filter((data) => data.userAudiobook && !data.userAudiobook.isRead) | ||||
|     booksWithProgress.sort((a, b) => { | ||||
|       return b.userAudiobook.lastUpdate - a.userAudiobook.lastUpdate | ||||
|     }) | ||||
|     return booksWithProgress.map(b => b.book).slice(0, limit) | ||||
|   }, | ||||
| 
 | ||||
|   getBooksMostRecentlyAdded(books, limit) { | ||||
|     var booksSortedByAddedAt = sort(books).desc(book => book.addedAt) | ||||
|     return booksSortedByAddedAt.slice(0, limit) | ||||
|   }, | ||||
| 
 | ||||
|   getBooksMostRecentlyFinished(booksWithUserAb, limit) { | ||||
|     var booksRead = booksWithUserAb.filter((data) => data.userAudiobook && data.userAudiobook.isRead) | ||||
|     booksRead.sort((a, b) => { | ||||
|       return b.userAudiobook.finishedAt - a.userAudiobook.finishedAt | ||||
|     }) | ||||
|     return booksRead.map(b => b.book).slice(0, limit) | ||||
|   }, | ||||
| 
 | ||||
|   getSeriesMostRecentlyAdded(series, limit) { | ||||
|     var seriesSortedByAddedAt = sort(series).desc(_series => { | ||||
|       var booksSortedByMostRecent = sort(_series.books).desc(b => b.addedAt) | ||||
|       return booksSortedByMostRecent[0].addedAt | ||||
|     }) | ||||
|     return seriesSortedByAddedAt.slice(0, limit) | ||||
|   } | ||||
| } | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user