Add:Lazy bookshelf

This commit is contained in:
advplyr 2021-12-04 19:56:29 -06:00
parent 446c6756ed
commit 37d3021302
43 changed files with 2264 additions and 666 deletions

View file

@ -22,8 +22,6 @@
<!-- <span class="material-icons cursor-pointer mx-4" :class="hasDownloadsFolder ? '' : 'text-warning'" @click="$store.commit('downloads/setShowModal', true)">source</span> -->
<!-- <widgets-connection-icon /> -->
<nuxt-link class="h-7 mx-2" to="/search">
<span class="material-icons" style="font-size: 1.75rem">search</span>
</nuxt-link>

View file

@ -54,7 +54,7 @@ export default {
var booksPerShelf = Math.floor(this.pageWidth / (this.cardWidth + 32))
var groupedBooks = []
var audiobooksSorted = this.$store.getters['audiobooks/getFilteredAndSorted']()
var audiobooksSorted = []
this.currFilterOrderKey = this.filterOrderKey
var numGroups = Math.ceil(audiobooksSorted.length / booksPerShelf)
@ -86,7 +86,6 @@ export default {
if (currentLibrary) {
this.$store.commit('libraries/setCurrentLibrary', currentLibrary.id)
}
this.$store.dispatch('audiobooks/load')
},
socketConnected(isConnected) {
if (isConnected) {

View file

@ -51,9 +51,7 @@ export default {
mobileFilterBy: 'all'
})
},
calcShelves() {
this.audiobooks = this.$store.getters['audiobooks/getFilteredAndSorted']()
},
calcShelves() {},
audiobooksUpdated() {
this.calcShelves()
},
@ -76,7 +74,6 @@ export default {
if (currentLibrary) {
this.$store.commit('libraries/setCurrentLibrary', currentLibrary.id)
}
this.$store.dispatch('audiobooks/load')
},
socketConnected(isConnected) {
if (isConnected) {

View file

@ -28,15 +28,25 @@
</template>
<script>
import TouchEvent from '@/objects/TouchEvent'
export default {
data() {
return {}
return {
touchEvent: null
}
},
watch: {
$route: {
handler() {
this.show = false
}
},
show: {
handler(newVal) {
if (newVal) this.registerListener()
else this.removeListener()
}
}
},
computed: {
@ -106,6 +116,25 @@ export default {
this.$store.commit('audiobooks/reset')
this.$store.dispatch('audiobooks/useDownloaded')
},
touchstart(e) {
this.touchEvent = new TouchEvent(e)
},
touchend(e) {
if (!this.touchEvent) return
this.touchEvent.setEndEvent(e)
if (this.touchEvent.isSwipeRight()) {
this.show = false
}
this.touchEvent = null
},
registerListener() {
document.addEventListener('touchstart', this.touchstart)
document.addEventListener('touchend', this.touchend)
},
removeListener() {
document.removeEventListener('touchstart', this.touchstart)
document.removeEventListener('touchend', this.touchend)
}
},
mounted() {},

View file

@ -0,0 +1,400 @@
<template>
<div id="bookshelf" class="w-full max-w-full h-full min-h-screen">
<template v-for="shelf in totalShelves">
<div :key="shelf" class="w-full px-2 bookshelfRow relative" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
<div class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20" :class="`h-${shelfDividerHeightIndex}`" />
</div>
</template>
<!-- <div class="w-full h-full px-4 py-2" v-show="isListView">
<template v-for="book in books">
<app-bookshelf-list-row :key="book.id" :audiobook="book" :page-width="pageWidth" class="my-2" />
</template>
</div> -->
<div v-show="!entities.length && initialized" class="w-full py-16 text-center text-xl">
<div class="py-4 capitalize">No {{ entityName }}</div>
<ui-btn v-if="hasFilter" @click="clearFilter">Clear Filter</ui-btn>
</div>
</div>
</template>
<script>
import bookshelfCardsHelpers from '@/mixins/bookshelfCardsHelpers'
export default {
props: {
page: String,
seriesId: String
},
mixins: [bookshelfCardsHelpers],
data() {
return {
bookshelfHeight: 0,
bookshelfWidth: 0,
bookshelfMarginLeft: 0,
shelvesPerPage: 0,
entitiesPerShelf: 8,
currentPage: 0,
currentBookWidth: 0,
booksPerFetch: 20,
initialized: false,
currentSFQueryString: null,
isFetchingEntities: false,
entities: [],
totalEntities: 0,
totalShelves: 0,
entityComponentRefs: {},
entityIndexesMounted: [],
pagesLoaded: {},
isFirstInit: false,
pendingReset: false
}
},
computed: {
isSocketConnected() {
return this.$store.state.socketConnected
},
isBookEntity() {
return this.entityName === 'books' || this.entityName === 'series-books'
},
shelfDividerHeightIndex() {
if (this.isBookEntity) return 4
return 6
},
entityName() {
return this.page
},
bookshelfView() {
return this.$store.state.bookshelfView
},
hasFilter() {
return this.filterBy !== 'all'
},
isListView() {
return this.bookshelfView === 'list'
},
books() {
return this.$store.getters['downloads/getAudiobooks']
},
orderBy() {
return this.$store.getters['user/getUserSetting']('mobileOrderBy')
},
orderDesc() {
return this.$store.getters['user/getUserSetting']('mobileOrderDesc')
},
filterBy() {
return this.$store.getters['user/getUserSetting']('mobileFilterBy')
},
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
},
isCoverSquareAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
},
bookCoverAspectRatio() {
return this.isCoverSquareAspectRatio ? 1 : 1.6
},
bookWidth() {
// var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
var coverSize = 100
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
return coverSize
},
bookHeight() {
if (this.isCoverSquareAspectRatio) return this.bookWidth
return this.bookWidth * 1.6
},
entityWidth() {
if (this.isBookEntity) return this.bookWidth
return this.bookWidth * 2
},
entityHeight() {
return this.bookHeight
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
shelfHeight() {
return this.entityHeight + 40
},
totalEntityCardWidth() {
// Includes margin
return this.entityWidth + 24
},
downloadedBooks() {
return this.$store.getters['downloads/getAudiobooks']
}
},
methods: {
clearFilter() {
this.$store.dispatch('user/updateUserSettings', {
mobileFilterBy: 'all'
})
},
async fetchEntities(page) {
var startIndex = page * this.booksPerFetch
this.isFetchingEntities = true
if (!this.initialized) {
this.currentSFQueryString = this.buildSearchParams()
}
var entityPath = this.entityName === 'books' ? `books/all` : this.entityName
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
var queryString = `?${sfQueryString}&limit=${this.booksPerFetch}&page=${page}`
if (this.entityName === 'series-books') {
entityPath = `series/${this.seriesId}`
queryString = ''
}
var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${queryString}`).catch((error) => {
console.error('failed to fetch books', error)
return null
})
this.isFetchingEntities = false
if (this.pendingReset) {
this.pendingReset = false
this.resetEntities()
return
}
if (payload && payload.results) {
console.log('Received payload', payload)
if (!this.initialized) {
this.initialized = true
this.totalEntities = payload.total
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
this.entities = new Array(this.totalEntities)
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
}
for (let i = 0; i < payload.results.length; i++) {
var index = i + startIndex
this.entities[index] = payload.results[i]
if (this.entityComponentRefs[index]) {
this.entityComponentRefs[index].setEntity(this.entities[index])
}
}
}
},
async loadPage(page) {
this.pagesLoaded[page] = true
await this.fetchEntities(page)
},
mountEntites(fromIndex, toIndex) {
for (let i = fromIndex; i < toIndex; i++) {
if (!this.entityIndexesMounted.includes(i)) {
this.cardsHelpers.mountEntityCard(i)
}
}
},
handleScroll(scrollTop) {
this.currScrollTop = scrollTop
var firstShelfIndex = Math.floor(scrollTop / this.shelfHeight)
var lastShelfIndex = Math.ceil((scrollTop + this.bookshelfHeight) / this.shelfHeight)
lastShelfIndex = Math.min(this.totalShelves - 1, lastShelfIndex)
var firstBookIndex = firstShelfIndex * this.entitiesPerShelf
var lastBookIndex = lastShelfIndex * this.entitiesPerShelf + this.entitiesPerShelf
lastBookIndex = Math.min(this.totalEntities, lastBookIndex)
var firstBookPage = Math.floor(firstBookIndex / this.booksPerFetch)
var lastBookPage = Math.floor(lastBookIndex / this.booksPerFetch)
if (!this.pagesLoaded[firstBookPage]) {
console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex)
this.loadPage(firstBookPage)
}
if (!this.pagesLoaded[lastBookPage]) {
console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
this.loadPage(lastBookPage)
}
this.entityIndexesMounted = this.entityIndexesMounted.filter((_index) => {
if (_index < firstBookIndex || _index >= lastBookIndex) {
var el = document.getElementById(`book-card-${_index}`)
if (el) el.remove()
return false
}
return true
})
this.mountEntites(firstBookIndex, lastBookIndex)
},
destroyEntityComponents() {
for (const key in this.entityComponentRefs) {
if (this.entityComponentRefs[key] && this.entityComponentRefs[key].destroy) {
this.entityComponentRefs[key].destroy()
}
}
},
setDownloads() {
// TODO: Check entityName
this.entities = this.downloadedBooks.map((db) => ({ ...db }))
// TOOD: Sort and filter here
this.totalEntities = this.entities.length
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
this.entities = new Array(this.totalEntities)
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
},
async resetEntities() {
if (this.isFetchingEntities) {
this.pendingReset = true
return
}
this.destroyEntityComponents()
this.entityIndexesMounted = []
this.entityComponentRefs = {}
this.pagesLoaded = {}
this.entities = []
this.totalShelves = 0
this.totalEntities = 0
this.currentPage = 0
this.initialized = false
this.initSizeData()
if (this.isSocketConnected) {
await this.loadPage(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntites(0, lastBookIndex)
} else {
this.setDownloads()
this.mountEntites(0, this.totalEntities - 1)
}
},
initSizeData() {
var bookshelf = document.getElementById('bookshelf')
if (!bookshelf) {
console.error('Failed to init size data')
return
}
var entitiesPerShelfBefore = this.entitiesPerShelf
var { clientHeight, clientWidth } = bookshelf
this.bookshelfHeight = clientHeight
this.bookshelfWidth = clientWidth
this.entitiesPerShelf = Math.floor((this.bookshelfWidth - 16) / this.totalEntityCardWidth)
this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2
this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2
this.currentBookWidth = this.bookWidth
if (this.totalEntities) {
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
}
return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed
},
async init(bookshelf) {
if (this.isFirstInit) return
this.isFirstInit = true
this.initSizeData(bookshelf)
await this.loadPage(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntites(0, lastBookIndex)
},
scroll(e) {
if (!e || !e.target) return
if (!this.isSocketConnected) return // Offline books are all mounted at once
var { scrollTop } = e.target
this.handleScroll(scrollTop)
},
socketInit(isConnected) {
if (isConnected) {
this.init()
} else {
this.isFirstInit = false
this.resetEntities()
}
},
buildSearchParams() {
let searchParams = new URLSearchParams()
if (this.filterBy && this.filterBy !== 'all') {
searchParams.set('filter', this.filterBy)
}
if (this.orderBy) {
searchParams.set('sort', this.orderBy)
searchParams.set('desc', this.orderDesc ? 1 : 0)
}
return searchParams.toString()
},
checkUpdateSearchParams() {
var newSearchParams = this.buildSearchParams()
var currentQueryString = window.location.search
if (currentQueryString && currentQueryString.startsWith('?')) currentQueryString = currentQueryString.slice(1)
if (newSearchParams === '') {
return false
}
if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
window.history.replaceState({ path: newurl }, '', newurl)
return true
}
return false
},
settingsUpdated(settings) {
var wasUpdated = this.checkUpdateSearchParams()
if (wasUpdated) {
this.resetEntities()
}
},
libraryChanged(libid) {
if (this.hasFilter) {
this.clearFilter()
} else {
this.resetEntities()
}
},
initListeners() {
var bookshelf = document.getElementById('bookshelf-wrapper')
if (bookshelf) {
bookshelf.addEventListener('scroll', this.scroll)
}
// this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
// this.$eventBus.$on('bookshelf-select-all', this.selectAllEntities)
// this.$eventBus.$on('bookshelf-keyword-filter', this.updateKeywordFilter)
this.$eventBus.$on('library-changed', this.libraryChanged)
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
// if (this.$root.socket) {
// this.$root.socket.on('audiobook_updated', this.audiobookUpdated)
// this.$root.socket.on('audiobook_added', this.audiobookAdded)
// this.$root.socket.on('audiobook_removed', this.audiobookRemoved)
// this.$root.socket.on('audiobooks_updated', this.audiobooksUpdated)
// this.$root.socket.on('audiobooks_added', this.audiobooksAdded)
// } else {
// console.error('Bookshelf - Socket not initialized')
// }
},
removeListeners() {
var bookshelf = document.getElementById('bookshelf-wrapper')
if (bookshelf) {
bookshelf.removeEventListener('scroll', this.scroll)
}
this.$eventBus.$off('library-changed', this.libraryChanged)
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
// if (this.$root.socket) {
// this.$root.socket.off('audiobook_updated', this.audiobookUpdated)
// this.$root.socket.off('audiobook_added', this.audiobookAdded)
// this.$root.socket.off('audiobook_removed', this.audiobookRemoved)
// this.$root.socket.off('audiobooks_updated', this.audiobooksUpdated)
// this.$root.socket.off('audiobooks_added', this.audiobooksAdded)
// } else {
// console.error('Bookshelf - Socket not initialized')
// }
}
},
mounted() {
if (this.$server.initialized) {
this.init()
}
this.$server.on('initialized', this.socketInit)
this.initListeners()
},
beforeDestroy() {
this.$server.off('initialized', this.socketInit)
this.removeListeners()
}
}
</script>

View file

@ -1,8 +1,10 @@
<template>
<div class="w-full relative">
<div class="bookshelfRow h-44 flex items-end px-3 max-w-full overflow-x-auto">
<template v-for="book in books">
<cards-book-card :key="book.id" :audiobook="book" :width="100" class="mx-2" />
<div class="bookshelfRow flex items-end px-3 max-w-full overflow-x-auto" :style="{ height: shelfHeight + 'px' }">
<template v-for="(entity, index) in entities">
<!-- <cards-book-card v-if="type === 'books'" :key="entity.id" :audiobook="entity" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" class="mx-2" /> -->
<cards-lazy-book-card v-if="type === 'books'" :key="entity.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-lazy-series-card v-else-if="type === 'series'" :key="entity.id" :index="index" :series-mount="entity" :width="bookWidth * 2" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
</template>
</div>
@ -19,7 +21,8 @@
export default {
props: {
label: String,
books: {
type: String,
entities: {
type: Array,
default: () => []
}
@ -27,7 +30,26 @@ export default {
data() {
return {}
},
computed: {},
computed: {
shelfHeight() {
return this.entityHeight + 40
},
bookWidth() {
var coverSize = 100
if (this.bookCoverAspectRatio === 1) return coverSize * 1.6
return coverSize
},
bookHeight() {
if (this.bookCoverAspectRatio === 1) return this.bookWidth
return this.bookWidth * 1.6
},
entityHeight() {
return this.bookHeight
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
}
},
methods: {},
mounted() {}
}

View file

@ -0,0 +1,46 @@
<template>
<div class="flex h-full px-1 overflow-hidden shadow-sm">
<!-- <img src="/icons/NoUserPhoto.png" class="w-40 h-40 max-h-40 object-contain" style="max-height: 40px; max-width: 40px" /> -->
<div style="max-height: 48px; max-width: 48px" class="w-12 h-12 bg-primary overflow-hidden rounded">
<svg width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
<path
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
fill="white"
/>
<path
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
fill="white"
/>
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
</svg>
</div>
<div class="flex-grow px-2 authorSearchCardContent h-full">
<p class="truncate text-sm">{{ author }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
author: String
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style>
.authorSearchCardContent {
width: calc(100% - 50px);
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View file

@ -1,17 +1,9 @@
<template>
<div class="relative">
<!-- New Book Flag -->
<div v-if="isNew" class="absolute top-4 left-0 w-4 h-10 pr-2 bg-darkgreen box-shadow-xl">
<div class="absolute top-0 left-0 w-full h-full transform -rotate-90 flex items-center justify-center">
<p class="text-center text-sm">New</p>
</div>
<div class="absolute -bottom-4 left-0 triangle-right" />
</div>
<div class="rounded-sm h-full overflow-hidden relative box-shadow-book">
<nuxt-link :to="`/audiobook/${audiobookId}`" class="cursor-pointer">
<div class="w-full relative" :style="{ height: height + 'px' }">
<cards-book-cover :audiobook="audiobook" :download-cover="downloadCover" :author-override="authorFormat" :width="width" />
<covers-book-cover :audiobook="audiobook" :download-cover="downloadCover" :width="width" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div v-if="download" class="absolute" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }">
<span class="material-icons text-success" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">download_done</span>
@ -38,15 +30,13 @@ export default {
width: {
type: Number,
default: 140
}
},
bookCoverAspectRatio: Number
},
data() {
return {}
},
computed: {
isNew() {
return this.tags.includes('new')
},
tags() {
return this.audiobook.tags || []
},
@ -57,29 +47,11 @@ export default {
return this.audiobook.book || {}
},
height() {
return this.width * 1.6
return this.width * this.bookCoverAspectRatio
},
sizeMultiplier() {
return this.width / 120
},
paddingX() {
return 16 * this.sizeMultiplier
},
author() {
return this.book.author
},
authorFL() {
return this.book.authorFL || this.author
},
authorLF() {
return this.book.authorLF || this.author
},
authorFormat() {
if (!this.orderBy || !this.orderBy.startsWith('book.author')) return null
return this.orderBy === 'book.authorLF' ? this.authorLF : this.authorFL
},
orderBy() {
return this.$store.getters['user/getUserSetting']('mobileOrderBy')
if (this.bookCoverAspectRatio === 1) return this.width / 160
return this.width / 100
},
mostRecentUserProgress() {
return this.$store.getters['user/getUserAudiobookData'](this.audiobookId)

View file

@ -0,0 +1,89 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<covers-book-cover :audiobook="audiobook" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="flex-grow px-2 h-full audiobookSearchCardContent">
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
<p v-else class="truncate text-sm" v-html="matchHtml" />
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
<p v-if="matchKey !== 'authorFL'" class="text-xs text-gray-200 truncate">by {{ authorFL }}</p>
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags'" class="m-0 p-0 truncate" v-html="matchHtml" />
</div>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
},
search: String,
matchKey: String,
matchText: String
},
data() {
return {}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
coverWidth() {
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
return 50
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
title() {
return this.book ? this.book.title : 'No Title'
},
subtitle() {
return this.book ? this.book.subtitle : ''
},
authorFL() {
return this.book ? this.book.authorFL : 'Unknown'
},
matchHtml() {
if (!this.matchText || !this.search) return ''
if (this.matchKey === 'subtitle') return ''
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
if (matchSplit.length < 2) return ''
var html = ''
var totalLenSoFar = 0
for (let i = 0; i < matchSplit.length - 1; i++) {
var indexOf = matchSplit[i].length
var firstPart = this.matchText.substr(totalLenSoFar, indexOf)
var actualWasThere = this.matchText.substr(totalLenSoFar + indexOf, this.search.length)
totalLenSoFar += indexOf + this.search.length
html += `${firstPart}<strong class="text-warning">${actualWasThere}</strong>`
}
var lastPart = this.matchText.substr(totalLenSoFar)
html += lastPart
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
if (this.matchKey === 'authorFL') return `by ${html}`
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
return `${html}`
}
},
methods: {},
mounted() {}
}
</script>
<style>
.audiobookSearchCardContent {
width: calc(100% - 80px);
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View file

@ -0,0 +1,264 @@
<template>
<div ref="card" :id="`book-card-${index}`" :style="{ width: width + 'px', minWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 bg-primary cursor-pointer box-shadow-book" @click="clickCard">
<!-- When cover image does not fill -->
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
<div class="absolute cover-bg" ref="coverBg" />
</div>
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
<div v-show="audiobook && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p>
</div>
<img v-show="audiobook" ref="cover" :src="bookCoverSrc" class="w-full h-full object-contain transition-opacity duration-300" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<!-- Placeholder Cover Title & Author -->
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div>
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
</div>
</div>
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
</div>
</div>
<div class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<div v-if="showError" :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
</div>
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
index: Number,
width: {
type: Number,
default: 120
},
height: {
type: Number,
default: 192
},
bookCoverAspectRatio: Number,
showVolumeNumber: Boolean,
bookMount: {
type: Object,
default: () => null
}
},
data() {
return {
isHovering: false,
isMoreMenuOpen: false,
isProcessingReadUpdate: false,
audiobook: null,
imageReady: false,
rescanning: false,
selected: false,
isSelectionMode: false,
showCoverBg: false
}
},
computed: {
_audiobook() {
return this.audiobook || {}
},
placeholderUrl() {
return '/book_placeholder.jpg'
},
bookCoverSrc() {
return this.store.getters['audiobooks/getBookCoverSrc'](this._audiobook, this.placeholderUrl)
},
audiobookId() {
return this._audiobook.id
},
hasEbook() {
return this._audiobook.numEbooks
},
hasTracks() {
return this._audiobook.numTracks
},
book() {
return this._audiobook.book || {}
},
hasCover() {
return !!this.book.cover
},
squareAspectRatio() {
return this.bookCoverAspectRatio === 1
},
sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 192 : 120
return this.width / baseSize
},
title() {
return this.book.title || ''
},
author() {
return this.book.author
},
authorFL() {
return this.book.authorFL || this.author
},
authorLF() {
return this.book.authorLF || this.author
},
volumeNumber() {
return this.book.volumeNumber || null
},
userProgress() {
return this.store.getters['user/getUserAudiobook'](this.audiobookId)
},
userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0
},
userIsRead() {
return this.userProgress ? !!this.userProgress.isRead : false
},
showError() {
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
},
isStreaming() {
return this.store.getters['getAudiobookIdStreaming'] === this.audiobookId
},
isMissing() {
return this._audiobook.isMissing
},
isInvalid() {
return this._audiobook.isInvalid
},
hasMissingParts() {
return this._audiobook.hasMissingParts
},
hasInvalidParts() {
return this._audiobook.hasInvalidParts
},
errorText() {
if (this.isMissing) return 'Audiobook directory is missing!'
else if (this.isInvalid) return 'Audiobook has no audio tracks & ebook'
var txt = ''
if (this.hasMissingParts) {
txt = `${this.hasMissingParts} missing parts.`
}
if (this.hasInvalidParts) {
if (this.hasMissingParts) txt += ' '
txt += `${this.hasInvalidParts} invalid parts.`
}
return txt || 'Unknown Error'
},
store() {
return this.$store || this.$nuxt.$store
},
userCanUpdate() {
return this.store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.store.getters['user/getUserCanDelete']
},
userCanDownload() {
return this.store.getters['user/getUserCanDownload']
},
userIsRoot() {
return this.store.getters['user/getIsRoot']
},
_socket() {
return this.$root.socket || this.$nuxt.$root.socket
},
titleFontSize() {
return 0.75 * this.sizeMultiplier
},
authorFontSize() {
return 0.6 * this.sizeMultiplier
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
},
authorBottom() {
return 0.75 * this.sizeMultiplier
},
titleCleaned() {
if (!this.title) return ''
if (this.title.length > 60) {
return this.title.slice(0, 57) + '...'
}
return this.title
},
authorCleaned() {
if (!this.authorFL) return ''
if (this.authorFL.length > 30) {
return this.authorFL.slice(0, 27) + '...'
}
return this.authorFL
}
},
methods: {
setSelectionMode(val) {
this.isSelectionMode = val
if (!val) this.selected = false
},
setEntity(audiobook) {
this.audiobook = audiobook
},
clickCard(e) {
if (this.isSelectionMode) {
e.stopPropagation()
e.preventDefault()
this.selectBtnClick()
} else {
var router = this.$router || this.$nuxt.$router
if (router) router.push(`/audiobook/${this.audiobookId}`)
}
},
selectBtnClick() {
this.selected = !this.selected
this.$emit('select', this.audiobook)
},
destroy() {
// destroy the vue listeners, etc
this.$destroy()
// remove the element from the DOM
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
} else if (this.$el && this.$el.remove) {
this.$el.remove()
}
},
setCoverBg() {
if (this.$refs.coverBg) {
this.$refs.coverBg.style.backgroundImage = `url("${this.bookCoverSrc}")`
}
},
imageLoaded() {
this.imageReady = true
if (this.$refs.cover && this.bookCoverSrc !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
if (arDiff > 0.15) {
this.showCoverBg = true
this.$nextTick(this.setCoverBg)
} else {
this.showCoverBg = false
}
}
}
},
mounted() {
if (this.bookMount) {
this.setEntity(this.bookMount)
}
}
}
</script>

View file

@ -0,0 +1,82 @@
<template>
<div ref="card" :id="`collection-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm cursor-pointer z-30" @click="clickCard">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
index: Number,
width: Number,
height: Number,
bookCoverAspectRatio: Number
},
data() {
return {
collection: null,
isSelectionMode: false,
selected: false
}
},
computed: {
labelFontSize() {
if (this.width < 160) return 0.75
return 0.875
},
sizeMultiplier() {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240
},
title() {
return this.collection ? this.collection.name : ''
},
books() {
return this.collection ? this.collection.books || [] : []
},
store() {
return this.$store || this.$nuxt.$store
},
currentLibraryId() {
return this.store.state.libraries.currentLibraryId
}
},
methods: {
setEntity(_collection) {
this.collection = _collection
},
setSelectionMode(val) {
this.isSelectionMode = val
},
clickCard() {
if (!this.collection) return
var router = this.$router || this.$nuxt.$router
router.push(`/collection/${this.collection.id}`)
},
clickEdit() {
this.$emit('edit', this.collection)
},
destroy() {
// destroy the vue listeners, etc
this.$destroy()
// remove the element from the DOM
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
} else if (this.$el && this.$el.remove) {
this.$el.remove()
}
}
},
mounted() {}
}
</script>

View file

@ -0,0 +1,100 @@
<template>
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm cursor-pointer z-30" @click="clickCard">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
index: Number,
width: Number,
height: Number,
bookCoverAspectRatio: Number,
seriesMount: {
type: Object,
default: () => null
},
isCategorized: Boolean
},
data() {
return {
series: null,
isSelectionMode: false,
selected: false,
imageReady: false
}
},
computed: {
labelFontSize() {
if (this.width < 160) return 0.75
return 0.875
},
sizeMultiplier() {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240
},
title() {
return this.series ? this.series.name : ''
},
books() {
return this.series ? this.series.books || [] : []
},
store() {
return this.$store || this.$nuxt.$store
},
currentLibraryId() {
return this.store.state.libraries.currentLibraryId
},
seriesId() {
return this.series ? this.$encode(this.series.id) : null
},
hasValidCovers() {
var validCovers = this.books.map((bookItem) => bookItem.book.cover)
return !!validCovers.length
}
},
methods: {
setEntity(_series) {
this.series = _series
},
setSelectionMode(val) {
this.isSelectionMode = val
},
clickCard() {
if (!this.series) return
var router = this.$router || this.$nuxt.$router
router.push(`/bookshelf/series/${this.seriesId}`)
},
imageLoaded() {
this.imageReady = true
},
destroy() {
// destroy the vue listeners, etc
this.$destroy()
// remove the element from the DOM
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
} else if (this.$el && this.$el.remove) {
this.$el.remove()
}
}
},
mounted() {
if (this.seriesMount) {
this.setEntity(this.seriesMount)
}
},
beforeDestroy() {}
}
</script>

View file

@ -0,0 +1,39 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<covers-group-cover :name="series" :book-items="bookItems" :width="80" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="flex-grow px-2 seriesSearchCardContent h-full">
<p class="truncate text-sm">{{ series }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
series: String,
bookItems: {
type: Array,
default: () => []
}
},
data() {
return {}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
}
},
methods: {},
mounted() {}
}
</script>
<style>
.seriesSearchCardContent {
width: calc(100% - 80px);
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View file

@ -0,0 +1,383 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ height: height + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
<div class="w-full h-full relative bg-bg">
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
<div class="absolute cover-bg" ref="coverBg" />
</div>
<img v-if="audiobook" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
<div v-show="loading && audiobook" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
<div class="absolute top-2 right-2">
<div class="la-ball-spin-clockwise la-sm">
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</div>
</div>
</div>
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
<img src="/Logo.png" loading="lazy" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
</div>
</div>
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div>
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
</div>
</div>
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
},
authorOverride: String,
width: {
type: Number,
default: 120
},
bookCoverAspectRatio: Number,
downloadCover: String
},
data() {
return {
loading: true,
imageFailed: false,
showCoverBg: false,
imageReady: false
}
},
watch: {
cover() {
this.imageFailed = false
}
},
computed: {
squareAspectRatio() {
return this.bookCoverAspectRatio === 1
},
height() {
return this.width * this.bookCoverAspectRatio
},
book() {
if (!this.audiobook) return {}
return this.audiobook.book || {}
},
title() {
return this.book.title || 'No Title'
},
titleCleaned() {
if (this.title.length > 60) {
return this.title.slice(0, 57) + '...'
}
return this.title
},
author() {
if (this.authorOverride) return this.authorOverride
return this.book.author || 'Unknown'
},
authorCleaned() {
if (this.author.length > 30) {
return this.author.slice(0, 27) + '...'
}
return this.author
},
placeholderUrl() {
return '/book_placeholder.jpg'
},
fullCoverUrl() {
if (this.downloadCover) return this.downloadCover
if (!this.audiobook) return null
var store = this.$store || this.$nuxt.$store
return store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
},
cover() {
return this.book.cover || this.placeholderUrl
},
hasCover() {
return !!this.book.cover
},
sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 192 : 120
return this.width / baseSize
},
titleFontSize() {
return 0.75 * this.sizeMultiplier
},
authorFontSize() {
return 0.6 * this.sizeMultiplier
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
},
authorBottom() {
return 0.75 * this.sizeMultiplier
},
userToken() {
return this.$store.getters['user/getToken']
}
},
methods: {
setCoverBg() {
if (this.$refs.coverBg) {
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
}
},
hideCoverBg() {},
imageLoaded() {
this.loading = false
this.$nextTick(() => {
this.imageReady = true
})
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
if (arDiff > 0.15) {
this.showCoverBg = true
this.$nextTick(this.setCoverBg)
} else {
this.showCoverBg = false
}
}
},
imageError(err) {
this.loading = false
console.error('ImgError', err)
this.imageFailed = true
}
},
mounted() {}
}
</script>
<style>
/*!
* Load Awesome v1.1.0 (http://github.danielcardoso.net/load-awesome/)
* Copyright 2015 Daniel Cardoso <@DanielCardoso>
* Licensed under MIT
*/
.la-ball-spin-clockwise,
.la-ball-spin-clockwise > div {
position: relative;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.la-ball-spin-clockwise {
display: block;
font-size: 0;
color: #fff;
}
.la-ball-spin-clockwise.la-dark {
color: #262626;
}
.la-ball-spin-clockwise > div {
display: inline-block;
float: none;
background-color: currentColor;
border: 0 solid currentColor;
}
.la-ball-spin-clockwise {
width: 32px;
height: 32px;
}
.la-ball-spin-clockwise > div {
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 8px;
margin-top: -4px;
margin-left: -4px;
border-radius: 100%;
-webkit-animation: ball-spin-clockwise 1s infinite ease-in-out;
-moz-animation: ball-spin-clockwise 1s infinite ease-in-out;
-o-animation: ball-spin-clockwise 1s infinite ease-in-out;
animation: ball-spin-clockwise 1s infinite ease-in-out;
}
.la-ball-spin-clockwise > div:nth-child(1) {
top: 5%;
left: 50%;
-webkit-animation-delay: -0.875s;
-moz-animation-delay: -0.875s;
-o-animation-delay: -0.875s;
animation-delay: -0.875s;
}
.la-ball-spin-clockwise > div:nth-child(2) {
top: 18.1801948466%;
left: 81.8198051534%;
-webkit-animation-delay: -0.75s;
-moz-animation-delay: -0.75s;
-o-animation-delay: -0.75s;
animation-delay: -0.75s;
}
.la-ball-spin-clockwise > div:nth-child(3) {
top: 50%;
left: 95%;
-webkit-animation-delay: -0.625s;
-moz-animation-delay: -0.625s;
-o-animation-delay: -0.625s;
animation-delay: -0.625s;
}
.la-ball-spin-clockwise > div:nth-child(4) {
top: 81.8198051534%;
left: 81.8198051534%;
-webkit-animation-delay: -0.5s;
-moz-animation-delay: -0.5s;
-o-animation-delay: -0.5s;
animation-delay: -0.5s;
}
.la-ball-spin-clockwise > div:nth-child(5) {
top: 94.9999999966%;
left: 50.0000000005%;
-webkit-animation-delay: -0.375s;
-moz-animation-delay: -0.375s;
-o-animation-delay: -0.375s;
animation-delay: -0.375s;
}
.la-ball-spin-clockwise > div:nth-child(6) {
top: 81.8198046966%;
left: 18.1801949248%;
-webkit-animation-delay: -0.25s;
-moz-animation-delay: -0.25s;
-o-animation-delay: -0.25s;
animation-delay: -0.25s;
}
.la-ball-spin-clockwise > div:nth-child(7) {
top: 49.9999750815%;
left: 5.0000051215%;
-webkit-animation-delay: -0.125s;
-moz-animation-delay: -0.125s;
-o-animation-delay: -0.125s;
animation-delay: -0.125s;
}
.la-ball-spin-clockwise > div:nth-child(8) {
top: 18.179464974%;
left: 18.1803700518%;
-webkit-animation-delay: 0s;
-moz-animation-delay: 0s;
-o-animation-delay: 0s;
animation-delay: 0s;
}
.la-ball-spin-clockwise.la-sm {
width: 16px;
height: 16px;
}
.la-ball-spin-clockwise.la-sm > div {
width: 4px;
height: 4px;
margin-top: -2px;
margin-left: -2px;
}
.la-ball-spin-clockwise.la-2x {
width: 64px;
height: 64px;
}
.la-ball-spin-clockwise.la-2x > div {
width: 16px;
height: 16px;
margin-top: -8px;
margin-left: -8px;
}
.la-ball-spin-clockwise.la-3x {
width: 96px;
height: 96px;
}
.la-ball-spin-clockwise.la-3x > div {
width: 24px;
height: 24px;
margin-top: -12px;
margin-left: -12px;
}
/*
* Animation
*/
@-webkit-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-webkit-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-webkit-transform: scale(0);
transform: scale(0);
}
}
@-moz-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-moz-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-moz-transform: scale(0);
transform: scale(0);
}
}
@-o-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-o-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-o-transform: scale(0);
transform: scale(0);
}
}
@keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-webkit-transform: scale(1);
-moz-transform: scale(1);
-o-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-webkit-transform: scale(0);
-moz-transform: scale(0);
-o-transform: scale(0);
transform: scale(0);
}
}
</style>

View file

@ -0,0 +1,61 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ width: width + 'px', height: height + 'px' }">
<div v-if="hasOwnCover" class="w-full h-full relative rounded-sm">
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
<div class="w-full h-full z-0" ref="coverBg" />
</div>
<img ref="cover" :src="fullCoverUrl" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
</div>
<div v-else-if="books.length" class="flex justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
<covers-book-cover :audiobook="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover v-if="books.length > 1" :audiobook="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
<p class="font-book text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
</div>
</div>
</template>
<script>
export default {
props: {
bookItems: {
type: Array,
default: () => []
},
width: Number,
height: Number,
bookCoverAspectRatio: Number
},
data() {
return {
imageFailed: false,
showCoverBg: false
}
},
computed: {
sizeMultiplier() {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240
},
hasOwnCover() {
return false
},
fullCoverUrl() {
return null
},
books() {
return this.bookItems || []
}
},
methods: {
imageError() {},
imageLoaded() {}
},
mounted() {}
}
</script>

View file

@ -0,0 +1,225 @@
<template>
<div ref="wrapper" :style="{ height: height + 'px', width: width + 'px' }" class="relative">
<div v-if="noValidCovers" class="absolute top-0 left-0 w-full h-full flex items-center justify-center box-shadow-book" :style="{ padding: `${sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier + 'rem' }">{{ name }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
id: String,
name: String,
bookItems: {
type: Array,
default: () => []
},
width: Number,
height: Number,
bookCoverAspectRatio: Number
},
data() {
return {
noValidCovers: false,
coverDiv: null,
isHovering: false,
coverWrapperEl: null,
coverImageEls: [],
coverWidth: 0,
offsetIncrement: 0,
isFannedOut: false,
isDetached: false,
isAttaching: false,
windowWidth: 0,
isInit: false
}
},
watch: {
bookItems: {
immediate: true,
handler(newVal) {
if (newVal) {
// ensure wrapper is initialized
this.$nextTick(this.init)
}
}
}
},
computed: {
sizeMultiplier() {
if (this.bookCoverAspectRatio === 1) return this.width / (100 * 1.6 * 2)
return this.width / 200
},
store() {
return this.$store || this.$nuxt.$store
},
router() {
return this.$router || this.$nuxt.$router
}
},
methods: {
detchCoverWrapper() {
if (!this.coverWrapperEl || !this.$refs.wrapper || this.isDetached) return
this.coverWrapperEl.remove()
this.isDetached = true
document.body.appendChild(this.coverWrapperEl)
this.coverWrapperEl.addEventListener('mouseleave', this.mouseleaveCover)
this.coverWrapperEl.style.position = 'absolute'
this.coverWrapperEl.style.zIndex = 40
this.updatePosition()
},
attachCoverWrapper() {
if (!this.coverWrapperEl || !this.$refs.wrapper || !this.isDetached) return
this.coverWrapperEl.remove()
this.coverWrapperEl.style.position = 'relative'
this.coverWrapperEl.style.left = 'unset'
this.coverWrapperEl.style.top = 'unset'
this.coverWrapperEl.style.width = this.$refs.wrapper.clientWidth + 'px'
this.$refs.wrapper.appendChild(this.coverWrapperEl)
this.isDetached = false
},
updatePosition() {
var rect = this.$refs.wrapper.getBoundingClientRect()
this.coverWrapperEl.style.top = rect.top + window.scrollY + 'px'
this.coverWrapperEl.style.left = rect.left + window.scrollX + 4 + 'px'
this.coverWrapperEl.style.height = rect.height + 'px'
this.coverWrapperEl.style.width = rect.width + 'px'
},
getCoverUrl(book) {
return this.store.getters['audiobooks/getBookCoverSrc'](book, '')
},
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
var src = coverData.coverUrl
var showCoverBg =
forceCoverBg ||
(await new Promise((resolve) => {
var image = new Image()
image.onload = () => {
var { naturalWidth, naturalHeight } = image
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
if (arDiff > 0.15) {
resolve(true)
} else {
resolve(false)
}
}
image.onerror = (err) => {
console.error(err)
resolve(false)
}
image.src = src
}))
var imgdiv = document.createElement('div')
imgdiv.style.height = this.height + 'px'
imgdiv.style.width = bgCoverWidth + 'px'
imgdiv.style.left = offsetLeft + 'px'
imgdiv.style.zIndex = zIndex
imgdiv.dataset.audiobookId = coverData.id
imgdiv.dataset.volumeNumber = coverData.volumeNumber || ''
imgdiv.className = 'absolute top-0 box-shadow-book transition-transform'
imgdiv.style.boxShadow = '4px 0px 4px #11111166'
// imgdiv.style.transform = 'skew(0deg, 15deg)'
if (showCoverBg) {
var coverbgwrapper = document.createElement('div')
coverbgwrapper.className = 'absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary'
var coverbg = document.createElement('div')
coverbg.className = 'absolute cover-bg'
coverbg.style.backgroundImage = `url("${src}")`
coverbgwrapper.appendChild(coverbg)
imgdiv.appendChild(coverbgwrapper)
}
var img = document.createElement('img')
img.src = src
img.className = 'absolute top-0 left-0 w-full h-full'
img.style.objectFit = showCoverBg ? 'contain' : 'cover'
imgdiv.appendChild(img)
return imgdiv
},
async init() {
if (this.isInit) return
this.isInit = true
if (this.coverDiv) {
this.coverDiv.remove()
this.coverDiv = null
}
var validCovers = this.bookItems
.map((bookItem) => {
return {
id: bookItem.id,
volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null,
coverUrl: this.getCoverUrl(bookItem)
}
})
.filter((b) => b.coverUrl !== '')
if (!validCovers.length) {
this.noValidCovers = true
return
}
this.noValidCovers = false
var coverWidth = this.width
var widthPer = this.width
if (validCovers.length > 1) {
coverWidth = this.height / this.bookCoverAspectRatio
widthPer = (this.width - coverWidth) / (validCovers.length - 1)
}
this.coverWidth = coverWidth
this.offsetIncrement = widthPer
var outerdiv = document.createElement('div')
outerdiv.id = `group-cover-${this.id || this.$encode(this.name)}`
this.coverWrapperEl = outerdiv
outerdiv.className = 'w-full h-full relative box-shadow-book'
var coverImageEls = []
var offsetLeft = 0
for (let i = 0; i < validCovers.length; i++) {
offsetLeft = widthPer * i
var zIndex = validCovers.length - i
var img = await this.buildCoverImg(validCovers[i], coverWidth, offsetLeft, zIndex, validCovers.length === 1)
outerdiv.appendChild(img)
coverImageEls.push(img)
}
this.coverImageEls = coverImageEls
if (this.$refs.wrapper) {
this.coverDiv = outerdiv
this.$refs.wrapper.appendChild(outerdiv)
}
}
},
mounted() {
this.windowWidth = window.innerWidth
},
beforeDestroy() {
if (this.coverWrapperEl) this.coverWrapperEl.remove()
if (this.coverImageEls && this.coverImageEls.length) {
this.coverImageEls.forEach((el) => el.remove())
}
if (this.coverDiv) this.coverDiv.remove()
}
}
</script>

View file

@ -5,9 +5,8 @@
<nuxt-link to="/bookshelf/series" v-if="selectedSeriesName" class="pt-1">
<span class="material-icons">arrow_back</span>
</nuxt-link>
<p v-show="!selectedSeriesName" class="font-book pt-1">{{ numEntities }} {{ entityTitle }}</p>
<p v-show="selectedSeriesName" class="ml-2 font-book pt-1">{{ selectedSeriesName }}</p>
<p v-show="!selectedSeriesName" class="font-book pt-1">{{ totalEntities }} {{ entityTitle }}</p>
<p v-show="selectedSeriesName" class="ml-2 font-book pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p>
<div class="flex-grow" />
<template v-if="page === 'library'">
<span class="material-icons px-2" @click="changeView">{{ viewIcon }}</span>
@ -32,12 +31,13 @@ export default {
showSortModal: false,
showFilterModal: false,
settings: {},
isListView: false
isListView: false,
totalEntities: 0
}
},
computed: {
hasFilters() {
return this.$store.getters['user/getUserSetting']('filterBy') !== 'all'
return this.$store.getters['user/getUserSetting']('mobileFilterBy') !== 'all'
},
page() {
var routeName = this.$route.name || ''
@ -47,42 +47,17 @@ export default {
return this.$route.query || {}
},
entityTitle() {
if (this.page === 'library') return 'Audiobooks'
if (this.page === 'library') return 'Books'
else if (this.page === 'series') {
if (this.selectedSeriesName) return 'Books in ' + this.selectedSeriesName
return 'Series'
} else if (this.page === 'collections') {
return 'Collections'
}
return ''
},
numEntities() {
if (this.page === 'library') return this.numAudiobooks
else if (this.page === 'series') {
if (this.selectedSeriesName) return this.numBooksInSeries
return this.series.length
} else if (this.page === 'collections') return this.numCollections
return 0
},
series() {
return this.$store.getters['audiobooks/getSeriesGroups']() || []
},
numCollections() {
return (this.$store.state.user.collections || []).length
},
numAudiobooks() {
return this.$store.getters['audiobooks/getFiltered']().length
},
numBooksInSeries() {
return this.selectedSeries ? (this.selectedSeries.books || []).length : 0
},
selectedSeries() {
if (!this.selectedSeriesName) return null
return this.series.find((s) => s.name === this.selectedSeriesName)
},
selectedSeriesName() {
if (this.page === 'series' && this.routeQuery.series) {
return this.$decode(this.routeQuery.series)
if (this.page === 'series' && this.$route.params.id) {
return this.$decode(this.$route.params.id)
}
return null
},
@ -120,13 +95,18 @@ export default {
for (const key in settings) {
this.settings[key] = settings[key]
}
},
setTotalEntities(total) {
this.totalEntities = total
}
},
mounted() {
this.init()
this.$eventBus.$on('bookshelf-total-entities', this.setTotalEntities)
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated })
},
beforeDestroy() {
this.$eventBus.$off('bookshelf-total-entities', this.setTotalEntities)
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
}
}

View file

@ -81,10 +81,20 @@ export default {
value: 'authors',
sublist: true
},
{
text: 'Narrator',
value: 'narrators',
sublist: true
},
{
text: 'Progress',
value: 'progress',
sublist: true
},
{
text: 'Issues',
value: 'issues',
sublist: false
}
]
}
@ -118,16 +128,19 @@ export default {
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
},
genres() {
return this.$store.getters['audiobooks/getGenresUsed']
return this.filterData.genres || []
},
tags() {
return this.$store.state.audiobooks.tags
return this.filterData.tags || []
},
series() {
return this.$store.state.audiobooks.series
return this.filterData.series || []
},
authors() {
return this.$store.getters['audiobooks/getUniqueAuthors']
return this.filterData.authors || []
},
narrators() {
return this.filterData.narrators || []
},
progress() {
return ['Read', 'Unread', 'In Progress']
@ -139,6 +152,9 @@ export default {
value: this.$encode(item)
}
})
},
filterData() {
return this.$store.state.libraries.filterData || {}
}
},
methods: {

View file

@ -53,10 +53,8 @@ export default {
methods: {
async clickedOption(lib) {
this.show = false
this.$store.commit('libraries/setCurrentLibrary', lib.id)
await this.$store.dispatch('audiobooks/load')
await this.$store.dispatch('libraries/fetch', lib.id)
this.$eventBus.$emit('library-changed', lib.id)
this.$localStore.setCurrentLibrary(lib)
}
},

View file

@ -9,7 +9,7 @@
<p v-if="totalDuration">{{ totalDurationPretty }}</p>
</div>
<template v-for="book in booksCopy">
<tables-collection-book-table-row :key="book.id" :book="book" :collection-id="collectionId" class="item" :class="drag ? '' : 'collection-book-item'" @edit="editBook" />
<tables-collection-book-table-row :key="book.id" :book="book" :collection-id="collectionId" class="item collection-book-item" @edit="editBook" />
</template>
</div>
</template>

View file

@ -1,117 +0,0 @@
<template>
<div>
<nuxt-link v-if="isConnected" to="/account" class="p-2 bg-white bg-opacity-10 border border-white border-opacity-40 rounded-full h-11 w-11 flex items-center justify-center">
<span class="material-icons">person</span>
</nuxt-link>
<div v-else-if="processing" class="relative p-2 bg-warning bg-opacity-10 border border-warning border-opacity-40 rounded-full h-11 w-11 flex items-center justify-center">
<div class="loader-dots block relative w-10 h-2.5">
<div class="absolute top-0 mt-0.5 w-1.5 h-1.5 rounded-full bg-warning"></div>
<div class="absolute top-0 mt-0.5 w-1.5 h-1.5 rounded-full bg-warning"></div>
<div class="absolute top-0 mt-0.5 w-1.5 h-1.5 rounded-full bg-warning"></div>
<div class="absolute top-0 mt-0.5 w-1.5 h-1.5 rounded-full bg-warning"></div>
</div>
</div>
<nuxt-link v-else to="/connect" class="relative p-2 bg-warning bg-opacity-10 border border-warning border-opacity-40 rounded-full h-11 w-11 flex items-center justify-center">
<span class="material-icons">{{ networkIcon }}</span>
<!-- <div class="absolute top-0 left-0"> -->
<!-- <div class="absolute -top-5 -right-5 overflow-hidden">
<svg class="w-20 h-20 animate-spin" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path clip-rule="evenodd" d="M15.165 8.53a.5.5 0 01-.404.58A7 7 0 1023 16a.5.5 0 011 0 8 8 0 11-9.416-7.874.5.5 0 01.58.404z" fill="currentColor" fill-rule="evenodd" />
</svg>
</div> -->
</nuxt-link>
</div>
</template>
<script>
export default {
props: {},
data() {
return {
processing: false,
serverUrl: null,
isConnected: false
}
},
watch: {
networkConnected(newVal) {
if (newVal) {
this.init()
}
}
},
computed: {
user() {
return this.$store.state.user.user
},
networkIcon() {
if (!this.networkConnected) return 'signal_wifi_connected_no_internet_4'
return 'cloud_off'
},
networkConnected() {
return this.$store.state.networkConnected
}
},
methods: {
socketConnected(val) {
this.processing = false
this.isConnected = val
},
async init() {
if (this.isConnected) {
return
}
if (!this.$server) {
console.error('Invalid server not initialized')
return
}
if (!this.networkConnected) return
var localServerUrl = await this.$localStore.getServerUrl()
var localUserToken = await this.$localStore.getToken()
if (localServerUrl) {
this.serverUrl = localServerUrl
// Server and Token are stored
if (localUserToken) {
this.processing = true
var isSocketAlreadyEstablished = this.$server.socket
var success = await this.$server.connect(localServerUrl, localUserToken)
if (!success && !this.$server.url) {
this.processing = false
this.serverUrl = null
} else if (!success) {
this.processing = false
} else if (isSocketAlreadyEstablished) {
// No need to wait for connect event
this.processing = false
}
} else {
// Server only is stored
var success = await this.$server.check(this.serverUrl)
if (!success) {
console.error('Invalid server')
this.$server.setServerUrl(null)
}
}
}
}
},
mounted() {
if (!this.$server) {
console.error('Server not initalized in connection icon')
return
}
if (this.$server.connected) {
this.isConnected = true
}
this.$server.on('connected', this.socketConnected)
this.init()
},
beforeDestroy() {
if (this.$server) this.$server.off('connected', this.socketConnected)
}
}
</script>