Compare commits

..

1 Commits

Author SHA1 Message Date
advplyr
74652e2e54 API route to generate waveform images 2023-04-29 14:57:51 -05:00
300 changed files with 10284 additions and 27075 deletions

1
.gitignore vendored
View File

@@ -15,4 +15,3 @@ test/
sw.*
.DS_STORE
.idea/*

View File

@@ -10,15 +10,11 @@ FROM sandreas/tone:v0.1.5 AS tone
FROM node:16-alpine
ENV NODE_ENV=production
RUN apk update && \
apk add --no-cache --update \
curl \
tzdata \
ffmpeg \
make \
python3 \
g++
ffmpeg
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
COPY --from=build /client/dist /client/dist
@@ -27,8 +23,10 @@ COPY server server
RUN npm ci --only=production
RUN apk del make python3 g++
EXPOSE 80
HEALTHCHECK \
--interval=30s \
--timeout=3s \
--start-period=10s \
CMD curl -f http://127.0.0.1/healthcheck || exit 1
CMD ["node", "index.js"]

View File

@@ -60,13 +60,13 @@ install_ffmpeg() {
fi
$WGET
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1 --no-same-owner
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1
rm ffmpeg-git-amd64-static.tar.xz
# Temp downloading tone library to the ffmpeg dir
echo "Getting tone.."
$WGET_TONE
tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1 --no-same-owner
tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1
rm tone-0.1.5-linux-x64.tar.gz
echo "Good to go on Ffmpeg (& tone)... hopefully"

View File

@@ -112,7 +112,7 @@ input[type=number] {
background-color: #373838;
}
.tracksTable tr:hover:not(:has(th)) {
.tracksTable tr:hover {
background-color: #474747;
}
@@ -232,20 +232,6 @@ Bookshelf Label
-webkit-box-orient: vertical;
}
.episode-subtitle-long {
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
line-height: 16px;
/* fallback */
max-height: 72px;
/* fallback */
-webkit-line-clamp: 6;
/* number of lines to show */
-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 {

View File

@@ -7,7 +7,7 @@
</nuxt-link>
<nuxt-link to="/">
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf <span v-if="showExperimentalFeatures" class="material-icons text-lg text-warning pr-1">logo_dev</span></h1>
</nuxt-link>
<ui-libraries-dropdown class="mr-2" />
@@ -15,6 +15,8 @@
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
<div class="flex-grow" />
<widgets-notification-widget class="hidden md:block" />
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
<span class="material-icons-outlined text-2xl text-warning text-opacity-50"> cast </span>
</ui-tooltip>
@@ -22,8 +24,6 @@
<google-cast-launcher></google-cast-launcher>
</div>
<widgets-notification-widget class="hidden md:block" />
<nuxt-link v-if="currentLibrary" to="/config/stats" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
<ui-tooltip :text="$strings.HeaderYourStats" direction="bottom" class="flex items-center">
<span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
@@ -149,6 +149,9 @@ export default {
processingBatch() {
return this.$store.state.processingBatch
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
isChromecastEnabled() {
return this.$store.getters['getServerSetting']('chromecastEnabled')
},
@@ -175,11 +178,6 @@ export default {
})
}
options.push({
text: 'Re-Scan',
action: 'rescan'
})
return options
}
},
@@ -208,39 +206,13 @@ export default {
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
contextMenuAction({ action }) {
contextMenuAction(action) {
if (action === 'quick-embed') {
this.requestBatchQuickEmbed()
} else if (action === 'quick-match') {
this.batchAutoMatchClick()
} else if (action === 'rescan') {
this.batchRescan()
}
},
async batchRescan() {
const payload = {
message: `Are you sure you want to re-scan ${this.selectedMediaItems.length} items?`,
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$post(`/api/items/batch/scan`, {
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
})
.then(() => {
console.log('Batch Re-Scan started')
this.cancelSelectionMode()
})
.catch((error) => {
console.error('Batch Re-Scan failed', error)
const errorMsg = error.response.data || 'Failed to batch re-scan'
this.$toast.error(errorMsg)
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
async playSelectedItems() {
this.$store.commit('setProcessingBatch', true)
@@ -303,13 +275,13 @@ export default {
this.$axios
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => {
this.$toast.success(this.$strings.ToastBatchUpdateSuccess)
this.$toast.success('Batch update success!')
this.$store.commit('setProcessingBatch', false)
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
})
.catch((error) => {
this.$toast.error(this.$strings.ToastBatchUpdateFailed)
this.$toast.error('Batch update failed')
console.error('Failed to batch update read/not read', error)
this.$store.commit('setProcessingBatch', false)
})

View File

@@ -16,7 +16,7 @@
<!-- Alternate plain view -->
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
<template v-for="(shelf, index) in shelves">
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
</widgets-item-slider>
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
@@ -36,7 +36,7 @@
<!-- Regular bookshelf view -->
<div v-else class="w-full">
<template v-for="(shelf, index) in shelves">
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" @selectEntity="(payload) => selectEntity(payload, index)" />
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening'" @selectEntity="(payload) => selectEntity(payload, index)" />
</template>
</div>
</div>
@@ -65,12 +65,12 @@ export default {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
libraryName() {
return this.$store.getters['libraries/getCurrentLibraryName']
},
@@ -171,7 +171,7 @@ export default {
},
async fetchCategories() {
const categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed,numEpisodesIncomplete`)
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed`)
.then((data) => {
return data
})
@@ -286,8 +286,7 @@ export default {
}
if (user.mediaProgress.length) {
const mediaProgressToHide = user.mediaProgress.filter((mp) => mp.hideFromContinueListening)
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-listening')
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-reading')
this.removeItemsFromContinueListening(mediaProgressToHide)
}
},
libraryItemAdded(libraryItem) {
@@ -337,9 +336,8 @@ export default {
},
libraryItemsAdded(libraryItems) {
console.log('libraryItems added', libraryItems)
const isThisLibrary = !libraryItems.some((li) => li.libraryId !== this.currentLibraryId)
if (!this.search && isThisLibrary) {
// TODO: Check if audiobook would be on this shelf
if (!this.search) {
this.fetchCategories()
}
},
@@ -348,12 +346,6 @@ export default {
this.libraryItemUpdated(li)
})
},
episodeAdded(episodeWithLibraryItem) {
const isThisLibrary = episodeWithLibraryItem.libraryItem?.libraryId === this.currentLibraryId
if (!this.search && isThisLibrary) {
this.fetchCategories()
}
},
removeAllSeriesFromContinueSeries(seriesIds) {
this.shelves.forEach((shelf) => {
if (shelf.type == 'book' && shelf.id == 'continue-series') {
@@ -365,8 +357,8 @@ export default {
}
})
},
removeItemsFromContinueListeningReading(mediaProgressItems, categoryId) {
const continueListeningShelf = this.shelves.find((s) => s.id === categoryId)
removeItemsFromContinueListening(mediaProgressItems) {
const continueListeningShelf = this.shelves.find((s) => s.id === 'continue-listening')
if (continueListeningShelf) {
if (continueListeningShelf.type === 'book') {
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
@@ -381,6 +373,17 @@ export default {
})
}
}
// this.shelves.forEach((shelf) => {
// if (shelf.id == 'continue-listening') {
// if (shelf.type == 'book') {
// // Filter out books from continue listening shelf
// shelf.entities = shelf.entities.filter((ent) => {
// if (mediaProgressItems.some(mp => mp.libraryItemId === ent.id)) return false
// return true
// })
// }
// }
// })
},
authorUpdated(author) {
this.shelves.forEach((shelf) => {
@@ -414,7 +417,6 @@ export default {
this.$root.socket.on('item_removed', this.libraryItemRemoved)
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
this.$root.socket.on('items_added', this.libraryItemsAdded)
this.$root.socket.on('episode_added', this.episodeAdded)
} else {
console.error('Error socket not initialized')
}
@@ -429,7 +431,6 @@ export default {
this.$root.socket.off('item_removed', this.libraryItemRemoved)
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
this.$root.socket.off('items_added', this.libraryItemsAdded)
this.$root.socket.off('episode_added', this.episodeAdded)
} else {
console.error('Error socket not initialized')
}

View File

@@ -81,8 +81,6 @@
<!-- issues page remove all button -->
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
</template>
<!-- search page -->
<template v-else-if="page === 'search'">
@@ -188,9 +186,6 @@ export default {
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
@@ -281,30 +276,10 @@ export default {
},
isIssuesFilter() {
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
},
contextMenuItems() {
const items = []
if (this.isPodcastLibrary && this.isLibraryPage && this.userCanDownload) {
items.push({
text: 'Export OPML',
action: 'export-opml'
})
}
return items
}
},
methods: {
contextMenuAction({ action }) {
if (action === 'export-opml') {
this.exportOPML()
}
},
exportOPML() {
this.$downloadFile(`/api/libraries/${this.currentLibraryId}/opml?token=${this.$store.getters['user/getToken']}`, null, true)
},
seriesContextMenuAction({ action }) {
seriesContextMenuAction(action) {
if (action === 'open-rss-feed') {
this.showOpenSeriesRSSFeed()
} else if (action === 're-add-to-continue-listening') {

View File

@@ -90,20 +90,10 @@ export default {
title: this.$strings.HeaderNotifications,
path: '/config/notifications'
},
{
id: 'config-email',
title: this.$strings.HeaderEmail,
path: '/config/email'
},
{
id: 'config-item-metadata-utils',
title: this.$strings.HeaderItemMetadataUtils,
path: '/config/item-metadata-utils'
},
{
id: 'config-rss-feeds',
title: this.$strings.HeaderRSSFeeds,
path: '/config/rss-feeds'
}
]

View File

@@ -78,6 +78,9 @@ export default {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
@@ -313,12 +316,12 @@ export default {
this.currentSFQueryString = this.buildSearchParams()
}
let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
const entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete`
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed`
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
console.error('failed to fetch items', error)
console.error('failed to fetch books', error)
return null
})
@@ -623,11 +626,6 @@ export default {
return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed
},
async init(bookshelf) {
if (this.entityName === 'series') {
this.booksPerFetch = 50
} else {
this.booksPerFetch = 100
}
this.checkUpdateSearchParams()
this.initSizeData(bookshelf)

View File

@@ -49,14 +49,6 @@
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2.5xl">queue_music</span>
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg class="w-6 h-6" viewBox="0 0 24 24">
<path
@@ -70,14 +62,6 @@
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/narrators`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isNarratorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2xl">record_voice_over</span>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p>
<div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="abs-icons icon-podcast text-xl"></span>
@@ -94,6 +78,14 @@
<div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2.5xl">queue_music</span>
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2xl">file_download</span>
@@ -186,9 +178,6 @@ export default {
isAuthorsPage() {
return this.$route.name === 'library-library-authors'
},
isNarratorsPage() {
return this.$route.name === 'library-library-narrators'
},
isPlaylistsPage() {
return this.paramId === 'playlists'
},

View File

@@ -15,7 +15,7 @@
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
<div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}?library=${libraryId}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</div>
<div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</div>
<widgets-explicit-indicator :explicit="isExplicit"></widgets-explicit-indicator>
@@ -268,10 +268,6 @@ export default {
seek(time) {
this.playerHandler.seek(time)
},
playbackTimeUpdate(time) {
// When updating progress from another session
this.playerHandler.seek(time, false)
},
setCurrentTime(time) {
this.currentTime = time
if (this.$refs.audioPlayer) {
@@ -370,8 +366,9 @@ export default {
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionPreviousTrack)
const hasNextChapter = this.$refs.audioPlayer && this.$refs.audioPlayer.hasNextChapter
navigator.mediaSession.setActionHandler('nexttrack', hasNextChapter ? this.mediaSessionNextTrack : null)
} else {
console.warn('Media session not available')
}
@@ -481,14 +478,12 @@ export default {
mounted() {
this.$eventBus.$on('cast-session-active', this.castSessionActive)
this.$eventBus.$on('playback-seek', this.seek)
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
this.$eventBus.$on('play-item', this.playLibraryItem)
this.$eventBus.$on('pause-item', this.pauseItem)
},
beforeDestroy() {
this.$eventBus.$off('cast-session-active', this.castSessionActive)
this.$eventBus.$off('playback-seek', this.seek)
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
this.$eventBus.$off('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem)
}

View File

@@ -1,5 +1,5 @@
<template>
<nuxt-link :to="`/author/${author.id}?library=${currentLibraryId}`">
<nuxt-link :to="`/author/${author.id}`">
<div @mouseover="mouseover" @mouseleave="mouseleave">
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
<!-- Image or placeholder -->

View File

@@ -5,7 +5,7 @@
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
<p v-else class="truncate text-sm" v-html="matchHtml" />
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300" v-html="matchHtml" />
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
@@ -61,6 +61,7 @@ export default {
},
matchHtml() {
if (!this.matchText || !this.search) return ''
if (this.matchKey === 'subtitle') return ''
// This used to highlight the part of the search found
// but with removing commas periods etc this is no longer plausible
@@ -68,7 +69,6 @@ export default {
if (this.matchKey === 'episode') return `<p class="truncate">${this.$strings.LabelEpisode}: ${html}</p>`
if (this.matchKey === 'tags') return `<p class="truncate">${this.$strings.LabelTags}: ${html}</p>`
if (this.matchKey === 'subtitle') return `<p class="truncate">${html}</p>`
if (this.matchKey === 'authors') return `by ${html}`
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`

View File

@@ -1,10 +1,8 @@
<template>
<div class="flex items-center px-1 overflow-hidden">
<div class="w-8 flex items-center justify-center">
<!-- <div class="text-lg"> -->
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{ actionIcon }}</span>
<div class="flex items-center h-full px-1 overflow-hidden">
<div class="h-5 w-5 min-w-5 text-lg mr-1.5 flex items-center justify-center">
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{actionIcon}}</span>
<widgets-loading-spinner v-else />
<!-- </div> -->
</div>
<div class="flex-grow px-2 taskRunningCardContent">
<p class="truncate text-sm">{{ title }}</p>
@@ -38,13 +36,10 @@ export default {
return this.task.details || 'Unknown'
},
isFinished() {
return !!this.task.isFinished
return this.task.isFinished || false
},
isFailed() {
return !!this.task.isFailed
},
isSuccess() {
return this.isFinished && !this.isFailed
return this.task.isFailed || false
},
failedMessage() {
return this.task.error || ''
@@ -53,11 +48,6 @@ export default {
return this.task.action || ''
},
actionIcon() {
if (this.isFailed) {
return 'error'
} else if (this.isSuccess) {
return 'done'
}
switch (this.action) {
case 'download-podcast-episode':
return 'cloud_download'
@@ -78,15 +68,16 @@ export default {
return ''
}
},
methods: {},
methods: {
},
mounted() {}
}
</script>
<style>
.taskRunningCardContent {
width: calc(100% - 84px);
height: 60px;
width: calc(100% - 80px);
height: 75px;
display: flex;
flex-direction: column;
justify-content: center;

View File

@@ -114,7 +114,6 @@ export default {
var files = this.item.itemFiles.concat(this.item.otherFiles)
return {
index: this.item.index,
directory: this.directory,
...this.itemData,
files
}

View File

@@ -76,10 +76,6 @@
<div ref="moreIcon" v-show="!isSelectionMode && moreMenuItems.length" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
</div>
<div v-if="ebookFormat" class="absolute" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }">
<span class="text-white/80" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ ebookFormat }}</span>
</div>
</div>
<!-- Processing/loading spinner overlay -->
@@ -89,7 +85,7 @@
<!-- Series name overlay -->
<div v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
<p v-if="seriesName" class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ seriesName }}</p>
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
</div>
<!-- Error widget -->
@@ -116,14 +112,9 @@
</div>
<!-- Podcast Num Episodes -->
<div v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<div v-else-if="numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
</div>
<!-- Podcast Num Episodes -->
<div v-else-if="numEpisodesIncomplete && !isHovering && !isSelectionMode" class="absolute rounded-full bg-yellow-400 text-black font-semibold box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
</div>
</div>
</template>
@@ -179,6 +170,12 @@ export default {
dateFormat() {
return this.store.state.serverSettings.dateFormat
},
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
enableEReader() {
return this.store.getters['getServerSetting']('enableEReader')
},
_libraryItem() {
return this.libraryItem || {}
},
@@ -218,16 +215,13 @@ export default {
// Only included when filtering by series or collapse series or Continue Series shelf on home page
return this.mediaMetadata.series
},
seriesName() {
return this.series?.name || null
},
seriesSequence() {
return this.series?.sequence || null
return this.series ? this.series.sequence : null
},
libraryId() {
return this._libraryItem.libraryId
},
ebookFormat() {
hasEbook() {
return this.media.ebookFormat
},
numTracks() {
@@ -235,11 +229,9 @@ export default {
return this.media.numTracks || 0 // toJSONMinified
},
numEpisodes() {
if (!this.isPodcast) return 0
return this.media.numEpisodes || 0
},
numEpisodesIncomplete() {
return this._libraryItem.numEpisodesIncomplete || 0
},
processingBatch() {
return this.store.state.processingBatch
},
@@ -260,14 +252,14 @@ export default {
},
booksInSeries() {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries?.numBooks || 0
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
},
seriesSequenceList() {
return this.collapsedSeries?.seriesSequenceList || null
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null
},
libraryItemIdsInSeries() {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries?.libraryItemIds || []
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : []
},
hasCover() {
return !!this.media.coverPath
@@ -321,7 +313,6 @@ export default {
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
if (this.orderBy === 'media.metadata.publishedYear' && this.mediaMetadata.publishedYear) return 'Published ' + this.mediaMetadata.publishedYear
return null
},
episodeProgress() {
@@ -334,9 +325,6 @@ export default {
if (this.episodeProgress) return this.episodeProgress
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
isEBookOnly() {
return !this.numTracks && this.ebookFormat
},
useEBookProgress() {
if (!this.userProgress || this.userProgress.progress) return false
return this.userProgress.ebookProgress > 0
@@ -372,13 +360,13 @@ export default {
return this.store.getters['getIsStreamingFromDifferentLibrary']
},
showReadButton() {
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.ebookFormat
return !this.isSelectionMode && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
},
isMissing() {
return this._libraryItem.isMissing
@@ -494,18 +482,6 @@ export default {
text: this.$strings.LabelAddToPlaylist
})
}
if (this.ebookFormat && this.store.state.libraries.ereaderDevices?.length) {
items.push({
text: this.$strings.LabelSendEbookToDevice,
subitems: this.store.state.libraries.ereaderDevices.map((d) => {
return {
text: d.name,
func: 'sendToDevice',
data: d.name
}
})
})
}
}
if (this.userCanUpdate) {
items.push({
@@ -532,7 +508,7 @@ export default {
if (this.continueListeningShelf) {
items.push({
func: 'removeFromContinueListening',
text: this.isEBookOnly ? this.$strings.ButtonRemoveFromContinueReading : this.$strings.ButtonRemoveFromContinueListening
text: this.$strings.ButtonRemoveFromContinueListening
})
}
if (!this.isPodcast) {
@@ -691,6 +667,7 @@ export default {
.$patch(apiEndpoint, updatePayload)
.then(() => {
this.processing = false
toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
})
.catch((error) => {
console.error('Failed', error)
@@ -735,40 +712,7 @@ export default {
// More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
},
sendToDevice(deviceName) {
// More menu func
const payload = {
// message: `Are you sure you want to send ${this.ebookFormat} ebook "${this.title}" to device "${deviceName}"?`,
message: this.$getString('MessageConfirmSendEbookToDevice', [this.ebookFormat, this.title, deviceName]),
callback: (confirmed) => {
if (confirmed) {
const payload = {
libraryItemId: this.libraryItemId,
deviceName
}
this.processing = true
const axios = this.$axios || this.$nuxt.$axios
axios
.$post(`/api/emails/send-ebook-to-device`, payload)
.then(() => {
this.$toast.success(this.$getString('ToastSendEbookToDeviceSuccess', [deviceName]))
})
.catch((error) => {
console.error('Failed to send ebook to device', error)
this.$toast.error(this.$strings.ToastSendEbookToDeviceFailed)
})
.finally(() => {
this.processing = false
})
}
},
type: 'yesNo'
}
this.store.commit('globals/setConfirmPrompt', payload)
},
removeSeriesFromContinueListening() {
if (!this.series) return
const axios = this.$axios || this.$nuxt.$axios
this.processing = true
axios
@@ -881,8 +825,8 @@ export default {
items: this.moreMenuItems
},
created() {
this.$on('action', (action) => {
if (action.func && _this[action.func]) _this[action.func](action.data)
this.$on('action', (func) => {
if (_this[func]) _this[func]()
})
this.$on('close', () => {
_this.isMoreMenuOpen = false
@@ -894,7 +838,7 @@ export default {
var wrapperBox = this.$refs.moreIcon.getBoundingClientRect()
var el = instance.$el
var elHeight = this.moreMenuItems.length * 28 + 10
var elHeight = this.moreMenuItems.length * 28 + 2
var elWidth = 130
var bottomOfIcon = wrapperBox.top + wrapperBox.height
@@ -921,13 +865,12 @@ export default {
this.createMoreMenu()
},
async clickReadEBook() {
const axios = this.$axios || this.$nuxt.$axios
var libraryItem = await axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
var libraryItem = await this.$axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
console.error('Failed to get lirbary item', this.libraryItemId)
return null
})
if (!libraryItem) return
this.store.commit('showEReader', { libraryItem, keepProgress: true })
this.store.commit('showEReader', libraryItem)
},
selectBtnClick(evt) {
if (this.processingBatch) return

View File

@@ -7,7 +7,7 @@
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
<div v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
<div v-if="isSeriesFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success w-full" />
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
@@ -85,13 +85,13 @@ export default {
case 'addedAt':
return `${this.$strings.LabelAdded} ${this.$formatDate(this.addedAt, this.dateFormat)}`
case 'totalDuration':
return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}`
return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}`
case 'lastBookUpdated':
const lastUpdated = Math.max(...this.books.map((x) => x.updatedAt), 0)
const lastUpdated = Math.max(...(this.books).map(x => x.updatedAt), 0)
return `${this.$strings.LabelLastBookUpdated} ${this.$formatDate(lastUpdated, this.dateFormat)}`
case 'lastBookAdded':
const lastBookAdded = Math.max(...this.books.map((x) => x.addedAt), 0)
return `${this.$strings.LabelLastBookAdded} ${this.$formatDate(lastBookAdded, this.dateFormat)}`
const lastBookAdded = Math.max(...(this.books).map(x => x.addedAt), 0)
return `${this.$strings.LabelLastBookAdded} ${this.$formatDate(lastBookAdded, this.dateFormat)}`
default:
return null
}
@@ -115,14 +115,6 @@ export default {
seriesBooksFinished() {
return this.seriesBookProgress.filter((p) => p.isFinished)
},
hasSeriesBookInProgress() {
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
},
seriesPercentInProgress() {
let totalFinishedAndInProgress = this.seriesBooksFinished.length
if (this.hasSeriesBookInProgress) totalFinishedAndInProgress += 1
return Math.min(1, Math.max(0, totalFinishedAndInProgress / this.books.length))
},
isSeriesFinished() {
return this.books.length === this.seriesBooksFinished.length
},

View File

@@ -1,8 +1,8 @@
<template>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
<div class="absolute inset-0 w-full h-full flex items-center justify-center pointer-events-none opacity-40">
<span class="material-icons-outlined text-[10rem]">record_voice_over</span>
<div class="absolute inset-0 w-full h-full flex items-center justify-center pointer-events-none opacity-20">
<span class="material-icons-outlined text-8xl">record_voice_over</span>
</div>
<!-- Narrator name & num books overlay -->
@@ -36,7 +36,7 @@ export default {
return this.narrator?.name || ''
},
numBooks() {
return this.narrator?.numBooks || this.narrator?.books?.length || 0
return this.narrator?.books?.length || 0
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']

View File

@@ -1,212 +0,0 @@
<template>
<div>
<div v-if="narrators?.length" class="flex py-0.5 mt-4">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNarrators }}</span>
</div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
<template v-for="(narrator, index) in narrators">
<nuxt-link :key="narrator" :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link
><span :key="index" v-if="index < narrators.length - 1">,&nbsp;</span>
</template>
</div>
</div>
<div v-if="publishedYear" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublishYear }}</span>
</div>
<div>
{{ publishedYear }}
</div>
</div>
<div v-if="publisher" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublisher }}</span>
</div>
<div>
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=publishers.${$encode(publisher)}`" class="hover:underline">{{ publisher }}</nuxt-link>
</div>
</div>
<div v-if="musicAlbum" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Album</span>
</div>
<div>
{{ musicAlbum }}
</div>
</div>
<div v-if="musicAlbumArtist" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span>
</div>
<div>
{{ musicAlbumArtist }}
</div>
</div>
<div v-if="musicTrackPretty" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Track</span>
</div>
<div>
{{ musicTrackPretty }}
</div>
</div>
<div v-if="musicDiscPretty" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Disc</span>
</div>
<div>
{{ musicDiscPretty }}
</div>
</div>
<div v-if="podcastType" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPodcastType }}</span>
</div>
<div class="capitalize">
{{ podcastType }}
</div>
</div>
<div class="flex py-0.5" v-if="genres.length">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelGenres }}</span>
</div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
<template v-for="(genre, index) in genres">
<nuxt-link :key="genre" :to="`/library/${libraryId}/bookshelf?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link
><span :key="index" v-if="index < genres.length - 1">,&nbsp;</span>
</template>
</div>
</div>
<div class="flex py-0.5" v-if="tags.length">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelTags }}</span>
</div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
<template v-for="(tag, index) in tags">
<nuxt-link :key="tag" :to="`/library/${libraryId}/bookshelf?filter=tags.${$encode(tag)}`" class="hover:underline">{{ tag }}</nuxt-link
><span :key="index" v-if="index < tags.length - 1">,&nbsp;</span>
</template>
</div>
</div>
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
</div>
<div>
{{ durationPretty }}
</div>
</div>
<div class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelSize }}</span>
</div>
<div>
{{ sizePretty }}
</div>
</div>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
libraryId() {
return this.libraryItem.libraryId
},
isPodcast() {
return this.libraryItem.mediaType === 'podcast'
},
audioFile() {
// Music track
return this.media.audioFile
},
media() {
return this.libraryItem.media || {}
},
tracks() {
return this.media.tracks || []
},
podcastEpisodes() {
return this.media.episodes || []
},
mediaMetadata() {
return this.media.metadata || {}
},
publishedYear() {
return this.mediaMetadata.publishedYear
},
genres() {
return this.mediaMetadata.genres || []
},
tags() {
return this.media.tags || []
},
podcastAuthor() {
return this.mediaMetadata.author || ''
},
authors() {
return this.mediaMetadata.authors || []
},
publisher() {
return this.mediaMetadata.publisher || ''
},
musicArtists() {
return this.mediaMetadata.artists || []
},
musicAlbum() {
return this.mediaMetadata.album || ''
},
musicAlbumArtist() {
return this.mediaMetadata.albumArtist || ''
},
musicTrackPretty() {
if (!this.mediaMetadata.trackNumber) return null
if (!this.mediaMetadata.trackTotal) return this.mediaMetadata.trackNumber
return `${this.mediaMetadata.trackNumber} / ${this.mediaMetadata.trackTotal}`
},
musicDiscPretty() {
if (!this.mediaMetadata.discNumber) return null
if (!this.mediaMetadata.discTotal) return this.mediaMetadata.discNumber
return `${this.mediaMetadata.discNumber} / ${this.mediaMetadata.discTotal}`
},
narrators() {
return this.mediaMetadata.narrators || []
},
durationPretty() {
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)
if (!this.tracks.length && !this.audioFile) return 'N/A'
if (this.audioFile) return this.$elapsedPrettyExtended(this.duration)
return this.$elapsedPretty(this.duration)
},
duration() {
if (!this.tracks.length && !this.audioFile) return 0
return this.media.duration
},
totalPodcastDuration() {
if (!this.podcastEpisodes.length) return 0
let totalDuration = 0
this.podcastEpisodes.forEach((ep) => (totalDuration += ep.duration || 0))
return totalDuration
},
sizePretty() {
return this.$bytesPretty(this.media.size)
},
podcastType() {
return this.mediaMetadata.type
}
},
methods: {},
mounted() {}
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs">{{ selectedText }}</span>
</span>
@@ -14,17 +14,12 @@
</div>
</button>
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-sm ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<!-- selected checkmark icon -->
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
<span class="material-icons text-base text-yellow-400">check</span>
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
</div>
</li>
</template>

View File

@@ -103,7 +103,7 @@ export default {
return this.$store.state.libraries.currentLibraryId
},
totalResults() {
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length + this.narratorResults.length
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length
}
},
methods: {
@@ -197,8 +197,8 @@ export default {
}
</script>
<style scoped>
<style>
.globalSearchMenu {
max-height: calc(100vh - 75px);
max-height: 80vh;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
</span>
@@ -9,35 +9,31 @@
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</span>
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
<span class="material-icons" style="font-size: 1.1rem">close</span>
</div>
</button>
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in selectItems">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
</div>
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons text-2xl">arrow_right</span>
</div>
<!-- selected checkmark icon -->
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
<span class="material-icons text-base text-yellow-400">check</span>
</div>
</li>
</template>
</ul>
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="option" @click="sublist = null">
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-black-400" role="option" @click="sublist = null">
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons text-2xl">arrow_left</span>
</div>
<div class="flex items-center justify-between">
<span class="font-normal block truncate">Back</span>
<span class="font-normal ml-3 block truncate">Back</span>
</div>
</li>
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
@@ -45,15 +41,16 @@
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
</div>
</li>
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('no-series'))">
<div class="flex items-center">
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">{{ $strings.MessageNoSeries }}</span>
</div>
</li>
<template v-for="item in sublistItems">
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedSublistOption(item.value)">
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
<div class="flex items-center">
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
</div>
<!-- selected checkmark icon -->
<div v-if="`${sublist}.${item.value}` === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
<span class="material-icons text-base text-yellow-400">check</span>
</div>
</li>
</template>
</ul>
@@ -75,8 +72,9 @@ export default {
},
watch: {
showMenu(newVal) {
if (newVal) {
this.sublist = this.selectedItemSublist
if (!newVal) {
if (this.sublist && !this.selectedItemSublist) this.sublist = null
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
}
}
},
@@ -124,11 +122,6 @@ export default {
value: 'narrators',
sublist: true
},
{
text: this.$strings.LabelPublisher,
value: 'publishers',
sublist: true
},
{
text: this.$strings.LabelLanguage,
value: 'languages',
@@ -172,11 +165,6 @@ export default {
value: 'narrators',
sublist: true
},
{
text: this.$strings.LabelPublisher,
value: 'publishers',
sublist: true
},
{
text: this.$strings.LabelLanguage,
value: 'languages',
@@ -197,11 +185,6 @@ export default {
value: 'tracks',
sublist: true
},
{
text: this.$strings.LabelEbooks,
value: 'ebooks',
sublist: true
},
{
text: this.$strings.LabelAbridged,
value: 'abridged',
@@ -272,25 +255,21 @@ export default {
return this.bookItems
},
selectedItemSublist() {
return this.selected?.includes('.') ? this.selected.split('.')[0] : null
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
},
selectedText() {
if (!this.selected) return ''
const parts = this.selected.split('.')
const filterName = this.selectItems.find((i) => i.value === parts[0])
let filterValue = null
var parts = this.selected.split('.')
var filterName = this.selectItems.find((i) => i.value === parts[0])
var filterValue = null
if (parts.length > 1) {
const decoded = this.$decode(parts[1])
if (parts[0] === 'authors') {
const author = this.authors.find((au) => au.id == decoded)
var decoded = this.$decode(parts[1])
if (decoded.startsWith('aut_')) {
var author = this.authors.find((au) => au.id == decoded)
if (author) filterValue = author.name
} else if (parts[0] === 'series') {
if (decoded === 'no-series') {
filterValue = this.$strings.MessageNoSeries
} else {
const series = this.series.find((se) => se.id == decoded)
if (series) filterValue = series.name
}
} else if (decoded.startsWith('ser_')) {
var series = this.series.find((se) => se.id == decoded)
if (series) filterValue = series.name
} else {
filterValue = decoded
}
@@ -323,9 +302,6 @@ export default {
languages() {
return this.filterData.languages || []
},
publishers() {
return this.filterData.publishers || []
},
progress() {
return [
{
@@ -348,10 +324,6 @@ export default {
},
tracks() {
return [
{
id: 'none',
name: this.$strings.LabelTracksNone
},
{
id: 'single',
name: this.$strings.LabelTracksSingleTrack
@@ -362,18 +334,6 @@ export default {
}
]
},
ebooks() {
return [
{
id: 'ebook',
name: this.$strings.LabelHasEbook
},
{
id: 'supplementary',
name: this.$strings.LabelHasSupplementaryEbook
}
]
},
missing() {
return [
{
@@ -431,7 +391,7 @@ export default {
]
},
sublistItems() {
const sublistItems = (this[this.sublist] || []).map((item) => {
return (this[this.sublist] || []).map((item) => {
if (typeof item === 'string') {
return {
text: item,
@@ -444,13 +404,6 @@ export default {
}
}
})
if (this.sublist === 'series') {
sublistItems.unshift({
text: this.$strings.MessageNoSeries,
value: this.$encode('no-series')
})
}
return sublistItems
},
filterData() {
return this.$store.state.libraries.filterData || {}
@@ -475,7 +428,7 @@ export default {
return
}
const val = option.value
var val = option.value
if (this.selected === val) {
this.showMenu = false
return
@@ -486,10 +439,4 @@ export default {
}
}
}
</script>
<style scoped>
.libraryFilterMenu {
max-height: calc(100vh - 125px);
}
</style>
</script>

View File

@@ -7,11 +7,11 @@
</span>
</button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in selectItems">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
</div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>

View File

@@ -1,17 +1,17 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span>
</button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
</div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>

View File

@@ -13,8 +13,8 @@
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
<img v-if="width > 100" src="/Logo.png" class="mb-2" :style="{ height: 40 * sizeMultiplier + 'px' }" />
<p class="text-center text-error" :style="{ fontSize: invalidCoverFontSize + 'rem' }">Invalid Cover</p>
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<p class="text-center text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
</div>
</div>
@@ -58,9 +58,6 @@ export default {
sizeMultiplier() {
return this.width / 120
},
invalidCoverFontSize() {
return Math.max(this.sizeMultiplier * 0.8, 0.5)
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
},

View File

@@ -6,7 +6,7 @@
</div>
</template>
<form @submit.prevent="submitForm">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
<div class="w-full p-8">
<div class="flex py-2">
<div class="w-1/2 px-2">
@@ -103,6 +103,7 @@
<ui-toggle-switch labeledBy="selected-tags-not-accessible--permissions-toggle" v-model="newUser.permissions.selectedTagsNotAccessible" />
</div>
</div>
</div>
</div>
@@ -352,8 +353,7 @@ export default {
accessAllTags: true,
selectedTagsNotAccessible: false
},
librariesAccessible: [],
itemTagsSelected: []
librariesAccessible: []
}
}
}

View File

@@ -1,99 +1,84 @@
<template>
<modals-modal v-model="show" name="audiofile-data-modal" :width="700" :height="'unset'">
<div v-if="audioFile" ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
<div class="flex items-center justify-between">
<p class="text-base text-gray-200 truncate">{{ metadata.filename }}</p>
<ui-btn v-if="ffprobeData" small class="ml-2" @click="ffprobeData = null">{{ $strings.ButtonReset }}</ui-btn>
<ui-btn v-else-if="userIsAdminOrUp" small :loading="probingFile" class="ml-2" @click="getFFProbeData">Probe Audio File</ui-btn>
<p class="text-base text-gray-200">{{ metadata.filename }}</p>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<ui-text-input-with-label :value="metadata.path" readonly :label="$strings.LabelPath" class="mb-4 text-sm" />
<div class="flex flex-col sm:flex-row text-sm">
<div class="w-full sm:w-1/2">
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelSize }}
</p>
<p>{{ $bytesPretty(metadata.size) }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelDuration }}
</p>
<p>{{ $secondsToTimestamp(audioFile.duration) }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">{{ $strings.LabelFormat }}</p>
<p>{{ audioFile.format }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelChapters }}
</p>
<p>{{ audioFile.chapters?.length || 0 }}</p>
</div>
<div v-if="audioFile.embeddedCoverArt" class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelEmbeddedCover }}
</p>
<p>{{ audioFile.embeddedCoverArt || '' }}</p>
</div>
</div>
<div class="w-full sm:w-1/2">
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelCodec }}
</p>
<p>{{ audioFile.codec }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelChannels }}
</p>
<p>{{ audioFile.channels }} ({{ audioFile.channelLayout }})</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelBitrate }}
</p>
<p>{{ $bytesPretty(audioFile.bitRate || 0, 0) }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">{{ $strings.LabelTimeBase }}</p>
<p>{{ audioFile.timeBase }}</p>
</div>
<div v-if="audioFile.language" class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelLanguage }}
</p>
<p>{{ audioFile.language || '' }}</p>
</div>
</div>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<template v-if="!ffprobeData">
<ui-text-input-with-label :value="metadata.path" readonly :label="$strings.LabelPath" class="mb-4 text-sm" />
<p class="font-bold mb-2">{{ $strings.LabelMetaTags }}</p>
<div class="flex flex-col sm:flex-row text-sm">
<div class="w-full sm:w-1/2">
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelSize }}
</p>
<p>{{ $bytesPretty(metadata.size) }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelDuration }}
</p>
<p>{{ $secondsToTimestamp(audioFile.duration) }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">{{ $strings.LabelFormat }}</p>
<p>{{ audioFile.format }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelChapters }}
</p>
<p>{{ audioFile.chapters?.length || 0 }}</p>
</div>
<div v-if="audioFile.embeddedCoverArt" class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelEmbeddedCover }}
</p>
<p>{{ audioFile.embeddedCoverArt || '' }}</p>
</div>
</div>
<div class="w-full sm:w-1/2">
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelCodec }}
</p>
<p>{{ audioFile.codec }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelChannels }}
</p>
<p>{{ audioFile.channels }} ({{ audioFile.channelLayout }})</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelBitrate }}
</p>
<p>{{ $bytesPretty(audioFile.bitRate || 0, 0) }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">{{ $strings.LabelTimeBase }}</p>
<p>{{ audioFile.timeBase }}</p>
</div>
<div v-if="audioFile.language" class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelLanguage }}
</p>
<p>{{ audioFile.language || '' }}</p>
</div>
</div>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<p class="font-bold mb-2">{{ $strings.LabelMetaTags }}</p>
<div v-for="(value, key) in metaTags" :key="key" class="flex mb-1 text-sm">
<p class="w-32 min-w-32 text-black-50 mb-1">
{{ key.replace('tag', '') }}
</p>
<p>{{ value }}</p>
</div>
</template>
<div v-else class="w-full">
<div class="relative">
<ui-textarea-with-label :value="prettyFfprobeData" readonly :rows="30" class="text-xs" />
<button class="absolute top-4 right-4" :class="copiedToClipboard ? 'text-success' : 'text-white/50 hover:text-white/80'" @click.stop="copyFfprobeData">
<span class="material-icons">{{ copiedToClipboard ? 'check' : 'content_copy' }}</span>
</button>
</div>
<div v-for="(value, key) in metaTags" :key="key" class="flex mb-1 text-sm">
<p class="w-32 min-w-32 text-black-50 mb-1">
{{ key.replace('tag', '') }}
</p>
<p>{{ value }}</p>
</div>
</div>
</modals-modal>
@@ -106,24 +91,10 @@ export default {
audioFile: {
type: Object,
default: () => {}
},
libraryItemId: String
}
},
data() {
return {
probingFile: false,
ffprobeData: null,
copiedToClipboard: false
}
},
watch: {
show(newVal) {
if (newVal) {
this.ffprobeData = null
this.copiedToClipboard = false
this.probingFile = false
}
}
return {}
},
computed: {
show: {
@@ -139,36 +110,9 @@ export default {
},
metaTags() {
return this.audioFile?.metaTags || {}
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
prettyFfprobeData() {
if (!this.ffprobeData) return ''
return JSON.stringify(this.ffprobeData, null, 2)
}
},
methods: {
getFFProbeData() {
this.probingFile = true
this.$axios
.$get(`/api/items/${this.libraryItemId}/ffprobe/${this.audioFile.ino}`)
.then((data) => {
console.log('Got ffprobe data', data)
this.ffprobeData = data
})
.catch((error) => {
console.error('Failed to get ffprobe data', error)
this.$toast.error('FFProbe failed')
})
.finally(() => {
this.probingFile = false
})
},
async copyFfprobeData() {
this.copiedToClipboard = await this.$copyToClipboard(this.prettyFfprobeData)
}
},
methods: {},
mounted() {}
}
</script>

View File

@@ -48,7 +48,7 @@ export default {
},
methods: {
clickedOption(action) {
this.$emit('action', { action })
this.$emit('action', action)
}
},
mounted() {}

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="text-lg md:text-2xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
<p class="text-3xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
@@ -50,19 +50,19 @@
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelItem }}</p>
<div v-if="_session.libraryId" class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibrary }} Id</div>
<div class="px-1 text-xs">
<div class="px-1">
{{ _session.libraryId }}
</div>
</div>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibraryItem }} Id</div>
<div class="px-1 text-xs">
<div class="px-1">
{{ _session.libraryItemId }}
</div>
</div>
<div v-if="_session.episodeId" class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelEpisode }} Id</div>
<div class="px-1 text-xs">
<div class="px-1">
{{ _session.episodeId }}
</div>
</div>
@@ -81,7 +81,7 @@
</div>
<div class="w-full md:w-1/3">
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
<p class="mb-1 text-xs">{{ _session.userId }}</p>
<p class="mb-1">{{ _session.userId }}</p>
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
<p class="mb-1">{{ playMethodName }}</p>

View File

@@ -2,11 +2,11 @@
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
<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" />
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
<div class="absolute top-3 right-3 landscape:top-2 landscape:right-2 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 h-8 w-8 landscape:h-8 landscape:w-8 md:portrait:h-12 md:portrait:w-12 lg:w-12 lg:h-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
<span class="material-icons text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
</button>
</div>
<slot name="outer" />
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" aria-modal="true" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
<slot />
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
<ui-loading-indicator />

View File

@@ -15,7 +15,7 @@
</template>
<form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime">
<ui-text-input v-model="customTime" type="number" step="any" min="0.1" placeholder="Time in minutes" class="w-48" />
<ui-btn color="success" type="submit" :padding-x="0" class="h-9 w-12 flex items-center justify-center ml-1">Set</ui-btn>
<ui-btn color="success" type="submit" padding-x="0" class="h-9 w-12 flex items-center justify-center ml-1">Set</ui-btn>
</form>
</div>
<div v-else class="w-full p-4">

View File

@@ -33,10 +33,8 @@
</div>
<div class="flex pt-2 px-2">
<ui-btn v-if="userCanDelete" small color="error" type="button" @click.stop="removeClick">{{ $strings.ButtonRemove }}</ui-btn>
<ui-btn type="button" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
<div class="flex-grow" />
<ui-btn type="button" class="mx-2" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div>
</div>
@@ -93,9 +91,6 @@ export default {
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
}
},
methods: {
@@ -105,31 +100,6 @@ export default {
this.authorCopy.description = this.author.description
this.authorCopy.imagePath = this.author.imagePath
},
removeClick() {
const payload = {
message: this.$getString('MessageConfirmRemoveAuthor', [this.author.name]),
callback: (confirmed) => {
if (confirmed) {
this.processing = true
this.$axios
.$delete(`/api/authors/${this.authorId}`)
.then(() => {
this.$toast.success('Author removed')
this.show = false
})
.catch((error) => {
console.error('Failed to remove author', error)
this.$toast.error('Failed to remove author')
})
.finally(() => {
this.processing = false
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
async submitForm() {
var keysToCheck = ['name', 'asin', 'description', 'imagePath']
var updatePayload = {}

View File

@@ -8,9 +8,10 @@
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<template v-if="!showImageUploader">
<form @submit.prevent="submitForm">
<div class="flex flex-wrap">
<div class="w-full flex justify-center mb-2 md:w-auto md:mb-0 md:block">
<div class="flex">
<div>
<covers-collection-cover :book-items="books" :width="200" :height="100 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- <ui-btn type="button" @click="showImageUploader = true">Upload</ui-btn> -->
</div>
<div class="flex-grow px-4">
<ui-text-input-with-label v-model="newCollectionName" :label="$strings.LabelName" class="mb-2" />
@@ -40,6 +41,7 @@
<ui-btn color="success">Upload</ui-btn>
</div>
</template>
<!-- <modals-upload-image-modal v-model="showUploadImageModal" entity="collection" :entity-id="collection.id" /> -->
</div>
</modals-modal>
</template>

View File

@@ -1,171 +0,0 @@
<template>
<modals-modal ref="modal" v-model="show" name="ereader-device-edit" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<form @submit.prevent="submitForm">
<div class="w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300">
<div class="w-full px-3 py-5 md:p-12">
<div class="flex items-center -mx-1 mb-2">
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="ereaderNameInput" v-model="newDevice.name" :disabled="processing" :label="$strings.LabelName" />
</div>
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="ereaderEmailInput" v-model="newDevice.email" :disabled="processing" :label="$strings.LabelEmail" />
</div>
</div>
<div class="flex items-center pt-4">
<div class="flex-grow" />
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
</div>
</div>
</div>
</form>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
existingDevices: {
type: Array,
default: () => []
},
ereaderDevice: {
type: Object,
default: () => null
}
},
data() {
return {
processing: false,
newDevice: {
name: '',
email: ''
}
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
title() {
return this.ereaderDevice ? 'Create Device' : 'Update Device'
}
},
methods: {
submitForm() {
this.$refs.ereaderNameInput.blur()
this.$refs.ereaderEmailInput.blur()
if (!this.newDevice.name?.trim() || !this.newDevice.email?.trim()) {
this.$toast.error('Name and email required')
return
}
this.newDevice.name = this.newDevice.name.trim()
this.newDevice.email = this.newDevice.email.trim()
if (!this.ereaderDevice) {
if (this.existingDevices.some((d) => d.name === this.newDevice.name)) {
this.$toast.error('EReader device with that name already exists')
return
}
this.submitCreate()
} else {
if (this.ereaderDevice.name !== this.newDevice.name && this.existingDevices.some((d) => d.name === this.newDevice.name)) {
this.$toast.error('EReader device with that name already exists')
return
}
this.submitUpdate()
}
},
submitUpdate() {
this.processing = true
const existingDevicesWithoutThisOne = this.existingDevices.filter((d) => d.name !== this.ereaderDevice.name)
const payload = {
ereaderDevices: [
...existingDevicesWithoutThisOne,
{
...this.newDevice
}
]
}
this.$axios
.$post(`/api/emails/ereader-devices`, payload)
.then((data) => {
this.$emit('update', data.ereaderDevices)
this.$toast.success('Device updated')
this.show = false
})
.catch((error) => {
console.error('Failed to update device', error)
this.$toast.error('Failed to update device')
})
.finally(() => {
this.processing = false
})
},
submitCreate() {
this.processing = true
const payload = {
ereaderDevices: [
...this.existingDevices,
{
...this.newDevice
}
]
}
this.$axios
.$post('/api/emails/ereader-devices', payload)
.then((data) => {
this.$emit('update', data.ereaderDevices || [])
this.$toast.success('Device added')
this.show = false
})
.catch((error) => {
console.error('Failed to add device', error)
this.$toast.error('Failed to add device')
})
.finally(() => {
this.processing = false
})
},
init() {
if (this.ereaderDevice) {
this.newDevice.name = this.ereaderDevice.name
this.newDevice.email = this.ereaderDevice.email
} else {
this.newDevice.name = ''
this.newDevice.email = ''
}
}
},
mounted() {}
}
</script>

View File

@@ -74,9 +74,6 @@ export default {
this.$store.commit('setEditModalTab', val)
}
},
height() {
return Math.min(this.availableHeight, 650)
},
tabs() {
return [
{
@@ -127,6 +124,9 @@ export default {
}
]
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
@@ -136,26 +136,14 @@ export default {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
selectedLibraryItem() {
return this.$store.state.selectedLibraryItem || {}
},
selectedLibraryItemId() {
return this.selectedLibraryItem.id
},
media() {
return this.libraryItem?.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
availableTabs() {
if (!this.userCanUpdate && !this.userCanDownload) return []
return this.tabs.filter((tab) => {
if (tab.experimental && !this.showExperimentalFeatures) return false
if (tab.mediaType && this.mediaType !== tab.mediaType) return false
if (tab.admin && !this.userIsAdminOrUp) return false
if (tab.id === 'tools' && this.isMissing) return false
if (tab.id === 'chapters' && this.isEBookOnly) return false
if ((tab.id === 'tools' || tab.id === 'files') && this.userCanDownload) return true
if (tab.id !== 'tools' && tab.id !== 'files' && this.userCanUpdate) return true
@@ -163,6 +151,9 @@ export default {
return false
})
},
height() {
return Math.min(this.availableHeight, 650)
},
tabName() {
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
return _tab ? _tab.component : ''
@@ -170,11 +161,20 @@ export default {
isMissing() {
return this.selectedLibraryItem.isMissing
},
isEBookOnly() {
return this.media.ebookFile && !this.media.tracks?.length
selectedLibraryItem() {
return this.$store.state.selectedLibraryItem || {}
},
selectedLibraryItemId() {
return this.selectedLibraryItem.id
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
mediaType() {
return this.libraryItem?.mediaType || null
return this.libraryItem ? this.libraryItem.mediaType : null
},
title() {
return this.mediaMetadata.title || 'No Title'

View File

@@ -1,8 +1,8 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
<div class="flex flex-wrap mb-4">
<div class="flex flex-wrap">
<div class="relative">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, libraryItemUpdatedAt, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- book cover overlay -->
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
@@ -17,10 +17,10 @@
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
<div class="flex items-center">
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 pt-4 md:min-w-32">
<ui-file-input ref="fileInput" @change="fileUploadSelected">
<span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span>
<span class="material-icons text-2xl inline-block md:!hidden">upload</span>
</ui-file-input>
<ui-file-input ref="fileInput" @change="fileUploadSelected"
><span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span
><span class="material-icons text-2xl inline-block md:!hidden">upload</span></ui-file-input
>
</div>
<form @submit.prevent="submitForm" class="flex flex-grow">
<ui-text-input-with-label v-model="imageUrl" :label="$strings.LabelCoverImageURL" />
@@ -36,10 +36,10 @@
</div>
<div v-if="showLocalCovers" class="flex items-center justify-center pb-2">
<template v-for="localCoverFile in localCovers">
<div :key="localCoverFile.ino" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="localCoverFile.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(localCoverFile)">
<template v-for="cover in localCovers">
<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' }">
<covers-preview-cover :src="localCoverFile.localPath" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-preview-cover :src="`${cover.localPath}?token=${userToken}`" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
</div>
</template>
@@ -128,7 +128,7 @@ export default {
},
providers() {
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
return [{ text: 'All', value: 'all' }, ...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders]
return [...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders]
},
searchTitleLabel() {
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
@@ -139,19 +139,16 @@ export default {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
libraryItemId() {
return this.libraryItem?.id || null
},
libraryItemUpdatedAt() {
return this.libraryItem?.updatedAt || null
return this.libraryItem ? this.libraryItem.id : null
},
mediaType() {
return this.libraryItem?.mediaType || null
return this.libraryItem ? this.libraryItem.mediaType : null
},
isPodcast() {
return this.mediaType == 'podcast'
},
media() {
return this.libraryItem?.media || {}
return this.libraryItem ? this.libraryItem.media || {} : {}
},
coverPath() {
return this.media.coverPath
@@ -160,7 +157,7 @@ export default {
return this.media.metadata || {}
},
libraryFiles() {
return this.libraryItem?.libraryFiles || []
return this.libraryItem ? this.libraryItem.libraryFiles || [] : []
},
userCanUpload() {
return this.$store.getters['user/getUserCanUpload']
@@ -172,8 +169,8 @@ export default {
return this.libraryFiles
.filter((f) => f.fileType === 'image')
.map((file) => {
const _file = { ...file }
_file.localPath = `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${file.ino}?token=${this.userToken}`
var _file = { ...file }
_file.localPath = `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}`
return _file
})
}
@@ -226,7 +223,7 @@ export default {
this.searchTitle = this.mediaMetadata.title || ''
this.searchAuthor = this.mediaMetadata.authorName || ''
if (this.isPodcast) this.provider = 'itunes'
else this.provider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'
else this.provider = localStorage.getItem('book-provider') || 'google'
},
removeCover() {
if (!this.media.coverPath) {
@@ -283,20 +280,21 @@ export default {
}
if (success) {
this.$toast.success('Update Successful')
} else if (this.media.coverPath) {
this.imageUrl = this.media.coverPath
// this.$emit('close')
} else {
this.imageUrl = this.media.coverPath || ''
}
this.isProcessing = false
},
getSearchQuery() {
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor || ''}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
if (this.isPodcast) searchQuery += '&podcast=1'
return searchQuery
},
persistProvider() {
try {
localStorage.setItem('book-cover-provider', this.provider)
localStorage.setItem('book-provider', this.provider)
} catch (error) {
console.error('PersistProvider', error)
}

View File

@@ -20,14 +20,18 @@
<div v-if="!episodes.length" class="flex my-4 text-center justify-center text-xl">{{ $strings.MessageNoEpisodes }}</div>
<table v-else class="text-sm tracksTable">
<tr>
<th class="text-center w-20 min-w-20">{{ $strings.LabelEpisode }}</th>
<th class="text-left">{{ $strings.LabelEpisodeTitle }}</th>
<th class="text-center w-28">{{ $strings.LabelEpisodeDuration }}</th>
<th class="text-center w-28">{{ $strings.LabelEpisodeSize }}</th>
<th class="text-left">Sort #</th>
<th class="text-left whitespace-nowrap">{{ $strings.LabelEpisode }}</th>
<th class="text-left">{{ $strings.EpisodeTitle }}</th>
<th class="text-center w-28">{{ $strings.EpisodeDuration }}</th>
<th class="text-center w-28">{{ $strings.EpisodeSize }}</th>
</tr>
<tr v-for="episode in episodes" :key="episode.id">
<td class="text-center w-20 min-w-20">
<p>{{ episode.episode }}</p>
<td class="text-left">
<p class="px-4">{{ episode.index }}</p>
</td>
<td class="text-left">
<p class="px-4">{{ episode.episode }}</p>
</td>
<td>
{{ episode.title }}

View File

@@ -205,7 +205,7 @@ export default {
processing: Boolean,
libraryItem: {
type: Object,
default: () => { }
default: () => {}
}
},
data() {
@@ -305,7 +305,7 @@ export default {
const filterData = this.$store.state.libraries.filterData || {}
const currentGenres = filterData.genres || []
const selectedMatchGenres = this.selectedMatch.genres || []
return [...new Set([...currentGenres, ...selectedMatchGenres])]
return [...new Set([...currentGenres ,...selectedMatchGenres])]
}
},
methods: {
@@ -325,9 +325,9 @@ export default {
}
},
getSearchQuery() {
if (this.isPodcast) return `term=${encodeURIComponent(this.searchTitle)}`
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${encodeURIComponent(this.searchTitle)}`
if (this.searchAuthor) searchQuery += `&author=${encodeURIComponent(this.searchAuthor)}`
if (this.isPodcast) return `term=${this.searchTitle}`
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${this.searchTitle}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
return searchQuery
},
submitSearch() {
@@ -580,7 +580,6 @@ export default {
.matchListWrapper {
height: calc(100% - 124px);
}
@media (min-width: 768px) {
.matchListWrapper {
height: calc(100% - 80px);

View File

@@ -20,7 +20,7 @@
</div>
<!-- Split to mp3 -->
<!-- <div v-if="showMp3Split" class="w-full border border-black-200 p-4 my-8">
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">{{ $strings.LabelToolsSplitM4b }}</p>
@@ -31,7 +31,7 @@
<ui-btn :disabled="true">{{ $strings.MessageNotYetImplemented }}</ui-btn>
</div>
</div>
</div> -->
</div>
<!-- Embed Metadata -->
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
@@ -79,6 +79,9 @@ export default {
return {}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
libraryItemId() {
return this.libraryItem?.id || null
},

View File

@@ -20,7 +20,7 @@
<p class="px-1 text-sm font-semibold">{{ $strings.LabelFolders }}</p>
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2">
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
<ui-editable-text ref="folderInput" v-model="folder.fullPath" :readonly="!!folder.id" type="text" class="w-full" @blur="existingFolderInputBlurred(folder)" />
<ui-editable-text ref="folderInput" v-model="folder.fullPath" readonly type="text" class="w-full" />
<span v-show="folders.length > 1" class="material-icons text-2xl ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
</div>
<div class="flex py-1 px-2 items-center w-full">
@@ -67,6 +67,10 @@ export default {
value: 'podcast',
text: this.$strings.LabelPodcasts
}
// {
// value: 'music',
// text: 'Music'
// }
]
},
folderPaths() {
@@ -106,11 +110,6 @@ export default {
formUpdated() {
this.$emit('update', this.getLibraryData())
},
existingFolderInputBlurred(folder) {
if (!folder.fullPath) {
this.removeFolder(folder)
}
},
newFolderInputBlurred() {
if (this.newFolderPath) {
this.folders.push({ fullPath: this.newFolderPath })
@@ -150,7 +149,6 @@ export default {
this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
this.icon = this.library ? this.library.icon : 'default'
this.mediaType = this.library ? this.library.mediaType : 'book'
this.showDirectoryPicker = false
}
},

View File

@@ -120,7 +120,7 @@ export default {
for (const key in this.libraryCopy) {
if (library[key] !== undefined) {
if (key === 'folders') {
this.libraryCopy.folders = library.folders.map((f) => ({ ...f })).filter((f) => !!f.fullPath?.trim())
this.libraryCopy.folders = library.folders.map((f) => ({ ...f }))
} else if (key === 'settings') {
for (const settingKey in library.settings) {
this.libraryCopy.settings[settingKey] = library.settings[settingKey]

View File

@@ -1,6 +1,6 @@
<template>
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
<div class="flex items-center py-3">
<div class="flex items-center py-2">
<ui-toggle-switch v-model="useSquareBookCovers" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsSquareBookCoversHelp">
<p class="pl-4 text-base">
@@ -11,44 +11,24 @@
</div>
<div class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="enableWatcher" @input="formUpdated" />
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" @input="formUpdated" />
<ui-toggle-switch v-else disabled :value="false" />
<p class="pl-4 text-base">{{ $strings.LabelSettingsEnableWatcherForLibrary }}</p>
<p class="pl-4 text-base">{{ $strings.LabelSettingsDisableWatcherForLibrary }}</p>
</div>
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p>
</div>
<div v-if="isBookLibrary" class="flex items-center py-3">
<ui-toggle-switch v-model="audiobooksOnly" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsAudiobooksOnlyHelp">
<p class="pl-4 text-base">
{{ $strings.LabelSettingsAudiobooksOnly }}
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div v-if="isBookLibrary" class="py-3">
<div v-if="mediaType == 'book'" class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithASIN }}</p>
</div>
</div>
<div v-if="isBookLibrary" class="py-3">
<div v-if="mediaType == 'book'" class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithISBN }}</p>
</div>
</div>
<div v-if="isBookLibrary" class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="hideSingleBookSeries" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsHideSingleBookSeriesHelp">
<p class="pl-4 text-base">
{{ $strings.LabelSettingsHideSingleBookSeries }}
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
</div>
</div>
</template>
@@ -65,11 +45,9 @@ export default {
return {
provider: null,
useSquareBookCovers: false,
enableWatcher: false,
disableWatcher: false,
skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false,
audiobooksOnly: false,
hideSingleBookSeries: false
skipMatchingMediaWithIsbn: false
}
},
computed: {
@@ -82,9 +60,6 @@ export default {
mediaType() {
return this.library.mediaType
},
isBookLibrary() {
return this.mediaType === 'book'
},
providers() {
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
@@ -95,11 +70,9 @@ export default {
return {
settings: {
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
disableWatcher: !this.enableWatcher,
disableWatcher: !!this.disableWatcher,
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
audiobooksOnly: !!this.audiobooksOnly,
hideSingleBookSeries: !!this.hideSingleBookSeries
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn
}
}
},
@@ -108,11 +81,9 @@ export default {
},
init() {
this.useSquareBookCovers = this.librarySettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
this.enableWatcher = !this.librarySettings.disableWatcher
this.disableWatcher = !!this.librarySettings.disableWatcher
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries
}
},
mounted() {

View File

@@ -39,7 +39,7 @@
</div>
</div>
<div class="flex justify-end pt-4">
<ui-checkbox v-if="!allDownloaded" v-model="selectAll" @input="toggleSelectAll" :label="selectAllLabel" small checkbox-bg="primary" border-color="gray-600" class="mx-8" />
<ui-checkbox v-if="!allDownloaded" v-model="selectAll" @input="toggleSelectAll" label="Select all episodes" small checkbox-bg="primary" border-color="gray-600" class="mx-8" />
<ui-btn v-if="!allDownloaded" :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
<p v-else class="text-success text-base px-2 py-4">All episodes are downloaded</p>
</div>
@@ -99,82 +99,46 @@ export default {
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
},
buttonText() {
if (!this.episodesSelected.length) return this.$strings.LabelNoEpisodesSelected
if (this.episodesSelected.length === 1) return `${this.$strings.LabelDownload} ${this.$strings.LabelEpisode.toLowerCase()}`
return this.$getString('LabelDownloadNEpisodes', [this.episodesSelected.length])
if (!this.episodesSelected.length) return 'No Episodes Selected'
return `Download ${this.episodesSelected.length} Episode${this.episodesSelected.length > 1 ? 's' : ''}`
},
itemEpisodes() {
if (!this.libraryItem) return []
return this.libraryItem.media.episodes || []
},
itemEpisodeMap() {
const map = {}
var map = {}
this.itemEpisodes.forEach((item) => {
if (item.enclosure) {
const cleanUrl = this.getCleanEpisodeUrl(item.enclosure.url)
map[cleanUrl] = true
}
if (item.enclosure) map[item.enclosure.url.split('?')[0]] = true
})
return map
},
episodesList() {
return this.episodesCleaned.filter((episode) => {
if (!this.searchText) return true
return episode.title?.toLowerCase().includes(this.searchText) || episode.subtitle?.toLowerCase().includes(this.searchText)
return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
})
},
selectAllLabel() {
if (this.episodesList.length === this.episodesCleaned.length) {
return this.$strings.LabelSelectAllEpisodes
}
const episodesNotDownloaded = this.episodesList.filter((ep) => !this.itemEpisodeMap[ep.cleanUrl]).length
return this.$getString('LabelSelectEpisodesShowing', [episodesNotDownloaded])
}
},
methods: {
/**
* RSS feed episode url is used for matching with existing downloaded episodes.
* Some RSS feeds include timestamps in the episode url (e.g. patreon) that can change on requests.
* These need to be removed in order to detect the same episode each time the feed is pulled.
*
* An RSS feed may include an `id` in the query string. In these cases we want to leave the `id`.
* @see https://github.com/advplyr/audiobookshelf/issues/1896
*
* @param {string} url - rss feed episode url
* @returns {string} rss feed episode url without dynamic query strings
*/
getCleanEpisodeUrl(url) {
let queryString = url.split('?')[1]
if (!queryString) return url
const searchParams = new URLSearchParams(queryString)
for (const p of Array.from(searchParams.keys())) {
if (p !== 'id') searchParams.delete(p)
}
if (!searchParams.toString()) return url
return `${url}?${searchParams.toString()}`
},
inputUpdate() {
clearTimeout(this.searchTimeout)
this.searchTimeout = setTimeout(() => {
if (!this.search?.trim()) {
if (!this.search || !this.search.trim()) {
this.searchText = ''
this.checkSetIsSelectedAll()
return
}
this.searchText = this.search.toLowerCase().trim()
this.checkSetIsSelectedAll()
}, 500)
},
toggleSelectAll(val) {
for (const episode of this.episodesList) {
for (const episode of this.episodesCleaned) {
if (this.itemEpisodeMap[episode.cleanUrl]) this.selectedEpisodes[episode.cleanUrl] = false
else this.$set(this.selectedEpisodes, episode.cleanUrl, val)
}
},
checkSetIsSelectedAll() {
for (const episode of this.episodesList) {
for (const episode of this.episodesCleaned) {
if (!this.itemEpisodeMap[episode.cleanUrl] && !this.selectedEpisodes[episode.cleanUrl]) {
this.selectAll = false
return
@@ -183,19 +147,19 @@ export default {
this.selectAll = true
},
toggleSelectEpisode(episode) {
if (this.itemEpisodeMap[episode.cleanUrl]) return
if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) return
this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl])
this.checkSetIsSelectedAll()
},
submit() {
let episodesToDownload = []
var episodesToDownload = []
if (this.episodesSelected.length) {
episodesToDownload = this.episodesSelected.map((cleanUrl) => this.episodesCleaned.find((ep) => ep.cleanUrl == cleanUrl))
}
const payloadSize = JSON.stringify(episodesToDownload).length
const sizeInMb = payloadSize / 1024 / 1024
const sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
var payloadSize = JSON.stringify(episodesToDownload).length
var sizeInMb = payloadSize / 1024 / 1024
var sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
console.log('Request size', sizeInMb)
if (sizeInMb > 4.99) {
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
@@ -210,9 +174,10 @@ export default {
this.show = false
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to download episodes'
console.error('Failed to download episodes', error)
this.processing = false
this.$toast.error(error.response?.data || 'Failed to download episodes')
this.$toast.error(errorMsg)
this.selectedEpisodes = {}
this.selectAll = false
@@ -224,7 +189,7 @@ export default {
.map((_ep) => {
return {
..._ep,
cleanUrl: this.getCleanEpisodeUrl(_ep.enclosure.url)
cleanUrl: _ep.enclosure.url.split('?')[0]
}
})
this.episodesCleaned.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))

View File

@@ -132,8 +132,6 @@ export default {
return
}
this.processing = true
const payload = {
serverAddress: window.origin,
slug: this.newFeedSlug,
@@ -153,9 +151,6 @@ export default {
const errorMsg = error.response ? error.response.data : null
this.$toast.error(errorMsg || 'Failed to open RSS Feed')
})
.finally(() => {
this.processing = false
})
},
copyToClipboard(str) {
this.$copyToClipboard(str, this)

View File

@@ -1,124 +0,0 @@
<template>
<modals-modal v-model="show" name="rss-feed-view-modal" :processing="processing" :width="700" :height="'unset'">
<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 v-if="feed" class="w-full">
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p>
<div class="w-full relative">
<ui-text-input v-model="feed.feedUrl" readonly />
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feed.feedUrl)">content_copy</span>
</div>
<div v-if="feed.meta" class="mt-5">
<div class="flex py-0.5">
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedPreventIndexing }}</span>
</div>
<div>{{ feed.meta.preventIndexing ? 'Yes' : 'No' }}</div>
</div>
<div v-if="feed.meta.ownerName" class="flex py-0.5">
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerName }}</span>
</div>
<div>{{ feed.meta.ownerName }}</div>
</div>
<div v-if="feed.meta.ownerEmail" class="flex py-0.5">
<div class="w-48">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerEmail }}</span>
</div>
<div>{{ feed.meta.ownerEmail }}</div>
</div>
</div>
<!-- -->
<div class="episodesTable mt-2">
<div class="bg-primary bg-opacity-40 h-12 header">
{{ $strings.LabelEpisodeTitle }}
</div>
<div class="scroller">
<div v-for="episode in feed.episodes" :key="episode.id" class="h-8 text-xs truncate">
{{ episode.title }}
</div>
</div>
</div>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
feed: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
_feed() {
return this.feed || {}
}
},
methods: {
copyToClipboard(str) {
this.$copyToClipboard(str, this)
}
},
mounted() {}
}
</script>
<style scoped>
.episodesTable {
width: 100%;
max-width: 100%;
border: 1px solid #474747;
display: flex;
flex-direction: column;
}
.episodesTable div.header {
background-color: #272727;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
padding: 4px 8px;
}
.episodesTable .scroller {
display: flex;
flex-direction: column;
max-height: 250px;
overflow-x: hidden;
overflow-y: scroll;
}
.episodesTable .scroller div {
background-color: #373838;
padding: 4px 8px;
display: flex;
align-items: center;
justify-content: flex-start;
height: 32px;
flex: 0 0 32px;
}
.episodesTable .scroller div:nth-child(even) {
background-color: #2f2f2f;
}
</style>

View File

@@ -1,11 +1,11 @@
<template>
<div class="w-full h-full">
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-8 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400" :style="{ width: pageMenuWidth + 'px' }">
<div v-for="(file, index) in cleanedPageNames" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index + 1)">
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52">
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index)">
<p class="text-sm truncate">{{ file }}</p>
</div>
</div>
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-10 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
<p class="text-xs">
<strong>{{ key }}</strong
@@ -14,38 +14,39 @@
</div>
</div>
<a v-if="pages && numPages" :href="mainImg" :download="pages[page - 1]" class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" :class="comicMetadata ? 'left-32' : 'left-20'">
<span class="material-icons text-xl">download</span>
</a>
<div v-if="comicMetadata" class="absolute top-0 left-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu">
<div v-if="comicMetadata" class="absolute top-0 right-52 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showInfoMenu = !showInfoMenu">
<span class="material-icons text-xl">more</span>
</div>
<div v-if="numPages" class="absolute top-0 left-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowPageMenu">
<div class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" style="right: 156px" @mousedown.prevent @click.stop.prevent="showPageMenu = !showPageMenu">
<span class="material-icons text-xl">menu</span>
</div>
<div v-if="numPages" class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
<p class="font-mono">{{ page }} / {{ numPages }}</p>
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
</div>
<div class="overflow-hidden w-full h-full relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="overflow-hidden m-auto comicwrapper relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
</div>
</div>
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
</div>
</div>
<div class="h-full flex justify-center">
<img v-if="mainImg" :src="mainImg" class="object-contain h-full m-auto" />
<img v-if="mainImg" :src="mainImg" class="object-contain comicimg" />
</div>
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
<ui-loading-indicator />
</div>
</div>
<!-- <div v-show="loading" class="w-screen h-screen absolute top-0 left-0 bg-black bg-opacity-20 flex items-center justify-center">
<ui-loading-indicator />
</div> -->
</div>
</template>
@@ -60,13 +61,7 @@ Archive.init({
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
},
playerOpen: Boolean,
keepProgress: Boolean,
fileId: String
url: String
},
data() {
return {
@@ -76,7 +71,6 @@ export default {
mainImg: null,
page: 0,
numPages: 0,
pageMenuWidth: 256,
showPageMenu: false,
showInfoMenu: false,
loadTimeout: null,
@@ -93,79 +87,17 @@ export default {
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() {
return this.libraryItem?.id
},
ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook`
},
comicMetadataKeys() {
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
},
canGoNext() {
return this.page < this.numPages
return this.page < this.numPages - 1
},
canGoPrev() {
return this.page > 1
},
userMediaProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
savedPage() {
if (!this.keepProgress) return 0
// Validate ebookLocation is a number
if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0
return Number(this.userMediaProgress.ebookLocation)
},
cleanedPageNames() {
return (
this.pages?.map((p) => {
if (p.length > 50) {
let firstHalf = p.slice(0, 22)
let lastHalf = p.slice(p.length - 23)
return `${firstHalf} ... ${lastHalf}`
}
return p
}) || []
)
return this.page > 0
}
},
methods: {
clickShowPageMenu() {
this.showInfoMenu = false
this.showPageMenu = !this.showPageMenu
},
clickShowInfoMenu() {
this.showPageMenu = false
this.showInfoMenu = !this.showInfoMenu
},
updateProgress() {
if (!this.keepProgress) return
if (!this.numPages) {
console.error('Num pages not loaded')
return
}
if (this.savedPage === this.page) {
return
}
const payload = {
ebookLocation: this.page,
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
}
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
console.error('ComicReader.updateProgress failed:', error)
})
},
clickOutside() {
if (this.showPageMenu) this.showPageMenu = false
if (this.showInfoMenu) this.showInfoMenu = false
@@ -178,15 +110,12 @@ export default {
if (!this.canGoPrev) return
this.setPage(this.page - 1)
},
setPage(page) {
if (page <= 0 || page > this.numPages) {
setPage(index) {
if (index < 0 || index > this.numPages - 1) {
return
}
this.showPageMenu = false
this.showInfoMenu = false
const filename = this.pages[page - 1]
this.page = page
this.updateProgress()
var filename = this.pages[index]
this.page = index
return this.extractFile(filename)
},
setLoadTimeout() {
@@ -216,11 +145,10 @@ export default {
},
async extract() {
this.loading = true
var buff = await this.$axios.$get(this.ebookUrl, {
responseType: 'blob',
headers: {
Authorization: `Bearer ${this.userToken}`
}
console.log('Extracting', this.url)
var buff = await this.$axios.$get(this.url, {
responseType: 'blob'
})
const archive = await Archive.open(buff)
const originalFilesObject = await archive.getFilesObject()
@@ -236,28 +164,9 @@ export default {
this.numPages = this.pages.length
// Calculate page menu size
const largestFilename = this.cleanedPageNames
.map((p) => p)
.sort((a, b) => a.length - b.length)
.pop()
const pEl = document.createElement('p')
pEl.innerText = largestFilename
pEl.style.fontSize = '0.875rem'
pEl.style.opacity = 0
pEl.style.position = 'absolute'
document.body.appendChild(pEl)
const textWidth = pEl.getBoundingClientRect()?.width
if (textWidth) {
this.pageMenuWidth = textWidth + (16 + 5 + 2 + 5)
}
pEl.remove()
if (this.pages.length) {
this.loading = false
const startPage = this.savedPage > 0 && this.savedPage <= this.numPages ? this.savedPage : 1
await this.setPage(startPage)
await this.setPage(0)
this.loadedFirstPage = true
} else {
this.$toast.error('Unable to extract pages')
@@ -303,8 +212,8 @@ export default {
},
parseImageFilename(filename) {
var basename = Path.basename(filename, Path.extname(filename))
var numbersinpath = basename.match(/\d+/g)
if (!numbersinpath?.length) {
var numbersinpath = basename.match(/\d{1,5}/g)
if (!numbersinpath || !numbersinpath.length) {
return {
index: -1,
filename
@@ -340,6 +249,15 @@ export default {
<style scoped>
.pagemenu {
max-height: calc(100% - 48px);
max-height: calc(100vh - 60px);
}
.comicimg {
height: calc(100vh - 40px);
margin: auto;
}
.comicwrapper {
width: 100vw;
height: calc(100vh - 40px);
margin-top: 20px;
}
</style>

View File

@@ -1,15 +1,15 @@
<template>
<div id="epub-reader" class="h-full w-full">
<div class="h-full w-full">
<div class="h-full flex items-center justify-center">
<button type="button" aria-label="Previous page" class="w-24 max-w-24 h-full hidden sm:flex items-center overflow-x-hidden justify-center opacity-50 hover:opacity-100">
<span v-if="hasPrev" class="material-icons text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
</button>
<div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center overflow-x-hidden justify-center">
<span v-if="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
</div>
<div id="frame" class="w-full" style="height: 80%">
<div id="viewer"></div>
</div>
<button type="button" aria-label="Next page" class="w-24 max-w-24 h-full hidden sm:flex items-center justify-center overflow-x-hidden opacity-50 hover:opacity-100">
<span v-if="hasNext" class="material-icons text-6xl" @mousedown.prevent @click="next">chevron_right</span>
</button>
<div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center justify-center overflow-x-hidden">
<span v-if="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
</div>
</div>
</div>
</template>
@@ -24,39 +24,22 @@ import ePub from 'epubjs'
*/
export default {
props: {
url: String,
libraryItem: {
type: Object,
default: () => {}
},
playerOpen: Boolean,
keepProgress: Boolean,
fileId: String
}
},
data() {
return {
windowWidth: 0,
windowHeight: 0,
/** @type {ePub.Book} */
book: null,
/** @type {ePub.Rendition} */
rendition: null,
ereaderSettings: {
theme: 'dark',
fontScale: 100,
lineSpacing: 115,
spread: 'auto'
}
}
},
watch: {
playerOpen() {
this.resize()
rendition: null
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
/** @returns {string} */
libraryItemId() {
return this.libraryItem?.id
@@ -69,79 +52,28 @@ export default {
},
/** @returns {Array<ePub.NavItem>} */
chapters() {
return this.book?.navigation?.toc || []
return this.book ? this.book.navigation.toc : []
},
userMediaProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
savedEbookLocation() {
if (!this.keepProgress) return null
if (!this.userMediaProgress?.ebookLocation) return null
// Validate ebookLocation is an epubcfi
if (!String(this.userMediaProgress.ebookLocation).startsWith('epubcfi')) return null
return this.userMediaProgress.ebookLocation
},
localStorageLocationsKey() {
return `ebookLocations-${this.libraryItemId}`
},
readerWidth() {
if (this.windowWidth < 640) return this.windowWidth
return this.windowWidth - 200
},
readerHeight() {
if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight
return this.windowHeight - 164
},
ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook`
},
themeRules() {
const isDark = this.ereaderSettings.theme === 'dark'
const fontColor = isDark ? '#fff' : '#000'
const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)'
const lineSpacing = this.ereaderSettings.lineSpacing / 100
const fontScale = this.ereaderSettings.fontScale / 100
return {
'*': {
color: `${fontColor}!important`,
'background-color': `${backgroundColor}!important`,
'line-height': lineSpacing * fontScale + 'rem!important'
},
a: {
color: `${fontColor}!important`
}
}
}
},
methods: {
updateSettings(settings) {
this.ereaderSettings = settings
if (!this.rendition) return
this.applyTheme()
const fontScale = settings.fontScale || 100
this.rendition.themes.fontSize(`${fontScale}%`)
this.rendition.spread(settings.spread || 'auto')
},
prev() {
if (!this.rendition?.manager) return
return this.rendition?.prev()
},
next() {
if (!this.rendition?.manager) return
return this.rendition?.next()
},
goToChapter(href) {
if (!this.rendition?.manager) return
return this.rendition?.display(href)
},
keyUp(e) {
@@ -158,7 +90,6 @@ export default {
* @param {string} payload.ebookProgress - eBook Progress Percentage
*/
updateProgress(payload) {
if (!this.keepProgress) return
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
console.error('EpubReader.updateProgress failed:', error)
})
@@ -250,7 +181,7 @@ export default {
},
/** @param {string} location - CFI of the new location */
relocated(location) {
if (this.savedEbookLocation === location.start.cfi) {
if (this.userMediaProgress?.ebookLocation === location.start.cfi) {
return
}
@@ -270,42 +201,43 @@ export default {
const reader = this
/** @type {ePub.Book} */
reader.book = new ePub(reader.ebookUrl, {
reader.book = new ePub(reader.url, {
width: this.readerWidth,
height: this.readerHeight - 50,
openAs: 'epub',
requestHeaders: {
Authorization: `Bearer ${this.userToken}`
}
height: window.innerHeight - 50
})
/** @type {ePub.Rendition} */
reader.rendition = reader.book.renderTo('viewer', {
width: this.readerWidth,
height: this.readerHeight * 0.8,
spread: 'auto',
snap: true,
manager: 'continuous',
flow: 'paginated'
height: window.innerHeight * 0.8
})
// load saved progress
reader.rendition.display(this.savedEbookLocation || reader.book.locations.start)
reader.rendition.display(this.userMediaProgress?.ebookLocation || reader.book.locations.start)
reader.rendition.on('rendered', () => {
this.applyTheme()
})
// load style
reader.rendition.themes.default({ '*': { color: '#fff!important' } })
reader.book.ready.then(() => {
// set up event listeners
reader.rendition.on('relocated', reader.relocated)
reader.rendition.on('keydown', reader.keyUp)
let touchStart = 0
let touchEnd = 0
reader.rendition.on('touchstart', (event) => {
this.$emit('touchstart', event)
touchStart = event.changedTouches[0].screenX
})
reader.rendition.on('touchend', (event) => {
this.$emit('touchend', event)
touchEnd = event.changedTouches[0].screenX
const touchDistanceX = Math.abs(touchEnd - touchStart)
if (touchStart < touchEnd && touchDistanceX > 120) {
this.next()
}
if (touchStart > touchEnd && touchDistanceX > 120) {
this.prev()
}
})
// load ebook cfi locations
@@ -321,25 +253,17 @@ export default {
},
resize() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
this.rendition?.resize(this.readerWidth, this.readerHeight * 0.8)
},
applyTheme() {
if (!this.rendition) return
this.rendition.getContents().forEach((c) => {
c.addStylesheetRules(this.themeRules)
})
this.rendition?.resize(this.readerWidth, window.innerHeight * 0.8)
}
},
mounted() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
window.addEventListener('resize', this.resize)
this.initEpub()
},
beforeDestroy() {
window.removeEventListener('resize', this.resize)
this.book?.destroy()
},
mounted() {
this.windowWidth = window.innerWidth
window.addEventListener('resize', this.resize)
this.initEpub()
}
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div class="w-full h-full">
<div class="h-full max-h-full w-full">
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-16 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-12 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
<iframe title="html-viewer" width="100%"> Loading </iframe>
</div>
</div>
@@ -15,30 +15,12 @@ import defaultCss from '@/assets/ebooks/basic.js'
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
},
playerOpen: Boolean,
fileId: String
url: String
},
data() {
return {}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() {
return this.libraryItem?.id
},
ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook`
}
},
computed: {},
methods: {
addHtmlCss() {
let iframe = document.getElementsByTagName('iframe')[0]
@@ -96,11 +78,8 @@ export default {
},
async initMobi() {
// Fetch mobi file as blob
var buff = await this.$axios.$get(this.ebookUrl, {
responseType: 'blob',
headers: {
Authorization: `Bearer ${this.userToken}`
}
var buff = await this.$axios.$get(this.url, {
responseType: 'blob'
})
var reader = new FileReader()
reader.onload = async (event) => {

View File

@@ -11,19 +11,15 @@
</div>
</div>
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 hidden md:flex items-center text-center">
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center">
<p class="font-mono">{{ page }} / {{ numPages }}</p>
</div>
<div class="absolute top-0 right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 hidden md:flex items-center text-center">
<ui-icon-btn icon="zoom_out" :size="8" :disabled="!canScaleDown" borderless class="mr-px" @click="zoomOut" />
<ui-icon-btn icon="zoom_in" :size="8" :disabled="!canScaleUp" borderless class="ml-px" @click="zoomIn" />
</div>
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
<div class="flex items-center justify-center">
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="overflow-auto">
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="pdfDocInitParams" :page="page" :rotate="rotate" @progress="progressEvt" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="url" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
</div>
</div>
</div>
@@ -34,26 +30,17 @@
</template>
<script>
import pdf from '@teckel/vue-pdf'
import pdf from 'vue-pdf'
export default {
components: {
pdf
},
props: {
libraryItem: {
type: Object,
default: () => {}
},
playerOpen: Boolean,
keepProgress: Boolean,
fileId: String
url: String
},
data() {
return {
windowWidth: 0,
windowHeight: 0,
scale: 1,
rotate: 0,
loadedRatio: 0,
page: 1,
@@ -61,121 +48,35 @@ export default {
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() {
return this.libraryItem?.id
},
fitToPageWidth() {
return this.pdfHeight * 0.6
},
pdfWidth() {
return this.fitToPageWidth * this.scale
return this.pdfHeight * 0.6667
},
pdfHeight() {
if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight - 120
return this.windowHeight - 284
},
maxScale() {
return Math.floor((this.windowWidth * 10) / this.fitToPageWidth) / 10
return window.innerHeight - 120
},
canGoNext() {
return this.page < this.numPages
},
canGoPrev() {
return this.page > 1
},
canScaleUp() {
return this.scale < this.maxScale
},
canScaleDown() {
return this.scale > 1
},
userMediaProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
savedPage() {
if (!this.keepProgress) return 0
// Validate ebookLocation is a number
if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0
return Number(this.userMediaProgress.ebookLocation)
},
ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook`
},
pdfDocInitParams() {
return {
url: this.ebookUrl,
httpHeaders: {
Authorization: `Bearer ${this.userToken}`
}
}
}
},
methods: {
zoomIn() {
this.scale += 0.1
},
zoomOut() {
this.scale -= 0.1
},
updateProgress() {
if (!this.keepProgress) return
if (!this.numPages) {
console.error('Num pages not loaded')
return
}
const payload = {
ebookLocation: this.page,
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
}
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
console.error('EpubReader.updateProgress failed:', error)
})
},
loadedEvt() {
if (this.savedPage > 0 && this.savedPage <= this.numPages) {
this.page = this.savedPage
}
},
progressEvt(progress) {
this.loadedRatio = progress
},
numPagesLoaded(e) {
this.numPages = e
},
prev() {
if (this.page <= 1) return
this.page--
this.updateProgress()
},
next() {
if (this.page >= this.numPages) return
this.page++
this.updateProgress()
},
error(err) {
console.error(err)
},
resize() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
}
},
mounted() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
window.addEventListener('resize', this.resize)
},
beforeDestroy() {
window.removeEventListener('resize', this.resize)
}
mounted() {}
}
</script>

View File

@@ -1,90 +1,38 @@
<template>
<div v-if="show" id="reader" :data-theme="ereaderTheme" class="group absolute top-0 left-0 w-full z-60 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': !!streamLibraryItem }">
<div class="absolute top-4 left-4 z-20 flex items-center">
<button v-if="isEpub" @click="toggleToC" type="button" aria-label="Table of contents menu" class="inline-flex opacity-80 hover:opacity-100">
<span class="material-icons text-2xl">menu</span>
</button>
<button v-if="hasSettings" @click="openSettings" type="button" aria-label="Ereader settings" class="mx-4 inline-flex opacity-80 hover:opacity-100">
<span class="material-icons text-1.5xl">settings</span>
</button>
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-60 bg-primary text-white">
<div class="absolute top-4 left-4 z-20">
<span v-if="hasToC && !tocOpen" ref="tocButton" class="material-icons cursor-pointer text-2xl" @click="toggleToC">menu</span>
</div>
<div class="absolute top-4 left-1/2 transform -translate-x-1/2">
<h1 :data-type="ebookType" class="text-lg sm:text-xl md:text-2xl mb-1 data-[type=comic]:hidden" style="line-height: 1.15; font-weight: 100">
<h1 class="text-lg sm:text-xl md:text-2xl mb-1" style="line-height: 1.15; font-weight: 100">
<span style="font-weight: 600">{{ abTitle }}</span>
<span v-if="abAuthor" class="hidden md:inline"> </span>
<span v-if="abAuthor" class="hidden md:inline">{{ abAuthor }}</span>
<span v-if="abAuthor" style="display: inline"> </span>
<span v-if="abAuthor">{{ abAuthor }}</span>
</h1>
</div>
<div class="absolute top-4 right-4 z-20">
<button @click="close" type="button" aria-label="Close ereader" class="inline-flex opacity-80 hover:opacity-100">
<span class="material-icons text-2xl">close</span>
</button>
<span v-if="hasSettings" class="material-icons cursor-pointer text-2xl" @click="openSettings">settings</span>
<span class="material-icons cursor-pointer text-2xl" @click="close">close</span>
</div>
<component v-if="componentName" ref="readerComponent" :is="componentName" :library-item="selectedLibraryItem" :player-open="!!streamLibraryItem" :keep-progress="keepProgress" :file-id="ebookFileId" @touchstart="touchstart" @touchend="touchend" @hook:mounted="readerMounted" />
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" :library-item="selectedLibraryItem" />
<!-- TOC side nav -->
<div v-if="tocOpen" class="w-full h-full fixed inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
<div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent="toggleToC">
<div class="p-4 h-full">
<div class="flex items-center mb-2">
<button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100">
<span class="material-icons text-2xl">arrow_back</span>
</button>
<p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p>
</div>
<div v-if="hasToC" class="w-72 h-full max-h-full absolute top-0 left-0 bg-bg shadow-xl transition-transform z-30" :class="tocOpen ? 'translate-x-0' : '-translate-x-72'" @click.stop.prevent="toggleToC">
<div class="p-4 h-full overflow-hidden">
<p class="text-lg font-semibold mb-2">Table of Contents</p>
<div class="tocContent">
<ul>
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
<a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a>
<ul v-if="chapter.subitems.length">
<li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4">
<a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subchapter.href)">{{ subchapter.label }}</a>
</li>
</ul>
<a :href="chapter.href" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a>
</li>
</ul>
</div>
</div>
</div>
<!-- ereader settings modal -->
<modals-modal v-model="showSettings" name="ereader-settings-modal" :width="500" :height="'unset'" :processing="false">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-3/4 overflow-hidden">
<p class="text-xl md:text-3xl text-white truncate">{{ $strings.HeaderEreaderSettings }}</p>
</div>
</template>
<div class="px-2 py-4 md:p-8 w-full text-base rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-x-hidden overflow-y-auto" style="max-height: 80vh">
<div class="flex items-center mb-4">
<div class="w-40">
<p class="text-lg">{{ $strings.LabelTheme }}:</p>
</div>
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" />
</div>
<div class="flex items-center mb-4">
<div class="w-40">
<p class="text-lg">{{ $strings.LabelFontScale }}:</p>
</div>
<ui-range-input v-model="ereaderSettings.fontScale" :min="5" :max="300" :step="5" @input="settingsUpdated" />
</div>
<div class="flex items-center mb-4">
<div class="w-40">
<p class="text-lg">{{ $strings.LabelLineSpacing }}:</p>
</div>
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" @input="settingsUpdated" />
</div>
<div class="flex items-center">
<div class="w-40">
<p class="text-lg">{{ $strings.LabelLayout }}:</p>
</div>
<ui-toggle-btns v-model="ereaderSettings.spread" :items="spreadItems" @input="settingsUpdated" />
</div>
</div>
</modals-modal>
</div>
</template>
@@ -92,21 +40,8 @@
export default {
data() {
return {
touchstartX: 0,
touchstartY: 0,
touchendX: 0,
touchendY: 0,
touchstartTime: 0,
touchIdentifier: null,
chapters: [],
tocOpen: false,
showSettings: false,
ereaderSettings: {
theme: 'dark',
fontScale: 100,
lineSpacing: 115,
spread: 'auto'
}
tocOpen: false
}
},
watch: {
@@ -125,34 +60,6 @@ export default {
this.$store.commit('setShowEReader', val)
}
},
ereaderTheme() {
if (this.isEpub) return this.ereaderSettings.theme
return 'dark'
},
spreadItems() {
return [
{
text: this.$strings.LabelLayoutSinglePage,
value: 'none'
},
{
text: this.$strings.LabelLayoutSplitPage,
value: 'auto'
}
]
},
themeItems() {
return [
{
text: this.$strings.LabelThemeDark,
value: 'dark'
},
{
text: this.$strings.LabelThemeLight,
value: 'light'
}
]
},
componentName() {
if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
@@ -160,11 +67,11 @@ export default {
else if (this.ebookType === 'comic') return 'readers-comic-reader'
return null
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
hasToC() {
return this.isEpub
},
hasSettings() {
return this.isEpub
return false
},
abTitle() {
return this.mediaMetadata.title
@@ -188,18 +95,10 @@ export default {
return this.selectedLibraryItem.folderId
},
ebookFile() {
// ebook file id is passed when reading a supplementary ebook
if (this.ebookFileId) {
return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId)
}
return this.media.ebookFile
},
ebookFormat() {
if (!this.ebookFile) return null
// Use file extension for supplementary ebook
if (!this.ebookFile.ebookFormat) {
return this.ebookFile.metadata.ext.toLowerCase().slice(1)
}
return this.ebookFile.ebookFormat
},
ebookType() {
@@ -221,37 +120,33 @@ export default {
isComic() {
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
},
ebookUrl() {
if (!this.ebookFile) return null
let filepath = ''
if (this.selectedLibraryItem.isFile) {
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
} else {
const itemRelPath = this.selectedLibraryItem.relPath
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
const relPath = this.ebookFile.metadata.relPath
if (relPath.startsWith('/')) relPath = relPath.slice(1)
filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
}
return `/ebook/${this.libraryId}/${this.folderId}/${filepath}`
},
userToken() {
return this.$store.getters['user/getToken']
},
keepProgress() {
return this.$store.state.ereaderKeepProgress
},
ebookFileId() {
return this.$store.state.ereaderFileId
},
isDarkTheme() {
return this.ereaderSettings.theme === 'dark'
}
},
methods: {
readerMounted() {
if (this.isEpub) {
this.loadEreaderSettings()
}
},
settingsUpdated() {
this.$refs.readerComponent?.updateSettings?.(this.ereaderSettings)
localStorage.setItem('ereaderSettings', JSON.stringify(this.ereaderSettings))
},
toggleToC() {
this.tocOpen = !this.tocOpen
this.chapters = this.$refs.readerComponent.chapters
},
openSettings() {
this.showSettings = true
},
openSettings() {},
hotkey(action) {
console.log('Reader hotkey', action)
if (!this.$refs.readerComponent) return
if (action === this.$hotkeys.EReader.NEXT_PAGE) {
@@ -268,72 +163,11 @@ export default {
prev() {
if (this.$refs.readerComponent?.prev) this.$refs.readerComponent.prev()
},
handleGesture() {
// Touch must be less than 1s. Must be > 60px drag and X distance > Y distance
const touchTimeMs = Date.now() - this.touchstartTime
if (touchTimeMs >= 1000) {
console.log('Touch too long', touchTimeMs)
return
}
const touchDistanceX = Math.abs(this.touchendX - this.touchstartX)
const touchDistanceY = Math.abs(this.touchendY - this.touchstartY)
const touchDistance = Math.sqrt(Math.pow(this.touchstartX - this.touchendX, 2) + Math.pow(this.touchstartY - this.touchendY, 2))
if (touchDistance < 60) {
return
}
if (touchDistanceX < 60 || touchDistanceY > touchDistanceX) {
return
}
if (this.touchendX < this.touchstartX) {
this.next()
}
if (this.touchendX > this.touchstartX) {
this.prev()
}
},
touchstart(e) {
// Ignore rapid touch
if (this.touchstartTime && Date.now() - this.touchstartTime < 250) {
return
}
this.touchstartX = e.touches[0].screenX
this.touchstartY = e.touches[0].screenY
this.touchstartTime = Date.now()
this.touchIdentifier = e.touches[0].identifier
},
touchend(e) {
if (this.touchIdentifier !== e.changedTouches[0].identifier) {
return
}
this.touchendX = e.changedTouches[0].screenX
this.touchendY = e.changedTouches[0].screenY
this.handleGesture()
},
registerListeners() {
this.$eventBus.$on('reader-hotkey', this.hotkey)
document.body.addEventListener('touchstart', this.touchstart)
document.body.addEventListener('touchend', this.touchend)
},
unregisterListeners() {
this.$eventBus.$off('reader-hotkey', this.hotkey)
document.body.removeEventListener('touchstart', this.touchstart)
document.body.removeEventListener('touchend', this.touchend)
},
loadEreaderSettings() {
try {
const settings = localStorage.getItem('ereaderSettings')
if (settings) {
this.ereaderSettings = JSON.parse(settings)
this.settingsUpdated()
}
} catch (error) {
console.error('Failed to load ereader settings', error)
}
},
init() {
this.registerListeners()
@@ -353,19 +187,12 @@ export default {
</script>
<style>
/* @import url(@/assets/calibre/basic.css); */
.ebook-viewer {
height: calc(100% - 96px);
}
.tocContent {
height: calc(100% - 36px);
overflow-y: auto;
}
#reader {
height: 100%;
}
#reader.reader-player-open {
height: calc(100% - 164px);
}
@media (max-height: 400px) {
#reader.reader-player-open {
height: 100%;
}
}
</style>

View File

@@ -235,6 +235,7 @@ export default {
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
})
}
console.log('Data', this.data)
this.monthLabels = []
var lastMonth = null

View File

@@ -18,7 +18,7 @@
</div>
</div>
<div v-if="isBookLibrary" class="flex px-4">
<div class="flex px-4">
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" />
</svg>
@@ -58,32 +58,26 @@ export default {
return {}
},
computed: {
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isBookLibrary() {
return this.currentLibraryMediaType === 'book'
},
user() {
return this.$store.state.user.user
},
totalItems() {
return this.libraryStats?.totalItems || 0
return this.libraryStats ? this.libraryStats.totalItems : 0
},
totalAuthors() {
return this.libraryStats?.totalAuthors || 0
return this.libraryStats ? this.libraryStats.totalAuthors : 0
},
numAudioTracks() {
return this.libraryStats?.numAudioTracks || 0
return this.libraryStats ? this.libraryStats.numAudioTracks : 0
},
totalDuration() {
return this.libraryStats?.totalDuration || 0
return this.libraryStats ? this.libraryStats.totalDuration : 0
},
totalHours() {
return Math.round(this.totalDuration / (60 * 60))
},
totalSizePretty() {
var totalSize = this.libraryStats?.totalSize || 0
var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0
return this.$bytesPretty(totalSize, 1)
},
totalSizeNum() {

View File

@@ -17,7 +17,7 @@
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="contextMenuItems.length" class="text-center">
<ui-context-menu-dropdown :items="contextMenuItems" :menu-width="110" @action="contextMenuAction" />
<ui-context-menu-dropdown :items="contextMenuItems" menu-width="110px" @action="contextMenuAction" />
</td>
</tr>
</template>
@@ -73,11 +73,11 @@ export default {
return items
},
downloadUrl() {
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${this.track.audioFile.ino}/download?token=${this.userToken}`
return `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(this.track.metadata.relPath).replace(/^\//, '')}?token=${this.userToken}`
}
},
methods: {
contextMenuAction({ action }) {
contextMenuAction(action) {
if (action === 'delete') {
this.deleteLibraryFile()
} else if (action === 'download') {
@@ -88,7 +88,7 @@ export default {
},
deleteLibraryFile() {
const payload = {
message: this.$strings.MessageConfirmDeleteFile,
message: 'This will delete the file from your file system. Are you sure?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
@@ -107,7 +107,15 @@ export default {
this.$store.commit('globals/setConfirmPrompt', payload)
},
downloadLibraryFile() {
this.$downloadFile(this.downloadUrl, this.track.metadata.filename)
const a = document.createElement('a')
a.style.display = 'none'
a.href = this.downloadUrl
a.download = this.track.metadata.filename
document.body.appendChild(a)
a.click()
setTimeout(() => {
a.remove()
})
}
},
mounted() {}

View File

@@ -21,14 +21,14 @@
<td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
<td>
<div class="w-full flex flex-row items-center justify-center">
<ui-btn v-if="backup.serverVersion && backup.key" small color="primary" @click="applyBackup(backup)">{{ $strings.ButtonRestore }}</ui-btn>
<ui-btn v-if="backup.serverVersion" small color="primary" @click="applyBackup(backup)">{{ $strings.ButtonRestore }}</ui-btn>
<a v-if="backup.serverVersion" :href="`/metadata/${$encodeUriPath(backup.path)}?token=${userToken}`" class="mx-1 pt-1 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
<ui-tooltip v-else text="This backup was created with an old version of audiobookshelf no longer supported" direction="bottom" class="mx-2 flex items-center">
<span class="material-icons-outlined text-2xl text-error">error_outline</span>
</ui-tooltip>
<button aria-label="Download Backup" class="inline-flex material-icons text-xl mx-1 mt-1 text-white/70 hover:text-white/100" @click.stop="downloadBackup(backup)">download</button>
<button aria-label="Delete Backup" class="inline-flex material-icons text-xl mx-1 text-white/70 hover:text-error" @click="deleteBackupClick(backup)">delete</button>
<span class="material-icons text-xl hover:text-error hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="deleteBackupClick(backup)">delete</span>
</div>
</td>
</tr>
@@ -80,9 +80,6 @@ export default {
}
},
methods: {
downloadBackup(backup) {
this.$downloadFile(`${process.env.serverUrl}/api/backups/${backup.id}/download?token=${this.userToken}`)
},
confirm() {
this.showConfirmApply = false
@@ -94,9 +91,8 @@ export default {
})
.catch((error) => {
this.isBackingUp = false
console.error('Failed to apply backup', error)
const errorMsg = error.response.data || this.$strings.ToastBackupRestoreFailed
this.$toast.error(errorMsg)
console.error('Failed', error)
this.$toast.error(this.$strings.ToastBackupRestoreFailed)
})
},
deleteBackupClick(backup) {
@@ -164,7 +160,6 @@ export default {
this.$axios
.$get('/api/backups')
.then((data) => {
this.$emit('loaded', data.backupLocation)
this.setBackups(data.backups || [])
})
.catch((error) => {

View File

@@ -1,87 +0,0 @@
<template>
<div class="w-full my-2">
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-2 md:pr-4">{{ $strings.HeaderEbookFiles }}</p>
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ ebookFiles.length }}</span>
</div>
<div class="flex-grow" />
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
<span class="material-icons text-4xl">expand_more</span>
</div>
</div>
<transition name="slide">
<div class="w-full" v-show="showFiles">
<table class="text-sm tracksTable">
<tr>
<th class="text-left px-4">{{ $strings.LabelPath }}</th>
<th class="text-left w-24 min-w-24">{{ $strings.LabelSize }}</th>
<th class="text-left px-4 w-24">
{{ $strings.LabelRead }} <ui-tooltip :text="$strings.LabelReadEbookWithoutProgress" direction="top" class="inline-block"><span class="material-icons-outlined text-sm align-middle">info</span></ui-tooltip>
</th>
<th v-if="showMoreColumn" class="text-center w-16"></th>
</tr>
<template v-for="file in ebookFiles">
<tables-ebook-files-table-row :key="file.path" :libraryItemId="libraryItemId" :showFullPath="showFullPath" :file="file" @read="readEbook" />
</template>
</table>
</div>
</transition>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
showFiles: false,
showFullPath: false
}
},
computed: {
libraryItemId() {
return this.libraryItem.id
},
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
},
libraryIsAudiobooksOnly() {
return this.$store.getters['libraries/getLibraryIsAudiobooksOnly']
},
showMoreColumn() {
return this.userCanDelete || this.userCanDownload || (this.userCanUpdate && !this.libraryIsAudiobooksOnly)
},
ebookFiles() {
return (this.libraryItem.libraryFiles || []).filter((lf) => lf.fileType === 'ebook')
}
},
methods: {
readEbook(fileIno) {
this.$store.commit('showEReader', { libraryItem: this.libraryItem, keepProgress: false, fileId: fileIno })
},
clickBar() {
this.showFiles = !this.showFiles
}
},
mounted() {}
}
</script>

View File

@@ -1,139 +0,0 @@
<template>
<tr>
<td class="px-4">
{{ showFullPath ? file.metadata.path : file.metadata.relPath }} <ui-tooltip :text="$strings.LabelPrimaryEbook" class="inline-block"><span v-if="isPrimary" class="material-icons-outlined text-success align-text-bottom">check_circle</span></ui-tooltip>
</td>
<td>
{{ $bytesPretty(file.metadata.size) }}
</td>
<td class="text-xs">
<ui-icon-btn icon="auto_stories" outlined borderless icon-font-size="1.125rem" :size="8" @click="readEbook" />
</td>
<td v-if="contextMenuItems.length" class="text-center">
<ui-context-menu-dropdown :items="contextMenuItems" :menu-width="130" :processing="processing" @action="contextMenuAction" />
</td>
</tr>
</template>
<script>
export default {
props: {
libraryItemId: String,
showFullPath: Boolean,
file: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
},
downloadUrl() {
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${this.file.ino}/download?token=${this.userToken}`
},
isPrimary() {
return !this.file.isSupplementary
},
libraryIsAudiobooksOnly() {
return this.$store.getters['libraries/getLibraryIsAudiobooksOnly']
},
contextMenuItems() {
const items = []
if (this.userCanUpdate && !this.libraryIsAudiobooksOnly) {
items.push({
text: this.isPrimary ? this.$strings.LabelSetEbookAsSupplementary : this.$strings.LabelSetEbookAsPrimary,
action: 'updateStatus'
})
}
if (this.userCanDownload) {
items.push({
text: this.$strings.LabelDownload,
action: 'download'
})
}
if (this.userCanDelete) {
items.push({
text: this.$strings.ButtonDelete,
action: 'delete'
})
}
return items
}
},
methods: {
readEbook() {
this.$emit('read', this.file.ino)
},
contextMenuAction({ action }) {
if (action === 'delete') {
this.deleteLibraryFile()
} else if (action === 'download') {
this.downloadLibraryFile()
} else if (action === 'updateStatus') {
this.updateEbookStatus()
}
},
updateEbookStatus() {
this.processing = true
this.$axios
.$patch(`/api/items/${this.libraryItemId}/ebook/${this.file.ino}/status`)
.then(() => {
this.$toast.success('Ebook updated')
})
.catch((error) => {
console.error('Failed to update ebook', error)
this.$toast.error('Failed to update ebook')
})
.finally(() => {
this.processing = false
})
},
deleteLibraryFile() {
const payload = {
message: this.$strings.MessageConfirmDeleteFile,
callback: (confirmed) => {
if (confirmed) {
this.processing = true
this.$axios
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
.then(() => {
this.$toast.success('File deleted')
})
.catch((error) => {
console.error('Failed to delete file', error)
this.$toast.error('Failed to delete file')
})
.finally(() => {
this.processing = false
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
downloadLibraryFile() {
this.$downloadFile(this.downloadUrl, this.file.metadata.filename)
}
},
mounted() {}
}
</script>

View File

@@ -27,7 +27,7 @@
</div>
</transition>
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :library-item-id="libraryItemId" :audio-file="selectedAudioFile" />
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :audio-file="selectedAudioFile" />
</div>
</template>
@@ -38,6 +38,7 @@ export default {
type: Object,
default: () => {}
},
isMissing: Boolean,
expanded: Boolean, // start expanded
inModal: Boolean
},
@@ -69,9 +70,6 @@ export default {
return this.libraryItem.libraryFiles || []
},
audioFiles() {
if (this.libraryItem.mediaType === 'podcast') {
return this.libraryItem.media?.episodes.map((ep) => ep.audioFile) || []
}
return this.libraryItem.media?.audioFiles || []
},
filesWithAudioFile() {

View File

@@ -12,7 +12,7 @@
</div>
</td>
<td v-if="contextMenuItems.length" class="text-center">
<ui-context-menu-dropdown :items="contextMenuItems" :menu-width="110" @action="contextMenuAction" />
<ui-context-menu-dropdown :items="contextMenuItems" menu-width="110px" @action="contextMenuAction" />
</td>
</tr>
</template>
@@ -45,7 +45,7 @@ export default {
return this.$store.getters['user/getIsAdminOrUp']
},
downloadUrl() {
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${this.file.ino}/download?token=${this.userToken}`
return `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(this.file.metadata.relPath).replace(/^\//, '')}?token=${this.userToken}`
},
contextMenuItems() {
const items = []
@@ -72,7 +72,7 @@ export default {
}
},
methods: {
contextMenuAction({ action }) {
contextMenuAction(action) {
if (action === 'delete') {
this.deleteLibraryFile()
} else if (action === 'download') {
@@ -83,7 +83,7 @@ export default {
},
deleteLibraryFile() {
const payload = {
message: this.$strings.MessageConfirmDeleteFile,
message: 'This will delete the file from your file system. Are you sure?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
@@ -102,7 +102,15 @@ export default {
this.$store.commit('globals/setConfirmPrompt', payload)
},
downloadLibraryFile() {
this.$downloadFile(this.downloadUrl, this.file.metadata.filename)
const a = document.createElement('a')
a.style.display = 'none'
a.href = this.downloadUrl
a.download = this.file.metadata.filename
document.body.appendChild(a)
a.click()
setTimeout(() => {
a.remove()
})
}
},
mounted() {}

View File

@@ -70,10 +70,7 @@ export default {
methods: {
editItem(playlistItem) {
if (playlistItem.episode) {
const episodeIds = this.items.map((pi) => pi.episodeId)
this.$store.commit('setEpisodeTableEpisodeIds', episodeIds)
this.$store.commit('setSelectedLibraryItem', playlistItem.libraryItem)
this.$store.commit('globals/setSelectedEpisode', playlistItem.episode)
this.$store.commit('globals/setSelectedEpisode', playlist.episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
} else {
const itemIds = this.items.map((i) => i.libraryItemId)

View File

@@ -33,7 +33,7 @@
</div>
</transition>
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :library-item-id="libraryItemId" :audio-file="selectedAudioFile" />
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :audio-file="selectedAudioFile" />
</div>
</template>

View File

@@ -19,13 +19,9 @@
</td>
<td class="text-sm">{{ user.type }}</td>
<td class="hidden lg:table-cell">
<div v-if="usersOnline[user.id]?.session?.displayTitle">
<p class="truncate text-xs">Listening: {{ usersOnline[user.id].session.displayTitle || '' }}</p>
<p class="truncate text-xs text-gray-300">{{ getDeviceInfoString(usersOnline[user.id].session.deviceInfo) }}</p>
</div>
<div v-else-if="user.latestSession?.displayTitle">
<p class="truncate text-xs">Last: {{ user.latestSession.displayTitle || '' }}</p>
<p class="truncate text-xs text-gray-300">{{ getDeviceInfoString(user.latestSession.deviceInfo) }}</p>
<div v-if="usersOnline[user.id]">
<p v-if="usersOnline[user.id].session && usersOnline[user.id].session.libraryItem" class="truncate text-xs">Listening: {{ usersOnline[user.id].session.libraryItem.media.metadata.title || '' }}</p>
<p v-else-if="usersOnline[user.id].mostRecent && usersOnline[user.id].mostRecent.media" class="truncate text-xs">Last: {{ usersOnline[user.id].mostRecent.media.metadata.title }}</p>
</div>
</td>
<td class="text-xs font-mono hidden sm:table-cell">
@@ -87,12 +83,6 @@ export default {
}
},
methods: {
getDeviceInfoString(deviceInfo) {
if (!deviceInfo) return ''
if (deviceInfo.manufacturer && deviceInfo.model) return `${deviceInfo.manufacturer} ${deviceInfo.model}`
return `${deviceInfo.osName || 'Unknown'} ${deviceInfo.osVersion || ''} ${deviceInfo.browserName || ''}`
},
deleteUserClick(user) {
if (this.isDeletingUser) return
if (confirm(this.$getString('MessageRemoveUserWarning', [user.username]))) {
@@ -124,12 +114,11 @@ export default {
},
loadUsers() {
this.$axios
.$get('/api/users?include=latestSession')
.$get('/api/users')
.then((res) => {
this.users = res.users.sort((a, b) => {
return a.createdAt - b.createdAt
})
console.log('Loaded users', this.users)
})
.catch((error) => {
console.error('Failed', error)

View File

@@ -1,18 +1,16 @@
<template>
<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-18 md:h-[5.5rem]">
<div v-if="book" class="flex h-16 md:h-20">
<div class="w-10 min-w-10 md:w-16 md:max-w-16 h-full">
<div class="flex h-full items-center justify-center">
<span class="material-icons drag-handle text-lg md:text-xl">menu</span>
</div>
</div>
<div class="h-full flex items-center" :style="{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }">
<div class="relative" :style="{ height: coverHeight + 'px', minHeight: coverHeight + 'px', maxHeight: coverHeight + 'px' }">
<covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="absolute top-0 left-0 flex items-center justify-center bg-black bg-opacity-50 h-full w-full z-10" v-show="isHovering && showPlayBtn">
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
<span class="material-icons text-2xl">play_arrow</span>
</div>
<div class="h-full relative flex items-center" :style="{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }">
<covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="absolute top-0 left-0 bg-black bg-opacity-50 flex items-center justify-center h-full w-full z-10" v-show="isHovering && showPlayBtn">
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
<span class="material-icons text-2xl">play_arrow</span>
</div>
</div>
</div>
@@ -21,12 +19,9 @@
<div class="truncate max-w-48 md:max-w-md">
<nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline text-sm md:text-base">{{ bookTitle }}</nuxt-link>
</div>
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${book.libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300"> {{ _series.text }}</nuxt-link>
</div>
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
<template v-for="(author, index) in bookAuthors">
<nuxt-link :key="author.id" :to="`/author/${author.id}?library=${book.libraryId}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
<nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">,&nbsp;</span>
</template>
</div>
@@ -101,19 +96,6 @@ export default {
bookDuration() {
return this.$elapsedPretty(this.media.duration)
},
series() {
return this.mediaMetadata.series || []
},
seriesList() {
return this.series.map((se) => {
let text = se.name
if (se.sequence) text += ` #${se.sequence}`
return {
...se,
text
}
})
},
isMissing() {
return this.book.isMissing
},
@@ -135,9 +117,6 @@ export default {
coverSize() {
return this.$store.state.globals.isMobile ? 30 : 50
},
coverHeight() {
return this.coverSize * 1.6
},
coverWidth() {
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
return this.coverSize
@@ -188,6 +167,7 @@ export default {
.$patch(`/api/me/progress/${this.book.id}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
})
.catch((error) => {
console.error('Failed', error)

View File

@@ -11,6 +11,10 @@
<ui-btn @click="clickAddLibrary">{{ $strings.ButtonAddYourFirstLibrary }}</ui-btn>
</div>
<p v-if="libraries.length" class="text-xs mt-4 text-gray-200">
*<strong>{{ $strings.ButtonForceReScan }}</strong> {{ $strings.MessageForceReScanDescription }}
</p>
<p v-if="libraries.length && libraries.some((li) => li.mediaType === 'book')" class="text-xs mt-4 text-gray-200">
**<strong>{{ $strings.ButtonMatchBooks }}</strong> {{ $strings.MessageMatchBooksDescription }}
</p>

View File

@@ -71,6 +71,11 @@ export default {
text: this.$strings.ButtonScan,
action: 'scan',
value: 'scan'
},
{
text: this.$strings.ButtonForceReScan,
action: 'force-scan',
value: 'force-scan'
}
]
if (this.isBookLibrary) {
@@ -89,7 +94,7 @@ export default {
}
},
methods: {
contextMenuAction({ action }) {
contextMenuAction(action) {
this.showMobileMenu = false
if (action === 'edit') {
this.editClick()
@@ -132,6 +137,26 @@ export default {
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
})
},
forceScan() {
const payload = {
message: this.$strings.MessageConfirmForceReScan,
callback: (confirmed) => {
if (confirmed) {
this.$store
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
.then(() => {
this.$toast.success(this.$strings.ToastLibraryScanStarted)
})
.catch((error) => {
console.error('Failed to start scan', error)
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
deleteClick() {
const payload = {
message: this.$getString('MessageConfirmDeleteLibrary', [this.library.name]),

View File

@@ -21,7 +21,7 @@
</div>
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
<template v-for="(author, index) in bookAuthors">
<nuxt-link :key="author.id" :to="`/author/${author.id}?library=${libraryItem.libraryId}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
<nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">,&nbsp;</span>
</template>
<nuxt-link v-if="episode" :to="`/item/${libraryItem.id}`" class="truncate hover:underline">{{ mediaMetadata.title }}</nuxt-link>
@@ -198,6 +198,7 @@ export default {
.$patch(routepath, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
})
.catch((error) => {
console.error('Failed', error)

View File

@@ -7,7 +7,8 @@
<widgets-podcast-type-indicator :type="episode.episodeType" />
</div>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5" v-html="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>
@@ -21,6 +22,10 @@
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
</button>
<!-- <button v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" class="h-8 w-8 flex justify-center items-center mx-2" :class="isQueued ? 'text-success' : ''" @click.stop="queueBtnClick">
<span class="material-icons-outlined">{{ isQueued ? 'playlist_add_check' : 'queue' }}</span>
</button> -->
<ui-tooltip v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" :text="isQueued ? $strings.MessageRemoveFromPlayerQueue : $strings.MessageAddToPlayerQueue" :class="isQueued ? 'text-success' : ''" direction="top">
<ui-icon-btn :icon="isQueued ? 'playlist_add_check' : 'playlist_play'" borderless @click="queueBtnClick" />
</ui-tooltip>
@@ -84,7 +89,7 @@ export default {
return this.episode.title || ''
},
subtitle() {
return this.episode.subtitle || this.description
return this.episode.subtitle || ''
},
description() {
return this.episode.description || ''
@@ -183,6 +188,7 @@ export default {
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
})
.catch((error) => {
console.error('Failed', error)

View File

@@ -1,29 +1,22 @@
<template>
<div class="w-full py-6">
<div class="flex flex-wrap flex-col md:flex-row md:items-center mb-4">
<div class="flex items-center flex-nowrap whitespace-nowrap mb-2 md:mb-0">
<p class="text-lg mb-0 font-semibold">{{ $strings.HeaderEpisodes }}</p>
<div class="inline-flex bg-white/5 px-1 mx-2 rounded-md text-sm text-gray-100">
<p v-if="episodesList.length === episodes.length">{{ episodes.length }}</p>
<p v-else>{{ episodesList.length }} / {{ episodes.length }}</p>
</div>
</div>
<p class="text-lg mb-2 font-semibold md:hidden">{{ $strings.HeaderEpisodes }}</p>
<div class="flex items-center mb-4">
<p class="text-lg mb-0 font-semibold hidden md:block">{{ $strings.HeaderEpisodes }}</p>
<div class="flex-grow hidden md:block" />
<div class="flex items-center">
<template v-if="isSelectionMode">
<ui-tooltip :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
<ui-read-icon-btn :disabled="processing" :is-read="selectedIsFinished" @click="toggleBatchFinished" class="mx-1.5" />
</ui-tooltip>
<ui-btn color="error" :disabled="processing" small class="h-9" @click="removeSelectedEpisodes">{{ $getString('MessageRemoveEpisodes', [selectedEpisodes.length]) }}</ui-btn>
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">{{ $strings.ButtonCancel }}</ui-btn>
</template>
<template v-else>
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 md:ml-4" />
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" />
<div class="flex-grow md:hidden" />
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
</template>
</div>
<template v-if="isSelectionMode">
<ui-tooltip :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
<ui-read-icon-btn :disabled="processing" :is-read="selectedIsFinished" @click="toggleBatchFinished" class="mx-1.5" />
</ui-tooltip>
<ui-btn color="error" :disabled="processing" small class="h-9" @click="removeSelectedEpisodes">{{ $getString('MessageRemoveEpisodes', [selectedEpisodes.length]) }}</ui-btn>
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">{{ $strings.ButtonCancel }}</ui-btn>
</template>
<template v-else>
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 sm:ml-4" />
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" />
<div class="flex-grow md:hidden" />
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
</template>
</div>
<p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p>
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
@@ -58,6 +51,7 @@ export default {
selectedEpisodes: [],
episodesToRemove: [],
processing: false,
quickMatchingEpisodes: false,
search: null,
searchTimeout: null,
searchText: null
@@ -77,10 +71,6 @@ export default {
{
text: 'Quick match all episodes',
action: 'quick-match-episodes'
},
{
text: this.allEpisodesFinished ? this.$strings.MessageMarkAllEpisodesNotFinished : this.$strings.MessageMarkAllEpisodesFinished,
action: 'batch-mark-as-finished'
}
]
},
@@ -167,20 +157,14 @@ export default {
episodesList() {
return this.episodesSorted.filter((episode) => {
if (!this.searchText) return true
return episode.title?.toLowerCase().includes(this.searchText) || episode.subtitle?.toLowerCase().includes(this.searchText)
return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
})
},
selectedIsFinished() {
// Find an item that is not finished, if none then all items finished
return !this.selectedEpisodes.some((episode) => {
const itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
return !itemProgress?.isFinished
})
},
allEpisodesFinished() {
return !this.episodesSorted.some((episode) => {
const itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
return !itemProgress?.isFinished
return !this.selectedEpisodes.find((episode) => {
var itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
return !itemProgress || !itemProgress.isFinished
})
},
dateFormat() {
@@ -191,7 +175,6 @@ export default {
}
},
methods: {
submit() {},
inputUpdate() {
clearTimeout(this.searchTimeout)
this.searchTimeout = setTimeout(() => {
@@ -202,36 +185,19 @@ export default {
this.searchText = this.search.toLowerCase().trim()
}, 500)
},
contextMenuAction({ action }) {
contextMenuAction(action) {
if (action === 'quick-match-episodes') {
if (this.processing) return
if (this.quickMatchingEpisodes) return
this.quickMatchAllEpisodes()
} else if (action === 'batch-mark-as-finished') {
if (this.processing) return
this.markAllEpisodesFinished()
}
},
markAllEpisodesFinished() {
const newIsFinished = !this.allEpisodesFinished
const payload = {
message: newIsFinished ? this.$strings.MessageConfirmMarkAllEpisodesFinished : this.$strings.MessageConfirmMarkAllEpisodesNotFinished,
callback: (confirmed) => {
if (confirmed) {
this.batchUpdateEpisodesFinished(this.episodesSorted, newIsFinished)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
quickMatchAllEpisodes() {
if (!this.mediaMetadata.feedUrl) {
this.$toast.error(this.$strings.MessagePodcastHasNoRSSFeedForMatching)
return
}
this.processing = true
this.quickMatchingEpisodes = true
const payload = {
message: 'Quick matching episodes will overwrite details if a match is found. Only unmatched episodes will be updated. Are you sure?',
@@ -251,7 +217,7 @@ export default {
this.$toast.error('Failed to match episodes')
})
}
this.processing = false
this.quickMatchingEpisodes = false
},
type: 'yesNo'
}
@@ -275,19 +241,17 @@ export default {
this.$store.commit('addItemToQueue', queueItem)
},
toggleBatchFinished() {
this.batchUpdateEpisodesFinished(this.selectedEpisodes, !this.selectedIsFinished)
},
batchUpdateEpisodesFinished(episodes, newIsFinished) {
this.processing = true
const updateProgressPayloads = episodes.map((episode) => {
var newIsFinished = !this.selectedIsFinished
var updateProgressPayloads = this.selectedEpisodes.map((episode) => {
return {
libraryItemId: this.libraryItem.id,
episodeId: episode.id,
isFinished: newIsFinished
}
})
return this.$axios
this.$axios
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => {
this.$toast.success(this.$strings.ToastBatchUpdateSuccess)

View File

@@ -73,7 +73,7 @@ export default {
}
</script>
<style scoped>
<style>
.btn::before {
content: '';
position: absolute;

View File

@@ -1,37 +1,16 @@
<template>
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing">
<button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu">
<button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-icons" :class="iconClass">more_vert</span>
</button>
<div v-else class="h-full w-full flex items-center justify-center">
<widgets-loading-spinner />
</div>
</slot>
<transition name="menu">
<div v-show="showMenu" ref="menuWrapper" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }">
<div v-show="showMenu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 overflow-auto focus:outline-none sm:text-sm" :style="{ width: menuWidth }">
<template v-for="(item, index) in items">
<template v-if="item.subitems">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
<p>{{ item.text }}</p>
</div>
<div
v-if="mouseoverItemIndex === index"
:key="`subitems-${index}`"
@mouseover="mouseoverSubItemMenu(index)"
@mouseleave="mouseleaveSubItemMenu(index)"
class="absolute bg-bg border rounded-b-md border-black-200 shadow-lg z-50 -ml-px py-1"
:class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'"
:style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }"
>
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.action, subitem.data)">
<p>{{ subitem.text }}</p>
</div>
</div>
</template>
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
<p class="text-left">{{ item.text }}</p>
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
<p>{{ item.text }}</p>
</div>
</template>
</div>
@@ -52,10 +31,9 @@ export default {
default: ''
},
menuWidth: {
type: Number,
default: 192
},
processing: Boolean
type: String,
default: '192px'
}
},
data() {
return {
@@ -64,54 +42,22 @@ export default {
events: ['mousedown'],
isActive: true
},
submenuWidth: 144,
showMenu: false,
mouseoverItemIndex: null,
isOverSubItemMenu: false,
openSubMenuLeft: false
}
},
computed: {
submenuLeftPos() {
return this.openSubMenuLeft ? -(this.submenuWidth - 1) : this.menuWidth - 0.5
showMenu: false
}
},
computed: {},
methods: {
mouseoverSubItemMenu(index) {
this.isOverSubItemMenu = true
},
mouseleaveSubItemMenu(index) {
setTimeout(() => {
if (this.isOverSubItemMenu && this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
}, 1)
},
mouseoverItem(index) {
this.isOverSubItemMenu = false
this.mouseoverItemIndex = index
},
mouseleaveItem(index) {
setTimeout(() => {
if (this.isOverSubItemMenu) return
if (this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
}, 1)
},
clickShowMenu() {
if (this.disabled) return
this.showMenu = !this.showMenu
this.$nextTick(() => {
const boundingRect = this.$refs.menuWrapper?.getBoundingClientRect()
if (boundingRect) {
this.openSubMenuLeft = window.innerWidth - boundingRect.x < this.menuWidth + this.submenuWidth + 5
}
})
},
clickedOutside() {
this.showMenu = false
},
clickAction(action, data) {
clickAction(action) {
if (this.disabled) return
this.showMenu = false
this.$emit('action', { action, data })
this.$emit('action', action)
}
},
mounted() {}

View File

@@ -1,14 +1,6 @@
<template>
<div v-if="currentLibrary" class="relative h-8 max-w-52 md:min-w-32" v-click-outside="clickOutsideObj">
<button
type="button"
:disabled="disabled"
class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200"
aria-haspopup="listbox"
:aria-expanded="showMenu"
:aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name"
@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 cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" :aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name" @click.stop.prevent="clickShowMenu">
<div class="flex items-center justify-center sm:justify-start">
<ui-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" />
<span class="hidden sm:block truncate">{{ currentLibrary.name }}</span>
@@ -16,7 +8,7 @@
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" 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 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="option" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
<div class="flex items-center px-2">
@@ -101,10 +93,4 @@ export default {
},
mounted() {}
}
</script>
<style scoped>
.librariesDropdownMenu {
max-height: calc(100vh - 75px);
}
</style>
</script>

View File

@@ -0,0 +1,61 @@
<template>
<div class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full bg-fg border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="showMenu = !showMenu">
<span class="flex items-center">
<span class="block truncate">{{ label }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons text-2xl text-gray-100" aria-label="User Account" role="button">person</span>
</span>
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3">
<template v-for="item in items">
<nuxt-link :key="item.value" v-if="item.to" :to="item.to">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
</div>
</li>
</nuxt-link>
<li v-else :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
</div>
</li>
</template>
</ul>
</transition>
</div>
</template>
<script>
export default {
props: {
label: {
type: String,
default: 'Menu'
},
items: {
type: Array,
default: () => []
}
},
data() {
return {
showMenu: false
}
},
methods: {
clickOutside() {
this.showMenu = false
},
clickedOption(itemValue) {
this.$emit('action', itemValue)
this.showMenu = false
}
},
mounted() {}
}
</script>

View File

@@ -46,7 +46,7 @@ export default {
type: Array,
default: () => []
},
filterKey: String,
endpoint: String,
label: String,
disabled: Boolean,
readonly: Boolean,
@@ -60,6 +60,7 @@ export default {
return {
textInput: null,
currentSearch: null,
searching: false,
typingTimeout: null,
isFocused: false,
menu: null,
@@ -96,9 +97,6 @@ export default {
},
itemsToShow() {
return this.items
},
filterData() {
return this.$store.state.libraries.filterData || {}
}
},
methods: {
@@ -111,15 +109,20 @@ export default {
getIsSelected(itemValue) {
return !!this.selected.find((i) => i.id === itemValue)
},
search() {
async search() {
if (this.searching) return
this.currentSearch = this.textInput
const dataToSearch = this.filterData[this.filterKey] || []
const results = dataToSearch.filter((au) => {
return au.name.toLowerCase().includes(this.currentSearch.toLowerCase().trim())
})
this.searching = true
const results = await this.$axios
.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15&token=${this.userToken}`)
.then((res) => res.results || res)
.catch((error) => {
console.error('Failed to get search results', error)
return []
})
this.items = results || []
this.searching = false
},
keydownInput() {
clearTimeout(this.typingTimeout)

View File

@@ -1,86 +0,0 @@
<template>
<div class="inline-flex">
<input v-model="input" type="range" :min="min" :max="max" :step="step" />
<p class="text-sm ml-2">{{ input }}%</p>
</div>
</template>
<script>
export default {
props: {
value: [String, Number],
min: Number,
max: Number,
step: Number
},
data() {
return {}
},
computed: {
input: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {},
mounted() {}
}
</script>
<style scoped>
input[type='range'] {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
}
input[type='range']:focus {
outline: none;
}
/* chromium */
input[type='range']::-webkit-slider-runnable-track {
background-color: rgb(0 0 0 / 0.25);
border-radius: 9999px;
height: 0.75rem;
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
margin-top: -0.25rem;
border-radius: 9999px;
background-color: rgb(255 255 255 / 0.7);
height: 1.25rem;
width: 1.25rem;
}
input[type='range']:focus::-webkit-slider-thumb {
border: 1px solid #6b6b6b;
outline: 3px solid #6b6b6b;
outline-offset: 0.125rem;
}
/* firefox */
input[type='range']::-moz-range-track {
background-color: rgb(0 0 0 / 0.25);
border-radius: 9999px;
height: 0.75rem;
}
input[type='range']::-moz-range-thumb {
border: none;
border-radius: 9999px;
margin-top: -0.25rem;
background-color: rgb(255 255 255 / 0.7);
height: 1.25rem;
width: 1.25rem;
}
input[type='range']:focus::-moz-range-thumb {
border: 1px solid #6b6b6b;
outline: 3px solid #6b6b6b;
outline-offset: 0.125rem;
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<ui-textarea-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :rows="rows" class="w-full" />
<ui-textarea-input ref="input" v-model="inputValue" :disabled="disabled" :rows="rows" class="w-full" />
</div>
</template>
@@ -11,7 +11,6 @@ export default {
value: [String, Number],
label: String,
disabled: Boolean,
readonly: Boolean,
rows: {
type: Number,
default: 2

View File

@@ -1,85 +0,0 @@
<template>
<div class="inline-flex toggle-btn-wrapper shadow-md">
<button v-for="item in items" :key="item.value" type="button" class="toggle-btn outline-none relative border border-gray-600 px-4 py-1" :class="{ selected: item.value === value }" @click.stop="clickBtn(item.value)">
{{ item.text }}
</button>
</div>
</template>
<script>
export default {
props: {
value: String,
/**
* [{ "text", "", "value": "" }]
*/
items: {
type: Array,
default: Object
}
},
data() {
return {}
},
computed: {},
methods: {
clickBtn(value) {
this.$emit('input', value)
}
},
mounted() {}
}
</script>
<style scoped>
.toggle-btn-wrapper .toggle-btn:first-child {
border-top-left-radius: 0.375rem /* 6px */;
border-bottom-left-radius: 0.375rem /* 6px */;
}
.toggle-btn-wrapper .toggle-btn:last-child {
border-top-right-radius: 0.375rem /* 6px */;
border-bottom-right-radius: 0.375rem /* 6px */;
}
.toggle-btn-wrapper .toggle-btn:first-child::before {
border-top-left-radius: 0.375rem /* 6px */;
border-bottom-left-radius: 0.375rem /* 6px */;
}
.toggle-btn-wrapper .toggle-btn:last-child::before {
border-top-right-radius: 0.375rem /* 6px */;
border-bottom-right-radius: 0.375rem /* 6px */;
}
.toggle-btn-wrapper .toggle-btn:not(:first-child) {
margin-left: -1px;
}
.toggle-btn::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
.toggle-btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
.toggle-btn:hover:not(:disabled) {
color: white;
}
.toggle-btn {
color: rgba(255, 255, 255, 0.75);
}
.toggle-btn.selected {
color: white;
}
.toggle-btn.selected::before {
background-color: rgba(255, 255, 255, 0.1);
}
button.toggle-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
</style>

View File

@@ -213,9 +213,7 @@ export default {
// Reload HTML content
this.$refs.trix.editor.loadHTML(newContent)
// Move cursor to end of new content updated
if (this.autofocus) {
this.$refs.trix.editor.setSelectedRange(this.getContentEndPosition())
}
this.$refs.trix.editor.setSelectedRange(this.getContentEndPosition())
},
getContentEndPosition() {
return this.$refs.trix.editor.getDocument().toString().length - 1

View File

@@ -12,8 +12,8 @@
<div class="flex flex-wrap mt-2 -mx-1">
<div class="w-full md:w-3/4 px-1">
<!-- Authors filter only contains authors in this library, uses filter data -->
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" :label="$strings.LabelAuthors" filter-key="authors" />
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" :label="$strings.LabelAuthors" endpoint="authors/search" />
</div>
<div class="flex-grow px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" :label="$strings.LabelPublishYear" />

View File

@@ -1,17 +1,7 @@
<template>
<div ref="wrapper" class="absolute bg-bg rounded-md py-1 border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" :style="{ width: menuWidth + 'px' }" 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-if="item.subitems">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
<p>{{ item.text }}</p>
</div>
<div v-if="mouseoverItemIndex === index" :key="`subitems-${index}`" @mouseover="mouseoverSubItemMenu(index)" @mouseleave="mouseleaveSubItemMenu(index)" class="absolute bg-bg rounded-b-md border border-black-200 py-1 shadow-lg z-50" :class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'" :style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }">
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.func, subitem.data)">
<p>{{ subitem.text }}</p>
</div>
</div>
</template>
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop="clickAction(item.func)">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.func)">
<p>{{ item.text }}</p>
</div>
</template>
@@ -32,43 +22,13 @@ export default {
handler: this.clickedOutside,
events: ['mousedown'],
isActive: true
},
submenuWidth: 144,
menuWidth: 144,
mouseoverItemIndex: null,
isOverSubItemMenu: false,
openSubMenuLeft: false
}
},
computed: {
submenuLeftPos() {
return this.openSubMenuLeft ? -this.submenuWidth : this.menuWidth - 1.5
}
}
},
computed: {},
methods: {
mouseoverSubItemMenu(index) {
this.isOverSubItemMenu = true
},
mouseleaveSubItemMenu(index) {
setTimeout(() => {
if (this.isOverSubItemMenu && this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
}, 1)
},
mouseoverItem(index) {
this.isOverSubItemMenu = false
this.mouseoverItemIndex = index
},
mouseleaveItem(index) {
setTimeout(() => {
if (this.isOverSubItemMenu) return
if (this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
}, 1)
},
clickAction(func, data) {
this.$emit('action', {
func,
data
})
clickAction(func) {
this.$emit('action', func)
this.close()
},
clickedOutside(e) {
@@ -84,14 +44,7 @@ export default {
this.$el.parentNode.removeChild(this.$el)
}
},
mounted() {
this.$nextTick(() => {
const boundingRect = this.$refs.wrapper?.getBoundingClientRect()
if (boundingRect) {
this.openSubMenuLeft = window.innerWidth - boundingRect.x < this.menuWidth + this.submenuWidth + 5
}
})
},
mounted() {},
beforeDestroy() {}
}
</script>

View File

@@ -1,21 +1,19 @@
<template>
<div v-if="tasksToShow.length" class="w-4 h-4 mx-3 relative" v-click-outside="clickOutsideObj">
<div v-if="tasksRunning" class="w-4 h-4 mx-3 relative" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full cursor-pointer" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<div class="flex h-full items-center justify-center">
<ui-tooltip v-if="tasksRunning" :text="$strings.LabelTasks" direction="bottom" class="flex items-center">
<ui-tooltip text="Tasks running" direction="bottom" class="flex items-center">
<widgets-loading-spinner />
</ui-tooltip>
<ui-tooltip v-else text="Activities" direction="bottom" class="flex items-center">
<span class="material-icons text-1.5xl" aria-label="Activities" role="button">notifications</span>
</ui-tooltip>
</div>
</button>
<transition name="menu">
<div class="sm:w-80 w-full relative">
<div v-show="showMenu" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalTaskRunningMenu">
<div v-show="showMenu" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalTaskRunningMenu">
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-if="tasksToShow.length">
<template v-for="task in tasksToShow">
<template v-if="tasksRunningOrFailed.length">
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelTasks }}</p>
<template v-for="task in tasksRunningOrFailed">
<nuxt-link :key="task.id" v-if="actionLink(task)" :to="actionLink(task)">
<li class="text-gray-50 select-none relative hover:bg-black-400 py-1 cursor-pointer">
<cards-item-task-running-card :task="task" />
@@ -56,10 +54,9 @@ export default {
tasksRunning() {
return this.tasks.some((t) => !t.isFinished)
},
tasksToShow() {
// return just the tasks that are running or failed (or show success) in the last 1 minute
const tasks = this.tasks.filter((t) => !t.isFinished || ((t.isFailed || t.showSuccess) && t.finishedAt > new Date().getTime() - 1000 * 60)) || []
return tasks.sort((a, b) => b.startedAt - a.startedAt)
tasksRunningOrFailed() {
// return just the tasks that are running or failed in the last 1 minute
return this.tasks.filter((t) => !t.isFinished || (t.isFailed && t.finishedAt > new Date().getTime() - 1000 * 60)) || []
}
},
methods: {
@@ -78,8 +75,6 @@ export default {
return `/audiobook/${task.data.libraryItemId}/manage?tool=m4b`
case 'embed-metadata':
return `/audiobook/${task.data.libraryItemId}/manage?tool=embed`
case 'scan-item':
return `/item/${task.data.libraryItemId}`
default:
return ''
}

View File

@@ -35,9 +35,7 @@ export default {
isSocketConnected: false,
isFirstSocketConnection: true,
socketConnectionToastId: null,
currentLang: null,
multiSessionOtherSessionId: null, // Used for multiple sessions open warning toast
multiSessionCurrentSessionId: null // Used for multiple sessions open warning toast
currentLang: null
}
},
watch: {
@@ -231,12 +229,8 @@ export default {
scanComplete(data) {
console.log('Scan complete received', data)
let message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" complete!`
let toastType = 'success'
if (data.error) {
message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" finished with error:\n${data.error}`
toastType = 'error'
} else if (data.results) {
var message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" complete!`
if (data.results) {
var scanResultMsgs = []
var results = data.results
if (results.added) scanResultMsgs.push(`${results.added} added`)
@@ -251,9 +245,9 @@ export default {
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
if (existingScan && !isNaN(existingScan.toastId)) {
this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: toastType, closeButton: false, onClose: () => null } }, true)
this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: 'success', closeButton: false, onClose: () => null } }, true)
} else {
this.$toast[toastType](message, { timeout: 5000 })
this.$toast.success(message, { timeout: 5000 })
}
this.$store.commit('scanners/remove', data)
@@ -306,27 +300,14 @@ export default {
this.$store.commit('users/updateUserOnline', user)
},
userSessionClosed(sessionId) {
// If this session or other session is closed then dismiss multiple sessions warning toast
if (sessionId === this.multiSessionOtherSessionId || this.multiSessionCurrentSessionId === sessionId) {
this.multiSessionOtherSessionId = null
this.multiSessionCurrentSessionId = null
this.$toast.dismiss('multiple-sessions')
}
if (this.$refs.streamContainer) this.$refs.streamContainer.sessionClosedEvent(sessionId)
},
userMediaProgressUpdate(payload) {
this.$store.commit('user/updateMediaProgress', payload)
if (payload.data) {
if (this.$store.getters['getIsMediaStreaming'](payload.data.libraryItemId, payload.data.episodeId) && this.$store.state.playbackSessionId !== payload.sessionId) {
this.multiSessionOtherSessionId = payload.sessionId
this.multiSessionCurrentSessionId = this.$store.state.playbackSessionId
console.log(`Media progress was updated from another session (${this.multiSessionOtherSessionId}) for currently open media. Device description=${payload.deviceDescription}. Current session id=${this.multiSessionCurrentSessionId}`)
if (this.$store.state.streamIsPlaying) {
this.$toast.update('multiple-sessions', { content: `Another session is open for this item on device ${payload.deviceDescription}`, options: { timeout: 20000, type: 'warning', pauseOnFocusLoss: false } }, true)
} else {
this.$eventBus.$emit('playback-time-update', payload.data.currentTime)
}
if (this.$store.getters['getIsMediaStreaming'](payload.data.libraryItemId, payload.data.episodeId)) {
// TODO: Update currently open session if being played from another device
}
}
},
@@ -347,10 +328,6 @@ export default {
}
this.$store.commit('libraries/removeCollection', collection)
},
seriesRemoved({ id, libraryId }) {
if (this.currentLibraryId !== libraryId) return
this.$store.commit('libraries/removeSeriesFromFilterData', id)
},
playlistAdded(playlist) {
if (playlist.userId !== this.user.id || this.currentLibraryId !== playlist.libraryId) return
this.$store.commit('libraries/addUpdateUserPlaylist', playlist)
@@ -388,11 +365,6 @@ export default {
adminMessageEvt(message) {
this.$toast.info(message)
},
ereaderDevicesUpdated(data) {
if (!data?.ereaderDevices) return
this.$store.commit('libraries/setEReaderDevices', data.ereaderDevices)
},
initializeSocket() {
this.socket = this.$nuxtSocket({
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
@@ -450,9 +422,6 @@ export default {
this.socket.on('collection_updated', this.collectionUpdated)
this.socket.on('collection_removed', this.collectionRemoved)
// Series Listeners
this.socket.on('series_removed', this.seriesRemoved)
// User Playlist Listeners
this.socket.on('playlist_added', this.playlistAdded)
this.socket.on('playlist_updated', this.playlistUpdated)
@@ -468,9 +437,6 @@ export default {
this.socket.on('task_finished', this.taskFinished)
this.socket.on('metadata_embed_queue_update', this.metadataEmbedQueueUpdate)
// EReader Device Listeners
this.socket.on('ereader-devices-updated', this.ereaderDevicesUpdated)
this.socket.on('backup_applied', this.backupApplied)
this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete)
@@ -502,9 +468,9 @@ export default {
}
},
checkActiveElementIsInput() {
const activeElement = document.activeElement
const inputs = ['input', 'select', 'button', 'textarea', 'trix-editor']
return activeElement && inputs.some((i) => i === activeElement.tagName.toLowerCase())
var activeElement = document.activeElement
var inputs = ['input', 'select', 'button', 'textarea']
return activeElement && inputs.indexOf(activeElement.tagName.toLowerCase()) !== -1
},
getHotkeyName(e) {
var keyCode = e.keyCode || e.which
@@ -571,6 +537,12 @@ export default {
.catch((err) => console.error(err))
},
initLocalStorage() {
// If experimental features set in local storage
var experimentalFeaturesSaved = localStorage.getItem('experimental')
if (experimentalFeaturesSaved === '1') {
this.$store.commit('setExperimentalFeatures', true)
}
// Queue auto play
var playerQueueAutoPlay = localStorage.getItem('playerQueueAutoPlay')
this.$store.commit('setPlayerQueueAutoPlay', playerQueueAutoPlay !== '0')

View File

@@ -27,7 +27,11 @@ module.exports = {
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ hid: 'description', name: 'description', content: '' }
],
script: [],
script: [
{
src: (process.env.ROUTER_BASE_PATH || '') + '/libs/sortable.js'
}
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: (process.env.ROUTER_BASE_PATH || '') + '/favicon.ico' }
]
@@ -71,8 +75,9 @@ module.exports = {
],
proxy: {
'/api/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } }
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
'/ebook/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' + process.env : '/' },
},
io: {

146
client/package-lock.json generated
View File

@@ -1,17 +1,16 @@
{
"name": "audiobookshelf-client",
"version": "2.4.4",
"version": "2.2.19",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.4.4",
"version": "2.2.19",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
"@nuxtjs/proxy": "^2.1.0",
"@teckel/vue-pdf": "^4.3.5",
"core-js": "^3.16.0",
"cron-parser": "^4.7.1",
"date-fns": "^2.25.0",
@@ -22,6 +21,7 @@
"nuxt-socket-io": "^1.1.18",
"trix": "^1.3.1",
"v-click-outside": "^3.1.2",
"vue-pdf": "^4.2.0",
"vue-toastification": "^1.7.11",
"vuedraggable": "^2.24.3"
},
@@ -2983,43 +2983,6 @@
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg=="
},
"node_modules/@teckel/vue-pdf": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/@teckel/vue-pdf/-/vue-pdf-4.3.5.tgz",
"integrity": "sha512-g2DAbZMPbPc7NPFImOsU/e7rt7wfdmBkmFa2kPsB4x+k+Bs8yC5Icmq/VnTSEq/Y8bNvEY7i6+JoicGnlfQL7Q==",
"dependencies": {
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"loader-utils": "^1.4.0",
"pdfjs-dist": "^2.5.207 <2.8.0",
"raw-loader": "^4.0.1",
"vue-resize-sensor": "^2.0.0",
"worker-loader": "^2.0.0"
}
},
"node_modules/@teckel/vue-pdf/node_modules/json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"dependencies": {
"minimist": "^1.2.0"
},
"bin": {
"json5": "lib/cli.js"
}
},
"node_modules/@teckel/vue-pdf/node_modules/loader-utils": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
"dependencies": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^1.0.1"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/@types/anymatch": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-3.0.0.tgz",
@@ -15958,6 +15921,43 @@
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g=="
},
"node_modules/vue-pdf": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/vue-pdf/-/vue-pdf-4.3.0.tgz",
"integrity": "sha512-zd3lJj6CbtrawgaaDDciTDjkJMUKiLWtbEmBg5CvFn9Noe9oAO/GNy/fc5c59qGuFCJ14ibIV1baw4S07e5bSQ==",
"dependencies": {
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"loader-utils": "^1.4.0",
"pdfjs-dist": "2.6.347",
"raw-loader": "^4.0.2",
"vue-resize-sensor": "^2.0.0",
"worker-loader": "^2.0.0"
}
},
"node_modules/vue-pdf/node_modules/json5": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"dependencies": {
"minimist": "^1.2.0"
},
"bin": {
"json5": "lib/cli.js"
}
},
"node_modules/vue-pdf/node_modules/loader-utils": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
"dependencies": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^1.0.1"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/vue-resize-sensor": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz",
@@ -19591,39 +19591,6 @@
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg=="
},
"@teckel/vue-pdf": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/@teckel/vue-pdf/-/vue-pdf-4.3.5.tgz",
"integrity": "sha512-g2DAbZMPbPc7NPFImOsU/e7rt7wfdmBkmFa2kPsB4x+k+Bs8yC5Icmq/VnTSEq/Y8bNvEY7i6+JoicGnlfQL7Q==",
"requires": {
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"loader-utils": "^1.4.0",
"pdfjs-dist": "^2.5.207 <2.8.0",
"raw-loader": "^4.0.1",
"vue-resize-sensor": "^2.0.0",
"worker-loader": "^2.0.0"
},
"dependencies": {
"json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"requires": {
"minimist": "^1.2.0"
}
},
"loader-utils": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^1.0.1"
}
}
}
},
"@types/anymatch": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-3.0.0.tgz",
@@ -29651,6 +29618,39 @@
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g=="
},
"vue-pdf": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/vue-pdf/-/vue-pdf-4.3.0.tgz",
"integrity": "sha512-zd3lJj6CbtrawgaaDDciTDjkJMUKiLWtbEmBg5CvFn9Noe9oAO/GNy/fc5c59qGuFCJ14ibIV1baw4S07e5bSQ==",
"requires": {
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"loader-utils": "^1.4.0",
"pdfjs-dist": "2.6.347",
"raw-loader": "^4.0.2",
"vue-resize-sensor": "^2.0.0",
"worker-loader": "^2.0.0"
},
"dependencies": {
"json5": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"requires": {
"minimist": "^1.2.0"
}
},
"loader-utils": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^1.0.1"
}
}
}
},
"vue-resize-sensor": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.4.4",
"version": "2.2.19",
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
"scripts": {
@@ -15,7 +15,6 @@
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
"@nuxtjs/proxy": "^2.1.0",
"@teckel/vue-pdf": "^4.3.5",
"core-js": "^3.16.0",
"cron-parser": "^4.7.1",
"date-fns": "^2.25.0",
@@ -26,6 +25,7 @@
"nuxt-socket-io": "^1.1.18",
"trix": "^1.3.1",
"v-click-outside": "^3.1.2",
"vue-pdf": "^4.2.0",
"vue-toastification": "^1.7.11",
"vuedraggable": "^2.24.3"
},

View File

@@ -94,9 +94,16 @@
<span class="material-icons-outlined text-lg">error_outline</span>
</button>
</ui-tooltip>
<button class="w-7 h-7 rounded-full flex items-center justify-center text-white" @click="setShowWaveform(chapter.id)">
<span class="material-icons-outlined text-lg">graphic_eq</span>
</button>
</div>
</div>
</div>
<div v-if="showWaveform[chapter.id]" :key="`${chapter.id}-waveform`">
<img :src="`${baseUrl}/api/tools/item/${libraryItem.id}/waveform?start=${Math.max(0, chapter.start - 10)}&end=${Math.min(mediaDuration, chapter.start + 10)}&token=${userToken}`" />
</div>
</template>
</div>
@@ -112,17 +119,17 @@
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
<div class="flex-grow">{{ $strings.LabelFilename }}</div>
<div class="w-20">{{ $strings.LabelDuration }}</div>
<div class="w-20 hidden md:block text-center">{{ $strings.HeaderChapters }}</div>
<div class="w-20 text-center">{{ $strings.HeaderChapters }}</div>
</div>
<template v-for="track in audioTracks">
<div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success bg-opacity-10' : ''">
<div class="flex-grow max-w-[calc(100%-80px)] pr-2">
<div class="flex-grow">
<p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p>
</div>
<div class="w-20" style="min-width: 80px">
<p class="text-xs font-mono text-gray-200">{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}</p>
</div>
<div class="w-20 hidden md:flex justify-center" style="min-width: 80px">
<div class="w-20 flex justify-center" style="min-width: 80px">
<span v-if="(track.chapters || []).length" class="material-icons text-success text-sm">check</span>
</div>
</div>
@@ -246,7 +253,8 @@ export default {
chapterData: null,
showSecondInputs: false,
audibleRegions: ['US', 'CA', 'UK', 'AU', 'FR', 'DE', 'JP', 'IT', 'IN', 'ES'],
hasChanges: false
hasChanges: false,
showWaveform: {}
}
},
computed: {
@@ -256,6 +264,9 @@ export default {
userToken() {
return this.$store.getters['user/getToken']
},
baseUrl() {
return process.env.serverUrl
},
media() {
return this.libraryItem.media || {}
},
@@ -288,6 +299,9 @@ export default {
}
},
methods: {
setShowWaveform(chapterId) {
this.$set(this.showWaveform, chapterId, true)
},
setChaptersFromTracks() {
let currentStartTime = 0
let index = 0

View File

@@ -94,7 +94,7 @@
<transition name="slide">
<div v-if="showEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
<div class="flex flex-wrap -mx-2">
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="'Audio Bitrate (e.g. 128k)'" class="m-2 max-w-40" />
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="'Audio Bitrate (e.g. 64k)'" class="m-2 max-w-40" />
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" :disabled="processing || isTaskFinished" :label="'Audio Channels (1 or 2)'" class="m-2 max-w-40" />
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" :disabled="processing || isTaskFinished" :label="'Audio Codec'" class="m-2 max-w-40" />
</div>
@@ -214,7 +214,7 @@ export default {
showEncodeOptions: false,
shouldBackupAudioFiles: true,
encodingOptions: {
bitrate: '128k',
bitrate: '64k',
channels: '2',
codec: 'aac'
}

View File

@@ -43,8 +43,8 @@
<script>
export default {
async asyncData({ store, app, params, redirect, query }) {
const author = await app.$axios.$get(`/api/authors/${params.id}?library=${query.library || store.state.libraries.currentLibraryId}&include=items,series`).catch((error) => {
async asyncData({ store, app, params, redirect }) {
const author = await app.$axios.$get(`/api/authors/${params.id}?library=${store.state.libraries.currentLibraryId}&include=items,series`).catch((error) => {
console.error('Failed to get author', error)
return null
})
@@ -53,10 +53,6 @@ export default {
return redirect(`/library/${store.state.libraries.currentLibraryId}/authors`)
}
if (query.library) {
store.commit('libraries/setCurrentLibrary', query.library)
}
return {
author
}

View File

@@ -26,8 +26,8 @@
</div>
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.authors" />
<!-- Authors filter only contains authors in this library, uses filter data -->
<ui-multi-select-query-input ref="authorsSelect" v-model="batchDetails.authors" :disabled="!selectedBatchUsage.authors" :label="$strings.LabelAuthors" filter-key="authors" class="mb-4 ml-4" />
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
<ui-multi-select-query-input ref="authorsSelect" v-model="batchDetails.authors" :disabled="!selectedBatchUsage.authors" :label="$strings.LabelAuthors" endpoint="authors/search" class="mb-4 ml-4" />
</div>
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.publishedYear" />

View File

@@ -145,7 +145,7 @@ export default {
feed: this.rssFeed
})
},
contextMenuAction({ action }) {
contextMenuAction(action) {
if (action === 'delete') {
this.removeClick()
} else if (action === 'create-playlist') {

View File

@@ -4,7 +4,7 @@
<div class="configContent" :class="`page-${currentPage}`">
<div v-show="isMobilePortrait" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2 cursor-pointer" @click.stop.prevent="toggleShowMore">
<span class="material-icons text-2xl cursor-pointer">arrow_forward</span>
<p class="pl-3 capitalize">{{ currentPage }}</p>
<p class="pl-3 capitalize">{{ $strings.HeaderSettings }}</p>
</div>
<nuxt-child />
</div>
@@ -55,8 +55,6 @@ export default {
else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats
else if (pageName === 'users') return this.$strings.HeaderUsers
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
else if (pageName === 'email') return this.$strings.HeaderEmail
}
return this.$strings.HeaderSettings
}
@@ -81,6 +79,14 @@ export default {
width: 900px;
max-width: calc(100% - 176px);
}
.configContent.page-library-stats {
width: 1200px;
}
@media (max-width: 1550px) {
.configContent.page-library-stats {
margin-left: 176px;
}
}
@media (max-width: 1240px) {
.configContent {
margin-left: 176px;
@@ -92,5 +98,8 @@ export default {
width: 100%;
max-width: 100%;
}
.configContent.page-library-stats {
margin-left: 0px;
}
}
</style>

View File

@@ -1,12 +1,6 @@
<template>
<div>
<app-settings-content :header-text="$strings.HeaderBackups" :description="$strings.MessageBackupsDescription">
<div v-if="backupLocation" class="flex items-center mb-4">
<span class="material-icons-outlined text-2xl text-black-50 mr-2">folder</span>
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelBackupLocation }}:</span>
<div class="text-gray-100 pl-4">{{ backupLocation }}</div>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="enableBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" />
<ui-tooltip :text="$strings.LabelBackupsEnableAutomaticBackupsHelp">
@@ -17,18 +11,14 @@
<div v-if="enableBackups" class="mb-6">
<div class="flex items-center pl-6 mb-2">
<span class="material-icons-outlined text-2xl text-black-50 mr-2">schedule</span>
<div class="w-40">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.HeaderSchedule }}:</span>
</div>
<div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.HeaderSchedule }}:</span></div>
<div class="text-gray-100">{{ scheduleDescription }}</div>
<span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer ml-2" @click="showCronBuilder = !showCronBuilder">edit</span>
</div>
<div v-if="nextBackupDate" class="flex items-center pl-6 py-0.5 px-2">
<span class="material-icons-outlined text-2xl text-black-50 mr-2">event</span>
<div class="w-40">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNextBackupDate }}:</span>
</div>
<div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNextBackupDate }}:</span></div>
<div class="text-gray-100">{{ nextBackupDate }}</div>
</div>
</div>
@@ -49,7 +39,7 @@
</ui-tooltip>
</div>
<tables-backups-table @loaded="backupsLoaded" />
<tables-backups-table />
<modals-backup-schedule-modal v-model="showCronBuilder" :cron-expression.sync="cronExpression" />
</app-settings-content>
@@ -58,11 +48,6 @@
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
}
},
data() {
return {
updatingServerSettings: false,
@@ -71,8 +56,7 @@ export default {
maxBackupSize: 1,
cronExpression: '',
newServerSettings: {},
showCronBuilder: false,
backupLocation: ''
showCronBuilder: false
}
},
watch: {
@@ -105,9 +89,6 @@ export default {
}
},
methods: {
backupsLoaded(backupLocation) {
this.backupLocation = backupLocation
},
updateBackupsSettings() {
if (isNaN(this.maxBackupSize) || this.maxBackupSize <= 0) {
this.$toast.error('Invalid maximum backup size')
@@ -117,7 +98,7 @@ export default {
this.$toast.error('Invalid number of backups to keep')
return
}
const updatePayload = {
var updatePayload = {
backupSchedule: this.enableBackups ? this.cronExpression : false,
backupsToKeep: Number(this.backupsToKeep),
maxBackupSize: Number(this.maxBackupSize)
@@ -127,15 +108,15 @@ export default {
updateServerSettings(payload) {
this.updatingServerSettings = true
this.$store
.dispatch('updateServerSettings', payload)
.then((success) => {
console.log('Updated Server Settings', success)
this.updatingServerSettings = false
})
.catch((error) => {
console.error('Failed to update server settings', error)
this.updatingServerSettings = false
})
.dispatch('updateServerSettings', payload)
.then((success) => {
console.log('Updated Server Settings', success)
this.updatingServerSettings = false
})
.catch((error) => {
console.error('Failed to update server settings', error)
this.updatingServerSettings = false
})
},
initServerSettings() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}

View File

@@ -1,260 +0,0 @@
<template>
<div>
<app-settings-content :header-text="$strings.HeaderEmailSettings" :description="''">
<form @submit.prevent="submitForm">
<div class="flex items-center -mx-1 mb-2">
<div class="w-full md:w-3/4 px-1">
<ui-text-input-with-label ref="hostInput" v-model="newSettings.host" :disabled="savingSettings" :label="$strings.LabelHost" />
</div>
<div class="w-full md:w-1/4 px-1">
<ui-text-input-with-label ref="portInput" v-model="newSettings.port" type="number" :disabled="savingSettings" :label="$strings.LabelPort" />
</div>
</div>
<div class="flex items-center mb-2 py-3">
<ui-toggle-switch labeledBy="email-settings-secure" v-model="newSettings.secure" :disabled="savingSettings" />
<ui-tooltip :text="$strings.LabelEmailSettingsSecureHelp">
<div class="pl-4 flex items-center">
<span id="email-settings-secure">{{ $strings.LabelEmailSettingsSecure }}</span>
<span class="material-icons text-lg pl-1">info_outlined</span>
</div>
</ui-tooltip>
</div>
<div class="flex items-center -mx-1 mb-2">
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="userInput" v-model="newSettings.user" :disabled="savingSettings" :label="$strings.LabelUsername" />
</div>
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="passInput" v-model="newSettings.pass" type="password" :disabled="savingSettings" :label="$strings.LabelPassword" />
</div>
</div>
<div class="flex items-center -mx-1 mb-2">
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="fromInput" v-model="newSettings.fromAddress" :disabled="savingSettings" :label="$strings.LabelEmailSettingsFromAddress" />
</div>
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="testInput" v-model="newSettings.testAddress" :disabled="savingSettings" :label="$strings.LabelEmailSettingsTestAddress" />
</div>
</div>
<div class="flex items-center justify-between pt-4">
<ui-btn v-if="hasUpdates" :disabled="savingSettings" type="button" @click="resetChanges">{{ $strings.ButtonReset }}</ui-btn>
<ui-btn v-else :loading="sendingTest" :disabled="savingSettings || !newSettings.host" type="button" @click="sendTestClick">{{ $strings.ButtonTest }}</ui-btn>
<ui-btn :loading="savingSettings" :disabled="!hasUpdates" type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div>
</form>
<div v-show="loading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-25 flex items-center justify-center">
<ui-loading-indicator />
</div>
</app-settings-content>
<app-settings-content :header-text="$strings.HeaderEreaderDevices" showAddButton :description="''" @clicked="addNewDeviceClick">
<table v-if="existingEReaderDevices.length" class="tracksTable my-4">
<tr>
<th class="text-left">{{ $strings.LabelName }}</th>
<th class="text-left">{{ $strings.LabelEmail }}</th>
<th class="w-40"></th>
</tr>
<tr v-for="device in existingEReaderDevices" :key="device.name">
<td>
<p class="text-sm md:text-base text-gray-100">{{ device.name }}</p>
</td>
<td class="text-left">
<p class="text-sm md:text-base text-gray-100">{{ device.email }}</p>
</td>
<td class="w-40">
<div class="flex justify-end items-center h-10">
<ui-icon-btn icon="edit" borderless :size="8" icon-font-size="1.1rem" :disabled="deletingDeviceName === device.name" class="mx-1" @click="editDeviceClick(device)" />
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" :disabled="deletingDeviceName === device.name" @click="deleteDeviceClick(device)" />
</div>
</td>
</tr>
</table>
<div v-else class="text-center py-4">
<p class="text-lg text-gray-100">No Devices</p>
</div>
</app-settings-content>
<modals-emails-e-reader-device-modal v-model="showEReaderDeviceModal" :existing-devices="existingEReaderDevices" :ereader-device="selectedEReaderDevice" @update="ereaderDevicesUpdated" />
</div>
</template>
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
}
},
data() {
return {
loading: false,
savingSettings: false,
sendingTest: false,
deletingDeviceName: null,
settings: null,
newSettings: {
host: null,
port: 465,
secure: true,
user: null,
pass: null,
testAddress: null,
fromAddress: null
},
newEReaderDevice: {
name: '',
email: ''
},
selectedEReaderDevice: null,
showEReaderDeviceModal: false
}
},
computed: {
hasUpdates() {
if (!this.settings) return true
for (const key in this.newSettings) {
if (key === 'ereaderDevices') continue
if (this.newSettings[key] !== this.settings[key]) return true
}
return false
},
existingEReaderDevices() {
return this.settings?.ereaderDevices || []
}
},
methods: {
resetChanges() {
this.newSettings = {
...this.settings
}
},
editDeviceClick(device) {
this.selectedEReaderDevice = device
this.showEReaderDeviceModal = true
},
deleteDeviceClick(device) {
const payload = {
message: `Are you sure you want to delete e-reader device "${device.name}"?`,
callback: (confirmed) => {
if (confirmed) {
this.deleteDevice(device)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
deleteDevice(device) {
const payload = {
ereaderDevices: this.existingEReaderDevices.filter((d) => d.name !== device.name)
}
this.deletingDeviceName = device.name
this.$axios
.$post(`/api/emails/ereader-devices`, payload)
.then((data) => {
this.ereaderDevicesUpdated(data.ereaderDevices)
this.$toast.success('Device deleted')
})
.catch((error) => {
console.error('Failed to delete device', error)
this.$toast.error('Failed to delete device')
})
.finally(() => {
this.deletingDeviceName = null
})
},
ereaderDevicesUpdated(ereaderDevices) {
this.settings.ereaderDevices = ereaderDevices
this.newSettings.ereaderDevices = ereaderDevices.map((d) => ({ ...d }))
},
addNewDeviceClick() {
this.selectedEReaderDevice = null
this.showEReaderDeviceModal = true
},
sendTestClick() {
this.sendingTest = true
this.$axios
.$post('/api/emails/test')
.then(() => {
this.$toast.success('Test Email Sent')
})
.catch((error) => {
console.error('Failed to send test email', error)
const errorMsg = error.response.data || 'Failed to send test email'
this.$toast.error(errorMsg)
})
.finally(() => {
this.sendingTest = false
})
},
validateForm() {
for (const ref of [this.$refs.hostInput, this.$refs.portInput, this.$refs.userInput, this.$refs.passInput, this.$refs.fromInput]) {
if (ref?.blur) ref.blur()
}
if (this.newSettings.port) {
this.newSettings.port = Number(this.newSettings.port)
}
return true
},
submitForm() {
if (!this.validateForm()) return
const updatePayload = {
host: this.newSettings.host,
port: this.newSettings.port,
secure: this.newSettings.secure,
user: this.newSettings.user,
pass: this.newSettings.pass,
testAddress: this.newSettings.testAddress,
fromAddress: this.newSettings.fromAddress
}
this.savingSettings = true
this.$axios
.$patch('/api/emails/settings', updatePayload)
.then((data) => {
this.settings = data.settings
this.newSettings = {
...data.settings
}
this.$toast.success('Email settings updated')
})
.catch((error) => {
console.error('Failed to update email settings', error)
this.$toast.error('Failed to update email settings')
})
.finally(() => {
this.savingSettings = false
})
},
init() {
this.loading = true
this.$axios
.$get(`/api/emails/settings`)
.then((data) => {
this.settings = data.settings
this.newSettings = {
...this.settings
}
})
.catch((error) => {
console.error('Failed to get email settings', error)
this.$toast.error('Failed to load email settings')
})
.finally(() => {
this.loading = false
})
}
},
mounted() {
this.init()
},
beforeDestroy() {}
}
</script>

View File

@@ -36,21 +36,14 @@
</ui-tooltip>
</div>
<div v-if="newServerSettings.sortingIgnorePrefix" class="w-72 ml-14 mb-2">
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" :label="$strings.LabelPrefixesToIgnore" @input="sortingPrefixesUpdated" :disabled="savingPrefixes" />
<div class="flex justify-end py-1">
<ui-btn v-if="hasPrefixesChanged" color="success" :loading="savingPrefixes" small @click="updateSortingPrefixes">Save</ui-btn>
</div>
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" :label="$strings.LabelPrefixesToIgnore" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
</div>
<div class="flex items-center py-2 mb-2">
<div class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
</div>
<div class="w-44 mb-2">
<ui-dropdown v-model="newServerSettings.metadataFileFormat" small :items="metadataFileFormats" label="Metadata File Format" @input="updateMetadataFileFormat" :disabled="updatingServerSettings" />
</div>
<div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsDisplay }}</h2>
</div>
@@ -160,17 +153,16 @@
</div>
<div class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-disable-watcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
<ui-tooltip :text="$strings.LabelSettingsEnableWatcherHelp">
<ui-toggle-switch labeledBy="settings-disable-watcher" v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
<ui-tooltip :text="$strings.LabelSettingsDisableWatcherHelp">
<p class="pl-4">
<span id="settings-disable-watcher">{{ $strings.LabelSettingsEnableWatcher }}</span>
<span id="settings-disable-watcher">{{ $strings.LabelSettingsDisableWatcher }}</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<!-- old experimental features -->
<!-- <div class="pt-4">
<div class="pt-4">
<h2 class="font-semibold">{{ $strings.HeaderSettingsExperimental }}</h2>
</div>
@@ -184,6 +176,26 @@
</a>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch labeledBy="settings-enable-e-reader" v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
<ui-tooltip :text="$strings.LabelSettingsEnableEReaderHelp">
<p class="pl-4">
<span id="settings-enable-e-reader">{{ $strings.LabelSettingsEnableEReader }}</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<!-- <div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerUseTone" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerUseTone', val)" />
<ui-tooltip text="Tone library for metadata">
<p class="pl-4">
Use Tone library for metadata
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div> -->
</div>
</div>
@@ -195,6 +207,7 @@
<div class="flex-grow" />
<ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isPurgingCache" @click.stop="purgeCache">{{ $strings.ButtonPurgeAllCache }}</ui-btn>
<ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isPurgingCache" @click.stop="purgeItemsCache">{{ $strings.ButtonPurgeItemsCache }}</ui-btn>
<ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isResettingLibraryItems" @click="resetLibraryItems">{{ $strings.ButtonRemoveAllLibraryItems }}</ui-btn>
</div>
<div class="flex items-center py-4">
@@ -251,33 +264,15 @@
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
}
},
data() {
return {
isResettingLibraryItems: false,
updatingServerSettings: false,
homepageUseBookshelfView: false,
useBookshelfView: false,
scannerEnableWatcher: false,
isPurgingCache: false,
hasPrefixesChanged: false,
newServerSettings: {},
showConfirmPurgeCache: false,
savingPrefixes: false,
metadataFileFormats: [
{
text: '.json',
value: 'json'
},
{
text: '.abs',
value: 'abs'
}
]
showConfirmPurgeCache: false
}
},
watch: {
@@ -294,6 +289,14 @@ export default {
providers() {
return this.$store.state.scanners.providers
},
showExperimentalFeatures: {
get() {
return this.$store.state.showExperimentalFeatures
},
set(val) {
this.$store.commit('setExperimentalFeatures', val)
}
},
dateFormats() {
return this.$store.state.globals.dateFormats
},
@@ -310,36 +313,15 @@ export default {
}
},
methods: {
sortingPrefixesUpdated(val) {
const prefixes = [...new Set(val?.map((prefix) => prefix.trim().toLowerCase()) || [])]
this.newServerSettings.sortingPrefixes = prefixes
const serverPrefixes = this.serverSettings.sortingPrefixes || []
this.hasPrefixesChanged = prefixes.some((p) => !serverPrefixes.includes(p)) || serverPrefixes.some((p) => !prefixes.includes(p))
},
updateSortingPrefixes() {
const prefixes = [...new Set(this.newServerSettings.sortingPrefixes.map((prefix) => prefix.trim().toLowerCase()) || [])]
if (!prefixes.length) {
updateSortingPrefixes(val) {
if (!val || !val.length) {
this.$toast.error('Must have at least 1 prefix')
return
}
this.savingPrefixes = true
this.$axios
.$patch(`/api/sorting-prefixes`, { sortingPrefixes: prefixes })
.then((data) => {
this.$toast.success(`Sorting prefixes updated. ${data.rowsUpdated} rows`)
if (data.serverSettings) {
this.$store.commit('setServerSettings', data.serverSettings)
}
this.hasPrefixesChanged = false
})
.catch((error) => {
console.error('Failed to update prefixes', error)
this.$toast.error('Failed to update sorting prefixes')
})
.finally(() => {
this.savingPrefixes = false
})
var prefixes = val.map((prefix) => prefix.trim().toLowerCase())
this.updateServerSettings({
sortingPrefixes: prefixes
})
},
updateScannerCoverProvider(val) {
this.updateServerSettings({
@@ -359,14 +341,7 @@ export default {
updateServerLanguage(val) {
this.updateSettingsKey('language', val)
},
updateMetadataFileFormat(val) {
if (this.serverSettings.metadataFileFormat === val) return
this.updateSettingsKey('metadataFileFormat', val)
},
updateSettingsKey(key, val) {
if (key === 'scannerDisableWatcher') {
this.newServerSettings.scannerDisableWatcher = val
}
this.updateServerSettings({
[key]: val
})
@@ -375,7 +350,8 @@ export default {
this.updatingServerSettings = true
this.$store
.dispatch('updateServerSettings', payload)
.then(() => {
.then((success) => {
console.log('Updated Server Settings', success)
this.updatingServerSettings = false
this.$toast.success('Server settings updated')
@@ -393,11 +369,27 @@ export default {
initServerSettings() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
this.newServerSettings.sortingPrefixes = [...(this.newServerSettings.sortingPrefixes || [])]
this.scannerEnableWatcher = !this.newServerSettings.scannerDisableWatcher
this.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL
this.useBookshelfView = this.newServerSettings.bookshelfView != this.$constants.BookshelfView.DETAIL
},
resetLibraryItems() {
if (confirm(this.$strings.MessageRemoveAllItemsWarning)) {
this.isResettingLibraryItems = true
this.$axios
.$delete('/api/items/all')
.then(() => {
this.isResettingLibraryItems = false
this.$toast.success('Successfully reset items')
location.reload()
})
.catch((error) => {
console.error('failed to reset items', error)
this.isResettingLibraryItems = false
this.$toast.error('Failed to reset items - manually remove the /config/libraryItems folder')
})
}
},
purgeCache() {
this.showConfirmPurgeCache = true
},

View File

@@ -38,11 +38,6 @@
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
}
},
data() {
return {
loading: false,

View File

@@ -19,11 +19,6 @@
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
}
},
data() {
return {}
},

View File

@@ -17,8 +17,8 @@
<ui-text-input v-else v-model="newTagName" />
<div class="flex-grow" />
<template v-if="editingTag !== tag">
<ui-icon-btn icon="edit" borderless :size="8" icon-font-size="1.1rem" class="mx-1" @click="editTagClick(tag)" />
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeTagClick(tag)" />
<ui-icon-btn v-if="editingTag !== tag" icon="edit" borderless :size="8" icon-font-size="1.1rem" class="mx-1" @click="editTagClick(tag)" />
<ui-icon-btn v-if="editingTag !== tag" icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeTagClick(tag)" />
</template>
<template v-else>
<ui-btn color="success" small class="mx-2" @click.stop="saveTagClick">{{ $strings.ButtonSave }}</ui-btn>
@@ -38,11 +38,6 @@
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
}
},
data() {
return {
loading: false,

View File

@@ -9,11 +9,6 @@
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
}
},
data() {
return {
showLibraryModal: false,

View File

@@ -22,7 +22,7 @@
</div>
</template>
</div>
<div v-if="isBookLibrary" class="w-80 my-6 mx-auto">
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTop10Authors }}</h1>
<p v-if="!top10Authors.length">{{ $strings.MessageNoAuthors }}</p>
<template v-for="(author, index) in top10Authors">
@@ -87,11 +87,6 @@
<script>
export default {
asyncData({ redirect, store }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
return
}
if (!store.state.libraries.currentLibraryId) {
return redirect('/config')
}
@@ -114,49 +109,43 @@ export default {
return this.$store.state.user.user
},
totalItems() {
return this.libraryStats?.totalItems || 0
return this.libraryStats ? this.libraryStats.totalItems : 0
},
genresWithCount() {
return this.libraryStats?.genresWithCount || []
return this.libraryStats ? this.libraryStats.genresWithCount : []
},
top5Genres() {
return this.genresWithCount?.slice(0, 5) || []
return this.genresWithCount.slice(0, 5)
},
top10LongestItems() {
return this.libraryStats?.longestItems || []
return this.libraryStats ? this.libraryStats.longestItems || [] : []
},
longestItemDuration() {
if (!this.top10LongestItems.length) return 0
return this.top10LongestItems[0].duration
},
top10LargestItems() {
return this.libraryStats?.largestItems || []
return this.libraryStats ? this.libraryStats.largestItems || [] : []
},
largestItemSize() {
if (!this.top10LargestItems.length) return 0
return this.top10LargestItems[0].size
},
authorsWithCount() {
return this.libraryStats?.authorsWithCount || []
return this.libraryStats ? this.libraryStats.authorsWithCount : []
},
mostUsedAuthorCount() {
if (!this.authorsWithCount.length) return 0
return this.authorsWithCount[0].count
},
top10Authors() {
return this.authorsWithCount?.slice(0, 10) || []
return this.authorsWithCount.slice(0, 10)
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
currentLibraryName() {
return this.$store.getters['libraries/getCurrentLibraryName']
},
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isBookLibrary() {
return this.currentLibraryMediaType === 'book'
}
},
methods: {

View File

@@ -28,11 +28,6 @@
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
}
},
data() {
return {
search: null,

View File

@@ -46,11 +46,6 @@
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
}
},
data() {
return {
loading: false,

View File

@@ -1,176 +0,0 @@
<template>
<div>
<app-settings-content :header-text="$strings.HeaderRSSFeeds">
<div v-if="feeds.length" class="block max-w-full">
<table class="rssFeedsTable text-xs">
<tr class="bg-primary bg-opacity-40 h-12">
<th class="w-16 min-w-16"></th>
<th class="w-48 max-w-64 min-w-24 text-left truncate">{{ $strings.LabelTitle }}</th>
<th class="w-48 min-w-24 text-left hidden xl:table-cell">{{ $strings.LabelSlug }}</th>
<th class="w-24 min-w-16 text-left hidden md:table-cell">{{ $strings.LabelType }}</th>
<th class="w-16 min-w-16 text-center">{{ $strings.HeaderEpisodes }}</th>
<th class="w-16 min-w-16 text-center hidden lg:table-cell">{{ $strings.LabelRSSFeedPreventIndexing }}</th>
<th class="w-48 min-w-24 flex-grow hidden md:table-cell">{{ $strings.LabelLastUpdate }}</th>
<th class="w-16 text-left"></th>
</tr>
<tr v-for="feed in feeds" :key="feed.id" class="cursor-pointer h-12" @click="showFeed(feed)">
<!-- -->
<td>
<img :src="coverUrl(feed)" class="h-full w-full" />
</td>
<!-- -->
<td class="w-48 max-w-64 min-w-24 text-left truncate">
<p class="truncate">{{ feed.meta.title }}</p>
</td>
<!-- -->
<td class="hidden xl:table-cell">
<p class="truncate">{{ feed.slug }}</p>
</td>
<!-- -->
<td class="hidden md:table-cell">
<p class="">{{ getEntityType(feed.entityType) }}</p>
</td>
<!-- -->
<td class="text-center">
<p class="">{{ feed.episodes.length }}</p>
</td>
<!-- -->
<td class="text-center leading-none hidden lg:table-cell">
<p v-if="feed.meta.preventIndexing" class="">
<span class="material-icons text-2xl">check</span>
</p>
</td>
<!-- -->
<td class="text-center hidden md:table-cell">
<ui-tooltip v-if="feed.updatedAt" direction="top" :text="$formatDatetime(feed.updatedAt, dateFormat, timeFormat)">
<p class="text-gray-200">{{ $dateDistanceFromNow(feed.updatedAt) }}</p>
</ui-tooltip>
</td>
<!-- -->
<td class="text-center">
<ui-icon-btn icon="delete" class="mx-0.5" :size="7" bg-color="error" outlined @click.stop="deleteFeedClick(feed)" />
</td>
</tr>
</table>
</div>
</app-settings-content>
<modals-rssfeed-view-feed-modal v-model="showFeedModal" :feed="selectedFeed" />
</div>
</template>
<script>
export default {
data() {
return {
showFeedModal: false,
selectedFeed: null,
feeds: []
}
},
computed: {
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
}
},
methods: {
showFeed(feed) {
this.selectedFeed = feed
this.showFeedModal = true
},
deleteFeedClick(feed) {
const payload = {
message: this.$strings.MessageConfirmCloseFeed,
callback: (confirmed) => {
if (confirmed) {
this.deleteFeed(feed)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
deleteFeed(feed) {
this.processing = true
this.$axios
.$post(`/api/feeds/${feed.id}/close`)
.then(() => {
this.$toast.success(this.$strings.ToastRSSFeedCloseSuccess)
this.show = false
this.loadFeeds()
})
.catch((error) => {
console.error('Failed to close RSS feed', error)
this.$toast.error(this.$strings.ToastRSSFeedCloseFailed)
})
.finally(() => {
this.processing = false
})
},
getEntityType(entityType) {
if (entityType === 'libraryItem') return this.$strings.LabelItem
else if (entityType === 'series') return this.$strings.LabelSeries
else if (entityType === 'collection') return this.$strings.LabelCollection
return this.$strings.LabelUnknown
},
coverUrl(feed) {
if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png`
return `${feed.feedUrl}/cover`
},
async loadFeeds() {
const data = await this.$axios.$get(`/api/feeds`).catch((err) => {
console.error('Failed to load RSS feeds', err)
return null
})
if (!data) {
this.$toast.error('Failed to load RSS feeds')
return
}
this.feeds = data.feeds
},
init() {
this.loadFeeds()
}
},
mounted() {
this.init()
}
}
</script>
<style scoped>
.rssFeedsTable {
border-collapse: collapse;
width: 100%;
max-width: 100%;
border: 1px solid #474747;
}
.rssFeedsTable tr:first-child {
background-color: #272727;
}
.rssFeedsTable tr:not(:first-child) {
background-color: #373838;
}
.rssFeedsTable tr:not(:first-child):nth-child(odd) {
background-color: #2f2f2f;
}
.rssFeedsTable tr:hover:not(:first-child) {
background-color: #474747;
}
.rssFeedsTable td {
padding: 4px 8px;
}
.rssFeedsTable th {
padding: 4px 8px;
font-size: 0.75rem;
}
</style>

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