diff --git a/client/assets/app.css b/client/assets/app.css index b09310e9..6e37ca4b 100644 --- a/client/assets/app.css +++ b/client/assets/app.css @@ -102,3 +102,7 @@ .box-shadow-book { box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166; } + +.box-shadow-side { + box-shadow: 4px 0px 4px #11111166; +} diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index 6eef4350..3fca7849 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -7,15 +7,16 @@ <span class="material-icons text-4xl text-white">arrow_back</span> </a> <h1 class="text-2xl font-book mr-6">AudioBookshelf</h1> - + <!-- <div class="-mb-2"> + <h1 class="text-lg font-book leading-3 mr-6 px-1">AudioBookshelf</h1> + <div class="bg-black bg-opacity-20 rounded-sm py-1.5 px-2 mt-1.5 flex items-center justify-between border border-bg"> + <p class="text-sm text-gray-400 leading-3">My Library</p> + <span class="material-icons text-sm leading-3 text-gray-400">expand_more</span> + </div> + </div> --> <controls-global-search /> <div class="flex-grow" /> - <!-- <a v-if="isUpdateAvailable" :href="githubTagUrl" target="_blank" class="flex items-center rounded-full bg-warning p-2 text-sm"> - <span class="material-icons">notification_important</span> - <span class="pl-2">Update is available! Check release notes for v{{ latestVersion }}</span> - </a> --> - <nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center"> <span class="material-icons">upload</span> </nuxt-link> @@ -45,10 +46,8 @@ </ui-tooltip> <template v-if="userCanUpdate"> <ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" /> - <!-- <ui-btn v-show="!processingBatchDelete" color="warning" small class="mx-2 w-10 h-10" :padding-y="0" :padding-x="0" @click="batchEditClick"><span class="material-icons text-gray-200 text-base">edit</span></ui-btn> --> </template> <ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" /> - <!-- <ui-btn v-if="userCanDelete" color="error" small class="mx-2" :loading="processingBatchDelete" @click="batchDeleteClick"><span class="material-icons text-gray-200 pt-1">delete</span></ui-btn> --> <span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span> </div> </div> diff --git a/client/components/app/BookShelf.vue b/client/components/app/BookShelf.vue index a1fa55e7..797453b6 100644 --- a/client/components/app/BookShelf.vue +++ b/client/components/app/BookShelf.vue @@ -17,17 +17,18 @@ </div> </div> <div v-else class="w-full flex flex-col items-center"> - <template v-for="(shelf, index) in groupedBooks"> + <template v-for="(shelf, index) in entities"> <div :key="index" class="w-full bookshelfRow relative"> <div class="flex justify-center items-center"> - <template v-for="audiobook in shelf"> - <cards-book-card :ref="`audiobookCard-${audiobook.id}`" :key="audiobook.id" :width="bookCoverWidth" :user-progress="userAudiobooks[audiobook.id]" :audiobook="audiobook" /> + <template v-for="entity in shelf"> + <cards-group-card v-if="page !== ''" :key="entity.id" :width="bookCoverWidth" :group="entity" /> + <cards-book-card v-else :key="entity.id" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" /> </template> </div> <div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" /> </div> </template> - <div v-show="!groupedBooks.length" class="w-full py-16 text-center text-xl"> + <div v-show="!entities.length" class="w-full py-16 text-center text-xl"> <div class="py-4">No Audiobooks</div> <ui-btn v-if="filterBy !== 'all' || keywordFilter" @click="clearFilter">Clear Filter</ui-btn> </div> @@ -37,11 +38,14 @@ <script> export default { + props: { + page: String + }, data() { return { width: 0, booksPerRow: 0, - groupedBooks: [], + entities: [], currFilterOrderKey: null, availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220], selectedSizeIndex: 3, @@ -95,13 +99,13 @@ export default { filterBy: 'all' }) } else { - this.setGroupedBooks() + this.setBookshelfEntities() } }, checkKeywordFilter() { clearTimeout(this.keywordFilterTimeout) this.keywordFilterTimeout = setTimeout(() => { - this.setGroupedBooks() + this.setBookshelfEntities() }, 500) }, increaseSize() { @@ -114,27 +118,34 @@ export default { this.resize() this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth }) }, - setGroupedBooks() { + setBookshelfEntities() { + if (this.page === '') { + var audiobooksSorted = this.$store.getters['audiobooks/getFilteredAndSorted']() + this.currFilterOrderKey = this.filterOrderKey + this.setGroupedBooks(audiobooksSorted) + } else { + var entities = this.$store.getters['audiobooks/getSeriesGroups']() + this.setGroupedBooks(entities) + } + }, + setGroupedBooks(entities) { var groups = [] var currentRow = 0 var currentGroup = [] - var audiobooksSorted = this.$store.getters['audiobooks/getFilteredAndSorted']() - this.currFilterOrderKey = this.filterOrderKey - - for (let i = 0; i < audiobooksSorted.length; i++) { + for (let i = 0; i < entities.length; i++) { var row = Math.floor(i / this.booksPerRow) if (row > currentRow) { groups.push([...currentGroup]) currentRow = row currentGroup = [] } - currentGroup.push(audiobooksSorted[i]) + currentGroup.push(entities[i]) } if (currentGroup.length) { groups.push([...currentGroup]) } - this.groupedBooks = groups + this.entities = groups }, calculateBookshelf() { this.width = this.$refs.wrapper.clientWidth @@ -142,12 +153,6 @@ export default { var booksPerRow = Math.floor(this.width / this.bookWidth) this.booksPerRow = booksPerRow }, - getAudiobookCard(id) { - if (this.$refs[`audiobookCard-${id}`] && this.$refs[`audiobookCard-${id}`].length) { - return this.$refs[`audiobookCard-${id}`][0] - } - return null - }, init() { var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize') var sizeIndex = this.availableSizes.findIndex((s) => s === bookshelfCoverSize) @@ -157,16 +162,16 @@ export default { resize() { this.$nextTick(() => { this.calculateBookshelf() - this.setGroupedBooks() + this.setBookshelfEntities() }) }, audiobooksUpdated() { console.log('[AudioBookshelf] Audiobooks Updated') - this.setGroupedBooks() + this.setBookshelfEntities() }, settingsUpdated(settings) { if (this.currFilterOrderKey !== this.filterOrderKey) { - this.setGroupedBooks() + this.setBookshelfEntities() } if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) { var index = this.availableSizes.indexOf(settings.bookshelfCoverSize) diff --git a/client/components/app/SideRail.vue b/client/components/app/SideRail.vue new file mode 100644 index 00000000..a9239632 --- /dev/null +++ b/client/components/app/SideRail.vue @@ -0,0 +1,71 @@ +<template> + <div class="w-20 border-r border-primary bg-bg h-full relative box-shadow-side z-20"> + <nuxt-link to="/library" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === '' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> + </svg> + + <p class="font-book pt-1.5" style="font-size: 0.8rem">Library</p> + + <div v-show="paramId === ''" class="h-0.5 w-full bg-yellow-400 absolute bottom-0 left-0" /> + </nuxt-link> + + <nuxt-link to="/library/series" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" /> + </svg> + + <p class="font-book pt-1.5" style="font-size: 0.8rem">Series</p> + + <div v-show="paramId === 'series'" class="h-0.5 w-full bg-yellow-400 absolute bottom-0 left-0" /> + </nuxt-link> + + <nuxt-link to="/library/collections" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> + </svg> + + <p class="font-book pt-1.5" style="font-size: 0.8rem">Collections</p> + + <div v-show="paramId === 'collections'" class="h-0.5 w-full bg-yellow-400 absolute bottom-0 left-0" /> + </nuxt-link> + + <nuxt-link to="/library/tags" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'tags' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" /> + </svg> + + <p class="font-book pt-1.5" style="font-size: 0.8rem">Tags</p> + + <div v-show="paramId === 'tags'" class="h-0.5 w-full bg-yellow-400 absolute bottom-0 left-0" /> + </nuxt-link> + + <nuxt-link to="/library/authors" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'authors' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /> + </svg> + + <p class="font-book pt-1.5" style="font-size: 0.8rem">Authors</p> + + <div v-show="paramId === 'authors'" class="h-0.5 w-full bg-yellow-400 absolute bottom-0 left-0" /> + </nuxt-link> + </div> +</template> + +<script> +export default { + data() { + return {} + }, + computed: { + paramId() { + return this.$route.params ? this.$route.params.id || '' : '' + }, + selectedClassName() { + return '' + } + }, + methods: {}, + mounted() {} +} +</script> \ No newline at end of file diff --git a/client/components/cards/GroupCard.vue b/client/components/cards/GroupCard.vue new file mode 100644 index 00000000..41f528bf --- /dev/null +++ b/client/components/cards/GroupCard.vue @@ -0,0 +1,56 @@ +<template> + <div class="relative"> + <div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard"> + <nuxt-link :to="`/library`" class="cursor-pointer"> + <div class="w-full relative box-shadow-book bg-primary" :style="{ height: height + 'px', width: height + 'px' }"></div> + </nuxt-link> + </div> + <!-- <div :style="{ width: height + 'px', height: height + 'px' }" class="box-shadow-book bg-primary"> + <p class="text-white">{{ groupName }}</p> + </div> --> + </div> +</template> + +<script> +export default { + props: { + group: { + type: Object, + default: () => null + }, + width: { + type: Number, + default: 120 + } + }, + data() { + return { + isHovering: false + } + }, + computed: { + _group() { + return this.group || {} + }, + height() { + return this.width * 1.6 + }, + sizeMultiplier() { + return this.width / 120 + }, + paddingX() { + return 16 * this.sizeMultiplier + }, + books() { + return this._group.books || [] + }, + groupName() { + return this._group.name || 'No Name' + } + }, + methods: { + clickCard() {} + }, + mounted() {} +} +</script> \ No newline at end of file diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 01f3883f..8cd11e52 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -1,7 +1,9 @@ <template> <div class="text-white max-h-screen h-screen overflow-hidden bg-bg"> <app-appbar /> + <Nuxt /> + <app-stream-container ref="streamContainer" /> <modals-edit-modal /> <widgets-scan-alert /> diff --git a/client/pages/index.vue b/client/pages/index.vue index 2051a0c4..8ae060b9 100644 --- a/client/pages/index.vue +++ b/client/pages/index.vue @@ -1,7 +1,12 @@ <template> <div class="page" :class="streamAudiobook ? 'streaming' : ''"> <app-book-shelf-toolbar /> + <!-- <div class="flex h-full"> + <app-side-rail /> + <div class="flex-grow"> --> <app-book-shelf /> + <!-- </div> --> + <!-- </div> --> </div> </template> diff --git a/client/pages/library/_id.vue b/client/pages/library/_id.vue new file mode 100644 index 00000000..1988d19a --- /dev/null +++ b/client/pages/library/_id.vue @@ -0,0 +1,31 @@ +<template> + <div class="page" :class="streamAudiobook ? 'streaming' : ''"> + <div class="flex h-full"> + <app-side-rail /> + <div class="flex-grow"> + <app-book-shelf-toolbar /> + <app-book-shelf :page="id || ''" /> + </div> + </div> + </div> +</template> + +<script> +export default { + asyncData({ params }) { + return { + id: params.id + } + }, + data() { + return {} + }, + computed: { + streamAudiobook() { + return this.$store.state.streamAudiobook + } + }, + methods: {}, + mounted() {} +} +</script> \ No newline at end of file diff --git a/client/store/audiobooks.js b/client/store/audiobooks.js index d3a655fb..09643604 100644 --- a/client/store/audiobooks.js +++ b/client/store/audiobooks.js @@ -63,6 +63,23 @@ export const getters = { return value }) }, + getSeriesGroups: (state, getters, rootState) => () => { + var series = {} + state.audiobooks.forEach((audiobook) => { + if (audiobook.book && audiobook.book.series) { + if (series[audiobook.book.series]) { + series[audiobook.book.series].books.push(audiobook) + } else { + series[audiobook.book.series] = { + type: 'series', + name: audiobook.book.series, + books: [audiobook] + } + } + } + }) + return Object.values(series) + }, getUniqueAuthors: (state) => { var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author) return [...new Set(_authors)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1) diff --git a/package.json b/package.json index 6653ba08..ff6b24ea 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,17 @@ "dev": "node index.js", "start": "node index.js", "client": "cd client && npm install && npm run generate", - "prod": "npm run client && npm install && node prod.js" + "prod": "npm run client && npm install && node prod.js", + "build-win": "cd client && npm run generate && cd .. && pkg -t node12-win-x64 -o ./dist/app .", + "build-linux": "pkg -t node12-linux-arm64 -o ./dist/app ." + }, + "bin": "prod.js", + "pkg": { + "assets": "client/dist/**/*", + "scripts": [ + "prod.js", + "server/**/*.js" + ] }, "author": "advplyr", "license": "ISC", diff --git a/prod.js b/prod.js index 628f1e2f..8d457746 100644 --- a/prod.js +++ b/prod.js @@ -13,6 +13,7 @@ process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be34 process.env.NODE_ENV = 'production' const server = require('./server/Server') + global.appRoot = __dirname var inputConfig = options.config ? Path.resolve(options.config) : null @@ -24,7 +25,7 @@ const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('conf const AUDIOBOOK_PATH = inputAudiobook || process.env.AUDIOBOOK_PATH || Path.resolve('audiobooks') const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata') -console.log('Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) +console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) const Server = new server(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) Server.start() diff --git a/server/Server.js b/server/Server.js index 8ec318cf..5fb723e0 100644 --- a/server/Server.js +++ b/server/Server.js @@ -113,6 +113,7 @@ class Server { await this.streamManager.ensureStreamsDir() await this.streamManager.removeOrphanStreams() await this.downloadManager.removeOrphanDownloads() + await this.db.init() this.auth.init() @@ -171,7 +172,6 @@ class Server { async start() { Logger.info('=== Starting Server ===') - await this.init() const app = express()