mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Batch updating and deleting, multi-select
This commit is contained in:
		
							parent
							
								
									8c9fb0d45e
								
							
						
					
					
						commit
						88c7c1632e
					
				| @ -75,6 +75,10 @@ | |||||||
|   box-shadow: 2px 8px 6px #111111aa; |   box-shadow: 2px 8px 6px #111111aa; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .box-shadow-lg-up { | ||||||
|  |   box-shadow: 0px -12px 8px #111111ee; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .box-shadow-xl { | .box-shadow-xl { | ||||||
|   box-shadow: 2px 14px 8px #111111aa; |   box-shadow: 2px 14px 8px #111111aa; | ||||||
| } | } | ||||||
| @ -17,6 +17,16 @@ | |||||||
| 
 | 
 | ||||||
|         <ui-menu :label="username" :items="menuItems" @action="menuAction" class="ml-5" /> |         <ui-menu :label="username" :items="menuItems" @action="menuAction" class="ml-5" /> | ||||||
|       </div> |       </div> | ||||||
|  | 
 | ||||||
|  |       <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 small class="text-sm mx-2" @click="toggleSelectAll">{{ isAllSelected ? 'Select None' : 'Select All' }}</ui-btn> | ||||||
|  | 
 | ||||||
|  |         <div class="flex-grow" /> | ||||||
|  |         <ui-btn v-show="!processingBatchDelete" color="warning" small class="mx-2" @click="batchEditClick"><span class="material-icons text-gray-200 pt-1">edit</span></ui-btn> | ||||||
|  |         <ui-btn color="error" small class="mx-2" :loading="processingBatchDelete" @click="batchDeleteClick"><span class="material-icons text-gray-200 pt-1">delete</span></ui-btn> | ||||||
|  |         <span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span> | ||||||
|  |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
| @ -35,7 +45,8 @@ export default { | |||||||
|           value: 'logout', |           value: 'logout', | ||||||
|           text: 'Logout' |           text: 'Logout' | ||||||
|         } |         } | ||||||
|       ] |       ], | ||||||
|  |       processingBatchDelete: false | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   computed: { |   computed: { | ||||||
| @ -47,6 +58,18 @@ export default { | |||||||
|     }, |     }, | ||||||
|     username() { |     username() { | ||||||
|       return this.user ? this.user.username : 'err' |       return this.user ? this.user.username : 'err' | ||||||
|  |     }, | ||||||
|  |     numAudiobooksSelected() { | ||||||
|  |       return this.selectedAudiobooks.length | ||||||
|  |     }, | ||||||
|  |     selectedAudiobooks() { | ||||||
|  |       return this.$store.state.selectedAudiobooks | ||||||
|  |     }, | ||||||
|  |     isAllSelected() { | ||||||
|  |       return this.audiobooksShowing.length === this.selectedAudiobooks.length | ||||||
|  |     }, | ||||||
|  |     audiobooksShowing() { | ||||||
|  |       return this.$store.getters['audiobooks/getFiltered']() | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
| @ -57,10 +80,6 @@ export default { | |||||||
|         this.$router.push('/') |         this.$router.push('/') | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     scan() { |  | ||||||
|       console.log('Call Start Init') |  | ||||||
|       this.$root.socket.emit('scan') |  | ||||||
|     }, |  | ||||||
|     logout() { |     logout() { | ||||||
|       this.$axios.$post('/logout').catch((error) => { |       this.$axios.$post('/logout').catch((error) => { | ||||||
|         console.error(error) |         console.error(error) | ||||||
| @ -74,6 +93,43 @@ export default { | |||||||
|       if (action === 'logout') { |       if (action === 'logout') { | ||||||
|         this.logout() |         this.logout() | ||||||
|       } |       } | ||||||
|  |     }, | ||||||
|  |     cancelSelectionMode() { | ||||||
|  |       if (this.processingBatchDelete) return | ||||||
|  |       this.$store.commit('setSelectedAudiobooks', []) | ||||||
|  |     }, | ||||||
|  |     toggleSelectAll() { | ||||||
|  |       if (this.isAllSelected) { | ||||||
|  |         this.cancelSelectionMode() | ||||||
|  |       } else { | ||||||
|  |         var audiobookIds = this.audiobooksShowing.map((a) => a.id) | ||||||
|  |         this.$store.commit('setSelectedAudiobooks', audiobookIds) | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     batchDeleteClick() { | ||||||
|  |       if (confirm(`Are you sure you want to delete these ${this.numAudiobooksSelected} audiobook(s)?`)) { | ||||||
|  |         this.processingBatchDelete = true | ||||||
|  |         this.$store.commit('setProcessingBatch', true) | ||||||
|  |         this.$axios | ||||||
|  |           .$post(`/api/audiobooks/delete`, { | ||||||
|  |             audiobookIds: this.selectedAudiobooks | ||||||
|  |           }) | ||||||
|  |           .then(() => { | ||||||
|  |             this.$toast.success('Batch delete success!') | ||||||
|  |             this.processingBatchDelete = false | ||||||
|  |             this.$store.commit('setProcessingBatch', false) | ||||||
|  |             this.$store.commit('setSelectedAudiobooks', []) | ||||||
|  |           }) | ||||||
|  |           .catch((error) => { | ||||||
|  |             this.$toast.error('Batch delete failed') | ||||||
|  |             console.error('Failed to batch delete', error) | ||||||
|  |             this.processingBatchDelete = false | ||||||
|  |             this.$store.commit('setProcessingBatch', false) | ||||||
|  |           }) | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     batchEditClick() { | ||||||
|  |       this.$router.push('/batch') | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   mounted() {} |   mounted() {} | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| <template> | <template> | ||||||
|   <div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-auto relative"> |   <div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-auto relative"> | ||||||
|     <!-- Cover size widget --> |     <!-- Cover size widget --> | ||||||
|     <div class="fixed bottom-2 right-4 z-20"> |     <div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-20"> | ||||||
|       <div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent> |       <div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent> | ||||||
|         <span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize">remove</span> |         <span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize">remove</span> | ||||||
|         <p class="px-2 font-mono">{{ bookCoverWidth }}</p> |         <p class="px-2 font-mono">{{ bookCoverWidth }}</p> | ||||||
| @ -62,6 +62,9 @@ export default { | |||||||
|     }, |     }, | ||||||
|     bookWidth() { |     bookWidth() { | ||||||
|       return this.bookCoverWidth + this.paddingX * 2 |       return this.bookCoverWidth + this.paddingX * 2 | ||||||
|  |     }, | ||||||
|  |     isSelectionMode() { | ||||||
|  |       return this.$store.getters['getNumAudiobooksSelected'] | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
| @ -102,8 +105,6 @@ export default { | |||||||
|       this.width = Math.max(0, this.width - this.rowPaddingX * 2) |       this.width = Math.max(0, this.width - this.rowPaddingX * 2) | ||||||
|       var booksPerRow = Math.floor(this.width / this.bookWidth) |       var booksPerRow = Math.floor(this.width / this.bookWidth) | ||||||
|       this.booksPerRow = booksPerRow |       this.booksPerRow = booksPerRow | ||||||
|       console.warn('this.selectedSizeIndex', this.selectedSizeIndex, 'Book Cover Size', this.bookCoverWidth) |  | ||||||
|       console.warn('Books Per Row', this.booksPerRow, 'Width', this.width, 'Book Width', this.bookWidth) |  | ||||||
|     }, |     }, | ||||||
|     getAudiobookCard(id) { |     getAudiobookCard(id) { | ||||||
|       if (this.$refs[`audiobookCard-${id}`] && this.$refs[`audiobookCard-${id}`].length) { |       if (this.$refs[`audiobookCard-${id}`] && this.$refs[`audiobookCard-${id}`].length) { | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| <template> | <template> | ||||||
|   <div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-20 bg-primary p-4"> |   <div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary p-4"> | ||||||
|     <div class="absolute -top-16 left-4"> |     <div class="absolute -top-16 left-4"> | ||||||
|       <cards-book-cover :audiobook="streamAudiobook" :width="88" /> |       <cards-book-cover :audiobook="streamAudiobook" :width="88" /> | ||||||
|     </div> |     </div> | ||||||
|  | |||||||
| @ -1,28 +1,32 @@ | |||||||
| <template> | <template> | ||||||
|   <nuxt-link :to="`/audiobook/${audiobookId}`" :style="{ padding: `16px ${paddingX}px` }" class="cursor-pointer"> |   <div class="rounded-sm h-full overflow-hidden relative bookCard" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard"> | ||||||
|     <div class="rounded-sm h-full overflow-hidden relative bookCard" @mouseover="isHovering = true" @mouseleave="isHovering = false"> |     <nuxt-link :to="isSelectionMode ? '' : `/audiobook/${audiobookId}`" class="cursor-pointer"> | ||||||
|       <div class="w-full relative" :style="{ height: height + 'px' }"> |       <div class="w-full relative" :style="{ height: height + 'px' }"> | ||||||
|         <cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" /> |         <cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" /> | ||||||
| 
 | 
 | ||||||
|         <div v-show="isHovering" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40"> |         <div v-show="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black rounded" :class="overlayWrapperClasslist"> | ||||||
|           <div class="h-full flex items-center justify-center"> |           <div v-show="!isSelectionMode" class="h-full flex items-center justify-center"> | ||||||
|             <div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play"> |             <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> |               <span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|           <div 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"> |           <div 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> |             <span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span> | ||||||
|           </div> |           </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> |         </div> | ||||||
|         <div class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div> |         <div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :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"> | ||||||
|  |             <span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span> | ||||||
|  |           </div> | ||||||
|  |         </ui-tooltip> | ||||||
|       </div> |       </div> | ||||||
|       <ui-tooltip v-if="showError" :text="errorText" class="absolute top-4 left-0"> |     </nuxt-link> | ||||||
|         <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"> |   </div> | ||||||
|           <span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span> |  | ||||||
|         </div> |  | ||||||
|       </ui-tooltip> |  | ||||||
|     </div> |  | ||||||
|   </nuxt-link> |  | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script> | <script> | ||||||
| @ -50,6 +54,18 @@ export default { | |||||||
|     audiobookId() { |     audiobookId() { | ||||||
|       return this.audiobook.id |       return this.audiobook.id | ||||||
|     }, |     }, | ||||||
|  |     isSelectionMode() { | ||||||
|  |       return this.$store.getters['getNumAudiobooksSelected'] | ||||||
|  |     }, | ||||||
|  |     selectedAudiobooks() { | ||||||
|  |       return this.$store.state.selectedAudiobooks | ||||||
|  |     }, | ||||||
|  |     selected() { | ||||||
|  |       return this.$store.getters['getIsAudiobookSelected'](this.audiobookId) | ||||||
|  |     }, | ||||||
|  |     processingBatch() { | ||||||
|  |       return this.$store.state.processingBatch | ||||||
|  |     }, | ||||||
|     book() { |     book() { | ||||||
|       return this.audiobook.book || {} |       return this.audiobook.book || {} | ||||||
|     }, |     }, | ||||||
| @ -112,9 +128,22 @@ export default { | |||||||
|         txt += `${this.hasInvalidParts} invalid parts.` |         txt += `${this.hasInvalidParts} invalid parts.` | ||||||
|       } |       } | ||||||
|       return txt || 'Unknown Error' |       return txt || 'Unknown Error' | ||||||
|  |     }, | ||||||
|  |     overlayWrapperClasslist() { | ||||||
|  |       var classes = [] | ||||||
|  |       if (this.isSelectionMode) classes.push('bg-opacity-60') | ||||||
|  |       else classes.push('bg-opacity-40') | ||||||
|  |       if (this.selected) { | ||||||
|  |         classes.push('border-2 border-yellow-400') | ||||||
|  |       } | ||||||
|  |       return classes | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|  |     selectBtnClick() { | ||||||
|  |       if (this.processingBatch) return | ||||||
|  |       this.$store.commit('toggleAudiobookSelected', this.audiobookId) | ||||||
|  |     }, | ||||||
|     clickError(e) { |     clickError(e) { | ||||||
|       e.stopPropagation() |       e.stopPropagation() | ||||||
|       this.$router.push(`/audiobook/${this.audiobookId}`) |       this.$router.push(`/audiobook/${this.audiobookId}`) | ||||||
| @ -125,6 +154,13 @@ export default { | |||||||
|     }, |     }, | ||||||
|     editClick() { |     editClick() { | ||||||
|       this.$store.commit('showEditModal', this.audiobook) |       this.$store.commit('showEditModal', this.audiobook) | ||||||
|  |     }, | ||||||
|  |     clickCard(e) { | ||||||
|  |       if (this.isSelectionMode) { | ||||||
|  |         e.stopPropagation() | ||||||
|  |         e.preventDefault() | ||||||
|  |         this.selectBtnClick() | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   mounted() {} |   mounted() {} | ||||||
|  | |||||||
| @ -91,12 +91,20 @@ export default { | |||||||
|         } |         } | ||||||
|         this.isFocused = false |         this.isFocused = false | ||||||
|         if (this.input !== this.textInput) { |         if (this.input !== this.textInput) { | ||||||
|           this.input = this.$cleanString(this.textInput) || null |           var val = this.$cleanString(this.textInput) || null | ||||||
|  |           this.input = val | ||||||
|  |           if (val && !this.items.includes(val)) { | ||||||
|  |             this.$emit('newItem', val) | ||||||
|  |           } | ||||||
|         } |         } | ||||||
|       }, 50) |       }, 50) | ||||||
|     }, |     }, | ||||||
|     submitForm() { |     submitForm() { | ||||||
|       this.input = this.$cleanString(this.textInput) || null |       var val = this.$cleanString(this.textInput) || null | ||||||
|  |       this.input = val | ||||||
|  |       if (val && !this.items.includes(val)) { | ||||||
|  |         this.$emit('newItem', val) | ||||||
|  |       } | ||||||
|       this.currentSearch = null |       this.currentSearch = null | ||||||
|     }, |     }, | ||||||
|     clickedOption(e, item) { |     clickedOption(e, item) { | ||||||
|  | |||||||
| @ -21,6 +21,9 @@ export default { | |||||||
|       if (this.$store.state.showEditModal) { |       if (this.$store.state.showEditModal) { | ||||||
|         this.$store.commit('setShowEditModal', false) |         this.$store.commit('setShowEditModal', false) | ||||||
|       } |       } | ||||||
|  |       if (this.$store.state.selectedAudiobooks) { | ||||||
|  |         this.$store.commit('setSelectedAudiobooks', []) | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   computed: { |   computed: { | ||||||
|  | |||||||
| @ -1,6 +1,7 @@ | |||||||
| export default function ({ store, redirect, route }) { | export default function ({ store, redirect, route }) { | ||||||
|   // If the user is not authenticated
 |   // If the user is not authenticated
 | ||||||
|   if (!store.state.user.user) { |   if (!store.state.user.user) { | ||||||
|  |     if (route.name === 'batch') return redirect('/login') | ||||||
|     return redirect(`/login?redirect=${route.path}`) |     return redirect(`/login?redirect=${route.path}`) | ||||||
|   } |   } | ||||||
| } | } | ||||||
| @ -1,6 +1,6 @@ | |||||||
| { | { | ||||||
|   "name": "audiobookshelf-client", |   "name": "audiobookshelf-client", | ||||||
|   "version": "0.9.83-beta", |   "version": "0.9.84-beta", | ||||||
|   "description": "Audiobook manager and player", |   "description": "Audiobook manager and player", | ||||||
|   "main": "index.js", |   "main": "index.js", | ||||||
|   "scripts": { |   "scripts": { | ||||||
|  | |||||||
							
								
								
									
										142
									
								
								client/pages/batch/index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								client/pages/batch/index.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,142 @@ | |||||||
|  | <template> | ||||||
|  |   <div ref="page" id="page-wrapper" class="page px-6 pt-6 pb-52 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''"> | ||||||
|  |     <div class="flex justify-center flex-wrap"> | ||||||
|  |       <template v-for="audiobook in audiobookCopies"> | ||||||
|  |         <div :key="audiobook.id" class="w-full max-w-3xl border border-black-300 p-6 -ml-px -mt-px flex"> | ||||||
|  |           <div class="w-32"> | ||||||
|  |             <cards-book-cover :audiobook="audiobook.originalAudiobook" :width="120" /> | ||||||
|  |           </div> | ||||||
|  |           <div class="flex-grow pl-4"> | ||||||
|  |             <ui-text-input-with-label v-model="audiobook.book.title" label="Title" /> | ||||||
|  | 
 | ||||||
|  |             <div class="flex mt-2 -mx-1"> | ||||||
|  |               <div class="w-3/4 px-1"> | ||||||
|  |                 <ui-text-input-with-label v-model="audiobook.book.author" label="Author" /> | ||||||
|  |               </div> | ||||||
|  |               <div class="flex-grow px-1"> | ||||||
|  |                 <ui-text-input-with-label v-model="audiobook.book.publishYear" type="number" label="Publish Year" /> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  | 
 | ||||||
|  |             <div class="flex mt-2 -mx-1"> | ||||||
|  |               <div class="w-3/4 px-1"> | ||||||
|  |                 <ui-input-dropdown v-model="audiobook.book.series" label="Series" :items="seriesItems" @input="seriesChanged" @newItem="newSeriesItem" /> | ||||||
|  |               </div> | ||||||
|  |               <div class="flex-grow px-1"> | ||||||
|  |                 <ui-text-input-with-label v-model="audiobook.book.volumeNumber" label="Volume #" /> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  | 
 | ||||||
|  |             <ui-textarea-with-label v-model="audiobook.book.description" :rows="3" label="Description" class="mt-2" /> | ||||||
|  | 
 | ||||||
|  |             <div class="flex mt-2 -mx-1"> | ||||||
|  |               <div class="w-1/2 px-1"> | ||||||
|  |                 <ui-multi-select v-model="audiobook.book.genres" label="Genres" :items="genres" /> | ||||||
|  |               </div> | ||||||
|  |               <div class="flex-grow px-1"> | ||||||
|  |                 <ui-multi-select v-model="audiobook.tags" label="Tags" :items="tags" /> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </template> | ||||||
|  |     </div> | ||||||
|  |     <div v-show="isProcessing" class="fixed top-0 left-0 z-50 w-full h-full flex items-center justify-center bg-black bg-opacity-60"> | ||||||
|  |       <ui-loading-indicator /> | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|  |     <div :class="isScrollable ? 'fixed left-0 box-shadow-lg-up bg-primary' : ''" class="w-full h-20 px-4 flex items-center border-t border-bg z-40" :style="{ bottom: streamAudiobook ? '165px' : '0px' }"> | ||||||
|  |       <div class="flex-grow" /> | ||||||
|  |       <ui-btn color="success" :padding-x="8" class="text-lg" :loading="isProcessing" @click="saveClick">Save</ui-btn> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <script> | ||||||
|  | export default { | ||||||
|  |   asyncData({ store, redirect }) { | ||||||
|  |     if (!store.state.selectedAudiobooks.length) { | ||||||
|  |       return redirect('/') | ||||||
|  |     } | ||||||
|  |     var audiobooks = store.state.audiobooks.audiobooks.filter((ab) => store.state.selectedAudiobooks.includes(ab.id)) | ||||||
|  |     return { | ||||||
|  |       audiobooks | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   data() { | ||||||
|  |     return { | ||||||
|  |       isProcessing: false, | ||||||
|  |       audiobookCopies: [], | ||||||
|  |       isScrollable: false, | ||||||
|  |       newSeriesItems: [] | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   computed: { | ||||||
|  |     streamAudiobook() { | ||||||
|  |       return this.$store.state.streamAudiobook | ||||||
|  |     }, | ||||||
|  |     genres() { | ||||||
|  |       return this.$store.state.audiobooks.genres | ||||||
|  |     }, | ||||||
|  |     tags() { | ||||||
|  |       return this.$store.state.audiobooks.tags | ||||||
|  |     }, | ||||||
|  |     series() { | ||||||
|  |       return this.$store.state.audiobooks.series | ||||||
|  |     }, | ||||||
|  |     seriesItems() { | ||||||
|  |       return [...this.series, ...this.newSeriesItems] | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   methods: { | ||||||
|  |     newSeriesItem(item) { | ||||||
|  |       if (!item) return | ||||||
|  |       this.newSeriesItems.push(item) | ||||||
|  |     }, | ||||||
|  |     seriesChanged() { | ||||||
|  |       this.newSeriesItems = this.newSeriesItems.filter((item) => { | ||||||
|  |         return this.audiobookCopies.find((ab) => ab.book.series === item) | ||||||
|  |       }) | ||||||
|  |     }, | ||||||
|  |     init() { | ||||||
|  |       this.audiobookCopies = this.audiobooks.map((ab) => { | ||||||
|  |         var copy = { ...ab } | ||||||
|  |         copy.tags = [...ab.tags] | ||||||
|  |         copy.book = { ...ab.book } | ||||||
|  |         copy.book.genres = [...ab.book.genres] | ||||||
|  |         copy.originalAudiobook = ab | ||||||
|  |         return copy | ||||||
|  |       }) | ||||||
|  |       this.$nextTick(() => { | ||||||
|  |         if (this.$refs.page.scrollHeight > this.$refs.page.clientHeight) { | ||||||
|  |           this.isScrollable = true | ||||||
|  |         } | ||||||
|  |       }) | ||||||
|  |     }, | ||||||
|  |     saveClick() { | ||||||
|  |       this.isProcessing = true | ||||||
|  | 
 | ||||||
|  |       this.$axios | ||||||
|  |         .$post('/api/audiobooks/update', this.audiobookCopies) | ||||||
|  |         .then((data) => { | ||||||
|  |           this.isProcessing = false | ||||||
|  |           if (data.updates) { | ||||||
|  |             this.$toast.success(`Successfully updated ${data.updates} audiobooks`) | ||||||
|  |             this.$router.replace('/') | ||||||
|  |           } else { | ||||||
|  |             this.$toast.warning('No updates were necessary') | ||||||
|  |           } | ||||||
|  |         }) | ||||||
|  |         .catch((error) => { | ||||||
|  |           console.error('failed to batch update', error) | ||||||
|  |           this.$toast.error('Failed to batch update') | ||||||
|  |           this.isProcessing = false | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   mounted() { | ||||||
|  |     this.init() | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
| @ -1,3 +1,4 @@ | |||||||
|  | import Vue from 'vue' | ||||||
| 
 | 
 | ||||||
| export const state = () => ({ | export const state = () => ({ | ||||||
|   streamAudiobook: null, |   streamAudiobook: null, | ||||||
| @ -8,10 +9,17 @@ export const state = () => ({ | |||||||
|   isScanningCovers: false, |   isScanningCovers: false, | ||||||
|   scanProgress: null, |   scanProgress: null, | ||||||
|   coverScanProgress: null, |   coverScanProgress: null, | ||||||
|   developerMode: false |   developerMode: false, | ||||||
|  |   selectedAudiobooks: [], | ||||||
|  |   processingBatch: false | ||||||
| }) | }) | ||||||
| 
 | 
 | ||||||
| export const getters = {} | export const getters = { | ||||||
|  |   getIsAudiobookSelected: state => audiobookId => { | ||||||
|  |     return !!state.selectedAudiobooks.includes(audiobookId) | ||||||
|  |   }, | ||||||
|  |   getNumAudiobooksSelected: state => state.selectedAudiobooks.length | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| export const actions = {} | export const actions = {} | ||||||
| 
 | 
 | ||||||
| @ -56,5 +64,18 @@ export const mutations = { | |||||||
|   }, |   }, | ||||||
|   setDeveloperMode(state, val) { |   setDeveloperMode(state, val) { | ||||||
|     state.developerMode = val |     state.developerMode = val | ||||||
|  |   }, | ||||||
|  |   setSelectedAudiobooks(state, audiobooks) { | ||||||
|  |     state.selectedAudiobooks = audiobooks | ||||||
|  |   }, | ||||||
|  |   toggleAudiobookSelected(state, audiobookId) { | ||||||
|  |     if (state.selectedAudiobooks.includes(audiobookId)) { | ||||||
|  |       state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId) | ||||||
|  |     } else { | ||||||
|  |       state.selectedAudiobooks.push(audiobookId) | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   setProcessingBatch(state, val) { | ||||||
|  |     state.processingBatch = val | ||||||
|   } |   } | ||||||
| } | } | ||||||
| @ -1,6 +1,6 @@ | |||||||
| { | { | ||||||
|   "name": "audiobookshelf", |   "name": "audiobookshelf", | ||||||
|   "version": "0.9.83-beta", |   "version": "0.9.84-beta", | ||||||
|   "description": "Self-hosted audiobook server for managing and playing audiobooks.", |   "description": "Self-hosted audiobook server for managing and playing audiobooks.", | ||||||
|   "main": "index.js", |   "main": "index.js", | ||||||
|   "scripts": { |   "scripts": { | ||||||
|  | |||||||
| @ -21,6 +21,8 @@ class ApiController { | |||||||
| 
 | 
 | ||||||
|     this.router.get('/audiobooks', this.getAudiobooks.bind(this)) |     this.router.get('/audiobooks', this.getAudiobooks.bind(this)) | ||||||
|     this.router.delete('/audiobooks', this.deleteAllAudiobooks.bind(this)) |     this.router.delete('/audiobooks', this.deleteAllAudiobooks.bind(this)) | ||||||
|  |     this.router.post('/audiobooks/delete', this.batchDeleteAudiobooks.bind(this)) | ||||||
|  |     this.router.post('/audiobooks/update', this.batchUpdateAudiobooks.bind(this)) | ||||||
| 
 | 
 | ||||||
|     this.router.get('/audiobook/:id', this.getAudiobook.bind(this)) |     this.router.get('/audiobook/:id', this.getAudiobook.bind(this)) | ||||||
|     this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this)) |     this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this)) | ||||||
| @ -88,10 +90,7 @@ class ApiController { | |||||||
|     res.json(audiobook.toJSONExpanded()) |     res.json(audiobook.toJSONExpanded()) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async deleteAudiobook(req, res) { |   async handleDeleteAudiobook(audiobook) { | ||||||
|     var audiobook = this.db.audiobooks.find(a => a.id === req.params.id) |  | ||||||
|     if (!audiobook) return res.sendStatus(404) |  | ||||||
| 
 |  | ||||||
|     // Remove audiobook from users
 |     // Remove audiobook from users
 | ||||||
|     for (let i = 0; i < this.db.users.length; i++) { |     for (let i = 0; i < this.db.users.length; i++) { | ||||||
|       var user = this.db.users[i] |       var user = this.db.users[i] | ||||||
| @ -114,12 +113,66 @@ class ApiController { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     var audiobookJSON = audiobook.toJSONMinified() | ||||||
|     await this.db.removeEntity('audiobook', audiobook.id) |     await this.db.removeEntity('audiobook', audiobook.id) | ||||||
|  |     this.emitter('audiobook_removed', audiobookJSON) | ||||||
|  |   } | ||||||
| 
 | 
 | ||||||
|     this.emitter('audiobook_removed', audiobook.toJSONMinified()) |   async deleteAudiobook(req, res) { | ||||||
|  |     var audiobook = this.db.audiobooks.find(a => a.id === req.params.id) | ||||||
|  |     if (!audiobook) return res.sendStatus(404) | ||||||
|  | 
 | ||||||
|  |     await this.handleDeleteAudiobook(audiobook) | ||||||
|     res.sendStatus(200) |     res.sendStatus(200) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async batchDeleteAudiobooks(req, res) { | ||||||
|  |     var { audiobookIds } = req.body | ||||||
|  |     if (!audiobookIds || !audiobookIds.length) { | ||||||
|  |       return res.sendStatus(500) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var audiobooksToDelete = this.db.audiobooks.filter(ab => audiobookIds.includes(ab.id)) | ||||||
|  |     if (!audiobooksToDelete.length) { | ||||||
|  |       return res.sendStatus(404) | ||||||
|  |     } | ||||||
|  |     for (let i = 0; i < audiobooksToDelete.length; i++) { | ||||||
|  |       Logger.info(`[ApiController] Deleting Audiobook "${audiobooksToDelete[i].title}"`) | ||||||
|  |       await this.handleDeleteAudiobook(audiobooksToDelete[i]) | ||||||
|  |     } | ||||||
|  |     res.sendStatus(200) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async batchUpdateAudiobooks(req, res) { | ||||||
|  |     var audiobooks = req.body | ||||||
|  |     if (!audiobooks || !audiobooks.length) { | ||||||
|  |       return res.sendStatus(500) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var audiobooksUpdated = 0 | ||||||
|  |     audiobooks = audiobooks.map((ab) => { | ||||||
|  |       var _ab = this.db.audiobooks.find(__ab => __ab.id === ab.id) | ||||||
|  |       if (!_ab) return null | ||||||
|  |       var hasUpdated = _ab.update(ab) | ||||||
|  |       if (!hasUpdated) return null | ||||||
|  |       audiobooksUpdated++ | ||||||
|  |       return _ab | ||||||
|  |     }).filter(ab => ab) | ||||||
|  | 
 | ||||||
|  |     if (audiobooksUpdated) { | ||||||
|  |       Logger.info(`[ApiController] ${audiobooksUpdated} Audiobooks have updates`) | ||||||
|  |       for (let i = 0; i < audiobooks.length; i++) { | ||||||
|  |         await this.db.updateAudiobook(audiobooks[i]) | ||||||
|  |         this.emitter('audiobook_updated', audiobooks[i].toJSONMinified()) | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     res.json({ | ||||||
|  |       success: true, | ||||||
|  |       updates: audiobooksUpdated | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   async updateAudiobookTracks(req, res) { |   async updateAudiobookTracks(req, res) { | ||||||
|     var audiobook = this.db.audiobooks.find(a => a.id === req.params.id) |     var audiobook = this.db.audiobooks.find(a => a.id === req.params.id) | ||||||
|     if (!audiobook) return res.sendStatus(404) |     if (!audiobook) return res.sendStatus(404) | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user