Merge branch 'master' into social

This commit is contained in:
Ben 2022-08-20 11:52:41 -04:00 committed by GitHub
commit 92c393d2b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
351 changed files with 53592 additions and 1765 deletions

View file

@ -25,5 +25,5 @@ HEALTHCHECK \
--interval=30s \ --interval=30s \
--timeout=3s \ --timeout=3s \
--start-period=10s \ --start-period=10s \
CMD curl -f http://127.0.0.1/ping || exit 1 CMD curl -f http://127.0.0.1/healthcheck || exit 1
CMD ["npm", "start"] CMD ["npm", "start"]

View file

@ -225,4 +225,18 @@ Bookshelf Label
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
/* number of lines to show */ /* number of lines to show */
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
}
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
padding-top: 104px;
}
.app-bar .Vue-Toastification__container.top-right {
padding-top: 64px;
}
.no-bars .Vue-Toastification__container.top-right {
padding-top: 8px;
} }

View file

@ -1,34 +1,40 @@
.flip-list-move { .flip-list-move {
transition: transform 0.5s; transition: transform 0.5s;
} }
.no-move { .no-move {
transition: transform 0s; transition: transform 0s;
} }
.ghost { .ghost {
opacity: 0.5; opacity: 0.5;
background-color: rgba(255, 255, 255, 0.25); background-color: rgba(255, 255, 255, 0.25);
} }
.list-group { .list-group {
min-height: 30px; min-height: 30px;
} }
#librariesTable .item {
cursor: n-resize;
}
.drag-handle { .drag-handle {
cursor: n-resize; cursor: n-resize;
} }
.list-group-item:not(.exclude) { .list-group-item:not(.exclude) {
cursor: n-resize; cursor: n-resize;
} }
.list-group-item.exclude { .list-group-item.exclude {
cursor: not-allowed; cursor: not-allowed;
} }
.list-group-item:not(.ghost):not(.exclude):hover { .list-group-item:not(.ghost):not(.exclude):hover {
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
} }
.list-group-item:nth-child(even):not(.ghost):not(.exclude) { .list-group-item:nth-child(even):not(.ghost):not(.exclude) {
background-color: rgba(0, 0, 0, 0.25); background-color: rgba(0, 0, 0, 0.25);
} }
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover { .list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
} }
@ -36,6 +42,7 @@
.list-group-item.exclude:not(.ghost) { .list-group-item.exclude:not(.ghost) {
background-color: rgba(255, 0, 0, 0.25); background-color: rgba(255, 0, 0, 0.25);
} }
.list-group-item.exclude:not(.ghost):hover { .list-group-item.exclude:not(.ghost):hover {
background-color: rgba(223, 0, 0, 0.25); background-color: rgba(223, 0, 0, 0.25);
} }

View file

@ -74,7 +74,7 @@
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
} }
@ -84,7 +84,7 @@
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
} }
@ -94,7 +94,7 @@
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+1F00-1FFF; unicode-range: U+1F00-1FFF;
} }
@ -104,7 +104,7 @@
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0370-03FF; unicode-range: U+0370-03FF;
} }
@ -114,7 +114,7 @@
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
} }
@ -124,7 +124,7 @@
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
@ -134,7 +134,7 @@
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
@ -154,7 +154,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
} }
@ -164,7 +164,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+1F00-1FFF; unicode-range: U+1F00-1FFF;
} }
@ -174,7 +174,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0370-03FF; unicode-range: U+0370-03FF;
} }
@ -184,7 +184,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
} }
@ -194,7 +194,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
@ -204,7 +204,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
@ -214,7 +214,7 @@
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
} }
@ -224,7 +224,7 @@
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
} }
@ -234,7 +234,7 @@
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+1F00-1FFF; unicode-range: U+1F00-1FFF;
} }
@ -244,7 +244,7 @@
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0370-03FF; unicode-range: U+0370-03FF;
} }
@ -254,7 +254,7 @@
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
} }
@ -264,7 +264,7 @@
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
@ -274,7 +274,7 @@
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
font-display: swap; font-display: swap;
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
@ -284,7 +284,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf'); src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
} }
@ -294,7 +294,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf'); src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
} }
@ -304,7 +304,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf'); src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+1F00-1FFF; unicode-range: U+1F00-1FFF;
} }
@ -314,7 +314,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf'); src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0370-03FF; unicode-range: U+0370-03FF;
} }
@ -324,7 +324,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf'); src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
@ -334,6 +334,6 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf'); src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }

View file

@ -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 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"> <div class="flex h-full items-center">
<nuxt-link to="/"> <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>
<nuxt-link to="/"> <nuxt-link to="/">
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf</h1> <h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
</nuxt-link> </nuxt-link>
<ui-libraries-dropdown /> <ui-libraries-dropdown class="mr-2" />
<controls-global-search v-if="currentLibrary" class="" /> <controls-global-search v-if="currentLibrary" class="" />
<div class="flex-grow" /> <div class="flex-grow" />

View file

@ -33,7 +33,7 @@
<!-- Regular bookshelf view --> <!-- Regular bookshelf view -->
<div v-else class="w-full"> <div v-else class="w-full">
<template v-for="(shelf, index) in shelves"> <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> </template>
</div> </div>
</div> </div>
@ -70,11 +70,8 @@ export default {
libraryName() { libraryName() {
return this.$store.getters['libraries/getCurrentLibraryName'] return this.$store.getters['libraries/getCurrentLibraryName']
}, },
bookshelfView() {
return this.$store.getters['getServerSetting']('bookshelfView')
},
isAlternativeBookshelfView() { isAlternativeBookshelfView() {
return this.bookshelfView === this.$constants.BookshelfView.TITLES return this.$store.getters['getHomeBookshelfView'] === this.$constants.BookshelfView.TITLES
}, },
bookCoverWidth() { bookCoverWidth() {
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize') var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
@ -82,13 +79,10 @@ export default {
return coverSize return coverSize
}, },
coverAspectRatio() { coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio') return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
isCoverSquareAspectRatio() { isCoverSquareAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE return this.coverAspectRatio == 1
},
bookCoverAspectRatio() {
return this.isCoverSquareAspectRatio ? 1 : 1.6
}, },
sizeMultiplier() { sizeMultiplier() {
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120 var baseSize = this.isCoverSquareAspectRatio ? 192 : 120

View file

@ -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="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"> <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> <p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p>
</div> </div>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a> <a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
</div> </div>
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version"/>
</div> </div>
</template> </template>
@ -26,7 +28,9 @@ export default {
isOpen: Boolean isOpen: Boolean
}, },
data() { data() {
return {} return {
showChangelogModal: false
}
}, },
computed: { computed: {
Source() { Source() {
@ -129,18 +133,24 @@ export default {
githubTagUrl() { githubTagUrl() {
return this.versionData.githubTagUrl return this.versionData.githubTagUrl
}, },
currentVersionChangelog() {
return this.versionData.currentVersionChangelog || 'No Changelog Available'
},
streamLibraryItem() { streamLibraryItem() {
return this.$store.state.streamLibraryItem return this.$store.state.streamLibraryItem
} }
}, },
methods: { methods: {
clickChangelog(){
this.showChangelogModal = true
},
clickOutside() { clickOutside() {
if (!this.isOpen) return if (!this.isOpen) return
this.closeDrawer() this.closeDrawer()
}, },
closeDrawer() { closeDrawer() {
this.$emit('update:isOpen', false) this.$emit('update:isOpen', false)
} },
} }
} }
</script> </script>

View file

@ -88,6 +88,7 @@ export default {
if (this.page === 'collections') return "You haven't made any collections yet" if (this.page === 'collections') return "You haven't made any collections yet"
if (this.hasFilter) { if (this.hasFilter) {
if (this.filterName === 'Issues') return 'No Issues' 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 for filter "${this.filterName}: ${this.filterValue}"`
} }
return 'No results' return 'No results'
@ -109,24 +110,20 @@ export default {
return this.$store.getters['user/getUserSetting']('collapseSeries') return this.$store.getters['user/getUserSetting']('collapseSeries')
}, },
coverAspectRatio() { coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio') return this.$store.getters['libraries/getBookCoverAspectRatio']
},
bookshelfView() {
return this.$store.getters['getServerSetting']('bookshelfView')
}, },
sortingIgnorePrefix() { sortingIgnorePrefix() {
return this.$store.getters['getServerSetting']('sortingIgnorePrefix') return this.$store.getters['getServerSetting']('sortingIgnorePrefix')
}, },
isCoverSquareAspectRatio() { isCoverSquareAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE return this.coverAspectRatio == 1
},
bookshelfView() {
return this.$store.getters['getBookshelfView']
}, },
isAlternativeBookshelfView() { isAlternativeBookshelfView() {
// if (!this.isEntityBook) return false // Only used for bookshelf showing books
return this.bookshelfView === this.$constants.BookshelfView.TITLES return this.bookshelfView === this.$constants.BookshelfView.TITLES
}, },
bookCoverAspectRatio() {
return this.isCoverSquareAspectRatio ? 1 : 1.6
},
hasFilter() { hasFilter() {
return this.filterBy && this.filterBy !== 'all' return this.filterBy && this.filterBy !== 'all'
}, },

View file

@ -83,17 +83,21 @@
</nuxt-link> </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' }"> <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> <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> <p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
</div> </div>
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version"/>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
data() { data() {
return {} return {
showChangelogModal: false
}
}, },
computed: { computed: {
Source() { Source() {
@ -171,17 +175,21 @@ export default {
hasUpdate() { hasUpdate() {
return !!this.versionData.hasUpdate return !!this.versionData.hasUpdate
}, },
latestVersion() {
return this.versionData.latestVersion
},
githubTagUrl() { githubTagUrl() {
return this.versionData.githubTagUrl return this.versionData.githubTagUrl
}, },
currentVersionChangelog() {
return this.versionData.currentVersionChangelog || 'No Changelog Available'
},
streamLibraryItem() { streamLibraryItem() {
return this.$store.state.streamLibraryItem return this.$store.state.streamLibraryItem
} }
}, },
methods: {}, methods: {
clickChangelog(){
this.showChangelogModal = true
}
},
mounted() {} mounted() {}
} }
</script> </script>

View file

@ -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 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" /> <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' }"> <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> </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 class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-20 sm:pl-24'">
<div> <div>
@ -77,16 +77,13 @@ export default {
}, },
computed: { computed: {
coverAspectRatio() { coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio') return this.$store.getters['libraries/getBookCoverAspectRatio']
},
bookCoverAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
}, },
bookCoverWidth() { bookCoverWidth() {
return 88 return 88
}, },
bookCoverPosTop() { bookCoverPosTop() {
if (this.bookCoverAspectRatio === 1) return -10 if (this.coverAspectRatio == 1) return -10
return -64 return -64
}, },
cover() { cover() {
@ -364,7 +361,11 @@ export default {
var episodeId = payload.episodeId || null var episodeId = payload.episodeId || null
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) { 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 return
} }
@ -377,7 +378,11 @@ export default {
libraryItem, libraryItem,
episodeId 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() { pauseItem() {
this.playerHandler.pause() this.playerHandler.pause()
@ -389,11 +394,13 @@ export default {
}, },
mounted() { mounted() {
this.$eventBus.$on('cast-session-active', this.castSessionActive) 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('play-item', this.playLibraryItem)
this.$eventBus.$on('pause-item', this.pauseItem) this.$eventBus.$on('pause-item', this.pauseItem)
}, },
beforeDestroy() { beforeDestroy() {
this.$eventBus.$off('cast-session-active', this.castSessionActive) 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('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem) this.$eventBus.$off('pause-item', this.pauseItem)
} }

View file

@ -1,16 +1,20 @@
<template> <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="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' }"> <div class="min-w-12 max-w-12 md:min-w-20 md:max-w-20">
<img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" /> <div class="w-full bg-primary">
</div> <img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" />
<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> </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-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"> <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"> <p class="leading-3 text-xs text-gray-400">
@ -25,7 +29,7 @@
<div v-else class="px-4 flex-grow"> <div v-else class="px-4 flex-grow">
<h1>{{ book.title }}</h1> <h1>{{ book.title }}</h1>
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p> <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> <p class="text-xs text-gray-400 leading-5">{{ book.trackCount }} Episodes</p>
</div> </div>
</div> </div>
@ -63,7 +67,7 @@ export default {
selectMatch() { selectMatch() {
var book = { ...this.book } var book = { ...this.book }
book.cover = this.selectedCover book.cover = this.selectedCover
this.$emit('select', this.book) this.$emit('select', book)
}, },
clickCover(cover) { clickCover(cover) {
this.selectedCover = cover this.selectedCover = cover

View file

@ -31,7 +31,7 @@ export default {
}, },
computed: { computed: {
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
coverWidth() { coverWidth() {
if (this.bookCoverAspectRatio === 1) return 50 * 1.2 if (this.bookCoverAspectRatio === 1) return 50 * 1.2

View file

@ -78,6 +78,10 @@
</div> </div>
</ui-tooltip> </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 --> <!-- 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` }"> <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> <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
@ -249,14 +253,14 @@ export default {
}, },
displayTitle() { displayTitle() {
if (this.recentEpisode) return this.recentEpisode.title if (this.recentEpisode) return this.recentEpisode.title
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) { const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
return this.mediaMetadata.titleIgnorePrefix if (this.collapsedSeries) return ignorePrefix ? this.collapsedSeries.nameIgnorePrefix : this.collapsedSeries.name
} return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix : this.title
return this.title
}, },
displayLineTwo() { displayLineTwo() {
if (this.recentEpisode) return this.title if (this.recentEpisode) return this.title
if (this.isPodcast) return this.author if (this.isPodcast) return this.author
if (this.collapsedSeries) return ''
if (this.isAuthorBookshelfView) { if (this.isAuthorBookshelfView) {
return this.mediaMetadata.publishedYear || '' return this.mediaMetadata.publishedYear || ''
} }
@ -264,6 +268,7 @@ export default {
return this.author return this.author
}, },
displaySortLine() { displaySortLine() {
if (this.collapsedSeries) return null
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat) 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 === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt, 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 if (!this.isAlternativeBookshelfView && !this.isAuthorBookshelfView) return 0
else if (!this.displaySortLine) return 3 * this.sizeMultiplier else if (!this.displaySortLine) return 3 * this.sizeMultiplier
return 4.25 * this.sizeMultiplier return 4.25 * this.sizeMultiplier
},
rssFeed() {
if (this.booksInSeries) return null
return this.store.getters['feeds/getFeedForItem'](this.libraryItemId)
} }
}, },
methods: { methods: {
@ -499,7 +508,21 @@ export default {
} }
this.$emit('edit', this.libraryItem) 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 = { var updatePayload = {
isFinished: !this.itemIsFinished isFinished: !this.itemIsFinished
} }

View file

@ -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 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="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"> <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>
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div> <div 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="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` }"> <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>
<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 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` }"> <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> </div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center"> <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>
</div> </div>
</template> </template>
@ -39,7 +39,8 @@ export default {
seriesMount: { seriesMount: {
type: Object, type: Object,
default: () => null default: () => null
} },
sortingIgnorePrefix: Boolean
}, },
data() { data() {
return { return {
@ -65,6 +66,13 @@ export default {
title() { title() {
return this.series ? this.series.name : '' 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() { books() {
return this.series ? this.series.books || [] : [] return this.series ? this.series.books || [] : []
}, },

View file

@ -24,7 +24,7 @@ export default {
}, },
computed: { computed: {
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
name() { name() {
return this.series.name return this.series.name

View file

@ -116,6 +116,11 @@ export default {
text: 'Issues', text: 'Issues',
value: 'issues', value: 'issues',
sublist: false sublist: false
},
{
text: 'RSS Feed Open',
value: 'feed-open',
sublist: false
} }
], ],
podcastItems: [ podcastItems: [

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="sm:w-80 w-full sm:ml-6 relative"> <div class="sm:w-80 w-full relative">
<form @submit.prevent="submitSearch"> <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" /> <ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
</form> </form>

View file

@ -44,6 +44,10 @@ export default {
text: 'Author (Last, First)', text: 'Author (Last, First)',
value: 'media.metadata.authorNameLF' value: 'media.metadata.authorNameLF'
}, },
{
text: 'Published Year',
value: 'media.metadata.publishedYear'
},
{ {
text: 'Added At', text: 'Added At',
value: 'addedAt' value: 'addedAt'

View file

@ -125,6 +125,9 @@ export default {
}, },
userToken() { userToken() {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
},
resolution() {
return `${this.naturalWidth}x${this.naturalHeight}px`
} }
}, },
methods: { methods: {

View file

@ -1,6 +1,6 @@
<template> <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="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"> <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 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 class="absolute cover-bg" ref="coverBg" />
</div> </div>
@ -17,6 +17,8 @@
<p class="text-center font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p> <p class="text-center font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
</div> </div>
</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> </div>
</template> </template>
@ -54,6 +56,9 @@ export default {
}, },
placeholderCoverPadding() { placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier return 0.8 * this.sizeMultiplier
},
resolution() {
return `${this.naturalWidth}x${this.naturalHeight}px`
} }
}, },
methods: { methods: {

View file

@ -146,7 +146,6 @@ export default {
watch: { watch: {
show: { show: {
handler(newVal) { handler(newVal) {
console.log('accoutn modal show change', newVal)
if (newVal) { if (newVal) {
this.init() this.init()
} }
@ -162,6 +161,9 @@ export default {
this.$emit('input', val) this.$emit('input', val)
} }
}, },
user() {
return this.$store.state.user.user
},
title() { title() {
return this.isNew ? 'Add New Account' : `Update ${(this.account || {}).username}` 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}`) this.$toast.error(`Failed to update account: ${data.error}`)
} else { } else {
console.log('Account updated', data.user) 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.$toast.success('Account updated')
this.show = false this.show = false
} }
@ -305,7 +313,6 @@ export default {
this.isNew = !this.account this.isNew = !this.account
if (this.account) { if (this.account) {
console.log(this.account)
this.newUser = { this.newUser = {
username: this.account.username, username: this.account.username,
password: this.account.password, password: this.account.password,

View 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>

View file

@ -1,6 +1,11 @@
<template> <template>
<modals-modal v-model="show" name="bookmarks" :width="500" :height="'unset'"> <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"> <div v-if="show" class="w-full h-full">
<template v-for="bookmark in bookmarks"> <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" /> <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"> <div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
<p class="text-xl">No Bookmarks</p> <p class="text-xl">No Bookmarks</p>
</div> </div>
<div class="w-full h-px bg-white bg-opacity-10" /> <div v-if="!hideCreate" class="w-full h-px bg-white bg-opacity-10" />
<form @submit.prevent="submitCreateBookmark"> <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 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"> <div class="w-16 max-w-16 text-center">
<p class="text-sm font-mono text-gray-400"> <p class="text-sm font-mono text-gray-400">
@ -39,7 +44,8 @@ export default {
type: Number, type: Number,
default: 0 default: 0
}, },
libraryItemId: String libraryItemId: String,
hideCreate: Boolean
}, },
data() { data() {
return { return {

View file

@ -75,7 +75,7 @@ export default {
} }
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
collection() { collection() {
return this.$store.state.globals.selectedCollection || {} return this.$store.state.globals.selectedCollection || {}

View file

@ -8,10 +8,10 @@
<div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop> <div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
<div class="flex"> <div class="flex">
<div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80"> <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>
<div class="w-24 sm:w-28 md:w-40 p-1"> <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> </div>
<div class="flex justify-end mt-2 p-1"> <div class="flex justify-end mt-2 p-1">
@ -59,9 +59,26 @@ export default {
set(val) { set(val) {
this.$emit('input', val) this.$emit('input', val)
} }
},
isNewSeries() {
if (!this.selectedSeries || !this.selectedSeries.id) return false
return this.selectedSeries.id.startsWith('new')
} }
}, },
methods: { 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() { submitSeriesForm() {
if (this.$refs.newSeriesSelect) { if (this.$refs.newSeriesSelect) {
this.$refs.newSeriesSelect.blur() this.$refs.newSeriesSelect.blur()
@ -89,15 +106,15 @@ export default {
setTimeout(() => { setTimeout(() => {
this.content.style.transform = 'scale(1)' this.content.style.transform = 'scale(1)'
}, 10) }, 10)
document.documentElement.classList.add('modal-open')
this.$store.commit('setInnerModalOpen', true) this.$store.commit('setInnerModalOpen', true)
this.$eventBus.$on('modal-hotkey', this.hotkey) this.$eventBus.$on('modal-hotkey', this.hotkey)
this.setInputFocus()
}, },
setHide() { setHide() {
if (this.content) this.content.style.transform = 'scale(0)' if (this.content) this.content.style.transform = 'scale(0)'
if (this.el) this.el.remove() if (this.el) this.el.remove()
document.documentElement.classList.remove('modal-open')
this.$store.commit('setInnerModalOpen', false) this.$store.commit('setInnerModalOpen', false)
this.$eventBus.$off('modal-hotkey', this.hotkey) this.$eventBus.$off('modal-hotkey', this.hotkey)

View file

@ -1,5 +1,5 @@
<template> <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> <template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden"> <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> <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> <p v-if="deviceInfo.deviceType" class="mb-1">Type: {{ deviceInfo.deviceType }}</p>
</div> </div>
</div> </div>
<div class="flex items-center">
<ui-btn small color="error" @click.stop="deleteSessionClick">Delete</ui-btn>
</div>
</div> </div>
</modals-modal> </modals-modal>
</template> </template>
@ -110,7 +114,9 @@ export default {
} }
}, },
data() { data() {
return {} return {
processing: false
}
}, },
computed: { computed: {
show: { show: {
@ -147,7 +153,37 @@ export default {
return 'Unknown' 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() {} mounted() {}
} }
</script> </script>

View file

@ -50,7 +50,8 @@ export default {
return { return {
el: null, el: null,
content: null, content: null,
preventClickoutside: false preventClickoutside: false,
isShowingPrompt: false
} }
}, },
watch: { watch: {
@ -93,7 +94,7 @@ export default {
this.show = false this.show = false
}, },
clickBg(ev) { clickBg(ev) {
if (!this.show) return if (!this.show || this.isShowingPrompt) return
if (this.preventClickoutside) { if (this.preventClickoutside) {
this.preventClickoutside = false this.preventClickoutside = false
return return
@ -147,8 +148,16 @@ export default {
} else { } else {
console.warn('Invalid modal init', this.name) 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> </script>

View file

@ -70,7 +70,7 @@ export default {
return this.selectedLibraryItem ? this.selectedLibraryItem.media.metadata.title : '' return this.selectedLibraryItem ? this.selectedLibraryItem.media.metadata.title : ''
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
selectedLibraryItem() { selectedLibraryItem() {
return this.$store.state.selectedLibraryItem return this.$store.state.selectedLibraryItem

View file

@ -116,6 +116,9 @@ export default {
if (result.updated) { if (result.updated) {
this.$toast.success('Author updated') this.$toast.success('Author updated')
this.show = false this.show = false
} else if (result.merged) {
this.$toast.success('Author merged')
this.show = false
} else this.$toast.info('No updates were needed') } else this.$toast.info('No updates were needed')
} }
this.processing = false this.processing = false

View file

@ -1,6 +1,5 @@
<template> <template>
<div class="flex items-center px-4 py-4 justify-start relative hover:bg-bg" :class="wrapperClass" @click="click" @mouseover="mouseover" @mouseleave="mouseleave"> <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">
<!-- <span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-80'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> -->
<div class="w-16 max-w-16 text-center"> <div class="w-16 max-w-16 text-center">
<p class="text-sm font-mono text-gray-400"> <p class="text-sm font-mono text-gray-400">
{{ this.$secondsToTimestamp(bookmark.time) }} {{ this.$secondsToTimestamp(bookmark.time) }}

View 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>

View file

@ -46,12 +46,14 @@ export default {
{ {
id: 'chapters', id: 'chapters',
title: 'Chapters', title: 'Chapters',
component: 'modals-item-tabs-chapters' component: 'modals-item-tabs-chapters',
mediaType: 'book'
}, },
{ {
id: 'episodes', id: 'episodes',
title: 'Episodes', title: 'Episodes',
component: 'modals-item-tabs-episodes' component: 'modals-item-tabs-episodes',
mediaType: 'podcast'
}, },
{ {
id: 'files', id: 'files',
@ -66,7 +68,16 @@ export default {
{ {
id: 'manage', id: 'manage',
title: '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() { userCanDownload() {
return this.$store.getters['user/getUserCanDownload'] return this.$store.getters['user/getUserCanDownload']
}, },
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
availableTabs() { availableTabs() {
if (!this.userCanUpdate && !this.userCanDownload) return [] if (!this.userCanUpdate && !this.userCanDownload) return []
return this.tabs.filter((tab) => { return this.tabs.filter((tab) => {
if (tab.experimental && !this.showExperimentalFeatures) return false if (tab.experimental && !this.showExperimentalFeatures) return false
if (tab.id === 'manage' && (this.isMissing || this.mediaType !== 'book')) return false if (tab.mediaType && this.mediaType !== tab.mediaType) return false
if (this.mediaType == 'podcast' && tab.id == 'chapters') return false if (tab.admin && !this.userIsAdminOrUp) return false
if (this.mediaType == 'book' && tab.id == 'episodes') 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.userCanDownload) return true
if (tab.id !== 'manage' && tab.id !== 'files' && this.userCanUpdate) return true if (tab.id !== 'manage' && tab.id !== 'files' && this.userCanUpdate) return true
@ -190,7 +205,6 @@ export default {
if (prevBook) { if (prevBook) {
this.unregisterListeners() this.unregisterListeners()
this.libraryItem = prevBook this.libraryItem = prevBook
this.selectedTab = 'details'
this.$store.commit('setSelectedLibraryItem', prevBook) this.$store.commit('setSelectedLibraryItem', prevBook)
this.$nextTick(this.registerListeners) this.$nextTick(this.registerListeners)
} else { } else {
@ -210,7 +224,6 @@ export default {
if (nextBook) { if (nextBook) {
this.unregisterListeners() this.unregisterListeners()
this.libraryItem = nextBook this.libraryItem = nextBook
this.selectedTab = 'details'
this.$store.commit('setSelectedLibraryItem', nextBook) this.$store.commit('setSelectedLibraryItem', nextBook)
this.$nextTick(this.registerListeners) this.$nextTick(this.registerListeners)
} else { } else {

View file

@ -31,9 +31,9 @@
<div v-if="showLocalCovers" class="flex items-center justify-center"> <div v-if="showLocalCovers" class="flex items-center justify-center">
<template v-for="cover in localCovers"> <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' }"> <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>
</div> </div>
</template> </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"> <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> <p v-if="!coversFound.length">No Covers Found</p>
<template v-for="cover in coversFound"> <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" /> <covers-preview-cover :src="cover" :width="80" show-open-new-tab :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div> </div>
</template> </template>
@ -129,11 +129,8 @@ export default {
else if (this.provider == 'itunes') return 'Search Term' else if (this.provider == 'itunes') return 'Search Term'
return 'Search Title' return 'Search Title'
}, },
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
},
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6 return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
libraryItemId() { libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null return this.libraryItem ? this.libraryItem.id : null

View file

@ -1,14 +1,14 @@
<template> <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"> <form @submit.prevent="submitSearch">
<div class="flex items-center justify-start -mx-1 h-20"> <div class="flex flex-wrap md:flex-nowrap items-center justify-start -mx-1">
<div class="w-40 px-1"> <div class="w-36 px-1">
<ui-dropdown v-model="provider" :items="providers" label="Provider" small /> <ui-dropdown v-model="provider" :items="providers" label="Provider" small />
</div> </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" /> <ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" placeholder="Search" />
</div> </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" /> <ui-text-input-with-label v-model="searchAuthor" label="Author" />
</div> </div>
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn> <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"> <div v-show="!processing && !searchResults.length && hasSearched" class="flex h-full items-center justify-center">
<p>No Results</p> <p>No Results</p>
</div> </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"> <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" /> <cards-book-match-card :key="index" :book="res" :is-podcast="isPodcast" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
</template> </template>
</div> </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 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-2"> <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"> <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> <span class="material-icons text-3xl">arrow_back</span>
</div> </div>
<p class="text-xl pl-3">Update Book Details</p> <p class="text-xl pl-3">Update Book Details</p>
</div> </div>
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
<form @submit.prevent="submitMatchUpdate"> <form @submit.prevent="submitMatchUpdate">
<div v-if="selectedMatch.cover" class="flex items-center py-2"> <div v-if="selectedMatch.cover" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.cover" /> <ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" label="Cover" class="flex-grow ml-4" /> <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>
<div v-if="selectedMatch.title" class="flex items-center py-2"> <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"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" label="Title" /> <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> <p v-if="mediaMetadata.title" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.title || '' }}</p>
</div> </div>
</div> </div>
<div v-if="selectedMatch.subtitle" class="flex items-center py-2"> <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"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" label="Subtitle" /> <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> <p v-if="mediaMetadata.subtitle" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.subtitle || '' }}</p>
</div> </div>
</div> </div>
<div v-if="selectedMatch.author" class="flex items-center py-2"> <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"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" label="Author" /> <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> <p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.authorName || '' }}</p>
</div> </div>
</div> </div>
<div v-if="selectedMatch.narrator" class="flex items-center py-2"> <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"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.narrator" :disabled="!selectedMatchUsage.narrator" label="Narrator" /> <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> <p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.narratorName || '' }}</p>
</div> </div>
</div> </div>
<div v-if="selectedMatch.description" class="flex items-center py-2"> <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" /> <ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
</div> </div>
<div v-if="selectedMatch.publisher" class="flex items-center py-2"> <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"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" label="Publisher" /> <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> <p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.publisher || '' }}</p>
</div> </div>
</div> </div>
<div v-if="selectedMatch.publishedYear" class="flex items-center py-2"> <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"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" label="Published Year" /> <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> <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>
<div v-if="selectedMatch.series" class="flex items-center py-2"> <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"> <div class="flex-grow ml-4">
<widgets-series-input-widget v-model="selectedMatch.series" /> <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> <p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.seriesName || '' }}</p>
</div> </div>
</div> </div>
<div v-if="selectedMatch.volumeNumber" class="flex items-center py-2"> <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" /> <ui-text-input-with-label v-model="selectedMatch.volumeNumber" :disabled="!selectedMatchUsage.volumeNumber" label="Volume Number" class="flex-grow ml-4" />
</div> </div>
<div v-if="selectedMatch.genres" class="flex items-center py-2"> <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"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" label="Genres" /> <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> <p v-if="mediaMetadata.genresList" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.genresList || '' }}</p>
</div> </div>
</div> </div>
<div v-if="selectedMatch.tags" class="flex items-center py-2"> <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"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.tags" :disabled="!selectedMatchUsage.tags" label="Tags" /> <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> <p v-if="mediaMetadata.tagsList" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.tagsList || '' }}</p>
</div> </div>
</div> </div>
<div v-if="selectedMatch.language" class="flex items-center py-2"> <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"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.language" :disabled="!selectedMatchUsage.language" label="Language" /> <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> <p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.language || '' }}</p>
</div> </div>
</div> </div>
<div v-if="selectedMatch.isbn" class="flex items-center py-2"> <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"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" /> <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> <p v-if="mediaMetadata.isbn" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.isbn || '' }}</p>
</div> </div>
</div> </div>
<div v-if="selectedMatch.asin" class="flex items-center py-2"> <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"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" /> <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> <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>
<div v-if="selectedMatch.itunesId" class="flex items-center py-2"> <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"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.itunesId" type="number" :disabled="!selectedMatchUsage.itunesId" label="iTunes ID" /> <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> <p v-if="mediaMetadata.itunesId" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.itunesId || '' }}</p>
</div> </div>
</div> </div>
<div v-if="selectedMatch.feedUrl" class="flex items-center py-2"> <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"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.feedUrl" :disabled="!selectedMatchUsage.feedUrl" label="RSS Feed URL" /> <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> <p v-if="mediaMetadata.feedUrl" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.feedUrl || '' }}</p>
</div> </div>
</div> </div>
<div v-if="selectedMatch.itunesPageUrl" class="flex items-center py-2"> <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"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.itunesPageUrl" :disabled="!selectedMatchUsage.itunesPageUrl" label="iTunes Page URL" /> <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> <p v-if="mediaMetadata.itunesPageUrl" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.itunesPageUrl || '' }}</p>
</div> </div>
</div> </div>
<div v-if="selectedMatch.releaseDate" class="flex items-center py-2"> <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"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.releaseDate" :disabled="!selectedMatchUsage.releaseDate" label="Release Date" /> <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> <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, itunesId: true,
feedUrl: true, feedUrl: true,
releaseDate: true releaseDate: true
} },
selectAll: true
} }
}, },
watch: { watch: {
@ -246,7 +253,7 @@ export default {
} }
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
providers() { providers() {
if (this.isPodcast) return this.$store.state.scanners.podcastProviders if (this.isPodcast) return this.$store.state.scanners.podcastProviders
@ -271,6 +278,14 @@ export default {
} }
}, },
methods: { 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() { persistProvider() {
try { try {
localStorage.setItem('book-provider', this.provider) localStorage.setItem('book-provider', this.provider)
@ -299,7 +314,7 @@ export default {
this.isProcessing = true this.isProcessing = true
this.lastSearch = searchQuery this.lastSearch = searchQuery
var searchEntity = this.isPodcast ? 'podcast' : 'books' 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) console.error('Failed', error)
return [] return []
}) })
@ -363,6 +378,10 @@ export default {
this.searchAuthor = this.libraryItem.media.metadata.authorName || '' this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
if (this.isPodcast) this.provider = 'itunes' if (this.isPodcast) this.provider = 'itunes'
else this.provider = localStorage.getItem('book-provider') || 'google' else this.provider = localStorage.getItem('book-provider') || 'google'
if (this.searchTitle) {
this.submitSearch()
}
}, },
selectMatch(match) { selectMatch(match) {
if (match) { if (match) {
@ -497,6 +516,11 @@ export default {
<style> <style>
.matchListWrapper { .matchListWrapper {
height: calc(100% - 80px); height: calc(100% - 124px);
}
@media (min-width: 768px) {
.matchListWrapper {
height: calc(100% - 80px);
}
} }
</style> </style>

View 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>

View file

@ -1,5 +1,5 @@
<template> <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 v-if="!showDirectoryPicker" class="w-full h-full py-4">
<div class="flex flex-wrap md:flex-nowrap -mx-1"> <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"> <div class="w-2/5 md:w-72 px-1 py-1 md:py-0">

View file

@ -11,10 +11,10 @@
</template> </template>
</div> </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"> <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" :is="tabName" :is-new="!library" :library="libraryCopy" :processing.sync="processing" @update="updateLibrary" @close="show = false" /> <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"> <div class="flex justify-end">
<ui-btn @click="submit">{{ buttonText }}</ui-btn> <ui-btn @click="submit">{{ buttonText }}</ui-btn>
</div> </div>
@ -46,6 +46,11 @@ export default {
id: 'settings', id: 'settings',
title: 'Settings', title: 'Settings',
component: 'modals-libraries-library-settings' component: 'modals-libraries-library-settings'
},
{
id: 'schedule',
title: 'Schedule',
component: 'modals-libraries-schedule-scan'
} }
], ],
libraryCopy: null libraryCopy: null
@ -84,6 +89,7 @@ export default {
}, },
updateLibrary(library) { updateLibrary(library) {
this.mapLibraryToCopy(library) this.mapLibraryToCopy(library)
console.log('Updated library', this.libraryCopy)
}, },
getNewLibraryData() { getNewLibraryData() {
return { return {
@ -93,9 +99,11 @@ export default {
icon: 'database', icon: 'database',
mediaType: 'book', mediaType: 'book',
settings: { settings: {
coverAspectRatio: this.$constants.BookCoverAspectRatio.SQUARE,
disableWatcher: false, disableWatcher: false,
skipMatchingMediaWithAsin: false, skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false skipMatchingMediaWithIsbn: false,
autoScanCronExpression: null
} }
} }
}, },
@ -112,7 +120,9 @@ export default {
if (key === 'folders') { if (key === 'folders') {
this.libraryCopy.folders = library.folders.map((f) => ({ ...f })) this.libraryCopy.folders = library.folders.map((f) => ({ ...f }))
} else if (key === 'settings') { } else if (key === 'settings') {
this.libraryCopy.settings = { ...library.settings } for (const settingKey in library.settings) {
this.libraryCopy.settings[settingKey] = library.settings[settingKey]
}
} else { } else {
this.libraryCopy[key] = library[key] this.libraryCopy[key] = library[key]
} }
@ -134,6 +144,13 @@ export default {
submit() { submit() {
if (!this.validate()) return 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) { if (this.library) {
this.submitUpdateLibrary() this.submitUpdateLibrary()
} else { } else {

View file

@ -1,23 +1,32 @@
<template> <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="py-3">
<div class="flex items-center"> <div class="flex items-center">
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" @input="formUpdated" /> <ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" @input="formUpdated" />
<ui-toggle-switch v-else disabled :value="false" /> <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> </div>
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p> <p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
</div> </div>
<div v-if="mediaType == 'book'" class="py-3"> <div v-if="mediaType == 'book'" class="py-3">
<div class="flex items-center"> <div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" /> <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> </div>
<div v-if="mediaType == 'book'" class="py-3"> <div v-if="mediaType == 'book'" class="py-3">
<div class="flex items-center"> <div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" /> <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> </div>
</div> </div>
@ -35,9 +44,13 @@ export default {
data() { data() {
return { return {
provider: null, provider: null,
useSquareBookCovers: false,
disableWatcher: false, disableWatcher: false,
skipMatchingMediaWithAsin: false, skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false skipMatchingMediaWithIsbn: false,
tooltips: {
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers'
}
} }
}, },
computed: { computed: {
@ -59,6 +72,7 @@ export default {
getLibraryData() { getLibraryData() {
return { return {
settings: { settings: {
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
disableWatcher: !!this.disableWatcher, disableWatcher: !!this.disableWatcher,
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin, skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn
@ -69,6 +83,7 @@ export default {
this.$emit('update', this.getLibraryData()) this.$emit('update', this.getLibraryData())
}, },
init() { init() {
this.useSquareBookCovers = this.librarySettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
this.disableWatcher = !!this.librarySettings.disableWatcher this.disableWatcher = !!this.librarySettings.disableWatcher
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn

View 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>

View file

@ -5,33 +5,14 @@
<p class="font-book text-3xl text-white truncate">{{ title }}</p> <p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div> </div>
</template> </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="absolute -top-10 left-0 z-10 w-full flex">
<div class="flex flex-wrap"> <template v-for="tab in tabs">
<div class="w-1/5 p-1"> <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>
<ui-text-input-with-label v-model="newEpisode.season" label="Season" /> </template>
</div> </div>
<div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.episode" label="Episode" /> <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">
</div> <component v-if="libraryItem && show" :is="tabComponentName" :library-item="libraryItem" :episode="episode" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
<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> </div>
</modals-modal> </modals-modal>
</template> </template>
@ -41,25 +22,19 @@ export default {
data() { data() {
return { return {
processing: false, processing: false,
newEpisode: { selectedTab: 'details',
season: null, tabs: [
episode: null, {
episodeType: null, id: 'details',
title: null, title: 'Details',
subtitle: null, component: 'modals-podcast-tabs-episode-details'
description: null, },
pubDate: null, {
publishedAt: null id: 'match',
}, title: 'Match',
pubDateInput: null component: 'modals-podcast-tabs-episode-match'
} }
}, ]
watch: {
episode: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
} }
}, },
computed: { computed: {
@ -77,67 +52,29 @@ export default {
episode() { episode() {
return this.$store.state.globals.selectedEpisode return this.$store.state.globals.selectedEpisode
}, },
episodeId() {
return this.episode ? this.episode.id : null
},
title() { title() {
if (!this.libraryItem) return '' if (!this.libraryItem) return ''
return this.libraryItem.media.metadata.title || 'Unknown' return this.libraryItem.media.metadata.title || 'Unknown'
},
tabComponentName() {
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
return _tab ? _tab.component : ''
} }
}, },
methods: { methods: {
updatePubDate(val) { selectTab(tab) {
if (val) { this.selectedTab = tab
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)
})
} }
}, },
mounted() {} mounted() {}
} }
</script> </script>
<style scoped>
.tab {
height: 40px;
}
.tab.tab-selected {
height: 41px;
}
</style>

View file

@ -7,16 +7,17 @@
</template> </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 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"> <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 Are you sure you want to remove episode<br /><span class="text-base">{{ episodeTitle }}</span
>? >?
</p> </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> <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>
<div class="flex justify-between items-center pt-4"> <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-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>
</div> </div>
</modals-modal> </modals-modal>
@ -30,9 +31,9 @@ export default {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
episode: { episodes: {
type: Object, type: Array,
default: () => {} default: () => []
} }
}, },
data() { data() {
@ -55,34 +56,49 @@ export default {
this.$emit('input', val) this.$emit('input', val)
} }
}, },
episode() {
if (this.episodes.length === 1) return this.episodes[0]
return null
},
title() { title() {
if (this.episodes.length > 1) return `Remove ${this.episodes.length} episodes`
return 'Remove Episode' return 'Remove Episode'
}, },
episodeId() { btnText() {
return this.episode ? this.episode.id : null 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() { episodeTitle() {
return this.episode ? this.episode.title : null return this.episode ? this.episode.title : null
} }
}, },
methods: { methods: {
submit() { async submit() {
this.processing = true this.processing = true
var queryString = this.hardDeleteFile ? '?hard=1' : '' var queryString = this.hardDeleteFile ? '?hard=1' : ''
this.$axios for (const episode of this.episodes) {
.$delete(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}${queryString}`) const success = await this.$axios
.then(() => { .$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.processing = false
this.$toast.success('Podcast episode removed')
this.show = false this.show = false
}) this.$emit('clearSelected')
.catch((error) => { return
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.processing = false
}) this.$toast.success(`${this.episodes.length} episode${this.episodes.length > 1 ? 's' : ''} removed`)
this.show = false
this.$emit('clearSelected')
} }
}, },
mounted() {} mounted() {}

View file

@ -66,7 +66,7 @@ export default {
return this.mediaMetadata.author return this.mediaMetadata.author
}, },
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
} }
}, },
methods: {}, methods: {},

View 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>

View 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>

View file

@ -226,6 +226,13 @@ export default {
}) })
this.updateTimestamp() 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) { seek(time) {
this.$emit('seek', time) this.$emit('seek', time)
}, },
@ -286,7 +293,10 @@ export default {
}, },
init() { init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1 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) if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
this.$emit('setPlaybackRate', this.playbackRate) this.$emit('setPlaybackRate', this.playbackRate)
}, },

View 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>

View file

@ -65,12 +65,10 @@ export default {
setTimeout(() => { setTimeout(() => {
this.content.style.transform = 'scale(1)' this.content.style.transform = 'scale(1)'
}, 10) }, 10)
document.documentElement.classList.add('modal-open')
}, },
setHide() { setHide() {
this.content.style.transform = 'scale(0)' this.content.style.transform = 'scale(0)'
this.el.remove() this.el.remove()
document.documentElement.classList.remove('modal-open')
} }
}, },
mounted() { mounted() {

View file

@ -24,10 +24,10 @@
<td class="font-book"> <td class="font-book">
{{ chapter.title }} {{ chapter.title }}
</td> </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) }} {{ $secondsToTimestamp(chapter.start) }}
</td> </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) }} {{ $secondsToTimestamp(chapter.end) }}
</td> </td>
</tr> </tr>
@ -57,6 +57,9 @@ export default {
media() { media() {
return this.libraryItem ? this.libraryItem.media || {} : {} return this.libraryItem ? this.libraryItem.media || {} : {}
}, },
metadata() {
return this.media.metadata || {}
},
chapters() { chapters() {
return this.media.chapters || [] return this.media.chapters || []
}, },
@ -67,6 +70,30 @@ export default {
methods: { methods: {
clickBar() { clickBar() {
this.expanded = !this.expanded 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() {} mounted() {}

View file

@ -1,12 +1,13 @@
<template> <template>
<div class="w-full bg-primary bg-opacity-40"> <div class="w-full bg-primary bg-opacity-40">
<div class="w-full h-14 flex items-center px-4 bg-primary"> <div class="w-full h-14 flex items-center px-4 md:px-6 py-2 bg-primary">
<p>Collection List</p> <p class="pr-4">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-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>
<div class="flex-grow" /> <div class="flex-grow" />
<!-- <p v-if="totalDuration">{{ totalDurationPretty }}</p> --> <p v-if="totalDuration" class="text-sm text-gray-200">{{ totalDurationPretty }}</p>
</div> </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"> <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"> <transition-group type="transition" :name="!drag ? 'collection-book' : null">
@ -51,11 +52,18 @@ export default {
} }
}, },
computed: { computed: {
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
},
bookCoverAspectRatio() { 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: { methods: {

View file

@ -1,12 +1,12 @@
<template> <template>
<div class="w-full px-2 py-2 overflow-hidden relative" @mouseover="mouseover" @mouseleave="mouseleave" :class="isHovering ? 'bg-white bg-opacity-5' : ''"> <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-20"> <div v-if="book" class="flex h-16 md:h-20">
<div class="w-16 max-w-16 h-full"> <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"> <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> </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" /> <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="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"> <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() { userIsFinished() {
return this.itemProgress ? !!this.itemProgress.isFinished : false return this.itemProgress ? !!this.itemProgress.isFinished : false
}, },
coverSize() {
return this.$store.state.globals.isMobile ? 40 : 50
},
coverWidth() { coverWidth() {
if (this.bookCoverAspectRatio === 1) return 50 * 1.6 if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
return 50 return this.coverSize
} }
}, },
methods: { methods: {

View file

@ -6,7 +6,7 @@
<span class="material-icons" style="font-size: 1.4rem">add</span> <span class="material-icons" style="font-size: 1.4rem">add</span>
</div> </div>
</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"> <template v-for="library in libraryCopies">
<div :key="library.id" class="item"> <div :key="library.id" class="item">
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :dragging="drag" @edit="editLibrary" @click="setLibrary" /> <tables-library-item :library="library" :selected="currentLibraryId === library.id" :dragging="drag" @edit="editLibrary" @click="setLibrary" />

View file

@ -23,7 +23,7 @@
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" /> <path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg> </svg>
</div> </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 --> <!-- For mobile -->
<modals-dialog v-model="showMobileMenu" :title="menuTitle" :items="mobileMenuItems" @action="mobileMenuAction" /> <modals-dialog v-model="showMobileMenu" :title="menuTitle" :items="mobileMenuItems" @action="mobileMenuAction" />
@ -105,7 +105,7 @@ export default {
}, },
matchAll() { matchAll() {
this.$axios this.$axios
.$post(`/api/libraries/${this.library.id}/matchall`) .$get(`/api/libraries/${this.library.id}/matchall`)
.then(() => { .then(() => {
console.log('Starting scan for matches') console.log('Starting scan for matches')
}) })

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave"> <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"> <div class="flex-grow px-2">
<p class="text-sm font-semibold"> <p class="text-sm font-semibold">
{{ title }} {{ title }}
@ -8,6 +8,12 @@
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ subtitle }}</p> <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"> <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"> <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> <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-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-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
</ui-tooltip> </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> <ui-icon-btn v-if="userCanUpdate" icon="edit" borderless @click="clickEdit" />
<p v-if="publishedAt" class="px-4 text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p> <ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" />
</div> </div>
</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>
<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="flex h-full items-center">
<div class="mx-1"> <div class="mx-1">
<ui-icon-btn v-if="userCanUpdate" icon="edit" borderless @click="clickEdit" /> <ui-checkbox v-model="isSelected" @input="selectedUpdated" checkbox-bg="bg" />
</div>
<div class="mx-1">
<ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" />
</div> </div>
</div> </div>
</div> </div>
@ -46,13 +50,15 @@ export default {
episode: { episode: {
type: Object, type: Object,
default: () => {} default: () => {}
} },
selectionMode: Boolean
}, },
data() { data() {
return { return {
isProcessingReadUpdate: false, isProcessingReadUpdate: false,
processingRemove: false, processingRemove: false,
isHovering: false isHovering: false,
isSelected: false
} }
}, },
computed: { computed: {
@ -78,7 +84,7 @@ export default {
return this.$secondsToTimestamp(this.episode.duration) return this.$secondsToTimestamp(this.episode.duration)
}, },
isStreaming() { isStreaming() {
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id) return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id)
}, },
streamIsPlaying() { streamIsPlaying() {
return this.$store.state.streamIsPlaying && this.isStreaming return this.$store.state.streamIsPlaying && this.isStreaming
@ -104,8 +110,17 @@ export default {
} }
}, },
methods: { 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() { mouseover() {
// if (this.isDragging) return
this.isHovering = true this.isHovering = true
}, },
mouseleave() { 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 = { var updatePayload = {
isFinished: !this.userIsFinished isFinished: !this.userIsFinished
} }

View file

@ -3,14 +3,18 @@
<div class="flex items-center mb-4"> <div class="flex items-center mb-4">
<p class="text-lg mb-0 font-semibold">Episodes</p> <p class="text-lg mb-0 font-semibold">Episodes</p>
<div class="flex-grow" /> <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> </div>
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p> <p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
<template v-for="episode in episodesSorted"> <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> </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> </div>
</template> </template>
@ -28,7 +32,9 @@ export default {
sortKey: 'publishedAt', sortKey: 'publishedAt',
sortDesc: true, sortDesc: true,
selectedEpisode: null, selectedEpisode: null,
showPodcastRemoveModal: false showPodcastRemoveModal: false,
selectedEpisodes: [],
episodesToRemove: []
} }
}, },
watch: { watch: {
@ -37,6 +43,9 @@ export default {
} }
}, },
computed: { computed: {
isSelectionMode() {
return this.selectedEpisodes.length > 0
},
userCanUpdate() { userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate'] return this.$store.getters['user/getUserCanUpdate']
}, },
@ -59,8 +68,31 @@ export default {
} }
}, },
methods: { 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) { removeEpisode(episode) {
this.selectedEpisode = episode this.episodesToRemove = [episode]
this.showPodcastRemoveModal = true this.showPodcastRemoveModal = true
}, },
editEpisode(episode) { editEpisode(episode) {

View file

@ -89,6 +89,9 @@ export default {
// this.currentSearch = this.textInput // this.currentSearch = this.textInput
}, 100) }, 100)
}, },
setFocus() {
if (this.$refs.input && this.editable) this.$refs.input.focus()
},
inputFocus() { inputFocus() {
this.isFocused = true this.isFocused = true
}, },

View file

@ -1,18 +1,18 @@
<template> <template>
<div v-if="currentLibrary" class="relative sm:w-36 h-8 px-1.5" v-click-outside="clickOutsideObj"> <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-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"> <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">
<span class="flex items-center justify-center sm:justify-start"> <div class="flex items-center justify-center sm:justify-start">
<widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-2" /> <widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" />
<span class="hidden sm:block">{{ currentLibrary.name }}</span> <span class="hidden sm:block truncate">{{ currentLibrary.name }}</span>
</span> </div>
</button> </button>
<transition name="menu"> <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"> <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)"> <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"> <div class="flex items-center px-2">
<widgets-library-icon :icon="library.icon" class="mr-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> <span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>
</div> </div>
</li> </li>

View file

@ -36,7 +36,8 @@ export default {
data() { data() {
return { return {
showPassword: false, showPassword: false,
isHovering: false isHovering: false,
isFocused: false
} }
}, },
computed: { computed: {
@ -66,9 +67,11 @@ export default {
this.inputValue = '' this.inputValue = ''
}, },
focused() { focused() {
this.isFocused = true
this.$emit('focus') this.$emit('focus')
}, },
blurred() { blurred() {
this.isFocused = false
this.$emit('blur') this.$emit('blur')
}, },
change(e) { change(e) {
@ -80,6 +83,9 @@ export default {
blur() { blur() {
if (this.$refs.input) this.$refs.input.blur() if (this.$refs.input) this.$refs.input.blur()
}, },
setFocus() {
if (this.$refs.input) this.$refs.input.focus()
},
mouseover() { mouseover() {
this.isHovering = true this.isHovering = true
}, },

View file

@ -1,8 +1,10 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }"> <slot>
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em> <p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
</p> {{ 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" /> <ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" @blur="inputBlurred" />
</div> </div>
</template> </template>
@ -34,6 +36,11 @@ export default {
} }
}, },
methods: { methods: {
setFocus() {
if (this.$refs.input && this.$refs.input.setFocus) {
this.$refs.input.setFocus()
}
},
blur() { blur() {
if (this.$refs.input && this.$refs.input.blur) { if (this.$refs.input && this.$refs.input.blur) {
this.$refs.input.blur() this.$refs.input.blur()

View 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>

View file

@ -41,13 +41,6 @@ export default {
this.setTooltipPosition(this.tooltip) this.setTooltipPosition(this.tooltip)
} }
}, },
getTextWidth() {
var styles = {
'font-size': '0.75rem'
}
var size = this.$calculateTextSize(this.text, styles)
return size.width
},
createTooltip() { createTooltip() {
if (!this.$refs.box) return if (!this.$refs.box) return
var tooltip = document.createElement('div') var tooltip = document.createElement('div')

View file

@ -46,7 +46,7 @@ export default {
}, },
computed: { computed: {
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
cardScaleMulitiplier() { cardScaleMulitiplier() {
return this.height / 192 return this.height / 192

View 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>

View file

@ -46,7 +46,7 @@ export default {
}, },
computed: { computed: {
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
cardScaleMulitiplier() { cardScaleMulitiplier() {
return this.height / 192 return this.height / 192

View file

@ -46,7 +46,7 @@ export default {
}, },
computed: { computed: {
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
cardScaleMulitiplier() { cardScaleMulitiplier() {
return this.height / 192 return this.height / 192

View file

@ -1,5 +1,5 @@
<template> <template>
<div :class="`h-${size} w-${size}`"> <div :class="`h-${size} w-${size} min-w-${size}`">
<component :is="iconComponentName" /> <component :is="iconComponentName" />
</div> </div>
</template> </template>

View file

@ -1,7 +1,7 @@
<template> <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"> <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"> <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> <p>{{ item.text }}</p>
</div> </div>
</template> </template>

View file

@ -40,8 +40,20 @@
</div> </div>
</div> </div>
<div class="flex-grow px-1 pt-6"> <div class="flex items-center 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="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> </div>
</form> </form>
</div> </div>
@ -72,6 +84,7 @@ export default {
language: null language: null
}, },
autoDownloadEpisodes: false, autoDownloadEpisodes: false,
maxEpisodesToKeep: 0,
newTags: [] newTags: []
} }
}, },
@ -199,6 +212,9 @@ export default {
if (this.media.autoDownloadEpisodes !== this.autoDownloadEpisodes) { if (this.media.autoDownloadEpisodes !== this.autoDownloadEpisodes) {
updatePayload.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 { return {
updatePayload, updatePayload,
@ -220,6 +236,7 @@ export default {
this.details.explicit = !!this.mediaMetadata.explicit this.details.explicit = !!this.mediaMetadata.explicit
this.autoDownloadEpisodes = !!this.media.autoDownloadEpisodes this.autoDownloadEpisodes = !!this.media.autoDownloadEpisodes
this.maxEpisodesToKeep = this.media.maxEpisodesToKeep || 0
this.newTags = [...(this.media.tags || [])] this.newTags = [...(this.media.tags || [])]
}, },
submitForm() { submitForm() {

View file

@ -46,7 +46,7 @@ export default {
}, },
computed: { computed: {
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
cardScaleMulitiplier() { cardScaleMulitiplier() {
return this.height / 192 return this.height / 192

View file

@ -5,5 +5,10 @@
</template> </template>
<script> <script>
export default {} export default {
mounted() {
document.body.classList.remove('app-bar', 'app-bar-and-toolbar')
document.body.classList.add('no-bars')
}
}
</script> </script>

View file

@ -15,6 +15,7 @@
<modals-podcast-edit-episode /> <modals-podcast-edit-episode />
<modals-podcast-view-episode /> <modals-podcast-view-episode />
<modals-authors-edit-modal /> <modals-authors-edit-modal />
<prompt-confirm />
<readers-reader /> <readers-reader />
</div> </div>
</template> </template>
@ -40,6 +41,7 @@ export default {
if (this.$store.state.selectedLibraryItems) { if (this.$store.state.selectedLibraryItems) {
this.$store.commit('setSelectedLibraryItems', []) this.$store.commit('setSelectedLibraryItems', [])
} }
this.updateBodyClass()
} }
}, },
computed: { computed: {
@ -53,11 +55,23 @@ export default {
if (!this.$route.name) return false if (!this.$route.name) return false
return !this.$route.name.startsWith('config') && this.$store.state.libraries.currentLibraryId return !this.$route.name.startsWith('config') && this.$store.state.libraries.currentLibraryId
}, },
isShowingToolbar() {
return this.isShowingSideRail && this.$route.name !== 'upload' && this.$route.name !== 'account'
},
appContentMarginLeft() { appContentMarginLeft() {
return this.isShowingSideRail ? 80 : 0 return this.isShowingSideRail ? 80 : 0
} }
}, },
methods: { methods: {
updateBodyClass() {
if (this.isShowingToolbar) {
document.body.classList.remove('no-bars', 'app-bar')
document.body.classList.add('app-bar-and-toolbar')
} else {
document.body.classList.remove('no-bars', 'app-bar-and-toolbar')
document.body.classList.add('app-bar')
}
},
updateSocketConnectionToast(content, type, timeout) { updateSocketConnectionToast(content, type, timeout) {
if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) { if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) {
this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false) this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false)
@ -197,6 +211,12 @@ export default {
libraryItemUpdated(libraryItem) { libraryItemUpdated(libraryItem) {
if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) { if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) {
this.$store.commit('setSelectedLibraryItem', libraryItem) this.$store.commit('setSelectedLibraryItem', libraryItem)
if (this.$store.state.globals.selectedEpisode && libraryItem.mediaType === 'podcast') {
const episode = libraryItem.media.episodes.find((ep) => ep.id === this.$store.state.globals.selectedEpisode.id)
if (episode) {
this.$store.commit('globals/setSelectedEpisode', episode)
}
}
} }
this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem) this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem)
this.$store.commit('libraries/updateFilterDataWithItem', libraryItem) this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
@ -341,11 +361,11 @@ export default {
download.status = this.$constants.DownloadStatus.EXPIRED download.status = this.$constants.DownloadStatus.EXPIRED
this.$store.commit('downloads/addUpdateDownload', download) this.$store.commit('downloads/addUpdateDownload', download)
}, },
showErrorToast(message) { rssFeedOpen(data) {
this.$toast.error(message) this.$store.commit('feeds/addFeed', data)
}, },
showSuccessToast(message) { rssFeedClosed(data) {
this.$toast.success(message) this.$store.commit('feeds/removeFeed', data)
}, },
backupApplied() { backupApplied() {
// Force refresh // Force refresh
@ -417,9 +437,9 @@ export default {
this.socket.on('abmerge_killed', this.abmergeKilled) this.socket.on('abmerge_killed', this.abmergeKilled)
this.socket.on('abmerge_expired', this.abmergeExpired) this.socket.on('abmerge_expired', this.abmergeExpired)
// Toast Listeners // Feed Listeners
this.socket.on('show_error_toast', this.showErrorToast) this.socket.on('rss_feed_open', this.rssFeedOpen)
this.socket.on('show_success_toast', this.showSuccessToast) this.socket.on('rss_feed_closed', this.rssFeedClosed)
this.socket.on('backup_applied', this.backupApplied) this.socket.on('backup_applied', this.backupApplied)
}, },
@ -521,6 +541,7 @@ export default {
this.initializeSocket() this.initializeSocket()
}, },
mounted() { mounted() {
this.updateBodyClass()
this.resize() this.resize()
window.addEventListener('resize', this.resize) window.addEventListener('resize', this.resize)
window.addEventListener('keydown', this.keyDown) window.addEventListener('keydown', this.keyDown)

View file

@ -51,14 +51,14 @@ export default {
index, index,
width: this.entityWidth, width: this.entityWidth,
height: this.entityHeight, height: this.entityHeight,
bookCoverAspectRatio: this.bookCoverAspectRatio, bookCoverAspectRatio: this.coverAspectRatio,
bookshelfView: this.bookshelfView bookshelfView: this.bookshelfView,
sortingIgnorePrefix: !!this.sortingIgnorePrefix
} }
if (this.entityName === 'books') { if (this.entityName === 'books') {
props.filterBy = this.filterBy props.filterBy = this.filterBy
props.orderBy = this.orderBy props.orderBy = this.orderBy
props.sortingIgnorePrefix = !!this.sortingIgnorePrefix
} }
var _this = this var _this = this

View file

@ -48,7 +48,8 @@ module.exports = {
'@/plugins/constants.js', '@/plugins/constants.js',
'@/plugins/init.client.js', '@/plugins/init.client.js',
'@/plugins/axios.js', '@/plugins/axios.js',
'@/plugins/toast.js' '@/plugins/toast.js',
'@/plugins/utils.js'
], ],
// Auto import components: https://go.nuxtjs.dev/config-components // Auto import components: https://go.nuxtjs.dev/config-components
@ -108,15 +109,15 @@ module.exports = {
background_color: '#373838', background_color: '#373838',
icons: [ icons: [
{ {
src: '/icon64.png', src: '/icon.svg',
sizes: "64x64" sizes: "64x64"
}, },
{ {
src: '/icon192.png', src: '/icon.svg',
sizes: "192x192" sizes: "192x192"
}, },
{ {
src: '/Logo.png', src: '/icon.svg',
sizes: "512x512" sizes: "512x512"
} }
] ]

17442
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.0.23", "version": "2.1.2",
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@ -36,4 +36,4 @@
"postcss": "^8.3.6", "postcss": "^8.3.6",
"tailwindcss": "^3.1.4" "tailwindcss": "^3.1.4"
} }
} }

View file

@ -17,6 +17,7 @@
<div class="flex items-center"> <div class="flex items-center">
<p class="text-lg mb-4 font-semibold">Audiobook Chapters</p> <p class="text-lg mb-4 font-semibold">Audiobook Chapters</p>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" label="Show seconds" class="mx-2" />
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">Lookup</ui-btn> <ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">Lookup</ui-btn>
<ui-btn color="success" small @click="saveChapters">Save</ui-btn> <ui-btn color="success" small @click="saveChapters">Save</ui-btn>
<div class="w-40" /> <div class="w-40" />
@ -32,7 +33,8 @@
<div :key="chapter.id" class="flex py-1"> <div :key="chapter.id" class="flex py-1">
<div class="w-12">#{{ chapter.id + 1 }}</div> <div class="w-12">#{{ chapter.id + 1 }}</div>
<div class="w-32 px-1"> <div class="w-32 px-1">
<ui-text-input v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" /> <ui-text-input v-if="showSecondInputs" v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
<ui-time-picker v-else class="text-xs" v-model="chapter.start" :show-three-digit-hour="mediaDuration >= 360000" @change="checkChapters" />
</div> </div>
<div class="flex-grow px-1"> <div class="flex-grow px-1">
<ui-text-input v-model="chapter.title" class="text-xs" /> <ui-text-input v-model="chapter.title" class="text-xs" />
@ -136,7 +138,7 @@
<script> <script>
export default { export default {
async asyncData({ store, params, app, redirect, route }) { async asyncData({ store, params, app, redirect, from }) {
if (!store.getters['user/getUserCanUpdate']) { if (!store.getters['user/getUserCanUpdate']) {
return redirect('/?error=unauthorized') return redirect('/?error=unauthorized')
} }
@ -152,8 +154,12 @@ export default {
console.error('Invalid media type') console.error('Invalid media type')
return redirect('/') return redirect('/')
} }
var previousRoute = from ? from.fullPath : null
if (from && from.path === '/login') previousRoute = null
return { return {
libraryItem libraryItem,
previousRoute
} }
}, },
data() { data() {
@ -168,7 +174,8 @@ export default {
asinInput: null, asinInput: null,
findingChapters: false, findingChapters: false,
showFindChaptersModal: false, showFindChaptersModal: false,
chapterData: null chapterData: null,
showSecondInputs: false
} }
}, },
computed: { computed: {
@ -339,7 +346,6 @@ export default {
this.saving = true this.saving = true
console.log('udpated chapters', this.newChapters)
const payload = { const payload = {
chapters: this.newChapters chapters: this.newChapters
} }
@ -349,7 +355,11 @@ export default {
this.saving = false this.saving = false
if (data.updated) { if (data.updated) {
this.$toast.success('Chapters updated') this.$toast.success('Chapters updated')
this.$router.push(`/item/${this.libraryItem.id}`) if (this.previousRoute) {
this.$router.push(this.previousRoute)
} else {
this.$router.push(`/item/${this.libraryItem.id}`)
}
} else { } else {
this.$toast.info('No changes needed updating') this.$toast.info('No changes needed updating')
} }

View file

@ -144,12 +144,6 @@ export default {
isPodcastLibrary() { isPodcastLibrary() {
return this.mediaType === 'podcast' return this.mediaType === 'podcast'
}, },
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
},
bookCoverAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
},
streamLibraryItem() { streamLibraryItem() {
return this.$store.state.streamLibraryItem return this.$store.state.streamLibraryItem
}, },

View file

@ -8,8 +8,8 @@
</div> </div>
</div> </div>
<div class="flex-grow px-2 py-6 md:py-0 md:px-10"> <div class="flex-grow px-2 py-6 md:py-0 md:px-10">
<div class="flex sm:items-end flex-col sm:flex-row"> <div class="flex items-end flex-row flex-wrap md:flex-nowrap">
<h1 class="text-2xl md:text-3xl font-sans"> <h1 class="text-2xl md:text-3xl font-sans w-full md:w-fit mb-4 md:mb-0">
{{ collectionName }} {{ collectionName }}
</h1> </h1>
<div class="flex-grow" /> <div class="flex-grow" />
@ -65,7 +65,7 @@ export default {
}, },
computed: { computed: {
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
streamLibraryItem() { streamLibraryItem() {
return this.$store.state.streamLibraryItem return this.$store.state.streamLibraryItem

View file

@ -71,6 +71,11 @@ export default {
.configContent.page-library-stats { .configContent.page-library-stats {
width: 1200px; width: 1200px;
} }
@media (max-width: 1550px) {
.configContent.page-library-stats {
margin-left: 176px;
}
}
@media (max-width: 1240px) { @media (max-width: 1240px) {
.configContent { .configContent {
margin-left: 176px; margin-left: 176px;
@ -82,5 +87,8 @@ export default {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
} }
.configContent.page-library-stats {
margin-left: 0px;
}
} }
</style> </style>

View file

@ -8,12 +8,20 @@
<p class="text-base mb-4 text-gray-300">Backups include users, user progress, book details, server settings and covers stored in <span class="font-mono text-gray-100">/metadata/items</span>. <br />Backups <strong>do not</strong> include any files stored in your library folders.</p> <p class="text-base mb-4 text-gray-300">Backups include users, user progress, book details, server settings and covers stored in <span class="font-mono text-gray-100">/metadata/items</span>. <br />Backups <strong>do not</strong> include any files stored in your library folders.</p>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-toggle-switch v-model="dailyBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" /> <ui-toggle-switch v-model="enableBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" />
<ui-tooltip :text="dailyBackupsTooltip"> <ui-tooltip :text="backupsTooltip">
<p class="pl-4 text-lg">Run daily backups <span class="material-icons icon-text">info_outlined</span></p> <p class="pl-4 text-lg">Enable automatic backups <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div v-if="enableBackups" class="mb-6">
<div class="flex items-center pl-6">
<span class="material-icons-outlined text-black-50">schedule</span>
<p class="text-gray-100 px-2">{{ scheduleDescription }}</p>
<span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer" @click="showCronBuilder = !showCronBuilder">edit</span>
</div>
</div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<ui-text-input type="number" v-model="backupsToKeep" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateBackupsSettings" /> <ui-text-input type="number" v-model="backupsToKeep" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateBackupsSettings" />
@ -30,6 +38,8 @@
<tables-backups-table /> <tables-backups-table />
</div> </div>
<modals-backup-schedule-modal v-model="showCronBuilder" :cron-expression.sync="cronExpression" />
</div> </div>
</template> </template>
@ -38,10 +48,12 @@ export default {
data() { data() {
return { return {
updatingServerSettings: false, updatingServerSettings: false,
dailyBackups: true, enableBackups: true,
backupsToKeep: 2, backupsToKeep: 2,
maxBackupSize: 1, maxBackupSize: 1,
newServerSettings: {} cronExpression: '',
newServerSettings: {},
showCronBuilder: false
} }
}, },
watch: { watch: {
@ -53,14 +65,19 @@ export default {
} }
}, },
computed: { computed: {
dailyBackupsTooltip() { backupsTooltip() {
return 'Runs at 1:30am every day (your server time). Saved in /metadata/backups.' return 'Backups saved in /metadata/backups'
}, },
maxBackupSizeTooltip() { maxBackupSizeTooltip() {
return 'As a safeguard against misconfiguration, backups will fail if they exceed the configured size.' return 'As a safeguard against misconfiguration, backups will fail if they exceed the configured size.'
}, },
serverSettings() { serverSettings() {
return this.$store.state.serverSettings return this.$store.state.serverSettings
},
scheduleDescription() {
if (!this.cronExpression) return ''
const parsed = this.$parseCronExpression(this.cronExpression)
return parsed ? parsed.description : 'Custom cron expression ' + this.cronExpression
} }
}, },
methods: { methods: {
@ -74,7 +91,7 @@ export default {
return return
} }
var updatePayload = { var updatePayload = {
backupSchedule: this.dailyBackups ? '30 1 * * *' : false, backupSchedule: this.enableBackups ? this.cronExpression : false,
backupsToKeep: Number(this.backupsToKeep), backupsToKeep: Number(this.backupsToKeep),
maxBackupSize: Number(this.maxBackupSize) maxBackupSize: Number(this.maxBackupSize)
} }
@ -97,8 +114,9 @@ export default {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {} this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2 this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
this.dailyBackups = !!this.newServerSettings.backupSchedule this.enableBackups = !!this.newServerSettings.backupSchedule
this.maxBackupSize = this.newServerSettings.maxBackupSize || 1 this.maxBackupSize = this.newServerSettings.maxBackupSize || 1
this.cronExpression = this.newServerSettings.backupSchedule || '30 1 * * *'
} }
}, },
mounted() { mounted() {

View file

@ -62,7 +62,7 @@
<h2 class="font-semibold">Display</h2> <h2 class="font-semibold">Display</h2>
</div> </div>
<div class="flex items-center py-2"> <!-- <div class="flex items-center py-2">
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" /> <ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
<ui-tooltip :text="tooltips.coverAspectRatio"> <ui-tooltip :text="tooltips.coverAspectRatio">
<p class="pl-4"> <p class="pl-4">
@ -70,6 +70,15 @@
<span class="material-icons icon-text text-sm">info_outlined</span> <span class="material-icons icon-text text-sm">info_outlined</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> -->
<div class="flex items-center py-2">
<ui-toggle-switch v-model="homeUseAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateHomeAlternativeBookshelfView" />
<ui-tooltip :text="tooltips.bookshelfView">
<p class="pl-4">
Alternative bookshelf view for home page
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div> </div>
<div class="flex items-center py-2"> <div class="flex items-center py-2">
@ -167,6 +176,16 @@
</ui-tooltip> </ui-tooltip>
</div> </div>
<!-- <div class="flex items-center py-2">
<ui-text-input type="number" v-model="newServerSettings.scannerMaxThreads" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateScannerMaxThreads" />
<ui-tooltip :text="tooltips.scannerMaxThreads">
<p class="pl-4">
Max # of threads to use
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div> -->
<div class="pt-4"> <div class="pt-4">
<h2 class="font-semibold">Experimental Features</h2> <h2 class="font-semibold">Experimental Features</h2>
</div> </div>
@ -194,6 +213,16 @@
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<!-- <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerUseSingleThreadedProber" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerUseSingleThreadedProber', val)" />
<ui-tooltip :text="tooltips.scannerUseSingleThreadedProber">
<p class="pl-4">
Scanner use old single threaded audio prober
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div> -->
</div> </div>
</div> </div>
</div> </div>
@ -261,6 +290,7 @@ export default {
isResettingLibraryItems: false, isResettingLibraryItems: false,
updatingServerSettings: false, updatingServerSettings: false,
useSquareBookCovers: false, useSquareBookCovers: false,
homeUseAlternativeBookshelfView: false,
useAlternativeBookshelfView: false, useAlternativeBookshelfView: false,
isPurgingCache: false, isPurgingCache: false,
newServerSettings: {}, newServerSettings: {},
@ -280,6 +310,8 @@ export default {
enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle just for use by you)', enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle just for use by you)',
scannerPreferOverdriveMediaMarker: 'MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically', scannerPreferOverdriveMediaMarker: 'MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically',
shareStats: 'Allow users to share latest listening activity with other users on an opt-in basis' shareStats: 'Allow users to share latest listening activity with other users on an opt-in basis'
scannerUseSingleThreadedProber: 'The old scanner used a single thread. Leaving it in to use as a comparison for now.',
scannerMaxThreads: 'Number of concurrent media files to scan at a time. Value of 1 will be a slower scan but less CPU usage. <br><br>Value of 0 defaults to # of CPU cores for this server times 2 (i.e. 4-core CPU will be 8)'
}, },
showConfirmPurgeCache: false showConfirmPurgeCache: false
} }
@ -311,6 +343,26 @@ export default {
} }
}, },
methods: { methods: {
updateScannerMaxThreads(val) {
if (!val || isNaN(val)) {
this.$toast.error('Invalid max threads must be a number')
this.newServerSettings.scannerMaxThreads = 0
return
}
if (Number(val) < 0) {
this.$toast.error('Max threads must be >= 0')
this.newServerSettings.scannerMaxThreads = 0
return
}
if (Math.round(Number(val)) !== Number(val)) {
this.$toast.error('Max threads must be an integer')
this.newServerSettings.scannerMaxThreads = 0
return
}
this.updateServerSettings({
scannerMaxThreads: Number(val)
})
},
updateSortingPrefixes(val) { updateSortingPrefixes(val) {
if (!val || !val.length) { if (!val || !val.length) {
this.$toast.error('Must have at least 1 prefix') this.$toast.error('Must have at least 1 prefix')
@ -331,6 +383,11 @@ export default {
coverAspectRatio: val ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD coverAspectRatio: val ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD
}) })
}, },
updateHomeAlternativeBookshelfView(val) {
this.updateServerSettings({
homeBookshelfView: val ? this.$constants.BookshelfView.TITLES : this.$constants.BookshelfView.STANDARD
})
},
updateAlternativeBookshelfView(val) { updateAlternativeBookshelfView(val) {
this.updateServerSettings({ this.updateServerSettings({
bookshelfView: val ? this.$constants.BookshelfView.TITLES : this.$constants.BookshelfView.STANDARD bookshelfView: val ? this.$constants.BookshelfView.TITLES : this.$constants.BookshelfView.STANDARD
@ -362,6 +419,7 @@ export default {
this.useSquareBookCovers = this.newServerSettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE this.useSquareBookCovers = this.newServerSettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
this.homeUseAlternativeBookshelfView = this.newServerSettings.homeBookshelfView === this.$constants.BookshelfView.TITLES
this.useAlternativeBookshelfView = this.newServerSettings.bookshelfView === this.$constants.BookshelfView.TITLES this.useAlternativeBookshelfView = this.newServerSettings.bookshelfView === this.$constants.BookshelfView.TITLES
}, },
resetLibraryItems() { resetLibraryItems() {

View file

@ -3,7 +3,7 @@
<h1 class="text-xl">Stats for library {{ currentLibraryName }}</h1> <h1 class="text-xl">Stats for library {{ currentLibraryName }}</h1>
<stats-preview-icons v-if="totalItems" :library-stats="libraryStats" /> <stats-preview-icons v-if="totalItems" :library-stats="libraryStats" />
<div class="flex md:flex-row flex-wrap justify-between flex-col mt-8"> <div class="flex lg:flex-row flex-wrap justify-between flex-col mt-8">
<div class="w-80 my-6 mx-auto"> <div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">Top 5 Genres</h1> <h1 class="text-2xl mb-4 font-book">Top 5 Genres</h1>
<p v-if="!top5Genres.length">No Genres</p> <p v-if="!top5Genres.length">No Genres</p>

View file

@ -161,6 +161,7 @@ export default {
}, },
beforeDestroy() { beforeDestroy() {
if (!this.$root.socket) return if (!this.$root.socket) return
this.$root.socket.emit('remove_log_listener')
this.$root.socket.off('daily_logs', this.dailyLogsLoaded) this.$root.socket.off('daily_logs', this.dailyLogsLoaded)
this.$root.socket.off('log', this.logEvtReceived) this.$root.socket.off('log', this.logEvtReceived)
} }

View file

@ -39,7 +39,7 @@
<td class="text-center"> <td class="text-center">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p> <p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</td> </td>
<td class="text-center"> <td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p> <p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td> </td>
<td class="text-center hidden sm:table-cell"> <td class="text-center hidden sm:table-cell">
@ -57,8 +57,8 @@
</div> </div>
<p v-else class="text-white text-opacity-50">No sessions yet...</p> <p v-else class="text-white text-opacity-50">No sessions yet...</p>
</div> </div>
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" /> <modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" />
</div> </div>
</template> </template>
@ -89,7 +89,8 @@ export default {
total: 0, total: 0,
currentPage: 0, currentPage: 0,
userFilter: null, userFilter: null,
selectedUser: '' selectedUser: '',
processingGoToTimestamp: false
} }
}, },
computed: { computed: {
@ -110,6 +111,44 @@ export default {
} }
}, },
methods: { methods: {
removedSession() {
this.loadSessions(this.currentPage)
},
async clickCurrentTime(session) {
if (this.processingGoToTimestamp) return
this.processingGoToTimestamp = true
const libraryItem = await this.$axios.$get(`/api/items/${session.libraryItemId}`).catch((error) => {
console.error('Failed to get library item', error)
return null
})
if (!libraryItem) {
this.$toast.error('Failed to get library item')
this.processingGoToTimestamp = false
return
}
if (session.episodeId && !libraryItem.media.episodes.find((ep) => ep.id === session.episodeId)) {
this.$toast.error('Failed to get podcast episode')
this.processingGoToTimestamp = false
return
}
const payload = {
message: `Start playback for "${session.displayTitle}" at ${this.$secondsToTimestamp(session.currentTime)}?`,
callback: (confirmed) => {
if (confirmed) {
this.$eventBus.$emit('play-item', {
libraryItemId: libraryItem.id,
episodeId: session.episodeId || null,
startTime: session.currentTime
})
}
this.processingGoToTimestamp = false
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
updateUserFilter() { updateUserFilter() {
this.loadSessions(0) this.loadSessions(0)
}, },

View file

@ -13,11 +13,12 @@
<widgets-online-indicator :value="!!userOnline" /> <widgets-online-indicator :value="!!userOnline" />
<h1 class="text-xl pl-2">{{ username }}</h1> <h1 class="text-xl pl-2">{{ username }}</h1>
</div> </div>
<div class="cursor-pointer text-gray-400 hover:text-white" @click="copyToClipboard(userToken)"> <div v-if="userToken" class="flex text-xs mt-4">
<p v-if="userToken" class="py-2 text-xs"> <ui-text-input-with-label label="API Token" :value="userToken" readonly />
<strong class="text-white">API Token: </strong><br /><span class="text-white">{{ userToken }}</span
><span class="material-icons pl-2 text-base">content_copy</span> <div class="px-1 mt-8 cursor-pointer" @click="copyToClipboard(userToken)">
</p> <span class="material-icons pl-2 text-base">content_copy</span>
</div>
</div> </div>
<div class="w-full h-px bg-white bg-opacity-10 my-2" /> <div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="py-2"> <div class="py-2">
@ -104,11 +105,8 @@ export default {
userToken() { userToken() {
return this.user.token return this.user.token
}, },
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
},
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6 return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
username() { username() {
return this.user.username return this.user.username
@ -138,12 +136,15 @@ export default {
this.$copyToClipboard(str, this) this.$copyToClipboard(str, this)
}, },
async init() { async init() {
this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions?page=0&itemsPerPage=10`).then((data) => { this.listeningSessions = await this.$axios
return data.sessions || [] .$get(`/api/users/${this.user.id}/listening-sessions?page=0&itemsPerPage=10`)
}).catch((err) => { .then((data) => {
console.error('Failed to load listening sesions', err) return data.sessions || []
return [] })
}) .catch((err) => {
console.error('Failed to load listening sesions', err)
return []
})
this.listeningStats = await this.$axios.$get(`/api/users/${this.user.id}/listening-stats`).catch((err) => { this.listeningStats = await this.$axios.$get(`/api/users/${this.user.id}/listening-stats`).catch((err) => {
console.error('Failed to load listening sesions', err) console.error('Failed to load listening sesions', err)
return [] return []

View file

@ -42,7 +42,7 @@
<td class="text-center"> <td class="text-center">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p> <p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</td> </td>
<td class="text-center"> <td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p> <p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td> </td>
<td class="text-center hidden sm:table-cell"> <td class="text-center hidden sm:table-cell">
@ -62,7 +62,7 @@
</div> </div>
</div> </div>
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" /> <modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" />
</div> </div>
</template> </template>
@ -85,7 +85,8 @@ export default {
listeningSessions: [], listeningSessions: [],
numPages: 0, numPages: 0,
total: 0, total: 0,
currentPage: 0 currentPage: 0,
processingGoToTimestamp: false
} }
}, },
computed: { computed: {
@ -97,6 +98,44 @@ export default {
} }
}, },
methods: { methods: {
removedSession() {
this.loadSessions(this.currentPage)
},
async clickCurrentTime(session) {
if (this.processingGoToTimestamp) return
this.processingGoToTimestamp = true
const libraryItem = await this.$axios.$get(`/api/items/${session.libraryItemId}`).catch((error) => {
console.error('Failed to get library item', error)
return null
})
if (!libraryItem) {
this.$toast.error('Failed to get library item')
this.processingGoToTimestamp = false
return
}
if (session.episodeId && !libraryItem.media.episodes.find((ep) => ep.id === session.episodeId)) {
this.$toast.error('Failed to get podcast episode')
this.processingGoToTimestamp = false
return
}
const payload = {
message: `Start playback for "${session.displayTitle}" at ${this.$secondsToTimestamp(session.currentTime)}?`,
callback: (confirmed) => {
if (confirmed) {
this.$eventBus.$emit('play-item', {
libraryItemId: libraryItem.id,
episodeId: session.episodeId || null,
startTime: session.currentTime
})
}
this.processingGoToTimestamp = false
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
prevPage() { prevPage() {
this.loadSessions(this.currentPage - 1) this.loadSessions(this.currentPage - 1)
}, },

View file

@ -7,11 +7,11 @@
<covers-book-cover :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- Item Progress Bar --> <!-- Item Progress Bar -->
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div> <div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1.5 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div>
<!-- Item Cover Overlay --> <!-- Item Cover Overlay -->
<div class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-30 opacity-0 hover:opacity-100 transition-opacity" @mousedown.prevent @mouseup.prevent> <div class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-30 opacity-0 hover:opacity-100 transition-opacity" @mousedown.prevent @mouseup.prevent>
<div v-show="showPlayButton && !streaming" class="h-full flex items-center justify-center pointer-events-none"> <div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none">
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="startStream"> <div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="startStream">
<span class="material-icons text-4xl">play_circle_filled</span> <span class="material-icons text-4xl">play_circle_filled</span>
</div> </div>
@ -24,12 +24,13 @@
<div class="flex-grow px-2 py-6 md:py-0 md:px-10"> <div class="flex-grow px-2 py-6 md:py-0 md:px-10">
<div class="flex justify-center"> <div class="flex justify-center">
<div class="mb-4"> <div class="mb-4">
<div class="flex sm:items-end flex-col sm:flex-row"> <h1 class="text-2xl md:text-3xl font-semibold">
<h1 class="text-2xl md:text-3xl font-sans"> {{ title }}
{{ title }} </h1>
</h1>
<p v-if="bookSubtitle" class="sm:ml-4 text-gray-400 text-xl md:text-2xl">{{ bookSubtitle }}</p> <p v-if="bookSubtitle" class="text-gray-200 text-xl md:text-2xl">{{ bookSubtitle }}</p>
</div>
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300 text-lg leading-7"> {{ _series.text }}</nuxt-link>
<template v-if="!isVideo"> <template v-if="!isVideo">
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p> <p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
@ -39,8 +40,6 @@
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p> <p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
</template> </template>
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300 text-lg leading-7"> {{ _series.text }}</nuxt-link>
<div v-if="narrator" class="flex py-0.5 mt-4"> <div v-if="narrator" class="flex py-0.5 mt-4">
<div class="w-32"> <div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Narrated By</span> <span class="text-white text-opacity-60 uppercase text-sm">Narrated By</span>
@ -129,12 +128,12 @@
<!-- Icon buttons --> <!-- Icon buttons -->
<div class="flex items-center justify-center md:justify-start pt-4"> <div class="flex items-center justify-center md:justify-start pt-4">
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream"> <ui-btn v-if="showPlayButton" :disabled="isStreaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span> <span v-show="!isStreaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
{{ streaming ? 'Playing' : 'Play' }} {{ isStreaming ? 'Playing' : 'Play' }}
</ui-btn> </ui-btn>
<ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2"> <ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">error</span> <span v-show="!isStreaming" class="material-icons -ml-2 pr-1 text-white">error</span>
{{ isMissing ? 'Missing' : 'Incomplete' }} {{ isMissing ? 'Missing' : 'Incomplete' }}
</ui-btn> </ui-btn>
@ -160,7 +159,11 @@
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" /> <ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
</ui-tooltip> </ui-tooltip>
<!-- Experimental RSS feed open --> <ui-tooltip v-if="bookmarks.length" text="Your Bookmarks" direction="top">
<ui-icon-btn :icon="bookmarks.length ? 'bookmarks' : 'bookmark_border'" class="mx-0.5" @click="clickBookmarksBtn" />
</ui-tooltip>
<!-- RSS feed -->
<ui-tooltip v-if="showRssFeedBtn" text="Open RSS Feed" direction="top"> <ui-tooltip v-if="showRssFeedBtn" text="Open RSS Feed" direction="top">
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeedUrl ? 'success' : 'primary'" outlined @click="clickRSSFeed" /> <ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeedUrl ? 'success' : 'primary'" outlined @click="clickRSSFeed" />
</ui-tooltip> </ui-tooltip>
@ -189,6 +192,7 @@
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" /> <modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
<modals-rssfeed-view-modal v-model="showRssFeedModal" :library-item="libraryItem" :feed-url="rssFeedUrl" /> <modals-rssfeed-view-modal v-model="showRssFeedModal" :library-item="libraryItem" :feed-url="rssFeedUrl" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
</div> </div>
</template> </template>
@ -222,7 +226,8 @@ export default {
podcastFeedEpisodes: [], podcastFeedEpisodes: [],
episodesDownloading: [], episodesDownloading: [],
episodeDownloadsQueued: [], episodeDownloadsQueued: [],
showRssFeedModal: false showRssFeedModal: false,
showBookmarksModal: false
} }
}, },
computed: { computed: {
@ -241,11 +246,8 @@ export default {
isFile() { isFile() {
return this.libraryItem.isFile return this.libraryItem.isFile
}, },
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
},
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6 return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
bookCoverWidth() { bookCoverWidth() {
return 208 return 208
@ -296,6 +298,10 @@ export default {
chapters() { chapters() {
return this.media.chapters || [] return this.media.chapters || []
}, },
bookmarks() {
if (this.isPodcast) return []
return this.$store.getters['user/getUserBookmarksForItem'](this.libraryItemId)
},
tracks() { tracks() {
return this.media.tracks || [] return this.media.tracks || []
}, },
@ -389,7 +395,7 @@ export default {
streamLibraryItem() { streamLibraryItem() {
return this.$store.state.streamLibraryItem return this.$store.state.streamLibraryItem
}, },
streaming() { isStreaming() {
return this.streamLibraryItem && this.streamLibraryItem.id === this.libraryItemId return this.streamLibraryItem && this.streamLibraryItem.id === this.libraryItemId
}, },
userCanUpdate() { userCanUpdate() {
@ -409,6 +415,31 @@ export default {
} }
}, },
methods: { methods: {
clickBookmarksBtn() {
this.showBookmarksModal = true
},
selectBookmark(bookmark) {
if (!bookmark) return
if (this.isStreaming) {
this.$eventBus.$emit('playback-seek', bookmark.time)
} else if (this.streamLibraryItem) {
this.showBookmarksModal = false
console.log('Already streaming library item so ask about it')
const payload = {
message: `Start playback for "${this.title}" at ${this.$secondsToTimestamp(bookmark.time)}?`,
callback: (confirmed) => {
if (confirmed) {
this.startStream(bookmark.time)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
} else {
this.startStream(bookmark.time)
}
this.showBookmarksModal = false
},
clearDownloadQueue() { clearDownloadQueue() {
if (confirm('Are you sure you want to clear episode download queue?')) { if (confirm('Are you sure you want to clear episode download queue?')) {
this.$axios this.$axios
@ -453,7 +484,21 @@ export default {
openEbook() { openEbook() {
this.$store.commit('showEReader', this.libraryItem) this.$store.commit('showEReader', this.libraryItem)
}, },
toggleFinished() { toggleFinished(confirmed = false) {
if (!this.userIsFinished && this.progressPercent > 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 = { var updatePayload = {
isFinished: !this.userIsFinished isFinished: !this.userIsFinished
} }
@ -470,7 +515,7 @@ export default {
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`) this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
}) })
}, },
startStream() { startStream(startTime = null) {
var episodeId = null var episodeId = null
if (this.isPodcast) { if (this.isPodcast) {
var episode = this.podcastEpisodes.find((ep) => { var episode = this.podcastEpisodes.find((ep) => {
@ -483,7 +528,8 @@ export default {
this.$eventBus.$emit('play-item', { this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItem.id, libraryItemId: this.libraryItem.id,
episodeId episodeId,
startTime
}) })
}, },
editClick() { editClick() {

View file

@ -124,9 +124,10 @@ export default {
location.reload() location.reload()
}, },
setUser({ user, userDefaultLibraryId, serverSettings, Source }) { setUser({ user, userDefaultLibraryId, serverSettings, Source, feeds }) {
this.$store.commit('setServerSettings', serverSettings) this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('setSource', Source) this.$store.commit('setSource', Source)
this.$store.commit('feeds/setFeeds', feeds)
if (serverSettings.chromecastEnabled) { if (serverSettings.chromecastEnabled) {
console.log('Chromecast enabled import script') console.log('Chromecast enabled import script')

View file

@ -18,6 +18,7 @@ export default class PlayerHandler {
this.isHlsTranscode = false this.isHlsTranscode = false
this.isVideo = false this.isVideo = false
this.currentSessionId = null this.currentSessionId = null
this.startTimeOverride = undefined // Used for starting playback at a specific time (i.e. clicking bookmark from library item page)
this.startTime = 0 this.startTime = 0
this.failedProgressSyncs = 0 this.failedProgressSyncs = 0
@ -51,12 +52,13 @@ export default class PlayerHandler {
return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId) return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId)
} }
load(libraryItem, episodeId, playWhenReady, playbackRate) { load(libraryItem, episodeId, playWhenReady, playbackRate, startTimeOverride = undefined) {
this.libraryItem = libraryItem this.libraryItem = libraryItem
this.episodeId = episodeId this.episodeId = episodeId
this.playWhenReady = playWhenReady this.playWhenReady = playWhenReady
this.initialPlaybackRate = playbackRate this.initialPlaybackRate = playbackRate
this.isVideo = libraryItem.mediaType === 'video' this.isVideo = libraryItem.mediaType === 'video'
this.startTimeOverride = (startTimeOverride == null || isNaN(startTimeOverride)) ? undefined : Number(startTimeOverride)
if (!this.player) this.switchPlayer(playWhenReady) if (!this.player) this.switchPlayer(playWhenReady)
else this.prepare() else this.prepare()
@ -142,11 +144,13 @@ export default class PlayerHandler {
} else { } else {
this.stopPlayInterval() this.stopPlayInterval()
} }
if (this.playerState === 'LOADED' || this.playerState === 'PLAYING') { if (this.player) {
this.ctx.setDuration(this.getDuration()) if (this.playerState === 'LOADED' || this.playerState === 'PLAYING') {
} this.ctx.setDuration(this.getDuration())
if (this.playerState !== 'LOADING') { }
this.ctx.setCurrentTime(this.player.getCurrentTime()) if (this.playerState !== 'LOADING') {
this.ctx.setCurrentTime(this.player.getCurrentTime())
}
} }
this.ctx.setPlaying(this.playerState === 'PLAYING') this.ctx.setPlaying(this.playerState === 'PLAYING')
@ -183,13 +187,14 @@ export default class PlayerHandler {
this.isVideo = session.libraryItem.mediaType === 'video' this.isVideo = session.libraryItem.mediaType === 'video'
this.playWhenReady = false this.playWhenReady = false
this.initialPlaybackRate = playbackRate this.initialPlaybackRate = playbackRate
this.startTimeOverride = undefined
this.prepareSession(session) this.prepareSession(session)
} }
prepareSession(session) { prepareSession(session) {
this.failedProgressSyncs = 0 this.failedProgressSyncs = 0
this.startTime = session.currentTime this.startTime = this.startTimeOverride !== undefined ? this.startTimeOverride : session.currentTime
this.currentSessionId = session.id this.currentSessionId = session.id
this.displayTitle = session.displayTitle this.displayTitle = session.displayTitle
this.displayAuthor = session.displayAuthor this.displayAuthor = session.displayAuthor

View file

@ -1,6 +1,6 @@
const SupportedFileTypes = { const SupportedFileTypes = {
image: ['png', 'jpg', 'jpeg', 'webp'], image: ['png', 'jpg', 'jpeg', 'webp'],
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'mp4', 'aac', 'wma', 'aiff', 'wav'], audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav'],
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
info: ['nfo'], info: ['nfo'],
text: ['txt'], text: ['txt'],
@ -75,6 +75,9 @@ const Hotkeys = {
} }
} }
export {
Constants
}
export default ({ app }, inject) => { export default ({ app }, inject) => {
inject('constants', Constants) inject('constants', Constants)
inject('keynames', KeyNames) inject('keynames', KeyNames)

View file

@ -30,92 +30,6 @@ Vue.prototype.$addDaysToDate = (jsdate, daysToAdd) => {
return date return date
} }
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
if (isNaN(bytes) || bytes == 0) {
return '0 Bytes'
}
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
if (seconds < 60) {
return `${Math.floor(seconds)} sec${useFullNames ? 'onds' : ''}`
}
var minutes = Math.floor(seconds / 60)
if (minutes < 70) {
return `${minutes} min${useFullNames ? `ute${minutes === 1 ? '' : 's'}` : ''}`
}
var hours = Math.floor(minutes / 60)
minutes -= hours * 60
if (!minutes) {
return `${hours} ${useFullNames ? 'hours' : 'hr'}`
}
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
}
Vue.prototype.$secondsToTimestamp = (seconds) => {
if (!seconds) return '0:00'
var _seconds = seconds
var _minutes = Math.floor(seconds / 60)
_seconds -= _minutes * 60
var _hours = Math.floor(_minutes / 60)
_minutes -= _hours * 60
_seconds = Math.floor(_seconds)
if (!_hours) {
return `${_minutes}:${_seconds.toString().padStart(2, '0')}`
}
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
}
Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true) => {
if (isNaN(seconds) || seconds === null) return ''
seconds = Math.round(seconds)
var minutes = Math.floor(seconds / 60)
seconds -= minutes * 60
var hours = Math.floor(minutes / 60)
minutes -= hours * 60
var days = 0
if (useDays || Math.floor(hours / 24) >= 100) {
days = Math.floor(hours / 24)
hours -= days * 24
}
var strs = []
if (days) strs.push(`${days}d`)
if (hours) strs.push(`${hours}h`)
if (minutes) strs.push(`${minutes}m`)
if (seconds) strs.push(`${seconds}s`)
return strs.join(' ')
}
Vue.prototype.$calculateTextSize = (text, styles = {}) => {
const el = document.createElement('p')
let attr = 'margin:0px;opacity:1;position:absolute;top:100px;left:100px;z-index:99;'
for (const key in styles) {
if (styles[key] && String(styles[key]).length > 0) {
attr += `${key}:${styles[key]};`
}
}
el.setAttribute('style', attr)
el.innerText = text
document.body.appendChild(el)
const boundingBox = el.getBoundingClientRect()
el.remove()
return {
height: boundingBox.height,
width: boundingBox.width
}
}
Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => { Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
if (typeof input !== 'string') { if (typeof input !== 'string') {
return false return false

128
client/plugins/utils.js Normal file
View file

@ -0,0 +1,128 @@
import Vue from 'vue'
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
if (isNaN(bytes) || bytes == 0) {
return '0 Bytes'
}
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
if (seconds < 60) {
return `${Math.floor(seconds)} sec${useFullNames ? 'onds' : ''}`
}
var minutes = Math.floor(seconds / 60)
if (minutes < 70) {
return `${minutes} min${useFullNames ? `ute${minutes === 1 ? '' : 's'}` : ''}`
}
var hours = Math.floor(minutes / 60)
minutes -= hours * 60
if (!minutes) {
return `${hours} ${useFullNames ? 'hours' : 'hr'}`
}
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
}
Vue.prototype.$secondsToTimestamp = (seconds) => {
if (!seconds) return '0:00'
var _seconds = seconds
var _minutes = Math.floor(seconds / 60)
_seconds -= _minutes * 60
var _hours = Math.floor(_minutes / 60)
_minutes -= _hours * 60
_seconds = Math.floor(_seconds)
if (!_hours) {
return `${_minutes}:${_seconds.toString().padStart(2, '0')}`
}
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
}
Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true) => {
if (isNaN(seconds) || seconds === null) return ''
seconds = Math.round(seconds)
var minutes = Math.floor(seconds / 60)
seconds -= minutes * 60
var hours = Math.floor(minutes / 60)
minutes -= hours * 60
var days = 0
if (useDays || Math.floor(hours / 24) >= 100) {
days = Math.floor(hours / 24)
hours -= days * 24
}
var strs = []
if (days) strs.push(`${days}d`)
if (hours) strs.push(`${hours}h`)
if (minutes) strs.push(`${minutes}m`)
if (seconds) strs.push(`${seconds}s`)
return strs.join(' ')
}
Vue.prototype.$parseCronExpression = (expression) => {
if (!expression) return null
const pieces = expression.split(' ')
if (pieces.length !== 5) {
return null
}
const commonPatterns = [
{
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 * * * *'
},
{
text: 'Every minute',
value: '* * * * *'
}
]
const patternMatch = commonPatterns.find(p => p.value === expression)
if (patternMatch) {
return {
description: patternMatch.text
}
}
if (isNaN(pieces[0]) || isNaN(pieces[1])) {
return null
}
if (pieces[2] !== '*' || pieces[3] !== '*') {
return null
}
if (pieces[4] !== '*' && pieces[4].split(',').some(p => isNaN(p))) {
return null
}
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
var weekdayText = 'day'
if (pieces[4] !== '*') weekdayText = pieces[4].split(',').map(p => weekdays[p]).join(', ')
return {
description: `Run every ${weekdayText} at ${pieces[1]}:${pieces[0].padStart(2, '0')}`
}
}

View file

@ -47,8 +47,13 @@ export async function checkForUpdate() {
largestVer = verObj largestVer = verObj
} }
} }
if (verObj.version == currVerObj.version) {
currVerObj.changelog = release.body
}
}) })
} }
}) })
if (!largestVer) { if (!largestVer) {
console.error('No valid version tags to compare with') console.error('No valid version tags to compare with')
@ -59,6 +64,7 @@ export async function checkForUpdate() {
hasUpdate: largestVer.total > currVerObj.total, hasUpdate: largestVer.total > currVerObj.total,
latestVersion: largestVer.version, latestVersion: largestVer.version,
githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${largestVer.version}`, githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${largestVer.version}`,
currentVersion: currVerObj.version currentVersion: currVerObj.version,
currentVersionChangelog: currVerObj.changelog
} }
} }

41
client/static/icon.svg Normal file
View file

@ -0,0 +1,41 @@
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1235.7 1235.4" style="enable-background:new 0 0 6702.7 1277.4;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:url(#SVGID_1_);}
.st2{fill:#C9C9C9;}
.st3{font-family:'GentiumBookBasic';}
.st4{font-size:800px;}
.st5{fill:#474747;}
</style>
<title>bgAsset 6</title>
<g id="Layer_2_1_">
<g id="Layer_2-2">
<g id="Layer_4">
<g id="Layer_5">
<circle class="st0" cx="618.6" cy="618.6" r="618.6"/>
</g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="617.37" y1="1257.3" x2="617.37" y2="61.4399" gradientTransform="matrix(1 0 0 -1 0 1278)">
<stop offset="0.32" style="stop-color:#CD9D49"/>
<stop offset="0.99" style="stop-color:#875D27"/>
</linearGradient>
<circle class="st1" cx="617.4" cy="618.6" r="597.9"/>
</g>
<path class="st0" d="M1005.6,574.1c-4.8-4-12.4-10-22.6-17v-79.2c0-201.9-163.7-365.6-365.6-365.6l0,0
c-201.9,0-365.6,163.7-365.6,365.6v79.2c-10.2,7-17.7,13-22.6,17c-4.1,3.4-6.5,8.5-6.5,13.9v94.9c0,5.4,2.4,10.5,6.5,14
c11.3,9.4,37.2,29.1,77.5,49.3v9.2c0,24.9,16,45,35.8,45l0,0c19.8,0,35.8-20.2,35.8-45V527.8c0-24.9-16-45-35.8-45l0,0
c-19,0-34.5,18.5-35.8,41.9h-0.1v-46.9c0-171.6,139.1-310.7,310.7-310.7l0,0C789,167.2,928,306.3,928,477.9v46.9H928
c-1.3-23.4-16.8-41.9-35.8-41.9l0,0c-19.8,0-35.8,20.2-35.8,45v227.6c0,24.9,16,45,35.8,45l0,0c19.8,0,35.8-20.2,35.8-45v-9.2
c40.3-20.2,66.2-39.9,77.5-49.3c4.2-3.5,6.5-8.6,6.5-14V588C1012.1,582.6,1009.7,577.5,1005.6,574.1z"/>
<path class="st0" d="M489.9,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3h-44.7
c-23.9,0-43.3,19.4-43.3,43.3v484.8c0,23.9,19.4,43.3,43.3,43.3L489.9,969.7z M418.2,514.6h98.7v10.3h-98.7V514.6z"/>
<path class="st0" d="M639.7,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3H595c-23.9,0-43.3,19.4-43.3,43.3
v484.8c0,23.9,19.4,43.3,43.3,43.3H639.7z M568,514.6h98.7v10.3H568V514.6z"/>
<path class="st0" d="M789.6,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3h-44.7
c-23.9,0-43.3,19.4-43.3,43.3v484.8c0,23.9,19.4,43.3,43.3,43.3L789.6,969.7z M717.9,514.6h98.7v10.3h-98.7V514.6z"/>
<path class="st0" d="M327.1,984.7h580.5c18,0,32.6,14.6,32.6,32.6v0c0,18-14.6,32.6-32.6,32.6H327.1c-18,0-32.6-14.6-32.6-32.6v0
C294.5,999.3,309.1,984.7,327.1,984.7z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -0,0 +1,44 @@
# License information
## Contribution License Agreement
If you contribute code to this project, you are implicitly allowing your code
to be distributed under the MIT license. You are also implicitly verifying that
all code is your original work. `</legalese>`
## Marked
Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/)
Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
## Markdown
Copyright © 2004, John Gruber
http://daringfireball.net/
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
* Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage.

File diff suppressed because one or more lines are too long

28
client/store/feeds.js Normal file
View file

@ -0,0 +1,28 @@
export const state = () => ({
feeds: []
})
export const getters = {
getFeedForItem: state => id => {
return state.feeds.find(feed => feed.id === id)
}
}
export const actions = {
}
export const mutations = {
addFeed(state, feed) {
var index = state.feeds.findIndex(f => f.id === feed.id)
if (index >= 0) state.feeds.splice(index, 1, feed)
else state.feeds.push(feed)
},
removeFeed(state, feed) {
state.feeds = state.feeds.filter(f => f.id !== feed.id)
},
setFeeds(state, feeds) {
state.feeds = feeds || []
}
}

View file

@ -6,6 +6,8 @@ export const state = () => ({
showEditCollectionModal: false, showEditCollectionModal: false,
showEditPodcastEpisode: false, showEditPodcastEpisode: false,
showViewPodcastEpisodeModal: false, showViewPodcastEpisodeModal: false,
showConfirmPrompt: false,
confirmPromptOptions: null,
showEditAuthorModal: false, showEditAuthorModal: false,
selectedEpisode: null, selectedEpisode: null,
selectedCollection: null, selectedCollection: null,
@ -69,6 +71,13 @@ export const mutations = {
setShowViewPodcastEpisodeModal(state, val) { setShowViewPodcastEpisodeModal(state, val) {
state.showViewPodcastEpisodeModal = val state.showViewPodcastEpisodeModal = val
}, },
setShowConfirmPrompt(state, val) {
state.showConfirmPrompt = val
},
setConfirmPrompt(state, options) {
state.confirmPromptOptions = options
state.showConfirmPrompt = true
},
setEditCollection(state, collection) { setEditCollection(state, collection) {
state.selectedCollection = collection state.selectedCollection = collection
state.showEditCollectionModal = true state.showEditCollectionModal = true

View file

@ -1,5 +1,6 @@
import { checkForUpdate, currentVersion } from '@/plugins/version' import { checkForUpdate, currentVersion } from '@/plugins/version'
import Vue from 'vue' import Vue from 'vue'
const { Constants } = require('../plugins/constants')
export const state = () => ({ export const state = () => ({
Source: null, Source: null,
@ -41,9 +42,18 @@ export const getters = {
getLibraryItemIdStreaming: state => { getLibraryItemIdStreaming: state => {
return state.streamLibraryItem ? state.streamLibraryItem.id : null return state.streamLibraryItem ? state.streamLibraryItem.id : null
}, },
getIsEpisodeStreaming: state => (libraryItemId, episodeId) => { getIsMediaStreaming: state => (libraryItemId, episodeId) => {
if (!state.streamLibraryItem) return null if (!state.streamLibraryItem) return null
if (!episodeId) return state.streamLibraryItem.id == libraryItemId
return state.streamLibraryItem.id == libraryItemId && state.streamEpisodeId == episodeId return state.streamLibraryItem.id == libraryItemId && state.streamEpisodeId == episodeId
},
getBookshelfView: state => {
if (!state.serverSettings || isNaN(state.serverSettings.bookshelfView)) return Constants.BookshelfView.STANDARD
return state.serverSettings.bookshelfView
},
getHomeBookshelfView: state => {
if (!state.serverSettings || isNaN(state.serverSettings.homeBookshelfView)) return Constants.BookshelfView.STANDARD
return state.serverSettings.homeBookshelfView
} }
} }

View file

@ -1,3 +1,5 @@
const { Constants } = require('../plugins/constants')
export const state = () => ({ export const state = () => ({
libraries: [], libraries: [],
lastLoad: 0, lastLoad: 0,
@ -42,6 +44,14 @@ export const getters = {
}) })
if (!librariesSorted.length) return null if (!librariesSorted.length) return null
return librariesSorted[0] return librariesSorted[0]
},
getCurrentLibrarySettings: (state, getters) => {
if (!getters.getCurrentLibrary) return null
return getters.getCurrentLibrary.settings
},
getBookCoverAspectRatio: (state, getters) => {
if (!getters.getCurrentLibrarySettings || isNaN(getters.getCurrentLibrarySettings.coverAspectRatio)) return 1
return getters.getCurrentLibrarySettings.coverAspectRatio === Constants.BookCoverAspectRatio.STANDARD ? 1.6 : 1
} }
} }

View file

@ -75,6 +75,9 @@ export const actions = {
if (state.settings.orderBy == 'media.duration') { if (state.settings.orderBy == 'media.duration') {
settingsUpdate.orderBy = 'media.numTracks' settingsUpdate.orderBy = 'media.numTracks'
} }
if (state.settings.orderBy == 'media.metadata.publishedYear') {
settingsUpdate.orderBy = 'media.metadata.title'
}
var invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues'] var invalidFilters = ['series', 'authors', 'narrators', 'languages', 'progress', 'issues']
var filterByFirstPart = (state.settings.filterBy || '').split('.').shift() var filterByFirstPart = (state.settings.filterBy || '').split('.').shift()
if (invalidFilters.includes(filterByFirstPart)) { if (invalidFilters.includes(filterByFirstPart)) {
@ -136,6 +139,10 @@ export const mutations = {
localStorage.removeItem('token') localStorage.removeItem('token')
} }
}, },
setUserToken(state, token) {
state.user.token = token
localStorage.setItem('token', user.token)
},
updateMediaProgress(state, { id, data }) { updateMediaProgress(state, { id, data }) {
if (!state.user) return if (!state.user) return
if (!data) { if (!data) {

Some files were not shown because too many files have changed in this diff Show more