mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2025-08-04 01:54:33 +02:00
Fix: android auto requirements, Change: New UI #33
This commit is contained in:
parent
bf8e48fd27
commit
0abefbd9bc
43 changed files with 2336 additions and 308 deletions
|
@ -1,13 +1,13 @@
|
|||
<template>
|
||||
<div class="w-full h-16 bg-primary relative">
|
||||
<div id="appbar" class="absolute top-0 left-0 w-full h-full z-30 flex items-center px-2">
|
||||
<div id="appbar" class="absolute top-0 left-0 w-full h-full z-10 flex items-center px-2">
|
||||
<nuxt-link v-show="!showBack" to="/" class="mr-3">
|
||||
<img src="/Logo.png" class="h-10 w-10" />
|
||||
</nuxt-link>
|
||||
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-2 cursor-pointer">
|
||||
<span class="material-icons text-3xl text-white">arrow_back</span>
|
||||
</a>
|
||||
<div>
|
||||
<div v-if="socketConnected">
|
||||
<div class="px-4 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
|
@ -20,18 +20,13 @@
|
|||
|
||||
<!-- <ui-menu :label="username" :items="menuItems" @action="menuAction" class="ml-5" /> -->
|
||||
|
||||
<span class="material-icons cursor-pointer mx-4" :class="hasDownloadsFolder ? '' : 'text-warning'" @click="$store.commit('downloads/setShowModal', true)">source</span>
|
||||
<!-- <span class="material-icons cursor-pointer mx-4" :class="hasDownloadsFolder ? '' : 'text-warning'" @click="$store.commit('downloads/setShowModal', true)">source</span> -->
|
||||
|
||||
<widgets-connection-icon />
|
||||
<!-- <widgets-connection-icon /> -->
|
||||
|
||||
<!-- <nuxt-link to="/account" class="relative w-28 bg-fg border border-gray-500 rounded shadow-sm ml-5 pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate">{{ username }}</span>
|
||||
</span>
|
||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<span class="material-icons text-gray-100">person</span>
|
||||
</span>
|
||||
</nuxt-link> -->
|
||||
<div class="h-7 mx-2">
|
||||
<span class="material-icons" style="font-size: 1.75rem" @click="clickShowSideDrawer">menu</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -54,6 +49,9 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
socketConnected() {
|
||||
return this.$store.state.socketConnected
|
||||
},
|
||||
currentLibrary() {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
|
@ -61,7 +59,7 @@ export default {
|
|||
return this.currentLibrary ? this.currentLibrary.name : 'Main'
|
||||
},
|
||||
showBack() {
|
||||
return this.$route.name !== 'index'
|
||||
return this.$route.name !== 'index' && !this.$route.name.startsWith('bookshelf')
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
|
@ -81,6 +79,9 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
clickShowSideDrawer() {
|
||||
this.$store.commit('setShowSideDrawer', true)
|
||||
},
|
||||
clickShowLibraryModal() {
|
||||
this.$store.commit('libraries/setShowModal', true)
|
||||
},
|
||||
|
@ -88,7 +89,7 @@ export default {
|
|||
if (this.$route.name === 'audiobook-id-edit') {
|
||||
this.$router.push(`/audiobook/${this.$route.params.id}`)
|
||||
} else {
|
||||
this.$router.push('/')
|
||||
this.$router.push('/bookshelf')
|
||||
}
|
||||
},
|
||||
logout() {
|
||||
|
|
116
components/app/SideDrawer.vue
Normal file
116
components/app/SideDrawer.vue
Normal file
|
@ -0,0 +1,116 @@
|
|||
<template>
|
||||
<div class="fixed top-0 left-0 right-0 bottom-0 w-full h-full z-50 overflow-hidden pointer-events-none">
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-black transition-opacity duration-200" :class="show ? 'bg-opacity-60 pointer-events-auto' : 'bg-opacity-0'" @click="clickBackground" />
|
||||
<div class="absolute top-0 right-0 w-64 h-full bg-primary transform transition-transform py-6 pointer-events-auto" :class="show ? '' : 'translate-x-64'" @click.stop>
|
||||
<div class="px-6 mb-4">
|
||||
<p v-if="socketConnected" class="text-base">
|
||||
Welcome, <strong>{{ username }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-full overflow-y-auto">
|
||||
<template v-for="item in navItems">
|
||||
<nuxt-link :to="item.to" :key="item.text" class="w-full hover:bg-bg hover:bg-opacity-60 flex items-center py-3 px-6 text-gray-300">
|
||||
<span class="text-lg" :class="item.iconOutlined ? 'material-icons-outlined' : 'material-icons'">{{ item.icon }}</span>
|
||||
<p class="pl-4">{{ item.text }}</p>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full flex items-center py-6 px-6 text-gray-300">
|
||||
<p class="text-xs">{{ $config.version }}</p>
|
||||
<div class="flex-grow" />
|
||||
<div v-if="socketConnected" class="flex items-center" @click="logout">
|
||||
<p class="text-xs pr-2">Logout</p>
|
||||
<span class="material-icons text-sm">logout</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
watch: {
|
||||
$route: {
|
||||
handler() {
|
||||
this.show = false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.$store.state.showSideDrawer
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('setShowSideDrawer', val)
|
||||
}
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
username() {
|
||||
return this.user ? this.user.username : ''
|
||||
},
|
||||
socketConnected() {
|
||||
return this.$store.state.socketConnected
|
||||
},
|
||||
navItems() {
|
||||
var items = [
|
||||
{
|
||||
icon: 'home',
|
||||
text: 'Home',
|
||||
to: '/bookshelf'
|
||||
},
|
||||
{
|
||||
icon: 'person',
|
||||
text: 'Account',
|
||||
to: '/account'
|
||||
},
|
||||
{
|
||||
icon: 'folder',
|
||||
iconOutlined: true,
|
||||
text: 'Downloads',
|
||||
to: '/downloads'
|
||||
},
|
||||
{
|
||||
icon: 'settings',
|
||||
text: 'Settings',
|
||||
to: '/config'
|
||||
}
|
||||
]
|
||||
if (!this.socketConnected) {
|
||||
items = [
|
||||
{
|
||||
icon: 'cloud_off',
|
||||
text: 'Connect to Server',
|
||||
to: '/connect'
|
||||
}
|
||||
].concat(items)
|
||||
}
|
||||
return items
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBackground() {
|
||||
this.show = false
|
||||
},
|
||||
async logout() {
|
||||
await this.$axios.$post('/logout').catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
this.$server.logout()
|
||||
this.$router.push('/connect')
|
||||
|
||||
this.$store.commit('audiobooks/reset')
|
||||
this.$store.dispatch('audiobooks/useDownloaded')
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {
|
||||
this.show = false
|
||||
}
|
||||
}
|
||||
</script>
|
35
components/bookshelf/GroupShelf.vue
Normal file
35
components/bookshelf/GroupShelf.vue
Normal file
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<div class="w-full relative">
|
||||
<div class="bookshelfRow flex items-end justify-around px-3 max-w-full" :class="shelfHeightClass">
|
||||
<template v-for="group in groups">
|
||||
<cards-series-card v-if="groupType === 'series'" :key="group.id" :group="group" :width="112" class="mx-2" />
|
||||
<cards-collection-card v-if="groupType === 'collection'" :key="group.id" :collection="group" :width="90" class="mx-2" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-5 z-40 bookshelfDivider"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
groupType: String,
|
||||
groups: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
shelfHeightClass() {
|
||||
if (this.groupType === 'series') return 'h-48'
|
||||
return 'h-44'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
28
components/bookshelf/LibraryShelf.vue
Normal file
28
components/bookshelf/LibraryShelf.vue
Normal file
|
@ -0,0 +1,28 @@
|
|||
<template>
|
||||
<div class="w-full relative">
|
||||
<div class="bookshelfRow h-48 flex items-end justify-around px-3 max-w-full">
|
||||
<template v-for="book in books">
|
||||
<cards-book-card :key="book.id" :audiobook="book" :width="108" class="mx-2" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-4 z-40 bookshelfDivider"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
books: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
34
components/bookshelf/Shelf.vue
Normal file
34
components/bookshelf/Shelf.vue
Normal file
|
@ -0,0 +1,34 @@
|
|||
<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" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-4 md:left-8 w-36 rounded-md" style="height: 18px">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
|
||||
<p class="transform text-xs">{{ label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-5 z-40 bookshelfDivider"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
label: String,
|
||||
books: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
86
components/cards/CollectionCard.vue
Normal file
86
components/cards/CollectionCard.vue
Normal file
|
@ -0,0 +1,86 @@
|
|||
<template>
|
||||
<div class="relative">
|
||||
<div class="rounded-sm h-full relative" @click="clickCard">
|
||||
<nuxt-link :to="groupTo" class="cursor-pointer">
|
||||
<div class="w-full relative bg-primary" :style="{ height: coverHeight + 'px', width: coverWidth + 'px' }">
|
||||
<cards-collection-cover ref="groupcover" :book-items="bookItems" :width="coverWidth" :height="coverHeight" />
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-5 h-5 rounded-md font-book text-center" :style="{ width: Math.min(160, coverWidth) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.8 * sizeMultiplier}rem` }">
|
||||
<p class="truncate pt-px" :style="{ fontSize: labelFontSize + 'rem' }">{{ collectionName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
collection: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
},
|
||||
paddingY: {
|
||||
type: Number,
|
||||
default: 24
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
watch: {
|
||||
width(newVal) {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.groupcover && this.$refs.groupcover.init) {
|
||||
this.$refs.groupcover.init()
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
labelFontSize() {
|
||||
if (this.coverWidth < 160) return 0.7
|
||||
return 0.75
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
_collection() {
|
||||
return this.collection || {}
|
||||
},
|
||||
groupTo() {
|
||||
return `/collection/${this._collection.id}`
|
||||
},
|
||||
coverWidth() {
|
||||
return this.width * 2
|
||||
},
|
||||
coverHeight() {
|
||||
return this.width * 1.6
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.width / 120
|
||||
},
|
||||
paddingX() {
|
||||
return 16 * this.sizeMultiplier
|
||||
},
|
||||
bookItems() {
|
||||
return this._collection.books || []
|
||||
},
|
||||
collectionName() {
|
||||
return this._collection.name || 'No Name'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickCard() {
|
||||
this.$emit('click', this.collection)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
63
components/cards/CollectionCover.vue
Normal file
63
components/cards/CollectionCover.vue
Normal file
|
@ -0,0 +1,63 @@
|
|||
<template>
|
||||
<div class="relative rounded-sm overflow-hidden" :style="{ width: width + 'px', height: height + 'px' }">
|
||||
<!-- <div class="absolute top-0 left-0 w-full h-full rounded-sm overflow-hidden z-10">
|
||||
<div class="w-full h-full border border-white border-opacity-10" />
|
||||
</div> -->
|
||||
|
||||
<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" />
|
||||
|
||||
<cards-book-cover :audiobook="books[0]" :width="width / 2" />
|
||||
<cards-book-cover v-if="books.length > 1" :audiobook="books[1]" :width="width / 2" />
|
||||
</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
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
imageFailed: false,
|
||||
showCoverBg: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sizeMultiplier() {
|
||||
return this.width / 120
|
||||
},
|
||||
hasOwnCover() {
|
||||
return false
|
||||
},
|
||||
fullCoverUrl() {
|
||||
return null
|
||||
},
|
||||
books() {
|
||||
return this.bookItems || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
imageError() {},
|
||||
imageLoaded() {}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
113
components/cards/SeriesCard.vue
Normal file
113
components/cards/SeriesCard.vue
Normal file
|
@ -0,0 +1,113 @@
|
|||
<template>
|
||||
<div class="rounded-sm relative" @click="clickCard">
|
||||
<nuxt-link :to="groupTo" class="cursor-pointer">
|
||||
<div class="w-full relative bg-primary" :style="{ height: coverHeight + 'px', width: coverWidth + 'px' }">
|
||||
<cards-series-cover ref="groupcover" :name="groupName" :group-to="groupTo" :type="groupType" :book-items="bookItems" :width="coverWidth" :height="coverHeight" />
|
||||
|
||||
<div class="absolute top-2 right-2 w-7 h-7 rounded-lg bg-black bg-opacity-90 text-gray-300 box-shadow-book flex items-center justify-center border border-white border-opacity-25 pointer-events-none z-40">
|
||||
<p class="font-book text-xl">{{ bookItems.length }}</p>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full h-1 flex flex-nowrap z-40">
|
||||
<div v-for="userProgress in userProgressItems" :key="userProgress.audiobookId" class="h-full w-full" :class="userProgress.isRead ? 'bg-success' : userProgress.progress > 0 ? 'bg-yellow-400' : ''" />
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-5 h-5 rounded-md font-book text-center" :style="{ width: Math.min(160, coverWidth) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.8 * sizeMultiplier}rem` }">
|
||||
<p class="truncate pt-px" :style="{ fontSize: labelFontSize + 'rem' }">{{ groupName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
group: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
watch: {
|
||||
width(newVal) {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.groupcover && this.$refs.groupcover.init) {
|
||||
this.$refs.groupcover.init()
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
labelFontSize() {
|
||||
if (this.coverWidth < 160) return 0.7
|
||||
return 0.75
|
||||
},
|
||||
_group() {
|
||||
return this.group || {}
|
||||
},
|
||||
groupType() {
|
||||
return this._group.type
|
||||
},
|
||||
groupTo() {
|
||||
if (this.groupType === 'series') {
|
||||
return `/bookshelf/series?series=${this.groupEncode}`
|
||||
} else {
|
||||
return `/bookshelf?filter=tags.${this.groupEncode}`
|
||||
}
|
||||
},
|
||||
coverWidth() {
|
||||
return this.coverHeight
|
||||
},
|
||||
coverHeight() {
|
||||
return this.width * 1.6
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.width / 120
|
||||
},
|
||||
paddingX() {
|
||||
return 16 * this.sizeMultiplier
|
||||
},
|
||||
bookItems() {
|
||||
return this._group.books || []
|
||||
},
|
||||
userAudiobooks() {
|
||||
return Object.values(this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {})
|
||||
},
|
||||
userProgressItems() {
|
||||
return this.bookItems.map((item) => {
|
||||
var userAudiobook = this.userAudiobooks.find((ab) => ab.audiobookId === item.id)
|
||||
return userAudiobook || {}
|
||||
})
|
||||
},
|
||||
groupName() {
|
||||
return this._group.name || 'No Name'
|
||||
},
|
||||
groupEncode() {
|
||||
return this.$encode(this.groupName)
|
||||
},
|
||||
filter() {
|
||||
return `${this.groupType}.${this.$encode(this.groupName)}`
|
||||
},
|
||||
hasValidCovers() {
|
||||
var validCovers = this.bookItems.map((bookItem) => bookItem.book.cover)
|
||||
return !!validCovers.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickCard() {
|
||||
this.$emit('click', this.group)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
171
components/cards/SeriesCover.vue
Normal file
171
components/cards/SeriesCover.vue
Normal file
|
@ -0,0 +1,171 @@
|
|||
<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: {
|
||||
name: String,
|
||||
bookItems: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
width: Number,
|
||||
height: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
noValidCovers: false,
|
||||
coverDiv: null,
|
||||
coverWrapperEl: null,
|
||||
coverImageEls: [],
|
||||
coverWidth: 0,
|
||||
offsetIncrement: 0,
|
||||
windowWidth: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
bookItems: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
// ensure wrapper is initialized
|
||||
this.$nextTick(this.init)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sizeMultiplier() {
|
||||
return this.width / 192
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
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 - 1.6)
|
||||
|
||||
// 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 bg-primary'
|
||||
|
||||
var coverbg = document.createElement('div')
|
||||
coverbg.className = 'w-full h-full'
|
||||
coverbg.style.backgroundImage = `url("${src}")`
|
||||
coverbg.style.backgroundSize = 'cover'
|
||||
coverbg.style.backgroundPosition = 'center'
|
||||
coverbg.style.opacity = 0.25
|
||||
coverbg.style.filter = 'blur(1px)'
|
||||
|
||||
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.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 / 1.6
|
||||
widthPer = (this.width - coverWidth) / (validCovers.length - 1)
|
||||
}
|
||||
this.coverWidth = coverWidth
|
||||
this.offsetIncrement = widthPer
|
||||
|
||||
var outerdiv = document.createElement('div')
|
||||
this.coverWrapperEl = outerdiv
|
||||
outerdiv.className = 'w-full h-full relative'
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
</script>
|
42
components/home/BookshelfNavBar.vue
Normal file
42
components/home/BookshelfNavBar.vue
Normal file
|
@ -0,0 +1,42 @@
|
|||
<template>
|
||||
<div class="w-full h-9 bg-bg relative">
|
||||
<div id="bookshelf-navbar" class="absolute z-10 top-0 left-0 w-full h-full flex bg-secondary text-gray-200">
|
||||
<nuxt-link to="/bookshelf" class="w-1/4 h-full flex items-center justify-center" :class="routeName === 'bookshelf' ? 'bg-primary' : 'text-gray-400'">
|
||||
<p>Home</p>
|
||||
</nuxt-link>
|
||||
<nuxt-link to="/bookshelf/library" class="w-1/4 h-full flex items-center justify-center" :class="routeName === 'bookshelf-library' ? 'bg-primary' : 'text-gray-400'">
|
||||
<p>Library</p>
|
||||
</nuxt-link>
|
||||
<nuxt-link to="/bookshelf/series" class="w-1/4 h-full flex items-center justify-center" :class="routeName === 'bookshelf-series' ? 'bg-primary' : 'text-gray-400'">
|
||||
<p>Series</p>
|
||||
</nuxt-link>
|
||||
<nuxt-link to="/bookshelf/collections" class="w-1/4 h-full flex items-center justify-center" :class="routeName === 'bookshelf-collections' ? 'bg-primary' : 'text-gray-400'">
|
||||
<p>Collections</p>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
routeName() {
|
||||
return this.$route.name
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#bookshelf-navbar {
|
||||
box-shadow: 0px 5px 5px #11111155;
|
||||
}
|
||||
#bookshelf-navbar a {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
138
components/home/BookshelfToolbar.vue
Normal file
138
components/home/BookshelfToolbar.vue
Normal file
|
@ -0,0 +1,138 @@
|
|||
<template>
|
||||
<div class="w-full h-9 bg-bg relative z-20">
|
||||
<div id="bookshelf-toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-2">
|
||||
<div class="flex items-center w-full text-sm">
|
||||
<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>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<template v-if="page === 'library'">
|
||||
<span class="material-icons px-2" @click="changeView">{{ viewIcon }}</span>
|
||||
<div class="relative flex items-center px-2">
|
||||
<span class="material-icons" @click="showFilterModal = true">filter_alt</span>
|
||||
<div v-show="hasFilters" class="absolute top-0 right-2 w-2 h-2 rounded-full bg-success border border-green-300 shadow-sm z-10 pointer-events-none" />
|
||||
</div>
|
||||
<span class="material-icons px-2" @click="showSortModal = true">sort</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<modals-order-modal v-model="showSortModal" :order-by.sync="settings.mobileOrderBy" :descending.sync="settings.mobileOrderDesc" @change="updateOrder" />
|
||||
<modals-filter-modal v-model="showFilterModal" :filter-by.sync="settings.mobileFilterBy" @change="updateFilter" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
showSortModal: false,
|
||||
showFilterModal: false,
|
||||
settings: {},
|
||||
isListView: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hasFilters() {
|
||||
return this.$store.getters['user/getUserSetting']('filterBy') !== 'all'
|
||||
},
|
||||
page() {
|
||||
var routeName = this.$route.name || ''
|
||||
return routeName.split('-')[1]
|
||||
},
|
||||
routeQuery() {
|
||||
return this.$route.query || {}
|
||||
},
|
||||
entityTitle() {
|
||||
if (this.page === 'library') return 'Audiobooks'
|
||||
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)
|
||||
}
|
||||
return null
|
||||
},
|
||||
viewIcon() {
|
||||
return this.isListView ? 'grid_view' : 'view_stream'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
changeView() {
|
||||
this.isListView = !this.isListView
|
||||
|
||||
var bookshelfView = this.isListView ? 'list' : 'grid'
|
||||
this.$localStore.setBookshelfView(bookshelfView)
|
||||
},
|
||||
updateOrder() {
|
||||
this.saveSettings()
|
||||
},
|
||||
updateFilter() {
|
||||
this.saveSettings()
|
||||
},
|
||||
saveSettings() {
|
||||
this.$store.commit('user/setSettings', this.settings) // Immediate update
|
||||
this.$store.dispatch('user/updateUserSettings', this.settings)
|
||||
},
|
||||
async init() {
|
||||
this.settings = { ...this.$store.state.user.settings }
|
||||
|
||||
var bookshelfView = await this.$localStore.getBookshelfView()
|
||||
this.isListView = bookshelfView === 'list'
|
||||
this.bookshelfReady = true
|
||||
console.log('Bookshelf view', bookshelfView)
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
for (const key in settings) {
|
||||
this.settings[key] = settings[key]
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated })
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#bookshelf-toolbar {
|
||||
box-shadow: 0px 5px 5px #11111155;
|
||||
}
|
||||
</style>
|
|
@ -7,8 +7,8 @@
|
|||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span>
|
||||
</div>
|
||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-4xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
<span v-if="item.value === selected" class="text-yellow-300 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-3xl">{{ descending ? 'south' : 'north' }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
|
|
80
components/tables/CollectionBooksTable.vue
Normal file
80
components/tables/CollectionBooksTable.vue
Normal file
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<div class="w-full bg-primary bg-opacity-40">
|
||||
<div class="w-full h-14 flex items-center px-4 bg-primary">
|
||||
<p>Collection List</p>
|
||||
<div class="w-6 h-6 bg-white bg-opacity-10 flex items-center justify-center rounded-full ml-2">
|
||||
<p class="font-mono text-sm">{{ books.length }}</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<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" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
collectionId: String,
|
||||
books: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
booksCopy: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
books: {
|
||||
handler(newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
totalDuration() {
|
||||
var _total = 0
|
||||
this.books.forEach((book) => {
|
||||
_total += book.duration
|
||||
})
|
||||
return _total
|
||||
},
|
||||
totalDurationPretty() {
|
||||
return this.$elapsedPretty(this.totalDuration)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editBook(book) {
|
||||
var bookIds = this.books.map((b) => b.id)
|
||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||
this.$store.commit('showEditModal', book)
|
||||
},
|
||||
init() {
|
||||
this.booksCopy = this.books.map((b) => ({ ...b }))
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.collection-book-item {
|
||||
transition: all 0.4s ease;
|
||||
}
|
||||
|
||||
.collection-book-enter-from,
|
||||
.collection-book-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
.collection-book-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
122
components/tables/collection/BookTableRow.vue
Normal file
122
components/tables/collection/BookTableRow.vue
Normal file
|
@ -0,0 +1,122 @@
|
|||
<template>
|
||||
<div class="w-full px-2 py-2 overflow-hidden relative">
|
||||
<div v-if="book" class="flex h-20">
|
||||
<div class="h-full relative" :style="{ width: '50px' }">
|
||||
<cards-book-cover :audiobook="book" :width="50" />
|
||||
</div>
|
||||
<div class="w-80 h-full px-2 flex items-center">
|
||||
<div>
|
||||
<nuxt-link :to="`/audiobook/${book.id}`" class="truncate hover:underline">{{ bookTitle }}</nuxt-link>
|
||||
<nuxt-link :to="`/bookshelf/library?filter=authors.${$encode(bookAuthor)}`" class="truncate block text-gray-400 text-sm hover:underline">{{ bookAuthor }}</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
collectionId: String,
|
||||
book: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessingReadUpdate: false,
|
||||
processingRemove: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
userIsRead: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
this.isRead = newVal
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
_book() {
|
||||
return this.book.book || {}
|
||||
},
|
||||
bookTitle() {
|
||||
return this._book.title || ''
|
||||
},
|
||||
bookAuthor() {
|
||||
return this._book.authorFL || ''
|
||||
},
|
||||
bookDuration() {
|
||||
return this.$secondsToTimestamp(this.book.duration)
|
||||
},
|
||||
isMissing() {
|
||||
return this.book.isMissing
|
||||
},
|
||||
isIncomplete() {
|
||||
return this.book.isIncomplete
|
||||
},
|
||||
numTracks() {
|
||||
return this.book.numTracks
|
||||
},
|
||||
isStreaming() {
|
||||
return this.$store.getters['getAudiobookIdStreaming'] === this.book.id
|
||||
},
|
||||
showPlayBtn() {
|
||||
return !this.isMissing && !this.isIncomplete && !this.isStreaming && this.numTracks
|
||||
},
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
||||
},
|
||||
userAudiobook() {
|
||||
return this.userAudiobooks[this.book.id] || null
|
||||
},
|
||||
userIsRead() {
|
||||
return this.userAudiobook ? !!this.userAudiobook.isRead : false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
playClick() {
|
||||
// this.$store.commit('setStreamAudiobook', this.book)
|
||||
// this.$root.socket.emit('open_stream', this.book.id)
|
||||
},
|
||||
clickEdit() {
|
||||
this.$emit('edit', this.book)
|
||||
},
|
||||
toggleRead() {
|
||||
var updatePayload = {
|
||||
isRead: !this.isRead
|
||||
}
|
||||
this.isProcessingReadUpdate = true
|
||||
this.$axios
|
||||
.$patch(`/api/user/audiobook/${this.book.id}`, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.success(`"${this.bookTitle}" 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'}`)
|
||||
})
|
||||
},
|
||||
removeClick() {
|
||||
this.processingRemove = true
|
||||
|
||||
this.$axios
|
||||
.$delete(`/api/collection/${this.collectionId}/book/${this.book.id}`)
|
||||
.then((updatedCollection) => {
|
||||
console.log(`Book removed from collection`, updatedCollection)
|
||||
this.$toast.success('Book removed from collection')
|
||||
this.processingRemove = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove book from collection', error)
|
||||
this.$toast.error('Failed to remove book from collection')
|
||||
this.processingRemove = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
Loading…
Add table
Add a link
Reference in a new issue