mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	change color of book read icon #105, basic .pdf reader #107, fix: cover path updating properly #102, step forward/backward from book edit modal #100, add all files tab to edit modal #99, select input auto submit on blur #98
This commit is contained in:
		
							parent
							
								
									315592efe5
								
							
						
					
					
						commit
						03963aa9a1
					
				| @ -23,7 +23,7 @@ | |||||||
|             <template v-for="entity in shelf"> |             <template v-for="entity in shelf"> | ||||||
|               <cards-group-card v-if="showGroups" :key="entity.id" :width="bookCoverWidth" :group="entity" @click="clickGroup" /> |               <cards-group-card v-if="showGroups" :key="entity.id" :width="bookCoverWidth" :group="entity" @click="clickGroup" /> | ||||||
|               <!-- <cards-book-3d :key="entity.id" v-else :width="100" :src="$store.getters['audiobooks/getBookCoverSrc'](entity.book)" /> --> |               <!-- <cards-book-3d :key="entity.id" v-else :width="100" :src="$store.getters['audiobooks/getBookCoverSrc'](entity.book)" /> --> | ||||||
|               <cards-book-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" /> |               <cards-book-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @edit="editBook" /> | ||||||
|             </template> |             </template> | ||||||
|           </div> |           </div> | ||||||
|           <div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" /> |           <div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" /> | ||||||
| @ -138,6 +138,11 @@ export default { | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|  |     editBook(audiobook) { | ||||||
|  |       var bookIds = this.entities.map((e) => e.id) | ||||||
|  |       this.$store.commit('setBookshelfBookIds', bookIds) | ||||||
|  |       this.$store.commit('showEditModal', audiobook) | ||||||
|  |     }, | ||||||
|     clickGroup(group) { |     clickGroup(group) { | ||||||
|       this.$emit('update:selectedSeries', group.name) |       this.$emit('update:selectedSeries', group.name) | ||||||
|     }, |     }, | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ | |||||||
|       <div class="w-full h-full" :style="{ marginTop: sizeMultiplier + 'rem' }"> |       <div class="w-full h-full" :style="{ marginTop: sizeMultiplier + 'rem' }"> | ||||||
|         <div class="flex items-center -mb-2"> |         <div class="flex items-center -mb-2"> | ||||||
|           <template v-for="entity in shelf.books"> |           <template v-for="entity in shelf.books"> | ||||||
|             <cards-book-card :key="entity.id" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @hook:updated="updatedBookCard" /> |             <cards-book-card :key="entity.id" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @hook:updated="updatedBookCard" @edit="editBook" /> | ||||||
|           </template> |           </template> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
| @ -53,6 +53,11 @@ export default { | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|  |     editBook(audiobook) { | ||||||
|  |       var bookIds = this.shelf.books.map((e) => e.id) | ||||||
|  |       this.$store.commit('setBookshelfBookIds', bookIds) | ||||||
|  |       this.$store.commit('showEditModal', audiobook) | ||||||
|  |     }, | ||||||
|     scrolled() { |     scrolled() { | ||||||
|       clearTimeout(this.scrollTimer) |       clearTimeout(this.scrollTimer) | ||||||
|       this.scrollTimer = setTimeout(() => { |       this.scrollTimer = setTimeout(() => { | ||||||
|  | |||||||
							
								
								
									
										74
									
								
								client/components/app/PdfReader.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								client/components/app/PdfReader.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,74 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="w-full pt-20"> | ||||||
|  |     <div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto"> | ||||||
|  |       <div class="flex items-center justify-center"> | ||||||
|  |         <div class="px-12"> | ||||||
|  |           <span class="material-icons text-5xl text-black" :class="!canGoPrev ? 'text-opacity-10' : 'cursor-pointer text-opacity-30 hover:text-opacity-90'" @click.stop.prevent="goPrevPage" @mousedown.prevent>arrow_back_ios</span> | ||||||
|  |         </div> | ||||||
|  |         <div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto"> | ||||||
|  |           <div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div> | ||||||
|  |           <pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="src" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf> | ||||||
|  |         </div> | ||||||
|  | 
 | ||||||
|  |         <div class="px-12"> | ||||||
|  |           <span class="material-icons text-5xl text-black" :class="!canGoNext ? 'text-opacity-10' : 'cursor-pointer text-opacity-30 hover:text-opacity-90'" @click.stop.prevent="goNextPage" @mousedown.prevent>arrow_forward_ios</span> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |     <div class="text-center py-2 text-lg"> | ||||||
|  |       <p>{{ page }} / {{ numPages }}</p> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <script> | ||||||
|  | import pdf from 'vue-pdf' | ||||||
|  | 
 | ||||||
|  | export default { | ||||||
|  |   components: { | ||||||
|  |     pdf | ||||||
|  |   }, | ||||||
|  |   props: { | ||||||
|  |     src: String | ||||||
|  |   }, | ||||||
|  |   data() { | ||||||
|  |     return { | ||||||
|  |       rotate: 0, | ||||||
|  |       loadedRatio: 0, | ||||||
|  |       page: 1, | ||||||
|  |       numPages: 0 | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   computed: { | ||||||
|  |     pdfWidth() { | ||||||
|  |       return this.pdfHeight * 0.6667 | ||||||
|  |     }, | ||||||
|  |     pdfHeight() { | ||||||
|  |       return window.innerHeight - 120 | ||||||
|  |     }, | ||||||
|  |     canGoNext() { | ||||||
|  |       return this.page < this.numPages | ||||||
|  |     }, | ||||||
|  |     canGoPrev() { | ||||||
|  |       return this.page > 1 | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   methods: { | ||||||
|  |     numPagesLoaded(e) { | ||||||
|  |       this.numPages = e | ||||||
|  |     }, | ||||||
|  |     goPrevPage() { | ||||||
|  |       if (this.page <= 1) return | ||||||
|  |       this.page-- | ||||||
|  |     }, | ||||||
|  |     goNextPage() { | ||||||
|  |       if (this.page >= this.numPages) return | ||||||
|  |       this.page++ | ||||||
|  |     }, | ||||||
|  |     error(err) { | ||||||
|  |       console.error(err) | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   mounted() {} | ||||||
|  | } | ||||||
|  | </script> | ||||||
| @ -14,7 +14,7 @@ | |||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
|     <!-- EPUB --> |     <!-- EPUB --> | ||||||
|     <div v-if="epubEbook" class="h-full flex items-center"> |     <div v-if="ebookType === 'epub'" class="h-full flex items-center"> | ||||||
|       <div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden"> |       <div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden"> | ||||||
|         <span v-show="hasPrev" class="material-icons text-black text-opacity-30 hover:text-opacity-80 cursor-pointer text-8xl" @mousedown.prevent @click="pageLeft">chevron_left</span> |         <span v-show="hasPrev" class="material-icons text-black text-opacity-30 hover:text-opacity-80 cursor-pointer text-8xl" @mousedown.prevent @click="pageLeft">chevron_left</span> | ||||||
|       </div> |       </div> | ||||||
| @ -30,11 +30,17 @@ | |||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|     <!-- MOBI/AZW3 --> |     <!-- MOBI/AZW3 --> | ||||||
|     <div v-else class="h-full max-h-full w-full"> |     <div v-else-if="ebookType === 'mobi'" class="h-full max-h-full w-full"> | ||||||
|       <div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-12 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20"> |       <div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-12 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20"> | ||||||
|         <iframe title="html-viewer" width="100%"> Loading </iframe> |         <iframe title="html-viewer" width="100%"> Loading </iframe> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|  |     <!-- PDF --> | ||||||
|  |     <div v-else-if="ebookType === 'pdf'" class="h-full flex items-center"> | ||||||
|  |       <app-pdf-reader :src="ebookUrl" /> | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|  |     <div class="absolute bottom-2 left-2">{{ ebookType }}</div> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| @ -55,7 +61,9 @@ export default { | |||||||
|       author: '', |       author: '', | ||||||
|       progress: 0, |       progress: 0, | ||||||
|       hasNext: true, |       hasNext: true, | ||||||
|       hasPrev: false |       hasPrev: false, | ||||||
|  |       ebookType: '', | ||||||
|  |       ebookUrl: '' | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   watch: { |   watch: { | ||||||
| @ -97,28 +105,23 @@ export default { | |||||||
|     epubEbook() { |     epubEbook() { | ||||||
|       return this.ebooks.find((eb) => eb.ext === '.epub') |       return this.ebooks.find((eb) => eb.ext === '.epub') | ||||||
|     }, |     }, | ||||||
|     epubPath() { |  | ||||||
|       return this.epubEbook ? this.epubEbook.path : null |  | ||||||
|     }, |  | ||||||
|     mobiEbook() { |     mobiEbook() { | ||||||
|       return this.ebooks.find((eb) => eb.ext === '.mobi' || eb.ext === '.azw3') |       return this.ebooks.find((eb) => eb.ext === '.mobi' || eb.ext === '.azw3') | ||||||
|     }, |     }, | ||||||
|     mobiPath() { |     pdfEbook() { | ||||||
|       return this.mobiEbook ? this.mobiEbook.path : null |       return this.ebooks.find((eb) => eb.ext === '.pdf') | ||||||
|     }, |  | ||||||
|     mobiUrl() { |  | ||||||
|       if (!this.mobiPath) return null |  | ||||||
|       return `/ebook/${this.libraryId}/${this.folderId}/${this.mobiPath}` |  | ||||||
|     }, |  | ||||||
|     url() { |  | ||||||
|       if (!this.epubPath) return null |  | ||||||
|       return `/ebook/${this.libraryId}/${this.folderId}/${this.epubPath}` |  | ||||||
|     }, |     }, | ||||||
|     userToken() { |     userToken() { | ||||||
|       return this.$store.getters['user/getToken'] |       return this.$store.getters['user/getToken'] | ||||||
|  |     }, | ||||||
|  |     selectedAudiobookFile() { | ||||||
|  |       return this.$store.state.selectedAudiobookFile | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|  |     getEbookUrl(path) { | ||||||
|  |       return `/ebook/${this.libraryId}/${this.folderId}/${path}` | ||||||
|  |     }, | ||||||
|     changedChapter() { |     changedChapter() { | ||||||
|       if (this.rendition) { |       if (this.rendition) { | ||||||
|         this.rendition.display(this.selectedChapter) |         this.rendition.display(this.selectedChapter) | ||||||
| @ -156,11 +159,28 @@ export default { | |||||||
|     }, |     }, | ||||||
|     init() { |     init() { | ||||||
|       this.registerListeners() |       this.registerListeners() | ||||||
| 
 |       if (this.selectedAudiobookFile) { | ||||||
|       if (this.epubEbook) { |         this.ebookUrl = this.getEbookUrl(this.selectedAudiobookFile.path) | ||||||
|  |         if (this.selectedAudiobookFile.ext === '.pdf') { | ||||||
|  |           this.ebookType = 'pdf' | ||||||
|  |         } else if (this.selectedAudiobookFile.ext === '.mobi' || this.selectedAudiobookFile.ext === '.azw3') { | ||||||
|  |           this.ebookType = 'mobi' | ||||||
|  |           this.initMobi() | ||||||
|  |         } else if (this.selectedAudiobookFile.ext === '.epub') { | ||||||
|  |           this.ebookType = 'epub' | ||||||
|  |           this.initEpub() | ||||||
|  |         } | ||||||
|  |       } else if (this.epubEbook) { | ||||||
|  |         this.ebookType = 'epub' | ||||||
|  |         this.ebookUrl = this.getEbookUrl(this.epubEbook.path) | ||||||
|         this.initEpub() |         this.initEpub() | ||||||
|       } else if (this.mobiEbook) { |       } else if (this.mobiEbook) { | ||||||
|  |         this.ebookType = 'mobi' | ||||||
|  |         this.ebookUrl = this.getEbookUrl(this.mobiEbook.path) | ||||||
|         this.initMobi() |         this.initMobi() | ||||||
|  |       } else if (this.pdfEbook) { | ||||||
|  |         this.ebookType = 'pdf' | ||||||
|  |         this.ebookUrl = this.getEbookUrl(this.pdfEbook.path) | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     addHtmlCss() { |     addHtmlCss() { | ||||||
| @ -219,7 +239,7 @@ export default { | |||||||
|     }, |     }, | ||||||
|     async initMobi() { |     async initMobi() { | ||||||
|       // Fetch mobi file as blob |       // Fetch mobi file as blob | ||||||
|       var buff = await this.$axios.$get(this.mobiUrl, { |       var buff = await this.$axios.$get(this.ebookUrl, { | ||||||
|         responseType: 'blob' |         responseType: 'blob' | ||||||
|       }) |       }) | ||||||
|       var reader = new FileReader() |       var reader = new FileReader() | ||||||
| @ -251,7 +271,7 @@ export default { | |||||||
|       //     Authorization: `Bearer ${this.userToken}` |       //     Authorization: `Bearer ${this.userToken}` | ||||||
|       //   } |       //   } | ||||||
|       // }) |       // }) | ||||||
|       var book = ePub(this.url) |       var book = ePub(this.ebookUrl) | ||||||
|       this.book = book |       this.book = book | ||||||
| 
 | 
 | ||||||
|       this.rendition = book.renderTo('viewer', { |       this.rendition = book.renderTo('viewer', { | ||||||
|  | |||||||
| @ -228,7 +228,8 @@ export default { | |||||||
|       this.$root.socket.emit('open_stream', this.audiobookId) |       this.$root.socket.emit('open_stream', this.audiobookId) | ||||||
|     }, |     }, | ||||||
|     editClick() { |     editClick() { | ||||||
|       this.$store.commit('showEditModal', this.audiobook) |       // this.$store.commit('showEditModal', this.audiobook) | ||||||
|  |       this.$emit('edit', this.audiobook) | ||||||
|     }, |     }, | ||||||
|     clickCard(e) { |     clickCard(e) { | ||||||
|       if (this.isSelectionMode) { |       if (this.isSelectionMode) { | ||||||
|  | |||||||
| @ -10,6 +10,14 @@ | |||||||
|         <div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div> |         <div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div> | ||||||
|       </template> |       </template> | ||||||
|     </div> |     </div> | ||||||
|  | 
 | ||||||
|  |     <div v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6"> | ||||||
|  |       <div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goPrevBook" @mousedown.prevent>arrow_back_ios</div> | ||||||
|  |     </div> | ||||||
|  |     <div v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6"> | ||||||
|  |       <div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div> | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|     <div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300"> |     <div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300"> | ||||||
|       <keep-alive> |       <keep-alive> | ||||||
|         <component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" /> |         <component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" /> | ||||||
| @ -51,6 +59,11 @@ export default { | |||||||
|           title: 'Chapters', |           title: 'Chapters', | ||||||
|           component: 'modals-edit-tabs-chapters' |           component: 'modals-edit-tabs-chapters' | ||||||
|         }, |         }, | ||||||
|  |         { | ||||||
|  |           id: 'files', | ||||||
|  |           title: 'Files', | ||||||
|  |           component: 'modals-edit-tabs-files' | ||||||
|  |         }, | ||||||
|         { |         { | ||||||
|           id: 'download', |           id: 'download', | ||||||
|           title: 'Download', |           title: 'Download', | ||||||
| @ -68,6 +81,7 @@ export default { | |||||||
|             this.show = false |             this.show = false | ||||||
|             return |             return | ||||||
|           } |           } | ||||||
|  | 
 | ||||||
|           if (!availableTabIds.includes(this.selectedTab)) { |           if (!availableTabIds.includes(this.selectedTab)) { | ||||||
|             this.selectedTab = availableTabIds[0] |             this.selectedTab = availableTabIds[0] | ||||||
|           } |           } | ||||||
| @ -137,9 +151,44 @@ export default { | |||||||
|     }, |     }, | ||||||
|     title() { |     title() { | ||||||
|       return this.book.title || 'No Title' |       return this.book.title || 'No Title' | ||||||
|  |     }, | ||||||
|  |     bookshelfBookIds() { | ||||||
|  |       return this.$store.state.bookshelfBookIds || [] | ||||||
|  |     }, | ||||||
|  |     currentBookshelfIndex() { | ||||||
|  |       if (!this.bookshelfBookIds.length) return 0 | ||||||
|  |       return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedAudiobookId) | ||||||
|  |     }, | ||||||
|  |     canGoPrev() { | ||||||
|  |       return this.bookshelfBookIds.length && this.currentBookshelfIndex > 0 | ||||||
|  |     }, | ||||||
|  |     canGoNext() { | ||||||
|  |       return this.bookshelfBookIds.length && this.currentBookshelfIndex < this.bookshelfBookIds.length - 1 | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|  |     goPrevBook() { | ||||||
|  |       if (this.currentBookshelfIndex - 1 < 0) return | ||||||
|  |       var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1] | ||||||
|  |       var prevBook = this.$store.getters['audiobooks/getAudiobook'](prevBookId) | ||||||
|  |       if (prevBook) { | ||||||
|  |         this.$store.commit('showEditModalOnTab', { audiobook: prevBook, tab: this.selectedTab }) | ||||||
|  |         this.$nextTick(this.init) | ||||||
|  |       } else { | ||||||
|  |         console.error('Book not found', prevBookId) | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     goNextBook() { | ||||||
|  |       if (this.currentBookshelfIndex >= this.bookshelfBookIds.length) return | ||||||
|  |       var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1] | ||||||
|  |       var nextBook = this.$store.getters['audiobooks/getAudiobook'](nextBookId) | ||||||
|  |       if (nextBook) { | ||||||
|  |         this.$store.commit('showEditModalOnTab', { audiobook: nextBook, tab: this.selectedTab }) | ||||||
|  |         this.$nextTick(this.init) | ||||||
|  |       } else { | ||||||
|  |         console.error('Book not found', nextBookId) | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     selectTab(tab) { |     selectTab(tab) { | ||||||
|       this.selectedTab = tab |       this.selectedTab = tab | ||||||
|     }, |     }, | ||||||
| @ -155,9 +204,12 @@ export default { | |||||||
|     }, |     }, | ||||||
|     async fetchFull() { |     async fetchFull() { | ||||||
|       try { |       try { | ||||||
|  |         this.processing = true | ||||||
|         this.audiobook = await this.$axios.$get(`/api/audiobook/${this.selectedAudiobookId}`) |         this.audiobook = await this.$axios.$get(`/api/audiobook/${this.selectedAudiobookId}`) | ||||||
|  |         this.processing = false | ||||||
|       } catch (error) { |       } catch (error) { | ||||||
|         console.error('Failed to fetch audiobook', this.selectedAudiobookId, error) |         console.error('Failed to fetch audiobook', this.selectedAudiobookId, error) | ||||||
|  |         this.processing = false | ||||||
|         this.show = false |         this.show = false | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -31,7 +31,7 @@ | |||||||
| 
 | 
 | ||||||
|           <div v-if="showLocalCovers" class="flex items-center justify-center"> |           <div v-if="showLocalCovers" class="flex items-center justify-center"> | ||||||
|             <template v-for="cover in localCovers"> |             <template v-for="cover in localCovers"> | ||||||
|               <div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover.localPath)"> |               <div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)"> | ||||||
|                 <div class="h-24 bg-primary" style="width: 60px"> |                 <div class="h-24 bg-primary" style="width: 60px"> | ||||||
|                   <img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" /> |                   <img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" /> | ||||||
|                 </div> |                 </div> | ||||||
| @ -265,8 +265,24 @@ export default { | |||||||
|       this.isProcessing = false |       this.isProcessing = false | ||||||
|       this.hasSearched = true |       this.hasSearched = true | ||||||
|     }, |     }, | ||||||
|     setCover(cover) { |     setCover(coverFile) { | ||||||
|       this.updateCover(cover) |       this.isProcessing = true | ||||||
|  |       this.$axios | ||||||
|  |         .$patch(`/api/audiobook/${this.audiobook.id}/coverfile`, coverFile) | ||||||
|  |         .then((data) => { | ||||||
|  |           console.log('response data', data) | ||||||
|  |           if (data && typeof data === 'string') { | ||||||
|  |             this.$toast.success(data) | ||||||
|  |           } | ||||||
|  |           this.isProcessing = false | ||||||
|  |         }) | ||||||
|  |         .catch((error) => { | ||||||
|  |           console.error('Failed to update', error) | ||||||
|  |           if (error.response && error.response.data) { | ||||||
|  |             this.$toast.error(error.response.data) | ||||||
|  |           } | ||||||
|  |           this.isProcessing = false | ||||||
|  |         }) | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -214,8 +214,6 @@ export default { | |||||||
|       this.details.volumeNumber = this.book.volumeNumber |       this.details.volumeNumber = this.book.volumeNumber | ||||||
|       this.details.publishYear = this.book.publishYear |       this.details.publishYear = this.book.publishYear | ||||||
| 
 | 
 | ||||||
|       console.log('INIT', this.details) |  | ||||||
| 
 |  | ||||||
|       this.newTags = this.audiobook.tags || [] |       this.newTags = this.audiobook.tags || [] | ||||||
|     }, |     }, | ||||||
|     resetProgress() { |     resetProgress() { | ||||||
|  | |||||||
							
								
								
									
										21
									
								
								client/components/modals/edit-tabs/Files.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								client/components/modals/edit-tabs/Files.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,21 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6"> | ||||||
|  |     <tables-all-files-table :audiobook="audiobook" /> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <script> | ||||||
|  | export default { | ||||||
|  |   props: { | ||||||
|  |     audiobook: { | ||||||
|  |       type: Object, | ||||||
|  |       default: () => {} | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   data() { | ||||||
|  |     return {} | ||||||
|  |   }, | ||||||
|  |   computed: {}, | ||||||
|  |   methods: {} | ||||||
|  | } | ||||||
|  | </script> | ||||||
| @ -53,7 +53,6 @@ export default { | |||||||
|   data() { |   data() { | ||||||
|     return { |     return { | ||||||
|       tracks: null, |       tracks: null, | ||||||
|       audioFiles: null, |  | ||||||
|       showFullPath: false |       showFullPath: false | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
| @ -104,7 +103,6 @@ export default { | |||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|     init() { |     init() { | ||||||
|       this.audioFiles = this.audiobook.audioFiles |  | ||||||
|       this.tracks = this.audiobook.tracks |       this.tracks = this.audiobook.tracks | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  | |||||||
							
								
								
									
										109
									
								
								client/components/tables/AllFilesTable.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								client/components/tables/AllFilesTable.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,109 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="w-full my-2"> | ||||||
|  |     <div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer"> | ||||||
|  |       <p class="pr-4">Files</p> | ||||||
|  |       <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ allFiles.length }}</span> | ||||||
|  |       <div class="flex-grow" /> | ||||||
|  | 
 | ||||||
|  |       <ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn> | ||||||
|  |     </div> | ||||||
|  |     <div class="w-full"> | ||||||
|  |       <table class="text-sm tracksTable"> | ||||||
|  |         <tr class="font-book"> | ||||||
|  |           <th class="text-left px-4">Path</th> | ||||||
|  |           <th class="text-left px-4 w-24">Filetype</th> | ||||||
|  |           <th v-if="userCanDownload" class="text-center w-20">Download</th> | ||||||
|  |         </tr> | ||||||
|  |         <template v-for="file in allFiles"> | ||||||
|  |           <tr :key="file.path"> | ||||||
|  |             <td class="font-book pl-2"> | ||||||
|  |               {{ showFullPath ? file.fullPath : file.path }} | ||||||
|  |             </td> | ||||||
|  |             <td class="text-xs"> | ||||||
|  |               <p>{{ file.filetype }}</p> | ||||||
|  |             </td> | ||||||
|  |             <td v-if="userCanDownload" class="text-center"> | ||||||
|  |               <a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a> | ||||||
|  |             </td> | ||||||
|  |           </tr> | ||||||
|  |         </template> | ||||||
|  |       </table> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <script> | ||||||
|  | export default { | ||||||
|  |   props: { | ||||||
|  |     audiobook: { | ||||||
|  |       type: Object, | ||||||
|  |       default: () => {} | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   data() { | ||||||
|  |     return { | ||||||
|  |       showFullPath: false | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   computed: { | ||||||
|  |     audiobookId() { | ||||||
|  |       return this.audiobook.id | ||||||
|  |     }, | ||||||
|  |     audiobookPath() { | ||||||
|  |       return this.audiobook.path | ||||||
|  |     }, | ||||||
|  |     userCanDownload() { | ||||||
|  |       return this.$store.getters['user/getUserCanDownload'] | ||||||
|  |     }, | ||||||
|  |     userToken() { | ||||||
|  |       return this.$store.getters['user/getToken'] | ||||||
|  |     }, | ||||||
|  |     isMissing() { | ||||||
|  |       return this.audiobook.isMissing | ||||||
|  |     }, | ||||||
|  |     showDownload() { | ||||||
|  |       return this.userCanDownload && !this.isMissing | ||||||
|  |     }, | ||||||
|  |     otherFiles() { | ||||||
|  |       return this.audiobook.otherFiles || [] | ||||||
|  |     }, | ||||||
|  |     audioFiles() { | ||||||
|  |       return this.audiobook.audioFiles || [] | ||||||
|  |     }, | ||||||
|  |     audioFilesCleaned() { | ||||||
|  |       return this.audioFiles.map((af) => { | ||||||
|  |         return { | ||||||
|  |           path: af.path, | ||||||
|  |           fullPath: af.fullPath, | ||||||
|  |           relativePath: this.getRelativePath(af.path), | ||||||
|  |           filetype: 'audio' | ||||||
|  |         } | ||||||
|  |       }) | ||||||
|  |     }, | ||||||
|  |     otherFilesCleaned() { | ||||||
|  |       return this.otherFiles.map((af) => { | ||||||
|  |         return { | ||||||
|  |           path: af.path, | ||||||
|  |           fullPath: af.fullPath, | ||||||
|  |           relativePath: this.getRelativePath(af.path), | ||||||
|  |           filetype: af.filetype | ||||||
|  |         } | ||||||
|  |       }) | ||||||
|  |     }, | ||||||
|  |     allFiles() { | ||||||
|  |       return this.audioFilesCleaned.concat(this.otherFilesCleaned) | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   methods: { | ||||||
|  |     getRelativePath(path) { | ||||||
|  |       var filePath = path.replace(/\\/g, '/') | ||||||
|  |       var audiobookPath = this.audiobookPath.replace(/\\/g, '/') | ||||||
|  |       return filePath | ||||||
|  |         .replace(audiobookPath + '/', '') | ||||||
|  |         .replace(/%/g, '%25') | ||||||
|  |         .replace(/#/g, '%23') | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   mounted() {} | ||||||
|  | } | ||||||
|  | </script> | ||||||
| @ -20,7 +20,7 @@ | |||||||
|           <tr class="font-book"> |           <tr class="font-book"> | ||||||
|             <th class="text-left px-4">Path</th> |             <th class="text-left px-4">Path</th> | ||||||
|             <th class="text-left px-4 w-24">Filetype</th> |             <th class="text-left px-4 w-24">Filetype</th> | ||||||
|             <th v-if="userCanDownload" class="text-center w-20">Download</th> |             <th v-if="userCanDownload && !isMissing" class="text-center w-20">Download</th> | ||||||
|           </tr> |           </tr> | ||||||
|           <template v-for="file in otherFilesCleaned"> |           <template v-for="file in otherFilesCleaned"> | ||||||
|             <tr :key="file.path"> |             <tr :key="file.path"> | ||||||
| @ -28,9 +28,12 @@ | |||||||
|                 {{ showFullPath ? file.fullPath : file.path }} |                 {{ showFullPath ? file.fullPath : file.path }} | ||||||
|               </td> |               </td> | ||||||
|               <td class="text-xs"> |               <td class="text-xs"> | ||||||
|                 <p>{{ file.filetype }}</p> |                 <div class="flex items-center"> | ||||||
|  |                   <span v-if="file.filetype === 'ebook'" class="material-icons text-base mr-1 cursor-pointer text-white text-opacity-60 hover:text-opacity-100" @click="readEbookClick(file)">auto_stories </span> | ||||||
|  |                   <p>{{ file.filetype }}</p> | ||||||
|  |                 </div> | ||||||
|               </td> |               </td> | ||||||
|               <td v-if="userCanDownload" class="text-center"> |               <td v-if="userCanDownload && !isMissing" class="text-center"> | ||||||
|                 <a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a> |                 <a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a> | ||||||
|               </td> |               </td> | ||||||
|             </tr> |             </tr> | ||||||
| @ -83,11 +86,17 @@ export default { | |||||||
|     userToken() { |     userToken() { | ||||||
|       return this.$store.getters['user/getToken'] |       return this.$store.getters['user/getToken'] | ||||||
|     }, |     }, | ||||||
|  |     isMissing() { | ||||||
|  |       return this.audiobook.isMissing | ||||||
|  |     }, | ||||||
|     userCanDownload() { |     userCanDownload() { | ||||||
|       return this.$store.getters['user/getUserCanDownload'] |       return this.$store.getters['user/getUserCanDownload'] | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|  |     readEbookClick(file) { | ||||||
|  |       this.$store.commit('showEReaderForFile', { audiobook: this.audiobook, file }) | ||||||
|  |     }, | ||||||
|     clickBar() { |     clickBar() { | ||||||
|       this.showFiles = !this.showFiles |       this.showFiles = !this.showFiles | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -116,9 +116,6 @@ export default { | |||||||
|       this.textInput = null |       this.textInput = null | ||||||
|       this.currentSearch = null |       this.currentSearch = null | ||||||
|       this.input = item |       this.input = item | ||||||
| 
 |  | ||||||
|       // this.input = this.textInput ? this.textInput.trim() : null |  | ||||||
|       console.log('Clicked option', item) |  | ||||||
|       if (this.$refs.input) this.$refs.input.blur() |       if (this.$refs.input) this.$refs.input.blur() | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|  | |||||||
| @ -127,6 +127,7 @@ export default { | |||||||
|           return |           return | ||||||
|         } |         } | ||||||
|         this.isFocused = false |         this.isFocused = false | ||||||
|  |         if (this.textInput) this.submitForm() | ||||||
|       }, 50) |       }, 50) | ||||||
|     }, |     }, | ||||||
|     focus() { |     focus() { | ||||||
| @ -145,6 +146,7 @@ export default { | |||||||
|       var newSelected = null |       var newSelected = null | ||||||
|       if (this.selected.includes(itemValue)) { |       if (this.selected.includes(itemValue)) { | ||||||
|         newSelected = this.selected.filter((s) => s !== itemValue) |         newSelected = this.selected.filter((s) => s !== itemValue) | ||||||
|  |         this.$emit('removedItem', itemValue) | ||||||
|       } else { |       } else { | ||||||
|         newSelected = this.selected.concat([itemValue]) |         newSelected = this.selected.concat([itemValue]) | ||||||
|       } |       } | ||||||
| @ -164,6 +166,7 @@ export default { | |||||||
|     removeItem(item) { |     removeItem(item) { | ||||||
|       var remaining = this.selected.filter((i) => i !== item) |       var remaining = this.selected.filter((i) => i !== item) | ||||||
|       this.$emit('input', remaining) |       this.$emit('input', remaining) | ||||||
|  |       this.$emit('removedItem', item) | ||||||
|       this.$nextTick(() => { |       this.$nextTick(() => { | ||||||
|         this.recalcMenuPos() |         this.recalcMenuPos() | ||||||
|       }) |       }) | ||||||
| @ -171,6 +174,7 @@ export default { | |||||||
|     insertNewItem(item) { |     insertNewItem(item) { | ||||||
|       this.selected.push(item) |       this.selected.push(item) | ||||||
|       this.$emit('input', this.selected) |       this.$emit('input', this.selected) | ||||||
|  |       this.$emit('newItem', item) | ||||||
|       this.textInput = null |       this.textInput = null | ||||||
|       this.currentSearch = null |       this.currentSearch = null | ||||||
|       this.$nextTick(() => { |       this.$nextTick(() => { | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| <template> | <template> | ||||||
|   <button class="icon-btn rounded-md bg-primary border border-gray-600 flex items-center justify-center h-9 w-9 relative" @click="clickBtn"> |   <button class="icon-btn rounded-md bg-primary border border-gray-600 flex items-center justify-center h-9 w-9 relative" @click="clickBtn"> | ||||||
|     <div class="w-5 h-5 text-white relative"> |     <div class="w-5 h-5 text-white relative"> | ||||||
|       <svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> |       <svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)"> | ||||||
|         <path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" /> |         <path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" /> | ||||||
|       </svg> |       </svg> | ||||||
|       <svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> |       <svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> | ||||||
|  | |||||||
							
								
								
									
										81
									
								
								client/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										81
									
								
								client/package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -1,6 +1,6 @@ | |||||||
| { | { | ||||||
|   "name": "audiobookshelf-client", |   "name": "audiobookshelf-client", | ||||||
|   "version": "1.4.6", |   "version": "1.4.8", | ||||||
|   "lockfileVersion": 1, |   "lockfileVersion": 1, | ||||||
|   "requires": true, |   "requires": true, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
| @ -3415,6 +3415,11 @@ | |||||||
|         "@babel/helper-define-polyfill-provider": "^0.2.2" |         "@babel/helper-define-polyfill-provider": "^0.2.2" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "babel-plugin-syntax-dynamic-import": { | ||||||
|  |       "version": "6.18.0", | ||||||
|  |       "resolved": "https://registry.npmjs.org/babel-plugin-syntax-dynamic-import/-/babel-plugin-syntax-dynamic-import-6.18.0.tgz", | ||||||
|  |       "integrity": "sha1-jWomIpyDdFqZgqRBBRVyyqF5sdo=" | ||||||
|  |     }, | ||||||
|     "backo2": { |     "backo2": { | ||||||
|       "version": "1.0.2", |       "version": "1.0.2", | ||||||
|       "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", |       "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", | ||||||
| @ -8494,6 +8499,11 @@ | |||||||
|         "sha.js": "^2.4.8" |         "sha.js": "^2.4.8" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "pdfjs-dist": { | ||||||
|  |       "version": "2.6.347", | ||||||
|  |       "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.6.347.tgz", | ||||||
|  |       "integrity": "sha512-QC+h7hG2su9v/nU1wEI3SnpPIrqJODL7GTDFvR74ANKGq1AFJW16PH8VWnhpiTi9YcLSFV9xLeWSgq+ckHLdVQ==" | ||||||
|  |     }, | ||||||
|     "picomatch": { |     "picomatch": { | ||||||
|       "version": "2.3.0", |       "version": "2.3.0", | ||||||
|       "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", |       "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", | ||||||
| @ -11239,6 +11249,37 @@ | |||||||
|       "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", |       "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", | ||||||
|       "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" |       "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" | ||||||
|     }, |     }, | ||||||
|  |     "raw-loader": { | ||||||
|  |       "version": "4.0.2", | ||||||
|  |       "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz", | ||||||
|  |       "integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==", | ||||||
|  |       "requires": { | ||||||
|  |         "loader-utils": "^2.0.0", | ||||||
|  |         "schema-utils": "^3.0.0" | ||||||
|  |       }, | ||||||
|  |       "dependencies": { | ||||||
|  |         "loader-utils": { | ||||||
|  |           "version": "2.0.0", | ||||||
|  |           "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz", | ||||||
|  |           "integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==", | ||||||
|  |           "requires": { | ||||||
|  |             "big.js": "^5.2.2", | ||||||
|  |             "emojis-list": "^3.0.0", | ||||||
|  |             "json5": "^2.1.2" | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         "schema-utils": { | ||||||
|  |           "version": "3.1.1", | ||||||
|  |           "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", | ||||||
|  |           "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", | ||||||
|  |           "requires": { | ||||||
|  |             "@types/json-schema": "^7.0.8", | ||||||
|  |             "ajv": "^6.12.5", | ||||||
|  |             "ajv-keywords": "^3.5.2" | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "rc9": { |     "rc9": { | ||||||
|       "version": "1.2.0", |       "version": "1.2.0", | ||||||
|       "resolved": "https://registry.npmjs.org/rc9/-/rc9-1.2.0.tgz", |       "resolved": "https://registry.npmjs.org/rc9/-/rc9-1.2.0.tgz", | ||||||
| @ -13314,6 +13355,24 @@ | |||||||
|       "resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz", |       "resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz", | ||||||
|       "integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g==" |       "integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g==" | ||||||
|     }, |     }, | ||||||
|  |     "vue-pdf": { | ||||||
|  |       "version": "4.3.0", | ||||||
|  |       "resolved": "https://registry.npmjs.org/vue-pdf/-/vue-pdf-4.3.0.tgz", | ||||||
|  |       "integrity": "sha512-zd3lJj6CbtrawgaaDDciTDjkJMUKiLWtbEmBg5CvFn9Noe9oAO/GNy/fc5c59qGuFCJ14ibIV1baw4S07e5bSQ==", | ||||||
|  |       "requires": { | ||||||
|  |         "babel-plugin-syntax-dynamic-import": "^6.18.0", | ||||||
|  |         "loader-utils": "^1.4.0", | ||||||
|  |         "pdfjs-dist": "2.6.347", | ||||||
|  |         "raw-loader": "^4.0.2", | ||||||
|  |         "vue-resize-sensor": "^2.0.0", | ||||||
|  |         "worker-loader": "^2.0.0" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "vue-resize-sensor": { | ||||||
|  |       "version": "2.0.0", | ||||||
|  |       "resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz", | ||||||
|  |       "integrity": "sha512-W+y2EAI/BxS4Vlcca9scQv8ifeBFck56DRtSwWJ2H4Cw1GLNUYxiZxUHHkuzuI5JPW/cYtL1bPO5xPyEXx4LmQ==" | ||||||
|  |     }, | ||||||
|     "vue-router": { |     "vue-router": { | ||||||
|       "version": "3.5.2", |       "version": "3.5.2", | ||||||
|       "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.2.tgz", |       "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.2.tgz", | ||||||
| @ -14181,6 +14240,26 @@ | |||||||
|         "errno": "~0.1.7" |         "errno": "~0.1.7" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "worker-loader": { | ||||||
|  |       "version": "2.0.0", | ||||||
|  |       "resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-2.0.0.tgz", | ||||||
|  |       "integrity": "sha512-tnvNp4K3KQOpfRnD20m8xltE3eWh89Ye+5oj7wXEEHKac1P4oZ6p9oTj8/8ExqoSBnk9nu5Pr4nKfQ1hn2APJw==", | ||||||
|  |       "requires": { | ||||||
|  |         "loader-utils": "^1.0.0", | ||||||
|  |         "schema-utils": "^0.4.0" | ||||||
|  |       }, | ||||||
|  |       "dependencies": { | ||||||
|  |         "schema-utils": { | ||||||
|  |           "version": "0.4.7", | ||||||
|  |           "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.7.tgz", | ||||||
|  |           "integrity": "sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ==", | ||||||
|  |           "requires": { | ||||||
|  |             "ajv": "^6.1.0", | ||||||
|  |             "ajv-keywords": "^3.1.0" | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "wrap-ansi": { |     "wrap-ansi": { | ||||||
|       "version": "6.2.0", |       "version": "6.2.0", | ||||||
|       "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", |       "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| { | { | ||||||
|   "name": "audiobookshelf-client", |   "name": "audiobookshelf-client", | ||||||
|   "version": "1.4.8", |   "version": "1.4.9", | ||||||
|   "description": "Audiobook manager and player", |   "description": "Audiobook manager and player", | ||||||
|   "main": "index.js", |   "main": "index.js", | ||||||
|   "scripts": { |   "scripts": { | ||||||
| @ -21,6 +21,7 @@ | |||||||
|     "hls.js": "^1.0.7", |     "hls.js": "^1.0.7", | ||||||
|     "nuxt": "^2.15.7", |     "nuxt": "^2.15.7", | ||||||
|     "nuxt-socket-io": "^1.1.18", |     "nuxt-socket-io": "^1.1.18", | ||||||
|  |     "vue-pdf": "^4.3.0", | ||||||
|     "vue-toastification": "^1.7.11", |     "vue-toastification": "^1.7.11", | ||||||
|     "vuedraggable": "^2.24.3" |     "vuedraggable": "^2.24.3" | ||||||
|   }, |   }, | ||||||
|  | |||||||
| @ -411,6 +411,7 @@ export default { | |||||||
|       this.$root.socket.emit('open_stream', this.audiobook.id) |       this.$root.socket.emit('open_stream', this.audiobook.id) | ||||||
|     }, |     }, | ||||||
|     editClick() { |     editClick() { | ||||||
|  |       this.$store.commit('setBookshelfBookIds', []) | ||||||
|       this.$store.commit('showEditModal', this.audiobook) |       this.$store.commit('showEditModal', this.audiobook) | ||||||
|     }, |     }, | ||||||
|     lookupMetadata(index) { |     lookupMetadata(index) { | ||||||
|  | |||||||
| @ -33,10 +33,10 @@ | |||||||
| 
 | 
 | ||||||
|             <div class="flex mt-2 -mx-1"> |             <div class="flex mt-2 -mx-1"> | ||||||
|               <div class="w-1/2 px-1"> |               <div class="w-1/2 px-1"> | ||||||
|                 <ui-multi-select v-model="audiobook.book.genres" label="Genres" :items="genres" /> |                 <ui-multi-select v-model="audiobook.book.genres" label="Genres" :items="genreItems" @newItem="newGenreItem" @removedItem="removedGenreItem" /> | ||||||
|               </div> |               </div> | ||||||
|               <div class="flex-grow px-1"> |               <div class="flex-grow px-1"> | ||||||
|                 <ui-multi-select v-model="audiobook.tags" label="Tags" :items="tags" /> |                 <ui-multi-select v-model="audiobook.tags" label="Tags" :items="tagItems" @newItem="newTagItem" @removedItem="removedTagItem" /> | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
| 
 | 
 | ||||||
| @ -76,7 +76,9 @@ export default { | |||||||
|       isProcessing: false, |       isProcessing: false, | ||||||
|       audiobookCopies: [], |       audiobookCopies: [], | ||||||
|       isScrollable: false, |       isScrollable: false, | ||||||
|       newSeriesItems: [] |       newSeriesItems: [], | ||||||
|  |       newTagItems: [], | ||||||
|  |       newGenreItems: [] | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   computed: { |   computed: { | ||||||
| @ -86,9 +88,15 @@ export default { | |||||||
|     genres() { |     genres() { | ||||||
|       return this.$store.state.audiobooks.genres |       return this.$store.state.audiobooks.genres | ||||||
|     }, |     }, | ||||||
|  |     genreItems() { | ||||||
|  |       return this.genres.concat(this.newGenreItems) | ||||||
|  |     }, | ||||||
|     tags() { |     tags() { | ||||||
|       return this.$store.state.audiobooks.tags |       return this.$store.state.audiobooks.tags | ||||||
|     }, |     }, | ||||||
|  |     tagItems() { | ||||||
|  |       return this.tags.concat(this.newTagItems) | ||||||
|  |     }, | ||||||
|     series() { |     series() { | ||||||
|       return this.$store.state.audiobooks.series |       return this.$store.state.audiobooks.series | ||||||
|     }, |     }, | ||||||
| @ -100,9 +108,42 @@ export default { | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|  |     newTagItem(item) { | ||||||
|  |       if (item && !this.newTagItems.includes(item)) { | ||||||
|  |         this.newTagItems.push(item) | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     removedTagItem(item) { | ||||||
|  |       // If newly added, remove if not used on any other audiobooks | ||||||
|  |       if (item && this.newTagItems.includes(item)) { | ||||||
|  |         var usedByOtherAb = this.audiobookCopies.find((ab) => { | ||||||
|  |           return ab.tags && ab.tags.includes(item) | ||||||
|  |         }) | ||||||
|  |         if (!usedByOtherAb) { | ||||||
|  |           this.newTagItems = this.newTagItems.filter((t) => t !== item) | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     newGenreItem(item) { | ||||||
|  |       if (item && !this.newGenreItems.includes(item)) { | ||||||
|  |         this.newGenreItems.push(item) | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     removedGenreItem(item) { | ||||||
|  |       // If newly added, remove if not used on any other audiobooks | ||||||
|  |       if (item && this.newGenreItems.includes(item)) { | ||||||
|  |         var usedByOtherAb = this.audiobookCopies.find((ab) => { | ||||||
|  |           return ab.book.genres && ab.book.genres.includes(item) | ||||||
|  |         }) | ||||||
|  |         if (!usedByOtherAb) { | ||||||
|  |           this.newGenreItems = this.newGenreItems.filter((t) => t !== item) | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     newSeriesItem(item) { |     newSeriesItem(item) { | ||||||
|       if (!item) return |       if (item && !this.newSeriesItems.includes(item)) { | ||||||
|       this.newSeriesItems.push(item) |         this.newSeriesItems.push(item) | ||||||
|  |       } | ||||||
|     }, |     }, | ||||||
|     seriesChanged() { |     seriesChanged() { | ||||||
|       this.newSeriesItems = this.newSeriesItems.filter((item) => { |       this.newSeriesItems = this.newSeriesItems.filter((item) => { | ||||||
|  | |||||||
| @ -21,9 +21,7 @@ | |||||||
| <script> | <script> | ||||||
| export default { | export default { | ||||||
|   asyncData({ redirect, store }) { |   asyncData({ redirect, store }) { | ||||||
|     var currentLibraryId = store.state.libraries.currentLibraryId |     redirect(`/library/${store.state.libraries.currentLibraryId}`) | ||||||
|     console.log('Redir', currentLibraryId) |  | ||||||
|     redirect(`/library/${currentLibraryId}`) |  | ||||||
|   }, |   }, | ||||||
|   data() { |   data() { | ||||||
|     return {} |     return {} | ||||||
|  | |||||||
| @ -31,6 +31,7 @@ export default { | |||||||
|         return [] |         return [] | ||||||
|       }) |       }) | ||||||
|       store.commit('audiobooks/setSearchResults', searchResults) |       store.commit('audiobooks/setSearchResults', searchResults) | ||||||
|  |       if (searchResults.length) searchResults.forEach((ab) => store.commit('audiobooks/addUpdate', ab)) | ||||||
|     } |     } | ||||||
|     var selectedSeries = query.series ? app.$decode(query.series) : null |     var selectedSeries = query.series ? app.$decode(query.series) : null | ||||||
|     store.commit('audiobooks/setSelectedSeries', selectedSeries) |     store.commit('audiobooks/setSelectedSeries', selectedSeries) | ||||||
|  | |||||||
| @ -9,6 +9,7 @@ export const state = () => ({ | |||||||
|   showEditModal: false, |   showEditModal: false, | ||||||
|   showEReader: false, |   showEReader: false, | ||||||
|   selectedAudiobook: null, |   selectedAudiobook: null, | ||||||
|  |   selectedAudiobookFile: null, | ||||||
|   playOnLoad: false, |   playOnLoad: false, | ||||||
|   developerMode: false, |   developerMode: false, | ||||||
|   selectedAudiobooks: [], |   selectedAudiobooks: [], | ||||||
| @ -16,7 +17,8 @@ export const state = () => ({ | |||||||
|   previousPath: '/', |   previousPath: '/', | ||||||
|   routeHistory: [], |   routeHistory: [], | ||||||
|   showExperimentalFeatures: false, |   showExperimentalFeatures: false, | ||||||
|   backups: [] |   backups: [], | ||||||
|  |   bookshelfBookIds: [] | ||||||
| }) | }) | ||||||
| 
 | 
 | ||||||
| export const getters = { | export const getters = { | ||||||
| @ -66,6 +68,9 @@ export const actions = { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const mutations = { | export const mutations = { | ||||||
|  |   setBookshelfBookIds(state, val) { | ||||||
|  |     state.bookshelfBookIds = val || [] | ||||||
|  |   }, | ||||||
|   setRouteHistory(state, val) { |   setRouteHistory(state, val) { | ||||||
|     state.routeHistory = val |     state.routeHistory = val | ||||||
|   }, |   }, | ||||||
| @ -113,7 +118,15 @@ export const mutations = { | |||||||
|     state.showEditModal = val |     state.showEditModal = val | ||||||
|   }, |   }, | ||||||
|   showEReader(state, audiobook) { |   showEReader(state, audiobook) { | ||||||
|  |     state.selectedAudiobookFile = null | ||||||
|     state.selectedAudiobook = audiobook |     state.selectedAudiobook = audiobook | ||||||
|  | 
 | ||||||
|  |     state.showEReader = true | ||||||
|  |   }, | ||||||
|  |   showEReaderForFile(state, { audiobook, file }) { | ||||||
|  |     state.selectedAudiobookFile = file | ||||||
|  |     state.selectedAudiobook = audiobook | ||||||
|  | 
 | ||||||
|     state.showEReader = true |     state.showEReader = true | ||||||
|   }, |   }, | ||||||
|   setShowEReader(state, val) { |   setShowEReader(state, val) { | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| { | { | ||||||
|   "name": "audiobookshelf", |   "name": "audiobookshelf", | ||||||
|   "version": "1.4.8", |   "version": "1.4.9", | ||||||
|   "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": { | ||||||
|  | |||||||
| @ -47,6 +47,7 @@ class ApiController { | |||||||
|     this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this)) |     this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this)) | ||||||
|     this.router.patch('/audiobook/:id/tracks', this.updateAudiobookTracks.bind(this)) |     this.router.patch('/audiobook/:id/tracks', this.updateAudiobookTracks.bind(this)) | ||||||
|     this.router.post('/audiobook/:id/cover', this.uploadAudiobookCover.bind(this)) |     this.router.post('/audiobook/:id/cover', this.uploadAudiobookCover.bind(this)) | ||||||
|  |     this.router.patch('/audiobook/:id/coverfile', this.updateAudiobookCoverFromFile.bind(this)) | ||||||
|     this.router.patch('/audiobook/:id', this.updateAudiobook.bind(this)) |     this.router.patch('/audiobook/:id', this.updateAudiobook.bind(this)) | ||||||
| 
 | 
 | ||||||
|     this.router.patch('/match/:id', this.match.bind(this)) |     this.router.patch('/match/:id', this.match.bind(this)) | ||||||
| @ -445,6 +446,26 @@ class ApiController { | |||||||
|     }) |     }) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async updateAudiobookCoverFromFile(req, res) { | ||||||
|  |     if (!req.user.canUpdate) { | ||||||
|  |       Logger.warn('User attempted to update without permission', req.user) | ||||||
|  |       return res.sendStatus(403) | ||||||
|  |     } | ||||||
|  |     var audiobook = this.db.audiobooks.find(a => a.id === req.params.id) | ||||||
|  |     if (!audiobook) return res.sendStatus(404) | ||||||
|  | 
 | ||||||
|  |     var coverFile = req.body | ||||||
|  |     var updated = await audiobook.setCoverFromFile(coverFile) | ||||||
|  | 
 | ||||||
|  |     if (updated) { | ||||||
|  |       await this.db.updateAudiobook(audiobook) | ||||||
|  |       this.emitter('audiobook_updated', audiobook.toJSONMinified()) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (updated) res.status(200).send('Cover updated successfully') | ||||||
|  |     else res.status(200).send('No update was made to cover') | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   async updateAudiobook(req, res) { |   async updateAudiobook(req, res) { | ||||||
|     if (!req.user.canUpdate) { |     if (!req.user.canUpdate) { | ||||||
|       Logger.warn('User attempted to update without permission', req.user) |       Logger.warn('User attempted to update without permission', req.user) | ||||||
|  | |||||||
| @ -241,10 +241,13 @@ class DownloadManager { | |||||||
| 
 | 
 | ||||||
|     if (shouldIncludeCover) { |     if (shouldIncludeCover) { | ||||||
|       var _cover = audiobook.book.coverFullPath |       var _cover = audiobook.book.coverFullPath | ||||||
|  | 
 | ||||||
|  |       // Supporting old local file prefix
 | ||||||
|       if (!_cover && audiobook.book.cover && audiobook.book.cover.startsWith(Path.sep + 'local')) { |       if (!_cover && audiobook.book.cover && audiobook.book.cover.startsWith(Path.sep + 'local')) { | ||||||
|         _cover = Path.join(this.AudiobookPath, _cover.replace(Path.sep + 'local', '')) |         _cover = Path.join(this.AudiobookPath, _cover.replace(Path.sep + 'local', '')) | ||||||
|         Logger.debug('Local cover url', _cover) |         Logger.debug('Local cover url', _cover) | ||||||
|       } |       } | ||||||
|  | 
 | ||||||
|       ffmpegInputs.push({ |       ffmpegInputs.push({ | ||||||
|         input: _cover, |         input: _cover, | ||||||
|         options: ['-f image2pipe'] |         options: ['-f image2pipe'] | ||||||
|  | |||||||
| @ -128,11 +128,15 @@ class Audiobook { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   get hasEpub() { |   get hasEpub() { | ||||||
|     return this.otherFiles.find(file => file.ext === '.epub') |     return this.ebooks.find(file => file.ext === '.epub') | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   get hasMobi() { |   get hasMobi() { | ||||||
|     return this.otherFiles.find(file => file.ext === '.mobi' || file.ext === '.azw3') |     return this.ebooks.find(file => file.ext === '.mobi' || file.ext === '.azw3') | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   get hasPdf() { | ||||||
|  |     return this.ebooks.find(file => file.ext === '.pdf') | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   get hasMissingIno() { |   get hasMissingIno() { | ||||||
| @ -206,7 +210,7 @@ class Audiobook { | |||||||
|       hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0, |       hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0, | ||||||
|       // numEbooks: this.ebooks.length,
 |       // numEbooks: this.ebooks.length,
 | ||||||
|       ebooks: this.ebooks.map(ebook => ebook.toJSON()), |       ebooks: this.ebooks.map(ebook => ebook.toJSON()), | ||||||
|       numEbooks: (this.hasEpub || this.hasMobi) ? 1 : 0, // Only supporting epubs in the reader currently
 |       numEbooks: (this.hasEpub || this.hasMobi || this.hasPdf) ? 1 : 0, // Only supporting epubs in the reader currently
 | ||||||
|       numTracks: this.tracks.length, |       numTracks: this.tracks.length, | ||||||
|       chapters: this.chapters || [], |       chapters: this.chapters || [], | ||||||
|       isMissing: !!this.isMissing, |       isMissing: !!this.isMissing, | ||||||
| @ -233,7 +237,8 @@ class Audiobook { | |||||||
|       audioFiles: this._audioFiles.map(audioFile => audioFile.toJSON()), |       audioFiles: this._audioFiles.map(audioFile => audioFile.toJSON()), | ||||||
|       otherFiles: this._otherFiles.map(otherFile => otherFile.toJSON()), |       otherFiles: this._otherFiles.map(otherFile => otherFile.toJSON()), | ||||||
|       ebooks: this.ebooks.map(ebook => ebook.toJSON()), |       ebooks: this.ebooks.map(ebook => ebook.toJSON()), | ||||||
|       numEbooks: this.hasEpub ? 1 : 0, |       numEbooks: (this.hasEpub || this.hasMobi || this.hasPdf) ? 1 : 0, | ||||||
|  |       numTracks: this.tracks.length, | ||||||
|       tags: this.tags, |       tags: this.tags, | ||||||
|       book: this.bookToJSON(), |       book: this.bookToJSON(), | ||||||
|       tracks: this.tracksToJSON(), |       tracks: this.tracksToJSON(), | ||||||
| @ -363,6 +368,19 @@ class Audiobook { | |||||||
|     this.book.setData(data) |     this.book.setData(data) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   setCoverFromFile(file) { | ||||||
|  |     if (!file || !file.fullPath || !file.path) { | ||||||
|  |       Logger.error(`[Audiobook] "${this.title}" Invalid file for setCoverFromFile`, file) | ||||||
|  |       return false | ||||||
|  |     } | ||||||
|  |     var updateBookPayload = {} | ||||||
|  |     updateBookPayload.coverFullPath = Path.normalize(file.fullPath) | ||||||
|  |     // Set ab local static path from file relative path
 | ||||||
|  |     var relImagePath = file.path.replace(this.path, '') | ||||||
|  |     updateBookPayload.cover = Path.normalize(Path.join(`/s/book/${this.id}`, relImagePath)) | ||||||
|  |     return this.book.update(updateBookPayload) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   addTrack(trackData) { |   addTrack(trackData) { | ||||||
|     var track = new AudioTrack() |     var track = new AudioTrack() | ||||||
|     track.setData(trackData) |     track.setData(trackData) | ||||||
|  | |||||||
| @ -132,12 +132,18 @@ class Book { | |||||||
|   update(payload) { |   update(payload) { | ||||||
|     var hasUpdates = false |     var hasUpdates = false | ||||||
| 
 | 
 | ||||||
|  |     // Normalize cover paths if passed
 | ||||||
|     if (payload.cover) { |     if (payload.cover) { | ||||||
|       // If updating to local cover then normalize path
 |  | ||||||
|       if (!payload.cover.startsWith('http:') && !payload.cover.startsWith('https:')) { |       if (!payload.cover.startsWith('http:') && !payload.cover.startsWith('https:')) { | ||||||
|         payload.cover = Path.normalize(payload.cover) |         payload.cover = Path.normalize(payload.cover) | ||||||
|         if (payload.coverFullPath) payload.coverFullPath = Path.normalize(payload.coverFullPath) |         if (payload.coverFullPath) payload.coverFullPath = Path.normalize(payload.coverFullPath) | ||||||
|  |         else { | ||||||
|  |           Logger.warn(`[Book] "${this.title}" updating book cover to "${payload.cover}" but no full path was passed`) | ||||||
|  |         } | ||||||
|       } |       } | ||||||
|  |     } else if (payload.coverFullPath) { | ||||||
|  |       Logger.warn(`[Book] "${this.title}" updating book full cover path to "${payload.coverFullPath}" but no relative path was passed`) | ||||||
|  |       payload.coverFullPath = Path.normalize(payload.coverFullPath) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     for (const key in payload) { |     for (const key in payload) { | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user