mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-26 19:21:39 +00:00
Merge branch 'master' into social
This commit is contained in:
commit
92c393d2b6
351 changed files with 53592 additions and 1765 deletions
|
|
@ -3,14 +3,14 @@
|
|||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
|
||||
<div class="flex h-full items-center">
|
||||
<nuxt-link to="/">
|
||||
<img src="/icon48.png" class="w-8 h-8 mr-8 sm:w-12 sm:h-12 sm:mr-4" />
|
||||
<img src="/icon.svg" class="w-10 min-w-10 h-10 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/">
|
||||
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
|
||||
</nuxt-link>
|
||||
|
||||
<ui-libraries-dropdown />
|
||||
<ui-libraries-dropdown class="mr-2" />
|
||||
|
||||
<controls-global-search v-if="currentLibrary" class="" />
|
||||
<div class="flex-grow" />
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
<!-- Regular bookshelf view -->
|
||||
<div v-else class="w-full">
|
||||
<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" />
|
||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -70,11 +70,8 @@ export default {
|
|||
libraryName() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||
},
|
||||
bookshelfView() {
|
||||
return this.$store.getters['getServerSetting']('bookshelfView')
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||
return this.$store.getters['getHomeBookshelfView'] === this.$constants.BookshelfView.TITLES
|
||||
},
|
||||
bookCoverWidth() {
|
||||
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
|
|
@ -82,13 +79,10 @@ export default {
|
|||
return coverSize
|
||||
},
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
isCoverSquareAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.isCoverSquareAspectRatio ? 1 : 1.6
|
||||
return this.coverAspectRatio == 1
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
|
|
|
|||
|
|
@ -11,12 +11,14 @@
|
|||
|
||||
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
|
||||
<div class="flex justify-between">
|
||||
<p class="font-mono text-sm">v{{ $config.version }}</p>
|
||||
<p class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</p>
|
||||
|
||||
<p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p>
|
||||
</div>
|
||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
|
||||
</div>
|
||||
|
||||
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -26,7 +28,9 @@ export default {
|
|||
isOpen: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
showChangelogModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
Source() {
|
||||
|
|
@ -129,18 +133,24 @@ export default {
|
|||
githubTagUrl() {
|
||||
return this.versionData.githubTagUrl
|
||||
},
|
||||
currentVersionChangelog() {
|
||||
return this.versionData.currentVersionChangelog || 'No Changelog Available'
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickChangelog(){
|
||||
this.showChangelogModal = true
|
||||
},
|
||||
clickOutside() {
|
||||
if (!this.isOpen) return
|
||||
this.closeDrawer()
|
||||
},
|
||||
closeDrawer() {
|
||||
this.$emit('update:isOpen', false)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -88,6 +88,7 @@ export default {
|
|||
if (this.page === 'collections') return "You haven't made any collections yet"
|
||||
if (this.hasFilter) {
|
||||
if (this.filterName === 'Issues') return 'No Issues'
|
||||
else if (this.filterName === 'Feed-open') return 'No RSS feeds are open'
|
||||
return `No Results for filter "${this.filterName}: ${this.filterValue}"`
|
||||
}
|
||||
return 'No results'
|
||||
|
|
@ -109,24 +110,20 @@ export default {
|
|||
return this.$store.getters['user/getUserSetting']('collapseSeries')
|
||||
},
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
bookshelfView() {
|
||||
return this.$store.getters['getServerSetting']('bookshelfView')
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
sortingIgnorePrefix() {
|
||||
return this.$store.getters['getServerSetting']('sortingIgnorePrefix')
|
||||
},
|
||||
isCoverSquareAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||
return this.coverAspectRatio == 1
|
||||
},
|
||||
bookshelfView() {
|
||||
return this.$store.getters['getBookshelfView']
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
// if (!this.isEntityBook) return false // Only used for bookshelf showing books
|
||||
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.isCoverSquareAspectRatio ? 1 : 1.6
|
||||
},
|
||||
hasFilter() {
|
||||
return this.filterBy && this.filterBy !== 'all'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -83,17 +83,21 @@
|
|||
</nuxt-link>
|
||||
|
||||
<div class="w-full h-12 px-1 py-2 border-t border-black border-opacity-20 absolute left-0" :style="{ bottom: streamLibraryItem ? '240px' : '65px' }">
|
||||
<p class="font-mono text-xs text-center text-gray-300 leading-3 mb-1">v{{ $config.version }}</p>
|
||||
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p>
|
||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
|
||||
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
||||
</div>
|
||||
|
||||
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
showChangelogModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
Source() {
|
||||
|
|
@ -171,17 +175,21 @@ export default {
|
|||
hasUpdate() {
|
||||
return !!this.versionData.hasUpdate
|
||||
},
|
||||
latestVersion() {
|
||||
return this.versionData.latestVersion
|
||||
},
|
||||
githubTagUrl() {
|
||||
return this.versionData.githubTagUrl
|
||||
},
|
||||
currentVersionChangelog() {
|
||||
return this.versionData.currentVersionChangelog || 'No Changelog Available'
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
methods: {
|
||||
clickChangelog(){
|
||||
this.showChangelogModal = true
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
<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">
|
||||
<div id="videoDock" />
|
||||
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-1 sm:left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||
</nuxt-link>
|
||||
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-20 sm:pl-24'">
|
||||
<div>
|
||||
|
|
@ -77,16 +77,13 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
bookCoverWidth() {
|
||||
return 88
|
||||
},
|
||||
bookCoverPosTop() {
|
||||
if (this.bookCoverAspectRatio === 1) return -10
|
||||
if (this.coverAspectRatio == 1) return -10
|
||||
return -64
|
||||
},
|
||||
cover() {
|
||||
|
|
@ -364,7 +361,11 @@ export default {
|
|||
var episodeId = payload.episodeId || null
|
||||
|
||||
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
|
||||
this.playerHandler.play()
|
||||
if (payload.startTime !== null && !isNaN(payload.startTime)) {
|
||||
this.seek(payload.startTime)
|
||||
} else {
|
||||
this.playerHandler.play()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -377,7 +378,11 @@ export default {
|
|||
libraryItem,
|
||||
episodeId
|
||||
})
|
||||
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
||||
})
|
||||
|
||||
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate, payload.startTime)
|
||||
},
|
||||
pauseItem() {
|
||||
this.playerHandler.pause()
|
||||
|
|
@ -389,11 +394,13 @@ export default {
|
|||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
||||
this.$eventBus.$on('playback-seek', this.seek)
|
||||
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('playback-seek', this.seek)
|
||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
<template>
|
||||
<div class="w-full border-b border-gray-700 pb-2">
|
||||
<div v-if="book" 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">
|
||||
<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 class="text-base">{{ book.title }}</h1>
|
||||
<div class="flex-grow" />
|
||||
<p>{{ book.publishedYear }}</p>
|
||||
<div class="min-w-12 max-w-12 md:min-w-20 md:max-w-20">
|
||||
<div class="w-full bg-primary">
|
||||
<img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
<p class="text-gray-300 text-sm">{{ book.author }}</p>
|
||||
</div>
|
||||
<div v-if="!isPodcast" class="px-2 md:px-4 flex-grow">
|
||||
<div class="flex items-center">
|
||||
<h1 class="text-sm md:text-base">{{ book.title }}</h1>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-sm md:text-base">{{ book.publishedYear }}</p>
|
||||
</div>
|
||||
<p class="text-gray-300 text-xs md:text-sm">by {{ book.author }}</p>
|
||||
<p v-if="book.narrator" class="text-gray-400 text-xs">Narrated by {{ book.narrator }}</p>
|
||||
<p v-if="book.duration" class="text-gray-400 text-xs">Runtime: {{ $elapsedPrettyExtended(book.duration * 60) }}</p>
|
||||
<div v-if="book.series && book.series.length" class="flex py-1 -mx-1">
|
||||
<div v-for="(series, index) in book.series" :key="index" class="bg-white bg-opacity-10 rounded-full px-1 py-0.5 mx-1">
|
||||
<p class="leading-3 text-xs text-gray-400">
|
||||
|
|
@ -25,7 +29,7 @@
|
|||
<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 v-if="book.genres" 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>
|
||||
|
|
@ -63,7 +67,7 @@ export default {
|
|||
selectMatch() {
|
||||
var book = { ...this.book }
|
||||
book.cover = this.selectedCover
|
||||
this.$emit('select', this.book)
|
||||
this.$emit('select', book)
|
||||
},
|
||||
clickCover(cover) {
|
||||
this.selectedCover = cover
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
coverWidth() {
|
||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@
|
|||
</div>
|
||||
</ui-tooltip>
|
||||
|
||||
<div v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.5 + 'rem' }">rss_feed</span>
|
||||
</div>
|
||||
|
||||
<!-- Series sequence -->
|
||||
<div v-if="seriesSequence && !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>
|
||||
|
|
@ -249,14 +253,14 @@ export default {
|
|||
},
|
||||
displayTitle() {
|
||||
if (this.recentEpisode) return this.recentEpisode.title
|
||||
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) {
|
||||
return this.mediaMetadata.titleIgnorePrefix
|
||||
}
|
||||
return this.title
|
||||
const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
|
||||
if (this.collapsedSeries) return ignorePrefix ? this.collapsedSeries.nameIgnorePrefix : this.collapsedSeries.name
|
||||
return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix : this.title
|
||||
},
|
||||
displayLineTwo() {
|
||||
if (this.recentEpisode) return this.title
|
||||
if (this.isPodcast) return this.author
|
||||
if (this.collapsedSeries) return ''
|
||||
if (this.isAuthorBookshelfView) {
|
||||
return this.mediaMetadata.publishedYear || ''
|
||||
}
|
||||
|
|
@ -264,6 +268,7 @@ export default {
|
|||
return this.author
|
||||
},
|
||||
displaySortLine() {
|
||||
if (this.collapsedSeries) return null
|
||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat)
|
||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat)
|
||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt, this.dateFormat)
|
||||
|
|
@ -443,6 +448,10 @@ export default {
|
|||
if (!this.isAlternativeBookshelfView && !this.isAuthorBookshelfView) return 0
|
||||
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
||||
return 4.25 * this.sizeMultiplier
|
||||
},
|
||||
rssFeed() {
|
||||
if (this.booksInSeries) return null
|
||||
return this.store.getters['feeds/getFeedForItem'](this.libraryItemId)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -499,7 +508,21 @@ export default {
|
|||
}
|
||||
this.$emit('edit', this.libraryItem)
|
||||
},
|
||||
toggleFinished() {
|
||||
toggleFinished(confirmed = false) {
|
||||
if (!this.itemIsFinished && this.userProgressPercent > 0 && !confirmed) {
|
||||
const payload = {
|
||||
message: `Are you sure you want to mark "${this.displayTitle}" as finished?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.toggleFinished(true)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.store.commit('globals/setConfirmPrompt', payload)
|
||||
return
|
||||
}
|
||||
|
||||
var updatePayload = {
|
||||
isFinished: !this.itemIsFinished
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
|
||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" :group-to="seriesBooksRoute" />
|
||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" :group-to="seriesBooksRoute" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
|
@ -10,16 +10,16 @@
|
|||
<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>
|
||||
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!isAlternativeBookshelfView" 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>
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -39,7 +39,8 @@ export default {
|
|||
seriesMount: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
sortingIgnorePrefix: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -65,6 +66,13 @@ export default {
|
|||
title() {
|
||||
return this.series ? this.series.name : ''
|
||||
},
|
||||
nameIgnorePrefix() {
|
||||
return this.series ? this.series.nameIgnorePrefix : ''
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title
|
||||
return this.title
|
||||
},
|
||||
books() {
|
||||
return this.series ? this.series.books || [] : []
|
||||
},
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
name() {
|
||||
return this.series.name
|
||||
|
|
|
|||
|
|
@ -116,6 +116,11 @@ export default {
|
|||
text: 'Issues',
|
||||
value: 'issues',
|
||||
sublist: false
|
||||
},
|
||||
{
|
||||
text: 'RSS Feed Open',
|
||||
value: 'feed-open',
|
||||
sublist: false
|
||||
}
|
||||
],
|
||||
podcastItems: [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="sm:w-80 w-full sm:ml-6 relative">
|
||||
<div class="sm:w-80 w-full relative">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ export default {
|
|||
text: 'Author (Last, First)',
|
||||
value: 'media.metadata.authorNameLF'
|
||||
},
|
||||
{
|
||||
text: 'Published Year',
|
||||
value: 'media.metadata.publishedYear'
|
||||
},
|
||||
{
|
||||
text: 'Added At',
|
||||
value: 'addedAt'
|
||||
|
|
|
|||
|
|
@ -125,6 +125,9 @@ export default {
|
|||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
resolution() {
|
||||
return `${this.naturalWidth}x${this.naturalHeight}px`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * bookCoverAspectRatio + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||
<div class="w-full h-full relative">
|
||||
<div class="relative rounded-sm" :style="{ height: width * bookCoverAspectRatio + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||
<div class="w-full h-full relative overflow-hidden">
|
||||
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
||||
<div class="absolute cover-bg" ref="coverBg" />
|
||||
</div>
|
||||
|
|
@ -17,6 +17,8 @@
|
|||
<p class="text-center font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!imageFailed" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -54,6 +56,9 @@ export default {
|
|||
},
|
||||
placeholderCoverPadding() {
|
||||
return 0.8 * this.sizeMultiplier
|
||||
},
|
||||
resolution() {
|
||||
return `${this.naturalWidth}x${this.naturalHeight}px`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -146,7 +146,6 @@ export default {
|
|||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
console.log('accoutn modal show change', newVal)
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
|
|
@ -162,6 +161,9 @@ export default {
|
|||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
title() {
|
||||
return this.isNew ? 'Add New Account' : `Update ${(this.account || {}).username}`
|
||||
},
|
||||
|
|
@ -250,6 +252,12 @@ export default {
|
|||
this.$toast.error(`Failed to update account: ${data.error}`)
|
||||
} else {
|
||||
console.log('Account updated', data.user)
|
||||
|
||||
if (data.user.id === this.user.id && data.user.token !== this.user.token) {
|
||||
console.log('Current user token was updated')
|
||||
this.$store.commit('user/setUserToken', data.user.token)
|
||||
}
|
||||
|
||||
this.$toast.success('Account updated')
|
||||
this.show = false
|
||||
}
|
||||
|
|
@ -305,7 +313,6 @@ export default {
|
|||
|
||||
this.isNew = !this.account
|
||||
if (this.account) {
|
||||
console.log(this.account)
|
||||
this.newUser = {
|
||||
username: this.account.username,
|
||||
password: this.account.password,
|
||||
|
|
|
|||
91
client/components/modals/BackupScheduleModal.vue
Normal file
91
client/components/modals/BackupScheduleModal.vue
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<template>
|
||||
<modals-modal v-model="show" name="backup-scheduler" :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">Set Backup Schedule</p>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="show && newCronExpression" 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">
|
||||
<widgets-cron-expression-builder ref="expressionBuilder" v-model="newCronExpression" @input="expressionUpdated" />
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<ui-btn :disabled="!isUpdated" @click="submit">{{ isUpdated ? 'Save Backup Schedule' : 'No update necessary' }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
cronExpression: {
|
||||
type: String,
|
||||
default: '* * * * *'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newCronExpression: null,
|
||||
isUpdated: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
expressionUpdated() {
|
||||
this.isUpdated = this.newCronExpression !== this.cronExpression
|
||||
},
|
||||
init() {
|
||||
this.newCronExpression = this.cronExpression
|
||||
this.isUpdated = false
|
||||
},
|
||||
submit() {
|
||||
// If custom expression input is focused then unfocus it instead of submitting
|
||||
if (this.$refs.expressionBuilder && this.$refs.expressionBuilder.checkBlurExpressionInput) {
|
||||
if (this.$refs.expressionBuilder.checkBlurExpressionInput()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
|
||||
var updatePayload = {
|
||||
backupSchedule: this.newCronExpression
|
||||
}
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', updatePayload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
this.processing = false
|
||||
this.show = false
|
||||
this.$emit('update:cronExpression', this.newCronExpression)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
<template>
|
||||
<modals-modal v-model="show" name="bookmarks" :width="500" :height="'unset'">
|
||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<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">Your Bookmarks</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div v-if="show" class="w-full h-full">
|
||||
<template v-for="bookmark in bookmarks">
|
||||
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @update="submitUpdateBookmark" @delete="deleteBookmark" />
|
||||
|
|
@ -8,8 +13,8 @@
|
|||
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
|
||||
<p class="text-xl">No Bookmarks</p>
|
||||
</div>
|
||||
<div class="w-full h-px bg-white bg-opacity-10" />
|
||||
<form @submit.prevent="submitCreateBookmark">
|
||||
<div v-if="!hideCreate" class="w-full h-px bg-white bg-opacity-10" />
|
||||
<form v-if="!hideCreate" @submit.prevent="submitCreateBookmark">
|
||||
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
|
||||
<div class="w-16 max-w-16 text-center">
|
||||
<p class="text-sm font-mono text-gray-400">
|
||||
|
|
@ -39,7 +44,8 @@ export default {
|
|||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
libraryItemId: String
|
||||
libraryItemId: String,
|
||||
hideCreate: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export default {
|
|||
}
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
collection() {
|
||||
return this.$store.state.globals.selectedCollection || {}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@
|
|||
<div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
|
||||
<div class="flex">
|
||||
<div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80">
|
||||
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
|
||||
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!isNewSeries" label="Series Name" />
|
||||
</div>
|
||||
<div class="w-24 sm:w-28 md:w-40 p-1">
|
||||
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
|
||||
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" label="Sequence" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-2 p-1">
|
||||
|
|
@ -59,9 +59,26 @@ export default {
|
|||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
isNewSeries() {
|
||||
if (!this.selectedSeries || !this.selectedSeries.id) return false
|
||||
return this.selectedSeries.id.startsWith('new')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setInputFocus() {
|
||||
if (this.isNewSeries) {
|
||||
// Focus on series input if new series
|
||||
if (this.$refs.newSeriesSelect) {
|
||||
this.$refs.newSeriesSelect.setFocus()
|
||||
}
|
||||
} else {
|
||||
// Focus on sequence input if existing series
|
||||
if (this.$refs.sequenceInput) {
|
||||
this.$refs.sequenceInput.setFocus()
|
||||
}
|
||||
}
|
||||
},
|
||||
submitSeriesForm() {
|
||||
if (this.$refs.newSeriesSelect) {
|
||||
this.$refs.newSeriesSelect.blur()
|
||||
|
|
@ -89,15 +106,15 @@ export default {
|
|||
setTimeout(() => {
|
||||
this.content.style.transform = 'scale(1)'
|
||||
}, 10)
|
||||
document.documentElement.classList.add('modal-open')
|
||||
|
||||
this.$store.commit('setInnerModalOpen', true)
|
||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||
|
||||
this.setInputFocus()
|
||||
},
|
||||
setHide() {
|
||||
if (this.content) this.content.style.transform = 'scale(0)'
|
||||
if (this.el) this.el.remove()
|
||||
document.documentElement.classList.remove('modal-open')
|
||||
|
||||
this.$store.commit('setInnerModalOpen', false)
|
||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<modals-modal v-model="show" name="listening-session-modal" :width="700" :height="'unset'">
|
||||
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
|
||||
<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">Session {{ _session.id }}</p>
|
||||
|
|
@ -96,6 +96,10 @@
|
|||
<p v-if="deviceInfo.deviceType" class="mb-1">Type: {{ deviceInfo.deviceType }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<ui-btn small color="error" @click.stop="deleteSessionClick">Delete</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
|
@ -110,7 +114,9 @@ export default {
|
|||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
|
|
@ -147,7 +153,37 @@ export default {
|
|||
return 'Unknown'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
methods: {
|
||||
deleteSessionClick() {
|
||||
const payload = {
|
||||
message: `Are you sure you want to delete this session?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.deleteSession()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
deleteSession() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/sessions/${this._session.id}`)
|
||||
.then(() => {
|
||||
this.processing = false
|
||||
this.$toast.success('Session deleted successfully')
|
||||
this.$emit('removedSession')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
this.processing = false
|
||||
console.error('Failed to delete session', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(errMsg || 'Failed to delete session')
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -50,7 +50,8 @@ export default {
|
|||
return {
|
||||
el: null,
|
||||
content: null,
|
||||
preventClickoutside: false
|
||||
preventClickoutside: false,
|
||||
isShowingPrompt: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
|
@ -93,7 +94,7 @@ export default {
|
|||
this.show = false
|
||||
},
|
||||
clickBg(ev) {
|
||||
if (!this.show) return
|
||||
if (!this.show || this.isShowingPrompt) return
|
||||
if (this.preventClickoutside) {
|
||||
this.preventClickoutside = false
|
||||
return
|
||||
|
|
@ -147,8 +148,16 @@ export default {
|
|||
} else {
|
||||
console.warn('Invalid modal init', this.name)
|
||||
}
|
||||
},
|
||||
showingPrompt(isShowing) {
|
||||
this.isShowingPrompt = isShowing
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
this.$eventBus.$on('showing-prompt', this.showingPrompt)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('showing-prompt', this.showingPrompt)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -70,7 +70,7 @@ export default {
|
|||
return this.selectedLibraryItem ? this.selectedLibraryItem.media.metadata.title : ''
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
selectedLibraryItem() {
|
||||
return this.$store.state.selectedLibraryItem
|
||||
|
|
|
|||
|
|
@ -116,6 +116,9 @@ export default {
|
|||
if (result.updated) {
|
||||
this.$toast.success('Author updated')
|
||||
this.show = false
|
||||
} else if (result.merged) {
|
||||
this.$toast.success('Author merged')
|
||||
this.show = false
|
||||
} else this.$toast.info('No updates were needed')
|
||||
}
|
||||
this.processing = false
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<template>
|
||||
<div class="flex items-center px-4 py-4 justify-start relative hover:bg-bg" :class="wrapperClass" @click="click" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<!-- <span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-80'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> -->
|
||||
<div class="flex items-center px-4 py-4 justify-start relative bg-primary hover:bg-opacity-25" :class="wrapperClass" @click.stop="click" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div class="w-16 max-w-16 text-center">
|
||||
<p class="text-sm font-mono text-gray-400">
|
||||
{{ this.$secondsToTimestamp(bookmark.time) }}
|
||||
|
|
|
|||
73
client/components/modals/changelog/ViewModal.vue
Normal file
73
client/components/modals/changelog/ViewModal.vue
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
|
||||
<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">Changelog</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
|
||||
<p class="text-xl font-bold pb-4">Changelog v{{ currentVersionNumber }}</p>
|
||||
<div class="custom-text" v-html="compiledMarkedown" />
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { marked } from '@/static/libs/marked/index.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
changelog: String,
|
||||
currentVersion: String
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
compiledMarkedown() {
|
||||
return marked.parse(this.changelog, { gfm: true, breaks: true })
|
||||
},
|
||||
currentVersionNumber() {
|
||||
return this.currentVersion
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
1. we need to manually define styles to apply to the parsed markdown elements,
|
||||
since we don't have access to the actual elements in this component
|
||||
|
||||
2. v-deep allows these to take effect on the content passed in to the v-html in the div above
|
||||
*/
|
||||
.custom-text ::v-deep > h2 {
|
||||
@apply text-lg font-bold;
|
||||
}
|
||||
.custom-text ::v-deep > h3 {
|
||||
@apply text-lg font-bold;
|
||||
}
|
||||
.custom-text ::v-deep > ul {
|
||||
@apply list-disc list-inside pb-4;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -46,12 +46,14 @@ export default {
|
|||
{
|
||||
id: 'chapters',
|
||||
title: 'Chapters',
|
||||
component: 'modals-item-tabs-chapters'
|
||||
component: 'modals-item-tabs-chapters',
|
||||
mediaType: 'book'
|
||||
},
|
||||
{
|
||||
id: 'episodes',
|
||||
title: 'Episodes',
|
||||
component: 'modals-item-tabs-episodes'
|
||||
component: 'modals-item-tabs-episodes',
|
||||
mediaType: 'podcast'
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
|
|
@ -66,7 +68,16 @@ export default {
|
|||
{
|
||||
id: 'manage',
|
||||
title: 'Manage',
|
||||
component: 'modals-item-tabs-manage'
|
||||
component: 'modals-item-tabs-manage',
|
||||
mediaType: 'book',
|
||||
admin: true
|
||||
},
|
||||
{
|
||||
id: 'schedule',
|
||||
title: 'Schedule',
|
||||
component: 'modals-item-tabs-schedule',
|
||||
mediaType: 'podcast',
|
||||
admin: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -120,13 +131,17 @@ export default {
|
|||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
availableTabs() {
|
||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||
return this.tabs.filter((tab) => {
|
||||
if (tab.experimental && !this.showExperimentalFeatures) return false
|
||||
if (tab.id === 'manage' && (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.mediaType && this.mediaType !== tab.mediaType) return false
|
||||
if (tab.admin && !this.userIsAdminOrUp) return false
|
||||
|
||||
if (tab.id === 'manage' && this.isMissing) return false
|
||||
|
||||
if ((tab.id === 'manage' || tab.id === 'files') && this.userCanDownload) return true
|
||||
if (tab.id !== 'manage' && tab.id !== 'files' && this.userCanUpdate) return true
|
||||
|
|
@ -190,7 +205,6 @@ export default {
|
|||
if (prevBook) {
|
||||
this.unregisterListeners()
|
||||
this.libraryItem = prevBook
|
||||
this.selectedTab = 'details'
|
||||
this.$store.commit('setSelectedLibraryItem', prevBook)
|
||||
this.$nextTick(this.registerListeners)
|
||||
} else {
|
||||
|
|
@ -210,7 +224,6 @@ export default {
|
|||
if (nextBook) {
|
||||
this.unregisterListeners()
|
||||
this.libraryItem = nextBook
|
||||
this.selectedTab = 'details'
|
||||
this.$store.commit('setSelectedLibraryItem', nextBook)
|
||||
this.$nextTick(this.registerListeners)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -31,9 +31,9 @@
|
|||
|
||||
<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.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
||||
<div :key="cover.path" class="m-0.5 mb-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" />
|
||||
<covers-preview-cover :src="`${cover.localPath}?token=${userToken}`" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -58,7 +58,7 @@
|
|||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-80 overflow-y-scroll mt-2 max-w-full">
|
||||
<p v-if="!coversFound.length">No Covers Found</p>
|
||||
<template v-for="cover in coversFound">
|
||||
<div :key="cover" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="updateCover(cover)">
|
||||
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="updateCover(cover)">
|
||||
<covers-preview-cover :src="cover" :width="80" show-open-new-tab :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -129,11 +129,8 @@ export default {
|
|||
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
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
<template>
|
||||
<div id="match-wrapper" class="w-full h-full overflow-hidden px-4 py-6 relative">
|
||||
<div id="match-wrapper" class="w-full h-full overflow-hidden px-2 md:px-4 py-4 md:py-6 relative">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<div class="flex items-center justify-start -mx-1 h-20">
|
||||
<div class="w-40 px-1">
|
||||
<div class="flex flex-wrap md:flex-nowrap items-center justify-start -mx-1">
|
||||
<div class="w-36 px-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
||||
</div>
|
||||
<div class="w-72 px-1">
|
||||
<div class="flex-grow md: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">
|
||||
<div v-show="provider != 'itunes'" class="w-60 md: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>
|
||||
|
|
@ -20,64 +20,70 @@
|
|||
<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">
|
||||
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper mt-4">
|
||||
<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 v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full px-2 py-6 md:p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="flex mb-4">
|
||||
<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>
|
||||
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
|
||||
<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" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly label="Cover" class="flex-grow mx-4" />
|
||||
<div class="min-w-12 max-w-12 md:min-w-16 md:max-w-16">
|
||||
<a :href="selectedMatch.cover" target="_blank" class="w-full bg-primary">
|
||||
<img :src="selectedMatch.cover" class="h-full w-full object-contain" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.title" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.title" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.title" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<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" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.subtitle" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<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" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.author" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<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" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.narrator" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<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-checkbox v-model="selectedMatchUsage.description" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<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-checkbox v-model="selectedMatchUsage.publisher" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<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" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.publishedYear" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<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>
|
||||
|
|
@ -85,46 +91,46 @@
|
|||
</div>
|
||||
|
||||
<div v-if="selectedMatch.series" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.series" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.series" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<widgets-series-input-widget v-model="selectedMatch.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-checkbox v-model="selectedMatchUsage.volumeNumber" @input="checkboxToggled" />
|
||||
<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.genres" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.genres" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" label="Genres" />
|
||||
<p v-if="mediaMetadata.genresList" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.genresList || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.tags" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.tags" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.tags" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.tags" :disabled="!selectedMatchUsage.tags" label="Tags" />
|
||||
<p v-if="mediaMetadata.tagsList" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.tagsList || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.language" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.language" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.language" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.language" :disabled="!selectedMatchUsage.language" label="Language" />
|
||||
<p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.language || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.isbn" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.isbn" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<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" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.asin" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<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>
|
||||
|
|
@ -132,28 +138,28 @@
|
|||
</div>
|
||||
|
||||
<div v-if="selectedMatch.itunesId" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.itunesId" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.itunesId" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<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" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.feedUrl" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<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" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.itunesPageUrl" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<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" />
|
||||
<ui-checkbox v-model="selectedMatchUsage.releaseDate" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<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>
|
||||
|
|
@ -209,7 +215,8 @@ export default {
|
|||
itunesId: true,
|
||||
feedUrl: true,
|
||||
releaseDate: true
|
||||
}
|
||||
},
|
||||
selectAll: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
|
@ -246,7 +253,7 @@ export default {
|
|||
}
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
|
|
@ -271,6 +278,14 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
selectAllToggled(val) {
|
||||
for (const key in this.selectedMatchUsage) {
|
||||
this.selectedMatchUsage[key] = val
|
||||
}
|
||||
},
|
||||
checkboxToggled() {
|
||||
this.selectAll = Object.values(this.selectedMatchUsage).findIndex((v) => v == false) < 0
|
||||
},
|
||||
persistProvider() {
|
||||
try {
|
||||
localStorage.setItem('book-provider', this.provider)
|
||||
|
|
@ -299,7 +314,7 @@ export default {
|
|||
this.isProcessing = true
|
||||
this.lastSearch = searchQuery
|
||||
var searchEntity = this.isPodcast ? 'podcast' : 'books'
|
||||
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`).catch((error) => {
|
||||
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 10000 }).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return []
|
||||
})
|
||||
|
|
@ -363,6 +378,10 @@ export default {
|
|||
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
|
||||
if (this.isPodcast) this.provider = 'itunes'
|
||||
else this.provider = localStorage.getItem('book-provider') || 'google'
|
||||
|
||||
if (this.searchTitle) {
|
||||
this.submitSearch()
|
||||
}
|
||||
},
|
||||
selectMatch(match) {
|
||||
if (match) {
|
||||
|
|
@ -497,6 +516,11 @@ export default {
|
|||
|
||||
<style>
|
||||
.matchListWrapper {
|
||||
height: calc(100% - 80px);
|
||||
height: calc(100% - 124px);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.matchListWrapper {
|
||||
height: calc(100% - 80px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
116
client/components/modals/item/tabs/Schedule.vue
Normal file
116
client/components/modals/item/tabs/Schedule.vue
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<div id="scheduleWrapper" class="w-full overflow-y-auto px-2 py-4 md:px-4 md:py-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-base md:text-lg font-semibold">Schedule Automatic Episode Downloads</p>
|
||||
<ui-checkbox v-model="enableAutoDownloadEpisodes" label="Enable" small checkbox-bg="bg" label-class="pl-2 text-base" />
|
||||
</div>
|
||||
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoDownloadEpisodes" v-model="cronExpression" @input="updatedCron" />
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg border-t border-white border-opacity-5">
|
||||
<div class="flex items-center px-2 md:px-4">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn @click="save" :disabled="!isUpdated" :color="isUpdated ? 'success' : 'primary'" class="mx-2">{{ isUpdated ? 'Save' : 'No update necessary' }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
enableAutoDownloadEpisodes: false,
|
||||
cronExpression: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
return this.processing
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
autoDownloadEpisodes() {
|
||||
return !!this.media.autoDownloadEpisodes
|
||||
},
|
||||
autoDownloadSchedule() {
|
||||
return this.media.autoDownloadSchedule
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
},
|
||||
isUpdated() {
|
||||
return this.autoDownloadSchedule !== this.cronExpression || this.autoDownloadEpisodes !== this.enableAutoDownloadEpisodes
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.enableAutoDownloadEpisodes = this.autoDownloadEpisodes
|
||||
this.cronExpression = this.autoDownloadSchedule
|
||||
},
|
||||
updatedCron() {
|
||||
console.log('Updated cron', this.cronExpression)
|
||||
},
|
||||
save() {
|
||||
// If custom expression input is focused then unfocus it instead of submitting
|
||||
if (this.$refs.cronExpressionBuilder && this.$refs.cronExpressionBuilder.checkBlurExpressionInput) {
|
||||
if (this.$refs.cronExpressionBuilder.checkBlurExpressionInput()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const updatePayload = {
|
||||
autoDownloadEpisodes: this.enableAutoDownloadEpisodes
|
||||
}
|
||||
if (this.enableAutoDownloadEpisodes) {
|
||||
updatePayload.autoDownloadSchedule = this.cronExpression
|
||||
}
|
||||
this.updateDetails(updatePayload)
|
||||
},
|
||||
async updateDetails(updatePayload) {
|
||||
this.isProcessing = true
|
||||
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, 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')
|
||||
return true
|
||||
} else {
|
||||
this.$toast.info('No updates were necessary')
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#scheduleWrapper {
|
||||
height: calc(100% - 80px);
|
||||
max-height: calc(100% - 80px);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="w-full h-full px-4 py-2 mb-4">
|
||||
<div class="w-full h-full px-1 md:px-4 py-2 mb-4">
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@
|
|||
</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="px-2 md: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" ref="tabComponent" :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="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10">
|
||||
<div class="flex justify-end">
|
||||
<ui-btn @click="submit">{{ buttonText }}</ui-btn>
|
||||
</div>
|
||||
|
|
@ -46,6 +46,11 @@ export default {
|
|||
id: 'settings',
|
||||
title: 'Settings',
|
||||
component: 'modals-libraries-library-settings'
|
||||
},
|
||||
{
|
||||
id: 'schedule',
|
||||
title: 'Schedule',
|
||||
component: 'modals-libraries-schedule-scan'
|
||||
}
|
||||
],
|
||||
libraryCopy: null
|
||||
|
|
@ -84,6 +89,7 @@ export default {
|
|||
},
|
||||
updateLibrary(library) {
|
||||
this.mapLibraryToCopy(library)
|
||||
console.log('Updated library', this.libraryCopy)
|
||||
},
|
||||
getNewLibraryData() {
|
||||
return {
|
||||
|
|
@ -93,9 +99,11 @@ export default {
|
|||
icon: 'database',
|
||||
mediaType: 'book',
|
||||
settings: {
|
||||
coverAspectRatio: this.$constants.BookCoverAspectRatio.SQUARE,
|
||||
disableWatcher: false,
|
||||
skipMatchingMediaWithAsin: false,
|
||||
skipMatchingMediaWithIsbn: false
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
autoScanCronExpression: null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -112,7 +120,9 @@ export default {
|
|||
if (key === 'folders') {
|
||||
this.libraryCopy.folders = library.folders.map((f) => ({ ...f }))
|
||||
} else if (key === 'settings') {
|
||||
this.libraryCopy.settings = { ...library.settings }
|
||||
for (const settingKey in library.settings) {
|
||||
this.libraryCopy.settings[settingKey] = library.settings[settingKey]
|
||||
}
|
||||
} else {
|
||||
this.libraryCopy[key] = library[key]
|
||||
}
|
||||
|
|
@ -134,6 +144,13 @@ export default {
|
|||
submit() {
|
||||
if (!this.validate()) return
|
||||
|
||||
// If custom expression input is focused then unfocus it instead of submitting
|
||||
if (this.$refs.tabComponent && this.$refs.tabComponent.checkBlurExpressionInput) {
|
||||
if (this.$refs.tabComponent.checkBlurExpressionInput()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (this.library) {
|
||||
this.submitUpdateLibrary()
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,23 +1,32 @@
|
|||
<template>
|
||||
<div class="w-full h-full px-4 py-1 mb-4">
|
||||
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="useSquareBookCovers" @input="formUpdated" />
|
||||
<ui-tooltip :text="tooltips.coverAspectRatio">
|
||||
<p class="pl-4 text-base">
|
||||
Use square book covers
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<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>
|
||||
<p class="pl-4 text-base">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 v-if="mediaType == 'book'" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
|
||||
<p class="pl-4 text-lg">Skip matching books that already have an ASIN</p>
|
||||
<p class="pl-4 text-base">Skip matching books that already have an ASIN</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mediaType == 'book'" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
|
||||
<p class="pl-4 text-lg">Skip matching books that already have an ISBN</p>
|
||||
<p class="pl-4 text-base">Skip matching books that already have an ISBN</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -35,9 +44,13 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
provider: null,
|
||||
useSquareBookCovers: false,
|
||||
disableWatcher: false,
|
||||
skipMatchingMediaWithAsin: false,
|
||||
skipMatchingMediaWithIsbn: false
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
tooltips: {
|
||||
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers'
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -59,6 +72,7 @@ export default {
|
|||
getLibraryData() {
|
||||
return {
|
||||
settings: {
|
||||
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
|
||||
disableWatcher: !!this.disableWatcher,
|
||||
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
||||
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn
|
||||
|
|
@ -69,6 +83,7 @@ export default {
|
|||
this.$emit('update', this.getLibraryData())
|
||||
},
|
||||
init() {
|
||||
this.useSquareBookCovers = this.librarySettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||
this.disableWatcher = !!this.librarySettings.disableWatcher
|
||||
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
|
||||
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
||||
|
|
|
|||
56
client/components/modals/libraries/ScheduleScan.vue
Normal file
56
client/components/modals/libraries/ScheduleScan.vue
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<template>
|
||||
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-base md:text-lg font-semibold">Schedule Automatic Library Scans</p>
|
||||
<ui-checkbox v-model="enableAutoScan" @input="toggleEnableAutoScan" label="Enable" small checkbox-bg="bg" label-class="pl-2 text-base" />
|
||||
</div>
|
||||
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoScan" v-model="cronExpression" @input="updatedCron" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
library: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
processing: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
cronExpression: null,
|
||||
enableAutoScan: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
checkBlurExpressionInput() {
|
||||
// returns true if advanced cron input is focused
|
||||
if (!this.$refs.cronExpressionBuilder) return false
|
||||
return this.$refs.cronExpressionBuilder.checkBlurExpressionInput()
|
||||
},
|
||||
toggleEnableAutoScan(v) {
|
||||
if (!v) this.updatedCron(null)
|
||||
else if (!this.cronExpression) {
|
||||
this.cronExpression = '0 0 * * 1'
|
||||
this.updatedCron(this.cronExpression)
|
||||
}
|
||||
},
|
||||
updatedCron(expression) {
|
||||
this.$emit('update', {
|
||||
settings: {
|
||||
autoScanCronExpression: expression
|
||||
}
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.cronExpression = this.library.settings.autoScanCronExpression
|
||||
this.enableAutoScan = !!this.cronExpression
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -5,33 +5,14 @@
|
|||
<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-y-auto" style="max-height: 80vh">
|
||||
<div class="flex flex-wrap">
|
||||
<div class="w-1/5 p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.season" label="Season" />
|
||||
</div>
|
||||
<div class="w-1/5 p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.episode" label="Episode" />
|
||||
</div>
|
||||
<div class="w-1/5 p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" />
|
||||
</div>
|
||||
<div class="w-2/5 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 default-style">
|
||||
<ui-rich-text-editor v-if="show" label="Description" v-model="newEpisode.description" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<ui-btn @click="submit">Submit</ui-btn>
|
||||
</div>
|
||||
<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-0.5 sm: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 ref="wrapper" class="p-4 w-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">
|
||||
<component v-if="libraryItem && show" :is="tabComponentName" :library-item="libraryItem" :episode="episode" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
|
@ -41,25 +22,19 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newEpisode: {
|
||||
season: null,
|
||||
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()
|
||||
}
|
||||
selectedTab: 'details',
|
||||
tabs: [
|
||||
{
|
||||
id: 'details',
|
||||
title: 'Details',
|
||||
component: 'modals-podcast-tabs-episode-details'
|
||||
},
|
||||
{
|
||||
id: 'match',
|
||||
title: 'Match',
|
||||
component: 'modals-podcast-tabs-episode-match'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -77,67 +52,29 @@ export default {
|
|||
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'
|
||||
},
|
||||
tabComponentName() {
|
||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||
return _tab ? _tab.component : ''
|
||||
}
|
||||
},
|
||||
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.season = this.episode.season || ''
|
||||
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)
|
||||
})
|
||||
selectTab(tab) {
|
||||
this.selectedTab = tab
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab {
|
||||
height: 40px;
|
||||
}
|
||||
.tab.tab-selected {
|
||||
height: 41px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -7,16 +7,17 @@
|
|||
</template>
|
||||
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||
<div class="mb-4">
|
||||
<p class="text-lg text-gray-200 mb-4">
|
||||
<p v-if="episode" class="text-lg text-gray-200 mb-4">
|
||||
Are you sure you want to remove episode<br /><span class="text-base">{{ episodeTitle }}</span
|
||||
>?
|
||||
</p>
|
||||
<p v-else class="text-lg text-gray-200 mb-4">Are you sure you want to remove {{ episodes.length }} episodes?</p>
|
||||
<p class="text-xs font-semibold text-warning text-opacity-90">Note: This does not delete the audio file unless toggling "Hard delete file"</p>
|
||||
</div>
|
||||
<div class="flex justify-between items-center pt-4">
|
||||
<ui-checkbox v-model="hardDeleteFile" label="Hard delete file" check-color="error" checkbox-bg="bg" small label-class="text-base text-gray-200 pl-3" />
|
||||
|
||||
<ui-btn @click="submit">{{ hardDeleteFile ? 'Delete episode' : 'Remove episode' }}</ui-btn>
|
||||
<ui-btn @click="submit">{{ btnText }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
|
|
@ -30,9 +31,9 @@ export default {
|
|||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
episodes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
|
|
@ -55,34 +56,49 @@ export default {
|
|||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
episode() {
|
||||
if (this.episodes.length === 1) return this.episodes[0]
|
||||
return null
|
||||
},
|
||||
title() {
|
||||
if (this.episodes.length > 1) return `Remove ${this.episodes.length} episodes`
|
||||
return 'Remove Episode'
|
||||
},
|
||||
episodeId() {
|
||||
return this.episode ? this.episode.id : null
|
||||
btnText() {
|
||||
if (this.episodes.length > 1) return this.hardDeleteFile ? `Delete ${this.episodes.length} episodes` : `Remove ${this.episodes.length} episodes`
|
||||
return this.hardDeleteFile ? 'Delete episode' : 'Remove episode'
|
||||
},
|
||||
episodeTitle() {
|
||||
return this.episode ? this.episode.title : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
async submit() {
|
||||
this.processing = true
|
||||
|
||||
var queryString = this.hardDeleteFile ? '?hard=1' : ''
|
||||
this.$axios
|
||||
.$delete(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}${queryString}`)
|
||||
.then(() => {
|
||||
for (const episode of this.episodes) {
|
||||
const success = await this.$axios
|
||||
.$delete(`/api/podcasts/${this.libraryItem.id}/episode/${episode.id}${queryString}`)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to remove episode'
|
||||
console.error('Failed to remove episode', error)
|
||||
this.$toast.error(errorMsg)
|
||||
return false
|
||||
})
|
||||
if (!success) {
|
||||
this.processing = false
|
||||
this.$toast.success('Podcast episode removed')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed remove episode'
|
||||
console.error('Failed update episode', error)
|
||||
this.processing = false
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
this.$emit('clearSelected')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.processing = false
|
||||
this.$toast.success(`${this.episodes.length} episode${this.episodes.length > 1 ? 's' : ''} removed`)
|
||||
this.show = false
|
||||
this.$emit('clearSelected')
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export default {
|
|||
return this.mediaMetadata.author
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
|
|
|
|||
136
client/components/modals/podcast/tabs/EpisodeDetails.vue
Normal file
136
client/components/modals/podcast/tabs/EpisodeDetails.vue
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="flex flex-wrap">
|
||||
<div class="w-1/5 p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.season" label="Season" />
|
||||
</div>
|
||||
<div class="w-1/5 p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.episode" label="Episode" />
|
||||
</div>
|
||||
<div class="w-1/5 p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" />
|
||||
</div>
|
||||
<div class="w-2/5 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 default-style">
|
||||
<ui-rich-text-editor label="Description" v-model="newEpisode.description" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<ui-btn @click="submit">Submit</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newEpisode: {
|
||||
season: null,
|
||||
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: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
return this.processing
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
episodeId() {
|
||||
return this.episode ? this.episode.id : null
|
||||
}
|
||||
},
|
||||
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.season = this.episode.season || ''
|
||||
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.isProcessing = true
|
||||
this.$axios
|
||||
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, payload)
|
||||
.then(() => {
|
||||
this.isProcessing = false
|
||||
this.$toast.success('Podcast episode updated')
|
||||
this.$emit('close')
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to update episode'
|
||||
console.error('Failed update episode', error)
|
||||
this.isProcessing = false
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
156
client/components/modals/podcast/tabs/EpisodeMatch.vue
Normal file
156
client/components/modals/podcast/tabs/EpisodeMatch.vue
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<template>
|
||||
<div style="min-height: 200px">
|
||||
<template v-if="!podcastFeedUrl">
|
||||
<div class="py-8">
|
||||
<widgets-alert type="error">Podcast has no RSS feed url to use for matching</widgets-alert>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="flex mb-2">
|
||||
<ui-text-input-with-label v-model="episodeTitle" :disabled="isProcessing" label="Episode Title" class="pr-1" />
|
||||
<ui-btn class="mt-5 ml-1" :loading="isProcessing" type="submit">Search</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="!isProcessing && searchedTitle && !episodesFound.length" class="w-full py-8">
|
||||
<p class="text-center text-lg">No episode matches found</p>
|
||||
</div>
|
||||
<div v-for="(episode, index) in episodesFound" :key="index" class="w-full py-4 border-b border-white border-opacity-5 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer px-2" @click.stop="selectEpisode(episode)">
|
||||
<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-400">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
episodeTitle: '',
|
||||
searchedTitle: '',
|
||||
episodesFound: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
episode: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
return this.processing
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
episodeId() {
|
||||
return this.episode ? this.episode.id : null
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
podcastFeedUrl() {
|
||||
return this.mediaMetadata.feedUrl
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getUpdatePayload(episodeData) {
|
||||
var updatePayload = {}
|
||||
for (const key in episodeData) {
|
||||
if (key === 'enclosure') {
|
||||
if (!this.episode.enclosure || JSON.stringify(this.episode.enclosure) !== JSON.stringify(episodeData.enclosure)) {
|
||||
updatePayload[key] = {
|
||||
...episodeData.enclosure
|
||||
}
|
||||
}
|
||||
} else if (episodeData[key] != this.episode[key]) {
|
||||
updatePayload[key] = episodeData[key]
|
||||
}
|
||||
}
|
||||
return updatePayload
|
||||
},
|
||||
selectEpisode(episode) {
|
||||
const episodeData = {
|
||||
title: episode.title || '',
|
||||
subtitle: episode.subtitle || '',
|
||||
description: episode.description || '',
|
||||
enclosure: episode.enclosure || null,
|
||||
episode: episode.episode || '',
|
||||
episodeType: episode.episodeType || '',
|
||||
season: episode.season || '',
|
||||
pubDate: episode.pubDate || '',
|
||||
publishedAt: episode.publishedAt
|
||||
}
|
||||
const updatePayload = this.getUpdatePayload(episodeData)
|
||||
if (!Object.keys(updatePayload).length) {
|
||||
return this.$toast.info('No updates are necessary')
|
||||
}
|
||||
console.log('Episode update payload', updatePayload)
|
||||
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessing = false
|
||||
this.$toast.success('Podcast episode updated')
|
||||
this.$emit('selectTab', 'details')
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to update episode'
|
||||
console.error('Failed update episode', error)
|
||||
this.isProcessing = false
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
},
|
||||
submitForm() {
|
||||
if (!this.episodeTitle || !this.episodeTitle.length) {
|
||||
this.$toast.error('Must enter an episode title')
|
||||
return
|
||||
}
|
||||
this.searchedTitle = this.episodeTitle
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$get(`/api/podcasts/${this.libraryItem.id}/search-episode?title=${this.$encodeUriPath(this.episodeTitle)}`)
|
||||
.then((results) => {
|
||||
this.episodesFound = results.episodes.map((ep) => ep.episode)
|
||||
console.log('Episodes found', this.episodesFound)
|
||||
this.isProcessing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to search for episode', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(errMsg || 'Failed to search for episode')
|
||||
this.isProcessing = false
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.searchedTitle = null
|
||||
this.episodesFound = []
|
||||
this.episodeTitle = this.episode ? this.episode.title || '' : ''
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -226,6 +226,13 @@ export default {
|
|||
})
|
||||
this.updateTimestamp()
|
||||
},
|
||||
checkUpdateChapterTrack() {
|
||||
// Changing media in player may not have chapters
|
||||
if (!this.chapters.length && this.useChapterTrack) {
|
||||
this.useChapterTrack = false
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
||||
}
|
||||
},
|
||||
seek(time) {
|
||||
this.$emit('seek', time)
|
||||
},
|
||||
|
|
@ -286,7 +293,10 @@ export default {
|
|||
},
|
||||
init() {
|
||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
|
||||
|
||||
var _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
|
||||
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
|
||||
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
||||
this.$emit('setPlaybackRate', this.playbackRate)
|
||||
},
|
||||
|
|
|
|||
117
client/components/prompt/Confirm.vue
Normal file
117
client/components/prompt/Confirm.vue
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<template>
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-60 opacity-0">
|
||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||
<div ref="content" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickedOutside">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<p class="text-base mb-8 mt-2 px-1">{{ message }}</p>
|
||||
<div class="flex px-1 items-center">
|
||||
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">Cancel</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="isYesNo" color="success" @click="confirm">Yes</ui-btn>
|
||||
<ui-btn v-else color="primary" @click="confirm">Ok</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {},
|
||||
data() {
|
||||
return {
|
||||
el: null,
|
||||
content: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.setShow()
|
||||
} else {
|
||||
this.setHide()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.$store.state.globals.showConfirmPrompt
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('globals/setShowConfirmPrompt', val)
|
||||
}
|
||||
},
|
||||
confirmPromptOptions() {
|
||||
return this.$store.state.globals.confirmPromptOptions || {}
|
||||
},
|
||||
message() {
|
||||
return this.confirmPromptOptions.message || ''
|
||||
},
|
||||
callback() {
|
||||
return this.confirmPromptOptions.callback
|
||||
},
|
||||
type() {
|
||||
return this.confirmPromptOptions.type || 'ok'
|
||||
},
|
||||
persistent() {
|
||||
return !!this.confirmPromptOptions.persistent
|
||||
},
|
||||
isYesNo() {
|
||||
return this.type === 'yesNo'
|
||||
},
|
||||
modalHeight() {
|
||||
return 'unset'
|
||||
},
|
||||
modalWidth() {
|
||||
return '500px'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickedOutside(evt) {
|
||||
if (!this.show) return
|
||||
if (evt) {
|
||||
evt.stopPropagation()
|
||||
evt.preventDefault()
|
||||
}
|
||||
|
||||
if (this.persistent) return
|
||||
if (this.callback) this.callback(false)
|
||||
this.show = false
|
||||
},
|
||||
nevermind() {
|
||||
if (this.callback) this.callback(false)
|
||||
this.show = false
|
||||
},
|
||||
confirm() {
|
||||
if (this.callback) this.callback(true)
|
||||
this.show = false
|
||||
},
|
||||
setShow() {
|
||||
this.$eventBus.$emit('showing-prompt', true)
|
||||
document.body.appendChild(this.el)
|
||||
setTimeout(() => {
|
||||
this.content.style.transform = 'scale(1)'
|
||||
}, 10)
|
||||
},
|
||||
setHide() {
|
||||
this.$eventBus.$emit('showing-prompt', false)
|
||||
this.content.style.transform = 'scale(0)'
|
||||
this.el.remove()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.el = this.$refs.wrapper
|
||||
this.content = this.$refs.content
|
||||
this.content.style.transform = 'scale(0)'
|
||||
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
|
||||
this.el.style.opacity = 1
|
||||
this.el.remove()
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.show) {
|
||||
this.$eventBus.$emit('showing-prompt', false)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -65,12 +65,10 @@ export default {
|
|||
setTimeout(() => {
|
||||
this.content.style.transform = 'scale(1)'
|
||||
}, 10)
|
||||
document.documentElement.classList.add('modal-open')
|
||||
},
|
||||
setHide() {
|
||||
this.content.style.transform = 'scale(0)'
|
||||
this.el.remove()
|
||||
document.documentElement.classList.remove('modal-open')
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
|
|
|||
|
|
@ -24,10 +24,10 @@
|
|||
<td class="font-book">
|
||||
{{ chapter.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
<td class="font-mono text-center hover:underline cursor-pointer" @click.stop="goToTimestamp(chapter.start)">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
<td class="font-mono text-center hover:underline cursor-pointer" @click.stop="goToTimestamp(chapter.start)">
|
||||
{{ $secondsToTimestamp(chapter.end) }}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -57,6 +57,9 @@ export default {
|
|||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
metadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
|
|
@ -67,6 +70,30 @@ export default {
|
|||
methods: {
|
||||
clickBar() {
|
||||
this.expanded = !this.expanded
|
||||
},
|
||||
goToTimestamp(time) {
|
||||
if (this.$store.getters['getIsMediaStreaming'](this.libraryItemId)) {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: null,
|
||||
startTime: time
|
||||
})
|
||||
} else {
|
||||
const payload = {
|
||||
message: `Start playback for "${this.metadata.title}" at ${this.$secondsToTimestamp(time)}?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: null,
|
||||
startTime: time
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
<template>
|
||||
<div class="w-full bg-primary bg-opacity-40">
|
||||
<div class="w-full h-14 flex items-center px-4 bg-primary">
|
||||
<p>Collection List</p>
|
||||
<div class="w-6 h-6 bg-white bg-opacity-10 flex items-center justify-center rounded-full ml-2">
|
||||
<p class="font-mono text-sm">{{ books.length }}</p>
|
||||
<div class="w-full h-14 flex items-center px-4 md:px-6 py-2 bg-primary">
|
||||
<p class="pr-4">Collection List</p>
|
||||
|
||||
<div class="w-6 h-6 md:w-7 md:h-7 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
|
||||
<span class="text-xs md:text-sm font-mono leading-none">{{ books.length }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<!-- <p v-if="totalDuration">{{ totalDurationPretty }}</p> -->
|
||||
<p v-if="totalDuration" class="text-sm text-gray-200">{{ 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">
|
||||
|
|
@ -51,11 +52,18 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
totalDuration() {
|
||||
var _total = 0
|
||||
this.books.forEach((book) => {
|
||||
_total += book.media.duration
|
||||
})
|
||||
return _total
|
||||
},
|
||||
totalDurationPretty() {
|
||||
return this.$elapsedPrettyExtended(this.totalDuration)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<template>
|
||||
<div class="w-full px-2 py-2 overflow-hidden relative" @mouseover="mouseover" @mouseleave="mouseleave" :class="isHovering ? 'bg-white bg-opacity-5' : ''">
|
||||
<div v-if="book" class="flex h-20">
|
||||
<div class="w-16 max-w-16 h-full">
|
||||
<div class="w-full px-1 md:px-2 py-2 overflow-hidden relative" @mouseover="mouseover" @mouseleave="mouseleave" :class="isHovering ? 'bg-white bg-opacity-5' : ''">
|
||||
<div v-if="book" class="flex h-16 md:h-20">
|
||||
<div class="w-10 min-w-10 md:w-16 md:max-w-16 h-full">
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<span class="material-icons drag-handle text-xl">menu</span>
|
||||
<span class="material-icons drag-handle text-lg md:text-xl">menu</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-full relative" :style="{ width: coverWidth + 'px' }">
|
||||
<div class="h-full relative" :style="{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }">
|
||||
<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">
|
||||
|
|
@ -107,9 +107,12 @@ export default {
|
|||
userIsFinished() {
|
||||
return this.itemProgress ? !!this.itemProgress.isFinished : false
|
||||
},
|
||||
coverSize() {
|
||||
return this.$store.state.globals.isMobile ? 40 : 50
|
||||
},
|
||||
coverWidth() {
|
||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.6
|
||||
return 50
|
||||
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
|
||||
return this.coverSize
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
||||
</div>
|
||||
</div>
|
||||
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
||||
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
||||
<template v-for="library in libraryCopies">
|
||||
<div :key="library.id" class="item">
|
||||
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="material-icons text-xl text-gray-400 hover:text-gray-50 ml-4">reorder</span>
|
||||
<span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 ml-4">reorder</span>
|
||||
|
||||
<!-- For mobile -->
|
||||
<modals-dialog v-model="showMobileMenu" :title="menuTitle" :items="mobileMenuItems" @action="mobileMenuAction" />
|
||||
|
|
@ -105,7 +105,7 @@ export default {
|
|||
},
|
||||
matchAll() {
|
||||
this.$axios
|
||||
.$post(`/api/libraries/${this.library.id}/matchall`)
|
||||
.$get(`/api/libraries/${this.library.id}/matchall`)
|
||||
.then(() => {
|
||||
console.log('Starting scan for matches')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<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 cursor-pointer" @click="$emit('view', episode)">
|
||||
<div v-if="episode" class="flex items-center cursor-pointer" :class="{ 'opacity-70': isSelected || selectionMode }" @click="clickedEpisode">
|
||||
<div class="flex-grow px-2">
|
||||
<p class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
|
|
@ -8,6 +8,12 @@
|
|||
|
||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ subtitle }}</p>
|
||||
|
||||
<div class="flex justify-between pt-2 max-w-xl">
|
||||
<p v-if="episode.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
|
||||
<p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
||||
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-2">
|
||||
<button 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 focus:outline-none" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
|
||||
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
|
|
@ -17,20 +23,18 @@
|
|||
<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.season" class="px-4 text-sm text-gray-300">Season #{{ episode.season }}</p>
|
||||
<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>
|
||||
|
||||
<ui-icon-btn v-if="userCanUpdate" icon="edit" borderless @click="clickEdit" />
|
||||
<ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-24 min-w-24" />
|
||||
<div v-if="isHovering || isSelected || selectionMode" class="hidden md:block w-12 min-w-12" />
|
||||
</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 v-if="isSelected || selectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-10 z-10 cursor-pointer" @click.stop="clickedSelectionBg" />
|
||||
<div class="hidden md:block md:w-12 md:min-w-12 md:-right-0 md:absolute md:top-0 h-full transform transition-transform z-20" :class="!isHovering && !isSelected && !selectionMode ? 'translate-x-24' : '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" />
|
||||
<ui-checkbox v-model="isSelected" @input="selectedUpdated" checkbox-bg="bg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -46,13 +50,15 @@ export default {
|
|||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
selectionMode: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessingReadUpdate: false,
|
||||
processingRemove: false,
|
||||
isHovering: false
|
||||
isHovering: false,
|
||||
isSelected: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -78,7 +84,7 @@ export default {
|
|||
return this.$secondsToTimestamp(this.episode.duration)
|
||||
},
|
||||
isStreaming() {
|
||||
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
|
||||
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id)
|
||||
},
|
||||
streamIsPlaying() {
|
||||
return this.$store.state.streamIsPlaying && this.isStreaming
|
||||
|
|
@ -104,8 +110,17 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
clickedEpisode() {
|
||||
this.$emit('view', this.episode)
|
||||
},
|
||||
clickedSelectionBg() {
|
||||
this.isSelected = !this.isSelected
|
||||
this.selectedUpdated(this.isSelected)
|
||||
},
|
||||
selectedUpdated(value) {
|
||||
this.$emit('selected', { isSelected: value, episode: this.episode })
|
||||
},
|
||||
mouseover() {
|
||||
// if (this.isDragging) return
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
|
|
@ -124,7 +139,21 @@ export default {
|
|||
})
|
||||
}
|
||||
},
|
||||
toggleFinished() {
|
||||
toggleFinished(confirmed = false) {
|
||||
if (!this.userIsFinished && this.itemProgressPercent > 0 && !confirmed) {
|
||||
const payload = {
|
||||
message: `Are you sure you want to mark "${this.title}" as finished?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.toggleFinished(true)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
return
|
||||
}
|
||||
|
||||
var updatePayload = {
|
||||
isFinished: !this.userIsFinished
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,18 @@
|
|||
<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" />
|
||||
<template v-if="isSelectionMode">
|
||||
<ui-btn color="error" small @click="removeSelectedEpisodes">Remove {{ selectedEpisodes.length }} episode{{ selectedEpisodes.length > 1 ? 's' : '' }}</ui-btn>
|
||||
<ui-btn small class="ml-2" @click="clearSelected">Cancel</ui-btn>
|
||||
</template>
|
||||
<controls-episode-sort-select v-else v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" />
|
||||
</div>
|
||||
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
|
||||
<template v-for="episode in episodesSorted">
|
||||
<tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" />
|
||||
<tables-podcast-episode-table-row ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" />
|
||||
</template>
|
||||
|
||||
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" :library-item="libraryItem" :episode="selectedEpisode" />
|
||||
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" @input="removeEpisodeModalToggled" :library-item="libraryItem" :episodes="episodesToRemove" @clearSelected="clearSelected" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -28,7 +32,9 @@ export default {
|
|||
sortKey: 'publishedAt',
|
||||
sortDesc: true,
|
||||
selectedEpisode: null,
|
||||
showPodcastRemoveModal: false
|
||||
showPodcastRemoveModal: false,
|
||||
selectedEpisodes: [],
|
||||
episodesToRemove: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
|
@ -37,6 +43,9 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
isSelectionMode() {
|
||||
return this.selectedEpisodes.length > 0
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
|
|
@ -59,8 +68,31 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
removeEpisodeModalToggled(val) {
|
||||
if (!val) this.episodesToRemove = []
|
||||
},
|
||||
clearSelected() {
|
||||
const episodeRows = this.$refs.episodeRow
|
||||
if (episodeRows && episodeRows.length) {
|
||||
for (const epRow of episodeRows) {
|
||||
if (epRow) epRow.isSelected = false
|
||||
}
|
||||
}
|
||||
this.selectedEpisodes = []
|
||||
},
|
||||
removeSelectedEpisodes() {
|
||||
this.episodesToRemove = this.selectedEpisodes
|
||||
this.showPodcastRemoveModal = true
|
||||
},
|
||||
episodeSelected({ isSelected, episode }) {
|
||||
if (isSelected) {
|
||||
this.selectedEpisodes.push(episode)
|
||||
} else {
|
||||
this.selectedEpisodes = this.selectedEpisodes.filter((ep) => ep.id !== episode.id)
|
||||
}
|
||||
},
|
||||
removeEpisode(episode) {
|
||||
this.selectedEpisode = episode
|
||||
this.episodesToRemove = [episode]
|
||||
this.showPodcastRemoveModal = true
|
||||
},
|
||||
editEpisode(episode) {
|
||||
|
|
|
|||
|
|
@ -89,6 +89,9 @@ export default {
|
|||
// this.currentSearch = this.textInput
|
||||
}, 100)
|
||||
},
|
||||
setFocus() {
|
||||
if (this.$refs.input && this.editable) this.$refs.input.focus()
|
||||
},
|
||||
inputFocus() {
|
||||
this.isFocused = true
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
<template>
|
||||
<div v-if="currentLibrary" class="relative sm:w-36 h-8 px-1.5" v-click-outside="clickOutsideObj">
|
||||
<button type="button" :disabled="disabled" class="w-10 sm:w-36 relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center justify-center sm:justify-start">
|
||||
<widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-2" />
|
||||
<span class="hidden sm:block">{{ currentLibrary.name }}</span>
|
||||
</span>
|
||||
<div v-if="currentLibrary" class="relative h-8 max-w-52 min-w-32" v-click-outside="clickOutsideObj">
|
||||
<button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<div class="flex items-center justify-center sm:justify-start">
|
||||
<widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" />
|
||||
<span class="hidden sm:block truncate">{{ currentLibrary.name }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-36 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">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px min-w-48 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="library in librariesFiltered">
|
||||
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
|
||||
<div class="flex items-center px-3">
|
||||
<widgets-library-icon :icon="library.icon" class="mr-2" />
|
||||
<div class="flex items-center px-2">
|
||||
<widgets-library-icon :icon="library.icon" class="mr-1.5 text-gray-400" />
|
||||
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>
|
||||
</div>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
showPassword: false,
|
||||
isHovering: false
|
||||
isHovering: false,
|
||||
isFocused: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -66,9 +67,11 @@ export default {
|
|||
this.inputValue = ''
|
||||
},
|
||||
focused() {
|
||||
this.isFocused = true
|
||||
this.$emit('focus')
|
||||
},
|
||||
blurred() {
|
||||
this.isFocused = false
|
||||
this.$emit('blur')
|
||||
},
|
||||
change(e) {
|
||||
|
|
@ -80,6 +83,9 @@ export default {
|
|||
blur() {
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
},
|
||||
setFocus() {
|
||||
if (this.$refs.input) this.$refs.input.focus()
|
||||
},
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
|
||||
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</p>
|
||||
<slot>
|
||||
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
|
||||
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</p>
|
||||
</slot>
|
||||
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" @blur="inputBlurred" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -34,6 +36,11 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
setFocus() {
|
||||
if (this.$refs.input && this.$refs.input.setFocus) {
|
||||
this.$refs.input.setFocus()
|
||||
}
|
||||
},
|
||||
blur() {
|
||||
if (this.$refs.input && this.$refs.input.blur) {
|
||||
this.$refs.input.blur()
|
||||
|
|
|
|||
212
client/components/ui/TimePicker.vue
Normal file
212
client/components/ui/TimePicker.vue
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
<template>
|
||||
<div class="relative">
|
||||
<div class="rounded text-gray-200 border w-full px-3 py-2" :class="focusedDigit ? 'bg-primary bg-opacity-50 border-gray-300' : 'bg-primary border-gray-600'" @click="clickInput" v-click-outside="clickOutsideObj">
|
||||
<div class="flex items-center">
|
||||
<template v-for="(digit, index) in digitDisplay">
|
||||
<div v-if="digit == ':'" :key="index" class="px-px" @click.stop="clickMedian(index)">:</div>
|
||||
<div v-else :key="index" class="px-px" :class="{ 'digit-focused': focusedDigit == digit }" @click.stop="focusDigit(digit)">{{ digits[digit] }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
showThreeDigitHour: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
clickOutsideObj: {
|
||||
handler: this.clickOutside,
|
||||
events: ['mousedown'],
|
||||
isActive: true
|
||||
},
|
||||
digitDisplay: ['hour1', 'hour0', ':', 'minute1', 'minute0', ':', 'second1', 'second0'],
|
||||
focusedDigit: null,
|
||||
digits: {
|
||||
hour2: 0,
|
||||
hour1: 0,
|
||||
hour0: 0,
|
||||
minute1: 0,
|
||||
minute0: 0,
|
||||
second1: 0,
|
||||
second0: 0
|
||||
},
|
||||
isOver99Hours: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.initDigits()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
initDigits() {
|
||||
var totalSeconds = !this.value || isNaN(this.value) ? 0 : Number(this.value)
|
||||
totalSeconds = Math.round(totalSeconds)
|
||||
|
||||
var minutes = Math.floor(totalSeconds / 60)
|
||||
var seconds = totalSeconds - minutes * 60
|
||||
var hours = Math.floor(minutes / 60)
|
||||
minutes -= hours * 60
|
||||
|
||||
this.digits.second1 = seconds <= 9 ? 0 : Number(String(seconds)[0])
|
||||
this.digits.second0 = seconds <= 9 ? seconds : Number(String(seconds)[1])
|
||||
|
||||
this.digits.minute1 = minutes <= 9 ? 0 : Number(String(minutes)[0])
|
||||
this.digits.minute0 = minutes <= 9 ? minutes : Number(String(minutes)[1])
|
||||
|
||||
if (hours > 99) {
|
||||
this.digits.hour2 = Number(String(hours)[0])
|
||||
this.digits.hour1 = Number(String(hours)[1])
|
||||
this.digits.hour0 = Number(String(hours)[2])
|
||||
this.isOver99Hours = true
|
||||
} else {
|
||||
this.digits.hour1 = hours <= 9 ? 0 : Number(String(hours)[0])
|
||||
this.digits.hour0 = hours <= 9 ? hours : Number(String(hours)[1])
|
||||
this.isOver99Hours = this.showThreeDigitHour
|
||||
}
|
||||
|
||||
if (this.isOver99Hours) {
|
||||
this.digitDisplay = ['hour2', 'hour1', 'hour0', ':', 'minute1', 'minute0', ':', 'second1', 'second0']
|
||||
} else {
|
||||
this.digitDisplay = ['hour1', 'hour0', ':', 'minute1', 'minute0', ':', 'second1', 'second0']
|
||||
}
|
||||
},
|
||||
updateSeconds() {
|
||||
var seconds = this.digits.second0 + this.digits.second1 * 10
|
||||
seconds += this.digits.minute0 * 60 + this.digits.minute1 * 600
|
||||
seconds += this.digits.hour0 * 3600 + this.digits.hour1 * 36000
|
||||
if (this.isOver99Hours) seconds += this.digits.hour2 * 360000
|
||||
|
||||
if (Number(this.value) !== seconds) {
|
||||
this.$emit('input', seconds)
|
||||
this.$emit('change', seconds)
|
||||
}
|
||||
},
|
||||
clickMedian(index) {
|
||||
// Click colon select digit to right
|
||||
if (index >= 5) {
|
||||
this.focusedDigit = 'second1'
|
||||
} else {
|
||||
this.focusedDigit = 'minute1'
|
||||
}
|
||||
},
|
||||
clickOutside() {
|
||||
this.removeFocus()
|
||||
},
|
||||
removeFocus() {
|
||||
this.focusedDigit = null
|
||||
this.removeListeners()
|
||||
},
|
||||
focusDigit(digit) {
|
||||
if (this.focusedDigit == null || isNaN(this.focusedDigit)) this.initListeners()
|
||||
this.focusedDigit = digit
|
||||
},
|
||||
clickInput() {
|
||||
if (this.focusedDigit) return
|
||||
this.focusDigit('second0')
|
||||
},
|
||||
shiftFocusLeft() {
|
||||
if (!this.focusedDigit) return
|
||||
if (this.focusedDigit.endsWith('2')) return
|
||||
|
||||
const isDigit1 = this.focusedDigit.endsWith('1')
|
||||
if (!isDigit1) {
|
||||
const digit1Key = this.focusedDigit.replace('0', '1')
|
||||
this.focusedDigit = digit1Key
|
||||
} else if (this.focusedDigit.startsWith('second')) {
|
||||
this.focusedDigit = 'minute0'
|
||||
} else if (this.focusedDigit.startsWith('minute')) {
|
||||
this.focusedDigit = 'hour0'
|
||||
} else if (this.isOver99Hours && this.focusedDigit.startsWith('hour')) {
|
||||
this.focusedDigit = 'hour2'
|
||||
}
|
||||
},
|
||||
shiftFocusRight() {
|
||||
if (!this.focusedDigit) return
|
||||
if (this.focusedDigit.endsWith('2')) {
|
||||
// Must be hour2
|
||||
this.focusedDigit = 'hour1'
|
||||
return
|
||||
}
|
||||
const isDigit1 = this.focusedDigit.endsWith('1')
|
||||
if (isDigit1) {
|
||||
const digit0Key = this.focusedDigit.replace('1', '0')
|
||||
this.focusedDigit = digit0Key
|
||||
} else if (this.focusedDigit.startsWith('hour')) {
|
||||
this.focusedDigit = 'minute1'
|
||||
} else if (this.focusedDigit.startsWith('minute')) {
|
||||
this.focusedDigit = 'second1'
|
||||
}
|
||||
},
|
||||
increaseFocused() {
|
||||
if (!this.focusedDigit) return
|
||||
const isDigit1 = this.focusedDigit.endsWith('1')
|
||||
const digit = Number(this.digits[this.focusedDigit])
|
||||
if (isDigit1 && !this.focusedDigit.startsWith('hour')) this.digits[this.focusedDigit] = (digit + 1) % 6
|
||||
else this.digits[this.focusedDigit] = (digit + 1) % 10
|
||||
this.updateSeconds()
|
||||
},
|
||||
decreaseFocused() {
|
||||
if (!this.focusedDigit) return
|
||||
const isDigit1 = this.focusedDigit.endsWith('1')
|
||||
const digit = Number(this.digits[this.focusedDigit])
|
||||
if (isDigit1 && !this.focusedDigit.startsWith('hour')) this.digits[this.focusedDigit] = digit - 1 < 0 ? 5 : digit - 1
|
||||
else this.digits[this.focusedDigit] = digit - 1 < 0 ? 9 : digit - 1
|
||||
this.updateSeconds()
|
||||
},
|
||||
keydown(evt) {
|
||||
if (!this.focusedDigit || !evt.key) return
|
||||
|
||||
if (evt.key === 'ArrowLeft') {
|
||||
return this.shiftFocusLeft()
|
||||
} else if (evt.key === 'ArrowRight') {
|
||||
return this.shiftFocusRight()
|
||||
} else if (evt.key === 'ArrowUp') {
|
||||
return this.increaseFocused()
|
||||
} else if (evt.key === 'ArrowDown') {
|
||||
return this.decreaseFocused()
|
||||
} else if (evt.key === 'Enter' || evt.key === 'Escape') {
|
||||
return this.removeFocus()
|
||||
}
|
||||
|
||||
if (isNaN(evt.key)) return
|
||||
|
||||
var digit = Number(evt.key)
|
||||
const isDigit1 = this.focusedDigit.endsWith('1')
|
||||
if (isDigit1 && !this.focusedDigit.startsWith('hour') && digit >= 6) {
|
||||
digit = 5
|
||||
}
|
||||
|
||||
this.digits[this.focusedDigit] = digit
|
||||
|
||||
this.updateSeconds()
|
||||
this.shiftFocusRight()
|
||||
},
|
||||
initListeners() {
|
||||
window.addEventListener('keydown', this.keydown)
|
||||
},
|
||||
removeListeners() {
|
||||
window.removeEventListener('keydown', this.keydown)
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {
|
||||
this.removeListeners()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.digit-focused {
|
||||
background-color: #555;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -41,13 +41,6 @@ export default {
|
|||
this.setTooltipPosition(this.tooltip)
|
||||
}
|
||||
},
|
||||
getTextWidth() {
|
||||
var styles = {
|
||||
'font-size': '0.75rem'
|
||||
}
|
||||
var size = this.$calculateTextSize(this.text, styles)
|
||||
return size.width
|
||||
},
|
||||
createTooltip() {
|
||||
if (!this.$refs.box) return
|
||||
var tooltip = document.createElement('div')
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
|
|
|
|||
312
client/components/widgets/CronExpressionBuilder.vue
Normal file
312
client/components/widgets/CronExpressionBuilder.vue
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
<template>
|
||||
<div class="w-full py-2">
|
||||
<div class="flex -mb-px">
|
||||
<div class="w-1/2 h-8 rounded-tl-md relative border border-black-200 flex items-center justify-center cursor-pointer" :class="!showAdvancedView ? 'text-white bg-bg hover:bg-opacity-60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary bg-opacity-70 hover:bg-opacity-60'" @click="showAdvancedView = false">
|
||||
<p class="text-sm">Scheduler</p>
|
||||
</div>
|
||||
<div class="w-1/2 h-8 rounded-tr-md relative border border-black-200 flex items-center justify-center -ml-px cursor-pointer" :class="showAdvancedView ? 'text-white bg-bg hover:bg-opacity-60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary bg-opacity-70 hover:bg-opacity-60'" @click="showAdvancedView = true">
|
||||
<p class="text-sm">Advanced</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 py-4 md:p-4 border border-black-200 rounded-b-md mr-px" style="min-height: 280px">
|
||||
<template v-if="!showAdvancedView">
|
||||
<ui-dropdown v-model="selectedInterval" @input="updateCron" label="Interval" :items="intervalOptions" class="mb-2" />
|
||||
|
||||
<ui-multi-select-dropdown v-if="selectedInterval === 'custom'" v-model="selectedWeekdays" @input="updateCron" label="Weekdays to run" :items="weekdays" />
|
||||
|
||||
<div v-if="(selectedWeekdays.length && selectedInterval === 'custom') || selectedInterval === 'daily'" class="flex items-center py-2">
|
||||
<ui-text-input-with-label v-model="selectedHour" @input="updateCron" @blur="hourBlur" type="number" label="Hour" class="max-w-20" />
|
||||
<p class="text-xl px-2 mt-4">:</p>
|
||||
<ui-text-input-with-label v-model="selectedMinute" @input="updateCron" @blur="minuteBlur" type="number" label="Minute" class="max-w-20" />
|
||||
</div>
|
||||
|
||||
<div v-if="description" class="w-full bg-primary bg-opacity-75 rounded-xl p-2 md:p-4 text-center mt-2">
|
||||
<p class="text-base md:text-lg text-gray-200" v-html="description" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="px-1 text-sm font-semibold">Cron Expression</p>
|
||||
<ui-text-input ref="customExpressionInput" v-model="customCronExpression" @blur="cronExpressionBlur" label="Cron Expression" :padding-y="2" text-center class="w-full text-2xl md:text-4xl -tracking-widest mb-4 font-mono" />
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<widgets-loading-spinner v-if="isValidating" class="mr-2" />
|
||||
<span v-else class="material-icons-outlined mr-2 text-xl" :class="isValid ? 'text-success' : 'text-error'">{{ isValid ? 'check_circle_outline' : 'error_outline' }}</span>
|
||||
<p v-if="isValidating" class="text-gray-300 text-base md:text-lg text-center">Checking cron...</p>
|
||||
<p v-else-if="customCronError" class="text-error text-base md:text-lg text-center">{{ customCronError }}</p>
|
||||
<p v-else class="text-success text-base md:text-lg text-center">Valid cron expression</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedInterval: 'custom',
|
||||
showAdvancedView: false,
|
||||
selectedHour: 0,
|
||||
selectedMinute: 0,
|
||||
selectedWeekdays: [],
|
||||
cronExpression: '0 0 * * *',
|
||||
customCronExpression: '0 0 * * *',
|
||||
customCronError: '',
|
||||
isValidating: false,
|
||||
validatedCron: null,
|
||||
isValid: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
minuteIsValid() {
|
||||
return !(isNaN(this.selectedMinute) || this.selectedMinute === '' || this.selectedMinute < 0 || this.selectedMinute > 59)
|
||||
},
|
||||
hourIsValid() {
|
||||
return !(isNaN(this.selectedHour) || this.selectedHour === '' || this.selectedHour < 0 || this.selectedHour > 23)
|
||||
},
|
||||
description() {
|
||||
if ((this.selectedInterval !== 'custom' || !this.selectedWeekdays.length) && this.selectedInterval !== 'daily') return ''
|
||||
|
||||
if (!this.hourIsValid) {
|
||||
return `<span class="text-error">Invalid hour must be 0-23 | ${this.selectedHour < 0 || this.selectedHour > 23}</span>`
|
||||
}
|
||||
if (!this.minuteIsValid) {
|
||||
return `<span class="text-error">Invalid minute must be 0-59</span>`
|
||||
}
|
||||
|
||||
var description = 'Run every '
|
||||
var weekdayTexts = ''
|
||||
if (this.selectedWeekdays.length === 7 || this.selectedInterval === 'daily') {
|
||||
weekdayTexts = 'day'
|
||||
} else {
|
||||
weekdayTexts = this.selectedWeekdays
|
||||
.map((weekday) => {
|
||||
return this.weekdays.find((w) => w.value === weekday).text
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
description += `<span class="font-bold text-white">${weekdayTexts}</span>`
|
||||
|
||||
const hourString = this.selectedHour.toString()
|
||||
const minuteString = this.selectedMinute.toString().padStart(2, '0')
|
||||
description += ` at <span class="font-bold text-white">${hourString}:${minuteString}</span>`
|
||||
return description
|
||||
},
|
||||
intervalOptions() {
|
||||
return [
|
||||
{
|
||||
text: 'Custom daily/weekly',
|
||||
value: 'custom'
|
||||
},
|
||||
{
|
||||
text: 'Every day',
|
||||
value: 'daily'
|
||||
},
|
||||
{
|
||||
text: 'Every 12 hours',
|
||||
value: '0 */12 * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 6 hours',
|
||||
value: '0 */6 * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 2 hours',
|
||||
value: '0 */2 * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every hour',
|
||||
value: '0 * * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 30 minutes',
|
||||
value: '*/30 * * * *'
|
||||
},
|
||||
{
|
||||
text: 'Every 15 minutes',
|
||||
value: '*/15 * * * *'
|
||||
}
|
||||
]
|
||||
},
|
||||
weekdays() {
|
||||
return [
|
||||
{
|
||||
text: 'Sunday',
|
||||
value: 0
|
||||
},
|
||||
{
|
||||
text: 'Monday',
|
||||
value: 1
|
||||
},
|
||||
{
|
||||
text: 'Tuesday',
|
||||
value: 2
|
||||
},
|
||||
{
|
||||
text: 'Wednesday',
|
||||
value: 3
|
||||
},
|
||||
{
|
||||
text: 'Thursday',
|
||||
value: 4
|
||||
},
|
||||
{
|
||||
text: 'Friday',
|
||||
value: 5
|
||||
},
|
||||
{
|
||||
text: 'Saturday',
|
||||
value: 6
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkBlurExpressionInput() {
|
||||
if (!this.showAdvancedView || !this.$refs.customExpressionInput) return false
|
||||
if (this.$refs.customExpressionInput.isFocused) {
|
||||
this.$refs.customExpressionInput.blur()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
updateCron() {
|
||||
if (this.selectedInterval === 'custom') {
|
||||
if (!this.minuteIsValid || !this.hourIsValid || !this.selectedWeekdays.length) {
|
||||
this.cronExpression = null
|
||||
return
|
||||
}
|
||||
this.selectedWeekdays.sort()
|
||||
|
||||
const daysOfWeekPiece = this.selectedWeekdays.length === 7 ? '*' : this.selectedWeekdays.join(',')
|
||||
this.cronExpression = `${this.selectedMinute} ${this.selectedHour} * * ${daysOfWeekPiece}`
|
||||
} else if (this.selectedInterval === 'daily') {
|
||||
if (!this.minuteIsValid || !this.hourIsValid) {
|
||||
this.cronExpression = null
|
||||
return
|
||||
}
|
||||
this.cronExpression = `${this.selectedMinute} ${this.selectedHour} * * *`
|
||||
} else {
|
||||
this.cronExpression = this.selectedInterval
|
||||
}
|
||||
|
||||
this.customCronExpression = this.cronExpression
|
||||
this.validatedCron = this.cronExpression
|
||||
this.isValid = true
|
||||
this.customCronError = ''
|
||||
this.$emit('input', this.cronExpression)
|
||||
},
|
||||
minuteBlur() {
|
||||
const v = this.selectedMinute
|
||||
if (v === '' || v === null || isNaN(v) || v < 0) {
|
||||
this.selectedMinute = 0
|
||||
} else if (v > 59) {
|
||||
this.selectedMinute = 59
|
||||
} else {
|
||||
this.selectedMinute = Number(v)
|
||||
}
|
||||
this.updateCron()
|
||||
},
|
||||
hourBlur() {
|
||||
const v = this.selectedHour
|
||||
if (v === '' || v === null || isNaN(v) || v < 0) {
|
||||
this.selectedHour = 0
|
||||
} else if (v > 23) {
|
||||
this.selectedHour = 23
|
||||
} else {
|
||||
this.selectedHour = Number(v)
|
||||
}
|
||||
this.updateCron()
|
||||
},
|
||||
async cronExpressionBlur() {
|
||||
this.customCronError = ''
|
||||
if (!this.customCronExpression || this.customCronExpression.split(' ').length !== 5) {
|
||||
this.customCronError = 'Invalid cron expression'
|
||||
this.isValid = false
|
||||
return
|
||||
}
|
||||
|
||||
if (this.customCronExpression !== this.cronExpression) {
|
||||
this.selectedWeekdays = []
|
||||
this.selectedHour = 0
|
||||
this.selectedMinute = 0
|
||||
this.cronExpression = this.customCronExpression
|
||||
}
|
||||
|
||||
if (!this.validatedCron || this.validatedCron !== this.cronExpression) {
|
||||
const validationPayload = await this.validateCron()
|
||||
this.isValid = validationPayload.isValid
|
||||
this.validatedCron = this.cronExpression
|
||||
this.customCronError = validationPayload.error || ''
|
||||
}
|
||||
|
||||
if (this.isValid) {
|
||||
this.$emit('input', this.cronExpression)
|
||||
}
|
||||
},
|
||||
validateCron() {
|
||||
this.isValidating = true
|
||||
return this.$axios
|
||||
.$post('/api/validate-cron', { expression: this.customCronExpression })
|
||||
.then(() => {
|
||||
this.isValidating = false
|
||||
return {
|
||||
isValid: true
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Invalid cron', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.isValidating = false
|
||||
return {
|
||||
isValid: false,
|
||||
error: errMsg || 'Invalid cron expression'
|
||||
}
|
||||
})
|
||||
},
|
||||
init() {
|
||||
if (!this.value) return
|
||||
const pieces = this.value.split(' ')
|
||||
if (pieces.length !== 5) {
|
||||
console.error('Invalid cron expression input', this.value)
|
||||
return
|
||||
}
|
||||
|
||||
const intervalMatch = this.intervalOptions.find((opt) => opt.value === this.value)
|
||||
if (intervalMatch) {
|
||||
this.selectedInterval = this.value
|
||||
} else {
|
||||
var isCustomCron = false
|
||||
if (isNaN(pieces[0]) || isNaN(pieces[1])) {
|
||||
isCustomCron = true
|
||||
} else if (pieces[2] !== '*' || pieces[3] !== '*') {
|
||||
isCustomCron = true
|
||||
} else if (pieces[4] !== '*' && pieces[4].split(',').some((num) => isNaN(num))) {
|
||||
isCustomCron = true
|
||||
}
|
||||
|
||||
if (isCustomCron) {
|
||||
this.showAdvancedView = true
|
||||
} else {
|
||||
if (pieces[4] === '*') this.selectedInterval = 'daily'
|
||||
|
||||
this.selectedWeekdays = pieces[4] === '*' ? [0, 1, 2, 3, 4, 5, 6] : pieces[4].split(',').map((num) => Number(num))
|
||||
this.selectedHour = pieces[1]
|
||||
this.selectedMinute = pieces[0]
|
||||
}
|
||||
}
|
||||
this.cronExpression = this.value
|
||||
this.customCronExpression = this.value
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -46,7 +46,7 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div :class="`h-${size} w-${size}`">
|
||||
<div :class="`h-${size} w-${size} min-w-${size}`">
|
||||
<component :is="iconComponentName" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0">
|
||||
<template v-for="(item, index) in items">
|
||||
<div :key="index" class="flex h-7 items-center px-2 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click="clickAction(item.func)">
|
||||
<div :key="index" class="flex h-7 items-center px-2 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.func)">
|
||||
<p>{{ item.text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -40,8 +40,20 @@
|
|||
</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 class="flex items-center px-1 pt-6">
|
||||
<div class="w-1/2 px-1 py-5">
|
||||
<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 v-if="autoDownloadEpisodes" class="w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="maxEpisodesToKeep" v-model="maxEpisodesToKeep" type="number" class="max-w-48">
|
||||
<ui-tooltip direction="bottom" text="Value of 0 sets no max limit. After a new episode is auto-downloaded this will delete the oldest episode if you have more than X episodes.">
|
||||
<p class="text-sm">
|
||||
Max episodes to keep
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</ui-text-input-with-label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -72,6 +84,7 @@ export default {
|
|||
language: null
|
||||
},
|
||||
autoDownloadEpisodes: false,
|
||||
maxEpisodesToKeep: 0,
|
||||
newTags: []
|
||||
}
|
||||
},
|
||||
|
|
@ -199,6 +212,9 @@ export default {
|
|||
if (this.media.autoDownloadEpisodes !== this.autoDownloadEpisodes) {
|
||||
updatePayload.autoDownloadEpisodes = !!this.autoDownloadEpisodes
|
||||
}
|
||||
if (this.autoDownloadEpisodes && !isNaN(this.maxEpisodesToKeep) && Number(this.maxEpisodesToKeep) != this.media.maxEpisodesToKeep) {
|
||||
updatePayload.maxEpisodesToKeep = Number(this.maxEpisodesToKeep)
|
||||
}
|
||||
|
||||
return {
|
||||
updatePayload,
|
||||
|
|
@ -220,6 +236,7 @@ export default {
|
|||
this.details.explicit = !!this.mediaMetadata.explicit
|
||||
|
||||
this.autoDownloadEpisodes = !!this.media.autoDownloadEpisodes
|
||||
this.maxEpisodesToKeep = this.media.maxEpisodesToKeep || 0
|
||||
this.newTags = [...(this.media.tags || [])]
|
||||
},
|
||||
submitForm() {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue