mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-01 04:00:45 -05:00
Compare commits
192 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc8fec62a0 | ||
|
|
034d858f18 | ||
|
|
ebc9e1a888 | ||
|
|
c5a9c2bf5a | ||
|
|
3dbce8fd71 | ||
|
|
b2d299dba6 | ||
|
|
cb5d9a8287 | ||
|
|
f9530897c0 | ||
|
|
7c7e8285a4 | ||
|
|
7b3f9a1e0c | ||
|
|
399e0ea0bc | ||
|
|
a47b0bce57 | ||
|
|
4b60b4f73e | ||
|
|
d88b20addd | ||
|
|
5d12cc3f23 | ||
|
|
84fb7ce8b3 | ||
|
|
243cc672f7 | ||
|
|
663546dd77 | ||
|
|
1b79b3f42d | ||
|
|
d4525ad5ca | ||
|
|
dc9c307663 | ||
|
|
554e9ec238 | ||
|
|
2276228531 | ||
|
|
6f7d2ef4cd | ||
|
|
ad3fbe7abf | ||
|
|
c58110c7b7 | ||
|
|
f781fa9e6b | ||
|
|
7f3543400a | ||
|
|
1ff5637c1b | ||
|
|
f2d9de5a5f | ||
|
|
8be3bebee8 | ||
|
|
ef88972b25 | ||
|
|
35f3b5863f | ||
|
|
ff294867f8 | ||
|
|
1c6cd7499b | ||
|
|
ce35ae6b03 | ||
|
|
28c99cf17f | ||
|
|
584e754eae | ||
|
|
68cf748e77 | ||
|
|
9b8f53caf6 | ||
|
|
fdf332937f | ||
|
|
182545a729 | ||
|
|
e83df2bf4b | ||
|
|
10299e3037 | ||
|
|
6a43672973 | ||
|
|
02bf55b401 | ||
|
|
f0615c2971 | ||
|
|
7ef44eb75b | ||
|
|
044804115b | ||
|
|
3b941d59a3 | ||
|
|
d69f6020c6 | ||
|
|
2fc60e4e9c | ||
|
|
cdcfd01da2 | ||
|
|
d6c5b6e8c6 | ||
|
|
5d305c96ad | ||
|
|
6d823f4e42 | ||
|
|
bd5e865a11 | ||
|
|
cd274e0844 | ||
|
|
e9249430c3 | ||
|
|
cd5e5099f2 | ||
|
|
09dd90e3fc | ||
|
|
a62f7a4861 | ||
|
|
5a26b01ffb | ||
|
|
cbde451120 | ||
|
|
8bbeae4873 | ||
|
|
05dff2583a | ||
|
|
79a82df914 | ||
|
|
3f6ed6dbf9 | ||
|
|
4edba20e9e | ||
|
|
2c6e1cc2b5 | ||
|
|
e1af25d9d8 | ||
|
|
9b30a8ff4b | ||
|
|
b1a9de819e | ||
|
|
68da974c12 | ||
|
|
8c47ccb651 | ||
|
|
d544ecc657 | ||
|
|
9f69a8ace3 | ||
|
|
a90cfc4d04 | ||
|
|
88354de495 | ||
|
|
5b02c5185f | ||
|
|
1152e5513e | ||
|
|
8ce9b55969 | ||
|
|
ccf08e9e80 | ||
|
|
b0b1d2707d | ||
|
|
469278cd1e | ||
|
|
10d9e11387 | ||
|
|
5328f4cddb | ||
|
|
4154022ad1 | ||
|
|
642e9787c0 | ||
|
|
da2e65c042 | ||
|
|
ab895fa8ed | ||
|
|
f5e892b862 | ||
|
|
ac097862fc | ||
|
|
23cc6bb210 | ||
|
|
c60807f998 | ||
|
|
99e2ea228d | ||
|
|
8df05896b5 | ||
|
|
174dac8fd4 | ||
|
|
2a386ca2a9 | ||
|
|
fc228013d3 | ||
|
|
64b824ef6b | ||
|
|
96cd91a385 | ||
|
|
5c91c1e2c7 | ||
|
|
2df5ab0dde | ||
|
|
baf738f5ba | ||
|
|
3a7cafbb95 | ||
|
|
3276b04256 | ||
|
|
ac3fa31d1e | ||
|
|
6e5e638076 | ||
|
|
609bf4309f | ||
|
|
66b5c14c6b | ||
|
|
e4936ed522 | ||
|
|
c201e2aa98 | ||
|
|
3d3f20296c | ||
|
|
9ae71615bc | ||
|
|
292840a0e3 | ||
|
|
84e6e6fdbe | ||
|
|
cfe27dff80 | ||
|
|
c75895d711 | ||
|
|
c0ff28ffff | ||
|
|
58dfa65660 | ||
|
|
3f8e685d64 | ||
|
|
08e1782253 | ||
|
|
0dd219f303 | ||
|
|
d5e96a3422 | ||
|
|
03bfecefee | ||
|
|
12027b9a76 | ||
|
|
0e665e2091 | ||
|
|
e32d05ea27 | ||
|
|
5446aea910 | ||
|
|
86e7c7fc33 | ||
|
|
173b72c3b5 | ||
|
|
3150822117 | ||
|
|
9a96d17a30 | ||
|
|
c98409b9ae | ||
|
|
0e3640c246 | ||
|
|
e030b59bae | ||
|
|
920ca683b9 | ||
|
|
28d76d21f1 | ||
|
|
e1e6b46456 | ||
|
|
122f2a2556 | ||
|
|
27f1bd90f9 | ||
|
|
f8d0384155 | ||
|
|
43bbfbfee3 | ||
|
|
deadc63dbb | ||
|
|
a9b9e23f46 | ||
|
|
6a06ba4327 | ||
|
|
3d2bbc7719 | ||
|
|
c9ea5dd2d7 | ||
|
|
eea3e2583c | ||
|
|
57399bb79e | ||
|
|
69fcb103e4 | ||
|
|
f00b120e96 | ||
|
|
14a8f84446 | ||
|
|
099ae7c776 | ||
|
|
1cf9e85272 | ||
|
|
c4eeb1cfb7 | ||
|
|
1dde02b170 | ||
|
|
08e648a3bc | ||
|
|
755e70b4a9 | ||
|
|
5ff4cd2c0b | ||
|
|
e36c31c5e7 | ||
|
|
d561a48229 | ||
|
|
5243a225e8 | ||
|
|
4fe60465e5 | ||
|
|
0af6ad63c1 | ||
|
|
68b13ae45f | ||
|
|
4c2ad3ede5 | ||
|
|
deea6702f0 | ||
|
|
7348432594 | ||
|
|
7d66f1eec9 | ||
|
|
be1e1e7ba0 | ||
|
|
4bdef893af | ||
|
|
6597fca576 | ||
|
|
ea9ec13845 | ||
|
|
30f15d3575 | ||
|
|
dad12537b6 | ||
|
|
65df377a49 | ||
|
|
2d19208340 | ||
|
|
73257188f6 | ||
|
|
5f4e5cd3d8 | ||
|
|
f2be3bc95e | ||
|
|
2a30cc428f | ||
|
|
b97ed953f7 | ||
|
|
65793f7109 | ||
|
|
2b7f53b0a7 | ||
|
|
c6eb1096e8 | ||
|
|
a907c88f66 | ||
|
|
43f48b65f8 | ||
|
|
2a4cbd48b8 | ||
|
|
b6e4f3a8c5 | ||
|
|
83976b5549 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,6 +4,7 @@ node_modules/
|
||||
/config/
|
||||
/audiobooks/
|
||||
/audiobooks2/
|
||||
/podcasts/
|
||||
/media/
|
||||
/metadata/
|
||||
test/
|
||||
@@ -11,4 +12,5 @@ test/
|
||||
/client/dist/
|
||||
/dist/
|
||||
|
||||
sw.*
|
||||
sw.*
|
||||
.DS_STORE
|
||||
@@ -1,12 +1,12 @@
|
||||
### STAGE 0: Build client ###
|
||||
FROM node:12-alpine AS build
|
||||
FROM node:16-alpine AS build
|
||||
WORKDIR /client
|
||||
COPY /client /client
|
||||
RUN npm install
|
||||
RUN npm run generate
|
||||
|
||||
### STAGE 1: Build server ###
|
||||
FROM node:12-alpine
|
||||
FROM node:16-alpine
|
||||
RUN apk update && apk add --no-cache --update ffmpeg
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=build /client/dist /client/dist
|
||||
|
||||
@@ -6,6 +6,7 @@ FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
|
||||
DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks"
|
||||
DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
|
||||
DEFAULT_PORT=7331
|
||||
DEFAULT_HOST="0.0.0.0"
|
||||
|
||||
CONFIG_PATH="/etc/default/audiobookshelf"
|
||||
|
||||
@@ -82,7 +83,8 @@ setup_config_interactive() {
|
||||
CONFIG_PATH=$DATA_PATH/config
|
||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
||||
PORT=$PORT"
|
||||
PORT=$PORT
|
||||
HOST=$DEFAULT_HOST"
|
||||
|
||||
echo "$config_text"
|
||||
|
||||
@@ -105,7 +107,8 @@ setup_config() {
|
||||
CONFIG_PATH=$DEFAULT_DATA_PATH/config
|
||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
||||
PORT=$DEFAULT_PORT"
|
||||
PORT=$DEFAULT_PORT
|
||||
HOST=$DEFAULT_HOST"
|
||||
|
||||
echo "$config_text"
|
||||
|
||||
|
||||
@@ -187,3 +187,15 @@ Bookshelf Label
|
||||
opacity: 1;
|
||||
filter: blur(20px);
|
||||
}
|
||||
|
||||
|
||||
.episode-subtitle {
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
line-height: 16px; /* fallback */
|
||||
max-height: 32px; /* fallback */
|
||||
-webkit-line-clamp: 2; /* number of lines to show */
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
||||
<div v-if="!isPodcast" class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
||||
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
||||
</div>
|
||||
|
||||
@@ -99,7 +99,8 @@ export default {
|
||||
default: () => []
|
||||
},
|
||||
sleepTimerSet: Boolean,
|
||||
sleepTimerRemaining: Number
|
||||
sleepTimerRemaining: Number,
|
||||
isPodcast: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -23,15 +23,15 @@
|
||||
</div>
|
||||
|
||||
<nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<span class="material-icons">equalizer</span>
|
||||
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
|
||||
</nuxt-link>
|
||||
|
||||
<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 mx-1">
|
||||
<span class="material-icons">upload</span>
|
||||
<span class="material-icons" aria-label="Upload Media" role="button">upload</span>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<span class="material-icons">settings</span>
|
||||
<span class="material-icons" aria-label="System Settings" role="button">settings</span>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md: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">
|
||||
@@ -44,16 +44,16 @@
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
||||
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
||||
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1>
|
||||
<div v-show="numLibraryItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
||||
<h1 class="text-2xl px-4">{{ numLibraryItemsSelected }} Selected</h1>
|
||||
<div class="flex-grow" />
|
||||
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
|
||||
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" />
|
||||
<ui-tooltip v-if="!isPodcastLibrary" :text="`Mark as ${selectedIsFinished ? 'Not Finished' : 'Finished'}`" direction="bottom">
|
||||
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
|
||||
</ui-tooltip>
|
||||
<ui-tooltip v-if="userCanUpdate" text="Add to Collection" direction="bottom">
|
||||
<ui-tooltip v-if="userCanUpdate && !isPodcastLibrary" text="Add to Collection" direction="bottom">
|
||||
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
|
||||
</ui-tooltip>
|
||||
<template v-if="userCanUpdate && numAudiobooksSelected < 50">
|
||||
<template v-if="userCanUpdate && numLibraryItemsSelected < 50">
|
||||
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
||||
</template>
|
||||
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
||||
@@ -79,6 +79,12 @@ export default {
|
||||
libraryName() {
|
||||
return this.currentLibrary ? this.currentLibrary.name : 'unknown'
|
||||
},
|
||||
libraryMediaType() {
|
||||
return this.currentLibrary ? this.currentLibrary.mediaType : null
|
||||
},
|
||||
isPodcastLibrary() {
|
||||
return this.libraryMediaType === 'podcast'
|
||||
},
|
||||
isHome() {
|
||||
return this.$route.name === 'library-library'
|
||||
},
|
||||
@@ -94,17 +100,14 @@ export default {
|
||||
username() {
|
||||
return this.user ? this.user.username : 'err'
|
||||
},
|
||||
numAudiobooksSelected() {
|
||||
return this.selectedAudiobooks.length
|
||||
numLibraryItemsSelected() {
|
||||
return this.selectedLibraryItems.length
|
||||
},
|
||||
selectedAudiobooks() {
|
||||
return this.$store.state.selectedAudiobooks
|
||||
selectedLibraryItems() {
|
||||
return this.$store.state.selectedLibraryItems
|
||||
},
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user.user.audiobooks || {}
|
||||
},
|
||||
selectedSeries() {
|
||||
return this.$store.state.audiobooks.selectedSeries
|
||||
userMediaProgress() {
|
||||
return this.$store.state.user.user.mediaProgress || []
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
@@ -115,11 +118,11 @@ export default {
|
||||
userCanUpload() {
|
||||
return this.$store.getters['user/getUserCanUpload']
|
||||
},
|
||||
selectedIsRead() {
|
||||
// Find an audiobook that is not read, if none then all audiobooks read
|
||||
return !this.selectedAudiobooks.find((ab) => {
|
||||
var userAb = this.userAudiobooks[ab]
|
||||
return !userAb || !userAb.isRead
|
||||
selectedIsFinished() {
|
||||
// Find an item that is not finished, if none then all items finished
|
||||
return !this.selectedLibraryItems.find((libraryItemId) => {
|
||||
var itemProgress = this.userMediaProgress.find((lip) => lip.libraryItemId === libraryItemId)
|
||||
return !itemProgress || !itemProgress.isFinished
|
||||
})
|
||||
},
|
||||
processingBatch() {
|
||||
@@ -150,25 +153,26 @@ export default {
|
||||
},
|
||||
cancelSelectionMode() {
|
||||
if (this.processingBatchDelete) return
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
this.$store.commit('setSelectedLibraryItems', [])
|
||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||
this.isAllSelected = false
|
||||
},
|
||||
toggleBatchRead() {
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
var newIsRead = !this.selectedIsRead
|
||||
var updateProgressPayloads = this.selectedAudiobooks.map((ab) => {
|
||||
var newIsFinished = !this.selectedIsFinished
|
||||
var updateProgressPayloads = this.selectedLibraryItems.map((lid) => {
|
||||
return {
|
||||
audiobookId: ab,
|
||||
isRead: newIsRead
|
||||
id: lid,
|
||||
isFinished: newIsFinished
|
||||
}
|
||||
})
|
||||
console.log('Progress payloads', updateProgressPayloads)
|
||||
this.$axios
|
||||
.patch(`/api/me/audiobook/batch/update`, updateProgressPayloads)
|
||||
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||
.then(() => {
|
||||
this.$toast.success('Batch update success!')
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
this.$store.commit('setSelectedLibraryItems', [])
|
||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -178,20 +182,20 @@ export default {
|
||||
})
|
||||
},
|
||||
batchDeleteClick() {
|
||||
var audiobookText = this.numAudiobooksSelected > 1 ? `these ${this.numAudiobooksSelected} audiobooks` : 'this audiobook'
|
||||
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the audiobooks from AudioBookshelf`
|
||||
var audiobookText = this.numLibraryItemsSelected > 1 ? `these ${this.numLibraryItemsSelected} items` : 'this item'
|
||||
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf`
|
||||
if (confirm(confirmMsg)) {
|
||||
this.processingBatchDelete = true
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
this.$axios
|
||||
.$post(`/api/books/batch/delete`, {
|
||||
audiobookIds: this.selectedAudiobooks
|
||||
.$post(`/api/items/batch/delete`, {
|
||||
libraryItemIds: this.selectedLibraryItems
|
||||
})
|
||||
.then(() => {
|
||||
this.$toast.success('Batch delete success!')
|
||||
this.processingBatchDelete = false
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
this.$store.commit('setSelectedLibraryItems', [])
|
||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
<template>
|
||||
<div class="outer-container">
|
||||
<!-- absolute positioned container -->
|
||||
<div class="inner-container">
|
||||
<div class="relative h-10">
|
||||
<div class="table-header" id="headerdiv">
|
||||
<table id="headertable" width="100%" cellpadding="0" cellspacing="0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="header-cell min-w-12 max-w-12"></th>
|
||||
<th class="header-cell min-w-6 max-w-6"></th>
|
||||
<th class="header-cell min-w-64 max-w-64 px-2">Title</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Author</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Series</th>
|
||||
<th class="header-cell min-w-24 max-w-24 px-2">Year</th>
|
||||
<th class="header-cell min-w-80 max-w-80 px-2">Description</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Narrator</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Genres</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Tags</th>
|
||||
<th class="header-cell min-w-24 max-w-24 px-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
<div class="absolute top-0 left-0 w-full h-full pointer-events-none" :class="isScrollable ? 'header-shadow' : ''" />
|
||||
</div>
|
||||
|
||||
<div ref="tableBody" class="table-body" onscroll="document.getElementById('headerdiv').scrollLeft = this.scrollLeft;" @scroll="tableScrolled">
|
||||
<table id="bodytable" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tbody>
|
||||
<template v-for="book in books">
|
||||
<app-book-list-row :key="book.id" :book="book" @edit="editBook" />
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
books: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isScrollable: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
checkIsScrolled() {
|
||||
if (!this.$refs.tableBody) return
|
||||
this.isScrollable = this.$refs.tableBody.scrollTop > 0
|
||||
},
|
||||
tableScrolled() {
|
||||
this.checkIsScrolled()
|
||||
},
|
||||
editBook(book) {
|
||||
var bookIds = this.books.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||
this.$store.commit('showEditModal', book)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.checkIsScrolled()
|
||||
},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.outer-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: visible;
|
||||
height: calc(100% - 50px);
|
||||
width: calc(100% - 10px);
|
||||
margin: 10px;
|
||||
}
|
||||
.inner-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.table-header {
|
||||
float: left;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
.header-shadow {
|
||||
box-shadow: 3px 8px 3px #11111155;
|
||||
}
|
||||
.table-body {
|
||||
float: left;
|
||||
height: 100%;
|
||||
width: inherit;
|
||||
overflow-y: scroll;
|
||||
padding-right: 0px;
|
||||
}
|
||||
.header-cell {
|
||||
background-color: #22222288;
|
||||
padding: 0px 4px;
|
||||
text-align: left;
|
||||
height: 40px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: semi-bold;
|
||||
}
|
||||
.body-cell {
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.book-row {
|
||||
background-color: #22222288;
|
||||
}
|
||||
.book-row:nth-child(odd) {
|
||||
background-color: #333;
|
||||
}
|
||||
.book-row.selected {
|
||||
background-color: rgba(0, 255, 0, 0.05);
|
||||
}
|
||||
</style>
|
||||
@@ -1,179 +0,0 @@
|
||||
<template>
|
||||
<tr class="book-row" :class="selected ? 'selected' : ''">
|
||||
<td class="body-cell min-w-12 max-w-12">
|
||||
<div class="flex justify-center">
|
||||
<div class="bg-white border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center focus-within:border-blue-500 w-4 h-4" @click="selectBtnClick">
|
||||
<svg v-if="selected" class="fill-current text-green-500 pointer-events-none w-2.5 h-2.5" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="body-cell min-w-6 max-w-6">
|
||||
<covers-hover-book-cover :audiobook="book" />
|
||||
</td>
|
||||
<td class="body-cell min-w-64 max-w-64 px-2">
|
||||
<nuxt-link :to="`/audiobook/${book.id}`" class="hover:underline">
|
||||
<p class="truncate">
|
||||
{{ book.book.title }}<span v-if="book.book.subtitle">: {{ book.book.subtitle }}</span>
|
||||
</p>
|
||||
</nuxt-link>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ book.book.authorFL }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ seriesText }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-24 max-w-24 px-2">
|
||||
<p class="truncate">{{ book.book.publishYear }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-80 max-w-80 px-2">
|
||||
<p class="truncate">{{ book.book.description }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ book.book.narrator }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ genresText }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ tagsText }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-24 max-w-24 px-2">
|
||||
<div class="flex">
|
||||
<span v-if="userCanUpdate" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="editClick">edit</span>
|
||||
<span v-if="showPlayButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-2xl mx-1" @click="startStream">play_arrow</span>
|
||||
<span v-if="showReadButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="openEbook">auto_stories</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
book: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
userAudiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessingReadUpdate: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
audiobookId() {
|
||||
return this.book.id
|
||||
},
|
||||
selected: {
|
||||
get() {
|
||||
return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
|
||||
},
|
||||
set(val) {
|
||||
if (this.processingBatch) return
|
||||
this.$store.commit('setAudiobookSelected', { audiobookId: this.audiobookId, selected: val })
|
||||
}
|
||||
},
|
||||
processingBatch() {
|
||||
return this.$store.state.processingBatch
|
||||
},
|
||||
bookObj() {
|
||||
return this.book.book || {}
|
||||
},
|
||||
series() {
|
||||
return this.bookObj.series || null
|
||||
},
|
||||
volumeNumber() {
|
||||
return this.bookObj.volumeNumber || null
|
||||
},
|
||||
seriesText() {
|
||||
if (!this.series) return ''
|
||||
if (!this.volumeNumber) return this.series
|
||||
return `${this.series} #${this.volumeNumber}`
|
||||
},
|
||||
genresText() {
|
||||
if (!this.bookObj.genres) return ''
|
||||
return this.bookObj.genres.join(', ')
|
||||
},
|
||||
tagsText() {
|
||||
return (this.book.tags || []).join(', ')
|
||||
},
|
||||
isMissing() {
|
||||
return this.book.isMissing
|
||||
},
|
||||
isInvalid() {
|
||||
return this.book.isInvalid
|
||||
},
|
||||
numEbooks() {
|
||||
return this.book.numEbooks
|
||||
},
|
||||
numTracks() {
|
||||
return this.book.numTracks
|
||||
},
|
||||
isStreaming() {
|
||||
return this.$store.getters['getAudiobookIdStreaming'] === this.audiobookId
|
||||
},
|
||||
showReadButton() {
|
||||
return this.showExperimentalFeatures && this.numEbooks
|
||||
},
|
||||
showPlayButton() {
|
||||
return !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
|
||||
},
|
||||
userIsRead() {
|
||||
return this.userAudiobook ? !!this.userAudiobook.isRead : false
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectBtnClick() {
|
||||
if (this.processingBatch) return
|
||||
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
|
||||
},
|
||||
openEbook() {
|
||||
this.$store.commit('showEReader', this.book)
|
||||
},
|
||||
downloadClick() {
|
||||
this.$store.commit('showEditModalOnTab', { audiobook: this.book, tab: 'download' })
|
||||
},
|
||||
toggleRead() {
|
||||
var updatePayload = {
|
||||
isRead: !this.userIsRead
|
||||
}
|
||||
this.isProcessingReadUpdate = true
|
||||
this.$axios
|
||||
.$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
||||
})
|
||||
},
|
||||
startStream() {
|
||||
this.$eventBus.$emit('play-audiobook', this.book.id)
|
||||
},
|
||||
editClick() {
|
||||
this.$emit('edit', this.book)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -7,13 +7,16 @@
|
||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
|
||||
</div>
|
||||
|
||||
<div v-if="loaded && !shelves.length && isRootUser" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">Audiobookshelf is empty!</p>
|
||||
<div v-if="loaded && !shelves.length && isRootUser && !search" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||
<div class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
|
||||
<p class="text-center text-xl font-book py-4">No results for query</p>
|
||||
</div>
|
||||
<div v-else class="w-full flex flex-col items-center">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
@@ -50,6 +53,9 @@ export default {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
libraryName() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||
},
|
||||
bookCoverWidth() {
|
||||
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
||||
@@ -85,7 +91,7 @@ export default {
|
||||
},
|
||||
async fetchCategories() {
|
||||
var categories = await this.$axios
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/categories?minified=1`)
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`)
|
||||
.then((data) => {
|
||||
return data
|
||||
})
|
||||
@@ -97,53 +103,61 @@ export default {
|
||||
},
|
||||
async setShelvesFromSearch() {
|
||||
var shelves = []
|
||||
if (this.results.audiobooks) {
|
||||
if (this.results.books && this.results.books.length) {
|
||||
shelves.push({
|
||||
id: 'audiobooks',
|
||||
id: 'books',
|
||||
label: 'Books',
|
||||
type: 'books',
|
||||
entities: this.results.audiobooks.map((ab) => ab.audiobook)
|
||||
type: 'book',
|
||||
entities: this.results.books.map((res) => res.libraryItem)
|
||||
})
|
||||
}
|
||||
|
||||
if (this.results.series) {
|
||||
if (this.results.podcasts && this.results.podcasts.length) {
|
||||
shelves.push({
|
||||
id: 'podcasts',
|
||||
label: 'Podcasts',
|
||||
type: 'podcast',
|
||||
entities: this.results.podcasts.map((res) => res.libraryItem)
|
||||
})
|
||||
}
|
||||
|
||||
if (this.results.series && this.results.series.length) {
|
||||
shelves.push({
|
||||
id: 'series',
|
||||
label: 'Series',
|
||||
type: 'series',
|
||||
entities: this.results.series.map((seriesObj) => {
|
||||
return {
|
||||
name: seriesObj.series,
|
||||
books: seriesObj.audiobooks,
|
||||
name: seriesObj.series.name,
|
||||
series: seriesObj.series,
|
||||
books: seriesObj.books,
|
||||
type: 'series'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
if (this.results.tags) {
|
||||
if (this.results.tags && this.results.tags.length) {
|
||||
shelves.push({
|
||||
id: 'tags',
|
||||
label: 'Tags',
|
||||
type: 'tags',
|
||||
entities: this.results.tags.map((tagObj) => {
|
||||
return {
|
||||
name: tagObj.tag,
|
||||
books: tagObj.audiobooks,
|
||||
name: tagObj.name,
|
||||
books: tagObj.books || [],
|
||||
type: 'tags'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
if (this.results.authors) {
|
||||
if (this.results.authors && this.results.authors.length) {
|
||||
shelves.push({
|
||||
id: 'authors',
|
||||
label: 'Authors',
|
||||
type: 'authors',
|
||||
entities: this.results.authors.map((a) => {
|
||||
return {
|
||||
id: a.author,
|
||||
name: a.author,
|
||||
numBooks: a.numBooks,
|
||||
...a,
|
||||
type: 'author'
|
||||
}
|
||||
})
|
||||
@@ -153,74 +167,98 @@ export default {
|
||||
},
|
||||
settingsUpdated(settings) {},
|
||||
scan() {
|
||||
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
|
||||
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
||||
},
|
||||
audiobookAdded(audiobook) {
|
||||
console.log('Audiobook added', audiobook)
|
||||
// TODO: Check if audiobook would be on this shelf
|
||||
libraryItemAdded(libraryItem) {
|
||||
console.log('libraryItem added', libraryItem)
|
||||
// TODO: Check if libraryItem would be on this shelf
|
||||
if (!this.search) {
|
||||
this.fetchCategories()
|
||||
}
|
||||
},
|
||||
audiobookUpdated(audiobook) {
|
||||
console.log('Audiobook updated', audiobook)
|
||||
libraryItemUpdated(libraryItem) {
|
||||
console.log('libraryItem updated', libraryItem)
|
||||
this.shelves.forEach((shelf) => {
|
||||
if (shelf.type === 'books') {
|
||||
if (shelf.type == 'book' || shelf.type == 'podcast') {
|
||||
shelf.entities = shelf.entities.map((ent) => {
|
||||
if (ent.id === audiobook.id) {
|
||||
return audiobook
|
||||
if (ent.id === libraryItem.id) {
|
||||
return libraryItem
|
||||
}
|
||||
return ent
|
||||
})
|
||||
} else if (shelf.type === 'series') {
|
||||
shelf.entities.forEach((ent) => {
|
||||
ent.books = ent.books.map((book) => {
|
||||
if (book.id === audiobook.id) return audiobook
|
||||
if (book.id === libraryItem.id) return libraryItem
|
||||
return book
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
removeBookFromShelf(audiobook) {
|
||||
removeBookFromShelf(libraryItem) {
|
||||
this.shelves.forEach((shelf) => {
|
||||
if (shelf.type === 'books') {
|
||||
if (shelf.type == 'book' || shelf.type == 'podcast') {
|
||||
shelf.entities = shelf.entities.filter((ent) => {
|
||||
return ent.id !== audiobook.id
|
||||
return ent.id !== libraryItem.id
|
||||
})
|
||||
} else if (shelf.type === 'series') {
|
||||
shelf.entities.forEach((ent) => {
|
||||
ent.books = ent.books.filter((book) => {
|
||||
return book.id !== audiobook.id
|
||||
return book.id !== libraryItem.id
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
audiobookRemoved(audiobook) {
|
||||
this.removeBookFromShelf(audiobook)
|
||||
libraryItemRemoved(libraryItem) {
|
||||
this.removeBookFromShelf(libraryItem)
|
||||
},
|
||||
audiobooksAdded(audiobooks) {
|
||||
console.log('audiobooks added', audiobooks)
|
||||
libraryItemsAdded(libraryItems) {
|
||||
console.log('libraryItems added', libraryItems)
|
||||
// TODO: Check if audiobook would be on this shelf
|
||||
if (!this.search) {
|
||||
this.fetchCategories()
|
||||
}
|
||||
},
|
||||
audiobooksUpdated(audiobooks) {
|
||||
audiobooks.forEach((ab) => {
|
||||
this.audiobookUpdated(ab)
|
||||
libraryItemsUpdated(items) {
|
||||
items.forEach((li) => {
|
||||
this.libraryItemUpdated(li)
|
||||
})
|
||||
},
|
||||
authorUpdated(author) {
|
||||
this.shelves.forEach((shelf) => {
|
||||
if (shelf.type == 'authors') {
|
||||
shelf.entities = shelf.entities.map((ent) => {
|
||||
if (ent.id === author.id) {
|
||||
return {
|
||||
...ent,
|
||||
...author
|
||||
}
|
||||
}
|
||||
return ent
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
authorRemoved(author) {
|
||||
this.shelves.forEach((shelf) => {
|
||||
if (shelf.type == 'authors') {
|
||||
shelf.entities = shelf.entities.filter((ent) => ent.id != author.id)
|
||||
}
|
||||
})
|
||||
},
|
||||
initListeners() {
|
||||
this.$store.commit('user/addSettingsListener', { id: '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)
|
||||
this.$root.socket.on('author_updated', this.authorUpdated)
|
||||
this.$root.socket.on('author_removed', this.authorRemoved)
|
||||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||
this.$root.socket.on('item_added', this.libraryItemAdded)
|
||||
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
||||
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
||||
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
||||
} else {
|
||||
console.error('Error socket not initialized')
|
||||
}
|
||||
@@ -229,11 +267,13 @@ export default {
|
||||
this.$store.commit('user/removeSettingsListener', '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)
|
||||
this.$root.socket.off('author_updated', this.authorUpdated)
|
||||
this.$root.socket.off('author_removed', this.authorRemoved)
|
||||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||
this.$root.socket.off('item_added', this.libraryItemAdded)
|
||||
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
||||
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
||||
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
||||
} else {
|
||||
console.error('Error socket not initialized')
|
||||
}
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
<div class="relative">
|
||||
<div ref="shelf" class="w-full max-w-full categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
|
||||
<div class="w-full h-full pt-6">
|
||||
<div v-if="shelf.type === 'books'" class="flex items-center">
|
||||
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
|
||||
<template v-for="(entity, index) in shelf.entities">
|
||||
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectBook" @edit="editBook" />
|
||||
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editBook" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
||||
<template v-for="(entity, index) in shelf.entities">
|
||||
<cards-lazy-book-card :key="entity.recentEpisode.id" :ref="`shelf-episode-${entity.recentEpisode.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editEpisode" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'series'" class="flex items-center">
|
||||
@@ -21,8 +26,8 @@
|
||||
</div>
|
||||
<div v-if="shelf.type === 'authors'" class="flex items-center">
|
||||
<template v-for="entity in shelf.entities">
|
||||
<nuxt-link :key="entity.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(entity.name)}`">
|
||||
<cards-author-card :width="bookCoverWidth" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" />
|
||||
<nuxt-link :key="entity.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(entity.id)}`">
|
||||
<cards-author-card :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</div>
|
||||
@@ -43,6 +48,7 @@
|
||||
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollRight">
|
||||
<span class="material-icons text-6xl text-white">chevron_right</span>
|
||||
</div>
|
||||
<modals-authors-edit-modal v-model="showAuthorModal" :author="selectedAuthor" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -64,12 +70,9 @@ export default {
|
||||
canScrollLeft: false,
|
||||
isScrolling: false,
|
||||
scrollTimer: null,
|
||||
updateTimer: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
isSelectionMode(newVal) {
|
||||
this.updateSelectionMode(newVal)
|
||||
updateTimer: null,
|
||||
showAuthorModal: false,
|
||||
selectedAuthor: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -79,9 +82,6 @@ export default {
|
||||
shelfHeight() {
|
||||
return this.bookCoverHeight + 48
|
||||
},
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
||||
},
|
||||
paddingLeft() {
|
||||
if (window.innerWidth < 768) return 1
|
||||
return 2.5
|
||||
@@ -90,29 +90,55 @@ export default {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumAudiobooksSelected'] > 0
|
||||
return this.$store.getters['getNumLibraryItemsSelected'] > 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clearSelectedEntities() {
|
||||
this.updateSelectionMode(false)
|
||||
},
|
||||
editAuthor(author) {
|
||||
this.selectedAuthor = author
|
||||
this.showAuthorModal = true
|
||||
},
|
||||
editBook(audiobook) {
|
||||
var bookIds = this.shelf.entities.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||
this.$store.commit('showEditModal', audiobook)
|
||||
},
|
||||
editEpisode({ libraryItem, episode }) {
|
||||
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||
},
|
||||
updateSelectionMode(val) {
|
||||
var selectedAudiobooks = this.$store.state.selectedAudiobooks
|
||||
if (this.shelf.type === 'books') {
|
||||
var selectedLibraryItems = this.$store.state.selectedLibraryItems
|
||||
if (this.shelf.type === 'book' || this.shelf.type === 'podcast') {
|
||||
this.shelf.entities.forEach((ent) => {
|
||||
var component = this.$refs[`shelf-book-${ent.id}`]
|
||||
if (!component || !component.length) return
|
||||
component = component[0]
|
||||
component.setSelectionMode(val)
|
||||
component.selected = selectedAudiobooks.includes(ent.id)
|
||||
component.selected = selectedLibraryItems.includes(ent.id)
|
||||
})
|
||||
} else if (this.shelf.type === 'episode') {
|
||||
this.shelf.entities.forEach((ent) => {
|
||||
var component = this.$refs[`shelf-episode-${ent.recentEpisode.id}`]
|
||||
if (!component || !component.length) return
|
||||
component = component[0]
|
||||
component.setSelectionMode(val)
|
||||
component.selected = selectedLibraryItems.includes(ent.id)
|
||||
})
|
||||
}
|
||||
},
|
||||
selectBook(audiobook) {
|
||||
this.$store.commit('toggleAudiobookSelected', audiobook.id)
|
||||
selectItem(libraryItem) {
|
||||
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
|
||||
this.$nextTick(() => {
|
||||
this.$eventBus.$emit('item-selected', libraryItem)
|
||||
})
|
||||
},
|
||||
itemSelectedEvt() {
|
||||
this.updateSelectionMode(this.isSelectionMode)
|
||||
},
|
||||
scrolled() {
|
||||
clearTimeout(this.scrollTimer)
|
||||
@@ -156,6 +182,14 @@ export default {
|
||||
this.canScrollLeft = false
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||
<template v-if="page !== 'search' && !isHome">
|
||||
<template v-if="page !== 'search' && page !== 'podcast-search' && !isHome">
|
||||
<p v-if="!selectedSeries" class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p>
|
||||
<div v-else class="items-center hidden md:flex">
|
||||
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
<div class="flex-grow hidden sm:inline-block" />
|
||||
|
||||
<ui-checkbox v-show="showSortFilters" v-model="settings.collapseSeries" label="Collapse Series" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
|
||||
<ui-checkbox v-show="showSortFilters && !isPodcast" v-model="settings.collapseSeries" label="Collapse Series" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
|
||||
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
|
||||
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
|
||||
<!-- <div v-show="showSortFilters" class="h-7 ml-4 flex border border-white border-opacity-25 rounded-md">
|
||||
@@ -70,6 +70,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isPodcast() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||
},
|
||||
isGridMode() {
|
||||
return this.viewMode === 'grid'
|
||||
},
|
||||
@@ -80,6 +83,7 @@ export default {
|
||||
return this.totalEntities
|
||||
},
|
||||
entityName() {
|
||||
if (this.isPodcast) return 'Podcasts'
|
||||
if (!this.page) return 'Books'
|
||||
if (this.page === 'series') return 'Series'
|
||||
if (this.page === 'collections') return 'Collections'
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<div class="w-full h-10 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamAudiobook && isMobileLandscape ? '300px' : '65px' }">
|
||||
<div class="w-full h-10 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
|
||||
<p class="font-mono text-sm">v{{ $config.version }}</p>
|
||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-sm">Update available: {{ latestVersion }}</a>
|
||||
</div>
|
||||
@@ -109,8 +109,8 @@ export default {
|
||||
githubTagUrl() {
|
||||
return this.versionData.githubTagUrl
|
||||
},
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -7,15 +7,16 @@
|
||||
</template>
|
||||
|
||||
<div v-if="initialized && !totalShelves && !hasFilter && isRootUser && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">Audiobookshelf is empty!</p>
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||
<div class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!totalShelves && initialized" class="w-full py-16">
|
||||
<p class="text-xl text-center">{{ emptyMessage }}</p>
|
||||
<div class="flex justify-center mt-2">
|
||||
<!-- Clear filter only available on Library bookshelf -->
|
||||
<div v-if="entityName === 'books'" class="flex justify-center mt-2">
|
||||
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">Clear Filter</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,7 +61,6 @@ export default {
|
||||
totalShelves: 0,
|
||||
bookshelfMarginLeft: 0,
|
||||
isSelectionMode: false,
|
||||
isSelectAll: false,
|
||||
currentSFQueryString: null,
|
||||
pendingReset: false,
|
||||
keywordFilter: null,
|
||||
@@ -85,10 +85,16 @@ export default {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
isPodcast() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||
},
|
||||
emptyMessage() {
|
||||
if (this.page === 'series') return `You have no series`
|
||||
if (this.page === 'series') return 'You have no series'
|
||||
if (this.page === 'collections') return "You haven't made any collections yet"
|
||||
if (this.hasFilter) return `No Results for filter "${this.filterValue}"`
|
||||
if (this.hasFilter) {
|
||||
if (this.filterName === 'Issues') return 'No Issues'
|
||||
return `No Results for filter "${this.filterName}: ${this.filterValue}"`
|
||||
}
|
||||
return 'No results'
|
||||
},
|
||||
entityName() {
|
||||
@@ -143,6 +149,9 @@ export default {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
libraryName() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||
},
|
||||
isEntityBook() {
|
||||
return this.entityName === 'series-books' || this.entityName === 'books'
|
||||
},
|
||||
@@ -183,8 +192,8 @@ export default {
|
||||
// Includes margin
|
||||
return this.entityWidth + 24
|
||||
},
|
||||
selectedAudiobooks() {
|
||||
return this.$store.state.selectedAudiobooks || []
|
||||
selectedLibraryItems() {
|
||||
return this.$store.state.selectedLibraryItems || []
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
@@ -210,13 +219,12 @@ export default {
|
||||
clearSelectedEntities() {
|
||||
this.updateBookSelectionMode(false)
|
||||
this.isSelectionMode = false
|
||||
this.isSelectAll = false
|
||||
},
|
||||
selectEntity(entity) {
|
||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||
this.$store.commit('toggleAudiobookSelected', entity.id)
|
||||
this.$store.commit('toggleLibraryItemSelected', entity.id)
|
||||
|
||||
var newIsSelectionMode = !!this.selectedAudiobooks.length
|
||||
var newIsSelectionMode = !!this.selectedLibraryItems.length
|
||||
if (this.isSelectionMode !== newIsSelectionMode) {
|
||||
this.isSelectionMode = newIsSelectionMode
|
||||
this.updateBookSelectionMode(newIsSelectionMode)
|
||||
@@ -239,7 +247,7 @@ export default {
|
||||
this.currentSFQueryString = this.buildSearchParams()
|
||||
}
|
||||
|
||||
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `books/all` : this.entityName
|
||||
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
|
||||
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
|
||||
|
||||
@@ -300,11 +308,11 @@ export default {
|
||||
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)
|
||||
// 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)
|
||||
// console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
|
||||
this.loadPage(lastBookPage)
|
||||
}
|
||||
|
||||
@@ -332,7 +340,6 @@ export default {
|
||||
this.totalEntities = 0
|
||||
this.currentPage = 0
|
||||
this.isSelectionMode = false
|
||||
this.isSelectAll = false
|
||||
this.initialized = false
|
||||
|
||||
this.initSizeData()
|
||||
@@ -374,9 +381,7 @@ export default {
|
||||
|
||||
let searchParams = new URLSearchParams()
|
||||
if (this.page === 'series-books') {
|
||||
searchParams.set('filter', `series.${this.seriesId}`)
|
||||
searchParams.set('sort', 'book.volumeNumber')
|
||||
searchParams.set('desc', 0)
|
||||
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
|
||||
} else {
|
||||
if (this.filterBy && this.filterBy !== 'all') {
|
||||
searchParams.set('filter', this.filterBy)
|
||||
@@ -385,7 +390,7 @@ export default {
|
||||
searchParams.set('sort', this.orderBy)
|
||||
searchParams.set('desc', this.orderDesc ? 1 : 0)
|
||||
}
|
||||
if (this.collapseSeries) {
|
||||
if (this.collapseSeries && !this.isPodcast) {
|
||||
searchParams.set('collapseseries', 1)
|
||||
}
|
||||
}
|
||||
@@ -425,44 +430,71 @@ export default {
|
||||
this.handleScroll(scrollTop)
|
||||
// }, 250)
|
||||
},
|
||||
audiobookAdded(audiobook) {
|
||||
console.log('Audiobook added', audiobook)
|
||||
libraryItemAdded(libraryItem) {
|
||||
console.log('libraryItem added', libraryItem)
|
||||
// TODO: Check if audiobook would be on this shelf
|
||||
this.resetEntities()
|
||||
},
|
||||
audiobookUpdated(audiobook) {
|
||||
console.log('Audiobook updated', audiobook)
|
||||
libraryItemUpdated(libraryItem) {
|
||||
console.log('Item updated', libraryItem)
|
||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id)
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||
if (indexOf >= 0) {
|
||||
this.entities[indexOf] = audiobook
|
||||
this.entities[indexOf] = libraryItem
|
||||
if (this.entityComponentRefs[indexOf]) {
|
||||
this.entityComponentRefs[indexOf].setEntity(audiobook)
|
||||
this.entityComponentRefs[indexOf].setEntity(libraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
audiobookRemoved(audiobook) {
|
||||
libraryItemRemoved(libraryItem) {
|
||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id)
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||
if (indexOf >= 0) {
|
||||
this.entities = this.entities.filter((ent) => ent.id !== audiobook.id)
|
||||
this.entities = this.entities.filter((ent) => ent.id !== libraryItem.id)
|
||||
this.totalEntities = this.entities.length
|
||||
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
||||
this.remountEntities()
|
||||
this.executeRebuild()
|
||||
}
|
||||
}
|
||||
},
|
||||
audiobooksAdded(audiobooks) {
|
||||
console.log('audiobooks added', audiobooks)
|
||||
libraryItemsAdded(libraryItems) {
|
||||
console.log('items added', libraryItems)
|
||||
// TODO: Check if audiobook would be on this shelf
|
||||
this.resetEntities()
|
||||
},
|
||||
audiobooksUpdated(audiobooks) {
|
||||
audiobooks.forEach((ab) => {
|
||||
this.audiobookUpdated(ab)
|
||||
libraryItemsUpdated(libraryItems) {
|
||||
libraryItems.forEach((ab) => {
|
||||
this.libraryItemUpdated(ab)
|
||||
})
|
||||
},
|
||||
collectionAdded(collection) {
|
||||
if (this.entityName !== 'collections') return
|
||||
console.log(`[LazyBookshelf] collectionAdded ${collection.id}`, collection)
|
||||
this.resetEntities()
|
||||
},
|
||||
collectionUpdated(collection) {
|
||||
if (this.entityName !== 'collections') return
|
||||
console.log(`[LazyBookshelf] collectionUpdated ${collection.id}`, collection)
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === collection.id)
|
||||
if (indexOf >= 0) {
|
||||
this.entities[indexOf] = collection
|
||||
if (this.entityComponentRefs[indexOf]) {
|
||||
this.entityComponentRefs[indexOf].setEntity(collection)
|
||||
}
|
||||
}
|
||||
},
|
||||
collectionRemoved(collection) {
|
||||
if (this.entityName !== 'collections') return
|
||||
console.log(`[LazyBookshelf] collectionRemoved ${collection.id}`, collection)
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === collection.id)
|
||||
if (indexOf >= 0) {
|
||||
this.entities = this.entities.filter((ent) => ent.id !== collection.id)
|
||||
this.totalEntities = this.entities.length
|
||||
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
||||
this.executeRebuild()
|
||||
}
|
||||
},
|
||||
initSizeData(_bookshelf) {
|
||||
var bookshelf = _bookshelf || document.getElementById('bookshelf')
|
||||
if (!bookshelf) {
|
||||
@@ -525,11 +557,14 @@ export default {
|
||||
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)
|
||||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||
this.$root.socket.on('item_added', this.libraryItemAdded)
|
||||
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
||||
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
||||
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
||||
this.$root.socket.on('collection_added', this.collectionAdded)
|
||||
this.$root.socket.on('collection_updated', this.collectionUpdated)
|
||||
this.$root.socket.on('collection_removed', this.collectionRemoved)
|
||||
} else {
|
||||
console.error('Bookshelf - Socket not initialized')
|
||||
}
|
||||
@@ -546,11 +581,14 @@ export default {
|
||||
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)
|
||||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||
this.$root.socket.off('item_added', this.libraryItemAdded)
|
||||
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
||||
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
||||
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
||||
this.$root.socket.off('collection_added', this.collectionAdded)
|
||||
this.$root.socket.off('collection_updated', this.collectionUpdated)
|
||||
this.$root.socket.off('collection_removed', this.collectionRemoved)
|
||||
} else {
|
||||
console.error('Bookshelf - Socket not initialized')
|
||||
}
|
||||
@@ -563,7 +601,7 @@ export default {
|
||||
}
|
||||
},
|
||||
scan() {
|
||||
this.$root.socket.emit('scan', this.currentLibraryId)
|
||||
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/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="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/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="isSeriesPage ? '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>
|
||||
@@ -31,7 +31,7 @@
|
||||
<div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/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'">
|
||||
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/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'">
|
||||
<span class="material-icons-outlined">collections_bookmark</span>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 0.9rem">Collections</p>
|
||||
@@ -39,7 +39,7 @@
|
||||
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="showExperimentalFeatures" :to="`/library/${currentLibraryId}/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="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/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="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
@@ -52,6 +52,14 @@
|
||||
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/search`" 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="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<icons-podcast-svg class="w-6 h-6" />
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 0.9rem">Search</p>
|
||||
|
||||
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" 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-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
|
||||
<span class="material-icons text-2xl">warning</span>
|
||||
|
||||
@@ -62,36 +70,6 @@
|
||||
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
||||
</div>
|
||||
</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-full w-0.5 bg-yellow-400 absolute top-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-full w-0.5 bg-yellow-400 absolute top-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-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -110,6 +88,15 @@ export default {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isPodcastLibrary() {
|
||||
return this.currentLibraryMediaType === 'podcast'
|
||||
},
|
||||
isPodcastSearchPage() {
|
||||
return this.$route.name === 'library-library-podcast-search'
|
||||
},
|
||||
homePage() {
|
||||
return this.$route.name === 'library-library'
|
||||
},
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<template>
|
||||
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
|
||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||
<covers-book-cover :audiobook="streamAudiobook" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</nuxt-link>
|
||||
<div class="flex items-start pl-24 mb-6 md:mb-0">
|
||||
<div>
|
||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
|
||||
{{ title }}
|
||||
</nuxt-link>
|
||||
<div class="text-gray-400 flex items-center">
|
||||
<span class="material-icons text-sm">person</span>
|
||||
<p v-if="authorFL" class="pl-1.5 text-sm sm:text-base">
|
||||
<nuxt-link v-for="(author, index) in authorsList" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}<span v-if="index < authorsList.length - 1">, </span></nuxt-link>
|
||||
<p v-if="podcastAuthor">{{ podcastAuthor }}</p>
|
||||
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</p>
|
||||
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p>
|
||||
</div>
|
||||
@@ -24,7 +25,6 @@
|
||||
<div class="flex-grow" />
|
||||
<span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span>
|
||||
</div>
|
||||
|
||||
<audio-player
|
||||
ref="audioPlayer"
|
||||
:chapters="chapters"
|
||||
@@ -33,6 +33,7 @@
|
||||
:bookmarks="bookmarks"
|
||||
:sleep-timer-set="sleepTimerSet"
|
||||
:sleep-timer-remaining="sleepTimerRemaining"
|
||||
:is-podcast="isPodcast"
|
||||
@playPause="playPause"
|
||||
@jumpForward="jumpForward"
|
||||
@jumpBackward="jumpBackward"
|
||||
@@ -44,7 +45,7 @@
|
||||
@showSleepTimer="showSleepTimerModal = true"
|
||||
/>
|
||||
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @select="selectBookmark" />
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
||||
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||
</div>
|
||||
@@ -60,7 +61,6 @@ export default {
|
||||
totalDuration: 0,
|
||||
showBookmarksModal: false,
|
||||
bookmarkCurrentTime: 0,
|
||||
bookmarkAudiobookId: null,
|
||||
playerLoading: false,
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
@@ -68,7 +68,9 @@ export default {
|
||||
sleepTimerSet: false,
|
||||
sleepTimerTime: 0,
|
||||
sleepTimerRemaining: 0,
|
||||
sleepTimer: null
|
||||
sleepTimer: null,
|
||||
displayTitle: null,
|
||||
initialPlaybackRate: 1
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -89,55 +91,64 @@ export default {
|
||||
return -64
|
||||
},
|
||||
cover() {
|
||||
if (this.streamAudiobook && this.streamAudiobook.cover) return this.streamAudiobook.cover
|
||||
if (this.media.coverPath) return this.media.coverPath
|
||||
return 'Logo.png'
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
userAudiobook() {
|
||||
if (!this.audiobookId) return
|
||||
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
|
||||
userMediaProgress() {
|
||||
if (!this.libraryItemId) return
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
userAudiobookCurrentTime() {
|
||||
return this.userAudiobook ? this.userAudiobook.currentTime || 0 : 0
|
||||
userItemCurrentTime() {
|
||||
return this.userMediaProgress ? this.userMediaProgress.currentTime || 0 : 0
|
||||
},
|
||||
bookmarks() {
|
||||
if (!this.userAudiobook) return []
|
||||
return (this.userAudiobook.bookmarks || []).map((bm) => ({ ...bm })).sort((a, b) => a.time - b.time)
|
||||
if (!this.libraryItemId) return []
|
||||
return this.$store.getters['user/getUserBookmarksForItem'](this.libraryItemId)
|
||||
},
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
audiobookId() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.id : null
|
||||
libraryItemId() {
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.id : null
|
||||
},
|
||||
book() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
|
||||
media() {
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.media || {} : {}
|
||||
},
|
||||
isPodcast() {
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
chapters() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.chapters || [] : []
|
||||
return this.media.chapters || []
|
||||
},
|
||||
title() {
|
||||
return this.book.title || 'No Title'
|
||||
if (this.playerHandler.displayTitle) return this.playerHandler.displayTitle
|
||||
return this.mediaMetadata.title || 'No Title'
|
||||
},
|
||||
author() {
|
||||
return this.book.author || 'Unknown'
|
||||
},
|
||||
authorFL() {
|
||||
return this.book.authorFL
|
||||
},
|
||||
authorsList() {
|
||||
return this.authorFL ? this.authorFL.split(', ') : []
|
||||
authors() {
|
||||
return this.mediaMetadata.authors || []
|
||||
},
|
||||
libraryId() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.libraryId : null
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
|
||||
},
|
||||
totalDurationPretty() {
|
||||
return this.$secondsToTimestamp(this.totalDuration)
|
||||
},
|
||||
podcastAuthor() {
|
||||
if (!this.isPodcast) return null
|
||||
return this.mediaMetadata.author || 'Unknown'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setPlaying(isPlaying) {
|
||||
this.isPlaying = isPlaying
|
||||
this.$store.commit('setIsPlaying', isPlaying)
|
||||
},
|
||||
setSleepTimer(seconds) {
|
||||
this.sleepTimerSet = true
|
||||
this.sleepTimerTime = seconds
|
||||
@@ -194,6 +205,7 @@ export default {
|
||||
this.playerHandler.setVolume(volume)
|
||||
},
|
||||
setPlaybackRate(playbackRate) {
|
||||
this.initialPlaybackRate = playbackRate
|
||||
this.playerHandler.setPlaybackRate(playbackRate)
|
||||
},
|
||||
seek(time) {
|
||||
@@ -217,7 +229,6 @@ export default {
|
||||
}
|
||||
},
|
||||
showBookmarks() {
|
||||
this.bookmarkAudiobookId = this.audiobookId
|
||||
this.bookmarkCurrentTime = this.currentTime
|
||||
this.showBookmarksModal = true
|
||||
},
|
||||
@@ -227,7 +238,7 @@ export default {
|
||||
},
|
||||
closePlayer() {
|
||||
this.playerHandler.closePlayer()
|
||||
this.$store.commit('setStreamAudiobook', null)
|
||||
this.$store.commit('setMediaPlaying', null)
|
||||
},
|
||||
streamProgress(data) {
|
||||
if (!data.numSegments) return
|
||||
@@ -239,13 +250,19 @@ export default {
|
||||
console.error('No Audio Ref')
|
||||
}
|
||||
},
|
||||
streamOpen(stream) {
|
||||
this.$store.commit('setStreamAudiobook', stream.audiobook)
|
||||
this.playerHandler.prepareStream(stream)
|
||||
sessionOpen(session) {
|
||||
this.$store.commit('setMediaPlaying', {
|
||||
libraryItem: session.libraryItem,
|
||||
episodeId: session.episodeId
|
||||
})
|
||||
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate)
|
||||
},
|
||||
streamOpen(session) {
|
||||
console.log(`[StreamContainer] Stream session open`, session)
|
||||
},
|
||||
streamClosed(streamId) {
|
||||
// Stream was closed from the server
|
||||
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
|
||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
||||
console.warn('[StreamContainer] Closing stream due to request from server')
|
||||
this.playerHandler.closePlayer()
|
||||
}
|
||||
@@ -260,7 +277,7 @@ export default {
|
||||
},
|
||||
streamError(streamId) {
|
||||
// Stream had critical error from the server
|
||||
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
|
||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
||||
console.warn('[StreamContainer] Closing stream due to stream error from server')
|
||||
this.playerHandler.closePlayer()
|
||||
}
|
||||
@@ -269,32 +286,48 @@ export default {
|
||||
this.playerHandler.resetStream(startTime, streamId)
|
||||
},
|
||||
castSessionActive(isActive) {
|
||||
if (isActive && this.playerHandler.isPlayingLocalAudiobook) {
|
||||
if (isActive && this.playerHandler.isPlayingLocalItem) {
|
||||
// Cast session started switch to cast player
|
||||
this.playerHandler.switchPlayer()
|
||||
} else if (!isActive && this.playerHandler.isPlayingCastedAudiobook) {
|
||||
} else if (!isActive && this.playerHandler.isPlayingCastedItem) {
|
||||
// Cast session ended switch to local player
|
||||
this.playerHandler.switchPlayer()
|
||||
}
|
||||
},
|
||||
async playAudiobook(audiobookId) {
|
||||
var audiobook = await this.$axios.$get(`/api/books/${audiobookId}`).catch((error) => {
|
||||
console.error('Failed to fetch full audiobook', error)
|
||||
async playLibraryItem(payload) {
|
||||
var libraryItemId = payload.libraryItemId
|
||||
var episodeId = payload.episodeId || null
|
||||
|
||||
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
|
||||
this.playerHandler.play()
|
||||
return
|
||||
}
|
||||
|
||||
var libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
|
||||
console.error('Failed to fetch full item', error)
|
||||
return null
|
||||
})
|
||||
if (!audiobook) return
|
||||
this.$store.commit('setStreamAudiobook', audiobook)
|
||||
if (!libraryItem) return
|
||||
this.$store.commit('setMediaPlaying', {
|
||||
libraryItem,
|
||||
episodeId
|
||||
})
|
||||
|
||||
this.playerHandler.load(audiobook, true, this.userAudiobookCurrentTime)
|
||||
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
|
||||
},
|
||||
pauseItem() {
|
||||
this.playerHandler.pause()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
||||
this.$eventBus.$on('play-audiobook', this.playAudiobook)
|
||||
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
||||
this.$eventBus.$off('play-audiobook', this.playAudiobook)
|
||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
<template>
|
||||
<div>
|
||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-lg relative">
|
||||
<div class="w-full h-full overflow-hidden max-w-full max-h-full relative">
|
||||
<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 @mouseover="mouseover" @mouseout="mouseout">
|
||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
<!-- Image or placeholder -->
|
||||
<covers-author-image :author="author" />
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full py-2 bg-black bg-opacity-25 px-2">
|
||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ name }}</p>
|
||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.85 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||
</div>
|
||||
<!-- Author name & num books overlay -->
|
||||
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Search icon btn -->
|
||||
<div v-show="!searching && isHovering" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="searchAuthor">
|
||||
<span class="material-icons text-lg">search</span>
|
||||
</div>
|
||||
<div v-show="isHovering && !searching" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="$emit('edit', author)">
|
||||
<span class="material-icons text-lg">edit</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading spinner -->
|
||||
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<widgets-loading-spinner size="" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="nameBelow" class="w-full py-1 px-2">
|
||||
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -34,11 +38,16 @@ export default {
|
||||
},
|
||||
width: Number,
|
||||
height: Number,
|
||||
sizeMultiplier: Number
|
||||
sizeMultiplier: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
nameBelow: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
placeholder: '/Logo.png'
|
||||
searching: false,
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -48,30 +57,40 @@ export default {
|
||||
_author() {
|
||||
return this.author || {}
|
||||
},
|
||||
authorId() {
|
||||
return this._author.id
|
||||
},
|
||||
name() {
|
||||
return this._author.name || ''
|
||||
},
|
||||
image() {
|
||||
return this._author.image || null
|
||||
},
|
||||
description() {
|
||||
return this._author.description
|
||||
},
|
||||
lastUpdate() {
|
||||
return this._author.lastUpdate
|
||||
},
|
||||
numBooks() {
|
||||
return this._author.numBooks || 0
|
||||
},
|
||||
imgSrc() {
|
||||
if (!this.image) return this.placeholder
|
||||
var encodedImg = this.image.replace(/%/g, '%25').replace(/#/g, '%23')
|
||||
|
||||
var url = new URL(encodedImg, document.baseURI)
|
||||
return url.href + `?token=${this.userToken}&ts=${this.lastUpdate}`
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
methods: {
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseout() {
|
||||
this.isHovering = false
|
||||
},
|
||||
async searchAuthor() {
|
||||
this.searching = true
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.name }).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
})
|
||||
if (!response) {
|
||||
this.$toast.error('Author not found')
|
||||
} else if (response.updated) {
|
||||
if (response.author.imagePath) this.$toast.success('Author was updated')
|
||||
else this.$toast.success('Author was updated (no image found)')
|
||||
} else {
|
||||
this.$toast.info('No updates were made for Author')
|
||||
}
|
||||
this.searching = false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,8 +1,10 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<img src="/icons/NoUserPhoto.png" class="w-40 h-40 max-h-40 object-contain" style="max-height: 40px; max-width: 40px" />
|
||||
<div class="overflow-hidden bg-primary rounded" style="height: 50px; width: 40px">
|
||||
<covers-author-image :author="author" />
|
||||
</div>
|
||||
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ author }}</p>
|
||||
<p class="truncate text-sm">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,12 +12,19 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
author: String
|
||||
author: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
name() {
|
||||
return this.author.name
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
<template>
|
||||
<div class="w-full border-b border-gray-700 pb-2">
|
||||
<div class="flex py-1 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer" @click="selectMatch">
|
||||
<img :src="selectedCover || '/book_placeholder.jpg'" class="h-24 object-cover" :style="{ width: 96 / bookCoverAspectRatio + 'px' }" />
|
||||
<div class="px-4 flex-grow">
|
||||
<div class="h-24 bg-primary" :style="{ minWidth: 96 / bookCoverAspectRatio + 'px' }">
|
||||
<img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
<div v-if="!isPodcast" class="px-4 flex-grow">
|
||||
<div class="flex items-center">
|
||||
<h1>{{ book.title }}</h1>
|
||||
<div class="flex-grow" />
|
||||
<p>{{ book.publishYear }}</p>
|
||||
<p>{{ book.publishedYear }}</p>
|
||||
</div>
|
||||
<p class="text-gray-400">{{ book.author }}</p>
|
||||
<div class="w-full max-h-12 overflow-hidden">
|
||||
<p class="text-gray-500 text-xs">{{ book.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="px-4 flex-grow">
|
||||
<h1>{{ book.title }}</h1>
|
||||
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ book.trackCount }} Episodes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="bookCovers.length > 1" class="flex">
|
||||
<template v-for="cover in bookCovers">
|
||||
@@ -31,6 +39,7 @@ export default {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
isPodcast: Boolean,
|
||||
bookCoverAspectRatio: Number
|
||||
},
|
||||
data() {
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="rounded-sm h-full relative" :style="{ padding: `${paddingY}px ${paddingX}px` }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
||||
<nuxt-link :to="groupTo" class="cursor-pointer">
|
||||
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: coverHeight + 'px', width: coverWidth + 'px' }">
|
||||
<covers-collection-cover ref="groupcover" :book-items="bookItems" :width="coverWidth" :height="coverHeight" />
|
||||
|
||||
<div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||
<!-- <div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="toggleSelected">
|
||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">radio_button_unchecked</span>
|
||||
</div> -->
|
||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto bottom-0 h-6 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 ${1 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :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 {
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
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.75
|
||||
return 0.875
|
||||
},
|
||||
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'
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleSelected() {
|
||||
// Selected
|
||||
},
|
||||
clickEdit() {
|
||||
this.$store.commit('globals/setEditCollection', this.collection)
|
||||
},
|
||||
mouseoverCard() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleaveCard() {
|
||||
this.isHovering = false
|
||||
},
|
||||
clickCard() {
|
||||
this.$emit('click', this.collection)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -12,9 +12,6 @@
|
||||
<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>
|
||||
@@ -74,7 +71,7 @@ export default {
|
||||
},
|
||||
groupTo() {
|
||||
if (this.groupType === 'series') {
|
||||
return `/library/${this.currentLibraryId}/series/${this.groupEncode}`
|
||||
return `/library/${this.currentLibraryId}/series/${this._group.id}`
|
||||
} else if (this.groupType === 'collection') {
|
||||
return `/collection/${this._group.id}`
|
||||
} else {
|
||||
@@ -100,15 +97,6 @@ export default {
|
||||
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'
|
||||
},
|
||||
@@ -119,7 +107,7 @@ export default {
|
||||
return `${this.groupType}.${this.$encode(this.groupName)}`
|
||||
},
|
||||
hasValidCovers() {
|
||||
var validCovers = this.bookItems.map((bookItem) => bookItem.book.cover)
|
||||
var validCovers = this.bookItems.map((bookItem) => bookItem.media.coverPath)
|
||||
return !!validCovers.length
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="flex items-center h-full px-1 overflow-hidden">
|
||||
<covers-book-cover :audiobook="audiobook" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="flex-grow px-2 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-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
|
||||
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
||||
|
||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||
@@ -18,7 +18,7 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
@@ -37,17 +37,27 @@ export default {
|
||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
||||
return 50
|
||||
},
|
||||
book() {
|
||||
return this.audiobook ? this.audiobook.book || {} : {}
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType == 'podcast'
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
title() {
|
||||
return this.book ? this.book.title : 'No Title'
|
||||
return this.mediaMetadata.title || 'No Title'
|
||||
},
|
||||
subtitle() {
|
||||
return this.book ? this.book.subtitle : ''
|
||||
return this.mediaMetadata.subtitle || ''
|
||||
},
|
||||
authorFL() {
|
||||
return this.book ? this.book.authorFL : 'Unknown'
|
||||
authorName() {
|
||||
if (this.isPodcast) return this.mediaMetadata.author || 'Unknown'
|
||||
return this.mediaMetadata.authorName || 'Unknown'
|
||||
},
|
||||
matchHtml() {
|
||||
if (!this.matchText || !this.search) return ''
|
||||
@@ -69,7 +79,7 @@ export default {
|
||||
html += lastPart
|
||||
|
||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||
if (this.matchKey === 'authorFL') return `by ${html}`
|
||||
if (this.matchKey === 'authors') return `by ${html}`
|
||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="relative w-full py-4 px-6 border border-white border-opacity-10 shadow-lg rounded-md my-6">
|
||||
<div class="absolute -top-3 -left-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full">
|
||||
<p class="text-base text-white text-opacity-80 font-mono">#{{ book.index }}</p>
|
||||
<p class="text-base text-white text-opacity-80 font-mono">#{{ item.index }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!processing && !uploadFailed && !uploadSuccess" class="absolute -top-3 -right-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-error cursor-pointer" @click="$emit('remove')">
|
||||
@@ -15,15 +15,19 @@
|
||||
|
||||
<div class="flex my-2 -mx-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="bookData.title" :disabled="processing" label="Title" @input="titleUpdated" />
|
||||
<ui-text-input-with-label v-model="itemData.title" :disabled="processing" label="Title" @input="titleUpdated" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="bookData.author" :disabled="processing" label="Author" />
|
||||
<ui-text-input-with-label v-if="!isPodcast" v-model="itemData.author" :disabled="processing" label="Author" />
|
||||
<div v-else class="w-full">
|
||||
<p class="px-1 text-sm font-semibold">Directory <em class="font-normal text-xs pl-2">(auto)</em></p>
|
||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex my-2 -mx-2">
|
||||
<div v-if="!isPodcast" class="flex my-2 -mx-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="bookData.series" :disabled="processing" label="Series" note="(optional)" />
|
||||
<ui-text-input-with-label v-model="itemData.series" :disabled="processing" label="Series" note="(optional)" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<div class="w-full">
|
||||
@@ -33,9 +37,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tables-uploaded-files-table :files="book.bookFiles" title="Book Files" class="mt-8" />
|
||||
<tables-uploaded-files-table v-if="book.otherFiles.length" title="Other Files" :files="book.otherFiles" />
|
||||
<tables-uploaded-files-table v-if="book.ignoredFiles.length" title="Ignored Files" :files="book.ignoredFiles" />
|
||||
<tables-uploaded-files-table :files="item.itemFiles" title="Item Files" class="mt-8" />
|
||||
<tables-uploaded-files-table v-if="item.otherFiles.length" title="Other Files" :files="item.otherFiles" />
|
||||
<tables-uploaded-files-table v-if="item.ignoredFiles.length" title="Ignored Files" :files="item.ignoredFiles" />
|
||||
</template>
|
||||
<widgets-alert v-if="uploadSuccess" type="success">
|
||||
<p class="text-base">Successfully Uploaded!</p>
|
||||
@@ -55,15 +59,16 @@ import Path from 'path'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
book: {
|
||||
item: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
mediaType: String,
|
||||
processing: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
bookData: {
|
||||
itemData: {
|
||||
title: '',
|
||||
author: '',
|
||||
series: ''
|
||||
@@ -75,14 +80,19 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isPodcast() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
directory() {
|
||||
if (!this.bookData.title) return ''
|
||||
if (this.bookData.series && this.bookData.author) {
|
||||
return Path.join(this.bookData.author, this.bookData.series, this.bookData.title)
|
||||
} else if (this.bookData.author) {
|
||||
return Path.join(this.bookData.author, this.bookData.title)
|
||||
if (!this.itemData.title) return ''
|
||||
if (this.isPodcast) return this.itemData.title
|
||||
|
||||
if (this.itemData.series && this.itemData.author) {
|
||||
return Path.join(this.itemData.author, this.itemData.series, this.itemData.title)
|
||||
} else if (this.itemData.author) {
|
||||
return Path.join(this.itemData.author, this.itemData.title)
|
||||
} else {
|
||||
return this.bookData.title
|
||||
return this.itemData.title
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -96,24 +106,24 @@ export default {
|
||||
this.error = ''
|
||||
},
|
||||
getData() {
|
||||
if (!this.bookData.title) {
|
||||
if (!this.itemData.title) {
|
||||
this.error = 'Must have a title'
|
||||
return null
|
||||
}
|
||||
this.error = ''
|
||||
var files = this.book.bookFiles.concat(this.book.otherFiles)
|
||||
var files = this.item.itemFiles.concat(this.item.otherFiles)
|
||||
return {
|
||||
index: this.book.index,
|
||||
...this.bookData,
|
||||
index: this.item.index,
|
||||
...this.itemData,
|
||||
files
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.book) {
|
||||
this.bookData.title = this.book.title
|
||||
this.bookData.author = this.book.author
|
||||
this.bookData.series = this.book.series
|
||||
if (this.item) {
|
||||
this.itemData.title = this.item.title
|
||||
this.itemData.author = this.item.author
|
||||
this.itemData.series = this.item.series
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,20 +8,21 @@
|
||||
<!-- Alternative bookshelf title/author/sort -->
|
||||
<div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
<span v-if="volumeNumber">#{{ volumeNumber }} </span>{{ displayTitle }}
|
||||
{{ displayTitle }}
|
||||
</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor || ' ' }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="booksInSeries" class="absolute z-20 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ booksInSeries }}</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' }">
|
||||
<div v-show="libraryItem && !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" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||
<!-- Cover Image -->
|
||||
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @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' }">
|
||||
@@ -34,11 +35,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No progress shown for collapsed series in library -->
|
||||
<div v-if="!booksInSeries" 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>
|
||||
<!-- No progress shown for collapsed series in library and podcasts (unless showing podcast episode) -->
|
||||
<div v-if="!booksInSeries && (!isPodcast || episodeProgress)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<!-- Overlay is not shown if collapsing series in library -->
|
||||
<div v-show="!booksInSeries && audiobook && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
|
||||
<div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
|
||||
<div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
|
||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||
@@ -51,7 +52,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
||||
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50 top-0 right-0" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||
</div>
|
||||
|
||||
@@ -59,13 +60,13 @@
|
||||
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
||||
</div>
|
||||
|
||||
<div ref="moreIcon" v-show="!isSelectionMode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
||||
<div ref="moreIcon" v-show="!isSelectionMode && !recentEpisode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
||||
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Series name overlay -->
|
||||
<div v-if="booksInSeries && audiobook && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
|
||||
<div v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
|
||||
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
|
||||
</div>
|
||||
|
||||
@@ -76,9 +77,19 @@
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
|
||||
<!-- Volume number -->
|
||||
<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>
|
||||
<!-- Series sequence -->
|
||||
<div v-if="seriesSequence && showSequence && !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' }">#{{ seriesSequence }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Episode # -->
|
||||
<div v-if="recentEpisodeNumber && !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' }">Episode #{{ recentEpisodeNumber }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
<div v-else-if="numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -99,7 +110,7 @@ export default {
|
||||
default: 192
|
||||
},
|
||||
bookCoverAspectRatio: Number,
|
||||
showVolumeNumber: Boolean,
|
||||
showSequence: Boolean,
|
||||
bookshelfView: Number,
|
||||
bookMount: {
|
||||
// Book can be passed as prop or set with setEntity()
|
||||
@@ -115,7 +126,7 @@ export default {
|
||||
isHovering: false,
|
||||
isMoreMenuOpen: false,
|
||||
isProcessingReadUpdate: false,
|
||||
audiobook: null,
|
||||
libraryItem: null,
|
||||
imageReady: false,
|
||||
rescanning: false,
|
||||
selected: false,
|
||||
@@ -127,7 +138,7 @@ export default {
|
||||
bookMount: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.audiobook = newVal
|
||||
this.libraryItem = newVal
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,42 +147,75 @@ export default {
|
||||
showExperimentalFeatures() {
|
||||
return this.store.state.showExperimentalFeatures
|
||||
},
|
||||
_audiobook() {
|
||||
return this.audiobook || {}
|
||||
_libraryItem() {
|
||||
return this.libraryItem || {}
|
||||
},
|
||||
book() {
|
||||
return this._audiobook.book || {}
|
||||
media() {
|
||||
return this._libraryItem.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
mediaType() {
|
||||
return this._libraryItem.mediaType
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
placeholderUrl() {
|
||||
return '/book_placeholder.jpg'
|
||||
},
|
||||
bookCoverSrc() {
|
||||
return this.store.getters['audiobooks/getBookCoverSrc'](this._audiobook, this.placeholderUrl)
|
||||
return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
|
||||
},
|
||||
audiobookId() {
|
||||
return this._audiobook.id
|
||||
libraryItemId() {
|
||||
return this._libraryItem.id
|
||||
},
|
||||
series() {
|
||||
return this.book.series
|
||||
// Only included when filtering by series or collapse series
|
||||
return this.mediaMetadata.series
|
||||
},
|
||||
seriesSequence() {
|
||||
return this.series ? this.series.sequence : null
|
||||
},
|
||||
libraryId() {
|
||||
return this._audiobook.libraryId
|
||||
return this._libraryItem.libraryId
|
||||
},
|
||||
hasEbook() {
|
||||
return this._audiobook.numEbooks
|
||||
return this.media.ebookFormat
|
||||
},
|
||||
hasTracks() {
|
||||
return this._audiobook.numTracks
|
||||
numTracks() {
|
||||
if (this.media.tracks) return this.media.tracks.length
|
||||
return this.media.numTracks || 0 // toJSONMinified
|
||||
},
|
||||
numEpisodes() {
|
||||
if (!this.isPodcast) return 0
|
||||
return this.media.numEpisodes || 0
|
||||
},
|
||||
processingBatch() {
|
||||
return this.store.state.processingBatch
|
||||
},
|
||||
recentEpisode() {
|
||||
// Only added to item when getting currently listening podcasts
|
||||
return this._libraryItem.recentEpisode
|
||||
},
|
||||
recentEpisodeNumber() {
|
||||
if (!this.recentEpisode) return null
|
||||
if (this.recentEpisode.episode) {
|
||||
return this.recentEpisode.episode.replace(/^#/, '')
|
||||
}
|
||||
return this.recentEpisode.index
|
||||
},
|
||||
collapsedSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this._libraryItem.collapsedSeries
|
||||
},
|
||||
booksInSeries() {
|
||||
// Only added to audiobook object when collapseSeries is enabled
|
||||
return this._audiobook.booksInSeries
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
||||
},
|
||||
hasCover() {
|
||||
return !!this.book.cover
|
||||
return !!this.media.coverPath
|
||||
},
|
||||
squareAspectRatio() {
|
||||
return this.bookCoverAspectRatio === 1
|
||||
@@ -181,87 +225,86 @@ export default {
|
||||
return this.width / baseSize
|
||||
},
|
||||
title() {
|
||||
return this.book.title || ''
|
||||
return this.mediaMetadata.title || ''
|
||||
},
|
||||
playIconFontSize() {
|
||||
return Math.max(2, 3 * this.sizeMultiplier)
|
||||
},
|
||||
author() {
|
||||
return this.book.author
|
||||
},
|
||||
authorFL() {
|
||||
return this.book.authorFL || this.author
|
||||
if (this.isPodcast) return this.mediaMetadata.author
|
||||
return this.mediaMetadata.authorName
|
||||
},
|
||||
authorLF() {
|
||||
return this.book.authorLF || this.author
|
||||
},
|
||||
volumeNumber() {
|
||||
return this.book.volumeNumber || null
|
||||
return this.mediaMetadata.authorNameLF
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.orderBy === 'book.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
|
||||
return this.title.substr(4) + ', The'
|
||||
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) {
|
||||
return this.mediaMetadata.titleIgnorePrefix
|
||||
}
|
||||
return this.title
|
||||
},
|
||||
displayAuthor() {
|
||||
if (this.orderBy === 'book.authorLF') return this.authorLF
|
||||
return this.authorFL
|
||||
if (this.isPodcast) return this.author
|
||||
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
|
||||
return this.author
|
||||
},
|
||||
displaySortLine() {
|
||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._audiobook.mtimeMs)
|
||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._audiobook.birthtimeMs)
|
||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._audiobook.addedAt)
|
||||
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this._audiobook.duration, false)
|
||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._audiobook.size)
|
||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
||||
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||
return null
|
||||
},
|
||||
episodeProgress() {
|
||||
// Only used on home page currently listening podcast shelf
|
||||
if (!this.recentEpisode) return null
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
|
||||
},
|
||||
userProgress() {
|
||||
return this.store.getters['user/getUserAudiobook'](this.audiobookId)
|
||||
if (this.episodeProgress) return this.episodeProgress
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
userProgressPercent() {
|
||||
return this.userProgress ? this.userProgress.progress || 0 : 0
|
||||
},
|
||||
userIsRead() {
|
||||
return this.userProgress ? !!this.userProgress.isRead : false
|
||||
itemIsFinished() {
|
||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||
},
|
||||
showError() {
|
||||
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
|
||||
return this.numMissingParts || this.isMissing || this.isInvalid
|
||||
},
|
||||
isStreaming() {
|
||||
return this.store.getters['getAudiobookIdStreaming'] === this.audiobookId
|
||||
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
||||
},
|
||||
showReadButton() {
|
||||
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
|
||||
},
|
||||
showPlayButton() {
|
||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.hasTracks && !this.isStreaming
|
||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
||||
},
|
||||
showSmallEBookIcon() {
|
||||
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
|
||||
},
|
||||
isMissing() {
|
||||
return this._audiobook.isMissing
|
||||
return this._libraryItem.isMissing
|
||||
},
|
||||
isInvalid() {
|
||||
return this._audiobook.isInvalid
|
||||
return this._libraryItem.isInvalid
|
||||
},
|
||||
hasMissingParts() {
|
||||
return this._audiobook.hasMissingParts
|
||||
},
|
||||
hasInvalidParts() {
|
||||
return this._audiobook.hasInvalidParts
|
||||
numMissingParts() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numMissingParts
|
||||
},
|
||||
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.isMissing) return 'Item directory is missing!'
|
||||
else if (this.isInvalid) {
|
||||
if (this.isPodcast) return 'Podcast has no episodes'
|
||||
return 'Item has no audio tracks & ebook'
|
||||
}
|
||||
if (this.hasInvalidParts) {
|
||||
if (this.hasMissingParts) txt += ' '
|
||||
txt += `${this.hasInvalidParts} invalid parts.`
|
||||
var txt = ''
|
||||
if (this.numMissingParts) {
|
||||
txt = `${this.numMissingParts} missing parts.`
|
||||
}
|
||||
return txt || 'Unknown Error'
|
||||
},
|
||||
@@ -290,34 +333,29 @@ export default {
|
||||
return this.store.getters['user/getIsRoot']
|
||||
},
|
||||
moreMenuItems() {
|
||||
var items = [
|
||||
{
|
||||
func: 'toggleRead',
|
||||
text: `Mark as ${this.userIsRead ? 'Not Read' : 'Read'}`
|
||||
},
|
||||
{
|
||||
func: 'openCollections',
|
||||
text: 'Add to Collection'
|
||||
}
|
||||
]
|
||||
var items = []
|
||||
if (!this.isPodcast) {
|
||||
items = [
|
||||
{
|
||||
func: 'toggleFinished',
|
||||
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
|
||||
},
|
||||
{
|
||||
func: 'openCollections',
|
||||
text: 'Add to Collection'
|
||||
}
|
||||
]
|
||||
}
|
||||
if (this.userCanUpdate) {
|
||||
if (this.hasTracks) {
|
||||
items.push({
|
||||
func: 'showEditModalTracks',
|
||||
text: 'Tracks'
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
func: 'showEditModalFiles',
|
||||
text: 'Files'
|
||||
})
|
||||
items.push({
|
||||
func: 'showEditModalMatch',
|
||||
text: 'Match'
|
||||
})
|
||||
}
|
||||
if (this.userCanDownload) {
|
||||
items.push({
|
||||
func: 'showEditModalDownload',
|
||||
text: 'Download'
|
||||
})
|
||||
}
|
||||
if (this.userIsRoot) {
|
||||
items.push({
|
||||
func: 'rescan',
|
||||
@@ -349,11 +387,11 @@ export default {
|
||||
return this.title
|
||||
},
|
||||
authorCleaned() {
|
||||
if (!this.authorFL) return ''
|
||||
if (this.authorFL.length > 30) {
|
||||
return this.authorFL.slice(0, 27) + '...'
|
||||
if (!this.author) return ''
|
||||
if (this.author.length > 30) {
|
||||
return this.author.slice(0, 27) + '...'
|
||||
}
|
||||
return this.authorFL
|
||||
return this.author
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
var constants = this.$constants || this.$nuxt.$constants
|
||||
@@ -370,8 +408,8 @@ export default {
|
||||
this.isSelectionMode = val
|
||||
if (!val) this.selected = false
|
||||
},
|
||||
setEntity(audiobook) {
|
||||
this.audiobook = audiobook
|
||||
setEntity(libraryItem) {
|
||||
this.libraryItem = libraryItem
|
||||
},
|
||||
clickCard(e) {
|
||||
if (this.isSelectionMode) {
|
||||
@@ -381,66 +419,69 @@ export default {
|
||||
} else {
|
||||
var router = this.$router || this.$nuxt.$router
|
||||
if (router) {
|
||||
if (this.booksInSeries) router.push(`/library/${this.libraryId}/series/${this.$encode(this.series)}`)
|
||||
else router.push(`/audiobook/${this.audiobookId}`)
|
||||
if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`)
|
||||
else router.push(`/item/${this.libraryItemId}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
editClick() {
|
||||
this.$emit('edit', this.audiobook)
|
||||
if (this.recentEpisode) {
|
||||
return this.$emit('edit', { libraryItem: this.libraryItem, episode: this.recentEpisode })
|
||||
}
|
||||
this.$emit('edit', this.libraryItem)
|
||||
},
|
||||
toggleRead() {
|
||||
// More menu func
|
||||
toggleFinished() {
|
||||
var updatePayload = {
|
||||
isRead: !this.userIsRead
|
||||
isFinished: !this.itemIsFinished
|
||||
}
|
||||
this.isProcessingReadUpdate = true
|
||||
var toast = this.$toast || this.$nuxt.$toast
|
||||
var axios = this.$axios || this.$nuxt.$axios
|
||||
axios
|
||||
.$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload)
|
||||
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
||||
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.isProcessingReadUpdate = false
|
||||
toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
||||
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
},
|
||||
audiobookScanComplete(result) {
|
||||
this.rescanning = false
|
||||
var toast = this.$toast || this.$nuxt.$toast
|
||||
if (!result) {
|
||||
toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||
} else if (result === 'UPDATED') {
|
||||
toast.success(`Re-Scan complete audiobook was updated`)
|
||||
} else if (result === 'UPTODATE') {
|
||||
toast.success(`Re-Scan complete audiobook was up to date`)
|
||||
} else if (result === 'REMOVED') {
|
||||
toast.error(`Re-Scan complete audiobook was removed`)
|
||||
}
|
||||
},
|
||||
rescan() {
|
||||
this.rescanning = true
|
||||
this._socket.once('audiobook_scan_complete', this.audiobookScanComplete)
|
||||
this._socket.emit('scan_audiobook', this.audiobookId)
|
||||
this.$axios
|
||||
.$get(`/api/items/${this.libraryItemId}/scan`)
|
||||
.then((data) => {
|
||||
this.rescanning = false
|
||||
var result = data.result
|
||||
if (!result) {
|
||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||
} else if (result === 'UPDATED') {
|
||||
this.$toast.success(`Re-Scan complete item was updated`)
|
||||
} else if (result === 'UPTODATE') {
|
||||
this.$toast.success(`Re-Scan complete item was up to date`)
|
||||
} else if (result === 'REMOVED') {
|
||||
this.$toast.error(`Re-Scan complete item was removed`)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to scan library item', error)
|
||||
this.$toast.error('Failed to scan library item')
|
||||
this.rescanning = false
|
||||
})
|
||||
},
|
||||
showEditModalTracks() {
|
||||
showEditModalFiles() {
|
||||
// More menu func
|
||||
this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'tracks' })
|
||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'files' })
|
||||
},
|
||||
showEditModalMatch() {
|
||||
// More menu func
|
||||
this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'match' })
|
||||
},
|
||||
showEditModalDownload() {
|
||||
// More menu func
|
||||
this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'download' })
|
||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
|
||||
},
|
||||
openCollections() {
|
||||
this.store.commit('setSelectedAudiobook', this.audiobook)
|
||||
this.store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.store.commit('globals/setShowUserCollectionsModal', true)
|
||||
},
|
||||
createMoreMenu() {
|
||||
@@ -493,17 +534,26 @@ export default {
|
||||
clickShowMore() {
|
||||
this.createMoreMenu()
|
||||
},
|
||||
clickReadEBook() {
|
||||
this.store.commit('showEReader', this.audiobook)
|
||||
async clickReadEBook() {
|
||||
var libraryItem = await this.$axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
|
||||
console.error('Failed to get lirbary item', this.libraryItemId)
|
||||
return null
|
||||
})
|
||||
if (!libraryItem) return
|
||||
console.log('Got library itemn', libraryItem)
|
||||
this.store.commit('showEReader', libraryItem)
|
||||
},
|
||||
selectBtnClick() {
|
||||
if (this.processingBatch) return
|
||||
this.selected = !this.selected
|
||||
this.$emit('select', this.audiobook)
|
||||
this.$emit('select', this.libraryItem)
|
||||
},
|
||||
play() {
|
||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
eventBus.$emit('play-audiobook', this.audiobookId)
|
||||
eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.recentEpisode ? this.recentEpisode.id : null
|
||||
})
|
||||
},
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
|
||||
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
|
||||
|
||||
<div v-if="isSeriesFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success w-full" />
|
||||
|
||||
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
<!-- <div v-if="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
|
||||
</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>
|
||||
@@ -51,12 +52,28 @@ export default {
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||
return this.width / 240
|
||||
},
|
||||
seriesId() {
|
||||
return this.series ? this.series.id : ''
|
||||
},
|
||||
title() {
|
||||
return this.series ? this.series.name : ''
|
||||
},
|
||||
books() {
|
||||
return this.series ? this.series.books || [] : []
|
||||
},
|
||||
seriesBookProgress() {
|
||||
return this.books
|
||||
.map((libraryItem) => {
|
||||
return this.store.getters['user/getUserMediaProgress'](libraryItem.id)
|
||||
})
|
||||
.filter((p) => !!p)
|
||||
},
|
||||
seriesBooksFinished() {
|
||||
return this.seriesBookProgress.filter((p) => p.isFinished)
|
||||
},
|
||||
isSeriesFinished() {
|
||||
return this.books.length === this.seriesBooksFinished.length
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
@@ -64,13 +81,10 @@ export default {
|
||||
return this.store.state.libraries.currentLibraryId
|
||||
},
|
||||
seriesBooksRoute() {
|
||||
return `/library/${this.currentLibraryId}/series/${this.$encode(this.title)}`
|
||||
},
|
||||
seriesId() {
|
||||
return this.series ? this.$encode(this.title) : null
|
||||
return `/library/${this.currentLibraryId}/series/${this.seriesId}`
|
||||
},
|
||||
hasValidCovers() {
|
||||
var validCovers = this.books.map((bookItem) => bookItem.book.cover)
|
||||
var validCovers = this.books.map((bookItem) => bookItem.media.coverPath)
|
||||
return !!validCovers.length
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<covers-group-cover :name="series" :book-items="bookItems" :width="60" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-group-cover :name="name" :book-items="bookItems" :width="60" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="flex-grow px-2 seriesSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ series }}</p>
|
||||
<p class="truncate text-sm">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,7 +10,10 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
series: String,
|
||||
series: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
bookItems: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
@@ -22,6 +25,9 @@ export default {
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
name() {
|
||||
return this.series.name
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
|
||||
94
client/components/controls/EpisodeSortSelect.vue
Normal file
94
client/components/controls/EpisodeSortSelect.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate text-xs">{{ 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-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: String,
|
||||
descending: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMenu: false,
|
||||
items: [
|
||||
{
|
||||
text: 'Current',
|
||||
value: 'index'
|
||||
},
|
||||
{
|
||||
text: 'Title',
|
||||
value: 'title'
|
||||
},
|
||||
{
|
||||
text: 'Episode',
|
||||
value: 'episode'
|
||||
},
|
||||
{
|
||||
text: 'Pub Date',
|
||||
value: 'publishedAt'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selected: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
selectedDesc: {
|
||||
get() {
|
||||
return this.descending
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:descending', val)
|
||||
}
|
||||
},
|
||||
selectedText() {
|
||||
var _selected = this.selected
|
||||
if (!_selected) return ''
|
||||
var _sel = this.items.find((i) => i.value === _selected)
|
||||
if (!_sel) return ''
|
||||
return _sel.text
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickOutside() {
|
||||
this.showMenu = false
|
||||
},
|
||||
clickedOption(val) {
|
||||
if (this.selected === val) {
|
||||
this.selectedDesc = !this.selectedDesc
|
||||
} else {
|
||||
this.selected = val
|
||||
}
|
||||
this.showMenu = false
|
||||
this.$nextTick(() => this.$emit('change', val))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<template v-for="item in selectItems">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
|
||||
@@ -67,7 +67,7 @@ export default {
|
||||
return {
|
||||
showMenu: false,
|
||||
sublist: null,
|
||||
items: [
|
||||
bookItems: [
|
||||
{
|
||||
text: 'All',
|
||||
value: 'all'
|
||||
@@ -107,6 +107,32 @@ export default {
|
||||
value: 'progress',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Missing',
|
||||
value: 'missing',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Issues',
|
||||
value: 'issues',
|
||||
sublist: false
|
||||
}
|
||||
],
|
||||
podcastItems: [
|
||||
{
|
||||
text: 'All',
|
||||
value: 'all'
|
||||
},
|
||||
{
|
||||
text: 'Genre',
|
||||
value: 'genres',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Tag',
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Issues',
|
||||
value: 'issues',
|
||||
@@ -132,18 +158,42 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
isPodcast() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||
},
|
||||
selectItems() {
|
||||
if (this.isPodcast) return this.podcastItems
|
||||
return this.bookItems
|
||||
},
|
||||
selectedItemSublist() {
|
||||
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
|
||||
},
|
||||
selectedText() {
|
||||
if (!this.selected) return ''
|
||||
var parts = this.selected.split('.')
|
||||
var filterName = this.selectItems.find((i) => i.value === parts[0])
|
||||
var filterValue = null
|
||||
if (parts.length > 1) {
|
||||
return this.$decode(parts[1])
|
||||
var decoded = this.$decode(parts[1])
|
||||
if (decoded.startsWith('aut_')) {
|
||||
var author = this.authors.find((au) => au.id == decoded)
|
||||
if (author) filterValue = author.name
|
||||
} else if (decoded.startsWith('ser_')) {
|
||||
var series = this.series.find((se) => se.id == decoded)
|
||||
if (series) filterValue = series.name
|
||||
} else {
|
||||
filterValue = decoded
|
||||
}
|
||||
}
|
||||
if (filterName && filterValue) {
|
||||
return `${filterName.text}: ${filterValue}`
|
||||
} else if (filterName) {
|
||||
return filterName.text
|
||||
} else if (filterValue) {
|
||||
return filterValue
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
var _sel = this.items.find((i) => i.value === this.selected)
|
||||
if (!_sel) return ''
|
||||
return _sel.text
|
||||
},
|
||||
genres() {
|
||||
return this.filterData.genres || []
|
||||
@@ -164,13 +214,23 @@ export default {
|
||||
return this.filterData.languages || []
|
||||
},
|
||||
progress() {
|
||||
return ['Read', 'Unread', 'In Progress']
|
||||
return ['Finished', 'In Progress', 'Not Started']
|
||||
},
|
||||
missing() {
|
||||
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Volume Number', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
|
||||
},
|
||||
sublistItems() {
|
||||
return (this[this.sublist] || []).map((item) => {
|
||||
return {
|
||||
text: item,
|
||||
value: this.$encode(item)
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
text: item,
|
||||
value: this.$encode(item)
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
text: item.name,
|
||||
value: this.$encode(item.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -19,38 +19,47 @@
|
||||
<p>No Results</p>
|
||||
</li>
|
||||
<template v-else>
|
||||
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
|
||||
<template v-for="item in audiobookResults">
|
||||
<li :key="item.audiobook.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/audiobook/${item.audiobook.id}`">
|
||||
<cards-audiobook-search-card :audiobook="item.audiobook" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||
<p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
|
||||
<template v-for="item in bookResults">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Podcasts</p>
|
||||
<template v-for="item in podcastResults">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
|
||||
<template v-for="item in authorResults">
|
||||
<li :key="item.author" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.author)}`">
|
||||
<cards-author-search-card :author="item.author" />
|
||||
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`">
|
||||
<cards-author-search-card :author="item" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
|
||||
<template v-for="item in seriesResults">
|
||||
<li :key="item.series" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/series/${$encode(item.series)}`">
|
||||
<cards-series-search-card :series="item.series" :book-items="item.audiobooks" />
|
||||
<li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`">
|
||||
<cards-series-search-card :series="item.series" :book-items="item.books" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p>
|
||||
<template v-for="item in tagResults">
|
||||
<li :key="item.tag" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.tag)}`">
|
||||
<cards-tag-search-card :tag="item.tag" />
|
||||
<li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
|
||||
<cards-tag-search-card :tag="item.name" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
@@ -70,7 +79,8 @@ export default {
|
||||
isTyping: false,
|
||||
isFetching: false,
|
||||
search: null,
|
||||
audiobookResults: [],
|
||||
podcastResults: [],
|
||||
bookResults: [],
|
||||
authorResults: [],
|
||||
seriesResults: [],
|
||||
tagResults: [],
|
||||
@@ -83,7 +93,7 @@ export default {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
totalResults() {
|
||||
return this.audiobookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length
|
||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -96,7 +106,8 @@ export default {
|
||||
clearResults() {
|
||||
this.search = null
|
||||
this.lastSearch = null
|
||||
this.audiobookResults = []
|
||||
this.podcastResults = []
|
||||
this.bookResults = []
|
||||
this.authorResults = []
|
||||
this.seriesResults = []
|
||||
this.tagResults = []
|
||||
@@ -136,7 +147,8 @@ export default {
|
||||
// Search was canceled
|
||||
if (!this.isFetching) return
|
||||
|
||||
this.audiobookResults = searchResults.audiobooks || []
|
||||
this.podcastResults = searchResults.podcast || []
|
||||
this.bookResults = searchResults.book || []
|
||||
this.authorResults = searchResults.authors || []
|
||||
this.seriesResults = searchResults.series || []
|
||||
this.tagResults = searchResults.tags || []
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</button>
|
||||
|
||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<template v-for="item in selectItems">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
|
||||
@@ -31,30 +31,48 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
showMenu: false,
|
||||
items: [
|
||||
bookItems: [
|
||||
{
|
||||
text: 'Title',
|
||||
value: 'book.title'
|
||||
value: 'media.metadata.title'
|
||||
},
|
||||
{
|
||||
text: 'Author (First Last)',
|
||||
value: 'book.authorFL'
|
||||
value: 'media.metadata.authorName'
|
||||
},
|
||||
{
|
||||
text: 'Author (Last, First)',
|
||||
value: 'book.authorLF'
|
||||
value: 'media.metadata.authorNameLF'
|
||||
},
|
||||
{
|
||||
text: 'Added At',
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: 'Volume #',
|
||||
value: 'book.volumeNumber'
|
||||
text: 'Size',
|
||||
value: 'size'
|
||||
},
|
||||
{
|
||||
text: 'Duration',
|
||||
value: 'duration'
|
||||
text: 'File Birthtime',
|
||||
value: 'birthtimeMs'
|
||||
},
|
||||
{
|
||||
text: 'File Modified',
|
||||
value: 'mtimeMs'
|
||||
}
|
||||
],
|
||||
podcastItems: [
|
||||
{
|
||||
text: 'Title',
|
||||
value: 'media.metadata.title'
|
||||
},
|
||||
{
|
||||
text: 'Author',
|
||||
value: 'media.metadata.author'
|
||||
},
|
||||
{
|
||||
text: 'Added At',
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: 'Size',
|
||||
@@ -88,9 +106,18 @@ export default {
|
||||
this.$emit('update:descending', val)
|
||||
}
|
||||
},
|
||||
isPodcast() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||
},
|
||||
selectItems() {
|
||||
if (this.isPodcast) return this.podcastItems
|
||||
return this.bookItems
|
||||
},
|
||||
selectedText() {
|
||||
var _selected = this.selected === 'book.author' ? 'book.authorFL' : this.selected
|
||||
var _sel = this.items.find((i) => i.value === _selected)
|
||||
var _selected = this.selected
|
||||
if (!_selected) return ''
|
||||
if (this.selected.startsWith('book.')) _selected = _selected.replace('book.', 'media.metadata.')
|
||||
var _sel = this.selectItems.find((i) => i.value === _selected)
|
||||
if (!_sel) return ''
|
||||
return _sel.text
|
||||
}
|
||||
|
||||
87
client/components/covers/AuthorImage.vue
Normal file
87
client/components/covers/AuthorImage.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div ref="wrapper" :class="`rounded-${rounded}`" class="w-full h-full bg-primary overflow-hidden">
|
||||
<svg v-if="!imagePath" 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 v-else class="w-full h-full relative">
|
||||
<div v-if="showCoverBg" class="cover-bg absolute" :style="{ backgroundImage: `url(${imgSrc})` }" />
|
||||
<img ref="img" :src="imgSrc" @load="imageLoaded" class="absolute top-0 left-0 h-full w-full" :class="coverContain ? 'object-contain' : 'object-cover'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
author: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
rounded: {
|
||||
type: String,
|
||||
default: 'lg'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showCoverBg: false,
|
||||
coverContain: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
_author() {
|
||||
return this.author || {}
|
||||
},
|
||||
authorId() {
|
||||
return this._author.id
|
||||
},
|
||||
imagePath() {
|
||||
return this._author.imagePath
|
||||
},
|
||||
updatedAt() {
|
||||
return this._author.updatedAt
|
||||
},
|
||||
imgSrc() {
|
||||
if (!this.imagePath) return null
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// Testing
|
||||
return `http://localhost:3333/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||
}
|
||||
return `/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
imageLoaded() {
|
||||
var aspectRatio = 1.25
|
||||
if (this.$refs.wrapper) {
|
||||
aspectRatio = this.$refs.wrapper.clientHeight / this.$refs.wrapper.clientWidth
|
||||
}
|
||||
if (this.$refs.img) {
|
||||
var { naturalWidth, naturalHeight } = this.$refs.img
|
||||
var imgAr = naturalHeight / naturalWidth
|
||||
var arDiff = Math.abs(imgAr - aspectRatio)
|
||||
if (arDiff > 0.15) {
|
||||
this.showCoverBg = true
|
||||
} else {
|
||||
this.showCoverBg = false
|
||||
this.coverContain = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -5,20 +5,11 @@
|
||||
<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-fill'" />
|
||||
<div v-show="loading && audiobook" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
|
||||
<img v-if="libraryItem" 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-fill'" />
|
||||
<div v-show="loading && libraryItem" 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>
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,11 +35,10 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
authorOverride: String,
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
@@ -75,12 +65,15 @@ export default {
|
||||
height() {
|
||||
return this.width * this.bookCoverAspectRatio
|
||||
},
|
||||
book() {
|
||||
if (!this.audiobook) return {}
|
||||
return this.audiobook.book || {}
|
||||
media() {
|
||||
if (!this.libraryItem) return {}
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
title() {
|
||||
return this.book.title || 'No Title'
|
||||
return this.mediaMetadata.title || 'No Title'
|
||||
},
|
||||
titleCleaned() {
|
||||
if (this.title.length > 60) {
|
||||
@@ -88,9 +81,11 @@ export default {
|
||||
}
|
||||
return this.title
|
||||
},
|
||||
authors() {
|
||||
return this.mediaMetadata.authors || []
|
||||
},
|
||||
author() {
|
||||
if (this.authorOverride) return this.authorOverride
|
||||
return this.book.author || 'Unknown'
|
||||
return this.authors.map((au) => au.name).join(', ')
|
||||
},
|
||||
authorCleaned() {
|
||||
if (this.author.length > 30) {
|
||||
@@ -102,15 +97,15 @@ export default {
|
||||
return '/book_placeholder.jpg'
|
||||
},
|
||||
fullCoverUrl() {
|
||||
if (!this.audiobook) return null
|
||||
if (!this.libraryItem) return null
|
||||
var store = this.$store || this.$nuxt.$store
|
||||
return store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
|
||||
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl)
|
||||
},
|
||||
cover() {
|
||||
return this.book.cover || this.placeholderUrl
|
||||
return this.media.coverPath || this.placeholderUrl
|
||||
},
|
||||
hasCover() {
|
||||
return !!this.book.cover
|
||||
return !!this.media.coverPath
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.squareAspectRatio ? 192 : 120
|
||||
@@ -138,12 +133,12 @@ export default {
|
||||
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
|
||||
@@ -168,214 +163,3 @@ export default {
|
||||
}
|
||||
</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>
|
||||
@@ -13,8 +13,8 @@
|
||||
<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" />
|
||||
<covers-book-cover :library-item="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-book-cover v-if="books.length > 1" :library-item="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" />
|
||||
|
||||
@@ -63,7 +63,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
getCoverUrl(book) {
|
||||
return this.store.getters['audiobooks/getBookCoverSrc'](book, '')
|
||||
return this.store.getters['globals/getLibraryItemCoverSrc'](book, '')
|
||||
},
|
||||
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
|
||||
var src = coverData.coverUrl
|
||||
@@ -151,7 +151,6 @@ export default {
|
||||
.map((bookItem) => {
|
||||
return {
|
||||
id: bookItem.id,
|
||||
volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null,
|
||||
coverUrl: this.getCoverUrl(bookItem)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ export default {
|
||||
return '/book_placeholder.jpg'
|
||||
},
|
||||
fullCoverUrl() {
|
||||
return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
|
||||
return this.$store.getters['globals/getLibraryItemCoverSrc'](this.audiobook, this.placeholderUrl)
|
||||
},
|
||||
hasCover() {
|
||||
return !!this.audiobook.book.cover
|
||||
|
||||
@@ -77,6 +77,19 @@
|
||||
<div v-if="!newUser.permissions.accessAllLibraries" class="my-4">
|
||||
<ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" label="Libraries Accessible to User" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-cen~ter my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>Can Access All Tags</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.accessAllTags" @input="accessAllTagsToggled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
||||
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" label="Tags Accessible to User" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex pt-4">
|
||||
@@ -103,7 +116,9 @@ export default {
|
||||
processing: false,
|
||||
newUser: {},
|
||||
isNew: true,
|
||||
accountTypes: ['guest', 'user', 'admin']
|
||||
accountTypes: ['guest', 'user', 'admin'],
|
||||
tags: [],
|
||||
loadingTags: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -135,9 +150,37 @@ export default {
|
||||
},
|
||||
libraryItems() {
|
||||
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
|
||||
},
|
||||
itemTags() {
|
||||
return this.tags.map((t) => {
|
||||
return {
|
||||
text: t,
|
||||
value: t
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
accessAllTagsToggled(val) {
|
||||
if (!val && !this.newUser.itemTagsAccessible.length) {
|
||||
this.newUser.itemTagsAccessible = this.libraries.map((l) => l.id)
|
||||
} else if (val && this.newUser.itemTagsAccessible.length) {
|
||||
this.newUser.itemTagsAccessible = []
|
||||
}
|
||||
},
|
||||
fetchAllTags() {
|
||||
this.loadingTags = true
|
||||
this.$axios
|
||||
.$get(`/api/tags`)
|
||||
.then((tags) => {
|
||||
this.tags = tags
|
||||
this.loadingTags = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load tags', error)
|
||||
this.loadingTags = false
|
||||
})
|
||||
},
|
||||
accessAllLibrariesToggled(val) {
|
||||
if (!val && !this.newUser.librariesAccessible.length) {
|
||||
this.newUser.librariesAccessible = this.libraries.map((l) => l.id)
|
||||
@@ -223,20 +266,25 @@ export default {
|
||||
download: type !== 'guest',
|
||||
update: type === 'admin',
|
||||
delete: type === 'admin',
|
||||
upload: type === 'admin'
|
||||
upload: type === 'admin',
|
||||
accessAllLibraries: true,
|
||||
accessAllTags: true
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.fetchAllTags()
|
||||
|
||||
this.isNew = !this.account
|
||||
if (this.account) {
|
||||
var librariesAccessible = this.account.librariesAccessible || []
|
||||
console.log(this.account)
|
||||
this.newUser = {
|
||||
username: this.account.username,
|
||||
password: this.account.password,
|
||||
type: this.account.type,
|
||||
isActive: this.account.isActive,
|
||||
permissions: { ...this.account.permissions },
|
||||
librariesAccessible: [...librariesAccessible]
|
||||
librariesAccessible: [...(this.account.librariesAccessible || [])],
|
||||
itemTagsAccessible: [...(this.account.itemTagsAccessible || [])]
|
||||
}
|
||||
} else {
|
||||
this.newUser = {
|
||||
@@ -249,7 +297,8 @@ export default {
|
||||
update: false,
|
||||
delete: false,
|
||||
upload: false,
|
||||
accessAllLibraries: true
|
||||
accessAllLibraries: true,
|
||||
accessAllTags: true
|
||||
},
|
||||
librariesAccessible: []
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export default {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
audiobookId: String
|
||||
libraryItemId: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -76,8 +76,15 @@ export default {
|
||||
this.showBookmarkTitleInput = true
|
||||
},
|
||||
deleteBookmark(bm) {
|
||||
var bookmark = { ...bm, audiobookId: this.audiobookId }
|
||||
this.$root.socket.emit('delete_bookmark', bookmark)
|
||||
this.$axios
|
||||
.$delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
|
||||
.then(() => {
|
||||
this.$toast.success('Bookmark removed')
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error(`Failed to remove bookmark`)
|
||||
console.error(error)
|
||||
})
|
||||
this.show = false
|
||||
},
|
||||
clickBookmark(bm) {
|
||||
@@ -85,9 +92,15 @@ export default {
|
||||
},
|
||||
submitUpdateBookmark(updatedBookmark) {
|
||||
var bookmark = { ...updatedBookmark }
|
||||
bookmark.audiobookId = this.audiobookId
|
||||
|
||||
this.$root.socket.emit('update_bookmark', bookmark)
|
||||
this.$axios
|
||||
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||
.then(() => {
|
||||
this.$toast.success('Bookmark updated')
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error(`Failed to update bookmark`)
|
||||
console.error(error)
|
||||
})
|
||||
this.show = false
|
||||
},
|
||||
submitCreateBookmark() {
|
||||
@@ -95,11 +108,18 @@ export default {
|
||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||
}
|
||||
var bookmark = {
|
||||
audiobookId: this.audiobookId,
|
||||
title: this.newBookmarkTitle,
|
||||
time: this.currentTime
|
||||
time: Math.floor(this.currentTime)
|
||||
}
|
||||
this.$root.socket.emit('create_bookmark', bookmark)
|
||||
this.$axios
|
||||
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||
.then(() => {
|
||||
this.$toast.success('Bookmark added')
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error(`Failed to create bookmark`)
|
||||
console.error(error)
|
||||
})
|
||||
|
||||
this.newBookmarkTitle = ''
|
||||
this.showBookmarkTitleInput = false
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<modals-libraries-edit-library v-if="show" :library="library" :processing.sync="processing" @close="show = false" />
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
library: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.library ? 'Update Library' : 'New Library'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
|
||||
<transition-group name="list-complete" tag="div">
|
||||
<template v-for="collection in sortedCollections">
|
||||
<modals-collections-user-collection-item :key="collection.id" :collection="collection" class="list-complete-item" @add="addToCollection" @remove="removeFromCollection" @close="show = false" />
|
||||
<modals-collections-user-collection-item :key="collection.id" :collection="collection" :book-cover-aspect-ratio="bookCoverAspectRatio" class="list-complete-item" @add="addToCollection" @remove="removeFromCollection" @close="show = false" />
|
||||
</template>
|
||||
</transition-group>
|
||||
</div>
|
||||
@@ -50,7 +50,7 @@ export default {
|
||||
this.loadCollections()
|
||||
this.newCollectionName = ''
|
||||
} else {
|
||||
this.$store.commit('setSelectedAudiobook', null)
|
||||
this.$store.commit('setSelectedLibraryItem', null)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -65,15 +65,18 @@ export default {
|
||||
},
|
||||
title() {
|
||||
if (this.showBatchUserCollectionModal) {
|
||||
return `${this.selectedBookIds.length} Books Selected`
|
||||
return `${this.selectedBookIds.length} Items Selected`
|
||||
}
|
||||
return this.selectedAudiobook ? this.selectedAudiobook.book.title : ''
|
||||
return this.selectedLibraryItem ? this.selectedLibraryItem.media.metadata.title : ''
|
||||
},
|
||||
selectedAudiobook() {
|
||||
return this.$store.state.selectedAudiobook
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
selectedAudiobookId() {
|
||||
return this.selectedAudiobook ? this.selectedAudiobook.id : null
|
||||
selectedLibraryItem() {
|
||||
return this.$store.state.selectedLibraryItem
|
||||
},
|
||||
selectedLibraryItemId() {
|
||||
return this.selectedLibraryItem ? this.selectedLibraryItem.id : null
|
||||
},
|
||||
collections() {
|
||||
return this.$store.state.user.collections || []
|
||||
@@ -87,7 +90,7 @@ export default {
|
||||
var collectionBookIds = c.books.map((b) => b.id)
|
||||
includesBook = !this.selectedBookIds.find((id) => !collectionBookIds.includes(id))
|
||||
} else {
|
||||
includesBook = !!c.books.find((b) => b.id === this.selectedAudiobookId)
|
||||
includesBook = !!c.books.find((b) => b.id === this.selectedLibraryItemId)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -101,7 +104,7 @@ export default {
|
||||
return this.$store.state.globals.showBatchUserCollectionModal
|
||||
},
|
||||
selectedBookIds() {
|
||||
return this.$store.state.selectedAudiobooks || []
|
||||
return this.$store.state.selectedLibraryItems || []
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
@@ -112,7 +115,7 @@ export default {
|
||||
this.$store.dispatch('user/loadUserCollections')
|
||||
},
|
||||
removeFromCollection(collection) {
|
||||
if (!this.selectedAudiobookId && !this.selectedBookIds.length) return
|
||||
if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return
|
||||
this.processing = true
|
||||
|
||||
if (this.showBatchUserCollectionModal) {
|
||||
@@ -132,7 +135,7 @@ export default {
|
||||
} else {
|
||||
// Remove single book
|
||||
this.$axios
|
||||
.$delete(`/api/collections/${collection.id}/book/${this.selectedAudiobookId}`)
|
||||
.$delete(`/api/collections/${collection.id}/book/${this.selectedLibraryItemId}`)
|
||||
.then((updatedCollection) => {
|
||||
console.log(`Book removed from collection`, updatedCollection)
|
||||
this.$toast.success('Book removed from collection')
|
||||
@@ -146,7 +149,7 @@ export default {
|
||||
}
|
||||
},
|
||||
addToCollection(collection) {
|
||||
if (!this.selectedAudiobookId && !this.selectedBookIds.length) return
|
||||
if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return
|
||||
this.processing = true
|
||||
|
||||
if (this.showBatchUserCollectionModal) {
|
||||
@@ -164,10 +167,10 @@ export default {
|
||||
this.processing = false
|
||||
})
|
||||
} else {
|
||||
if (!this.selectedAudiobookId) return
|
||||
if (!this.selectedLibraryItemId) return
|
||||
|
||||
this.$axios
|
||||
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedAudiobookId })
|
||||
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId })
|
||||
.then((updatedCollection) => {
|
||||
console.log(`Book added to collection`, updatedCollection)
|
||||
this.$toast.success('Book added to collection')
|
||||
@@ -181,12 +184,12 @@ export default {
|
||||
}
|
||||
},
|
||||
submitCreateCollection() {
|
||||
if (!this.newCollectionName || (!this.selectedAudiobookId && !this.selectedBookIds.length)) {
|
||||
if (!this.newCollectionName || (!this.selectedLibraryItemId && !this.selectedBookIds.length)) {
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
|
||||
var books = this.showBatchUserCollectionModal ? this.selectedBookIds : [this.selectedAudiobookId]
|
||||
var books = this.showBatchUserCollectionModal ? this.selectedBookIds : [this.selectedLibraryItemId]
|
||||
var newCollection = {
|
||||
books: books,
|
||||
libraryId: this.currentLibraryId,
|
||||
|
||||
160
client/components/modals/authors/EditModal.vue
Normal file
160
client/components/modals/authors/EditModal.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="edit-author" :width="800" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="flex">
|
||||
<div class="w-40 p-2">
|
||||
<div class="w-full h-45 relative">
|
||||
<covers-author-image :author="author" />
|
||||
<div v-show="!processing" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
|
||||
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<div class="flex">
|
||||
<div class="w-3/4 p-2">
|
||||
<ui-text-input-with-label v-model="authorCopy.name" :disabled="processing" label="Name" />
|
||||
</div>
|
||||
<div class="flex-grow p-2">
|
||||
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" label="Description" :rows="8" />
|
||||
</div>
|
||||
|
||||
<div class="flex pt-2 px-2">
|
||||
<ui-btn type="button" @click="searchAuthor">Quick Match</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn type="submit">Submit</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
author: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
authorCopy: {
|
||||
name: '',
|
||||
asin: '',
|
||||
description: ''
|
||||
},
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
author: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
authorId() {
|
||||
if (!this.author) return ''
|
||||
return this.author.id
|
||||
},
|
||||
title() {
|
||||
return 'Edit Author'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.authorCopy.name = this.author.name
|
||||
this.authorCopy.asin = this.author.asin
|
||||
this.authorCopy.description = this.author.description
|
||||
},
|
||||
async submitForm() {
|
||||
var keysToCheck = ['name', 'asin', 'description']
|
||||
var updatePayload = {}
|
||||
keysToCheck.forEach((key) => {
|
||||
if (this.authorCopy[key] !== this.author[key]) {
|
||||
updatePayload[key] = this.authorCopy[key]
|
||||
}
|
||||
})
|
||||
if (!Object.keys(updatePayload).length) {
|
||||
this.$toast.info('No updates are necessary')
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to update author')
|
||||
return null
|
||||
})
|
||||
if (result) {
|
||||
if (result.updated) this.$toast.success('Author updated')
|
||||
else this.$toast.info('No updates were needed')
|
||||
}
|
||||
this.processing = false
|
||||
},
|
||||
async removeCover() {
|
||||
var updatePayload = {
|
||||
imagePath: null,
|
||||
relImagePath: null
|
||||
}
|
||||
this.processing = true
|
||||
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to remove image')
|
||||
return null
|
||||
})
|
||||
if (result && result.updated) {
|
||||
this.$toast.success('Author image removed')
|
||||
}
|
||||
this.processing = false
|
||||
},
|
||||
async searchAuthor() {
|
||||
if (!this.authorCopy.name) {
|
||||
this.$toast.error('Must enter an author name')
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.authorCopy.name }).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
})
|
||||
if (!response) {
|
||||
this.$toast.error('Author not found')
|
||||
} else if (response.updated) {
|
||||
if (response.author.imagePath) this.$toast.success('Author was updated')
|
||||
else this.$toast.success('Author was updated (no image found)')
|
||||
} else {
|
||||
this.$toast.info('No updates were made for Author')
|
||||
}
|
||||
this.processing = false
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
@@ -4,7 +4,7 @@
|
||||
<!-- <span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-80'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> -->
|
||||
<div class="w-20 max-w-20 text-center">
|
||||
<!-- <img src="/Logo.png" /> -->
|
||||
<covers-collection-cover :book-items="books" :width="80" :height="40 * 1.6" />
|
||||
<covers-collection-cover :book-items="books" :width="80" :height="40 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div class="flex-grow overflow-hidden px-2">
|
||||
<!-- <template v-if="isEditing">
|
||||
@@ -38,7 +38,8 @@ export default {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
highlight: Boolean
|
||||
highlight: Boolean,
|
||||
bookCoverAspectRatio: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
|
||||
<template v-for="(authorName, index) in searchAuthors">
|
||||
<cards-search-author-card :key="index" :author-name="authorName" @match="setSelectedMatch" />
|
||||
</template>
|
||||
|
||||
<div v-show="processing" class="flex h-full items-center justify-center">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="flex mb-2">
|
||||
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="text-xl pl-3">Update Author Details</p>
|
||||
</div>
|
||||
<form @submit.prevent="submitMatchUpdate">
|
||||
<div v-if="selectedMatch.image" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.image" />
|
||||
<img :src="selectedMatch.image" class="w-24 object-contain ml-4" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.image" :disabled="!selectedMatchUsage.image" label="Image" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.name" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.name" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.name" :disabled="!selectedMatchUsage.name" label="Name" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.description" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.description" />
|
||||
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end py-2">
|
||||
<ui-btn color="success" type="submit">Update</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchAuthors: [],
|
||||
audiobookId: null,
|
||||
searchAuthor: null,
|
||||
lastSearch: null,
|
||||
hasSearched: false,
|
||||
selectedMatch: null,
|
||||
|
||||
selectedMatchUsage: {
|
||||
image: true,
|
||||
name: true,
|
||||
description: true
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
audiobook: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
return this.processing
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// getSearchQuery() {
|
||||
// return `q=${this.searchAuthor}`
|
||||
// },
|
||||
// submitSearch() {
|
||||
// if (!this.searchTitle) {
|
||||
// this.$toast.warning('Search title is required')
|
||||
// return
|
||||
// }
|
||||
// this.runSearch()
|
||||
// },
|
||||
// async runSearch() {
|
||||
// var searchQuery = this.getSearchQuery()
|
||||
// if (this.lastSearch === searchQuery) return
|
||||
// this.selectedMatch = null
|
||||
// this.isProcessing = true
|
||||
// this.lastSearch = searchQuery
|
||||
// var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => {
|
||||
// console.error('Failed', error)
|
||||
// return []
|
||||
// })
|
||||
// if (result) {
|
||||
// this.selectedMatch = result
|
||||
// }
|
||||
// this.isProcessing = false
|
||||
// this.hasSearched = true
|
||||
// },
|
||||
init() {
|
||||
this.selectedMatch = null
|
||||
// this.selectedMatchUsage = {
|
||||
// title: true,
|
||||
// subtitle: true,
|
||||
// cover: true,
|
||||
// author: true,
|
||||
// description: true,
|
||||
// isbn: true,
|
||||
// publisher: true,
|
||||
// publishYear: true
|
||||
// }
|
||||
|
||||
if (this.audiobook.id !== this.audiobookId) {
|
||||
this.selectedMatch = null
|
||||
this.hasSearched = false
|
||||
this.audiobookId = this.audiobook.id
|
||||
}
|
||||
|
||||
if (!this.audiobook.book || !this.audiobook.book.authorFL) {
|
||||
this.searchAuthors = []
|
||||
return
|
||||
}
|
||||
this.searchAuthors = (this.audiobook.book.authorFL || '').split(', ')
|
||||
},
|
||||
selectMatch(match) {
|
||||
this.selectedMatch = match
|
||||
},
|
||||
buildMatchUpdatePayload() {
|
||||
var updatePayload = {}
|
||||
for (const key in this.selectedMatchUsage) {
|
||||
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
||||
updatePayload[key] = this.selectedMatch[key]
|
||||
}
|
||||
}
|
||||
return updatePayload
|
||||
},
|
||||
async submitMatchUpdate() {
|
||||
var updatePayload = this.buildMatchUpdatePayload()
|
||||
if (!Object.keys(updatePayload).length) {
|
||||
return
|
||||
}
|
||||
this.isProcessing = true
|
||||
|
||||
if (updatePayload.cover) {
|
||||
var coverPayload = {
|
||||
url: updatePayload.cover
|
||||
}
|
||||
var success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, coverPayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
if (success) {
|
||||
this.$toast.success('Book Cover Updated')
|
||||
} else {
|
||||
this.$toast.error('Book Cover Failed to Update')
|
||||
}
|
||||
console.log('Updated cover')
|
||||
delete updatePayload.cover
|
||||
}
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
var bookUpdatePayload = {
|
||||
book: updatePayload
|
||||
}
|
||||
var success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, bookUpdatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
if (success) {
|
||||
this.$toast.success('Book Details Updated')
|
||||
this.selectedMatch = null
|
||||
this.$emit('selectTab', 'details')
|
||||
} else {
|
||||
this.$toast.error('Book Details Failed to Update')
|
||||
}
|
||||
} else {
|
||||
this.selectedMatch = null
|
||||
}
|
||||
this.isProcessing = false
|
||||
},
|
||||
setSelectedMatch(authorMatchObj) {
|
||||
this.selectedMatch = authorMatchObj
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.matchListWrapper {
|
||||
height: calc(100% - 80px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,59 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
|
||||
<table v-else class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
||||
<th class="text-left">Title</th>
|
||||
<th class="text-center">Start</th>
|
||||
<th class="text-center">End</th>
|
||||
</tr>
|
||||
<template v-for="chapter in chapters">
|
||||
<tr :key="chapter.id">
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ chapter.id }}</p>
|
||||
</td>
|
||||
<td class="font-book">
|
||||
{{ chapter.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.end) }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
chapters: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
audiobook: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
init() {
|
||||
this.chapters = this.audiobook.chapters || []
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,342 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<form class="w-full h-full" @submit.prevent="submitForm">
|
||||
<div ref="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
|
||||
<div class="flex -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label v-model="details.title" label="Title" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="details.subtitle" label="Subtitle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<ui-text-input-with-label v-model="details.author" label="Author" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<ui-input-dropdown ref="seriesDropdown" v-model="details.series" label="Series" :items="series" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/3 px-1">
|
||||
<ui-text-input-with-label v-model="details.narrator" label="Narrator" />
|
||||
</div>
|
||||
<div class="w-1/3 px-1">
|
||||
<ui-text-input-with-label v-model="details.publisher" label="Publisher" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="details.language" label="Language" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/3 px-1">
|
||||
<ui-text-input-with-label v-model="details.isbn" label="ISBN" />
|
||||
</div>
|
||||
<div class="w-1/3 px-1">
|
||||
<ui-text-input-with-label v-model="details.asin" label="ASIN" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
|
||||
<div class="flex items-center px-4">
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-tooltip v-if="!isMissing" text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="mr-4 hidden sm:block">
|
||||
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
|
||||
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
|
||||
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-btn type="submit">Submit</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
details: {
|
||||
title: null,
|
||||
subtitle: null,
|
||||
description: null,
|
||||
author: null,
|
||||
narrator: null,
|
||||
series: null,
|
||||
volumeNumber: null,
|
||||
publishYear: null,
|
||||
publisher: null,
|
||||
language: null,
|
||||
isbn: null,
|
||||
asin: null,
|
||||
genres: []
|
||||
},
|
||||
newTags: [],
|
||||
resettingProgress: false,
|
||||
isScrollable: false,
|
||||
savingMetadata: false,
|
||||
rescanning: false,
|
||||
quickMatching: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
audiobook: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
return this.processing
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
isMissing() {
|
||||
return !!this.audiobook && !!this.audiobook.isMissing
|
||||
},
|
||||
audiobookId() {
|
||||
return this.audiobook ? this.audiobook.id : null
|
||||
},
|
||||
book() {
|
||||
return this.audiobook ? this.audiobook.book || {} : {}
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
genres() {
|
||||
return this.filterData.genres || []
|
||||
},
|
||||
tags() {
|
||||
return this.filterData.tags || []
|
||||
},
|
||||
series() {
|
||||
return this.filterData.series || []
|
||||
},
|
||||
filterData() {
|
||||
return this.$store.state.libraries.filterData || {}
|
||||
},
|
||||
libraryId() {
|
||||
return this.audiobook ? this.audiobook.libraryId : null
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
|
||||
},
|
||||
libraryScan() {
|
||||
if (!this.libraryId) return null
|
||||
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
quickMatch() {
|
||||
this.quickMatching = true
|
||||
var matchOptions = {
|
||||
provider: this.libraryProvider,
|
||||
title: this.details.title,
|
||||
author: this.details.author !== this.book.author ? this.details.author : null
|
||||
}
|
||||
this.$axios
|
||||
.$post(`/api/books/${this.audiobookId}/match`, matchOptions)
|
||||
.then((res) => {
|
||||
this.quickMatching = false
|
||||
if (res.warning) {
|
||||
this.$toast.warning(res.warning)
|
||||
} else if (res.updated) {
|
||||
this.$toast.success('Audiobook details updated')
|
||||
} else {
|
||||
this.$toast.info('No updates were made')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
console.error('Failed to match', error)
|
||||
this.$toast.error(errMsg || 'Failed to match')
|
||||
this.quickMatching = false
|
||||
})
|
||||
},
|
||||
audiobookScanComplete(result) {
|
||||
this.rescanning = false
|
||||
if (!result) {
|
||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||
} else if (result === 'UPDATED') {
|
||||
this.$toast.success(`Re-Scan complete audiobook was updated`)
|
||||
} else if (result === 'UPTODATE') {
|
||||
this.$toast.success(`Re-Scan complete audiobook was up to date`)
|
||||
} else if (result === 'REMOVED') {
|
||||
this.$toast.error(`Re-Scan complete audiobook was removed`)
|
||||
}
|
||||
},
|
||||
rescan() {
|
||||
this.rescanning = true
|
||||
this.$root.socket.once('audiobook_scan_complete', this.audiobookScanComplete)
|
||||
this.$root.socket.emit('scan_audiobook', this.audiobookId)
|
||||
},
|
||||
saveMetadataComplete(result) {
|
||||
this.savingMetadata = false
|
||||
if (result.error) {
|
||||
this.$toast.error(result.error)
|
||||
} else if (result.audiobookId) {
|
||||
var { savedPath } = result
|
||||
if (!savedPath) {
|
||||
this.$toast.error(`Failed to save metadata file (${result.audiobookId})`)
|
||||
} else {
|
||||
this.$toast.success(`Metadata file saved "${result.audiobookTitle}"`)
|
||||
}
|
||||
}
|
||||
},
|
||||
saveMetadata() {
|
||||
this.savingMetadata = true
|
||||
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
|
||||
this.$root.socket.emit('save_metadata', this.audiobookId)
|
||||
},
|
||||
submitForm() {
|
||||
if (this.isProcessing) {
|
||||
return
|
||||
}
|
||||
this.isProcessing = true
|
||||
if (this.$refs.seriesDropdown && this.$refs.seriesDropdown.isFocused) {
|
||||
this.$refs.seriesDropdown.blur()
|
||||
}
|
||||
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
|
||||
this.$refs.genresSelect.forceBlur()
|
||||
}
|
||||
if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {
|
||||
this.$refs.tagsSelect.forceBlur()
|
||||
}
|
||||
this.$nextTick(this.handleForm)
|
||||
},
|
||||
async handleForm() {
|
||||
const updatePayload = {
|
||||
book: this.details,
|
||||
tags: this.newTags
|
||||
}
|
||||
|
||||
var updatedAudiobook = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (updatedAudiobook) {
|
||||
this.$toast.success('Update Successful')
|
||||
this.$emit('close')
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.details.title = this.book.title
|
||||
this.details.subtitle = this.book.subtitle
|
||||
this.details.description = this.book.description
|
||||
this.details.author = this.book.author
|
||||
this.details.narrator = this.book.narrator
|
||||
this.details.genres = this.book.genres || []
|
||||
this.details.series = this.book.series
|
||||
this.details.volumeNumber = this.book.volumeNumber
|
||||
this.details.publishYear = this.book.publishYear
|
||||
this.details.publisher = this.book.publisher || null
|
||||
this.details.language = this.book.language || null
|
||||
this.details.isbn = this.book.isbn || null
|
||||
this.details.asin = this.book.asin || null
|
||||
|
||||
this.newTags = this.audiobook.tags || []
|
||||
},
|
||||
deleteAudiobook() {
|
||||
if (confirm(`Are you sure you want to remove this audiobook?\n\n*Does not delete your files, only removes the audiobook from AudioBookshelf`)) {
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$delete(`/api/books/${this.audiobookId}`)
|
||||
.then(() => {
|
||||
console.log('Audiobook removed')
|
||||
this.$toast.success('Audiobook Removed')
|
||||
this.$emit('close')
|
||||
this.isProcessing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Remove Audiobook failed', error)
|
||||
this.isProcessing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
checkIsScrollable() {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.formWrapper) {
|
||||
if (this.$refs.formWrapper.scrollHeight > this.$refs.formWrapper.clientHeight) {
|
||||
this.isScrollable = true
|
||||
} else {
|
||||
this.isScrollable = false
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
setResizeObserver() {
|
||||
try {
|
||||
this.$nextTick(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
this.checkIsScrollable()
|
||||
})
|
||||
resizeObserver.observe(this.$refs.formWrapper)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to set resize observer')
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setResizeObserver()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.details-form-wrapper {
|
||||
height: calc(100% - 70px);
|
||||
max-height: calc(100% - 70px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,215 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||
<p class="text-center text-lg mb-4 py-8">Preparing downloads can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted.<br />Download will timeout after 15 minutes.</p>
|
||||
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-4">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
|
||||
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||
|
||||
<ui-btn v-if="singleDownloadStatus !== $constants.DownloadStatus.READY" :loading="singleDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
|
||||
<div v-else>
|
||||
<ui-btn @click="downloadWithProgress(singleAudioDownload)">Download</ui-btn>
|
||||
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(singleAudioDownload.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full border border-black-200 p-4 my-4">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p v-if="totalFiles > 1" class="text-lg">Zip {{ totalFiles }} Files</p>
|
||||
<p v-else>Zip 1 File</p>
|
||||
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .ZIP file from the contents of the audiobook directory.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<p v-if="zipDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
||||
<p v-if="zipDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||
<p v-if="zipDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||
|
||||
<ui-btn v-if="zipDownloadStatus !== $constants.DownloadStatus.READY" :loading="zipDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startZipDownload">Start Download</ui-btn>
|
||||
<div v-else>
|
||||
<ui-btn @click="downloadWithProgress(zipDownload)">Download</ui-btn>
|
||||
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(zipDownload.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showM4bDownload" class="w-full flex items-center justify-center absolute bottom-4 left-0 right-0 text-center">
|
||||
<p class="text-error text-lg">* <strong>Experimental:</strong> Merging multiple .m4b files may have issues. <a href="https://github.com/advplyr/audiobookshelf/issues" class="underline text-blue-600" target="_blank">Report issues here.</a></p>
|
||||
</div>
|
||||
|
||||
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<p class="text-lg">Download.... {{ downloadPercent }}%</p>
|
||||
<p class="w-24 font-mono pl-8 text-right">
|
||||
{{ downloadAmount }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tempDisable: false,
|
||||
isDownloading: false,
|
||||
downloadPercent: '0',
|
||||
downloadAmount: '0 KB'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
singleDownloadStatus(newVal) {
|
||||
if (newVal) {
|
||||
this.tempDisable = false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
audiobookId() {
|
||||
return this.audiobook ? this.audiobook.id : null
|
||||
},
|
||||
_audiobook() {
|
||||
return this.audiobook || {}
|
||||
},
|
||||
downloads() {
|
||||
return this.$store.getters['downloads/getDownloads'](this.audiobookId)
|
||||
},
|
||||
singleAudioDownload() {
|
||||
return this.downloads.find((d) => d.type === 'singleAudio')
|
||||
},
|
||||
singleDownloadStatus() {
|
||||
return this.singleAudioDownload ? this.singleAudioDownload.status : false
|
||||
},
|
||||
zipDownload() {
|
||||
return this.downloads.find((d) => d.type === 'zip')
|
||||
},
|
||||
zipDownloadStatus() {
|
||||
return this.zipDownload ? this.zipDownload.status : false
|
||||
},
|
||||
isSingleTrack() {
|
||||
if (!this.audiobook.tracks) return false
|
||||
return this.audiobook.tracks.length === 1
|
||||
},
|
||||
singleTrackPath() {
|
||||
if (!this.isSingleTrack) return null
|
||||
return this.audiobook.tracks[0].path
|
||||
},
|
||||
audioFiles() {
|
||||
return this.audiobook ? this.audiobook.audioFiles || [] : []
|
||||
},
|
||||
otherFiles() {
|
||||
return this.audiobook ? this.audiobook.otherFiles || [] : []
|
||||
},
|
||||
totalFiles() {
|
||||
return this.audioFiles.length + this.otherFiles.length
|
||||
},
|
||||
showM4bDownload() {
|
||||
return !this._audiobook.isMissing && !this._audiobook.isInvalid && this._audiobook.tracks.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
startZipDownload() {
|
||||
// console.log('Download request received', this.audiobook)
|
||||
|
||||
this.tempDisable = true
|
||||
setTimeout(() => {
|
||||
this.tempDisable = false
|
||||
}, 1000)
|
||||
|
||||
var downloadPayload = {
|
||||
audiobookId: this.audiobook.id,
|
||||
type: 'zip'
|
||||
}
|
||||
this.$root.socket.emit('download', downloadPayload)
|
||||
},
|
||||
startSingleAudioDownload() {
|
||||
// console.log('Download request received', this.audiobook)
|
||||
|
||||
this.tempDisable = true
|
||||
setTimeout(() => {
|
||||
this.tempDisable = false
|
||||
}, 1000)
|
||||
|
||||
var downloadPayload = {
|
||||
audiobookId: this.audiobook.id,
|
||||
type: 'singleAudio',
|
||||
includeMetadata: true,
|
||||
includeCover: true
|
||||
}
|
||||
this.$root.socket.emit('download', downloadPayload)
|
||||
},
|
||||
downloadWithProgress(download) {
|
||||
var downloadId = download.id
|
||||
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
|
||||
var filename = download.filename
|
||||
|
||||
this.isDownloading = true
|
||||
|
||||
var request = new XMLHttpRequest()
|
||||
request.responseType = 'blob'
|
||||
request.open('get', downloadUrl, true)
|
||||
request.setRequestHeader('Authorization', `Bearer ${this.$store.getters['user/getToken']}`)
|
||||
request.send()
|
||||
|
||||
request.onreadystatechange = () => {
|
||||
if (request.readyState === 4) {
|
||||
this.isDownloading = false
|
||||
}
|
||||
if (request.readyState == 4 && request.status == 200) {
|
||||
const url = window.URL.createObjectURL(request.response)
|
||||
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
setTimeout(() => {
|
||||
if (anchor) anchor.remove()
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = (err) => {
|
||||
console.error('Download error', err)
|
||||
this.isDownloading = false
|
||||
}
|
||||
|
||||
request.onprogress = (e) => {
|
||||
const percent_complete = Math.floor((e.loaded / e.total) * 100)
|
||||
this.downloadAmount = this.$bytesPretty(e.loaded)
|
||||
this.downloadPercent = percent_complete
|
||||
|
||||
// const duration = (new Date().getTime() - startTime) / 1000
|
||||
// const bps = e.loaded / duration
|
||||
// const kbps = Math.floor(bps / 1024)
|
||||
// const time = (e.total - e.loaded) / bps
|
||||
// const seconds = Math.floor(time % 60)
|
||||
// const minutes = Math.floor(time / 60)
|
||||
// console.log(`${percent_complete}% - ${kbps} Kbps - ${minutes} min ${seconds} sec remaining`)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,115 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div class="mb-4">
|
||||
<template v-if="hasTracks">
|
||||
<div class="w-full bg-primary px-4 py-2 flex items-center">
|
||||
<p class="pr-4">Audio Tracks</p>
|
||||
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`">
|
||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th>#</th>
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-left">Size</th>
|
||||
<th class="text-left">Duration</th>
|
||||
<th v-if="showDownload" class="text-center">Download</th>
|
||||
</tr>
|
||||
<template v-for="track in tracksCleaned">
|
||||
<tr :key="track.index">
|
||||
<td class="text-center">
|
||||
<p>{{ track.index }}</p>
|
||||
</td>
|
||||
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(track.size) }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="showDownload" class="font-mono text-center">
|
||||
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</template>
|
||||
<div v-else class="flex my-4 text-center justify-center text-xl">No Audio Tracks</div>
|
||||
</div>
|
||||
|
||||
<tables-all-files-table :audiobook="audiobook" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tracks: null,
|
||||
showFullPath: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
audiobook: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
audiobookPath() {
|
||||
return this.audiobook.path
|
||||
},
|
||||
tracksCleaned() {
|
||||
return this.tracks.map((track) => {
|
||||
var trackPath = track.path.replace(/\\/g, '/')
|
||||
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
|
||||
|
||||
return {
|
||||
...track,
|
||||
relativePath: trackPath
|
||||
.replace(audiobookPath + '/', '')
|
||||
.replace(/%/g, '%25')
|
||||
.replace(/#/g, '%23')
|
||||
}
|
||||
})
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
isMissing() {
|
||||
return this.audiobook.isMissing
|
||||
},
|
||||
showDownload() {
|
||||
return this.userCanDownload && !this.isMissing
|
||||
},
|
||||
hasTracks() {
|
||||
return this.audiobook.tracks.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.tracks = this.audiobook.tracks
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,286 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<div class="flex items-center justify-start -mx-1 h-20">
|
||||
<div class="w-40 px-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
||||
</div>
|
||||
<div class="w-72 px-1">
|
||||
<ui-text-input-with-label v-model="searchTitle" :label="provider == 'audible' ? 'Search Title or ASIN' : 'Search Title'" placeholder="Search" />
|
||||
</div>
|
||||
<div class="w-72 px-1">
|
||||
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
||||
</div>
|
||||
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
<div v-show="processing" class="flex h-full items-center justify-center">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<div v-show="!processing && !searchResults.length && hasSearched" class="flex h-full items-center justify-center">
|
||||
<p>No Results</p>
|
||||
</div>
|
||||
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper">
|
||||
<template v-for="(res, index) in searchResults">
|
||||
<cards-book-match-card :key="index" :book="res" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="flex mb-2">
|
||||
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="text-xl pl-3">Update Book Details</p>
|
||||
</div>
|
||||
<form @submit.prevent="submitMatchUpdate">
|
||||
<div v-if="selectedMatch.cover" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.cover" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" label="Cover" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.title" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.title" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" label="Title" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.subtitle" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.subtitle" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" label="Subtitle" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.author" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.author" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" label="Author" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.narrator" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.narrator" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.narrator" :disabled="!selectedMatchUsage.narrator" label="Narrator" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.description" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.description" />
|
||||
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.publisher" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.publisher" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" label="Publisher" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.publishYear" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.publishYear" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.publishYear" :disabled="!selectedMatchUsage.publishYear" label="Publish Year" class="flex-grow ml-4" />
|
||||
</div>
|
||||
|
||||
<div v-if="selectedMatch.series" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.series" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" label="Series" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.volumeNumber" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.volumeNumber" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.volumeNumber" :disabled="!selectedMatchUsage.volumeNumber" label="Volume Number" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.isbn" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.asin" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.asin" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end py-2">
|
||||
<ui-btn color="success" type="submit">Update</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
audiobookId: null,
|
||||
searchTitle: null,
|
||||
searchAuthor: null,
|
||||
lastSearch: null,
|
||||
provider: 'google',
|
||||
searchResults: [],
|
||||
hasSearched: false,
|
||||
selectedMatch: null,
|
||||
selectedMatchUsage: {
|
||||
title: true,
|
||||
subtitle: true,
|
||||
cover: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
publisher: true,
|
||||
publishYear: true,
|
||||
series: true,
|
||||
volumeNumber: true,
|
||||
asin: true,
|
||||
isbn: true
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
audiobook: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
return this.processing
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
providers() {
|
||||
return this.$store.state.scanners.providers
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
persistProvider() {
|
||||
try {
|
||||
localStorage.setItem('book-provider', this.provider)
|
||||
} catch (error) {
|
||||
console.error('PersistProvider', error)
|
||||
}
|
||||
},
|
||||
getSearchQuery() {
|
||||
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${this.searchTitle}`
|
||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
||||
return searchQuery
|
||||
},
|
||||
submitSearch() {
|
||||
if (!this.searchTitle) {
|
||||
this.$toast.warning('Search title is required')
|
||||
return
|
||||
}
|
||||
this.persistProvider()
|
||||
this.runSearch()
|
||||
},
|
||||
async runSearch() {
|
||||
var searchQuery = this.getSearchQuery()
|
||||
if (this.lastSearch === searchQuery) return
|
||||
this.searchResults = []
|
||||
this.isProcessing = true
|
||||
this.lastSearch = searchQuery
|
||||
var results = await this.$axios.$get(`/api/search/books?${searchQuery}`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return []
|
||||
})
|
||||
results = results.filter((res) => {
|
||||
return !!res.title
|
||||
})
|
||||
this.searchResults = results
|
||||
this.isProcessing = false
|
||||
this.hasSearched = true
|
||||
},
|
||||
init() {
|
||||
this.selectedMatch = null
|
||||
this.selectedMatchUsage = {
|
||||
title: true,
|
||||
subtitle: true,
|
||||
cover: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
publisher: true,
|
||||
publishYear: true,
|
||||
series: true,
|
||||
volumeNumber: true,
|
||||
asin: true,
|
||||
isbn: true
|
||||
}
|
||||
|
||||
if (this.audiobook.id !== this.audiobookId) {
|
||||
this.searchResults = []
|
||||
this.hasSearched = false
|
||||
this.audiobookId = this.audiobook.id
|
||||
}
|
||||
|
||||
if (!this.audiobook.book || !this.audiobook.book.title) {
|
||||
this.searchTitle = null
|
||||
this.searchAuthor = null
|
||||
return
|
||||
}
|
||||
this.searchTitle = this.audiobook.book.title
|
||||
this.searchAuthor = this.audiobook.book.authorFL || ''
|
||||
this.provider = localStorage.getItem('book-provider') || 'google'
|
||||
},
|
||||
selectMatch(match) {
|
||||
this.selectedMatch = match
|
||||
},
|
||||
buildMatchUpdatePayload() {
|
||||
var updatePayload = {}
|
||||
for (const key in this.selectedMatchUsage) {
|
||||
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
||||
updatePayload[key] = this.selectedMatch[key]
|
||||
}
|
||||
}
|
||||
return updatePayload
|
||||
},
|
||||
async submitMatchUpdate() {
|
||||
var updatePayload = this.buildMatchUpdatePayload()
|
||||
if (!Object.keys(updatePayload).length) {
|
||||
return
|
||||
}
|
||||
this.isProcessing = true
|
||||
|
||||
if (updatePayload.cover) {
|
||||
var coverPayload = {
|
||||
url: updatePayload.cover
|
||||
}
|
||||
var success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, coverPayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
if (success) {
|
||||
this.$toast.success('Book Cover Updated')
|
||||
} else {
|
||||
this.$toast.error('Book Cover Failed to Update')
|
||||
}
|
||||
console.log('Updated cover')
|
||||
delete updatePayload.cover
|
||||
}
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
var bookUpdatePayload = {
|
||||
book: updatePayload
|
||||
}
|
||||
var success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, bookUpdatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
if (success) {
|
||||
this.$toast.success('Book Details Updated')
|
||||
this.selectedMatch = null
|
||||
this.$emit('selectTab', 'details')
|
||||
} else {
|
||||
this.$toast.error('Book Details Failed to Update')
|
||||
}
|
||||
} else {
|
||||
this.selectedMatch = null
|
||||
}
|
||||
this.isProcessing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.matchListWrapper {
|
||||
height: calc(100% - 80px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,110 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<template v-if="hasTracks">
|
||||
<div class="w-full bg-primary px-4 py-2 flex items-center">
|
||||
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`" class="mr-4">
|
||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th>#</th>
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-left">Size</th>
|
||||
<th class="text-left">Duration</th>
|
||||
<th v-if="showDownload" class="text-center">Download</th>
|
||||
</tr>
|
||||
<template v-for="track in tracksCleaned">
|
||||
<tr :key="track.index">
|
||||
<td class="text-center">
|
||||
<p>{{ track.index }}</p>
|
||||
</td>
|
||||
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(track.size) }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="showDownload" class="font-mono text-center">
|
||||
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</template>
|
||||
<div v-else class="flex my-4 text-center justify-center text-xl">No Audio Tracks</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tracks: null,
|
||||
showFullPath: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
audiobook: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
audiobookPath() {
|
||||
return this.audiobook.path
|
||||
},
|
||||
tracksCleaned() {
|
||||
return this.tracks.map((track) => {
|
||||
var trackPath = track.path.replace(/\\/g, '/')
|
||||
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
|
||||
|
||||
return {
|
||||
...track,
|
||||
relativePath: trackPath
|
||||
.replace(audiobookPath + '/', '')
|
||||
.replace(/%/g, '%25')
|
||||
.replace(/#/g, '%23')
|
||||
}
|
||||
})
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
isMissing() {
|
||||
return this.audiobook.isMissing
|
||||
},
|
||||
showDownload() {
|
||||
return this.userCanDownload && !this.isMissing
|
||||
},
|
||||
hasTracks() {
|
||||
return this.audiobook.tracks.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.tracks = this.audiobook.tracks
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -5,7 +5,7 @@
|
||||
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute -top-10 left-0 w-full flex">
|
||||
<div class="absolute -top-10 left-0 z-10 w-full flex">
|
||||
<template v-for="tab in availableTabs">
|
||||
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||
</template>
|
||||
@@ -18,8 +18,8 @@
|
||||
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
|
||||
<component v-if="audiobook && show" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
||||
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
|
||||
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
@@ -29,49 +29,44 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
audiobook: null,
|
||||
fetchOnShow: false,
|
||||
libraryItem: null,
|
||||
tabs: [
|
||||
{
|
||||
id: 'details',
|
||||
title: 'Details',
|
||||
component: 'modals-edit-tabs-details'
|
||||
component: 'modals-item-tabs-details'
|
||||
},
|
||||
{
|
||||
id: 'cover',
|
||||
title: 'Cover',
|
||||
component: 'modals-edit-tabs-cover'
|
||||
component: 'modals-item-tabs-cover'
|
||||
},
|
||||
// {
|
||||
// id: 'tracks',
|
||||
// title: 'Tracks',
|
||||
// component: 'modals-edit-tabs-tracks'
|
||||
// },
|
||||
{
|
||||
id: 'chapters',
|
||||
title: 'Chapters',
|
||||
component: 'modals-edit-tabs-chapters'
|
||||
component: 'modals-item-tabs-chapters'
|
||||
},
|
||||
{
|
||||
id: 'episodes',
|
||||
title: 'Episodes',
|
||||
component: 'modals-item-tabs-episodes'
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
title: 'Files',
|
||||
component: 'modals-edit-tabs-files'
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
title: 'Download',
|
||||
component: 'modals-edit-tabs-download'
|
||||
component: 'modals-item-tabs-files'
|
||||
},
|
||||
{
|
||||
id: 'match',
|
||||
title: 'Match',
|
||||
component: 'modals-edit-tabs-match'
|
||||
component: 'modals-item-tabs-match'
|
||||
},
|
||||
{
|
||||
id: 'merge',
|
||||
title: 'Merge',
|
||||
component: 'modals-item-tabs-merge',
|
||||
experimental: true
|
||||
}
|
||||
// {
|
||||
// id: 'authors',
|
||||
// title: 'Authors',
|
||||
// component: 'modals-edit-tabs-authors'
|
||||
// }
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -89,12 +84,7 @@ export default {
|
||||
this.selectedTab = availableTabIds[0]
|
||||
}
|
||||
|
||||
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) {
|
||||
if (this.fetchOnShow) this.fetchFull()
|
||||
return
|
||||
}
|
||||
this.fetchOnShow = false
|
||||
this.audiobook = null
|
||||
this.libraryItem = null
|
||||
this.init()
|
||||
this.registerListeners()
|
||||
} else {
|
||||
@@ -120,22 +110,26 @@ export default {
|
||||
this.$store.commit('setEditModalTab', val)
|
||||
}
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
availableTabs() {
|
||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||
return this.tabs.filter((tab) => {
|
||||
if (tab.id === 'download' && this.isMissing) return false
|
||||
if ((tab.id === 'download' || tab.id === 'files' || tab.id === 'authors') && this.userCanDownload) return true
|
||||
if (tab.id !== 'download' && tab.id !== 'files' && tab.id !== 'authors' && this.userCanUpdate) return true
|
||||
if (tab.id === 'match' && this.userCanUpdate && this.showExperimentalFeatures) return true
|
||||
if (tab.experimental && !this.showExperimentalFeatures) return false
|
||||
if (tab.id === 'merge' && (this.isMissing || this.mediaType !== 'book')) return false
|
||||
if (this.mediaType == 'podcast' && tab.id == 'chapters') return false
|
||||
if (this.mediaType == 'book' && tab.id == 'episodes') return false
|
||||
|
||||
if ((tab.id === 'merge' || tab.id === 'files') && this.userCanDownload) return true
|
||||
if (tab.id !== 'merge' && tab.id !== 'files' && this.userCanUpdate) return true
|
||||
if (tab.id === 'match' && this.userCanUpdate) return true
|
||||
return false
|
||||
})
|
||||
},
|
||||
@@ -148,26 +142,32 @@ export default {
|
||||
return _tab ? _tab.component : ''
|
||||
},
|
||||
isMissing() {
|
||||
return this.selectedAudiobook.isMissing
|
||||
return this.selectedLibraryItem.isMissing
|
||||
},
|
||||
selectedAudiobook() {
|
||||
return this.$store.state.selectedAudiobook || {}
|
||||
selectedLibraryItem() {
|
||||
return this.$store.state.selectedLibraryItem || {}
|
||||
},
|
||||
selectedAudiobookId() {
|
||||
return this.selectedAudiobook.id
|
||||
selectedLibraryItemId() {
|
||||
return this.selectedLibraryItem.id
|
||||
},
|
||||
book() {
|
||||
return this.audiobook ? this.audiobook.book || {} : {}
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||
},
|
||||
title() {
|
||||
return this.book.title || 'No Title'
|
||||
return this.mediaMetadata.title || 'No Title'
|
||||
},
|
||||
bookshelfBookIds() {
|
||||
return this.$store.state.bookshelfBookIds || []
|
||||
},
|
||||
currentBookshelfIndex() {
|
||||
if (!this.bookshelfBookIds.length) return 0
|
||||
return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedAudiobookId)
|
||||
return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedLibraryItemId)
|
||||
},
|
||||
canGoPrev() {
|
||||
return this.bookshelfBookIds.length && this.currentBookshelfIndex > 0
|
||||
@@ -181,15 +181,18 @@ export default {
|
||||
if (this.currentBookshelfIndex - 1 < 0) return
|
||||
var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]
|
||||
this.processing = true
|
||||
var prevBook = await this.$axios.$get(`/api/books/${prevBookId}`).catch((error) => {
|
||||
var prevBook = await this.$axios.$get(`/api/items/${prevBookId}?expanded=1`).catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
|
||||
this.$toast.error(errorMsg)
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
if (prevBook) {
|
||||
this.$store.commit('showEditModalOnTab', { audiobook: prevBook, tab: this.selectedTab })
|
||||
this.$nextTick(this.init)
|
||||
this.unregisterListeners()
|
||||
this.libraryItem = prevBook
|
||||
this.selectedTab = 'details'
|
||||
this.$store.commit('setSelectedLibraryItem', prevBook)
|
||||
this.$nextTick(this.registerListeners)
|
||||
} else {
|
||||
console.error('Book not found', prevBookId)
|
||||
}
|
||||
@@ -198,15 +201,18 @@ export default {
|
||||
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
|
||||
this.processing = true
|
||||
var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1]
|
||||
var nextBook = await this.$axios.$get(`/api/books/${nextBookId}`).catch((error) => {
|
||||
var nextBook = await this.$axios.$get(`/api/items/${nextBookId}?expanded=1`).catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
|
||||
this.$toast.error(errorMsg)
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
if (nextBook) {
|
||||
this.$store.commit('showEditModalOnTab', { audiobook: nextBook, tab: this.selectedTab })
|
||||
this.$nextTick(this.init)
|
||||
this.unregisterListeners()
|
||||
this.libraryItem = nextBook
|
||||
this.selectedTab = 'details'
|
||||
this.$store.commit('setSelectedLibraryItem', nextBook)
|
||||
this.$nextTick(this.registerListeners)
|
||||
} else {
|
||||
console.error('Book not found', nextBookId)
|
||||
}
|
||||
@@ -216,23 +222,19 @@ export default {
|
||||
this.selectedTab = tab
|
||||
}
|
||||
},
|
||||
audiobookUpdated() {
|
||||
if (!this.show) this.fetchOnShow = true
|
||||
else {
|
||||
this.fetchFull()
|
||||
}
|
||||
libraryItemUpdated(expandedLibraryItem) {
|
||||
this.libraryItem = expandedLibraryItem
|
||||
},
|
||||
init() {
|
||||
this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedAudiobookId })
|
||||
this.fetchFull()
|
||||
},
|
||||
async fetchFull() {
|
||||
try {
|
||||
this.processing = true
|
||||
this.audiobook = await this.$axios.$get(`/api/books/${this.selectedAudiobookId}`)
|
||||
this.libraryItem = await this.$axios.$get(`/api/items/${this.selectedLibraryItemId}?expanded=1`)
|
||||
this.processing = false
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch audiobook', this.selectedAudiobookId, error)
|
||||
console.error('Failed to fetch audiobook', this.selectedLibraryItemId, error)
|
||||
this.processing = false
|
||||
this.show = false
|
||||
}
|
||||
@@ -246,9 +248,11 @@ export default {
|
||||
},
|
||||
registerListeners() {
|
||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||
this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
|
||||
},
|
||||
unregisterListeners() {
|
||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||
this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
@@ -258,7 +262,7 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style scoped>
|
||||
.tab {
|
||||
height: 40px;
|
||||
}
|
||||
55
client/components/modals/item/tabs/Chapters.vue
Normal file
55
client/components/modals/item/tabs/Chapters.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div class="w-full mb-4">
|
||||
<div v-if="chapters.length" class="w-full p-4 bg-primary">
|
||||
<p>Audiobook Chapters</p>
|
||||
</div>
|
||||
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
|
||||
<table v-else class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
||||
<th class="text-left">Title</th>
|
||||
<th class="text-center">Start</th>
|
||||
<th class="text-center">End</th>
|
||||
</tr>
|
||||
<tr v-for="chapter in chapters" :key="chapter.id">
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ chapter.id }}</p>
|
||||
</td>
|
||||
<td class="font-book">
|
||||
{{ chapter.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.end) }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
}
|
||||
},
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
@@ -2,9 +2,9 @@
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative">
|
||||
<div class="flex">
|
||||
<div class="relative">
|
||||
<covers-book-cover :audiobook="audiobook" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<!-- book cover overlay -->
|
||||
<div v-if="book.cover" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
||||
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
||||
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
|
||||
<div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
|
||||
<span class="material-icons">delete</span>
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<div v-if="showLocalCovers" class="flex items-center justify-center">
|
||||
<template v-for="cover in localCovers">
|
||||
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
||||
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
||||
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
||||
<img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
@@ -47,9 +47,9 @@
|
||||
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
||||
</div>
|
||||
<div class="w-72 px-1">
|
||||
<ui-text-input-with-label v-model="searchTitle" :label="provider == 'audible' ? 'Search Title or ASIN' : 'Search Title'" placeholder="Search" />
|
||||
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" placeholder="Search" />
|
||||
</div>
|
||||
<div class="w-72 px-1">
|
||||
<div v-show="provider != 'itunes'" class="w-72 px-1">
|
||||
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
||||
</div>
|
||||
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
||||
@@ -82,7 +82,7 @@
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
audiobook: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
@@ -98,25 +98,11 @@ export default {
|
||||
showLocalCovers: false,
|
||||
previewUpload: null,
|
||||
selectedFile: null,
|
||||
providers: [
|
||||
{
|
||||
text: 'Google Books',
|
||||
value: 'google'
|
||||
},
|
||||
{
|
||||
text: 'Open Library',
|
||||
value: 'openlibrary'
|
||||
},
|
||||
{
|
||||
text: 'Audible',
|
||||
value: 'audible'
|
||||
}
|
||||
],
|
||||
provider: 'google'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
audiobook: {
|
||||
libraryItem: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
@@ -134,23 +120,41 @@ export default {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
},
|
||||
searchTitleLabel() {
|
||||
if (this.provider == 'audible') return 'Search Title or ASIN'
|
||||
else if (this.provider == 'itunes') return 'Search Term'
|
||||
return 'Search Title'
|
||||
},
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||
},
|
||||
audiobookId() {
|
||||
return this.audiobook ? this.audiobook.id : null
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
},
|
||||
book() {
|
||||
return this.audiobook ? this.audiobook.book || {} : {}
|
||||
mediaType() {
|
||||
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||
},
|
||||
audiobookPath() {
|
||||
return this.audiobook ? this.audiobook.path : null
|
||||
isPodcast() {
|
||||
return this.mediaType == 'podcast'
|
||||
},
|
||||
otherFiles() {
|
||||
return this.audiobook ? this.audiobook.otherFiles || [] : []
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
coverPath() {
|
||||
return this.media.coverPath
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
libraryFiles() {
|
||||
return this.libraryItem ? this.libraryItem.libraryFiles || [] : []
|
||||
},
|
||||
userCanUpload() {
|
||||
return this.$store.getters['user/getUserCanUpload']
|
||||
@@ -159,12 +163,11 @@ export default {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
localCovers() {
|
||||
return this.otherFiles
|
||||
.filter((f) => f.filetype === 'image')
|
||||
return this.libraryFiles
|
||||
.filter((f) => f.fileType === 'image')
|
||||
.map((file) => {
|
||||
var _file = { ...file }
|
||||
var imgRelPath = _file.path.replace(this.audiobookPath, '')
|
||||
_file.localPath = `/s/book/${this.audiobookId}/${imgRelPath}`
|
||||
_file.localPath = `/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}`
|
||||
return _file
|
||||
})
|
||||
}
|
||||
@@ -176,7 +179,7 @@ export default {
|
||||
form.set('cover', this.selectedFile)
|
||||
|
||||
this.$axios
|
||||
.$post(`/api/books/${this.audiobook.id}/cover`, form)
|
||||
.$post(`/api/items/${this.libraryItemId}/cover`, form)
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
@@ -209,17 +212,18 @@ export default {
|
||||
},
|
||||
init() {
|
||||
this.showLocalCovers = false
|
||||
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.authorFL)) {
|
||||
if (this.coversFound.length && (this.searchTitle !== this.mediaMetadata.title || this.searchAuthor !== this.mediaMetadata.authorName)) {
|
||||
this.coversFound = []
|
||||
this.hasSearched = false
|
||||
}
|
||||
this.imageUrl = this.book.cover || ''
|
||||
this.searchTitle = this.book.title || ''
|
||||
this.searchAuthor = this.book.authorFL || ''
|
||||
this.provider = localStorage.getItem('book-provider') || 'openlibrary'
|
||||
this.imageUrl = this.media.coverPath || ''
|
||||
this.searchTitle = this.mediaMetadata.title || ''
|
||||
this.searchAuthor = this.mediaMetadata.authorName || ''
|
||||
if (this.isPodcast) this.provider = 'itunes'
|
||||
else this.provider = localStorage.getItem('book-provider') || 'google'
|
||||
},
|
||||
removeCover() {
|
||||
if (!this.book.cover) {
|
||||
if (!this.media.coverPath) {
|
||||
this.imageUrl = ''
|
||||
return
|
||||
}
|
||||
@@ -229,7 +233,7 @@ export default {
|
||||
this.updateCover(this.imageUrl)
|
||||
},
|
||||
async updateCover(cover) {
|
||||
if (cover === this.book.cover) {
|
||||
if (cover === this.coverPath) {
|
||||
console.warn('Cover has not changed..', cover)
|
||||
return
|
||||
}
|
||||
@@ -237,9 +241,21 @@ export default {
|
||||
this.isProcessing = true
|
||||
var success = false
|
||||
|
||||
// Download cover from url and use
|
||||
if (cover.startsWith('http:') || cover.startsWith('https:')) {
|
||||
success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, { url: cover }).catch((error) => {
|
||||
if (!cover) {
|
||||
// Remove cover
|
||||
success = await this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}/cover`)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove cover', error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
}
|
||||
return false
|
||||
})
|
||||
} else if (cover.startsWith('http:') || cover.startsWith('https:')) {
|
||||
// Download cover from url and use
|
||||
success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover }).catch((error) => {
|
||||
console.error('Failed to download cover from url', error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
@@ -249,11 +265,9 @@ export default {
|
||||
} else {
|
||||
// Update local cover url
|
||||
const updatePayload = {
|
||||
book: {
|
||||
cover: cover
|
||||
}
|
||||
cover
|
||||
}
|
||||
success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, updatePayload).catch((error) => {
|
||||
success = await this.$axios.$patch(`/api/items/${this.libraryItemId}/cover`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
@@ -263,15 +277,16 @@ export default {
|
||||
}
|
||||
if (success) {
|
||||
this.$toast.success('Update Successful')
|
||||
this.$emit('close')
|
||||
// this.$emit('close')
|
||||
} else {
|
||||
this.imageUrl = this.book.cover || ''
|
||||
this.imageUrl = this.media.coverPath || ''
|
||||
}
|
||||
this.isProcessing = false
|
||||
},
|
||||
getSearchQuery() {
|
||||
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
|
||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
||||
if (this.isPodcast) searchQuery += '&podcast=1'
|
||||
return searchQuery
|
||||
},
|
||||
persistProvider() {
|
||||
@@ -296,23 +311,7 @@ export default {
|
||||
this.hasSearched = true
|
||||
},
|
||||
setCover(coverFile) {
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$patch(`/api/books/${this.audiobook.id}/coverfile`, coverFile)
|
||||
.then((data) => {
|
||||
console.log('response data', data)
|
||||
if (data && typeof data === 'string') {
|
||||
this.$toast.success(data)
|
||||
}
|
||||
this.isProcessing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
}
|
||||
this.isProcessing = false
|
||||
})
|
||||
this.updateCover(coverFile.metadata.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
228
client/components/modals/item/tabs/Details.vue
Normal file
228
client/components/modals/item/tabs/Details.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<widgets-book-details-edit v-if="mediaType == 'book'" ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" />
|
||||
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" />
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
|
||||
<div class="flex items-center px-4">
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
|
||||
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
|
||||
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-btn @click="submitForm">Submit</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
resettingProgress: false,
|
||||
isScrollable: false,
|
||||
rescanning: false,
|
||||
quickMatching: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
return this.processing
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
isMissing() {
|
||||
return !!this.libraryItem && !!this.libraryItem.isMissing
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
libraryId() {
|
||||
return this.libraryItem ? this.libraryItem.libraryId : null
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
|
||||
},
|
||||
libraryScan() {
|
||||
if (!this.libraryId) return null
|
||||
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
quickMatch() {
|
||||
if (this.quickMatching) return
|
||||
if (!this.$refs.itemDetailsEdit) return
|
||||
|
||||
var { title, author } = this.$refs.itemDetailsEdit.getTitleAndAuthorName()
|
||||
if (!title) {
|
||||
this.$toast.error('Must have a title for quick match')
|
||||
return
|
||||
}
|
||||
this.quickMatching = true
|
||||
var matchOptions = {
|
||||
provider: this.libraryProvider,
|
||||
title: title || null,
|
||||
author: author || null
|
||||
}
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItemId}/match`, matchOptions)
|
||||
.then((res) => {
|
||||
this.quickMatching = false
|
||||
if (res.warning) {
|
||||
this.$toast.warning(res.warning)
|
||||
} else if (res.updated) {
|
||||
this.$toast.success('Item details updated')
|
||||
} else {
|
||||
this.$toast.info('No updates were made')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
console.error('Failed to match', error)
|
||||
this.$toast.error(errMsg || 'Failed to match')
|
||||
this.quickMatching = false
|
||||
})
|
||||
},
|
||||
rescan() {
|
||||
this.rescanning = true
|
||||
this.$axios
|
||||
.$get(`/api/items/${this.libraryItemId}/scan`)
|
||||
.then((data) => {
|
||||
this.rescanning = false
|
||||
var result = data.result
|
||||
if (!result) {
|
||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||
} else if (result === 'UPDATED') {
|
||||
this.$toast.success(`Re-Scan complete item was updated`)
|
||||
} else if (result === 'UPTODATE') {
|
||||
this.$toast.success(`Re-Scan complete item was up to date`)
|
||||
} else if (result === 'REMOVED') {
|
||||
this.$toast.error(`Re-Scan complete item was removed`)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to scan library item', error)
|
||||
this.$toast.error('Failed to scan library item')
|
||||
this.rescanning = false
|
||||
})
|
||||
},
|
||||
submitForm() {
|
||||
if (this.isProcessing) {
|
||||
return
|
||||
}
|
||||
if (!this.$refs.itemDetailsEdit) {
|
||||
return
|
||||
}
|
||||
var updatedDetails = this.$refs.itemDetailsEdit.getDetails()
|
||||
if (!updatedDetails.hasChanges) {
|
||||
this.$toast.info('No changes were made')
|
||||
return
|
||||
}
|
||||
this.updateDetails(updatedDetails)
|
||||
},
|
||||
async updateDetails(updatedDetails) {
|
||||
this.isProcessing = true
|
||||
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatedDetails.updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (updateResult) {
|
||||
if (updateResult.updated) {
|
||||
this.$toast.success('Item details updated')
|
||||
// this.$emit('close')
|
||||
} else {
|
||||
this.$toast.info('No updates were necessary')
|
||||
}
|
||||
}
|
||||
},
|
||||
removeItem() {
|
||||
if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}`)
|
||||
.then(() => {
|
||||
console.log('Item removed')
|
||||
this.$toast.success('Item Removed')
|
||||
this.$emit('close')
|
||||
this.isProcessing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Remove item failed', error)
|
||||
this.isProcessing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
checkIsScrollable() {
|
||||
this.$nextTick(() => {
|
||||
var formWrapper = document.getElementById('formWrapper')
|
||||
if (formWrapper) {
|
||||
if (formWrapper.scrollHeight > formWrapper.clientHeight) {
|
||||
this.isScrollable = true
|
||||
} else {
|
||||
this.isScrollable = false
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
setResizeObserver() {
|
||||
try {
|
||||
var formWrapper = document.getElementById('formWrapper')
|
||||
if (formWrapper) {
|
||||
this.$nextTick(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
this.checkIsScrollable()
|
||||
})
|
||||
resizeObserver.observe(formWrapper)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set resize observer')
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setResizeObserver()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.details-form-wrapper {
|
||||
height: calc(100% - 70px);
|
||||
max-height: calc(100% - 70px);
|
||||
}
|
||||
</style>
|
||||
97
client/components/modals/item/tabs/Episodes.vue
Normal file
97
client/components/modals/item/tabs/Episodes.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div class="w-full mb-4">
|
||||
<!-- <div class="flex items-center mb-4">
|
||||
<p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check for new episodes</ui-btn>
|
||||
</div> -->
|
||||
|
||||
<div v-if="episodes.length" class="w-full p-4 bg-primary">
|
||||
<p>Podcast Episodes</p>
|
||||
</div>
|
||||
<div v-if="!episodes.length" class="flex my-4 text-center justify-center text-xl">No Episodes</div>
|
||||
<table v-else class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left">Sort #</th>
|
||||
<th class="text-left whitespace-nowrap">Episode #</th>
|
||||
<th class="text-left">Title</th>
|
||||
<th class="text-center w-28">Duration</th>
|
||||
<th class="text-center w-28">Size</th>
|
||||
</tr>
|
||||
<tr v-for="episode in episodes" :key="episode.id">
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ episode.index }}</p>
|
||||
</td>
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ episode.episode }}</p>
|
||||
</td>
|
||||
<td class="font-book">
|
||||
{{ episode.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(episode.duration) }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $bytesPretty(episode.size) }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
checkingNewEpisodes: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
autoDownloadEpisodes() {
|
||||
return !!this.media.autoDownloadEpisodes
|
||||
},
|
||||
lastEpisodeCheck() {
|
||||
return this.media.lastEpisodeCheck
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
},
|
||||
episodes() {
|
||||
return this.media.episodes || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkForNewEpisodes() {
|
||||
this.checkingNewEpisodes = true
|
||||
this.$axios
|
||||
.$get(`/api/podcasts/${this.libraryItemId}/checknew`)
|
||||
.then((response) => {
|
||||
if (response.episodes && response.episodes.length) {
|
||||
console.log('New episodes', response.episodes.length)
|
||||
this.$toast.success(`${response.episodes.length} new episodes found!`)
|
||||
} else {
|
||||
this.$toast.info('No new episodes found')
|
||||
}
|
||||
this.checkingNewEpisodes = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Unknown Error'
|
||||
this.$toast.error(errorMsg)
|
||||
this.checkingNewEpisodes = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
68
client/components/modals/item/tabs/Files.vue
Normal file
68
client/components/modals/item/tabs/Files.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<template v-for="audiobook in audiobooks">
|
||||
<tables-tracks-table :key="audiobook.id" :title="`Audiobook Tracks (${audiobook.name})`" :audiobook-id="audiobook.id" :tracks="audiobook.tracks" class="mb-4" />
|
||||
</template>
|
||||
|
||||
<tables-library-files-table expanded :files="libraryFiles" :library-item-id="libraryItem.id" :is-missing="isMissing" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tracks: [],
|
||||
showFullPath: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
libraryItem: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
libraryFiles() {
|
||||
return this.libraryItem.libraryFiles || []
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
isMissing() {
|
||||
return this.libraryItem.isMissing
|
||||
},
|
||||
showDownload() {
|
||||
return this.userCanDownload && !this.isMissing
|
||||
},
|
||||
audiobooks() {
|
||||
return this.media.audiobooks || []
|
||||
},
|
||||
ebooks() {
|
||||
return this.media.ebooks || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.tracks = this.media.tracks || []
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
411
client/components/modals/item/tabs/Match.vue
Normal file
411
client/components/modals/item/tabs/Match.vue
Normal file
@@ -0,0 +1,411 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<div class="flex items-center justify-start -mx-1 h-20">
|
||||
<div class="w-40 px-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
||||
</div>
|
||||
<div class="w-72 px-1">
|
||||
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" placeholder="Search" />
|
||||
</div>
|
||||
<div v-show="provider != 'itunes'" class="w-72 px-1">
|
||||
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
||||
</div>
|
||||
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
<div v-show="processing" class="flex h-full items-center justify-center">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<div v-show="!processing && !searchResults.length && hasSearched" class="flex h-full items-center justify-center">
|
||||
<p>No Results</p>
|
||||
</div>
|
||||
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper">
|
||||
<template v-for="(res, index) in searchResults">
|
||||
<cards-book-match-card :key="index" :book="res" :is-podcast="isPodcast" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="flex mb-2">
|
||||
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="text-xl pl-3">Update Book Details</p>
|
||||
</div>
|
||||
<form @submit.prevent="submitMatchUpdate">
|
||||
<div v-if="selectedMatch.cover" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.cover" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" label="Cover" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.title" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.title" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" label="Title" />
|
||||
<p v-if="mediaMetadata.title" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.title || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.subtitle" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.subtitle" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" label="Subtitle" />
|
||||
<p v-if="mediaMetadata.subtitle" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.subtitle || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.author" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.author" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" label="Author" />
|
||||
<p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.authorName || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.narrator" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.narrator" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.narrator" :disabled="!selectedMatchUsage.narrator" label="Narrator" />
|
||||
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.narratorName || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.description" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.description" />
|
||||
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.publisher" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.publisher" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" label="Publisher" />
|
||||
<p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.publisher || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.publishedYear" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.publishedYear" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" label="Published Year" />
|
||||
<p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.publishedYear || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedMatch.series" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.series" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" label="Series" />
|
||||
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.seriesName || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.volumeNumber" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.volumeNumber" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.volumeNumber" :disabled="!selectedMatchUsage.volumeNumber" label="Volume Number" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.isbn" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" />
|
||||
<p v-if="mediaMetadata.isbn" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.isbn || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.asin" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.asin" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" />
|
||||
<p v-if="mediaMetadata.asin" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.asin || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedMatch.itunesId" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.itunesId" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.itunesId" type="number" :disabled="!selectedMatchUsage.itunesId" label="iTunes ID" />
|
||||
<p v-if="mediaMetadata.itunesId" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.itunesId || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.feedUrl" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.feedUrl" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.feedUrl" :disabled="!selectedMatchUsage.feedUrl" label="RSS Feed URL" />
|
||||
<p v-if="mediaMetadata.feedUrl" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.feedUrl || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.itunesPageUrl" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.itunesPageUrl" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.itunesPageUrl" :disabled="!selectedMatchUsage.itunesPageUrl" label="iTunes Page URL" />
|
||||
<p v-if="mediaMetadata.itunesPageUrl" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.itunesPageUrl || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.releaseDate" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.releaseDate" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.releaseDate" :disabled="!selectedMatchUsage.releaseDate" label="Release Date" />
|
||||
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.releaseDate || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end py-2">
|
||||
<ui-btn color="success" type="submit">Update</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
libraryItemId: null,
|
||||
searchTitle: null,
|
||||
searchAuthor: null,
|
||||
lastSearch: null,
|
||||
provider: 'google',
|
||||
searchResults: [],
|
||||
hasSearched: false,
|
||||
selectedMatch: null,
|
||||
selectedMatchUsage: {
|
||||
title: true,
|
||||
subtitle: true,
|
||||
cover: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
publisher: true,
|
||||
publishedYear: true,
|
||||
series: true,
|
||||
volumeNumber: true,
|
||||
asin: true,
|
||||
isbn: true,
|
||||
// Podcast specific
|
||||
itunesPageUrl: true,
|
||||
itunesId: true,
|
||||
feedUrl: true,
|
||||
releaseDate: true
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
libraryItem: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
return this.processing
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
},
|
||||
searchTitleLabel() {
|
||||
if (this.provider == 'audible') return 'Search Title or ASIN'
|
||||
else if (this.provider == 'itunes') return 'Search Term'
|
||||
return 'Search Title'
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType == 'podcast'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
persistProvider() {
|
||||
try {
|
||||
localStorage.setItem('book-provider', this.provider)
|
||||
} catch (error) {
|
||||
console.error('PersistProvider', error)
|
||||
}
|
||||
},
|
||||
getSearchQuery() {
|
||||
if (this.isPodcast) return `term=${this.searchTitle}`
|
||||
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${this.searchTitle}`
|
||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
||||
return searchQuery
|
||||
},
|
||||
submitSearch() {
|
||||
if (!this.searchTitle) {
|
||||
this.$toast.warning('Search title is required')
|
||||
return
|
||||
}
|
||||
this.persistProvider()
|
||||
this.runSearch()
|
||||
},
|
||||
async runSearch() {
|
||||
var searchQuery = this.getSearchQuery()
|
||||
if (this.lastSearch === searchQuery) return
|
||||
this.searchResults = []
|
||||
this.isProcessing = true
|
||||
this.lastSearch = searchQuery
|
||||
var searchEntity = this.isPodcast ? 'podcast' : 'books'
|
||||
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return []
|
||||
})
|
||||
// console.log('Got search results', results)
|
||||
results = (results || []).filter((res) => {
|
||||
return !!res.title
|
||||
})
|
||||
|
||||
if (this.isPodcast) {
|
||||
// Map to match PodcastMetadata keys
|
||||
results = results.map((res) => {
|
||||
res.itunesPageUrl = res.pageUrl || null
|
||||
res.itunesId = res.id || null
|
||||
res.author = res.artistName || null
|
||||
return res
|
||||
})
|
||||
}
|
||||
|
||||
this.searchResults = results || []
|
||||
this.isProcessing = false
|
||||
this.hasSearched = true
|
||||
},
|
||||
init() {
|
||||
this.selectedMatch = null
|
||||
this.selectedMatchUsage = {
|
||||
title: true,
|
||||
subtitle: true,
|
||||
cover: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
publisher: true,
|
||||
publishedYear: true,
|
||||
series: true,
|
||||
volumeNumber: true,
|
||||
asin: true,
|
||||
isbn: true,
|
||||
// Podcast specific
|
||||
itunesPageUrl: true,
|
||||
itunesId: true,
|
||||
feedUrl: true,
|
||||
releaseDate: true
|
||||
}
|
||||
|
||||
if (this.libraryItem.id !== this.libraryItemId) {
|
||||
this.searchResults = []
|
||||
this.hasSearched = false
|
||||
this.libraryItemId = this.libraryItem.id
|
||||
}
|
||||
|
||||
if (!this.libraryItem.media || !this.libraryItem.media.metadata.title) {
|
||||
this.searchTitle = null
|
||||
this.searchAuthor = null
|
||||
return
|
||||
}
|
||||
this.searchTitle = this.libraryItem.media.metadata.title
|
||||
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
|
||||
if (this.isPodcast) this.provider = 'itunes'
|
||||
else this.provider = localStorage.getItem('book-provider') || 'google'
|
||||
},
|
||||
selectMatch(match) {
|
||||
this.selectedMatch = match
|
||||
},
|
||||
buildMatchUpdatePayload() {
|
||||
var updatePayload = {}
|
||||
|
||||
var volumeNumber = this.selectedMatchUsage.volumeNumber ? this.selectedMatch.volumeNumber || null : null
|
||||
for (const key in this.selectedMatchUsage) {
|
||||
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
||||
if (key === 'series') {
|
||||
var seriesItem = {
|
||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||
name: this.selectedMatch[key],
|
||||
sequence: volumeNumber
|
||||
}
|
||||
updatePayload.series = [seriesItem]
|
||||
} else if (key === 'author' && !this.isPodcast) {
|
||||
var authorItem = {
|
||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||
name: this.selectedMatch[key]
|
||||
}
|
||||
updatePayload.authors = [authorItem]
|
||||
} else if (key === 'narrator') {
|
||||
updatePayload.narrators = [this.selectedMatch[key]]
|
||||
} else if (key === 'itunesId') {
|
||||
updatePayload.itunesId = Number(this.selectedMatch[key])
|
||||
} else if (key !== 'volumeNumber') {
|
||||
updatePayload[key] = this.selectedMatch[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
return updatePayload
|
||||
},
|
||||
async submitMatchUpdate() {
|
||||
var updatePayload = this.buildMatchUpdatePayload()
|
||||
if (!Object.keys(updatePayload).length) {
|
||||
return
|
||||
}
|
||||
this.isProcessing = true
|
||||
|
||||
if (updatePayload.cover) {
|
||||
var coverPayload = {
|
||||
url: updatePayload.cover
|
||||
}
|
||||
var success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
if (success) {
|
||||
this.$toast.success('Item Cover Updated')
|
||||
} else {
|
||||
this.$toast.error('Item Cover Failed to Update')
|
||||
}
|
||||
console.log('Updated cover')
|
||||
delete updatePayload.cover
|
||||
}
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
var mediaUpdatePayload = {
|
||||
metadata: updatePayload
|
||||
}
|
||||
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
if (updateResult) {
|
||||
if (updateResult.updated) {
|
||||
this.$toast.success('Item details updated')
|
||||
} else {
|
||||
this.$toast.info('No detail updates were necessary')
|
||||
}
|
||||
this.selectedMatch = null
|
||||
this.$emit('selectTab', 'details')
|
||||
} else {
|
||||
this.$toast.error('Item Details Failed to Update')
|
||||
}
|
||||
} else {
|
||||
this.selectedMatch = null
|
||||
}
|
||||
this.isProcessing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.matchListWrapper {
|
||||
height: calc(100% - 80px);
|
||||
}
|
||||
</style>
|
||||
214
client/components/modals/item/tabs/Merge.vue
Normal file
214
client/components/modals/item/tabs/Merge.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
|
||||
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
||||
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||
|
||||
<ui-btn v-if="abmergeStatus !== $constants.DownloadStatus.READY" :loading="abmergeStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startAudiobookMerge">Start Merge</ui-btn>
|
||||
<div v-else>
|
||||
<div class="flex">
|
||||
<ui-btn @click="downloadWithProgress(abmergeDownload)">Download</ui-btn>
|
||||
<ui-icon-btn small icon="delete" bg-color="error" class="ml-2" @click="removeDownload" />
|
||||
</div>
|
||||
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(abmergeDownload.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-left text-base mb-4 py-4">
|
||||
<span class="text-error">* <strong>Experimental</strong></span
|
||||
> - M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes.
|
||||
</p>
|
||||
|
||||
<p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p>
|
||||
<p v-else-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks to merge</p>
|
||||
|
||||
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<p class="text-lg">Download.... {{ downloadPercent }}%</p>
|
||||
<p class="w-24 font-mono pl-8 text-right">
|
||||
{{ downloadAmount }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tempDisable: false,
|
||||
isDownloading: false,
|
||||
downloadPercent: '0',
|
||||
downloadAmount: '0 KB'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
abmergeStatus(newVal) {
|
||||
if (newVal) {
|
||||
this.tempDisable = false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
downloads() {
|
||||
return this.$store.getters['downloads/getDownloads'](this.libraryItemId)
|
||||
},
|
||||
abmergeDownload() {
|
||||
return this.downloads.find((d) => d.type === 'abmerge')
|
||||
},
|
||||
abmergeStatus() {
|
||||
return this.abmergeDownload ? this.abmergeDownload.status : false
|
||||
},
|
||||
libraryFiles() {
|
||||
return this.libraryItem.libraryFiles
|
||||
},
|
||||
totalFiles() {
|
||||
return this.libraryFiles.length
|
||||
},
|
||||
mediaTracks() {
|
||||
return this.media.tracks || []
|
||||
},
|
||||
isSingleM4b() {
|
||||
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
|
||||
},
|
||||
showM4bDownload() {
|
||||
if (this.libraryItem.isMissing || !this.mediaTracks.length) return false
|
||||
return !this.isSingleM4b && this.mediaTracks.length > 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
removeDownload() {
|
||||
if (!this.abmergeDownload) return
|
||||
if (!confirm(`Are you sure you want to remove this merge download?`)) return
|
||||
|
||||
var downloadId = this.abmergeDownload.id
|
||||
|
||||
this.tempDisable = true
|
||||
this.$axios
|
||||
.$delete(`/api/download/${downloadId}`)
|
||||
.then(() => {
|
||||
this.tempDisable = false
|
||||
this.$toast.success('Merge download deleted')
|
||||
this.$store.commit('downloads/removeDownload', { id: downloadId })
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
this.$toast.error(errorMsg)
|
||||
this.tempDisable = false
|
||||
})
|
||||
},
|
||||
startAudiobookMerge() {
|
||||
this.tempDisable = true
|
||||
|
||||
this.$axios
|
||||
.$get(`/api/audiobook-merge/${this.libraryItemId}`)
|
||||
.then(() => {
|
||||
this.tempDisable = false
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
this.$toast.error(errorMsg)
|
||||
this.tempDisable = false
|
||||
})
|
||||
},
|
||||
downloadWithProgress(download) {
|
||||
var downloadId = download.id
|
||||
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
|
||||
var filename = download.filename
|
||||
|
||||
this.isDownloading = true
|
||||
|
||||
var request = new XMLHttpRequest()
|
||||
request.responseType = 'blob'
|
||||
request.open('get', downloadUrl, true)
|
||||
request.setRequestHeader('Authorization', `Bearer ${this.$store.getters['user/getToken']}`)
|
||||
request.send()
|
||||
|
||||
request.onreadystatechange = () => {
|
||||
if (request.readyState === 4) {
|
||||
this.isDownloading = false
|
||||
}
|
||||
if (request.readyState == 4 && request.status == 200) {
|
||||
const url = window.URL.createObjectURL(request.response)
|
||||
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
setTimeout(() => {
|
||||
if (anchor) anchor.remove()
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = (err) => {
|
||||
console.error('Download error', err)
|
||||
this.isDownloading = false
|
||||
}
|
||||
|
||||
request.onprogress = (e) => {
|
||||
const percent_complete = Math.floor((e.loaded / e.total) * 100)
|
||||
this.downloadAmount = this.$bytesPretty(e.loaded)
|
||||
this.downloadPercent = percent_complete
|
||||
|
||||
// const duration = (new Date().getTime() - startTime) / 1000
|
||||
// const bps = e.loaded / duration
|
||||
// const kbps = Math.floor(bps / 1024)
|
||||
// const time = (e.total - e.loaded) / bps
|
||||
// const seconds = Math.floor(time % 60)
|
||||
// const minutes = Math.floor(time / 60)
|
||||
// console.log(`${percent_complete}% - ${kbps} Kbps - ${minutes} min ${seconds} sec remaining`)
|
||||
}
|
||||
},
|
||||
loadDownloads() {
|
||||
this.$axios
|
||||
.$get(`/api/downloads`)
|
||||
.then((data) => {
|
||||
var pendingDownloads = data.pendingDownloads.map((pd) => {
|
||||
pd.download.status = this.$constants.DownloadStatus.PENDING
|
||||
return pd.download
|
||||
})
|
||||
var downloads = data.downloads.map((d) => {
|
||||
d.status = this.$constants.DownloadStatus.READY
|
||||
return d
|
||||
})
|
||||
var allDownloads = downloads.concat(pendingDownloads)
|
||||
this.$store.commit('downloads/setDownloads', allDownloads)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load downloads', error)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadDownloads()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,20 +1,18 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-4 py-2 mb-4">
|
||||
<div v-show="showDirectoryPicker" class="flex items-center py-1 mb-2">
|
||||
<span class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="backArrowPress">arrow_back</span>
|
||||
<p class="px-4 text-xl">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!showDirectoryPicker" class="w-full h-full py-4">
|
||||
<div class="flex flex-wrap md:flex-nowrap -mx-1">
|
||||
<div class="w-2/5 md:w-72 px-1 py-1 md:py-0">
|
||||
<ui-dropdown v-model="mediaType" :items="mediaTypes" label="Media Type" :disabled="!isNew" small @input="changedMediaType" />
|
||||
</div>
|
||||
<div class="w-full md:flex-grow px-1 py-1 md:py-0">
|
||||
<ui-text-input-with-label v-model="name" label="Library Name" />
|
||||
<ui-text-input-with-label v-model="name" label="Library Name" @blur="nameBlurred" />
|
||||
</div>
|
||||
<div class="w-1/2 md:w-72 px-1 py-1 md:py-0">
|
||||
<ui-media-type-picker v-model="mediaType" />
|
||||
<div class="w-1/5 md:w-18 px-1 py-1 md:py-0">
|
||||
<ui-media-icon-picker v-model="icon" @input="iconChanged" />
|
||||
</div>
|
||||
<div class="w-1/2 md:w-72 px-1 py-1 md:py-0">
|
||||
<ui-dropdown v-model="provider" :items="providers" label="Metadata Provider" small />
|
||||
<div class="w-2/5 md:w-72 px-1 py-1 md:py-0">
|
||||
<ui-dropdown v-model="provider" :items="providers" label="Metadata Provider" small @input="formUpdated" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,36 +20,26 @@
|
||||
<p class="px-1 text-sm font-semibold">Folders</p>
|
||||
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2">
|
||||
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<ui-editable-text v-model="folder.fullPath" type="text" class="w-full" />
|
||||
<ui-editable-text v-model="folder.fullPath" readonly type="text" class="w-full" />
|
||||
<span v-show="folders.length > 1" class="material-icons ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
|
||||
</div>
|
||||
<p v-if="!folders.length" class="text-sm text-gray-300 px-1 py-2">No folders</p>
|
||||
<div class="flex py-1 px-2 items-center w-full">
|
||||
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<ui-editable-text v-model="newFolderPath" placeholder="New folder path" type="text" class="w-full" @blur="newFolderInputBlurred" />
|
||||
</div>
|
||||
|
||||
<ui-btn class="w-full mt-2" color="primary" @click="showDirectoryPicker = true">Browse for Folder</ui-btn>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full py-4 px-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-show="!disableSubmit" color="success" :disabled="disableSubmit" @click="submit">{{ library ? 'Update Library' : 'Create Library' }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<modals-libraries-folder-chooser v-else :paths="folderPaths" @select="selectFolder" />
|
||||
|
||||
<div v-if="!showDirectoryPicker">
|
||||
<div class="flex items-center pt-2">
|
||||
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" />
|
||||
<ui-toggle-switch v-else disabled :value="false" />
|
||||
<p class="pl-4 text-lg">Disable folder watcher for library</p>
|
||||
</div>
|
||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
|
||||
</div>
|
||||
<modals-libraries-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
isNew: Boolean,
|
||||
library: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
@@ -61,40 +49,73 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
name: '',
|
||||
provider: '',
|
||||
mediaType: '',
|
||||
provider: 'google',
|
||||
icon: '',
|
||||
folders: [],
|
||||
showDirectoryPicker: false,
|
||||
disableWatcher: false
|
||||
newFolderPath: '',
|
||||
mediaType: null,
|
||||
mediaTypes: [
|
||||
{
|
||||
value: 'book',
|
||||
text: 'Books'
|
||||
},
|
||||
{
|
||||
value: 'podcast',
|
||||
text: 'Podcasts'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
title() {
|
||||
if (this.showDirectoryPicker) return 'Choose a Folder'
|
||||
return ''
|
||||
},
|
||||
folderPaths() {
|
||||
return this.folders.map((f) => f.fullPath)
|
||||
},
|
||||
disableSubmit() {
|
||||
if (!this.library) {
|
||||
return false
|
||||
}
|
||||
var newfolderpaths = this.folderPaths.join(',')
|
||||
var origfolderpaths = this.library.folders.map((f) => f.fullPath).join(',')
|
||||
|
||||
return newfolderpaths === origfolderpaths && this.name === this.library.name && this.provider === this.library.provider && this.disableWatcher === this.library.disableWatcher && this.mediaType === this.library.mediaType
|
||||
},
|
||||
providers() {
|
||||
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
},
|
||||
globalWatcherDisabled() {
|
||||
return this.$store.getters['getServerSetting']('scannerDisableWatcher')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getLibraryData() {
|
||||
return {
|
||||
name: this.name,
|
||||
provider: this.provider,
|
||||
folders: this.folders,
|
||||
icon: this.icon,
|
||||
mediaType: this.mediaType
|
||||
}
|
||||
},
|
||||
formUpdated() {
|
||||
this.$emit('update', this.getLibraryData())
|
||||
},
|
||||
newFolderInputBlurred() {
|
||||
if (this.newFolderPath) {
|
||||
this.folders.push({ fullPath: this.newFolderPath })
|
||||
this.newFolderPath = ''
|
||||
this.formUpdated()
|
||||
}
|
||||
},
|
||||
iconChanged() {
|
||||
this.formUpdated()
|
||||
},
|
||||
nameBlurred() {
|
||||
if (this.name !== this.library.name) {
|
||||
this.formUpdated()
|
||||
}
|
||||
},
|
||||
changedMediaType() {
|
||||
this.provider = this.providers[0].value
|
||||
this.formUpdated()
|
||||
},
|
||||
selectFolder(fullPath) {
|
||||
this.folders.push({ fullPath })
|
||||
this.showDirectoryPicker = false
|
||||
this.formUpdated()
|
||||
},
|
||||
removeFolder(folder) {
|
||||
this.folders = this.folders.filter((f) => f.fullPath !== folder.fullPath)
|
||||
this.formUpdated()
|
||||
},
|
||||
backArrowPress() {
|
||||
if (this.showDirectoryPicker) {
|
||||
@@ -103,94 +124,11 @@ export default {
|
||||
},
|
||||
init() {
|
||||
this.name = this.library ? this.library.name : ''
|
||||
this.provider = this.library ? this.library.provider : ''
|
||||
this.provider = this.library ? this.library.provider : 'google'
|
||||
this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
|
||||
this.disableWatcher = this.library ? !!this.library.disableWatcher : false
|
||||
this.mediaType = this.library ? this.library.mediaType : 'default'
|
||||
this.icon = this.library ? this.library.icon : 'default'
|
||||
this.mediaType = this.library ? this.library.mediaType : 'book'
|
||||
this.showDirectoryPicker = false
|
||||
},
|
||||
selectFolder(fullPath) {
|
||||
this.folders.push({ fullPath })
|
||||
this.showDirectoryPicker = false
|
||||
},
|
||||
submit() {
|
||||
if (this.library) {
|
||||
this.updateLibrary()
|
||||
} else {
|
||||
this.createLibrary()
|
||||
}
|
||||
},
|
||||
updateLibrary() {
|
||||
if (!this.name) {
|
||||
this.$toast.error('Library must have a name')
|
||||
return
|
||||
}
|
||||
if (!this.folders.length) {
|
||||
this.$toast.error('Library must have at least 1 path')
|
||||
return
|
||||
}
|
||||
var newLibraryPayload = {
|
||||
name: this.name,
|
||||
provider: this.provider,
|
||||
folders: this.folders,
|
||||
mediaType: this.mediaType,
|
||||
icon: this.mediaType,
|
||||
disableWatcher: this.disableWatcher
|
||||
}
|
||||
|
||||
this.$emit('update:processing', true)
|
||||
this.$axios
|
||||
.$patch(`/api/libraries/${this.library.id}`, newLibraryPayload)
|
||||
.then((res) => {
|
||||
this.$emit('update:processing', false)
|
||||
this.$emit('close')
|
||||
this.$toast.success(`Library "${res.name}" updated successfully`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
} else {
|
||||
this.$toast.error('Failed to update library')
|
||||
}
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
},
|
||||
createLibrary() {
|
||||
if (!this.name) {
|
||||
this.$toast.error('Library must have a name')
|
||||
return
|
||||
}
|
||||
if (!this.folders.length) {
|
||||
this.$toast.error('Library must have at least 1 path')
|
||||
return
|
||||
}
|
||||
var newLibraryPayload = {
|
||||
name: this.name,
|
||||
provider: this.provider,
|
||||
folders: this.folders,
|
||||
mediaType: this.mediaType,
|
||||
icon: this.mediaType,
|
||||
disableWatcher: this.disableWatcher
|
||||
}
|
||||
|
||||
this.$emit('update:processing', true)
|
||||
this.$axios
|
||||
.$post('/api/libraries', newLibraryPayload)
|
||||
.then((res) => {
|
||||
this.$emit('update:processing', false)
|
||||
this.$emit('close')
|
||||
this.$toast.success(`Library "${res.name}" created successfully`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
} else {
|
||||
this.$toast.error('Failed to create library')
|
||||
}
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
218
client/components/modals/libraries/EditModal.vue
Normal file
218
client/components/modals/libraries/EditModal.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute -top-10 left-0 z-10 w-full flex">
|
||||
<template v-for="tab in tabs">
|
||||
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="px-4 w-full text-sm pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<component v-if="libraryCopy && show" :is="tabName" :is-new="!library" :library="libraryCopy" :processing.sync="processing" @update="updateLibrary" @close="show = false" />
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-opacity-10">
|
||||
<div class="flex justify-end">
|
||||
<ui-btn @click="submit">{{ buttonText }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
library: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
selectedTab: 'details',
|
||||
tabs: [
|
||||
{
|
||||
id: 'details',
|
||||
title: 'Details',
|
||||
component: 'modals-libraries-edit-library'
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
title: 'Settings',
|
||||
component: 'modals-libraries-library-settings'
|
||||
}
|
||||
],
|
||||
libraryCopy: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.library ? 'Update Library' : 'New Library'
|
||||
},
|
||||
buttonText() {
|
||||
return this.library ? 'Update Library' : 'Create New Library'
|
||||
},
|
||||
tabName() {
|
||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||
return _tab ? _tab.component : ''
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectTab(tab) {
|
||||
this.selectedTab = tab
|
||||
},
|
||||
updateLibrary(library) {
|
||||
this.mapLibraryToCopy(library)
|
||||
},
|
||||
getNewLibraryData() {
|
||||
return {
|
||||
name: '',
|
||||
provider: 'google',
|
||||
folders: [],
|
||||
icon: 'database',
|
||||
mediaType: 'book',
|
||||
settings: {
|
||||
disableWatcher: false
|
||||
}
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.selectedTab = 'details'
|
||||
this.libraryCopy = this.getNewLibraryData()
|
||||
if (this.library) {
|
||||
this.mapLibraryToCopy(this.library)
|
||||
}
|
||||
},
|
||||
mapLibraryToCopy(library) {
|
||||
for (const key in this.libraryCopy) {
|
||||
if (library[key] !== undefined) {
|
||||
if (key === 'folders') {
|
||||
this.libraryCopy.folders = library.folders.map((f) => ({ ...f }))
|
||||
} else if (key === 'settings') {
|
||||
this.libraryCopy.settings = { ...library.settings }
|
||||
} else {
|
||||
this.libraryCopy[key] = library[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
validate() {
|
||||
if (!this.libraryCopy.name) {
|
||||
this.$toast.error('Library must have a name')
|
||||
return false
|
||||
}
|
||||
if (!this.libraryCopy.folders.length) {
|
||||
this.$toast.error('Library must have at least 1 path')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
submit() {
|
||||
if (!this.validate()) return
|
||||
|
||||
if (this.library) {
|
||||
this.submitUpdateLibrary()
|
||||
} else {
|
||||
this.submitCreateLibrary()
|
||||
}
|
||||
},
|
||||
getLibraryUpdatePayload() {
|
||||
var updatePayload = {}
|
||||
for (const key in this.libraryCopy) {
|
||||
if (key === 'folders') {
|
||||
if (this.libraryCopy.folders.map((f) => f.fullPath).join(',') !== this.library.folders.map((f) => f.fullPath).join(',')) {
|
||||
updatePayload.folders = [...this.libraryCopy.folders]
|
||||
}
|
||||
} else if (key === 'settings') {
|
||||
for (const settingsKey in this.libraryCopy.settings) {
|
||||
if (this.libraryCopy.settings[settingsKey] !== this.library.settings[settingsKey]) {
|
||||
if (!updatePayload.settings) updatePayload.settings = {}
|
||||
updatePayload.settings[settingsKey] = this.libraryCopy.settings[settingsKey]
|
||||
}
|
||||
}
|
||||
} else if (key !== 'mediaType' && this.libraryCopy[key] !== this.library[key]) {
|
||||
updatePayload[key] = this.libraryCopy[key]
|
||||
}
|
||||
}
|
||||
return updatePayload
|
||||
},
|
||||
submitUpdateLibrary() {
|
||||
var newLibraryPayload = this.getLibraryUpdatePayload()
|
||||
if (!Object.keys(newLibraryPayload).length) {
|
||||
this.$toast.info('No updates are necessary')
|
||||
return
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$patch(`/api/libraries/${this.library.id}`, newLibraryPayload)
|
||||
.then((res) => {
|
||||
this.processing = false
|
||||
this.show = false
|
||||
this.$toast.success(`Library "${res.name}" updated successfully`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
} else {
|
||||
this.$toast.error('Failed to update library')
|
||||
}
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
submitCreateLibrary() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post('/api/libraries', this.libraryCopy)
|
||||
.then((res) => {
|
||||
this.processing = false
|
||||
this.show = false
|
||||
this.$toast.success(`Library "${res.name}" created successfully`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
} else {
|
||||
this.$toast.error('Failed to create library')
|
||||
}
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab {
|
||||
height: 40px;
|
||||
}
|
||||
.tab.tab-selected {
|
||||
height: 41px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,14 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div class="w-full h-full bg-bg absolute top-0 left-0 px-4 py-4 z-10">
|
||||
<div class="flex items-center py-1 mb-2">
|
||||
<span class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="$emit('back')">arrow_back</span>
|
||||
<p class="px-4 text-xl">Choose a Folder</p>
|
||||
</div>
|
||||
<div v-if="allFolders.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2">
|
||||
<p class="font-mono truncate">{{ selectedPath || '\\' }}</p>
|
||||
</div>
|
||||
<div v-if="allFolders.length" class="flex bg-primary bg-opacity-50 p-4">
|
||||
<div class="w-1/2 border-r border-bg">
|
||||
<div v-if="allFolders.length" class="flex bg-primary bg-opacity-50 p-4 folder-container">
|
||||
<div class="w-1/2 border-r border-bg h-full overflow-y-auto">
|
||||
<div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center" @click="goBack">
|
||||
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<p class="text-base font-mono px-2">..</p>
|
||||
@@ -15,7 +19,7 @@
|
||||
<span v-if="dir.dirs && dir.dirs.length && dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<div class="w-1/2 h-full overflow-y-auto">
|
||||
<div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" @click="selectSubDir(dir)">
|
||||
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
|
||||
@@ -30,12 +34,8 @@
|
||||
<p class="text-gray-300">Note: folders already mapped will not be shown</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full py-4 px-8">
|
||||
<div class="w-full py-2">
|
||||
<ui-btn :disabled="!selectedPath" color="primary" class="w-full mt-2" @click="selectFolder">Select Folder Path</ui-btn>
|
||||
<!-- <div class="flex items-center">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" @click="selectFolder">Select</ui-btn>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -64,7 +64,6 @@ export default {
|
||||
computed: {
|
||||
_directories() {
|
||||
return this.directories.map((d) => {
|
||||
console.log('Directories', d)
|
||||
var isUsed = !!this.paths.find((path) => path.endsWith(d.path))
|
||||
var isSelected = d.path === this.selectedPath
|
||||
var classes = []
|
||||
@@ -162,4 +161,9 @@ export default {
|
||||
.dir-item.dir-used {
|
||||
background-color: rgba(255, 25, 0, 0.1);
|
||||
}
|
||||
.folder-container {
|
||||
max-height: calc(100% - 130px);
|
||||
height: calc(100% - 130px);
|
||||
min-height: calc(100% - 130px);
|
||||
}
|
||||
</style>
|
||||
63
client/components/modals/libraries/LibrarySettings.vue
Normal file
63
client/components/modals/libraries/LibrarySettings.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-4 py-1 mb-4">
|
||||
<div class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" @input="formUpdated" />
|
||||
<ui-toggle-switch v-else disabled :value="false" />
|
||||
<p class="pl-4 text-lg">Disable folder watcher for library</p>
|
||||
</div>
|
||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
library: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
processing: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
provider: null,
|
||||
disableWatcher: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
librarySettings() {
|
||||
return this.library.settings || {}
|
||||
},
|
||||
globalWatcherDisabled() {
|
||||
return this.$store.getters['getServerSetting']('scannerDisableWatcher')
|
||||
},
|
||||
mediaType() {
|
||||
return this.library.mediaType
|
||||
},
|
||||
providers() {
|
||||
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getLibraryData() {
|
||||
return {
|
||||
settings: {
|
||||
disableWatcher: !!this.disableWatcher
|
||||
}
|
||||
}
|
||||
},
|
||||
formUpdated() {
|
||||
this.$emit('update', this.getLibraryData())
|
||||
},
|
||||
init() {
|
||||
this.disableWatcher = !!this.librarySettings.disableWatcher
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
138
client/components/modals/podcast/EditEpisode.vue
Normal file
138
client/components/modals/podcast/EditEpisode.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="podcast-episode-edit-modal" :width="800" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||
<div class="flex flex-wrap">
|
||||
<div class="w-1/3 p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.episode" label="Episode" />
|
||||
</div>
|
||||
<div class="w-1/3 p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" />
|
||||
</div>
|
||||
<div class="w-1/3 p-1">
|
||||
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" label="Pub Date" />
|
||||
</div>
|
||||
<div class="w-full p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.title" label="Title" />
|
||||
</div>
|
||||
<div class="w-full p-1">
|
||||
<ui-textarea-with-label v-model="newEpisode.subtitle" label="Subtitle" :rows="3" />
|
||||
</div>
|
||||
<div class="w-full p-1">
|
||||
<ui-textarea-with-label v-model="newEpisode.description" label="Description" :rows="8" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<ui-btn @click="submit">Submit</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newEpisode: {
|
||||
episode: null,
|
||||
episodeType: null,
|
||||
title: null,
|
||||
subtitle: null,
|
||||
description: null,
|
||||
pubDate: null,
|
||||
publishedAt: null
|
||||
},
|
||||
pubDateInput: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
episode: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.$store.state.globals.showEditPodcastEpisode
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', val)
|
||||
}
|
||||
},
|
||||
libraryItem() {
|
||||
return this.$store.state.selectedLibraryItem
|
||||
},
|
||||
episode() {
|
||||
return this.$store.state.globals.selectedEpisode
|
||||
},
|
||||
episodeId() {
|
||||
return this.episode ? this.episode.id : null
|
||||
},
|
||||
title() {
|
||||
if (!this.libraryItem) return ''
|
||||
return this.libraryItem.media.metadata.title || 'Unknown'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updatePubDate(val) {
|
||||
if (val) {
|
||||
this.newEpisode.pubDate = this.$formatJsDate(new Date(val), 'E, d MMM yyyy HH:mm:ssxx')
|
||||
this.newEpisode.publishedAt = new Date(val).valueOf()
|
||||
} else {
|
||||
this.newEpisode.pubDate = null
|
||||
this.newEpisode.publishedAt = null
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.newEpisode.episode = this.episode.episode || ''
|
||||
this.newEpisode.episodeType = this.episode.episodeType || ''
|
||||
this.newEpisode.title = this.episode.title || ''
|
||||
this.newEpisode.subtitle = this.episode.subtitle || ''
|
||||
this.newEpisode.description = this.episode.description || ''
|
||||
this.newEpisode.pubDate = this.episode.pubDate || ''
|
||||
this.newEpisode.publishedAt = this.episode.publishedAt
|
||||
|
||||
this.pubDateInput = this.episode.pubDate ? this.$formatJsDate(new Date(this.episode.pubDate), "yyyy-MM-dd'T'HH:mm") : null
|
||||
},
|
||||
getUpdatePayload() {
|
||||
var updatePayload = {}
|
||||
for (const key in this.newEpisode) {
|
||||
if (this.newEpisode[key] != this.episode[key]) {
|
||||
updatePayload[key] = this.newEpisode[key]
|
||||
}
|
||||
}
|
||||
return updatePayload
|
||||
},
|
||||
submit() {
|
||||
const payload = this.getUpdatePayload()
|
||||
if (!Object.keys(payload).length) {
|
||||
return this.$toast.info('No updates were made')
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, payload)
|
||||
.then(() => {
|
||||
this.processing = false
|
||||
this.$toast.success('Podcast episode updated')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed update episode'
|
||||
console.error('Failed update episode', error)
|
||||
this.processing = false
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
172
client/components/modals/podcast/EpisodeFeed.vue
Normal file
172
client/components/modals/podcast/EpisodeFeed.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="podcast-episodes-modal" :width="1200" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="wrapper" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||
<div ref="episodeContainer" id="episodes-scroll" class="w-full overflow-x-hidden overflow-y-auto">
|
||||
<div
|
||||
v-for="(episode, index) in episodes"
|
||||
:key="index"
|
||||
class="relative"
|
||||
:class="episode.enclosure && itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
|
||||
@click="toggleSelectEpisode(index)"
|
||||
>
|
||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||
<span v-if="episode.enclosure && itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
|
||||
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
|
||||
</div>
|
||||
<div class="px-8 py-2">
|
||||
<p v-if="episode.episode" class="font-semibold text-gray-200">#{{ episode.episode }}</p>
|
||||
<p class="break-words mb-1">{{ episode.title }}</p>
|
||||
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
|
||||
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||
<!-- <span class="material-icons cursor-pointer text-lg hover:text-success" @click="saveEpisode(episode)">save</span> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<div class="relative">
|
||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||
<ui-checkbox v-model="selectAll" small checkbox-bg="primary" border-color="gray-600" :disabled="allDownloaded" />
|
||||
</div>
|
||||
<div class="px-8 py-2">
|
||||
<p :class="!allDownloaded ? 'font-semibold text-gray-200' : 'text-gray-400'">Select all episodes</p>
|
||||
</div>
|
||||
</div>
|
||||
<ui-btn :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
episodes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
selectedEpisodes: {}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
selectAll: {
|
||||
get() {
|
||||
return this.episodesSelected.length == this.episodes.filter((_, index) => !(this.episodes[index].enclosure && this.itemEpisodeMap[this.episodes[index].enclosure.url])).length
|
||||
},
|
||||
set(val) {
|
||||
for (const key in this.selectedEpisodes) {
|
||||
this.selectedEpisodes[key] = val
|
||||
}
|
||||
}
|
||||
},
|
||||
title() {
|
||||
if (!this.libraryItem) return ''
|
||||
return this.libraryItem.media.metadata.title || 'Unknown'
|
||||
},
|
||||
allDownloaded() {
|
||||
return Object.values(this.episodes).filter((episode) => !(episode.enclosure && this.itemEpisodeMap[episode.enclosure.url])).length === 0
|
||||
},
|
||||
episodesSelected() {
|
||||
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
||||
},
|
||||
buttonText() {
|
||||
if (!this.episodesSelected.length) return 'No Episodes Selected'
|
||||
return `Download ${this.episodesSelected.length} Episode${this.episodesSelected.length > 1 ? 's' : ''}`
|
||||
},
|
||||
itemEpisodes() {
|
||||
if (!this.libraryItem) return []
|
||||
return this.libraryItem.media.episodes || []
|
||||
},
|
||||
itemEpisodeMap() {
|
||||
var map = {}
|
||||
this.itemEpisodes.forEach((item) => {
|
||||
if (item.enclosure) map[item.enclosure.url] = true
|
||||
})
|
||||
return map
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleSelectEpisode(index) {
|
||||
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
|
||||
},
|
||||
submit() {
|
||||
var episodesToDownload = []
|
||||
if (this.episodesSelected.length) {
|
||||
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
|
||||
}
|
||||
|
||||
var payloadSize = JSON.stringify(episodesToDownload).length
|
||||
var sizeInMb = payloadSize / 1024 / 1024
|
||||
var sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
|
||||
console.log('Request size', sizeInMb)
|
||||
if (sizeInMb > 4.99) {
|
||||
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/podcasts/${this.libraryItem.id}/download-episodes`, episodesToDownload)
|
||||
.then(() => {
|
||||
this.processing = false
|
||||
this.$toast.success('Started downloading episodes')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to download episodes'
|
||||
console.error('Failed to download episodes', error)
|
||||
this.processing = false
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
},
|
||||
init() {
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
var episode = this.episodes[i]
|
||||
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {
|
||||
// Do not include episodes already downloaded
|
||||
this.$set(this.selectedEpisodes, String(i), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#podcast-wrapper {
|
||||
min-height: 400px;
|
||||
max-height: 80vh;
|
||||
}
|
||||
#episodes-scroll {
|
||||
max-height: calc(80vh - 200px);
|
||||
}
|
||||
</style>
|
||||
229
client/components/modals/podcast/NewModal.vue
Normal file
229
client/components/modals/podcast/NewModal.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="new-podcast-modal" :width="1000" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="wrapper" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||
<div class="w-full p-4">
|
||||
<p class="text-lg font-semibold mb-2">Details</p>
|
||||
|
||||
<div v-if="podcast.imageUrl" class="p-1 w-full">
|
||||
<img :src="podcast.imageUrl" class="h-16 w-16 object-contain" />
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-text-input-with-label v-model="podcast.title" label="Title" @input="titleUpdated" />
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-text-input-with-label v-model="podcast.author" label="Author" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-text-input-with-label v-model="podcast.feedUrl" label="Feed URL" readonly />
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" label="Genres" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 w-full">
|
||||
<ui-textarea-with-label v-model="podcast.description" label="Description" :rows="3" />
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" @input="folderUpdated" />
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-text-input-with-label v-model="fullPath" label="Podcast Path" readonly />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center py-4">
|
||||
<div class="flex-grow" />
|
||||
<div class="px-4">
|
||||
<ui-checkbox v-model="podcast.autoDownloadEpisodes" label="Auto Download Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
<ui-btn color="success" @click="submit">Add Podcast</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Path from 'path'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
podcastData: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
podcastFeedData: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
selectedFolderId: null,
|
||||
fullPath: null,
|
||||
podcast: {
|
||||
title: '',
|
||||
author: '',
|
||||
description: '',
|
||||
releaseDate: '',
|
||||
genres: [],
|
||||
feedUrl: '',
|
||||
feedImageUrl: '',
|
||||
itunesPageUrl: '',
|
||||
itunesId: '',
|
||||
itunesArtistId: '',
|
||||
autoDownloadEpisodes: false
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this._podcastData.title
|
||||
},
|
||||
currentLibrary() {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
folders() {
|
||||
if (!this.currentLibrary) return []
|
||||
return this.currentLibrary.folders || []
|
||||
},
|
||||
folderItems() {
|
||||
return this.folders.map((fold) => {
|
||||
return {
|
||||
value: fold.id,
|
||||
text: fold.fullPath
|
||||
}
|
||||
})
|
||||
},
|
||||
_podcastData() {
|
||||
return this.podcastData || {}
|
||||
},
|
||||
feedMetadata() {
|
||||
if (!this.podcastFeedData) return {}
|
||||
return this.podcastFeedData.metadata || {}
|
||||
},
|
||||
episodes() {
|
||||
if (!this.podcastFeedData) return []
|
||||
return this.podcastFeedData.episodes || []
|
||||
},
|
||||
selectedFolder() {
|
||||
return this.folders.find((f) => f.id === this.selectedFolderId)
|
||||
},
|
||||
selectedFolderPath() {
|
||||
if (!this.selectedFolder) return ''
|
||||
return this.selectedFolder.fullPath
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
titleUpdated() {
|
||||
this.folderUpdated()
|
||||
},
|
||||
folderUpdated() {
|
||||
if (!this.selectedFolderPath || !this.podcast.title) {
|
||||
this.fullPath = ''
|
||||
return
|
||||
}
|
||||
this.fullPath = Path.join(this.selectedFolderPath, this.podcast.title)
|
||||
},
|
||||
submit() {
|
||||
const podcastPayload = {
|
||||
path: this.fullPath,
|
||||
folderId: this.selectedFolderId,
|
||||
libraryId: this.currentLibrary.id,
|
||||
media: {
|
||||
metadata: {
|
||||
title: this.podcast.title,
|
||||
author: this.podcast.author,
|
||||
description: this.podcast.description,
|
||||
releaseDate: this.podcast.releaseDate,
|
||||
genres: [...this.podcast.genres],
|
||||
feedUrl: this.podcast.feedUrl,
|
||||
imageUrl: this.podcast.imageUrl,
|
||||
itunesPageUrl: this.podcast.itunesPageUrl,
|
||||
itunesId: this.podcast.itunesId,
|
||||
itunesArtistId: this.podcast.itunesArtistId,
|
||||
language: this.podcast.language
|
||||
},
|
||||
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
|
||||
}
|
||||
}
|
||||
console.log('Podcast payload', podcastPayload)
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post('/api/podcasts', podcastPayload)
|
||||
.then((libraryItem) => {
|
||||
this.processing = false
|
||||
this.$toast.success('Podcast created successfully')
|
||||
this.show = false
|
||||
this.$router.push(`/item/${libraryItem.id}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to create podcast'
|
||||
console.error('Failed to create podcast', error)
|
||||
this.processing = false
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
},
|
||||
init() {
|
||||
// Prefer using itunes podcast data but not always passed in if manually entering rss feed
|
||||
this.podcast.title = this._podcastData.title || this.feedMetadata.title || ''
|
||||
this.podcast.author = this._podcastData.artistName || this.feedMetadata.author || ''
|
||||
this.podcast.description = this._podcastData.description || this.feedMetadata.descriptionPlain || ''
|
||||
this.podcast.releaseDate = this._podcastData.releaseDate || ''
|
||||
this.podcast.genres = this._podcastData.genres || this.feedMetadata.categories || []
|
||||
this.podcast.feedUrl = this._podcastData.feedUrl || this.feedMetadata.feedUrl || ''
|
||||
this.podcast.imageUrl = this._podcastData.cover || this.feedMetadata.image || ''
|
||||
this.podcast.itunesPageUrl = this._podcastData.pageUrl || ''
|
||||
this.podcast.itunesId = this._podcastData.id || ''
|
||||
this.podcast.itunesArtistId = this._podcastData.artistId || ''
|
||||
this.podcast.language = this._podcastData.language || ''
|
||||
this.podcast.autoDownloadEpisodes = false
|
||||
|
||||
if (this.folderItems[0]) {
|
||||
this.selectedFolderId = this.folderItems[0].value
|
||||
this.folderUpdated()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#podcast-wrapper {
|
||||
min-height: 400px;
|
||||
max-height: 80vh;
|
||||
}
|
||||
#episodes-scroll {
|
||||
max-height: calc(80vh - 200px);
|
||||
}
|
||||
</style>
|
||||
@@ -18,10 +18,7 @@
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
ebookType: '',
|
||||
ebookUrl: ''
|
||||
}
|
||||
return {}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
@@ -47,46 +44,65 @@ export default {
|
||||
return null
|
||||
},
|
||||
abTitle() {
|
||||
return this.selectedAudiobook.book.title
|
||||
return this.mediaMetadata.title
|
||||
},
|
||||
abAuthor() {
|
||||
return this.selectedAudiobook.book.author
|
||||
return this.mediaMetadata.authorName
|
||||
},
|
||||
selectedAudiobook() {
|
||||
return this.$store.state.selectedAudiobook
|
||||
selectedLibraryItem() {
|
||||
return this.$store.state.selectedLibraryItem || {}
|
||||
},
|
||||
media() {
|
||||
return this.selectedLibraryItem.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
libraryId() {
|
||||
return this.selectedAudiobook.libraryId
|
||||
return this.selectedLibraryItem.libraryId
|
||||
},
|
||||
folderId() {
|
||||
return this.selectedAudiobook.folderId
|
||||
return this.selectedLibraryItem.folderId
|
||||
},
|
||||
ebooks() {
|
||||
return this.selectedAudiobook.ebooks || []
|
||||
ebookFile() {
|
||||
return this.media.ebookFile
|
||||
},
|
||||
epubEbook() {
|
||||
return this.ebooks.find((eb) => eb.ext === '.epub')
|
||||
ebookFormat() {
|
||||
if (!this.ebookFile) return null
|
||||
return this.ebookFile.ebookFormat
|
||||
},
|
||||
mobiEbook() {
|
||||
return this.ebooks.find((eb) => eb.ext === '.mobi' || eb.ext === '.azw3')
|
||||
ebookType() {
|
||||
if (this.isMobi) return 'mobi'
|
||||
else if (this.isEpub) return 'epub'
|
||||
else if (this.isPdf) return 'pdf'
|
||||
else if (this.isComic) return 'comic'
|
||||
return null
|
||||
},
|
||||
pdfEbook() {
|
||||
return this.ebooks.find((eb) => eb.ext === '.pdf')
|
||||
isEpub() {
|
||||
return this.ebookFormat == 'epub'
|
||||
},
|
||||
comicEbook() {
|
||||
return this.ebooks.find((eb) => eb.ext === '.cbz' || eb.ext === '.cbr')
|
||||
isMobi() {
|
||||
return this.ebookFormat == 'mobi' || this.ebookFormat == 'azw3'
|
||||
},
|
||||
isPdf() {
|
||||
return this.ebookFormat == 'pdf'
|
||||
},
|
||||
isComic() {
|
||||
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
||||
},
|
||||
ebookUrl() {
|
||||
if (!this.ebookFile) return null
|
||||
var itemRelPath = this.selectedLibraryItem.relPath
|
||||
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
|
||||
var relPath = this.ebookFile.metadata.relPath
|
||||
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
||||
return `/ebook/${this.libraryId}/${this.folderId}/${itemRelPath}/${relPath}`
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
selectedAudiobookFile() {
|
||||
return this.$store.state.selectedAudiobookFile
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getEbookUrl(path) {
|
||||
return `/ebook/${this.libraryId}/${this.folderId}/${path}`
|
||||
},
|
||||
hotkey(action) {
|
||||
console.log('Reader hotkey', action)
|
||||
if (!this.$refs.readerComponent) return
|
||||
@@ -107,31 +123,6 @@ export default {
|
||||
},
|
||||
init() {
|
||||
this.registerListeners()
|
||||
|
||||
if (this.selectedAudiobookFile) {
|
||||
this.ebookUrl = this.getEbookUrl(this.selectedAudiobookFile.path)
|
||||
if (this.selectedAudiobookFile.ext === '.pdf') {
|
||||
this.ebookType = 'pdf'
|
||||
} else if (this.selectedAudiobookFile.ext === '.mobi' || this.selectedAudiobookFile.ext === '.azw3') {
|
||||
this.ebookType = 'mobi'
|
||||
} else if (this.selectedAudiobookFile.ext === '.epub') {
|
||||
this.ebookType = 'epub'
|
||||
} else if (this.selectedAudiobookFile.ext === '.cbr' || this.selectedAudiobookFile.ext === '.cbz') {
|
||||
this.ebookType = 'comic'
|
||||
}
|
||||
} else if (this.epubEbook) {
|
||||
this.ebookType = 'epub'
|
||||
this.ebookUrl = this.getEbookUrl(this.epubEbook.path)
|
||||
} else if (this.mobiEbook) {
|
||||
this.ebookType = 'mobi'
|
||||
this.ebookUrl = this.getEbookUrl(this.mobiEbook.path)
|
||||
} else if (this.pdfEbook) {
|
||||
this.ebookType = 'pdf'
|
||||
this.ebookUrl = this.getEbookUrl(this.pdfEbook.path)
|
||||
} else if (this.comicEbook) {
|
||||
this.ebookType = 'comic'
|
||||
this.ebookUrl = this.getEbookUrl(this.comicEbook.path)
|
||||
}
|
||||
},
|
||||
close() {
|
||||
this.unregisterListeners()
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
|
||||
</svg>
|
||||
<div class="px-2">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalBooks }}</p>
|
||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Books in Library</p>
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalItems }}</p>
|
||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Items in Library</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex px-4">
|
||||
<span class="material-icons text-7xl">show_chart</span>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalAudiobookHours }}</p>
|
||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Overall Hours</p>
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalTime }}</p>
|
||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Overall {{ useOverallHours ? 'Hours' : 'Days' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,8 +61,8 @@ export default {
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
totalBooks() {
|
||||
return this.libraryStats ? this.libraryStats.totalBooks : 0
|
||||
totalItems() {
|
||||
return this.libraryStats ? this.libraryStats.totalItems : 0
|
||||
},
|
||||
totalAuthors() {
|
||||
return this.libraryStats ? this.libraryStats.totalAuthors : 0
|
||||
@@ -70,12 +70,11 @@ export default {
|
||||
numAudioTracks() {
|
||||
return this.libraryStats ? this.libraryStats.numAudioTracks : 0
|
||||
},
|
||||
totalAudiobookDuration() {
|
||||
totalDuration() {
|
||||
return this.libraryStats ? this.libraryStats.totalDuration : 0
|
||||
},
|
||||
totalAudiobookHours() {
|
||||
var totalHours = Math.round(this.totalAudiobookDuration / (60 * 60))
|
||||
return totalHours
|
||||
totalHours() {
|
||||
return Math.round(this.totalDuration / (60 * 60))
|
||||
},
|
||||
totalSizePretty() {
|
||||
var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0
|
||||
@@ -86,6 +85,13 @@ export default {
|
||||
},
|
||||
totalSizeMod() {
|
||||
return this.totalSizePretty.split(' ')[1]
|
||||
},
|
||||
useOverallHours() {
|
||||
return this.totalHours < 10000
|
||||
},
|
||||
totalTime() {
|
||||
if (this.useOverallHours) return this.totalHours
|
||||
return Math.round(this.totalHours / 24)
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full my-2">
|
||||
<div class="w-full bg-primary px-4 py-2 flex items-center cursor-pointer">
|
||||
<p class="pr-4">All Files</p>
|
||||
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ allFiles.length }}</span>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left px-4">Path</th>
|
||||
<th class="text-left px-4 w-24">Filetype</th>
|
||||
<th v-if="userCanDownload" class="text-center w-20">Download</th>
|
||||
</tr>
|
||||
<template v-for="file in allFiles">
|
||||
<tr :key="file.path">
|
||||
<td class="font-book pl-2">
|
||||
{{ showFullPath ? file.fullPath : file.path }}
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<p>{{ file.filetype }}</p>
|
||||
</td>
|
||||
<td v-if="userCanDownload" class="text-center">
|
||||
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showFullPath: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
audiobookId() {
|
||||
return this.audiobook.id
|
||||
},
|
||||
audiobookPath() {
|
||||
return this.audiobook.path
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
isMissing() {
|
||||
return this.audiobook.isMissing
|
||||
},
|
||||
showDownload() {
|
||||
return this.userCanDownload && !this.isMissing
|
||||
},
|
||||
otherFiles() {
|
||||
return this.audiobook.otherFiles || []
|
||||
},
|
||||
audioFiles() {
|
||||
return this.audiobook.audioFiles || []
|
||||
},
|
||||
audioFilesCleaned() {
|
||||
return this.audioFiles.map((af) => {
|
||||
return {
|
||||
path: af.path,
|
||||
fullPath: af.fullPath,
|
||||
relativePath: this.getRelativePath(af.path),
|
||||
filetype: 'audio'
|
||||
}
|
||||
})
|
||||
},
|
||||
otherFilesCleaned() {
|
||||
return this.otherFiles.map((af) => {
|
||||
return {
|
||||
path: af.path,
|
||||
fullPath: af.fullPath,
|
||||
relativePath: this.getRelativePath(af.path),
|
||||
filetype: af.filetype
|
||||
}
|
||||
})
|
||||
},
|
||||
allFiles() {
|
||||
return this.audioFilesCleaned.concat(this.otherFilesCleaned)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getRelativePath(path) {
|
||||
var filePath = path.replace(/\\/g, '/')
|
||||
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
|
||||
return filePath
|
||||
.replace(audiobookPath + '/', '')
|
||||
.replace(/%/g, '%25')
|
||||
.replace(/#/g, '%23')
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -13,17 +13,20 @@
|
||||
<th class="hidden sm:table-cell w-20 md:w-28">Size</th>
|
||||
<th class="w-36"></th>
|
||||
</tr>
|
||||
<tr v-for="backup in backups" :key="backup.id">
|
||||
<tr v-for="backup in backups" :key="backup.id" :class="!backup.serverVersion ? 'bg-error bg-opacity-10' : ''">
|
||||
<td>
|
||||
<p class="truncate text-xs sm:text-sm md:text-base">/{{ backup.path.replace(/\\/g, '/') }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell font-sans text-base">{{ backup.datePretty }}</td>
|
||||
<td class="hidden sm:table-cell font-mono md:text-base text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
|
||||
<td class="hidden sm:table-cell font-sans text-sm">{{ backup.datePretty }}</td>
|
||||
<td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
|
||||
<td>
|
||||
<div class="w-full flex flex-row items-center justify-center">
|
||||
<ui-btn small color="primary" @click="applyBackup(backup)">Apply</ui-btn>
|
||||
<ui-btn v-if="backup.serverVersion" small color="primary" @click="applyBackup(backup)">Apply</ui-btn>
|
||||
|
||||
<a :href="`/metadata/${backup.path.replace(/%/g, '%25').replace(/#/g, '%23')}?token=${userToken}`" class="mx-1 pt-1 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
|
||||
<a v-if="backup.serverVersion" :href="`/metadata/${$encodeUriPath(backup.path)}?token=${userToken}`" class="mx-1 pt-1 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
|
||||
<ui-tooltip v-else text="This backup was created with an old version of audiobookshelf no longer supported" direction="bottom" class="mx-2 flex items-center">
|
||||
<span class="material-icons-outlined text-error">error_outline</span>
|
||||
</ui-tooltip>
|
||||
|
||||
<span class="material-icons text-xl hover:text-error hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="deleteBackupClick(backup)">delete</span>
|
||||
</div>
|
||||
@@ -42,7 +45,7 @@
|
||||
<div v-if="selectedBackup" class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<p class="text-error text-lg font-semibold">Important Notice!</p>
|
||||
<p class="text-base py-1">Applying a backup will overwrite users, user progress, book details, settings, and covers stored in metadata with the backed up data.</p>
|
||||
<p class="text-base py-1">Backups <strong>do not</strong> modify any files in your library folders, only data in the audiobookshelf created <span class="font-mono">/config</span> and <span class="font-mono">/metadata</span> directories.</p>
|
||||
<p class="text-base py-1">Backups <strong>do not</strong> modify any files in your library folders, only data in the audiobookshelf created <span class="font-mono">/config</span> and <span class="font-mono">/metadata</span> directories. If you have enabled server settings to store cover art and metadata in your library folders then those are not backup up or overwritten.</p>
|
||||
<p class="text-base py-1">All clients using your server will be automatically refreshed.</p>
|
||||
|
||||
<p class="text-lg text-center my-8">Are you sure you want to apply the backup created on {{ selectedBackup.datePretty }}?</p>
|
||||
@@ -77,14 +80,24 @@ export default {
|
||||
methods: {
|
||||
confirm() {
|
||||
this.showConfirmApply = false
|
||||
this.$root.socket.once('apply_backup_complete', this.applyBackupComplete)
|
||||
this.$root.socket.emit('apply_backup', this.selectedBackup.id)
|
||||
|
||||
this.$axios
|
||||
.$get(`/api/backups/${this.selectedBackup.id}/apply`)
|
||||
.then(() => {
|
||||
this.isBackingUp = false
|
||||
location.replace('/config/backups?backup=1')
|
||||
})
|
||||
.catch((error) => {
|
||||
this.isBackingUp = false
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to apply backup')
|
||||
})
|
||||
},
|
||||
deleteBackupClick(backup) {
|
||||
if (confirm(`Are you sure you want to delete backup for ${backup.datePretty}?`)) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/backup/${backup.id}`)
|
||||
.$delete(`/api/backups/${backup.id}`)
|
||||
.then((backups) => {
|
||||
console.log('Backup deleted', backups)
|
||||
this.$store.commit('setBackups', backups)
|
||||
@@ -98,29 +111,24 @@ export default {
|
||||
})
|
||||
}
|
||||
},
|
||||
applyBackupComplete(success) {
|
||||
if (success) {
|
||||
// this.$toast.success('Backup Applied, refresh the page')
|
||||
location.replace('/config/backups?backup=1')
|
||||
} else {
|
||||
this.$toast.error('Failed to apply backup')
|
||||
}
|
||||
},
|
||||
applyBackup(backup) {
|
||||
this.selectedBackup = backup
|
||||
this.showConfirmApply = true
|
||||
},
|
||||
backupComplete(backups) {
|
||||
this.isBackingUp = false
|
||||
if (backups) {
|
||||
this.$toast.success('Backup Successful')
|
||||
this.$store.commit('setBackups', backups)
|
||||
} else this.$toast.error('Backup Failed')
|
||||
},
|
||||
clickCreateBackup() {
|
||||
this.isBackingUp = true
|
||||
this.$root.socket.once('backup_complete', this.backupComplete)
|
||||
this.$root.socket.emit('create_backup')
|
||||
this.$axios
|
||||
.$post('/api/backups')
|
||||
.then((backups) => {
|
||||
this.isBackingUp = false
|
||||
this.$toast.success('Backup Successful')
|
||||
this.$store.commit('setBackups', backups)
|
||||
})
|
||||
.catch((error) => {
|
||||
this.isBackingUp = false
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Backup Failed')
|
||||
})
|
||||
},
|
||||
backupUploaded(file) {
|
||||
var form = new FormData()
|
||||
@@ -129,7 +137,7 @@ export default {
|
||||
this.processing = true
|
||||
|
||||
this.$axios
|
||||
.$post('/api/backup/upload', form)
|
||||
.$post('/api/backups/upload', form)
|
||||
.then((result) => {
|
||||
console.log('Upload backup result', result)
|
||||
this.$store.commit('setBackups', result)
|
||||
@@ -171,11 +179,11 @@ export default {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#backups tr:nth-child(even) {
|
||||
#backups tr:nth-child(even):not(.bg-error) {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
#backups tr:not(.staticrow):hover {
|
||||
#backups tr:not(.staticrow):not(.bg-error):hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<p class="font-mono text-sm">{{ books.length }}</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<p v-if="totalDuration">{{ totalDurationPretty }}</p>
|
||||
<!-- <p v-if="totalDuration">{{ totalDurationPretty }}</p> -->
|
||||
</div>
|
||||
<draggable v-model="booksCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
||||
<transition-group type="transition" :name="!drag ? 'collection-book' : null">
|
||||
@@ -56,16 +56,6 @@ export default {
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||
},
|
||||
totalDuration() {
|
||||
var _total = 0
|
||||
this.books.forEach((book) => {
|
||||
_total += book.duration
|
||||
})
|
||||
return _total
|
||||
},
|
||||
totalDurationPretty() {
|
||||
return this.$elapsedPretty(this.totalDuration)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<template>
|
||||
<div class="w-full my-2">
|
||||
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||
<p class="pr-2 md:pr-4">Other Files</p>
|
||||
<p class="pr-2 md:pr-4">Library Files</p>
|
||||
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||
<span class="text-sm font-mono">{{ files.length }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<!-- <nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
||||
</nuxt-link> -->
|
||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-4xl">expand_more</span>
|
||||
@@ -19,22 +16,25 @@
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left px-4">Path</th>
|
||||
<th class="text-left w-24 min-w-24">Size</th>
|
||||
<th class="text-left px-4 w-24">Filetype</th>
|
||||
<th v-if="userCanDownload && !isMissing" class="text-center w-20">Download</th>
|
||||
</tr>
|
||||
<template v-for="file in otherFilesCleaned">
|
||||
<template v-for="file in files">
|
||||
<tr :key="file.path">
|
||||
<td class="font-book pl-2">
|
||||
{{ showFullPath ? file.fullPath : file.path }}
|
||||
<td class="font-book px-4">
|
||||
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(file.metadata.size) }}
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<div class="flex items-center">
|
||||
<span v-if="file.filetype === 'ebook'" class="material-icons text-base mr-1 cursor-pointer text-white text-opacity-60 hover:text-opacity-100" @click="readEbookClick(file)">auto_stories </span>
|
||||
<p>{{ file.filetype }}</p>
|
||||
<p>{{ file.fileType }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="userCanDownload && !isMissing" class="text-center">
|
||||
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
<a :href="`/s/item/${libraryItemId}/${$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -51,10 +51,9 @@ export default {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
libraryItemId: String,
|
||||
isMissing: Boolean,
|
||||
expanded: Boolean // start expanded
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -63,44 +62,20 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
audiobookId() {
|
||||
return this.audiobook.id
|
||||
},
|
||||
audiobookPath() {
|
||||
return this.audiobook.path
|
||||
},
|
||||
otherFilesCleaned() {
|
||||
return this.files.map((file) => {
|
||||
var filePath = file.path.replace(/\\/g, '/')
|
||||
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
|
||||
|
||||
return {
|
||||
...file,
|
||||
relativePath: filePath
|
||||
.replace(audiobookPath + '/', '')
|
||||
.replace(/%/g, '%25')
|
||||
.replace(/#/g, '%23')
|
||||
}
|
||||
})
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
isMissing() {
|
||||
return this.audiobook.isMissing
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
readEbookClick(file) {
|
||||
this.$store.commit('showEReaderForFile', { audiobook: this.audiobook, file })
|
||||
},
|
||||
clickBar() {
|
||||
this.showFiles = !this.showFiles
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
this.showFiles = this.expanded
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div class="w-full my-2">
|
||||
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||
<p class="pr-2 md:pr-4">Audio Tracks</p>
|
||||
<p class="pr-2 md:pr-4">{{ title }}</p>
|
||||
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
||||
</div>
|
||||
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-2 md:mr-4">
|
||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
|
||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
||||
</nuxt-link>
|
||||
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
|
||||
@@ -25,20 +25,20 @@
|
||||
<th class="text-left w-20">Duration</th>
|
||||
<th v-if="userCanDownload" class="text-center w-20">Download</th>
|
||||
</tr>
|
||||
<template v-for="track in tracksCleaned">
|
||||
<template v-for="track in tracks">
|
||||
<tr :key="track.index">
|
||||
<td class="text-center">
|
||||
<p>{{ track.index }}</p>
|
||||
</td>
|
||||
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
|
||||
<td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(track.size) }}
|
||||
{{ $bytesPretty(track.metadata.size) }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="userCanDownload" class="text-center">
|
||||
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
<a :href="`/s/item/${libraryItemId}/${$encodeUriPath(track.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -51,14 +51,15 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Audio Tracks'
|
||||
},
|
||||
tracks: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
libraryItemId: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -67,26 +68,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
audiobookId() {
|
||||
return this.audiobook.id
|
||||
},
|
||||
audiobookPath() {
|
||||
return this.audiobook.path
|
||||
},
|
||||
tracksCleaned() {
|
||||
return this.tracks.map((track) => {
|
||||
var trackPath = track.path.replace(/\\/g, '/')
|
||||
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
|
||||
|
||||
return {
|
||||
...track,
|
||||
relativePath: trackPath
|
||||
.replace(audiobookPath + '/', '')
|
||||
.replace(/%/g, '%25')
|
||||
.replace(/#/g, '%23')
|
||||
}
|
||||
})
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
|
||||
@@ -26,11 +26,11 @@
|
||||
</td>
|
||||
<td class="text-sm">{{ user.type }}</td>
|
||||
<td class="hidden lg:table-cell">
|
||||
<div v-if="usersOnline[user.id] && usersOnline[user.id].stream && usersOnline[user.id].stream.audiobook && usersOnline[user.id].stream.audiobook.book">
|
||||
<p class="truncate text-xs">Reading: {{ usersOnline[user.id].stream.audiobook.book.title || '' }}</p>
|
||||
<div v-if="usersOnline[user.id] && usersOnline[user.id].session && usersOnline[user.id].session.libraryItem && usersOnline[user.id].session.libraryItem.media">
|
||||
<p class="truncate text-xs">Listening: {{ usersOnline[user.id].session.libraryItem.media.metadata.title || '' }}</p>
|
||||
</div>
|
||||
<div v-else-if="user.audiobooks && getLastRead(user.audiobooks)">
|
||||
<p class="truncate text-xs">Last: {{ getLastRead(user.audiobooks) }}</p>
|
||||
<div v-else-if="user.mostRecent">
|
||||
<p class="truncate text-xs">Last: {{ user.mostRecent.metadata.title }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-xs font-mono hidden sm:table-cell">
|
||||
@@ -76,28 +76,13 @@ export default {
|
||||
currentUserId() {
|
||||
return this.$store.state.user.user.id
|
||||
},
|
||||
userStream() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
usersOnline() {
|
||||
var usermap = {}
|
||||
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, stream: u.stream }))
|
||||
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, session: u.session }))
|
||||
return usermap
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getLastRead(audiobooks) {
|
||||
var abs = Object.values(audiobooks).filter((ab) => {
|
||||
return ab.progress > 0
|
||||
})
|
||||
if (abs.length) {
|
||||
abs = abs.sort((a, b) => b.lastUpdate - a.lastUpdate)
|
||||
// Book object is attached on request
|
||||
if (abs[0].book) return abs[0].book.title
|
||||
return abs[0].audiobookTitle ? abs[0].audiobookTitle : null
|
||||
}
|
||||
return null
|
||||
},
|
||||
deleteUserClick(user) {
|
||||
if (this.isDeletingUser) return
|
||||
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-full relative" :style="{ width: coverWidth + 'px' }">
|
||||
<covers-book-cover :audiobook="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="absolute top-0 left-0 bg-black bg-opacity-50 flex items-center justify-center h-full w-full z-10" v-show="isHovering && showPlayBtn">
|
||||
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
|
||||
<span class="material-icons">play_arrow</span>
|
||||
@@ -16,8 +16,7 @@
|
||||
</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="`/library/${book.libraryId}/bookshelf?filter=authors.${$encode(bookAuthor)}`" class="truncate block text-gray-400 text-sm hover:underline">{{ bookAuthor }}</nuxt-link>
|
||||
<nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline">{{ bookTitle }}</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow flex items-center">
|
||||
@@ -28,15 +27,10 @@
|
||||
<span class="material-icons text-lg text-white text-opacity-70 hover:text-opacity-100 cursor-pointer">radio_button_unchecked</span>
|
||||
</div> -->
|
||||
</div>
|
||||
<!-- <div class="absolute top-0 left-0 z-40 bg-red-500 w-full h-full">
|
||||
<div class="w-24 h-full absolute top-0 -right-24 transform transition-transform" :class="isHovering ? 'translate-x-0' : '-translate-x-24'">
|
||||
<span class="material-icons">edit</span>
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : '-translate-x-24'">
|
||||
<div class="flex h-full items-center">
|
||||
<ui-tooltip :text="isRead ? 'Mark as Not Read' : 'Mark as Read'" direction="top">
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="isRead" borderless class="mx-1 mt-0.5" @click="toggleRead" />
|
||||
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||
</ui-tooltip>
|
||||
<div class="mx-1" :class="isHovering ? '' : 'ml-6'">
|
||||
<ui-icon-btn icon="edit" borderless @click="clickEdit" />
|
||||
@@ -68,12 +62,6 @@ export default {
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
userIsRead: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
this.isRead = newVal
|
||||
}
|
||||
},
|
||||
isDragging: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
@@ -83,17 +71,23 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
_book() {
|
||||
return this.book.book || {}
|
||||
media() {
|
||||
return this.book.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
tracks() {
|
||||
return this.media.tracks || []
|
||||
},
|
||||
bookTitle() {
|
||||
return this._book.title || ''
|
||||
return this.mediaMetadata.title || ''
|
||||
},
|
||||
bookAuthor() {
|
||||
return this._book.authorFL || ''
|
||||
return (this.mediaMetadata.authors || []).map((au) => au.name).join(', ')
|
||||
},
|
||||
bookDuration() {
|
||||
return this.$secondsToTimestamp(this.book.duration)
|
||||
return this.$secondsToTimestamp(this.media.duration)
|
||||
},
|
||||
isMissing() {
|
||||
return this.book.isMissing
|
||||
@@ -101,23 +95,17 @@ export default {
|
||||
isInvalid() {
|
||||
return this.book.isInvalid
|
||||
},
|
||||
numTracks() {
|
||||
return this.book.numTracks
|
||||
},
|
||||
isStreaming() {
|
||||
return this.$store.getters['getAudiobookIdStreaming'] === this.book.id
|
||||
return this.$store.getters['getLibraryItemIdStreaming'] === this.book.id
|
||||
},
|
||||
showPlayBtn() {
|
||||
return !this.isMissing && !this.isInvalid && !this.isStreaming && this.numTracks
|
||||
return !this.isMissing && !this.isInvalid && !this.isStreaming && this.tracks.length
|
||||
},
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
||||
itemProgress() {
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.book.id)
|
||||
},
|
||||
userAudiobook() {
|
||||
return this.userAudiobooks[this.book.id] || null
|
||||
},
|
||||
userIsRead() {
|
||||
return this.userAudiobook ? !!this.userAudiobook.isRead : false
|
||||
userIsFinished() {
|
||||
return this.itemProgress ? !!this.itemProgress.isFinished : false
|
||||
},
|
||||
coverWidth() {
|
||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.6
|
||||
@@ -133,26 +121,28 @@ export default {
|
||||
this.isHovering = false
|
||||
},
|
||||
playClick() {
|
||||
this.$eventBus.$emit('play-audiobook', this.book.id)
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.book.id
|
||||
})
|
||||
},
|
||||
clickEdit() {
|
||||
this.$emit('edit', this.book)
|
||||
},
|
||||
toggleRead() {
|
||||
toggleFinished() {
|
||||
var updatePayload = {
|
||||
isRead: !this.isRead
|
||||
isFinished: !this.userIsFinished
|
||||
}
|
||||
this.isProcessingReadUpdate = true
|
||||
this.$axios
|
||||
.$patch(`/api/me/audiobook/${this.book.id}`, updatePayload)
|
||||
.$patch(`/api/me/progress/${this.book.id}`, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.success(`"${this.bookTitle}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
||||
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
},
|
||||
removeClick() {
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
<draggable :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
||||
<template v-for="library in libraryCopies">
|
||||
<div :key="library.id" class="item">
|
||||
<modals-libraries-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
||||
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<modals-edit-library-modal v-model="showLibraryModal" :library="selectedLibrary" />
|
||||
<modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" />
|
||||
|
||||
<p class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
</svg>
|
||||
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="success" @click.stop="scan">Scan</ui-btn>
|
||||
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" class="ml-2" @click.stop="forceScan">Force Re-Scan</ui-btn>
|
||||
<ui-btn v-show="isHovering && !libraryScan" small color="success" @click.stop="scan">Scan</ui-btn>
|
||||
<ui-btn v-show="isHovering && !libraryScan" small color="bg" class="ml-2" @click.stop="forceScan">Force Re-Scan</ui-btn>
|
||||
|
||||
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" class="ml-2" @click.stop="matchAll">Match Books</ui-btn>
|
||||
<ui-btn v-show="isHovering && !libraryScan && isBookLibrary" small color="bg" class="ml-2" @click.stop="matchAll">Match Books</ui-btn>
|
||||
|
||||
<span v-show="isHovering && !libraryScan && showEdit && canEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
|
||||
<span v-show="!libraryScan && isHovering && showEdit && canDelete && !isDeleting" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50 cursor-pointer'" @click.stop="deleteClick">delete</span>
|
||||
<span v-show="isHovering && !libraryScan && showEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
|
||||
<span v-show="!libraryScan && isHovering && showEdit && !isDeleting" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50 cursor-pointer'" @click.stop="deleteClick">delete</span>
|
||||
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
|
||||
<svg viewBox="0 0 24 24" class="w-6 h-6">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
@@ -49,20 +49,17 @@ export default {
|
||||
libraryScan() {
|
||||
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
|
||||
},
|
||||
canEdit() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
mediaType() {
|
||||
return this.library.mediaType
|
||||
},
|
||||
canDelete() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
canScan() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
isBookLibrary() {
|
||||
return this.mediaType === 'book'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
matchAll() {
|
||||
this.$axios
|
||||
.$post(`/api/libraries/${this.library.id}/matchbooks`)
|
||||
.$post(`/api/libraries/${this.library.id}/matchall`)
|
||||
.then(() => {
|
||||
console.log('Starting scan for matches')
|
||||
})
|
||||
@@ -76,10 +73,10 @@ export default {
|
||||
this.$emit('edit', this.library)
|
||||
},
|
||||
scan() {
|
||||
this.$root.socket.emit('scan', this.library.id)
|
||||
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id })
|
||||
},
|
||||
forceScan() {
|
||||
this.$root.socket.emit('scan', this.library.id, { forceRescan: true })
|
||||
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
|
||||
},
|
||||
deleteClick() {
|
||||
if (this.isMain) return
|
||||
175
client/components/tables/podcast/EpisodeTableRow.vue
Normal file
175
client/components/tables/podcast/EpisodeTableRow.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div v-if="episode" class="flex items-center h-24">
|
||||
<div v-show="userCanUpdate" class="w-12 min-w-12 max-w-16 h-full">
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<span class="material-icons drag-handle text-lg text-white text-opacity-50 hover:text-opacity-100">menu</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<p class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ description }}</p>
|
||||
<div class="flex items-center pt-2">
|
||||
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick">
|
||||
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
|
||||
</div>
|
||||
|
||||
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||
</ui-tooltip>
|
||||
<p v-if="episode.episode" class="px-4 text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
||||
<p v-if="publishedAt" class="px-4 text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-24 min-w-24" />
|
||||
</div>
|
||||
<div class="w-24 min-w-24 -right-0 absolute top-0 h-full transform transition-transform" :class="!isHovering ? 'translate-x-32' : 'translate-x-0'">
|
||||
<div class="flex h-full items-center">
|
||||
<div class="mx-1">
|
||||
<ui-icon-btn v-if="userCanUpdate" icon="edit" borderless @click="clickEdit" />
|
||||
</div>
|
||||
<div class="mx-1">
|
||||
<ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!userIsFinished" class="absolute bottom-0 left-0 h-0.5 bg-warning" :style="{ width: itemProgressPercent * 100 + '%' }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItemId: String,
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
isDragging: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessingReadUpdate: false,
|
||||
processingRemove: false,
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
isDragging: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.isHovering = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
audioFile() {
|
||||
return this.episode.audioFile
|
||||
},
|
||||
title() {
|
||||
return this.episode.title || ''
|
||||
},
|
||||
description() {
|
||||
if (this.episode.subtitle) return this.episode.subtitle
|
||||
var desc = this.episode.description || ''
|
||||
return desc
|
||||
},
|
||||
duration() {
|
||||
return this.$secondsToTimestamp(this.episode.duration)
|
||||
},
|
||||
isStreaming() {
|
||||
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
|
||||
},
|
||||
streamIsPlaying() {
|
||||
return this.$store.state.streamIsPlaying && this.isStreaming
|
||||
},
|
||||
itemProgress() {
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episode.id)
|
||||
},
|
||||
itemProgressPercent() {
|
||||
return this.itemProgress ? this.itemProgress.progress : 0
|
||||
},
|
||||
userIsFinished() {
|
||||
return this.itemProgress ? !!this.itemProgress.isFinished : false
|
||||
},
|
||||
timeRemaining() {
|
||||
if (this.streamIsPlaying) return 'Playing'
|
||||
if (!this.itemProgress) return this.$elapsedPretty(this.episode.duration)
|
||||
if (this.userIsFinished) return 'Finished'
|
||||
var remaining = Math.floor(this.itemProgress.duration - this.itemProgress.currentTime)
|
||||
return `${this.$elapsedPretty(remaining)} left`
|
||||
},
|
||||
publishedAt() {
|
||||
return this.episode.publishedAt
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mouseover() {
|
||||
if (this.isDragging) return
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
},
|
||||
clickEdit() {
|
||||
this.$emit('edit', this.episode)
|
||||
},
|
||||
playClick() {
|
||||
if (this.streamIsPlaying) {
|
||||
this.$eventBus.$emit('pause-item')
|
||||
} else {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.episode.id
|
||||
})
|
||||
}
|
||||
},
|
||||
toggleFinished() {
|
||||
var updatePayload = {
|
||||
isFinished: !this.userIsFinished
|
||||
}
|
||||
this.isProcessingReadUpdate = true
|
||||
this.$axios
|
||||
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
},
|
||||
removeClick() {
|
||||
if (confirm(`Are you sure you want to remove episode ${this.title}?\nNote: Does not delete from file system`)) {
|
||||
this.processingRemove = true
|
||||
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}/episode/${this.episode.id}`)
|
||||
.then((updatedPodcast) => {
|
||||
console.log(`Episode removed from podcast`, updatedPodcast)
|
||||
this.$toast.success('Episode removed from podcast')
|
||||
this.processingRemove = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove episode from podcast', error)
|
||||
this.$toast.error('Failed to remove episode from podcast')
|
||||
this.processingRemove = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
152
client/components/tables/podcast/EpisodesTable.vue
Normal file
152
client/components/tables/podcast/EpisodesTable.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="w-full py-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<p class="text-lg mb-0 font-semibold">Episodes</p>
|
||||
<div class="flex-grow" />
|
||||
<controls-episode-sort-select v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" @change="changeSort" />
|
||||
<div v-if="userCanUpdate" class="w-12">
|
||||
<ui-icon-btn v-if="orderChanged" :loading="savingOrder" icon="save" bg-color="primary" class="ml-auto" @click="saveOrder" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
|
||||
<draggable v-model="episodesCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
||||
<transition-group type="transition" :name="!drag ? 'episode' : null">
|
||||
<template v-for="episode in episodesCopy">
|
||||
<tables-podcast-episode-table-row :key="episode.id" :is-dragging="drag" :episode="episode" :library-item-id="libraryItem.id" class="item" :class="drag ? '' : 'episode'" @edit="editEpisode" />
|
||||
</template>
|
||||
</transition-group>
|
||||
</draggable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
draggable
|
||||
},
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
sortKey: 'index',
|
||||
sortDesc: true,
|
||||
drag: false,
|
||||
episodesCopy: [],
|
||||
orderChanged: false,
|
||||
savingOrder: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
libraryItem: {
|
||||
handler(newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dragOptions() {
|
||||
return {
|
||||
animation: 200,
|
||||
group: 'description',
|
||||
ghostClass: 'ghost',
|
||||
disabled: !this.userCanUpdate
|
||||
}
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
episodes() {
|
||||
return this.media.episodes || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
changeSort() {
|
||||
this.episodesCopy.sort((a, b) => {
|
||||
if (this.sortDesc) {
|
||||
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||
}
|
||||
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||
})
|
||||
|
||||
this.orderChanged = this.checkHasOrderChanged()
|
||||
},
|
||||
checkHasOrderChanged() {
|
||||
for (let i = 0; i < this.episodesCopy.length; i++) {
|
||||
var epc = this.episodesCopy[i]
|
||||
var ep = this.episodes[i]
|
||||
if (epc.index != ep.index) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
editEpisode(episode) {
|
||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||
},
|
||||
draggableUpdate() {
|
||||
this.orderChanged = this.checkHasOrderChanged()
|
||||
},
|
||||
async saveOrder() {
|
||||
if (!this.userCanUpdate) return
|
||||
|
||||
this.savingOrder = true
|
||||
|
||||
var episodesUpdate = {
|
||||
episodes: this.episodesCopy.map((b) => b.id)
|
||||
}
|
||||
await this.$axios
|
||||
.$patch(`/api/items/${this.libraryItem.id}/episodes`, episodesUpdate)
|
||||
.then((podcast) => {
|
||||
console.log('Podcast updated', podcast)
|
||||
this.$toast.success('Saved episode order')
|
||||
this.orderChanged = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update podcast', error)
|
||||
this.$toast.error('Failed to save podcast episode order')
|
||||
})
|
||||
this.savingOrder = false
|
||||
},
|
||||
init() {
|
||||
this.episodesCopy = this.episodes.map((ep) => {
|
||||
return {
|
||||
...ep
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.episode-item {
|
||||
transition: all 0.4s ease;
|
||||
}
|
||||
|
||||
.episode-enter-from,
|
||||
.episode-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
.episode-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :class="classList">
|
||||
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
|
||||
<slot />
|
||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||
<!-- <span class="material-icons animate-spin">refresh</span> -->
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<label class="flex justify-start items-center cursor-pointer">
|
||||
<div class="border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
|
||||
<input v-model="selected" type="checkbox" class="opacity-0 absolute cursor-pointer" />
|
||||
<label class="flex justify-start items-center" :class="!disabled ? 'cursor-pointer' : ''">
|
||||
<div class="border-2 rounded flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
|
||||
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
|
||||
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
||||
</div>
|
||||
<div v-if="label" class="select-none pl-1 text-gray-100" :class="labelClass">{{ label }}</div>
|
||||
<div v-if="label" class="select-none text-gray-100" :class="labelClassname">{{ label }}</div>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
@@ -18,10 +18,19 @@ export default {
|
||||
type: String,
|
||||
default: 'white'
|
||||
},
|
||||
borderColor: {
|
||||
type: String,
|
||||
default: 'gray-400'
|
||||
},
|
||||
checkColor: {
|
||||
type: String,
|
||||
default: 'green-500'
|
||||
}
|
||||
},
|
||||
labelClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
disabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -36,15 +45,17 @@ export default {
|
||||
}
|
||||
},
|
||||
wrapperClass() {
|
||||
var classes = [`bg-${this.checkboxBg}`]
|
||||
var classes = [`bg-${this.checkboxBg} border-${this.borderColor}`]
|
||||
if (this.small) classes.push('w-4 h-4')
|
||||
else classes.push('w-6 h-6')
|
||||
|
||||
return classes.join(' ')
|
||||
},
|
||||
labelClass() {
|
||||
if (this.small) return 'text-xs md:text-sm'
|
||||
return ''
|
||||
labelClassname() {
|
||||
if (this.labelClass) return this.labelClass
|
||||
var classes = ['pl-1']
|
||||
if (this.small) classes.push('text-xs md:text-sm')
|
||||
return classes.join(' ')
|
||||
},
|
||||
svgClass() {
|
||||
var classes = [`text-${this.checkColor}`]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
||||
<p class="text-sm font-semibold">{{ label }}</p>
|
||||
<button type="button" :disabled="disabled" class="relative w-full border border-gray-600 rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" :class="small ? 'h-9' : 'h-10'" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<p class="text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</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">expand_more</span>
|
||||
<span class="material-icons">expand_more</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -63,6 +63,16 @@ export default {
|
||||
},
|
||||
selectedText() {
|
||||
return this.selectedItem ? this.selectedItem.text : ''
|
||||
},
|
||||
buttonClass() {
|
||||
var classes = []
|
||||
if (this.small) classes.push('h-9')
|
||||
else classes.push('h-10')
|
||||
|
||||
if (this.disabled) classes.push('cursor-not-allowed border-gray-600 bg-primary bg-opacity-70 border-opacity-70 text-gray-400')
|
||||
else classes.push('cursor-pointer border-gray-600 bg-primary text-gray-100')
|
||||
|
||||
return classes.join(' ')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -33,7 +33,6 @@ export default {
|
||||
var _files = Array.from(e.target.files)
|
||||
if (_files && _files.length) {
|
||||
var file = _files[0]
|
||||
console.log('File', file)
|
||||
this.$emit('change', file)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<template>
|
||||
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" @mousedown.prevent :disabled="disabled" :class="className" @click="clickBtn">
|
||||
<span :class="outlined ? 'material-icons-outlined' : 'material-icons'" :style="{ fontSize }">{{ icon }}</span>
|
||||
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
|
||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span v-else :class="outlined ? 'material-icons-outlined' : 'material-icons'" :style="{ fontSize }">{{ icon }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -14,7 +19,8 @@ export default {
|
||||
default: 'primary'
|
||||
},
|
||||
outlined: Boolean,
|
||||
borderless: Boolean
|
||||
borderless: Boolean,
|
||||
loading: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -34,7 +40,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
clickBtn(e) {
|
||||
if (this.disabled) {
|
||||
if (this.disabled || this.loading) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ul ref="menu" v-show="isFocused && items.length && (itemsToShow.length || currentSearch)" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<ul ref="menu" v-show="isFocused && itemsToShow.length" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in itemsToShow">
|
||||
<li :key="item" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div class="flex items-center">
|
||||
@@ -47,7 +47,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
isFocused: false,
|
||||
currentSearch: null,
|
||||
// currentSearch: null,
|
||||
typingTimeout: null,
|
||||
textInput: null
|
||||
}
|
||||
@@ -70,12 +70,13 @@ export default {
|
||||
}
|
||||
},
|
||||
itemsToShow() {
|
||||
if (!this.currentSearch || !this.textInput || this.textInput === this.input) {
|
||||
return this.items
|
||||
if (!this.editable) return this.items
|
||||
if (!this.textInput || this.textInput === this.input) {
|
||||
return []
|
||||
}
|
||||
return this.items.filter((i) => {
|
||||
var iValue = String(i).toLowerCase()
|
||||
return iValue.includes(this.currentSearch.toLowerCase())
|
||||
return iValue.includes(this.textInput.toLowerCase())
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -83,7 +84,7 @@ export default {
|
||||
keydownInput() {
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.currentSearch = this.textInput
|
||||
// this.currentSearch = this.textInput
|
||||
}, 100)
|
||||
},
|
||||
inputFocus() {
|
||||
@@ -127,11 +128,11 @@ export default {
|
||||
if (val && !this.items.includes(val)) {
|
||||
this.$emit('newItem', val)
|
||||
}
|
||||
this.currentSearch = null
|
||||
// this.currentSearch = null
|
||||
},
|
||||
clickedOption(e, item) {
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
// this.currentSearch = null
|
||||
this.input = item
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
}
|
||||
|
||||
@@ -4,22 +4,15 @@
|
||||
|
||||
<button type="button" :disabled="disabled" class="relative h-full w-full border border-gray-600 rounded shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer bg-primary text-gray-100 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<widgets-library-icon :icon="selected" class="mr-2" />
|
||||
<span class="block truncate text-sm">{{ selectedName }}</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">expand_more</span>
|
||||
<widgets-library-icon :icon="selected" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
||||
<template v-for="type in types">
|
||||
<li :key="type.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="select(type)">
|
||||
<div class="flex items-center px-3">
|
||||
<widgets-library-icon :icon="type.id" class="mr-2" />
|
||||
<span class="font-normal block truncate font-sans text-sm">{{ type.name }}</span>
|
||||
</div>
|
||||
<li :key="type.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400 flex justify-center" id="listbox-option-0" role="option" @click="select(type)">
|
||||
<widgets-library-icon :icon="type.id" />
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
@@ -34,7 +27,7 @@ export default {
|
||||
disabled: Boolean,
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Media Type'
|
||||
default: 'Icon'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -47,23 +40,23 @@ export default {
|
||||
showMenu: false,
|
||||
types: [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default'
|
||||
id: 'database',
|
||||
name: 'Database'
|
||||
},
|
||||
{
|
||||
id: 'audiobooks',
|
||||
id: 'audiobook',
|
||||
name: 'Audiobooks'
|
||||
},
|
||||
{
|
||||
id: 'books',
|
||||
id: 'book',
|
||||
name: 'Books'
|
||||
},
|
||||
{
|
||||
id: 'podcasts',
|
||||
id: 'podcast',
|
||||
name: 'Podcasts'
|
||||
},
|
||||
{
|
||||
id: 'comics',
|
||||
id: 'comic',
|
||||
name: 'Comics'
|
||||
}
|
||||
]
|
||||
@@ -72,7 +65,7 @@ export default {
|
||||
computed: {
|
||||
selected: {
|
||||
get() {
|
||||
return this.value || 'default'
|
||||
return this.value || 'database'
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
@@ -82,7 +75,7 @@ export default {
|
||||
return this.types.find((t) => t.id === this.selected)
|
||||
},
|
||||
selectedName() {
|
||||
return this.selectedItem ? this.selectedItem.name : 'Default'
|
||||
return this.selectedItem ? this.selectedItem.name : 'Database'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -5,7 +5,7 @@
|
||||
<span class="block truncate">{{ label }}</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 class="material-icons text-gray-100" aria-label="User Account" role="button">person</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded-md px-2 py-1 cursor-text" :class="disabled ? 'bg-black-300' : 'bg-primary'" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
|
||||
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
|
||||
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
|
||||
<span v-if="showEdit" class="material-icons text-white hover:text-warning" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
|
||||
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
|
||||
</div>
|
||||
{{ item }}
|
||||
</div>
|
||||
<input ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -47,7 +48,9 @@ export default {
|
||||
default: () => []
|
||||
},
|
||||
label: String,
|
||||
disabled: Boolean
|
||||
disabled: Boolean,
|
||||
readonly: Boolean,
|
||||
showEdit: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -67,7 +70,7 @@ export default {
|
||||
computed: {
|
||||
selected: {
|
||||
get() {
|
||||
return this.value
|
||||
return this.value || []
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
@@ -76,6 +79,13 @@ export default {
|
||||
showMenu() {
|
||||
return this.isFocused
|
||||
},
|
||||
wrapperClass() {
|
||||
var classes = []
|
||||
if (this.disabled) classes.push('bg-black-300')
|
||||
else classes.push('bg-primary')
|
||||
if (!this.readonly) classes.push('cursor-text')
|
||||
return classes.join(' ')
|
||||
},
|
||||
itemsToShow() {
|
||||
if (!this.currentSearch || !this.textInput) {
|
||||
return this.items
|
||||
@@ -88,6 +98,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editItem(item) {
|
||||
this.$emit('edit', item)
|
||||
},
|
||||
keydownInput() {
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
|
||||
@@ -62,7 +62,7 @@ export default {
|
||||
},
|
||||
selectedItems() {
|
||||
return (this.value || []).map((v) => {
|
||||
return this.items.find((i) => i.value === v) || {}
|
||||
return this.items.find((i) => i.value === v) || { text: v, value: v }
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -113,6 +113,7 @@ export default {
|
||||
removeItem(itemValue) {
|
||||
var remaining = this.selected.filter((i) => i !== itemValue)
|
||||
this.$emit('input', remaining)
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.recalcMenuPos()
|
||||
})
|
||||
|
||||
283
client/components/ui/MultiSelectQueryInput.vue
Normal file
283
client/components/ui/MultiSelectQueryInput.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-0.5" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div v-for="item in selected" :key="item.id" class="rounded-full px-2 py-0.5 m-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center justify-center relative min-w-12">
|
||||
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
|
||||
<span v-if="showEdit" class="material-icons text-white hover:text-warning mr-1" style="font-size: 1rem" @click.stop="editItem(item)">edit</span>
|
||||
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.id)">close</span>
|
||||
</div>
|
||||
{{ item[textKey] }}
|
||||
</div>
|
||||
<div v-if="showEdit" class="rounded-full cursor-pointer w-6 h-6 mx-0.5 bg-bg flex items-center justify-center">
|
||||
<span class="material-icons text-white hover:text-success pt-px pr-px" style="font-size: 1.1rem" @click.stop="addItem">add</span>
|
||||
</div>
|
||||
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in itemsToShow">
|
||||
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ item.name }}</span>
|
||||
</div>
|
||||
<span v-if="getIsSelected(item.id)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">checkmark</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
<li v-if="!itemsToShow.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal">No items</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
endpoint: String,
|
||||
label: String,
|
||||
disabled: Boolean,
|
||||
readonly: Boolean,
|
||||
showEdit: Boolean,
|
||||
textKey: {
|
||||
type: String,
|
||||
default: 'name'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
textInput: null,
|
||||
currentSearch: null,
|
||||
searching: false,
|
||||
typingTimeout: null,
|
||||
isFocused: false,
|
||||
menu: null,
|
||||
items: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
showMenu(newVal) {
|
||||
if (newVal) this.setListener()
|
||||
else this.removeListener()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selected: {
|
||||
get() {
|
||||
return this.value || []
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
wrapperClass() {
|
||||
var classes = []
|
||||
if (this.disabled) classes.push('bg-black-300')
|
||||
else classes.push('bg-primary')
|
||||
if (!this.readonly) classes.push('cursor-text')
|
||||
return classes.join(' ')
|
||||
},
|
||||
showMenu() {
|
||||
return this.isFocused && this.currentSearch
|
||||
},
|
||||
itemsToShow() {
|
||||
return this.items
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addItem() {
|
||||
this.$emit('add')
|
||||
},
|
||||
editItem(item) {
|
||||
this.$emit('edit', item)
|
||||
},
|
||||
getIsSelected(itemValue) {
|
||||
return !!this.selected.find((i) => i.id === itemValue)
|
||||
},
|
||||
async search() {
|
||||
if (this.searching) return
|
||||
this.currentSearch = this.textInput
|
||||
this.searching = true
|
||||
var results = await this.$axios.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15&token=${this.userToken}`).catch((error) => {
|
||||
console.error('Failed to get search results', error)
|
||||
return []
|
||||
})
|
||||
this.items = results || []
|
||||
this.searching = false
|
||||
},
|
||||
keydownInput() {
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.search()
|
||||
}, 250)
|
||||
this.setInputWidth()
|
||||
},
|
||||
setInputWidth() {
|
||||
setTimeout(() => {
|
||||
var value = this.$refs.input.value
|
||||
var len = value.length * 7 + 24
|
||||
this.$refs.input.style.width = len + 'px'
|
||||
this.recalcMenuPos()
|
||||
}, 50)
|
||||
},
|
||||
recalcMenuPos() {
|
||||
if (!this.menu) return
|
||||
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
|
||||
if (boundingBox.y > window.innerHeight - 8) {
|
||||
// Input is off the page
|
||||
return this.forceBlur()
|
||||
}
|
||||
var menuHeight = this.menu.clientHeight
|
||||
var top = boundingBox.y + boundingBox.height - 4
|
||||
if (top + menuHeight > window.innerHeight - 20) {
|
||||
// Reverse menu to open upwards
|
||||
top = boundingBox.y - menuHeight - 4
|
||||
}
|
||||
|
||||
this.menu.style.top = top + 'px'
|
||||
this.menu.style.left = boundingBox.x + 'px'
|
||||
this.menu.style.width = boundingBox.width + 'px'
|
||||
},
|
||||
unmountMountMenu() {
|
||||
if (!this.$refs.menu) return
|
||||
this.menu = this.$refs.menu
|
||||
|
||||
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
|
||||
this.menu.remove()
|
||||
document.body.appendChild(this.menu)
|
||||
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
|
||||
this.menu.style.left = boundingBox.x + 'px'
|
||||
this.menu.style.width = boundingBox.width + 'px'
|
||||
},
|
||||
inputFocus() {
|
||||
if (!this.menu) {
|
||||
this.unmountMountMenu()
|
||||
}
|
||||
this.isFocused = true
|
||||
this.$nextTick(this.recalcMenuPos)
|
||||
},
|
||||
inputBlur() {
|
||||
if (!this.isFocused) return
|
||||
|
||||
setTimeout(() => {
|
||||
if (document.activeElement === this.$refs.input) {
|
||||
return
|
||||
}
|
||||
this.isFocused = false
|
||||
if (this.textInput) this.submitForm()
|
||||
}, 50)
|
||||
},
|
||||
focus() {
|
||||
if (this.$refs.input) this.$refs.input.focus()
|
||||
},
|
||||
blur() {
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
},
|
||||
forceBlur() {
|
||||
this.isFocused = false
|
||||
if (this.textInput) this.submitForm()
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
},
|
||||
clickedOption(e, item) {
|
||||
if (e) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
if (this.$refs.input) this.$refs.input.focus()
|
||||
|
||||
var newSelected = null
|
||||
if (this.getIsSelected(item.id)) {
|
||||
newSelected = this.selected.filter((s) => s.id !== item.id)
|
||||
this.$emit('removedItem', item.id)
|
||||
} else {
|
||||
newSelected = this.selected.concat([item])
|
||||
}
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.$emit('input', newSelected)
|
||||
this.$nextTick(() => {
|
||||
this.recalcMenuPos()
|
||||
})
|
||||
},
|
||||
clickWrapper() {
|
||||
if (this.disabled) return
|
||||
if (this.showMenu) {
|
||||
return this.blur()
|
||||
}
|
||||
this.focus()
|
||||
},
|
||||
removeItem(itemId) {
|
||||
var remaining = this.selected.filter((i) => i.id !== itemId)
|
||||
this.$emit('input', remaining)
|
||||
this.$emit('removedItem', itemId)
|
||||
this.$nextTick(() => {
|
||||
this.recalcMenuPos()
|
||||
})
|
||||
},
|
||||
insertNewItem(item) {
|
||||
this.selected.push(item)
|
||||
this.$emit('input', this.selected)
|
||||
this.$emit('newItem', item)
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.$nextTick(() => {
|
||||
this.blur()
|
||||
})
|
||||
},
|
||||
submitForm() {
|
||||
if (!this.textInput) return
|
||||
|
||||
var cleaned = this.textInput.trim()
|
||||
var matchesItem = this.items.find((i) => {
|
||||
return i === cleaned
|
||||
})
|
||||
if (matchesItem) {
|
||||
this.clickedOption(null, matchesItem)
|
||||
} else {
|
||||
this.insertNewItem({
|
||||
id: `new-${Date.now()}`,
|
||||
name: this.textInput
|
||||
})
|
||||
}
|
||||
},
|
||||
scroll() {
|
||||
this.recalcMenuPos()
|
||||
},
|
||||
setListener() {
|
||||
document.addEventListener('scroll', this.scroll, true)
|
||||
},
|
||||
removeListener() {
|
||||
document.removeEventListener('scroll', this.scroll, true)
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {
|
||||
if (this.menu) this.menu.remove()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input {
|
||||
border-style: inherit !important;
|
||||
}
|
||||
input:read-only {
|
||||
color: #aaa;
|
||||
background-color: #444;
|
||||
}
|
||||
</style>
|
||||
156
client/components/ui/QueryInput.vue
Normal file
156
client/components/ui/QueryInput.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'">
|
||||
<input ref="input" v-model="textInput" :disabled="disabled" class="h-full w-full bg-transparent focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ul ref="menu" v-show="isFocused && currentSearch" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ item.name }}</span>
|
||||
</div>
|
||||
<span v-if="isItemSelected(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">checkmark</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
<li v-if="!items.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal">No items</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: String,
|
||||
disabled: Boolean,
|
||||
label: String,
|
||||
endpoint: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isFocused: false,
|
||||
currentSearch: null,
|
||||
typingTimeout: null,
|
||||
textInput: null,
|
||||
searching: false,
|
||||
items: [],
|
||||
selectedItemObject: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
this.textInput = newVal
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
input: {
|
||||
get() {
|
||||
return this.value || ''
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isItemSelected(item) {
|
||||
return !!this.input.toLowerCase() === item.name
|
||||
},
|
||||
async search() {
|
||||
if (this.searching) return
|
||||
this.currentSearch = this.textInput
|
||||
this.searching = true
|
||||
var results = await this.$axios.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15`).catch((error) => {
|
||||
console.error('Failed to get search results', error)
|
||||
return []
|
||||
})
|
||||
this.items = results || []
|
||||
this.searching = false
|
||||
},
|
||||
keydownInput() {
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.search()
|
||||
}, 250)
|
||||
},
|
||||
inputFocus() {
|
||||
this.isFocused = true
|
||||
},
|
||||
blur() {
|
||||
// Handle blur immediately
|
||||
this.isFocused = false
|
||||
if (this.inputName.toLowerCase() !== this.textInput.toLowerCase()) {
|
||||
var val = this.textInput ? this.textInput.trim() : null
|
||||
if (val) {
|
||||
this.submitForm()
|
||||
}
|
||||
}
|
||||
|
||||
if (this.$refs.input) {
|
||||
this.$refs.input.blur()
|
||||
}
|
||||
},
|
||||
inputBlur() {
|
||||
if (!this.isFocused) return
|
||||
|
||||
setTimeout(() => {
|
||||
if (document.activeElement === this.$refs.input) {
|
||||
return
|
||||
}
|
||||
this.isFocused = false
|
||||
if (this.input !== this.textInput) {
|
||||
var val = this.textInput ? this.textInput.trim() : null
|
||||
if (val) {
|
||||
this.setItem(val)
|
||||
}
|
||||
}
|
||||
}, 50)
|
||||
},
|
||||
submitForm() {
|
||||
var val = this.textInput ? this.textInput.trim() : null
|
||||
if (val) {
|
||||
this.setItem(val)
|
||||
}
|
||||
},
|
||||
setItem(itemText) {
|
||||
if (!this.items.find((i) => i.name.toLowerCase() !== val.toLowerCase())) {
|
||||
var newItem = {
|
||||
id: `new-${Date.now()}`,
|
||||
name: val
|
||||
}
|
||||
this.$emit('selected', newItem)
|
||||
this.input = val
|
||||
} else {
|
||||
var item = this.items.find((i) => i.name.toLowerCase() !== val.toLowerCase())
|
||||
this.$emit('selected', item)
|
||||
this.input = item.name
|
||||
}
|
||||
this.currentSearch = null
|
||||
},
|
||||
clickedOption(e, item) {
|
||||
this.textInput = item.name
|
||||
this.currentSearch = null
|
||||
this.input = item.name
|
||||
this.selectedItemObject = item
|
||||
this.$emit('selected', item)
|
||||
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -83,4 +83,7 @@ input:read-only {
|
||||
color: #bbb;
|
||||
background-color: #444;
|
||||
}
|
||||
input::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">
|
||||
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</p>
|
||||
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :type="type" class="w-full" />
|
||||
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" @blur="inputBlurred" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -17,6 +17,7 @@ export default {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
readonly: Boolean,
|
||||
disabled: Boolean
|
||||
},
|
||||
data() {
|
||||
@@ -37,6 +38,9 @@ export default {
|
||||
if (this.$refs.input && this.$refs.input.blur) {
|
||||
this.$refs.input.blur()
|
||||
}
|
||||
},
|
||||
inputBlurred() {
|
||||
this.$emit('blur')
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<textarea v-model="inputValue" :rows="rows" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
|
||||
<textarea ref="input" v-model="inputValue" :rows="rows" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -31,6 +31,11 @@ export default {
|
||||
methods: {
|
||||
change(e) {
|
||||
this.$emit('change', e.target.value)
|
||||
},
|
||||
blur() {
|
||||
if (this.$refs.input && this.$refs.input.blur) {
|
||||
this.$refs.input.blur()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||
<ui-textarea-input v-model="inputValue" :disabled="disabled" :rows="rows" class="w-full" />
|
||||
<ui-textarea-input ref="input" v-model="inputValue" :disabled="disabled" :rows="rows" class="w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -29,7 +29,13 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
methods: {
|
||||
blur() {
|
||||
if (this.$refs.input && this.$refs.input.blur) {
|
||||
this.$refs.input.blur()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
77
client/components/widgets/AudiobookData.vue
Normal file
77
client/components/widgets/AudiobookData.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4">
|
||||
<p class="text-sm mb-2">
|
||||
Missing Parts <span class="text-sm">({{ missingParts.length }})</span>
|
||||
</p>
|
||||
<p class="text-sm font-mono">{{ missingPartChunks.join(', ') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="invalidParts.length" class="bg-error border-red-800 shadow-md p-4">
|
||||
<p class="text-sm mb-2">
|
||||
Invalid Parts <span class="text-sm">({{ invalidParts.length }})</span>
|
||||
</p>
|
||||
<div>
|
||||
<p v-for="part in invalidParts" :key="part.filename" class="text-sm font-mono">{{ part.filename }}: {{ part.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tables-tracks-table :title="`Audiobook Tracks`" :tracks="media.tracks" :library-item-id="libraryItemId" class="mt-6" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItemId: String,
|
||||
media: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
missingPartChunks() {
|
||||
if (this.missingParts === 1) return this.missingParts[0]
|
||||
var chunks = []
|
||||
|
||||
var currentIndex = this.missingParts[0]
|
||||
var currentChunk = [this.missingParts[0]]
|
||||
|
||||
for (let i = 1; i < this.missingParts.length; i++) {
|
||||
var partIndex = this.missingParts[i]
|
||||
if (currentIndex === partIndex - 1) {
|
||||
currentChunk.push(partIndex)
|
||||
currentIndex = partIndex
|
||||
} else {
|
||||
// console.log('Chunk ended', currentChunk.join(', '), currentIndex, partIndex)
|
||||
if (currentChunk.length === 0) {
|
||||
console.error('How is current chunk 0?', currentChunk.join(', '))
|
||||
}
|
||||
chunks.push(currentChunk)
|
||||
currentChunk = [partIndex]
|
||||
currentIndex = partIndex
|
||||
}
|
||||
}
|
||||
if (currentChunk.length) {
|
||||
chunks.push(currentChunk)
|
||||
}
|
||||
chunks = chunks.map((chunk) => {
|
||||
if (chunk.length === 1) return chunk[0]
|
||||
else return `${chunk[0]}-${chunk[chunk.length - 1]}`
|
||||
})
|
||||
return chunks
|
||||
},
|
||||
missingParts() {
|
||||
return this.media.missingParts || []
|
||||
},
|
||||
invalidParts() {
|
||||
return this.media.invalidParts || []
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
350
client/components/widgets/BookDetailsEdit.vue
Normal file
350
client/components/widgets/BookDetailsEdit.vue
Normal file
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<form class="w-full h-full" @submit.prevent="submitForm">
|
||||
<div id="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
|
||||
<div class="flex -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
|
||||
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
||||
</div>
|
||||
<div class="flex-grow px-1 pt-6">
|
||||
<div class="flex justify-center">
|
||||
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="showSeriesForm" class="absolute top-0 left-0 z-20 w-full h-full bg-black bg-opacity-50 rounded-lg flex items-center justify-center" @click="cancelSeriesForm">
|
||||
<div class="absolute top-0 right-0 p-4">
|
||||
<span class="material-icons text-gray-200 hover:text-white text-4xl cursor-pointer">close</span>
|
||||
</div>
|
||||
<form @submit.prevent="submitSeriesForm">
|
||||
<div class="bg-bg rounded-lg p-8" @click.stop>
|
||||
<div class="flex">
|
||||
<div class="flex-grow p-1 min-w-80">
|
||||
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
|
||||
</div>
|
||||
<div class="w-40 p-1">
|
||||
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-2 p-1">
|
||||
<ui-btn type="submit">Save</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedSeries: {},
|
||||
showSeriesForm: false,
|
||||
details: {
|
||||
title: null,
|
||||
subtitle: null,
|
||||
description: null,
|
||||
authors: [],
|
||||
narrators: [],
|
||||
series: [],
|
||||
publishedYear: null,
|
||||
publisher: null,
|
||||
language: null,
|
||||
isbn: null,
|
||||
asin: null,
|
||||
genres: [],
|
||||
explicit: false
|
||||
},
|
||||
newTags: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
libraryItem: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
genres() {
|
||||
return this.filterData.genres || []
|
||||
},
|
||||
tags() {
|
||||
return this.filterData.tags || []
|
||||
},
|
||||
series() {
|
||||
return this.filterData.series || []
|
||||
},
|
||||
narrators() {
|
||||
return this.filterData.narrators || []
|
||||
},
|
||||
filterData() {
|
||||
return this.$store.state.libraries.filterData || {}
|
||||
},
|
||||
existingSeriesNames() {
|
||||
// Only show series names not already selected
|
||||
var alreadySelectedSeriesIds = this.details.series.map((se) => se.id)
|
||||
return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name)
|
||||
},
|
||||
seriesItems: {
|
||||
get() {
|
||||
return this.details.series.map((se) => {
|
||||
return {
|
||||
displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name,
|
||||
...se
|
||||
}
|
||||
})
|
||||
},
|
||||
set(val) {
|
||||
this.details.series = val
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getDetails() {
|
||||
this.forceBlur()
|
||||
return this.checkForChanges()
|
||||
},
|
||||
getTitleAndAuthorName() {
|
||||
this.forceBlur()
|
||||
return {
|
||||
title: this.details.title,
|
||||
author: (this.details.authors || []).map((au) => au.name).join(', ')
|
||||
}
|
||||
},
|
||||
mapBatchDetails(batchDetails) {
|
||||
for (const key in batchDetails) {
|
||||
if (key === 'tags') {
|
||||
this.newTags = [...batchDetails.tags]
|
||||
} else if (key === 'genres' || key === 'narrators') {
|
||||
this.details[key] = [...batchDetails[key]]
|
||||
} else if (key === 'authors' || key === 'series') {
|
||||
this.details[key] = batchDetails[key].map((i) => ({ ...i }))
|
||||
} else {
|
||||
this.details[key] = batchDetails[key]
|
||||
}
|
||||
}
|
||||
},
|
||||
forceBlur() {
|
||||
if (this.$refs.titleInput) this.$refs.titleInput.blur()
|
||||
if (this.$refs.subtitleInput) this.$refs.subtitleInput.blur()
|
||||
if (this.$refs.publishYearInput) this.$refs.publishYearInput.blur()
|
||||
if (this.$refs.descriptionInput) this.$refs.descriptionInput.blur()
|
||||
if (this.$refs.isbnInput) this.$refs.isbnInput.blur()
|
||||
if (this.$refs.asinInput) this.$refs.asinInput.blur()
|
||||
if (this.$refs.publisherInput) this.$refs.publisherInput.blur()
|
||||
if (this.$refs.languageInput) this.$refs.languageInput.blur()
|
||||
|
||||
if (this.$refs.authorsSelect && this.$refs.authorsSelect.isFocused) {
|
||||
this.$refs.authorsSelect.forceBlur()
|
||||
}
|
||||
if (this.$refs.narratorsSelect && this.$refs.narratorsSelect.isFocused) {
|
||||
this.$refs.narratorsSelect.forceBlur()
|
||||
}
|
||||
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
|
||||
this.$refs.genresSelect.forceBlur()
|
||||
}
|
||||
if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {
|
||||
this.$refs.tagsSelect.forceBlur()
|
||||
}
|
||||
},
|
||||
cancelSeriesForm() {
|
||||
this.showSeriesForm = false
|
||||
},
|
||||
editSeriesItem(series) {
|
||||
var _series = this.details.series.find((se) => se.id === series.id)
|
||||
if (!_series) return
|
||||
this.selectedSeries = {
|
||||
..._series
|
||||
}
|
||||
this.showSeriesForm = true
|
||||
},
|
||||
addNewSeries() {
|
||||
this.selectedSeries = {
|
||||
id: `new-${Date.now()}`,
|
||||
name: '',
|
||||
sequence: ''
|
||||
}
|
||||
this.showSeriesForm = true
|
||||
},
|
||||
submitSeriesForm() {
|
||||
if (!this.selectedSeries.name) {
|
||||
this.$toast.error('Must enter a series')
|
||||
return
|
||||
}
|
||||
if (this.$refs.newSeriesSelect) {
|
||||
this.$refs.newSeriesSelect.blur()
|
||||
}
|
||||
var existingSeriesIndex = this.details.series.findIndex((se) => se.id === this.selectedSeries.id)
|
||||
|
||||
var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
|
||||
if (existingSeriesIndex < 0 && seriesSameName) {
|
||||
this.selectedSeries.id = seriesSameName.id
|
||||
}
|
||||
|
||||
if (existingSeriesIndex >= 0) {
|
||||
this.details.series.splice(existingSeriesIndex, 1, { ...this.selectedSeries })
|
||||
} else {
|
||||
this.details.series.push({
|
||||
...this.selectedSeries
|
||||
})
|
||||
}
|
||||
|
||||
this.showSeriesForm = false
|
||||
},
|
||||
stringArrayEqual(array1, array2) {
|
||||
// return false if different
|
||||
if (array1.length !== array2.length) return false
|
||||
for (var item of array1) {
|
||||
if (!array2.includes(item)) return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
objectArrayEqual(array1, array2) {
|
||||
const isIterable = (value) => {
|
||||
return Symbol.iterator in Object(value)
|
||||
}
|
||||
if (!isIterable(array1) || !isIterable(array2)) {
|
||||
console.error(array1, array2)
|
||||
throw new Error('Invalid arrays passed in')
|
||||
}
|
||||
|
||||
// array of objects with id key
|
||||
if (array1.length !== array2.length) return false
|
||||
|
||||
for (var item of array1) {
|
||||
var matchingItem = array2.find((a) => a.id === item.id)
|
||||
if (!matchingItem) return false
|
||||
for (var key in item) {
|
||||
if (item[key] !== matchingItem[key]) {
|
||||
// console.log('Object array item keys changed', key, item[key], matchingItem[key])
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
checkForChanges() {
|
||||
var metadata = {}
|
||||
for (const key in this.details) {
|
||||
var newValue = this.details[key]
|
||||
var oldValue = this.mediaMetadata[key]
|
||||
// Key cleared out or key first populated
|
||||
if ((!newValue && oldValue) || (newValue && !oldValue)) {
|
||||
metadata[key] = newValue
|
||||
} else if (key === 'narrators' || key === 'genres') {
|
||||
// Check array of strings
|
||||
if (!this.stringArrayEqual(newValue, oldValue)) {
|
||||
metadata[key] = [...newValue]
|
||||
}
|
||||
} else if (key === 'authors' || key === 'series') {
|
||||
if (!this.objectArrayEqual(newValue, oldValue)) {
|
||||
metadata[key] = newValue.map((v) => ({ ...v }))
|
||||
}
|
||||
} else if (newValue && newValue != oldValue) {
|
||||
// Intentional !=
|
||||
metadata[key] = newValue
|
||||
}
|
||||
}
|
||||
var updatePayload = {}
|
||||
if (!!Object.keys(metadata).length) updatePayload.metadata = metadata
|
||||
|
||||
if (!this.stringArrayEqual(this.newTags, this.media.tags || [])) {
|
||||
updatePayload.tags = [...this.newTags]
|
||||
}
|
||||
return {
|
||||
updatePayload,
|
||||
hasChanges: !!Object.keys(updatePayload).length
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.details.title = this.mediaMetadata.title
|
||||
this.details.subtitle = this.mediaMetadata.subtitle
|
||||
this.details.description = this.mediaMetadata.description
|
||||
this.details.authors = (this.mediaMetadata.authors || []).map((se) => ({ ...se }))
|
||||
this.details.narrators = [...(this.mediaMetadata.narrators || [])]
|
||||
this.details.genres = [...(this.mediaMetadata.genres || [])]
|
||||
this.details.series = (this.mediaMetadata.series || []).map((se) => ({ ...se }))
|
||||
this.details.publishedYear = this.mediaMetadata.publishedYear
|
||||
this.details.publisher = this.mediaMetadata.publisher || null
|
||||
this.details.language = this.mediaMetadata.language || null
|
||||
this.details.isbn = this.mediaMetadata.isbn || null
|
||||
this.details.asin = this.mediaMetadata.asin || null
|
||||
this.details.explicit = !!this.mediaMetadata.explicit
|
||||
this.newTags = [...(this.media.tags || [])]
|
||||
},
|
||||
submitForm() {
|
||||
this.$emit('submit')
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
|
||||
<span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize">remove</span>
|
||||
<span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize" aria-label="Decrease Cover Size" role="button">remove</span>
|
||||
<p class="px-2 font-mono">{{ bookCoverWidth }}</p>
|
||||
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span>
|
||||
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize" aria-label="Increase Cover Size" role="button">add</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
241
client/components/widgets/LoadingSpinner.vue
Normal file
241
client/components/widgets/LoadingSpinner.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="la-ball-spin-clockwise" :class="`${size}`">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
size: {
|
||||
type: String,
|
||||
default: 'la-sm'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
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>
|
||||
233
client/components/widgets/PodcastDetailsEdit.vue
Normal file
233
client/components/widgets/PodcastDetailsEdit.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<form class="w-full h-full" @submit.prevent="submitForm">
|
||||
<div id="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
|
||||
<div class="flex -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label ref="authorInput" v-model="details.author" label="Author" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" label="RSS Feed URL" class="mt-2" />
|
||||
|
||||
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" label="Release Date" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
||||
</div>
|
||||
<div class="flex-grow px-1 pt-6">
|
||||
<div class="flex justify-center">
|
||||
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow px-1 pt-6">
|
||||
<ui-checkbox v-model="autoDownloadEpisodes" label="Auto Download New Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
details: {
|
||||
title: null,
|
||||
author: null,
|
||||
description: null,
|
||||
releaseDate: null,
|
||||
genres: [],
|
||||
feedUrl: null,
|
||||
imageUrl: null,
|
||||
itunesPageUrl: null,
|
||||
itunesId: null,
|
||||
itunesArtistId: null,
|
||||
explicit: false,
|
||||
language: null
|
||||
},
|
||||
autoDownloadEpisodes: false,
|
||||
newTags: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
libraryItem: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
genres() {
|
||||
return this.filterData.genres || []
|
||||
},
|
||||
tags() {
|
||||
return this.filterData.tags || []
|
||||
},
|
||||
filterData() {
|
||||
return this.$store.state.libraries.filterData || {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getDetails() {
|
||||
this.forceBlur()
|
||||
return this.checkForChanges()
|
||||
},
|
||||
getTitleAndAuthorName() {
|
||||
this.forceBlur()
|
||||
return {
|
||||
title: this.details.title,
|
||||
author: this.details.author
|
||||
}
|
||||
},
|
||||
mapBatchDetails(batchDetails) {
|
||||
for (const key in batchDetails) {
|
||||
if (key === 'tags') {
|
||||
this.newTags = [...batchDetails.tags]
|
||||
} else if (key === 'genres') {
|
||||
this.details[key] = [...batchDetails[key]]
|
||||
} else {
|
||||
this.details[key] = batchDetails[key]
|
||||
}
|
||||
}
|
||||
},
|
||||
forceBlur() {
|
||||
if (this.$refs.titleInput) this.$refs.titleInput.blur()
|
||||
if (this.$refs.authorInput) this.$refs.authorInput.blur()
|
||||
if (this.$refs.releaseDateInput) this.$refs.releaseDateInput.blur()
|
||||
if (this.$refs.descriptionInput) this.$refs.descriptionInput.blur()
|
||||
if (this.$refs.feedUrlInput) this.$refs.feedUrlInput.blur()
|
||||
if (this.$refs.itunesIdInput) this.$refs.itunesIdInput.blur()
|
||||
if (this.$refs.languageInput) this.$refs.languageInput.blur()
|
||||
|
||||
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
|
||||
this.$refs.genresSelect.forceBlur()
|
||||
}
|
||||
if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {
|
||||
this.$refs.tagsSelect.forceBlur()
|
||||
}
|
||||
},
|
||||
stringArrayEqual(array1, array2) {
|
||||
// return false if different
|
||||
if (array1.length !== array2.length) return false
|
||||
for (var item of array1) {
|
||||
if (!array2.includes(item)) return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
objectArrayEqual(array1, array2) {
|
||||
const isIterable = (value) => {
|
||||
return Symbol.iterator in Object(value)
|
||||
}
|
||||
if (!isIterable(array1) || !isIterable(array2)) {
|
||||
console.error(array1, array2)
|
||||
throw new Error('Invalid arrays passed in')
|
||||
}
|
||||
|
||||
// array of objects with id key
|
||||
if (array1.length !== array2.length) return false
|
||||
|
||||
for (var item of array1) {
|
||||
var matchingItem = array2.find((a) => a.id === item.id)
|
||||
if (!matchingItem) return false
|
||||
for (var key in item) {
|
||||
if (item[key] !== matchingItem[key]) {
|
||||
// console.log('Object array item keys changed', key, item[key], matchingItem[key])
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
checkForChanges() {
|
||||
var metadata = {}
|
||||
for (const key in this.details) {
|
||||
var newValue = this.details[key]
|
||||
var oldValue = this.mediaMetadata[key]
|
||||
// Key cleared out or key first populated
|
||||
if ((!newValue && oldValue) || (newValue && !oldValue)) {
|
||||
metadata[key] = newValue
|
||||
} else if (key === 'genres') {
|
||||
// Check array of strings
|
||||
if (!this.stringArrayEqual(newValue, oldValue)) {
|
||||
metadata[key] = [...newValue]
|
||||
}
|
||||
} else if (newValue && newValue != oldValue) {
|
||||
// Intentional !=
|
||||
metadata[key] = newValue
|
||||
}
|
||||
}
|
||||
var updatePayload = {}
|
||||
if (!!Object.keys(metadata).length) updatePayload.metadata = metadata
|
||||
|
||||
if (!this.stringArrayEqual(this.newTags, this.media.tags || [])) {
|
||||
updatePayload.tags = [...this.newTags]
|
||||
}
|
||||
|
||||
if (this.media.autoDownloadEpisodes !== this.autoDownloadEpisodes) {
|
||||
updatePayload.autoDownloadEpisodes = !!this.autoDownloadEpisodes
|
||||
}
|
||||
|
||||
return {
|
||||
updatePayload,
|
||||
hasChanges: !!Object.keys(updatePayload).length
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.details.title = this.mediaMetadata.title
|
||||
this.details.author = this.mediaMetadata.author || ''
|
||||
this.details.description = this.mediaMetadata.description || ''
|
||||
this.details.releaseDate = this.mediaMetadata.releaseDate || ''
|
||||
this.details.genres = [...(this.mediaMetadata.genres || [])]
|
||||
this.details.feedUrl = this.mediaMetadata.feedUrl || ''
|
||||
this.details.imageUrl = this.mediaMetadata.imageUrl || ''
|
||||
this.details.itunesPageUrl = this.mediaMetadata.itunesPageUrl || ''
|
||||
this.details.itunesId = this.mediaMetadata.itunesId || ''
|
||||
this.details.itunesArtistId = this.mediaMetadata.itunesArtistId || ''
|
||||
this.details.language = this.mediaMetadata.language || ''
|
||||
this.details.explicit = !!this.mediaMetadata.explicit
|
||||
|
||||
this.autoDownloadEpisodes = !!this.media.autoDownloadEpisodes
|
||||
this.newTags = [...(this.media.tags || [])]
|
||||
},
|
||||
submitForm() {
|
||||
this.$emit('submit')
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user