mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-11-03 19:07:00 -05:00 
			
		
		
		
	Add: Experimental list view #149
This commit is contained in:
		
							parent
							
								
									6fd3317454
								
							
						
					
					
						commit
						729654f5b2
					
				@ -19,6 +19,9 @@
 | 
			
		||||
::-webkit-scrollbar {
 | 
			
		||||
  width: 8px;
 | 
			
		||||
}
 | 
			
		||||
::-webkit-scrollbar:horizontal {
 | 
			
		||||
  height: 8px;
 | 
			
		||||
}
 | 
			
		||||
/* ::-webkit-scrollbar:horizontal { */
 | 
			
		||||
  /* height: 16px; */
 | 
			
		||||
  /* height: 24px;
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										127
									
								
								client/components/app/BookList.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								client/components/app/BookList.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,127 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="outer-container">
 | 
			
		||||
    <!-- absolute positioned container -->
 | 
			
		||||
    <div class="inner-container">
 | 
			
		||||
      <div class="relative h-10">
 | 
			
		||||
        <div class="table-header" id="headerdiv">
 | 
			
		||||
          <table id="headertable" width="100%" cellpadding="0" cellspacing="0">
 | 
			
		||||
            <thead>
 | 
			
		||||
              <tr>
 | 
			
		||||
                <th class="header-cell min-w-12 max-w-12"></th>
 | 
			
		||||
                <th class="header-cell min-w-6 max-w-6"></th>
 | 
			
		||||
                <th class="header-cell min-w-64 max-w-64 px-2">Title</th>
 | 
			
		||||
                <th class="header-cell min-w-48 max-w-48 px-2">Author</th>
 | 
			
		||||
                <th class="header-cell min-w-48 max-w-48 px-2">Series</th>
 | 
			
		||||
                <th class="header-cell min-w-24 max-w-24 px-2">Year</th>
 | 
			
		||||
                <th class="header-cell min-w-80 max-w-80 px-2">Description</th>
 | 
			
		||||
                <th class="header-cell min-w-48 max-w-48 px-2">Narrator</th>
 | 
			
		||||
                <th class="header-cell min-w-48 max-w-48 px-2">Genres</th>
 | 
			
		||||
                <th class="header-cell min-w-48 max-w-48 px-2">Tags</th>
 | 
			
		||||
                <th class="header-cell min-w-24 max-w-24 px-2"></th>
 | 
			
		||||
              </tr>
 | 
			
		||||
            </thead>
 | 
			
		||||
          </table>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="absolute top-0 left-0 w-full h-full pointer-events-none" :class="isScrollable ? 'header-shadow' : ''" />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div ref="tableBody" class="table-body" onscroll="document.getElementById('headerdiv').scrollLeft = this.scrollLeft;" @scroll="tableScrolled">
 | 
			
		||||
        <table id="bodytable" width="100%" cellpadding="0" cellspacing="0">
 | 
			
		||||
          <tbody>
 | 
			
		||||
            <template v-for="book in books">
 | 
			
		||||
              <app-book-list-row :key="book.id" :book="book" @edit="editBook" />
 | 
			
		||||
            </template>
 | 
			
		||||
          </tbody>
 | 
			
		||||
        </table>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
export default {
 | 
			
		||||
  props: {
 | 
			
		||||
    books: {
 | 
			
		||||
      type: Array,
 | 
			
		||||
      default: () => []
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      isScrollable: false
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  computed: {},
 | 
			
		||||
  methods: {
 | 
			
		||||
    checkIsScrolled() {
 | 
			
		||||
      if (!this.$refs.tableBody) return
 | 
			
		||||
      this.isScrollable = this.$refs.tableBody.scrollTop > 0
 | 
			
		||||
    },
 | 
			
		||||
    tableScrolled() {
 | 
			
		||||
      this.checkIsScrolled()
 | 
			
		||||
    },
 | 
			
		||||
    editBook(book) {
 | 
			
		||||
      var bookIds = this.books.map((e) => e.id)
 | 
			
		||||
      this.$store.commit('setBookshelfBookIds', bookIds)
 | 
			
		||||
      this.$store.commit('showEditModal', book)
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  mounted() {
 | 
			
		||||
    this.checkIsScrolled()
 | 
			
		||||
  },
 | 
			
		||||
  beforeDestroy() {}
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
.outer-container {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 0;
 | 
			
		||||
  left: 0;
 | 
			
		||||
  overflow: visible;
 | 
			
		||||
  height: calc(100% - 50px);
 | 
			
		||||
  width: calc(100% - 10px);
 | 
			
		||||
  margin: 10px;
 | 
			
		||||
}
 | 
			
		||||
.inner-container {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  position: relative;
 | 
			
		||||
}
 | 
			
		||||
.table-header {
 | 
			
		||||
  float: left;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
.header-shadow {
 | 
			
		||||
  box-shadow: 3px 8px 3px #11111155;
 | 
			
		||||
}
 | 
			
		||||
.table-body {
 | 
			
		||||
  float: left;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  width: inherit;
 | 
			
		||||
  overflow-y: scroll;
 | 
			
		||||
  padding-right: 0px;
 | 
			
		||||
}
 | 
			
		||||
.header-cell {
 | 
			
		||||
  background-color: #22222288;
 | 
			
		||||
  padding: 0px 4px;
 | 
			
		||||
  text-align: left;
 | 
			
		||||
  height: 40px;
 | 
			
		||||
  font-size: 0.9rem;
 | 
			
		||||
  font-weight: semi-bold;
 | 
			
		||||
}
 | 
			
		||||
.body-cell {
 | 
			
		||||
  text-align: left;
 | 
			
		||||
  font-size: 0.9rem;
 | 
			
		||||
}
 | 
			
		||||
.book-row {
 | 
			
		||||
  background-color: #22222288;
 | 
			
		||||
}
 | 
			
		||||
.book-row:nth-child(odd) {
 | 
			
		||||
  background-color: #333;
 | 
			
		||||
}
 | 
			
		||||
.book-row.selected {
 | 
			
		||||
  background-color: rgba(0, 255, 0, 0.05);
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										184
									
								
								client/components/app/BookListRow.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								client/components/app/BookListRow.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,184 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <tr class="book-row" :class="selected ? 'selected' : ''">
 | 
			
		||||
    <td class="body-cell min-w-12 max-w-12">
 | 
			
		||||
      <div class="flex justify-center">
 | 
			
		||||
        <div class="bg-white border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center focus-within:border-blue-500 w-4 h-4" @click="selectBtnClick">
 | 
			
		||||
          <svg v-if="selected" class="fill-current text-green-500 pointer-events-none w-2.5 h-2.5" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </td>
 | 
			
		||||
    <td class="body-cell min-w-6 max-w-6">
 | 
			
		||||
      <cards-book-cover :width="24" :audiobook="book" />
 | 
			
		||||
    </td>
 | 
			
		||||
    <td class="body-cell min-w-64 max-w-64 px-2">
 | 
			
		||||
      <p class="truncate">
 | 
			
		||||
        {{ book.book.title }}<span v-if="book.book.subtitle">: {{ book.book.subtitle }}</span>
 | 
			
		||||
      </p>
 | 
			
		||||
    </td>
 | 
			
		||||
    <td class="body-cell min-w-48 max-w-48 px-2">
 | 
			
		||||
      <p class="truncate">{{ book.book.authorFL }}</p>
 | 
			
		||||
    </td>
 | 
			
		||||
    <td class="body-cell min-w-48 max-w-48 px-2">
 | 
			
		||||
      <p class="truncate">{{ seriesText }}</p>
 | 
			
		||||
    </td>
 | 
			
		||||
    <td class="body-cell min-w-24 max-w-24 px-2">
 | 
			
		||||
      <p class="truncate">{{ book.book.publishYear }}</p>
 | 
			
		||||
    </td>
 | 
			
		||||
    <td class="body-cell min-w-80 max-w-80 px-2">
 | 
			
		||||
      <p class="truncate">{{ book.book.description }}</p>
 | 
			
		||||
    </td>
 | 
			
		||||
    <td class="body-cell min-w-48 max-w-48 px-2">
 | 
			
		||||
      <p class="truncate">{{ book.book.narrator }}</p>
 | 
			
		||||
    </td>
 | 
			
		||||
    <td class="body-cell min-w-48 max-w-48 px-2">
 | 
			
		||||
      <p class="truncate">{{ genresText }}</p>
 | 
			
		||||
    </td>
 | 
			
		||||
    <td class="body-cell min-w-48 max-w-48 px-2">
 | 
			
		||||
      <p class="truncate">{{ tagsText }}</p>
 | 
			
		||||
    </td>
 | 
			
		||||
    <td class="body-cell min-w-24 max-w-24 px-2">
 | 
			
		||||
      <div class="flex">
 | 
			
		||||
        <span v-if="userCanUpdate" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="editClick">edit</span>
 | 
			
		||||
        <span v-if="showPlayButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-2xl mx-1" @click="startStream">play_arrow</span>
 | 
			
		||||
        <span v-if="showReadButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="openEbook">auto_stories</span>
 | 
			
		||||
      </div>
 | 
			
		||||
    </td>
 | 
			
		||||
  </tr>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
export default {
 | 
			
		||||
  props: {
 | 
			
		||||
    book: {
 | 
			
		||||
      type: Object,
 | 
			
		||||
      default: () => {}
 | 
			
		||||
    },
 | 
			
		||||
    userAudiobook: {
 | 
			
		||||
      type: Object,
 | 
			
		||||
      default: () => {}
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      isProcessingReadUpdate: false
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    showExperimentalFeatures() {
 | 
			
		||||
      return this.$store.state.showExperimentalFeatures
 | 
			
		||||
    },
 | 
			
		||||
    audiobookId() {
 | 
			
		||||
      return this.book.id
 | 
			
		||||
    },
 | 
			
		||||
    isSelectionMode() {
 | 
			
		||||
      return !!this.selectedAudiobooks.length
 | 
			
		||||
    },
 | 
			
		||||
    selectedAudiobooks() {
 | 
			
		||||
      return this.$store.state.selectedAudiobooks
 | 
			
		||||
    },
 | 
			
		||||
    selected: {
 | 
			
		||||
      get() {
 | 
			
		||||
        return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
 | 
			
		||||
      },
 | 
			
		||||
      set(val) {
 | 
			
		||||
        if (this.processingBatch) return
 | 
			
		||||
        this.$store.commit('setAudiobookSelected', { audiobookId: this.audiobookId, selected: val })
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    processingBatch() {
 | 
			
		||||
      return this.$store.state.processingBatch
 | 
			
		||||
    },
 | 
			
		||||
    bookObj() {
 | 
			
		||||
      return this.book.book || {}
 | 
			
		||||
    },
 | 
			
		||||
    series() {
 | 
			
		||||
      return this.bookObj.series || null
 | 
			
		||||
    },
 | 
			
		||||
    volumeNumber() {
 | 
			
		||||
      return this.bookObj.volumeNumber || null
 | 
			
		||||
    },
 | 
			
		||||
    seriesText() {
 | 
			
		||||
      if (!this.series) return ''
 | 
			
		||||
      if (!this.volumeNumber) return this.series
 | 
			
		||||
      return `${this.series} #${this.volumeNumber}`
 | 
			
		||||
    },
 | 
			
		||||
    genresText() {
 | 
			
		||||
      if (!this.bookObj.genres) return ''
 | 
			
		||||
      return this.bookObj.genres.join(', ')
 | 
			
		||||
    },
 | 
			
		||||
    tagsText() {
 | 
			
		||||
      return (this.book.tags || []).join(', ')
 | 
			
		||||
    },
 | 
			
		||||
    isMissing() {
 | 
			
		||||
      return this.book.isMissing
 | 
			
		||||
    },
 | 
			
		||||
    isIncomplete() {
 | 
			
		||||
      return this.book.isIncomplete
 | 
			
		||||
    },
 | 
			
		||||
    numEbooks() {
 | 
			
		||||
      return this.book.numEbooks
 | 
			
		||||
    },
 | 
			
		||||
    numTracks() {
 | 
			
		||||
      return this.book.numTracks
 | 
			
		||||
    },
 | 
			
		||||
    isStreaming() {
 | 
			
		||||
      return this.$store.getters['getAudiobookIdStreaming'] === this.audiobookId
 | 
			
		||||
    },
 | 
			
		||||
    showReadButton() {
 | 
			
		||||
      return this.showExperimentalFeatures && this.numEbooks
 | 
			
		||||
    },
 | 
			
		||||
    showPlayButton() {
 | 
			
		||||
      return !this.isMissing && !this.isIncomplete && this.numTracks && !this.isStreaming
 | 
			
		||||
    },
 | 
			
		||||
    userIsRead() {
 | 
			
		||||
      return this.userAudiobook ? !!this.userAudiobook.isRead : false
 | 
			
		||||
    },
 | 
			
		||||
    userCanUpdate() {
 | 
			
		||||
      return this.$store.getters['user/getUserCanUpdate']
 | 
			
		||||
    },
 | 
			
		||||
    userCanDelete() {
 | 
			
		||||
      return this.$store.getters['user/getUserCanDelete']
 | 
			
		||||
    },
 | 
			
		||||
    userCanDownload() {
 | 
			
		||||
      return this.$store.getters['user/getUserCanDownload']
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    selectBtnClick() {
 | 
			
		||||
      if (this.processingBatch) return
 | 
			
		||||
      this.$store.commit('toggleAudiobookSelected', this.audiobookId)
 | 
			
		||||
    },
 | 
			
		||||
    openEbook() {
 | 
			
		||||
      this.$store.commit('showEReader', this.book)
 | 
			
		||||
    },
 | 
			
		||||
    downloadClick() {
 | 
			
		||||
      this.$store.commit('showEditModalOnTab', { audiobook: this.book, tab: 'download' })
 | 
			
		||||
    },
 | 
			
		||||
    toggleRead() {
 | 
			
		||||
      var updatePayload = {
 | 
			
		||||
        isRead: !this.userIsRead
 | 
			
		||||
      }
 | 
			
		||||
      this.isProcessingReadUpdate = true
 | 
			
		||||
      this.$axios
 | 
			
		||||
        .$patch(`/api/user/audiobook/${this.audiobookId}`, updatePayload)
 | 
			
		||||
        .then(() => {
 | 
			
		||||
          this.isProcessingReadUpdate = false
 | 
			
		||||
          this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
 | 
			
		||||
        })
 | 
			
		||||
        .catch((error) => {
 | 
			
		||||
          console.error('Failed', error)
 | 
			
		||||
          this.isProcessingReadUpdate = false
 | 
			
		||||
          this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
 | 
			
		||||
        })
 | 
			
		||||
    },
 | 
			
		||||
    startStream() {
 | 
			
		||||
      this.$store.commit('setStreamAudiobook', this.book)
 | 
			
		||||
      this.$root.socket.emit('open_stream', this.book.id)
 | 
			
		||||
    },
 | 
			
		||||
    editClick() {
 | 
			
		||||
      this.$emit('edit', this.book)
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  mounted() {}
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
@ -1,57 +1,56 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
 | 
			
		||||
    <!-- Cover size widget -->
 | 
			
		||||
    <div v-show="!isSelectionMode && isGridMode" class="fixed bottom-2 right-4 z-30">
 | 
			
		||||
      <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>
 | 
			
		||||
        <p class="px-2 font-mono">{{ bookCoverWidth }}</p>
 | 
			
		||||
        <span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span>
 | 
			
		||||
  <div class="bookshelf overflow-hidden relative block max-h-full">
 | 
			
		||||
    <div ref="wrapper" class="h-full w-full relative" :class="isGridMode ? 'overflow-y-scroll' : 'overflow-hidden'">
 | 
			
		||||
      <!-- Cover size widget -->
 | 
			
		||||
      <div v-show="!isSelectionMode && isGridMode" class="fixed bottom-2 right-4 z-30">
 | 
			
		||||
        <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>
 | 
			
		||||
          <p class="px-2 font-mono">{{ bookCoverWidth }}</p>
 | 
			
		||||
          <span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
 | 
			
		||||
      <p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
 | 
			
		||||
      <div class="flex">
 | 
			
		||||
        <ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn>
 | 
			
		||||
        <ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
 | 
			
		||||
      <div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
 | 
			
		||||
        <p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
 | 
			
		||||
        <div class="flex">
 | 
			
		||||
          <ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn>
 | 
			
		||||
          <ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div v-else-if="page === 'search'" id="bookshelf-categorized" class="w-full flex flex-col items-center">
 | 
			
		||||
      <template v-for="(shelf, index) in categorizedShelves">
 | 
			
		||||
        <app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" />
 | 
			
		||||
      </template>
 | 
			
		||||
      <div v-show="!categorizedShelves.length" class="w-full py-16 text-center text-xl">
 | 
			
		||||
        <div class="py-4 mb-6"><p class="text-2xl">No Results</p></div>
 | 
			
		||||
      <div v-else-if="page === 'search'" id="bookshelf-categorized" class="w-full flex flex-col items-center">
 | 
			
		||||
        <template v-for="(shelf, index) in categorizedShelves">
 | 
			
		||||
          <app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" />
 | 
			
		||||
        </template>
 | 
			
		||||
        <div v-show="!categorizedShelves.length" class="w-full py-16 text-center text-xl">
 | 
			
		||||
          <div class="py-4 mb-6"><p class="text-2xl">No Results</p></div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div v-else id="bookshelf" class="w-full flex flex-col items-center">
 | 
			
		||||
      <template v-if="viewMode === 'grid'">
 | 
			
		||||
        <template v-for="(shelf, index) in shelves">
 | 
			
		||||
          <div :key="index" class="w-full bookshelfRow relative">
 | 
			
		||||
            <div class="flex justify-center items-center">
 | 
			
		||||
              <template v-for="entity in shelf">
 | 
			
		||||
                <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-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @edit="editBook" />
 | 
			
		||||
              </template>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
 | 
			
		||||
      <div v-else class="w-full">
 | 
			
		||||
        <template v-if="viewMode === 'grid'">
 | 
			
		||||
          <div class="w-full flex flex-col items-center">
 | 
			
		||||
            <template v-for="(shelf, index) in shelves">
 | 
			
		||||
              <div :key="index" class="w-full bookshelfRow relative">
 | 
			
		||||
                <div class="flex justify-center items-center">
 | 
			
		||||
                  <template v-for="entity in shelf">
 | 
			
		||||
                    <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-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @edit="editBook" />
 | 
			
		||||
                  </template>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
 | 
			
		||||
              </div>
 | 
			
		||||
            </template>
 | 
			
		||||
          </div>
 | 
			
		||||
        </template>
 | 
			
		||||
      </template>
 | 
			
		||||
      <template v-else>
 | 
			
		||||
        <template v-for="(entity, index) in entities">
 | 
			
		||||
          <div :key="index" class="w-full bookshelfRow relative">
 | 
			
		||||
            <app-bookshelf-list-row :book-item="entity" :book-cover-width="bookCoverWidth" :user-audiobook="userAudiobooks[entity.id]" />
 | 
			
		||||
            <div class="bookshelfDivider h-3 w-full absolute bottom-0 left-0 right-0 z-10" />
 | 
			
		||||
          </div>
 | 
			
		||||
        <template v-else>
 | 
			
		||||
          <app-book-list :books="entities" />
 | 
			
		||||
        </template>
 | 
			
		||||
      </template>
 | 
			
		||||
      <div v-show="!shelves.length" class="w-full py-16 text-center text-xl">
 | 
			
		||||
        <div v-if="page === 'search'" class="py-4 mb-6"><p class="text-2xl">No Results</p></div>
 | 
			
		||||
        <div v-else class="py-4">No {{ showGroups ? 'Series' : 'Audiobooks' }}</div>
 | 
			
		||||
        <ui-btn v-if="!showGroups && (filterBy !== 'all' || keywordFilter)" @click="clearFilter">Clear Filter</ui-btn>
 | 
			
		||||
        <ui-btn v-else-if="page === 'search'" to="/library">Back to Library</ui-btn>
 | 
			
		||||
        <div v-show="!shelves.length" class="w-full py-16 text-center text-xl">
 | 
			
		||||
          <div v-if="page === 'search'" class="py-4 mb-6"><p class="text-2xl">No Results</p></div>
 | 
			
		||||
          <div v-else class="py-4">No {{ showGroups ? 'Series' : 'Audiobooks' }}</div>
 | 
			
		||||
          <ui-btn v-if="!showGroups && (filterBy !== 'all' || keywordFilter)" @click="clearFilter">Clear Filter</ui-btn>
 | 
			
		||||
          <ui-btn v-else-if="page === 'search'" to="/library">Back to Library</ui-btn>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
@ -122,7 +121,6 @@ export default {
 | 
			
		||||
      return this.bookCoverWidth / 120
 | 
			
		||||
    },
 | 
			
		||||
    bookCoverWidth() {
 | 
			
		||||
      if (this.viewMode === 'list') return 60
 | 
			
		||||
      var coverWidth = this.availableSizes[this.selectedSizeIndex]
 | 
			
		||||
      return coverWidth
 | 
			
		||||
    },
 | 
			
		||||
@ -363,8 +361,9 @@ export default {
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
#bookshelf {
 | 
			
		||||
.bookshelf {
 | 
			
		||||
  height: calc(100% - 40px);
 | 
			
		||||
  width: calc(100vw - 80px);
 | 
			
		||||
}
 | 
			
		||||
.bookshelfRow {
 | 
			
		||||
  background-image: url(/wood_panels.jpg);
 | 
			
		||||
 | 
			
		||||
@ -1,216 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div :class="selected ? 'bg-success bg-opacity-10' : ''">
 | 
			
		||||
    <div class="flex px-12 mx-auto" style="max-width: 1400px">
 | 
			
		||||
      <div class="w-12 h-full flex items-center justify-center self-center">
 | 
			
		||||
        <ui-checkbox v-model="selected" />
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="p-3">
 | 
			
		||||
        <cards-book-cover :width="bookCoverWidth" :audiobook="bookItem" />
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="flex-grow p-3">
 | 
			
		||||
        <div class="flex h-full">
 | 
			
		||||
          <div class="w-full max-w-xl">
 | 
			
		||||
            <nuxt-link :to="`/audiobook/${audiobookId}`" class="flex items-center hover:underline">
 | 
			
		||||
              <p class="text-base font-book">{{ title }}<span v-if="subtitle">:</span></p>
 | 
			
		||||
              <p class="text-base font-book pl-2 text-gray-200">{{ subtitle }}</p>
 | 
			
		||||
            </nuxt-link>
 | 
			
		||||
            <p class="text-gray-200 text-sm" v-if="seriesText">{{ seriesText }}</p>
 | 
			
		||||
            <p class="text-sm text-gray-300">{{ author }}</p>
 | 
			
		||||
            <div class="flex pt-2">
 | 
			
		||||
              <div class="rounded-full bg-black bg-opacity-50 px-2 py-px text-xs text-gray-200">
 | 
			
		||||
                <p>{{ numTracks }} Tracks</p>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div class="rounded-full bg-black bg-opacity-50 px-2 py-px text-xs text-gray-200 mx-2">
 | 
			
		||||
                <p>{{ durationPretty }}</p>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div class="rounded-full bg-black bg-opacity-50 px-2 py-px text-xs text-gray-200">
 | 
			
		||||
                <p>{{ sizePretty }}</p>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="w-full max-w-xl pr-6 pl-12 items-center h-full pb-3 hidden xl:flex">
 | 
			
		||||
            <p class="text-sm text-gray-200 max-3-lines">{{ description }}</p>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="w-32 h-full self-center">
 | 
			
		||||
        <div class="flex justify-center mb-2">
 | 
			
		||||
          <ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9" @click="startStream">
 | 
			
		||||
            <span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
 | 
			
		||||
            {{ streaming ? 'Streaming' : 'Play' }}
 | 
			
		||||
          </ui-btn>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="flex">
 | 
			
		||||
          <ui-tooltip v-if="userCanUpdate" text="Edit" direction="top">
 | 
			
		||||
            <ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
 | 
			
		||||
          </ui-tooltip>
 | 
			
		||||
 | 
			
		||||
          <ui-tooltip v-if="userCanDownload" :disabled="isMissing" text="Download" direction="top">
 | 
			
		||||
            <ui-icon-btn icon="download" :disabled="isMissing" class="mx-0.5" @click="downloadClick" />
 | 
			
		||||
          </ui-tooltip>
 | 
			
		||||
 | 
			
		||||
          <ui-tooltip :text="userIsRead ? 'Mark as Not Read' : 'Mark as Read'" direction="top">
 | 
			
		||||
            <ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsRead" class="mx-0.5" @click="toggleRead" />
 | 
			
		||||
          </ui-tooltip>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
export default {
 | 
			
		||||
  props: {
 | 
			
		||||
    bookItem: {
 | 
			
		||||
      type: Object,
 | 
			
		||||
      default: () => {}
 | 
			
		||||
    },
 | 
			
		||||
    userAudiobook: {
 | 
			
		||||
      type: Object,
 | 
			
		||||
      default: () => {}
 | 
			
		||||
    },
 | 
			
		||||
    bookCoverWidth: Number
 | 
			
		||||
  },
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      isProcessingReadUpdate: false
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    audiobookId() {
 | 
			
		||||
      return this.bookItem.id
 | 
			
		||||
    },
 | 
			
		||||
    isSelectionMode() {
 | 
			
		||||
      return !!this.selectedAudiobooks.length
 | 
			
		||||
    },
 | 
			
		||||
    selectedAudiobooks() {
 | 
			
		||||
      return this.$store.state.selectedAudiobooks
 | 
			
		||||
    },
 | 
			
		||||
    selected: {
 | 
			
		||||
      get() {
 | 
			
		||||
        return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
 | 
			
		||||
      },
 | 
			
		||||
      set(val) {
 | 
			
		||||
        if (this.processingBatch) return
 | 
			
		||||
        this.$store.commit('setAudiobookSelected', { audiobookId: this.audiobookId, selected: val })
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    processingBatch() {
 | 
			
		||||
      return this.$store.state.processingBatch
 | 
			
		||||
    },
 | 
			
		||||
    isMissing() {
 | 
			
		||||
      return this.bookItem.isMissing
 | 
			
		||||
    },
 | 
			
		||||
    isIncomplete() {
 | 
			
		||||
      return this.bookItem.isIncomplete
 | 
			
		||||
    },
 | 
			
		||||
    numTracks() {
 | 
			
		||||
      return this.bookItem.numTracks
 | 
			
		||||
    },
 | 
			
		||||
    durationPretty() {
 | 
			
		||||
      return this.$elapsedPretty(this.bookItem.duration)
 | 
			
		||||
    },
 | 
			
		||||
    sizePretty() {
 | 
			
		||||
      return this.$bytesPretty(this.bookItem.size)
 | 
			
		||||
    },
 | 
			
		||||
    streamAudiobook() {
 | 
			
		||||
      return this.$store.state.streamAudiobook
 | 
			
		||||
    },
 | 
			
		||||
    streaming() {
 | 
			
		||||
      return this.streamAudiobook && this.streamAudiobook.id === this.audiobookId
 | 
			
		||||
    },
 | 
			
		||||
    book() {
 | 
			
		||||
      return this.bookItem.book || {}
 | 
			
		||||
    },
 | 
			
		||||
    title() {
 | 
			
		||||
      return this.book.title
 | 
			
		||||
    },
 | 
			
		||||
    subtitle() {
 | 
			
		||||
      return this.book.subtitle
 | 
			
		||||
    },
 | 
			
		||||
    series() {
 | 
			
		||||
      return this.book.series || null
 | 
			
		||||
    },
 | 
			
		||||
    volumeNumber() {
 | 
			
		||||
      return this.book.volumeNumber || null
 | 
			
		||||
    },
 | 
			
		||||
    seriesText() {
 | 
			
		||||
      if (!this.series) return ''
 | 
			
		||||
      if (!this.volumeNumber) return this.series
 | 
			
		||||
      return `${this.series} #${this.volumeNumber}`
 | 
			
		||||
    },
 | 
			
		||||
    description() {
 | 
			
		||||
      return this.book.description
 | 
			
		||||
    },
 | 
			
		||||
    author() {
 | 
			
		||||
      return this.book.authorFL
 | 
			
		||||
    },
 | 
			
		||||
    showPlayButton() {
 | 
			
		||||
      return !this.isMissing && !this.isIncomplete && this.numTracks
 | 
			
		||||
    },
 | 
			
		||||
    userCurrentTime() {
 | 
			
		||||
      return this.userAudiobook ? this.userAudiobook.currentTime : 0
 | 
			
		||||
    },
 | 
			
		||||
    userIsRead() {
 | 
			
		||||
      return this.userAudiobook ? !!this.userAudiobook.isRead : false
 | 
			
		||||
    },
 | 
			
		||||
    userCanUpdate() {
 | 
			
		||||
      return this.$store.getters['user/getUserCanUpdate']
 | 
			
		||||
    },
 | 
			
		||||
    userCanDelete() {
 | 
			
		||||
      return this.$store.getters['user/getUserCanDelete']
 | 
			
		||||
    },
 | 
			
		||||
    userCanDownload() {
 | 
			
		||||
      return this.$store.getters['user/getUserCanDownload']
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    selectBtnClick() {
 | 
			
		||||
      if (this.processingBatch) return
 | 
			
		||||
      this.$store.commit('toggleAudiobookSelected', this.audiobookId)
 | 
			
		||||
    },
 | 
			
		||||
    openEbook() {
 | 
			
		||||
      this.$store.commit('showEReader', this.bookItem)
 | 
			
		||||
    },
 | 
			
		||||
    downloadClick() {
 | 
			
		||||
      this.$store.commit('showEditModalOnTab', { audiobook: this.bookItem, tab: 'download' })
 | 
			
		||||
    },
 | 
			
		||||
    toggleRead() {
 | 
			
		||||
      var updatePayload = {
 | 
			
		||||
        isRead: !this.userIsRead
 | 
			
		||||
      }
 | 
			
		||||
      this.isProcessingReadUpdate = true
 | 
			
		||||
      this.$axios
 | 
			
		||||
        .$patch(`/api/user/audiobook/${this.audiobookId}`, updatePayload)
 | 
			
		||||
        .then(() => {
 | 
			
		||||
          this.isProcessingReadUpdate = false
 | 
			
		||||
          this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
 | 
			
		||||
        })
 | 
			
		||||
        .catch((error) => {
 | 
			
		||||
          console.error('Failed', error)
 | 
			
		||||
          this.isProcessingReadUpdate = false
 | 
			
		||||
          this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
 | 
			
		||||
        })
 | 
			
		||||
    },
 | 
			
		||||
    startStream() {
 | 
			
		||||
      this.$store.commit('setStreamAudiobook', this.bookItem)
 | 
			
		||||
      this.$root.socket.emit('open_stream', this.bookItem.id)
 | 
			
		||||
    },
 | 
			
		||||
    editClick() {
 | 
			
		||||
      this.$store.commit('setBookshelfBookIds', [])
 | 
			
		||||
      this.$store.commit('showEditModal', this.bookItem)
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  mounted() {}
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
.max-3-lines {
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  text-overflow: ellipsis;
 | 
			
		||||
  display: -webkit-box;
 | 
			
		||||
  -webkit-line-clamp: 3; /* number of lines to show */
 | 
			
		||||
  -webkit-box-orient: vertical;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@ -1,8 +1,8 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <label class="flex justify-start items-start">
 | 
			
		||||
    <div class="bg-white border-2 rounded border-gray-400 w-6 h-6 flex flex-shrink-0 justify-center items-center focus-within:border-blue-500">
 | 
			
		||||
    <div class="bg-white border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center focus-within:border-blue-500" :class="wrapperClass">
 | 
			
		||||
      <input v-model="selected" type="checkbox" class="opacity-0 absolute" />
 | 
			
		||||
      <svg v-if="selected" class="fill-current w-4 h-4 text-green-500 pointer-events-none" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
 | 
			
		||||
      <svg v-if="selected" class="fill-current text-green-500 pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div v-if="label" class="select-none">{{ label }}</div>
 | 
			
		||||
  </label>
 | 
			
		||||
@ -12,7 +12,8 @@
 | 
			
		||||
export default {
 | 
			
		||||
  props: {
 | 
			
		||||
    value: Boolean,
 | 
			
		||||
    label: Boolean
 | 
			
		||||
    label: Boolean,
 | 
			
		||||
    small: Boolean
 | 
			
		||||
  },
 | 
			
		||||
  data() {
 | 
			
		||||
    return {}
 | 
			
		||||
@ -25,6 +26,14 @@ export default {
 | 
			
		||||
      set(val) {
 | 
			
		||||
        this.$emit('input', !!val)
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    wrapperClass() {
 | 
			
		||||
      if (this.small) return 'w-4 h-4'
 | 
			
		||||
      return 'w-6 h-6'
 | 
			
		||||
    },
 | 
			
		||||
    svgClass() {
 | 
			
		||||
      if (this.small) return 'w-3 h-3'
 | 
			
		||||
      return 'w-4 h-4'
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {},
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "audiobookshelf-client",
 | 
			
		||||
  "version": "1.5.8",
 | 
			
		||||
  "version": "1.5.9",
 | 
			
		||||
  "description": "Audiobook manager and player",
 | 
			
		||||
  "main": "index.js",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
 | 
			
		||||
@ -17,6 +17,24 @@ module.exports = {
 | 
			
		||||
      height: {
 | 
			
		||||
        '7.5': '1.75rem'
 | 
			
		||||
      },
 | 
			
		||||
      maxWidth: {
 | 
			
		||||
        '6': '1.5rem',
 | 
			
		||||
        '12': '3rem',
 | 
			
		||||
        '24': '6rem',
 | 
			
		||||
        '32': '8rem',
 | 
			
		||||
        '48': '12rem',
 | 
			
		||||
        '64': '16rem',
 | 
			
		||||
        '80': '20rem'
 | 
			
		||||
      },
 | 
			
		||||
      minWidth: {
 | 
			
		||||
        '6': '1.5rem',
 | 
			
		||||
        '12': '3rem',
 | 
			
		||||
        '24': '6rem',
 | 
			
		||||
        '32': '8rem',
 | 
			
		||||
        '48': '12rem',
 | 
			
		||||
        '64': '16rem',
 | 
			
		||||
        '80': '20rem'
 | 
			
		||||
      },
 | 
			
		||||
      spacing: {
 | 
			
		||||
        '-54': '-13.5rem'
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "audiobookshelf",
 | 
			
		||||
  "version": "1.5.8",
 | 
			
		||||
  "version": "1.5.9",
 | 
			
		||||
  "description": "Self-hosted audiobook server for managing and playing audiobooks",
 | 
			
		||||
  "main": "index.js",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user