Merge remote-tracking branch 'remotes/upstream/master'

# Conflicts:
#	client/pages/item/_id/index.vue
This commit is contained in:
Toni Barth 2024-09-06 21:51:08 +02:00
commit 6643b371cc
212 changed files with 5454 additions and 8175 deletions

55
.github/workflows/apply_comments.yaml vendored Normal file
View file

@ -0,0 +1,55 @@
name: Add issue comments by label
on:
issues:
types:
- labeled
jobs:
help-wanted:
if: github.event.label.name == 'help wanted'
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Help wanted comment
run: gh issue comment "$NUMBER" --body "$BODY"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
NUMBER: ${{ github.event.issue.number }}
BODY: >
This issue is not able to be completed due to limited bandwidth or access to the required test hardware.
This issue is available for anyone to work on.
config-issue:
if: github.event.label.name == 'config-issue'
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Config issue comment
run: gh issue close "$NUMBER" --reason "not planned" --comment "$BODY"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
NUMBER: ${{ github.event.issue.number }}
BODY: >
After reviewing this issue, this appears to be a problem with your setup and not Audiobookshelf. This issue is being closed to keep the issue tracker focused on Audiobookshelf itself. Please reach out on the Audiobookshelf Discord for community support.
Some common search terms to help you find the solution to your problem:
- Reverse proxy
- Enabling websockets
- SSL (https vs http)
- Configuring a static IP
- `localhost` versus IP address
- hairpin NAT
- VPN
- firewall ports
- public versus private network
- bridge versus host mode
- Docker networking
- DNS (such as EAI_AGAIN errors)
After you have followed these steps, please post the solution or steps you followed to fix the problem to help others in the future, or show that it is a problem with Audiobookshelf so we can reopen the issue.

View file

@ -1,13 +1,15 @@
name: API linting
# Run on pull requests or pushes when there is a change to the OpenAPI file
# Run on pull requests or pushes when there is a change to any OpenAPI files in docs/
on:
pull_request:
push:
paths:
- docs/
pull_request:
paths:
- docs/
- 'docs/**'
# This action only needs read permissions
permissions:
contents: read
jobs:
build:

View file

@ -2,14 +2,7 @@
font-family: 'Material Symbols Rounded';
font-style: normal;
font-weight: 400;
src: url(~static/fonts/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].woff2) format('woff2');
}
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 400;
src: url(~static/fonts/MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].woff2) format('woff2');
src: url(~static/fonts/MaterialSymbolsRounded.woff2) format('woff2');
}
.material-symbols {
@ -32,26 +25,6 @@
'FILL' 1
}
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
vertical-align: top;
}
.material-symbols-outlined.fill {
font-variation-settings:
'FILL' 1
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';

View file

@ -16,7 +16,7 @@
<div class="flex-grow" />
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
<span class="material-symbols-outlined text-2xl text-warning text-opacity-50"> cast </span>
<span class="material-symbols text-2xl text-warning text-opacity-50"> cast </span>
</ui-tooltip>
<div v-if="isChromecastInitialized" class="w-6 min-w-6 h-6 ml-2 mr-1 sm:mx-2 cursor-pointer">
<google-cast-launcher></google-cast-launcher>
@ -26,19 +26,19 @@
<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-symbols text-2xl" aria-label="User Stats" role="button">equalizer</span>
<span class="material-symbols text-2xl" aria-label="User Stats" role="button">&#xe01d;</span>
</ui-tooltip>
</nuxt-link>
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<ui-tooltip :text="$strings.ButtonUpload" direction="bottom" class="flex items-center">
<span class="material-symbols text-2xl" aria-label="Upload Media" role="button">upload</span>
<span class="material-symbols text-2xl" aria-label="Upload Media" role="button">&#xf09b;</span>
</ui-tooltip>
</nuxt-link>
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<ui-tooltip :text="$strings.HeaderSettings" direction="bottom" class="flex items-center">
<span class="material-symbols text-2xl" aria-label="System Settings" role="button">settings</span>
<span class="material-symbols text-2xl" aria-label="System Settings" role="button">&#xe8b8;</span>
</ui-tooltip>
</nuxt-link>
@ -47,7 +47,7 @@
<span class="block truncate">{{ username }}</span>
</span>
<span class="h-full md:ml-3 md:absolute inset-y-0 md:right-0 flex items-center justify-center md:pr-2 pointer-events-none">
<span class="material-symbols text-xl text-gray-100">person</span>
<span class="material-symbols text-xl text-gray-100">&#xe7fd;</span>
</span>
</nuxt-link>
</div>
@ -264,7 +264,6 @@ export default {
libraryItems.forEach((item) => {
let subtitle = ''
if (item.mediaType === 'book') subtitle = item.media.metadata.authors.map((au) => au.name).join(', ')
else if (item.mediaType === 'music') subtitle = item.media.metadata.artists.join(', ')
queueItems.push({
libraryItemId: item.id,
libraryId: item.libraryId,
@ -332,13 +331,13 @@ export default {
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
})
.then(() => {
this.$toast.success('Batch delete success')
this.$toast.success(this.$strings.ToastBatchDeleteSuccess)
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
})
.catch((error) => {
console.error('Batch delete failed', error)
this.$toast.error('Batch delete failed')
this.$toast.error(this.$strings.ToastBatchDeleteFailed)
})
.finally(() => {
this.$store.commit('setProcessingBatch', false)

View file

@ -24,11 +24,11 @@
</nuxt-link>
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="flex-grow h-full flex justify-center items-center" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p v-if="isPlaylistsPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonPlaylists }}</p>
<span v-else class="material-symbols-outlined text-lg">queue_music</span>
<span v-else class="material-symbols text-lg">&#xe03d;</span>
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="isCollectionsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p v-if="isCollectionsPage" class="text-sm">{{ $strings.ButtonCollections }}</p>
<span v-else class="material-symbols-outlined text-lg">collections_bookmark</span>
<span v-else class="material-symbols text-lg">&#xe431;</span>
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="flex-grow h-full flex justify-center items-center" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p v-if="isAuthorsPage" class="text-sm">{{ $strings.ButtonAuthors }}</p>
@ -159,6 +159,7 @@ export default {
}
this.addSubtitlesMenuItem(items)
this.addCollapseSubSeriesMenuItem(items)
return items
},
@ -245,9 +246,6 @@ export default {
isPodcastLibrary() {
return this.currentLibraryMediaType === 'podcast'
},
isMusicLibrary() {
return this.currentLibraryMediaType === 'music'
},
isLibraryPage() {
return this.page === ''
},
@ -280,7 +278,6 @@ export default {
},
entityName() {
if (this.isAlbumsPage) return 'Albums'
if (this.isMusicLibrary) return 'Tracks'
if (this.isPodcastLibrary) return this.$strings.LabelPodcasts
if (!this.page) return this.$strings.LabelBooks
@ -371,6 +368,21 @@ export default {
}
}
},
addCollapseSubSeriesMenuItem(items) {
if (this.selectedSeries && this.isBookLibrary && !this.isBatchSelecting) {
if (this.settings.collapseBookSeries) {
items.push({
text: this.$strings.LabelExpandSubSeries,
action: 'expand-sub-series'
})
} else {
items.push({
text: this.$strings.LabelCollapseSubSeries,
action: 'collapse-sub-series'
})
}
}
},
handleSubtitlesAction(action) {
if (action === 'show-subtitles') {
this.settings.showSubtitles = true
@ -397,6 +409,19 @@ export default {
}
return false
},
handleCollapseSubSeriesAction(action) {
if (action === 'collapse-sub-series') {
this.settings.collapseBookSeries = true
this.updateCollapseSubSeries()
return true
}
if (action === 'expand-sub-series') {
this.settings.collapseBookSeries = false
this.updateCollapseSubSeries()
return true
}
return false
},
contextMenuAction({ action }) {
if (action === 'export-opml') {
this.exportOPML()
@ -427,6 +452,8 @@ export default {
this.markSeriesFinished()
} else if (this.handleSubtitlesAction(action)) {
return
} else if (this.handleCollapseSubSeriesAction(action)) {
return
}
},
showOpenSeriesRSSFeed() {
@ -442,11 +469,11 @@ export default {
this.$axios
.$get(`/api/me/series/${this.seriesId}/readd-to-continue-listening`)
.then(() => {
this.$toast.success('Series re-added to continue listening')
this.$toast.success(this.$strings.ToastItemUpdateSuccess)
})
.catch((error) => {
console.error('Failed to re-add series to continue listening', error)
this.$toast.error('Failed to re-add series to continue listening')
this.$toast.error(this.$strings.ToastItemUpdateFailed)
})
.finally(() => {
this.processingSeries = false
@ -473,7 +500,7 @@ export default {
})
if (!response) {
console.error(`Author ${author.name} not found`)
this.$toast.error(`Author ${author.name} not found`)
this.$toast.error(this.$getString('ToastAuthorNotFound', [author.name]))
} else if (response.updated) {
if (response.author.imagePath) console.log(`Author ${response.author.name} was updated`)
else console.log(`Author ${response.author.name} was updated (no image found)`)
@ -491,13 +518,13 @@ export default {
this.$axios
.$delete(`/api/libraries/${this.currentLibraryId}/issues`)
.then(() => {
this.$toast.success('Removed library items with issues')
this.$toast.success(this.$strings.ToastRemoveItemsWithIssuesSuccess)
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
})
.catch((error) => {
console.error('Failed to remove library items with issues', error)
this.$toast.error('Failed to remove library items with issues')
this.$toast.error(this.$strings.ToastRemoveItemsWithIssuesFailed)
})
.finally(() => {
this.processingIssues = false
@ -553,7 +580,7 @@ export default {
updateCollapseSeries() {
this.saveSettings()
},
updateCollapseBookSeries() {
updateCollapseSubSeries() {
this.saveSettings()
},
updateShowSubtitles() {

View file

@ -1,10 +1,9 @@
<template>
<div v-if="streamLibraryItem" id="mediaPlayerContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 lg:h-40 z-50 bg-primary px-2 lg:px-4 pb-1 lg:pb-4 pt-2">
<div id="videoDock" />
<div class="absolute left-2 top-2 lg:left-4 cursor-pointer">
<covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
</div>
<div class="flex items-start mb-6 lg:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
<div class="flex items-start mb-6 lg:mb-0" :class="isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
<div class="min-w-0 w-full">
<div class="flex items-center">
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
@ -12,10 +11,9 @@
</nuxt-link>
<widgets-explicit-indicator v-if="isExplicit" />
</div>
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center w-1/2 sm:w-4/5 lg:w-2/5">
<div class="text-gray-400 flex items-center w-1/2 sm:w-4/5 lg:w-2/5">
<span class="material-symbols text-sm">person</span>
<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 truncate">
<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>
@ -43,12 +41,14 @@
:sleep-timer-remaining="sleepTimerRemaining"
:sleep-timer-type="sleepTimerType"
:is-podcast="isPodcast"
:hasNextItemInQueue="hasNextItemInQueue"
@playPause="playPause"
@jumpForward="jumpForward"
@jumpBackward="jumpBackward"
@setVolume="setVolume"
@setPlaybackRate="setPlaybackRate"
@seek="seek"
@nextItemInQueue="playNextItemInQueue"
@close="closePlayer"
@showBookmarks="showBookmarks"
@showSleepTimer="showSleepTimerModal = true"
@ -60,7 +60,7 @@
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" :library-item-id="libraryItemId" />
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" />
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
</div>
@ -138,9 +138,6 @@ export default {
isPodcast() {
return this.streamLibraryItem?.mediaType === 'podcast'
},
isMusic() {
return this.streamLibraryItem?.mediaType === 'music'
},
isExplicit() {
return !!this.mediaMetadata.explicit
},
@ -172,9 +169,15 @@ export default {
if (!this.isPodcast) return null
return this.mediaMetadata.author || 'Unknown'
},
musicArtists() {
if (!this.isMusic) return null
return this.mediaMetadata.artists.join(', ')
hasNextItemInQueue() {
return this.currentPlayerQueueIndex < this.playerQueueItems.length - 1
},
currentPlayerQueueIndex() {
if (!this.libraryItemId) return -1
return this.playerQueueItems.findIndex((i) => {
if (this.streamEpisode?.id) return i.episodeId === this.streamEpisode.id
return i.libraryItemId === this.libraryItemId
})
},
playerQueueItems() {
return this.$store.state.playerQueueItems || []
@ -460,6 +463,30 @@ export default {
this.playerHandler.switchPlayer()
}
},
playNextItemInQueue() {
if (this.hasNextItemInQueue) {
this.playQueueItem({ index: this.currentPlayerQueueIndex + 1 })
}
},
/**
* @param {{ index: number }} payload
*/
playQueueItem(payload) {
if (payload?.index === undefined) {
console.error('playQueueItem: No index provided')
return
}
if (!this.playerQueueItems[payload.index]) {
console.error('playQueueItem: No item found at index', payload.index)
return
}
const item = this.playerQueueItems[payload.index]
this.playLibraryItem({
libraryItemId: item.libraryItemId,
episodeId: item.episodeId || null,
queueItems: this.playerQueueItems
})
},
async playLibraryItem(payload) {
const libraryItemId = payload.libraryItemId
const episodeId = payload.episodeId || null
@ -512,6 +539,7 @@ export default {
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-queue-item', this.playQueueItem)
this.$eventBus.$on('play-item', this.playLibraryItem)
this.$eventBus.$on('pause-item', this.pauseItem)
},
@ -519,6 +547,7 @@ export default {
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-queue-item', this.playQueueItem)
this.$eventBus.$off('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem)
}

View file

@ -15,7 +15,7 @@
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-symbols text-2xl">format_list_bulleted</span>
<span class="material-symbols text-2xl">&#xe241;</span>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p>
@ -43,7 +43,7 @@
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-symbols-outlined text-2xl">collections_bookmark</span>
<span class="material-symbols text-2xl">&#xe431;</span>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
@ -51,7 +51,7 @@
</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-symbols text-2.5xl">queue_music</span>
<span class="material-symbols text-2.5xl">&#xe03d;</span>
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
@ -72,7 +72,7 @@
</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-symbols text-2xl">record_voice_over</span>
<span class="material-symbols text-2xl">&#xe91f;</span>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p>
@ -80,7 +80,7 @@
</nuxt-link>
<nuxt-link v-if="isBookLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/stats`" 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="isStatsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-symbols text-2xl">monitoring</span>
<span class="material-symbols text-2xl">&#xf190;</span>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonStats }}</p>
@ -95,16 +95,8 @@
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isMusicLibrary" :to="`/library/${currentLibraryId}/bookshelf/albums`" 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="isMusicAlbumsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-symbols-outlined text-xl">album</span>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p>
<div v-show="isMusicAlbumsPage" 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-symbols text-2xl">file_download</span>
<span class="material-symbols text-2xl">&#xf090;</span>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonDownloadQueue }}</p>
@ -172,9 +164,6 @@ export default {
isPodcastLibrary() {
return this.currentLibraryMediaType === 'podcast'
},
isMusicLibrary() {
return this.currentLibraryMediaType === 'music'
},
isPodcastDownloadQueuePage() {
return this.$route.name === 'library-library-podcast-download-queue'
},
@ -184,9 +173,6 @@ export default {
isPodcastLatestPage() {
return this.$route.name === 'library-library-podcast-latest'
},
isMusicAlbumsPage() {
return this.paramId === 'albums'
},
homePage() {
return this.$route.name === 'library-library'
},

View file

@ -201,23 +201,6 @@ export default {
// This method returns immediately without waiting for the DOM to update
return this.coverWidth
},
/*
cardHeight() {
// This method returns immediately without waiting for the DOM to update
return this.coverHeight + this.detailsHeight
},
detailsHeight() {
if (!this.isAlternativeBookshelfView) return 0
const lineHeight = 1.5
const remSize = 16
const baseHeight = this.sizeMultiplier * lineHeight * remSize
const titleHeight = 0.9 * baseHeight
const line2Height = 0.8 * baseHeight
const line3Height = this.displaySortLine ? 0.8 * baseHeight : 0
const marginHeight = 8 * 2 * this.sizeMultiplier // py-2
return titleHeight + line2Height + line3Height + marginHeight
},
*/
sizeMultiplier() {
return this.store.getters['user/getSizeMultiplier']
},
@ -243,9 +226,6 @@ export default {
isPodcast() {
return this.mediaType === 'podcast' || this.store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
},
isMusic() {
return this.mediaType === 'music'
},
isExplicit() {
return this.mediaMetadata.explicit || false
},
@ -353,7 +333,6 @@ export default {
displayLineTwo() {
if (this.recentEpisode) return this.title
if (this.isPodcast) return this.author
if (this.isMusic) return this.artist
if (this.collapsedSeries) return ''
if (this.isAuthorBookshelfView) {
return this.mediaMetadata.publishedYear || ''
@ -363,14 +342,14 @@ export default {
},
displaySortLine() {
if (this.collapsedSeries) return null
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt, this.dateFormat)
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 === 'mtimeMs') return this.$getString('LabelFileModifiedDate', [this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat)])
if (this.orderBy === 'birthtimeMs') return this.$getString('LabelFileBornDate', [this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat)])
if (this.orderBy === 'addedAt') return this.$getString('LabelAddedDate', [this.$formatDate(this._libraryItem.addedAt, this.dateFormat)])
if (this.orderBy === 'media.duration') return this.$strings.LabelDuration + ': ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return this.$strings.LabelSize + ': ' + this.$bytesPretty(this._libraryItem.size)
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} ` + this.$strings.LabelEpisodes
if (this.orderBy === 'media.metadata.publishedYear') {
if (this.mediaMetadata.publishedYear) return 'Published ' + this.mediaMetadata.publishedYear
if (this.mediaMetadata.publishedYear) return this.$getString('LabelPublishedDate', [this.mediaMetadata.publishedYear])
return '\u00A0'
}
return null
@ -381,7 +360,6 @@ export default {
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
},
userProgress() {
if (this.isMusic) return null
if (this.episodeProgress) return this.episodeProgress
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
@ -437,7 +415,7 @@ export default {
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.ebookFormat
@ -481,8 +459,6 @@ export default {
return this.store.getters['user/getIsAdminOrUp']
},
moreMenuItems() {
if (this.isMusic) return []
if (this.recentEpisode) {
const items = [
{
@ -727,7 +703,7 @@ export default {
toggleFinished(confirmed = false) {
if (!this.itemIsFinished && this.userProgressPercent > 0 && !confirmed) {
const payload = {
message: `Are you sure you want to mark "${this.displayTitle}" as finished?`,
message: this.$getString('MessageConfirmMarkItemFinished', [this.displayTitle]),
callback: (confirmed) => {
if (confirmed) {
this.toggleFinished(true)
@ -772,18 +748,18 @@ export default {
.then((data) => {
var result = data.result
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
this.$toast.error(this.$getString('ToastRescanFailed', [this.displayTitle]))
} else if (result === 'UPDATED') {
this.$toast.success(`Re-Scan complete item was updated`)
this.$toast.success(this.$strings.ToastRescanUpdated)
} else if (result === 'UPTODATE') {
this.$toast.success(`Re-Scan complete item was up to date`)
this.$toast.success(this.$strings.ToastRescanUpToDate)
} else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete item was removed`)
this.$toast.error(this.$strings.ToastRescanRemoved)
}
})
.catch((error) => {
console.error('Failed to scan library item', error)
this.$toast.error('Failed to scan library item')
this.$toast.error(this.$strings.ToastScanFailed)
})
.finally(() => {
this.processing = false
@ -840,7 +816,7 @@ export default {
})
.catch((error) => {
console.error('Failed to remove series from home', error)
this.$toast.error('Failed to update user')
this.$toast.error(this.$strings.ToastFailedToUpdateUser)
})
.finally(() => {
this.processing = false
@ -858,7 +834,7 @@ export default {
})
.catch((error) => {
console.error('Failed to hide item from home', error)
this.$toast.error('Failed to update user')
this.$toast.error(this.$strings.ToastFailedToUpdateUser)
})
.finally(() => {
this.processing = false
@ -873,7 +849,7 @@ export default {
episodeId: this.recentEpisode.id,
title: this.recentEpisode.title,
subtitle: this.mediaMetadata.title,
caption: this.recentEpisode.publishedAt ? `Published ${this.$formatDate(this.recentEpisode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
caption: this.recentEpisode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(this.recentEpisode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: this.recentEpisode.audioFile.duration || null,
coverPath: this.media.coverPath || null
}
@ -923,11 +899,11 @@ export default {
axios
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
.then(() => {
this.$toast.success('Item deleted')
this.$toast.success(this.$strings.ToastItemDeletedSuccess)
})
.catch((error) => {
console.error('Failed to delete item', error)
this.$toast.error('Failed to delete item')
this.$toast.error(this.$strings.ToastItemDeletedFailed)
})
.finally(() => {
this.processing = false
@ -1033,7 +1009,7 @@ export default {
episodeId: episode.id,
title: episode.title,
subtitle: this.mediaMetadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.audioFile.duration || null,
coverPath: this.media.coverPath || null
})

View file

@ -57,23 +57,11 @@ export default {
return this.store.getters['libraries/getBookCoverAspectRatio']
},
cardWidth() {
return this.width || this.coverHeight * 2
return this.width || (this.coverHeight / this.bookCoverAspectRatio) * 2
},
coverHeight() {
return this.height * this.sizeMultiplier
},
cardHeight() {
return this.coverHeight + this.bottomTextHeight
},
bottomTextHeight() {
if (!this.isAlternativeBookshelfView) return 0 // bottom text appears on top of the divider
const lineHeight = 1.5
const remSize = 16
const baseHeight = this.sizeMultiplier * lineHeight * remSize
const titleHeight = this.labelFontSize * baseHeight
const paddingHeight = 4 * 2 * this.sizeMultiplier // py-1
return titleHeight + paddingHeight
},
labelFontSize() {
if (this.width < 160) return 0.75
return 0.9

View file

@ -65,7 +65,7 @@ export default {
return this.store.getters['libraries/getBookCoverAspectRatio']
},
cardWidth() {
return this.width || this.coverHeight * 2
return this.width || (this.coverHeight / this.bookCoverAspectRatio) * 2
},
coverHeight() {
return this.height * this.sizeMultiplier
@ -96,7 +96,7 @@ export default {
displaySortLine() {
switch (this.orderBy) {
case 'addedAt':
return `${this.$strings.LabelAdded} ${this.$formatDate(this.addedAt, this.dateFormat)}`
return this.$getString('LabelAddedDate', [this.$formatDate(this.addedAt, this.dateFormat)])
case 'totalDuration':
return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}`
case 'lastBookUpdated':

View file

@ -3,7 +3,7 @@
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(name)}`">
<div cy-id="card" :style="{ width: cardWidth + 'px', height: cardHeight + 'px', fontSize: sizeMultiplier + 'rem' }" 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-symbols-outlined text-[10em]">record_voice_over</span>
<span class="material-symbols text-[10em]">&#xe91f;</span>
</div>
<!-- Narrator name & num books overlay -->

View file

@ -1,7 +1,7 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<div class="w-10 h-10 flex items-center justify-center">
<span class="material-symbols text-2xl text-gray-200">record_voice_over</span>
<span class="material-symbols text-2xl text-gray-200">&#xe91f;</span>
</div>
<div class="flex-grow px-2 narratorSearchCardContent h-full">
<p class="truncate text-sm">{{ narrator }}</p>

View file

@ -4,11 +4,11 @@
<p class="text-base md:text-lg font-semibold pr-4">{{ eventName }}</p>
<div class="flex-grow" />
<ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" @click.stop="fireTestEventAndSucceed">Fire onTest Event</ui-btn>
<ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" color="red-600" @click.stop="fireTestEventAndFail">Fire & Fail</ui-btn>
<ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" @click.stop="fireTestEventAndSucceed">{{ this.$strings.ButtonFireOnTest }}</ui-btn>
<ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" color="red-600" @click.stop="fireTestEventAndFail">{{ this.$strings.ButtonFireAndFail }}</ui-btn>
<!-- <ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" @click.stop="rapidFireTestEvents">Rapid Fire</ui-btn> -->
<ui-btn v-else-if="notification.enabled" :loading="sendingTest" small class="mr-2" @click.stop="sendTestClick">Test</ui-btn>
<ui-btn v-else :loading="enabling" small color="success" class="mr-2" @click="enableNotification">Enable</ui-btn>
<ui-btn v-else-if="notification.enabled" :loading="sendingTest" small class="mr-2" @click.stop="sendTestClick">{{ this.$strings.ButtonTest }}</ui-btn>
<ui-btn v-else :loading="enabling" small color="success" class="mr-2" @click="enableNotification">{{ this.$strings.ButtonEnable }}</ui-btn>
<ui-icon-btn :size="7" icon-font-size="1.1rem" icon="edit" class="mr-2" @click="editNotification" />
<ui-icon-btn bg-color="error" :size="7" icon-font-size="1.2rem" icon="delete" @click="deleteNotificationClick" />
@ -65,12 +65,12 @@ export default {
this.$axios
.$get(`/api/notifications/test?fail=${intentionallyFail ? 1 : 0}`)
.then(() => {
this.$toast.success('Triggered onTest Event')
this.$toast.success(this.$strings.ToastNotificationTestTriggerSuccess)
})
.catch((error) => {
console.error('Failed', error)
const errorMsg = error.response ? error.response.data : null
this.$toast.error(`Failed: ${errorMsg}` || 'Failed to trigger onTest event')
this.$toast.error(`Failed: ${errorMsg}` || this.$strings.ToastNotificationTestTriggerFailed)
})
.finally(() => {
this.testing = false
@ -91,7 +91,7 @@ export default {
// End testing functions
sendTestClick() {
const payload = {
message: `Trigger this notification with test data?`,
message: this.$strings.MessageConfirmNotificationTestTrigger,
callback: (confirmed) => {
if (confirmed) {
this.sendTest()
@ -106,12 +106,12 @@ export default {
this.$axios
.$get(`/api/notifications/${this.notification.id}/test`)
.then(() => {
this.$toast.success('Triggered test notification')
this.$toast.success(this.$strings.ToastNotificationTestTriggerSuccess)
})
.catch((error) => {
console.error('Failed', error)
const errorMsg = error.response ? error.response.data : null
this.$toast.error(`Failed: ${errorMsg}` || 'Failed to trigger test notification')
this.$toast.error(`Failed: ${errorMsg}` || this.$strings.ToastNotificationTestTriggerFailed)
})
.finally(() => {
this.sendingTest = false
@ -127,11 +127,10 @@ export default {
.$patch(`/api/notifications/${this.notification.id}`, payload)
.then((updatedSettings) => {
this.$emit('update', updatedSettings)
this.$toast.success('Notification enabled')
})
.catch((error) => {
console.error('Failed to update notification', error)
this.$toast.error('Failed to update notification')
this.$toast.error(this.$strings.ToastNotificationUpdateFailed)
})
.finally(() => {
this.enabling = false
@ -139,7 +138,7 @@ export default {
},
deleteNotificationClick() {
const payload = {
message: `Are you sure you want to delete this notification?`,
message: this.$strings.MessageConfirmDeleteNotification,
callback: (confirmed) => {
if (confirmed) {
this.deleteNotification()
@ -155,11 +154,10 @@ export default {
.$delete(`/api/notifications/${this.notification.id}`)
.then((updatedSettings) => {
this.$emit('update', updatedSettings)
this.$toast.success('Deleted notification')
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Failed to delete notification')
this.$toast.error(this.$strings.ToastNotificationDeleteFailed)
})
.finally(() => {
this.deleting = false
@ -171,4 +169,4 @@ export default {
},
mounted() {}
}
</script>
</script>

View file

@ -27,38 +27,6 @@
<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>
@ -97,7 +65,7 @@
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=languages.${$encode(language)}`" class="hover:underline">{{ language }}</nuxt-link>
</div>
</div>
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
<div v-if="tracks.length || (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>
@ -134,10 +102,6 @@ export default {
isPodcast() {
return this.libraryItem.mediaType === 'podcast'
},
audioFile() {
// Music track
return this.media.audioFile
},
media() {
return this.libraryItem.media || {}
},
@ -168,25 +132,6 @@ export default {
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 || []
},
@ -220,4 +165,4 @@ export default {
methods: {},
mounted() {}
}
</script>
</script>

View file

@ -5,7 +5,7 @@
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
</form>
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
<span v-if="!search" class="material-symbols" style="font-size: 1.2rem">search</span>
<span v-if="!search" class="material-symbols" style="font-size: 1.2rem">&#xe8b6;</span>
<span v-else class="material-symbols" style="font-size: 1.2rem">close</span>
</div>
</div>
@ -42,7 +42,7 @@
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelAuthors }}</p>
<template v-for="item in authorResults">
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`">
<nuxt-link :to="`/author/${item.id}`">
<cards-author-search-card :author="item" />
</nuxt-link>
</li>

View file

@ -98,9 +98,6 @@ export default {
isPodcast() {
return this.libraryMediaType === 'podcast'
},
isMusic() {
return this.libraryMediaType === 'music'
},
seriesItems() {
return [
{
@ -274,35 +271,9 @@ export default {
}
]
},
musicItems() {
return [
{
text: this.$strings.LabelAll,
value: 'all'
},
{
text: this.$strings.LabelGenre,
textPlural: this.$strings.LabelGenres,
value: 'genres',
sublist: true
},
{
text: this.$strings.LabelTag,
textPlural: this.$strings.LabelTags,
value: 'tags',
sublist: true
},
{
text: this.$strings.ButtonIssues,
value: 'issues',
sublist: false
}
]
},
selectItems() {
if (this.isSeries) return this.seriesItems
if (this.isPodcast) return this.podcastItems
if (this.isMusic) return this.musicItems
return this.bookItems
},
selectedItemSublist() {

View file

@ -56,9 +56,6 @@ export default {
isPodcast() {
return this.libraryMediaType === 'podcast'
},
isMusic() {
return this.libraryMediaType === 'music'
},
podcastItems() {
return [
{
@ -148,40 +145,10 @@ export default {
}
]
},
musicItems() {
return [
{
text: this.$strings.LabelTitle,
value: 'media.metadata.title'
},
{
text: this.$strings.LabelAddedAt,
value: 'addedAt'
},
{
text: this.$strings.LabelSize,
value: 'size'
},
{
text: this.$strings.LabelDuration,
value: 'media.duration'
},
{
text: this.$strings.LabelFileBirthtime,
value: 'birthtimeMs'
},
{
text: this.$strings.LabelFileModified,
value: 'mtimeMs'
}
]
},
selectItems() {
let items = null
if (this.isPodcast) {
items = this.podcastItems
} else if (this.isMusic) {
items = this.musicItems
} else if (this.$store.getters['user/getUserSetting']('filterBy').startsWith('series.')) {
items = this.seriesItems
} else {

View file

@ -111,7 +111,7 @@
</div>
<div class="flex pt-4 px-2">
<ui-btn v-if="hasOpenIDLink" small :loading="unlinkingFromOpenID" color="primary" type="button" class="mr-2" @click.stop="unlinkOpenID">Unlink OpenID</ui-btn>
<ui-btn v-if="hasOpenIDLink" small :loading="unlinkingFromOpenID" color="primary" type="button" class="mr-2" @click.stop="unlinkOpenID">{{ $strings.ButtonUnlinkOpenId }}</ui-btn>
<ui-btn v-if="isEditingRoot" small class="flex items-center" to="/account">{{ $strings.ButtonChangeRootPassword }}</ui-btn>
<div class="flex-grow" />
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
@ -212,19 +212,19 @@ export default {
},
unlinkOpenID() {
const payload = {
message: 'Are you sure you want to unlink this user from OpenID?',
message: this.$strings.MessageConfirmUnlinkOpenId,
callback: (confirmed) => {
if (confirmed) {
this.unlinkingFromOpenID = true
this.$axios
.$patch(`/api/users/${this.account.id}/openid-unlink`)
.then(() => {
this.$toast.success('User unlinked from OpenID')
this.$toast.success(this.$strings.ToastUnlinkOpenIdSuccess)
this.show = false
})
.catch((error) => {
console.error('Failed to unlink user from OpenID', error)
this.$toast.error('Failed to unlink user from OpenID')
this.$toast.error(this.$strings.ToastUnlinkOpenIdFailed)
})
.finally(() => {
this.unlinkingFromOpenID = false
@ -265,15 +265,15 @@ export default {
},
submitForm() {
if (!this.newUser.username) {
this.$toast.error('Enter a username')
this.$toast.error(this.$strings.ToastNewUserUsernameError)
return
}
if (!this.newUser.permissions.accessAllLibraries && !this.newUser.librariesAccessible.length) {
this.$toast.error('Must select at least one library')
this.$toast.error(this.$strings.ToastNewUserLibraryError)
return
}
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsSelected.length) {
this.$toast.error('Must select at least one tag')
this.$toast.error(this.$strings.ToastNewUserTagError)
return
}
@ -313,12 +313,12 @@ export default {
this.processing = false
console.error('Failed to update account', error)
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || 'Failed to update account')
this.$toast.error(errMsg || this.$strings.ToastFailedToUpdateAccount)
})
},
submitCreateAccount() {
if (!this.newUser.password) {
this.$toast.error('Must have a password, only root user can have an empty password')
this.$toast.error(this.$strings.ToastNewUserPasswordError)
return
}
@ -329,9 +329,9 @@ export default {
.then((data) => {
this.processing = false
if (data.error) {
this.$toast.error(`Failed to create account: ${data.error}`)
this.$toast.error(this.$strings.ToastNewUserCreatedFailed + ': ' + data.error)
} else {
this.$toast.success('New account created')
this.$toast.success(this.$strings.ToastNewUserCreatedSuccess)
this.show = false
}
})
@ -351,6 +351,7 @@ export default {
update: type === 'admin',
delete: type === 'admin',
upload: type === 'admin',
accessExplicitContent: true,
accessAllLibraries: true,
accessAllTags: true,
selectedTagsNotAccessible: false
@ -385,6 +386,7 @@ export default {
upload: false,
accessAllLibraries: true,
accessAllTags: true,
accessExplicitContent: true,
selectedTagsNotAccessible: false
},
librariesAccessible: [],

View file

@ -2,7 +2,7 @@
<modals-modal ref="modal" v-model="show" name="custom-metadata-provider" :width="600" :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">Add custom metadata provider</p>
<p class="text-3xl text-white truncate">{{ $strings.HeaderAddCustomMetadataProvider }}</p>
</div>
</template>
<form @submit.prevent="submitForm">
@ -20,7 +20,7 @@
<ui-text-input-with-label v-model="newUrl" label="URL" />
</div>
<div class="w-full mb-2 p-1">
<ui-text-input-with-label v-model="newAuthHeaderValue" :label="'Authorization Header Value'" type="password" />
<ui-text-input-with-label v-model="newAuthHeaderValue" :label="$strings.LabelProviderAuthorizationValue" type="password" />
</div>
<div class="flex px-1 pt-4">
<div class="flex-grow" />
@ -67,7 +67,7 @@ export default {
methods: {
submitForm() {
if (!this.newName || !this.newUrl) {
this.$toast.error('Must add name and url')
this.$toast.error(this.$strings.ToastProviderNameAndUrlRequired)
return
}
@ -81,13 +81,13 @@ export default {
})
.then((data) => {
this.$emit('added', data.provider)
this.$toast.success('New provider added')
this.$toast.success(this.$strings.ToastProviderCreatedSuccess)
this.show = false
})
.catch((error) => {
const errorMsg = error.response?.data || 'Unknown error'
console.error('Failed to add provider', error)
this.$toast.error('Failed to add provider: ' + errorMsg)
this.$toast.error(this.$strings.ToastProviderCreatedFailed + ': ' + errorMsg)
})
.finally(() => {
this.processing = false

View file

@ -4,7 +4,7 @@
<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>
<ui-btn v-else-if="userIsAdminOrUp" small :loading="probingFile" class="ml-2" @click="getFFProbeData">{{ $strings.ButtonProbeAudioFile }}</ui-btn>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
@ -159,7 +159,7 @@ export default {
})
.catch((error) => {
console.error('Failed to get ffprobe data', error)
this.$toast.error('FFProbe failed')
this.$toast.error(this.$strings.ToastFailedToLoadData)
})
.finally(() => {
this.probingFile = false

View file

@ -9,7 +9,7 @@
<widgets-cron-expression-builder ref="expressionBuilder" v-model="newCronExpression" @input="expressionUpdated" />
<div class="flex items-center justify-end">
<ui-btn :disabled="!isUpdated" @click="submit">{{ isUpdated ? $strings.ButtonSave : $strings.MessageNoUpdateNecessary }}</ui-btn>
<ui-btn :disabled="!isUpdated" @click="submit">{{ isUpdated ? $strings.ButtonSave : $strings.MessageNoUpdatesWereNecessary }}</ui-btn>
</div>
</div>
</modals-modal>

View file

@ -94,7 +94,7 @@ export default {
this.$toast.success(this.$strings.ToastBookmarkRemoveSuccess)
})
.catch((error) => {
this.$toast.error(this.$strings.ToastBookmarkRemoveFailed)
this.$toast.error(this.$strings.ToastRemoveFailed)
console.error(error)
})
this.show = false

View file

@ -8,7 +8,7 @@
<div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
<div class="flex">
<div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80">
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!isNewSeries" :label="$strings.LabelSeriesName" />
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!isNewSeries" :label="$strings.LabelSeriesName" @input="seriesNameInputHandler" />
</div>
<div class="w-24 sm:w-28 md:w-40 p-1">
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" :label="$strings.LabelSequence" />
@ -66,6 +66,11 @@ export default {
}
},
methods: {
seriesNameInputHandler() {
if (this.$refs.sequenceInput) {
this.$refs.sequenceInput.setFocus()
}
},
setInputFocus() {
if (this.isNewSeries) {
// Focus on series input if new series
@ -134,4 +139,4 @@ export default {
},
mounted() {}
}
</script>
</script>

View file

@ -100,7 +100,7 @@
<div class="flex items-center">
<ui-btn v-if="!isOpenSession && !isMediaItemShareSession" small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
<ui-btn v-else-if="!isMediaItemShareSession" small color="error" @click.stop="closeSessionClick">Close Open Session</ui-btn>
<ui-btn v-else-if="!isMediaItemShareSession" small color="error" @click.stop="closeSessionClick">{{ $strings.ButtonCloseSession }}</ui-btn>
</div>
</div>
</modals-modal>
@ -206,14 +206,13 @@ export default {
this.$axios
.$post(`/api/session/${this._session.id}/close`)
.then(() => {
this.$toast.success('Session closed')
this.show = false
this.$emit('closedSession')
})
.catch((error) => {
console.error('Failed to close session', error)
const errMsg = error.response?.data || ''
this.$toast.error(errMsg || 'Failed to close open session')
this.$toast.error(errMsg || this.$strings.ToastSessionCloseFailed)
})
.finally(() => {
this.processing = false

View file

@ -165,7 +165,7 @@ export default {
},
openShare() {
if (!this.newShareSlug) {
this.$toast.error('Slug is required')
this.$toast.error(this.$strings.ToastSlugRequired)
return
}
const payload = {

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="$strings.LabelTimeInMinutes" 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-18 flex items-center justify-center ml-1">{{ $strings.ButtonSubmit }}</ui-btn>
</form>
</div>
<div v-if="timerSet" class="w-full p-4">

View file

@ -78,14 +78,13 @@ export default {
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success('Cover Uploaded')
this.resetCoverPreview()
}
this.processingUpload = false
})
.catch((error) => {
console.error('Failed', error)
var errorMsg = error.response && error.response.data ? error.response.data : 'Unknown Error'
var errorMsg = error.response && error.response.data ? error.response.data : this.$strings.ToastUnknownError
this.$toast.error(errorMsg)
this.processingUpload = false
})
@ -95,7 +94,7 @@ export default {
var success = await this.$axios.$post(`/api/${this.entity}/${this.entityId}/cover`, { url: this.imageUrl }).catch((error) => {
console.error('Failed to download cover from url', error)
var errorMsg = error.response && error.response.data ? error.response.data : 'Unknown Error'
var errorMsg = error.response && error.response.data ? error.response.data : this.$strings.ToastUnknownError
this.$toast.error(errorMsg)
return false
})
@ -104,4 +103,4 @@ export default {
}
}
}
</script>
</script>

View file

@ -116,12 +116,12 @@ export default {
this.$axios
.$delete(`/api/authors/${this.authorId}`)
.then(() => {
this.$toast.success('Author removed')
this.$toast.success(this.$strings.ToastAuthorRemoveSuccess)
this.show = false
})
.catch((error) => {
console.error('Failed to remove author', error)
this.$toast.error('Failed to remove author')
this.$toast.error(this.$strings.ToastRemoveFailed)
})
.finally(() => {
this.processing = false
@ -141,7 +141,7 @@ export default {
}
})
if (!Object.keys(updatePayload).length) {
this.$toast.info(this.$strings.MessageNoUpdateNecessary)
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
return
}
this.processing = true
@ -158,7 +158,7 @@ export default {
} else if (result.merged) {
this.$toast.success(this.$strings.ToastAuthorUpdateMerged)
this.show = false
} else this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
} else this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
}
this.processing = false
},
@ -174,7 +174,7 @@ export default {
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed)
this.$toast.error(this.$strings.ToastRemoveFailed)
})
.finally(() => {
this.processing = false
@ -182,7 +182,7 @@ export default {
},
submitUploadCover() {
if (!this.imageUrl?.startsWith('http:') && !this.imageUrl?.startsWith('https:')) {
this.$toast.error('Invalid image url')
this.$toast.error(this.$strings.ToastInvalidImageUrl)
return
}
@ -194,14 +194,14 @@ export default {
.$post(`/api/authors/${this.authorId}/image`, updatePayload)
.then((data) => {
this.imageUrl = ''
this.$toast.success('Author image updated')
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
this.authorCopy.updatedAt = data.author.updatedAt
this.authorCopy.imagePath = data.author.imagePath
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error(error.response.data || 'Failed to remove author image')
this.$toast.error(error.response.data || this.$strings.ToastRemoveFailed)
})
.finally(() => {
this.processing = false
@ -209,7 +209,7 @@ export default {
},
async searchAuthor() {
if (!this.authorCopy.name && !this.authorCopy.asin) {
this.$toast.error('Must enter an author name')
this.$toast.error(this.$strings.ToastNameRequired)
return
}
this.processing = true
@ -228,17 +228,19 @@ export default {
return null
})
if (!response) {
this.$toast.error('Author not found')
this.$toast.error(this.$strings.ToastAuthorSearchNotFound)
} else if (response.updated) {
if (response.author.imagePath) {
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
} else this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
} else {
this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
}
this.authorCopy = {
...response.author
}
} else {
this.$toast.info('No updates were made for Author')
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
}
this.processing = false
}

View file

@ -143,7 +143,7 @@ export default {
})
.catch((error) => {
console.error('Failed to remove books from collection', error)
this.$toast.error(this.$strings.ToastCollectionItemsRemoveFailed)
this.$toast.error(this.$strings.ToastRemoveFailed)
this.processing = false
})
} else {
@ -157,7 +157,7 @@ export default {
})
.catch((error) => {
console.error('Failed to remove book from collection', error)
this.$toast.error(this.$strings.ToastCollectionItemsRemoveFailed)
this.$toast.error(this.$strings.ToastRemoveFailed)
this.processing = false
})
}
@ -172,12 +172,12 @@ export default {
.$post(`/api/collections/${collection.id}/batch/add`, { books: this.selectedBookIds })
.then((updatedCollection) => {
console.log(`Books added to collection`, updatedCollection)
this.$toast.success('Books added to collection')
this.$toast.success(this.$strings.ToastCollectionItemsAddSuccess)
this.processing = false
})
.catch((error) => {
console.error('Failed to add books to collection', error)
this.$toast.error('Failed to add books to collection')
this.$toast.error(this.$strings.ToastCollectionItemsAddFailed)
this.processing = false
})
} else {
@ -187,12 +187,12 @@ export default {
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId })
.then((updatedCollection) => {
console.log(`Book added to collection`, updatedCollection)
this.$toast.success('Book added to collection')
this.$toast.success(this.$strings.ToastCollectionItemsAddSuccess)
this.processing = false
})
.catch((error) => {
console.error('Failed to add book to collection', error)
this.$toast.error('Failed to add book to collection')
this.$toast.error(this.$strings.ToastCollectionItemsAddFailed)
this.processing = false
})
}
@ -221,7 +221,7 @@ export default {
.catch((error) => {
console.error('Failed to create collection', error)
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(`Failed to create collection: ${errMsg}`)
this.$toast.error(this.$strings.ToastCollectionCreateFailed + ': ' + errMsg)
this.processing = false
})
}

View file

@ -106,7 +106,7 @@ export default {
.catch((error) => {
console.error('Failed to remove collection', error)
this.processing = false
this.$toast.error(this.$strings.ToastCollectionRemoveFailed)
this.$toast.error(this.$strings.ToastRemoveFailed)
})
}
},
@ -115,7 +115,7 @@ export default {
return
}
if (!this.newCollectionName) {
return this.$toast.error('Collection must have a name')
return this.$toast.error(this.$strings.ToastNameRequired)
}
this.processing = true

View file

@ -125,12 +125,12 @@ export default {
this.$refs.ereaderEmailInput.blur()
if (!this.newDevice.name?.trim() || !this.newDevice.email?.trim()) {
this.$toast.error('Name and email required')
this.$toast.error(this.$strings.ToastNameEmailRequired)
return
}
if (this.newDevice.availabilityOption === 'specificUsers' && !this.newDevice.users.length) {
this.$toast.error('Must select at least one user')
this.$toast.error(this.$strings.ToastSelectAtLeastOneUser)
return
}
if (this.newDevice.availabilityOption !== 'specificUsers') {
@ -142,14 +142,14 @@ export default {
if (!this.ereaderDevice) {
if (this.existingDevices.some((d) => d.name === this.newDevice.name)) {
this.$toast.error('Ereader device with that name already exists')
this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)
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')
this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)
return
}
@ -174,12 +174,11 @@ export default {
.$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')
this.$toast.error(this.$strings.ToastDeviceUpdateFailed)
})
.finally(() => {
this.processing = false
@ -201,12 +200,11 @@ export default {
.$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')
this.$toast.error(this.$strings.ToastDeviceAddFailed)
})
.finally(() => {
this.processing = false

View file

@ -194,7 +194,6 @@ export default {
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success('Cover Uploaded')
this.resetCoverPreview()
}
this.processingUpload = false
@ -204,7 +203,7 @@ export default {
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
} else {
this.$toast.error('Oops, something went wrong...')
this.$toast.error(this.$strings.ToastUnknownError)
}
this.processingUpload = false
})
@ -255,7 +254,7 @@ export default {
},
async updateCover(cover) {
if (!cover.startsWith('http:') && !cover.startsWith('https:')) {
this.$toast.error('Invalid URL')
this.$toast.error(this.$strings.ToastInvalidUrl)
return
}
@ -264,11 +263,10 @@ export default {
.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover })
.then(() => {
this.imageUrl = ''
this.$toast.success('Update Successful')
})
.catch((error) => {
console.error('Failed to update cover', error)
this.$toast.error(error.response?.data || 'Failed to update cover')
this.$toast.error(error.response?.data || this.$strings.ToastCoverUpdateFailed)
})
.finally(() => {
this.isProcessing = false
@ -308,12 +306,9 @@ export default {
this.isProcessing = true
this.$axios
.$patch(`/api/items/${this.libraryItemId}/cover`, { cover: coverFile.metadata.path })
.then(() => {
this.$toast.success('Update Successful')
})
.catch((error) => {
console.error('Failed to set local cover', error)
this.$toast.error(error.response?.data || 'Failed to set cover')
this.$toast.error(error.response?.data || this.$strings.ToastCoverUpdateFailed)
})
.finally(() => {
this.isProcessing = false
@ -321,4 +316,4 @@ export default {
}
}
}
</script>
</script>

View file

@ -92,7 +92,7 @@ export default {
var { title, author } = this.$refs.itemDetailsEdit.getTitleAndAuthorName()
if (!title) {
this.$toast.error('Must have a title for quick match')
this.$toast.error(this.$strings.ToastTitleRequired)
return
}
this.quickMatching = true
@ -108,9 +108,9 @@ export default {
if (res.warning) {
this.$toast.warning(res.warning)
} else if (res.updated) {
this.$toast.success('Item details updated')
this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)
} else {
this.$toast.info('No updates were made')
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
}
})
.catch((error) => {
@ -128,18 +128,18 @@ export default {
this.rescanning = false
var result = data.result
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
this.$toast.error(this.$getString('ToastRescanFailed', [this.title]))
} else if (result === 'UPDATED') {
this.$toast.success(`Re-Scan complete item was updated`)
this.$toast.success(this.$strings.ToastRescanUpdated)
} else if (result === 'UPTODATE') {
this.$toast.success(`Re-Scan complete item was up to date`)
this.$toast.success(this.$strings.ToastRescanUpToDate)
} else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete item was removed`)
this.$toast.error(this.$strings.ToastRescanRemoved)
}
})
.catch((error) => {
console.error('Failed to scan library item', error)
this.$toast.error('Failed to scan library item')
this.$toast.error(this.$strings.ToastScanFailed)
this.rescanning = false
})
},
@ -156,7 +156,7 @@ export default {
}
var updatedDetails = this.$refs.itemDetailsEdit.getDetails()
if (!updatedDetails.hasChanges) {
this.$toast.info('No changes were made')
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
return false
}
return this.updateDetails(updatedDetails)
@ -170,7 +170,7 @@ export default {
this.isProcessing = false
if (updateResult) {
if (updateResult.updated) {
this.$toast.success('Item details updated')
this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)
return true
} else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
@ -217,4 +217,4 @@ export default {
height: calc(100% - 80px);
max-height: calc(100% - 80px);
}
</style>
</style>

View file

@ -397,7 +397,7 @@ export default {
},
submitSearch() {
if (!this.searchTitle) {
this.$toast.warning('Search title is required')
this.$toast.warning(this.$strings.ToastTitleRequired)
return
}
this.persistProvider()
@ -618,7 +618,7 @@ export default {
if (updateResult.updated) {
this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)
} else {
this.$toast.info(this.$strings.ToastItemDetailsUpdateUnneeded)
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
}
this.clearSelectedMatch()
this.$emit('selectTab', 'details')

View file

@ -163,7 +163,7 @@ export default {
this.isProcessing = false
if (updateResult) {
if (updateResult.updated) {
this.$toast.success('Item details updated')
this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)
return true
} else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)

View file

@ -156,7 +156,7 @@ export default {
},
validate() {
if (!this.libraryCopy.name) {
this.$toast.error('Library must have a name')
this.$toast.error(this.$strings.ToastNameRequired)
return false
}
if (!this.libraryCopy.folders.length) {
@ -205,7 +205,7 @@ export default {
submitUpdateLibrary() {
var newLibraryPayload = this.getLibraryUpdatePayload()
if (!Object.keys(newLibraryPayload).length) {
this.$toast.info('No updates are necessary')
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
return
}
@ -264,4 +264,4 @@ export default {
.tab.tab-selected {
height: 41px;
}
</style>
</style>

View file

@ -162,7 +162,7 @@ export default {
})
.catch((error) => {
console.error('Failed to get filesystem paths', error)
this.$toast.error('Failed to get filesystem paths')
this.$toast.error(this.$strings.ToastFailedToLoadData)
return []
})
.finally(() => {

View file

@ -78,4 +78,4 @@ export default {
},
mounted() {}
}
</script>
</script>

View file

@ -86,7 +86,7 @@ export default {
return this.selectedEventData && this.selectedEventData.requiresLibrary
},
title() {
return this.isNew ? 'Create Notification' : 'Update Notification'
return this.isNew ? this.$strings.HeaderNotificationCreate : this.$strings.HeaderNotificationUpdate
},
availableVariables() {
return this.selectedEventData ? this.selectedEventData.variables || null : null
@ -104,9 +104,9 @@ export default {
},
submitForm() {
this.$refs.urlsInput?.forceBlur()
if (!this.newNotification.urls.length) {
this.$toast.error('Must enter an Apprise URL')
this.$toast.error(this.$strings.ToastAppriseUrlRequired)
return
}
@ -127,12 +127,12 @@ export default {
.$patch(`/api/notifications/${payload.id}`, payload)
.then((updatedSettings) => {
this.$emit('update', updatedSettings)
this.$toast.success('Notification updated')
this.$toast.success(this.$strings.ToastNotificationUpdateSuccess)
this.show = false
})
.catch((error) => {
console.error('Failed to update notification', error)
this.$toast.error('Failed to update notification')
this.$toast.error(this.$strings.ToastNotificationUpdateFailed)
})
.finally(() => {
this.processing = false
@ -149,12 +149,11 @@ export default {
.$post('/api/notifications', payload)
.then((updatedSettings) => {
this.$emit('update', updatedSettings)
this.$toast.success('Notification created')
this.show = false
})
.catch((error) => {
console.error('Failed to create notification', error)
this.$toast.error('Failed to create notification')
this.$toast.error(this.$strings.ToastNotificationCreateFailed)
})
.finally(() => {
this.processing = false

View file

@ -13,7 +13,7 @@
<div class="flex-grow" />
<ui-checkbox v-model="playerQueueAutoPlay" label="Auto Play" medium checkbox-bg="primary" border-color="gray-600" label-class="pl-2 mb-px" />
</div>
<modals-player-queue-item-row v-for="(item, index) in playerQueueItems" :key="index" :item="item" :index="index" @play="playItem" @remove="removeItem" />
<modals-player-queue-item-row v-for="(item, index) in playerQueueItems" :key="index" :item="item" :index="index" @play="playItem(index)" @remove="removeItem" />
</div>
</div>
</modals-modal>
@ -22,8 +22,7 @@
<script>
export default {
props: {
value: Boolean,
libraryItemId: String
value: Boolean
},
data() {
return {}
@ -50,11 +49,9 @@ export default {
}
},
methods: {
playItem(item) {
this.$eventBus.$emit('play-item', {
libraryItemId: item.libraryItemId,
episodeId: item.episodeId || null,
queueItems: this.playerQueueItems
playItem(index) {
this.$eventBus.$emit('play-queue-item', {
index
})
this.show = false
},
@ -63,4 +60,4 @@ export default {
}
}
}
</script>
</script>

View file

@ -130,12 +130,12 @@ export default {
.$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
.then((updatedPlaylist) => {
console.log(`Items removed from playlist`, updatedPlaylist)
this.$toast.success('Playlist item(s) removed')
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
this.processing = false
})
.catch((error) => {
console.error('Failed to remove items from playlist', error)
this.$toast.error('Failed to remove playlist item(s)')
this.$toast.error(this.$strings.ToastPlaylistUpdateFailed)
this.processing = false
})
},
@ -148,12 +148,12 @@ export default {
.$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })
.then((updatedPlaylist) => {
console.log(`Items added to playlist`, updatedPlaylist)
this.$toast.success('Items added to playlist')
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
this.processing = false
})
.catch((error) => {
console.error('Failed to add items to playlist', error)
this.$toast.error('Failed to add items to playlist')
this.$toast.error(this.$strings.ToastPlaylistUpdateFailed)
this.processing = false
})
},
@ -174,14 +174,14 @@ export default {
.$post('/api/playlists', newPlaylist)
.then((data) => {
console.log('New playlist created', data)
this.$toast.success(`Playlist "${data.name}" created`)
this.$toast.success(this.$strings.ToastPlaylistCreateSuccess + ': ' + data.name)
this.processing = false
this.newPlaylistName = ''
})
.catch((error) => {
console.error('Failed to create playlist', error)
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(`Failed to create playlist: ${errMsg}`)
this.$toast.error(this.$strings.ToastPlaylistCreateFailed + ': ' + errMsg)
this.processing = false
})
}

View file

@ -86,7 +86,7 @@ export default {
.catch((error) => {
console.error('Failed to remove playlist', error)
this.processing = false
this.$toast.error(this.$strings.ToastPlaylistRemoveFailed)
this.$toast.error(this.$strings.ToastRemoveFailed)
})
}
},
@ -95,7 +95,7 @@ export default {
return
}
if (!this.newPlaylistName) {
return this.$toast.error('Playlist must have a name')
return this.$toast.error(this.$strings.ToastNameRequired)
}
this.processing = true

View file

@ -142,7 +142,7 @@ export default {
const updatedDetails = this.getUpdatePayload()
if (!Object.keys(updatedDetails).length) {
this.$toast.info('No changes were made')
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
return false
}
return this.updateDetails(updatedDetails)

View file

@ -105,7 +105,7 @@ export default {
}
const updatePayload = this.getUpdatePayload(episodeData)
if (!Object.keys(updatePayload).length) {
return this.$toast.info('No updates are necessary')
return this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
}
console.log('Episode update payload', updatePayload)
@ -126,7 +126,7 @@ export default {
},
submitForm() {
if (!this.episodeTitle || !this.episodeTitle.length) {
this.$toast.error('Must enter an episode title')
this.$toast.error(this.$strings.ToastTitleRequired)
return
}
this.searchedTitle = this.episodeTitle

View file

@ -121,14 +121,14 @@ export default {
methods: {
openFeed() {
if (!this.newFeedSlug) {
this.$toast.error('Must set a feed slug')
this.$toast.error(this.$strings.ToastSlugRequired)
return
}
const sanitized = this.$sanitizeSlug(this.newFeedSlug)
if (this.newFeedSlug !== sanitized) {
this.newFeedSlug = sanitized
this.$toast.warning('Slug had to be modified - Run again')
this.$toast.warning(this.$strings.ToastSlugMustChange)
return
}

View file

@ -20,8 +20,8 @@
<span class="material-symbols text-2xl sm:text-3xl">forward_media</span>
</button>
</ui-tooltip>
<ui-tooltip direction="top" :text="$strings.ButtonNextChapter" class="ml-4 lg:ml-8">
<button :aria-label="$strings.ButtonNextChapter" :disabled="!hasNextChapter" :class="hasNextChapter ? 'text-gray-300' : 'text-gray-500'" @mousedown.prevent @mouseup.prevent @click.stop="nextChapter">
<ui-tooltip direction="top" :text="hasNextLabel" class="ml-4 lg:ml-8">
<button :aria-label="hasNextLabel" :disabled="!hasNext" class="text-gray-300 disabled:text-gray-500" @mousedown.prevent @mouseup.prevent @click.stop="next">
<span class="material-symbols text-2xl sm:text-3xl">last_page</span>
</button>
</ui-tooltip>
@ -43,7 +43,8 @@ export default {
seekLoading: Boolean,
playbackRate: Number,
paused: Boolean,
hasNextChapter: Boolean
hasNextChapter: Boolean,
hasNextItemInQueue: Boolean
},
data() {
return {}
@ -62,6 +63,13 @@ export default {
},
jumpBackwardText() {
return this.getJumpText('jumpBackwardAmount', this.$strings.ButtonJumpBackward)
},
hasNextLabel() {
if (this.hasNextItemInQueue && !this.hasNextChapter) return this.$strings.ButtonNextItemInQueue
return this.$strings.ButtonNextChapter
},
hasNext() {
return this.hasNextItemInQueue || this.hasNextChapter
}
},
methods: {
@ -71,9 +79,9 @@ export default {
prevChapter() {
this.$emit('prevChapter')
},
nextChapter() {
if (!this.hasNextChapter) return
this.$emit('nextChapter')
next() {
if (!this.hasNext) return
this.$emit('next')
},
jumpBackward() {
this.$emit('jumpBackward')

View file

@ -43,7 +43,7 @@
</ui-tooltip>
</div>
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :hasNextChapter="hasNextChapter" :hasNextItemInQueue="hasNextItemInQueue" @prevChapter="prevChapter" @next="goToNext" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
</div>
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" :playback-rate="playbackRate" @seek="seek" />
@ -82,7 +82,8 @@ export default {
sleepTimerType: String,
isPodcast: Boolean,
hideBookmarks: Boolean,
hideSleepTimer: Boolean
hideSleepTimer: Boolean,
hasNextItemInQueue: Boolean
},
data() {
return {
@ -145,7 +146,7 @@ export default {
return Math.round((100 * time) / duration)
},
currentChapterName() {
return this.currentChapter ? this.currentChapter.title : ''
return this.currentChapter?.title || ''
},
currentChapterDuration() {
if (!this.currentChapter) return 0
@ -177,22 +178,6 @@ export default {
methods: {
toggleFullscreen(isFullscreen) {
this.$store.commit('setPlayerIsFullscreen', isFullscreen)
var videoPlayerEl = document.getElementById('video-player')
if (videoPlayerEl) {
if (isFullscreen) {
videoPlayerEl.style.width = '100vw'
videoPlayerEl.style.height = '100vh'
videoPlayerEl.style.top = '0px'
videoPlayerEl.style.left = '0px'
} else {
videoPlayerEl.style.width = '384px'
videoPlayerEl.style.height = '216px'
videoPlayerEl.style.top = 'unset'
videoPlayerEl.style.bottom = '80px'
videoPlayerEl.style.left = '16px'
}
}
},
setDuration(duration) {
this.duration = duration
@ -278,10 +263,13 @@ export default {
this.seek(this.currentChapter.start)
}
},
nextChapter() {
if (!this.currentChapter || !this.hasNextChapter) return
var nextChapter = this.chapters[this.currentChapterIndex + 1]
this.seek(nextChapter.start)
goToNext() {
if (this.hasNextChapter) {
const nextChapter = this.chapters[this.currentChapterIndex + 1]
this.seek(nextChapter.start)
} else if (this.hasNextItemInQueue) {
this.$emit('nextItemInQueue')
}
},
setStreamReady() {
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(1)

View file

@ -29,7 +29,7 @@
</div>
<div class="flex p-2">
<span class="material-symbols-outlined text-5xl pt-1">insert_drive_file</span>
<span class="material-symbols text-5xl pt-1">insert_drive_file</span>
<div class="px-1">
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(totalSizeNum) }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelSize }} ({{ totalSizeMod }})</p>
@ -37,7 +37,7 @@
</div>
<div class="flex p-2">
<span class="material-symbols-outlined text-5xl pt-1">audio_file</span>
<span class="material-symbols text-5xl pt-1">audio_file</span>
<div class="px-1">
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(numAudioTracks) }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAudioTracks }}</p>
@ -103,4 +103,4 @@ export default {
methods: {},
mounted() {}
}
</script>
</script>

View file

@ -73,7 +73,7 @@ export default {
const addIcon = (icon, color, fontSize, x, y) => {
ctx.fillStyle = color
ctx.font = `${fontSize} Material Symbols Outlined`
ctx.font = `${fontSize} Material Symbols Rounded`
ctx.fillText(icon, x, y)
}
@ -152,7 +152,7 @@ export default {
// Top text
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
addText(`${this.year} ${this.$strings.StatsYearInReview}`, '18px', 'bold', 'white', '1px', 65, 51,)
addText(`${this.year} ${this.$strings.StatsYearInReview}`, '18px', 'bold', 'white', '1px', 65, 51)
// Top left box
createRoundedRect(50, 100, 340, 160)
@ -261,7 +261,7 @@ export default {
.catch((error) => {
console.error('Failed to share', error)
if (error.name !== 'AbortError') {
this.$toast.error('Failed to share: ' + error.message)
this.$toast.error(this.$strings.ToastFailedToShare + ': ' + error.message)
}
})
} else {

View file

@ -2,7 +2,7 @@
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-1 sm:p-4 mb-4">
<!-- hack to get icon fonts loaded on init -->
<div class="h-0 w-0 overflow-hidden opacity-0">
<span class="material-symbols-outlined">close</span>
<span class="material-symbols">close</span>
<span class="abs-icons icon-audiobookshelf" />
</div>
@ -38,7 +38,7 @@
<!-- next button -->
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
<span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span>
<span class="material-symbols-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
<span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
</ui-btn>
</div>
<stats-year-in-review ref="yearInReview" :variant="yearInReviewVariant" :year="yearInReviewYear" :processing.sync="processingYearInReview" />
@ -74,7 +74,7 @@
<!-- next button -->
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
<span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span>
<span class="material-symbols-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
<span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
</ui-btn>
</div>
</div>
@ -138,4 +138,4 @@ export default {
}
}
}
</script>
</script>

View file

@ -237,7 +237,7 @@ export default {
.catch((error) => {
console.error('Failed to share', error)
if (error.name !== 'AbortError') {
this.$toast.error('Failed to share: ' + error.message)
this.$toast.error(this.$strings.ToastFailedToShare + ': ' + error.message)
}
})
} else {

View file

@ -64,7 +64,7 @@ export default {
const addIcon = (icon, color, fontSize, x, y) => {
ctx.fillStyle = color
ctx.font = `${fontSize} Material Symbols Outlined`
ctx.font = `${fontSize} Material Symbols Rounded`
ctx.fillText(icon, x, y)
}
@ -167,7 +167,7 @@ export default {
.catch((error) => {
console.error('Failed to share', error)
if (error.name !== 'AbortError') {
this.$toast.error('Failed to share: ' + error.message)
this.$toast.error(this.$strings.ToastFailedToShare + ': ' + error.message)
}
})
} else {

View file

@ -23,7 +23,7 @@
<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-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-symbols-outlined text-2xl text-error">error_outline</span>
<span class="material-symbols text-2xl text-error">error_outline</span>
</ui-tooltip>
<button aria-label="Download Backup" class="inline-flex material-symbols text-xl mx-1 mt-1 text-white/70 hover:text-white/100" @click.stop="downloadBackup(backup)">download</button>
@ -186,7 +186,7 @@ export default {
mounted() {
this.loadBackups()
if (this.$route.query.backup) {
this.$toast.success('Backup applied successfully')
this.$toast.success(this.$strings.ToastBackupAppliedSuccess)
}
}
}

View file

@ -6,7 +6,7 @@
<div class="flex-grow" />
<ui-btn v-if="userCanUpdate" small :to="`/audiobook/${libraryItemId}/chapters`" color="primary" class="mr-2" @click="clickEditChapters">{{ $strings.ButtonEditChapters }}</ui-btn>
<div v-if="!keepOpen" class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="expanded ? 'transform rotate-180' : ''">
<span class="material-symbols text-4xl">expand_more</span>
<span class="material-symbols text-4xl">&#xe313;</span>
</div>
</div>
<transition name="slide">

View file

@ -78,7 +78,7 @@ export default {
})
.catch((error) => {
console.error('Failed to update collection', error)
this.$toast.error('Failed to save collection books order')
this.$toast.error(this.$strings.ToastCollectionUpdateFailed)
})
},
editBook(book) {
@ -110,4 +110,4 @@ export default {
.collection-book-leave-active {
position: absolute;
}
</style>
</style>

View file

@ -45,7 +45,7 @@ export default {
methods: {
removeProvider(provider) {
const payload = {
message: `Are you sure you want remove custom metadata provider "${provider.name}"?`,
message: this.$getString('MessageConfirmDeleteMetadataProvider', [provider.name]),
callback: (confirmed) => {
if (confirmed) {
this.$emit('update:processing', true)
@ -53,12 +53,12 @@ export default {
this.$axios
.$delete(`/api/custom-metadata-providers/${provider.id}`)
.then(() => {
this.$toast.success('Provider removed')
this.$toast.success(this.$strings.ToastProviderRemoveSuccess)
this.$emit('removed', provider.id)
})
.catch((error) => {
console.error('Failed to remove provider', error)
this.$toast.error('Failed to remove provider')
this.$toast.error(this.$strings.ToastRemoveFailed)
})
.finally(() => {
this.$emit('update:processing', false)

View file

@ -8,7 +8,7 @@
<div class="flex-grow" />
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="toggleFullPath">{{ $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-symbols text-4xl">expand_more</span>
<span class="material-symbols text-4xl">&#xe313;</span>
</div>
</div>
<transition name="slide">
@ -18,7 +18,7 @@
<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-symbols-outlined text-sm align-middle">info</span></ui-tooltip>
{{ $strings.LabelRead }} <ui-tooltip :text="$strings.LabelReadEbookWithoutProgress" direction="top" class="inline-block"><span class="material-symbols text-sm align-middle">info</span></ui-tooltip>
</th>
<th v-if="showMoreColumn" class="text-center w-16"></th>
</tr>
@ -92,4 +92,4 @@ export default {
}
}
}
</script>
</script>

View file

@ -1,7 +1,7 @@
<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-symbols-outlined text-success align-text-bottom">check_circle</span></ui-tooltip>
{{ showFullPath ? file.metadata.path : file.metadata.relPath }} <ui-tooltip :text="$strings.LabelPrimaryEbook" class="inline-block"><span v-if="isPrimary" class="material-symbols text-success align-text-bottom">check_circle</span></ui-tooltip>
</td>
<td>
{{ $bytesPretty(file.metadata.size) }}

View file

@ -8,7 +8,7 @@
<div class="flex-grow" />
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="toggleFullPath">{{ $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-symbols text-4xl">expand_more</span>
<span class="material-symbols text-4xl">&#xe313;</span>
</div>
</div>
<transition name="slide">
@ -103,4 +103,4 @@ export default {
this.showFiles = this.expanded
}
}
</script>
</script>

View file

@ -92,7 +92,7 @@ export default {
})
.catch((error) => {
console.error('Failed to update playlist', error)
this.$toast.error('Failed to save playlist items order')
this.$toast.error(this.$strings.ToastPlaylistUpdateFailed)
})
},
init() {
@ -119,4 +119,4 @@ export default {
.playlist-item-leave-active {
position: absolute;
}
</style>
</style>

View file

@ -11,7 +11,7 @@
<ui-btn small color="primary">{{ $strings.ButtonManageTracks }}</ui-btn>
</nuxt-link>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
<span class="material-symbols text-4xl">expand_more</span>
<span class="material-symbols text-4xl">&#xe313;</span>
</div>
</div>
<transition name="slide">
@ -92,4 +92,4 @@ export default {
}
}
}
</script>
</script>

View file

@ -44,7 +44,7 @@
<div v-if="user.type !== 'root' || userIsRoot" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)">
<button type="button" :aria-label="$getString('ButtonUserEdit', [user.username])" class="material-symbols text-base">edit</button>
</div>
<div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)">
<div v-show="user.type !== 'root' && user.id !== currentUserId" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)">
<button type="button" :aria-label="$getString('ButtonUserDelete', [user.username])" class="material-symbols text-base">delete</button>
</div>
</div>

View file

@ -76,8 +76,7 @@ export default {
var newOrder = libraryOrderData.map((lib) => lib.id).join(',')
if (currOrder !== newOrder) {
this.$axios.$post('/api/libraries/order', libraryOrderData).then((response) => {
if (response.libraries && response.libraries.length) {
this.$toast.success('Library order saved', { timeout: 1500 })
if (response.libraries?.length) {
this.$store.commit('libraries/set', response.libraries)
}
})
@ -110,4 +109,4 @@ export default {
this.$store.commit('libraries/removeListener', 'libraries-table')
}
}
</script>
</script>

View file

@ -218,12 +218,12 @@ export default {
this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess)
} else {
console.log(`Item removed from playlist`, updatedPlaylist)
this.$toast.success('Item removed from playlist')
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
}
})
.catch((error) => {
console.error('Failed to remove item from playlist', error)
this.$toast.error('Failed to remove item from playlist')
this.$toast.error(this.$strings.ToastPlaylistUpdateFailed)
})
.finally(() => {
this.processingRemove = false

View file

@ -270,7 +270,7 @@ export default {
if (data.numEpisodesUpdated) {
this.$toast.success(`${data.numEpisodesUpdated} episodes updated`)
} else {
this.$toast.info('No changes were made')
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
}
})
.catch((error) => {
@ -295,7 +295,7 @@ export default {
episodeId: episode.id,
title: episode.title,
subtitle: this.mediaMetadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.audioFile.duration || null,
coverPath: this.media.coverPath || null
}
@ -372,7 +372,7 @@ export default {
episodeId: episode.id,
title: episode.title,
subtitle: this.mediaMetadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.audioFile.duration || null,
coverPath: this.media.coverPath || null
})

View file

@ -2,7 +2,7 @@
<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">
<span class="material-symbols text-2xl" :class="iconClass">more_vert</span>
<span class="material-symbols text-2xl" :class="iconClass">&#xe5d4;</span>
</button>
<div v-else class="h-full w-full flex items-center justify-center">
<widgets-loading-spinner />

View file

@ -5,7 +5,7 @@
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
<span v-else :class="outlined ? 'material-symbols-outlined' : 'material-symbols'" :style="{ fontSize }">{{ icon }}</span>
<span v-else :class="outlined ? 'material-symbols' : 'material-symbols fill'" :style="{ fontSize }" v-html="icon" />
</button>
</template>
@ -86,4 +86,4 @@ button.icon-btn:disabled::before {
button.icon-btn:disabled span {
color: #777;
}
</style>
</style>

View file

@ -4,13 +4,13 @@
<div ref="wrapper" class="relative">
<form @submit.prevent="submitForm">
<div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'">
<input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @focus="inputFocus" @blur="inputBlur" />
<input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @focus="inputFocus" @blur="inputBlur" @keydown="keydownHandler" />
</div>
</form>
<ul ref="menu" v-show="isFocused && itemsToShow.length" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in itemsToShow">
<li :key="item" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<li :key="item" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" :class="isMenuItemSelected(item) ? 'text-yellow-300' : ''" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item }}</span>
</div>
@ -30,7 +30,10 @@
</template>
<script>
import menuKeyboardNavigationMixin from '@/mixins/menuKeyboardNavigation'
export default {
mixins: [menuKeyboardNavigationMixin],
props: {
value: [String, Number],
disabled: Boolean,
@ -81,6 +84,9 @@ export default {
}
},
methods: {
keydownHandler(e) {
this.menuNavigationHandler(e)
},
setFocus() {
if (this.$refs.input && this.editable) this.$refs.input.focus()
},

View file

@ -7,7 +7,7 @@
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
</div>
<div class="text-gray-200 text-xs font-light mt-2 text-center">{{ text }}</div>
<div class="text-gray-200 text-xs font-light mt-2 text-center">{{ message }}</div>
</div>
</div>
</template>
@ -17,7 +17,12 @@ export default {
props: {
text: {
type: String,
default: 'Please Wait...'
default: null
}
},
computed: {
message() {
return this.text || this.$strings.MessagePleaseWait
}
}
}
@ -67,4 +72,4 @@ export default {
transform: translate(24px, 0);
}
}
</style>
</style>

View file

@ -37,7 +37,10 @@
</template>
<script>
import menuKeyboardNavigationMixin from '@/mixins/menuKeyboardNavigation'
export default {
mixins: [menuKeyboardNavigationMixin],
props: {
value: {
type: Array,
@ -63,8 +66,7 @@ export default {
typingTimeout: null,
isFocused: false,
menu: null,
filteredItems: null,
selectedMenuItemIndex: null
filteredItems: null
}
},
watch: {
@ -119,34 +121,8 @@ export default {
this.filteredItems = results || []
},
keydownInput(event) {
let items = this.itemsToShow
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
if (!items.length) return
if (event.key === 'ArrowDown') {
if (this.selectedMenuItemIndex === null) {
this.selectedMenuItemIndex = 0
} else {
this.selectedMenuItemIndex = Math.min(this.selectedMenuItemIndex + 1, items.length - 1)
}
} else if (event.key === 'ArrowUp') {
if (this.selectedMenuItemIndex === null) {
this.selectedMenuItemIndex = items.length - 1
} else {
this.selectedMenuItemIndex = Math.max(this.selectedMenuItemIndex - 1, 0)
}
}
this.recalcScroll()
return
} else if (event.key === 'Enter') {
if (this.selectedMenuItemIndex !== null) {
this.clickedOption(event, items[this.selectedMenuItemIndex])
} else {
this.submitForm()
}
return
}
this.selectedMenuItemIndex = null
this.menuNavigationHandler(event)
clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => {
this.search()
@ -161,24 +137,6 @@ export default {
this.recalcMenuPos()
}, 50)
},
recalcScroll() {
if (!this.menu) return
var menuItems = this.menu.querySelectorAll('li')
if (!menuItems.length) return
var selectedItem = menuItems[this.selectedMenuItemIndex]
if (!selectedItem) return
var menuHeight = this.menu.offsetHeight
var itemHeight = selectedItem.offsetHeight
var itemTop = selectedItem.offsetTop
var itemBottom = itemTop + itemHeight
if (itemBottom > this.menu.scrollTop + menuHeight) {
let menuPaddingBottom = parseFloat(window.getComputedStyle(this.menu).paddingBottom)
this.menu.scrollTop = itemBottom - menuHeight + menuPaddingBottom
} else if (itemTop < this.menu.scrollTop) {
let menuPaddingTop = parseFloat(window.getComputedStyle(this.menu).paddingTop)
this.menu.scrollTop = itemTop - menuPaddingTop
}
},
recalcMenuPos() {
if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
@ -317,9 +275,6 @@ export default {
this.textInput = null
this.currentSearch = null
this.selectedMenuItemIndex = null
this.$nextTick(() => {
this.blur()
})
},
submitForm() {
if (!this.textInput) return

View file

@ -20,7 +20,7 @@
<ul ref="menu" v-show="showMenu" class="absolute z-60 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in itemsToShow">
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="itemsToShow[selectedMenuItemIndex] === item ? 'text-yellow-300' : ''" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="isMenuItemSelected(item) ? 'text-yellow-300' : ''" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.name }}</span>
</div>
@ -40,7 +40,10 @@
</template>
<script>
import menuKeyboardNavigationMixin from '@/mixins/menuKeyboardNavigation'
export default {
mixins: [menuKeyboardNavigationMixin],
props: {
value: {
type: Array,
@ -63,8 +66,7 @@ export default {
typingTimeout: null,
isFocused: false,
menu: null,
items: [],
selectedMenuItemIndex: null
items: []
}
},
watch: {
@ -124,34 +126,7 @@ export default {
this.items = results || []
},
keydownInput(event) {
let items = this.itemsToShow
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
if (!items.length) return
if (event.key === 'ArrowDown') {
if (this.selectedMenuItemIndex === null) {
this.selectedMenuItemIndex = 0
} else {
this.selectedMenuItemIndex = Math.min(this.selectedMenuItemIndex + 1, items.length - 1)
}
} else if (event.key === 'ArrowUp') {
if (this.selectedMenuItemIndex === null) {
this.selectedMenuItemIndex = items.length - 1
} else {
this.selectedMenuItemIndex = Math.max(this.selectedMenuItemIndex - 1, 0)
}
}
this.recalcScroll()
return
} else if (event.key === 'Enter') {
if (this.selectedMenuItemIndex !== null) {
this.clickedOption(event, items[this.selectedMenuItemIndex])
} else {
this.submitForm()
}
return
}
this.selectedMenuItemIndex = null
this.menuNavigationHandler(event)
clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => {
this.search()
@ -166,24 +141,6 @@ export default {
this.recalcMenuPos()
}, 50)
},
recalcScroll() {
if (!this.menu) return
var menuItems = this.menu.querySelectorAll('li')
if (!menuItems.length) return
var selectedItem = menuItems[this.selectedMenuItemIndex]
if (!selectedItem) return
var menuHeight = this.menu.offsetHeight
var itemHeight = selectedItem.offsetHeight
var itemTop = selectedItem.offsetTop
var itemBottom = itemTop + itemHeight
if (itemBottom > this.menu.scrollTop + menuHeight) {
let menuPaddingBottom = parseFloat(window.getComputedStyle(this.menu).paddingBottom)
this.menu.scrollTop = itemBottom - menuHeight + menuPaddingBottom
} else if (itemTop < this.menu.scrollTop) {
let menuPaddingTop = parseFloat(window.getComputedStyle(this.menu).paddingTop)
this.menu.scrollTop = itemTop - menuPaddingTop
}
},
recalcMenuPos() {
if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
@ -323,9 +280,6 @@ export default {
this.textInput = null
this.currentSearch = null
this.selectedMenuItemIndex = null
this.$nextTick(() => {
this.blur()
})
},
submitForm() {
if (!this.textInput) return

View file

@ -24,12 +24,12 @@ export default {
computed: {},
methods: {
clickBtn(e) {
e.stopPropagation()
if (this.disabled) {
e.preventDefault()
return
}
this.$emit('click')
e.stopPropagation()
}
},
mounted() {}
@ -54,4 +54,4 @@ button.icon-btn:hover:not(:disabled)::before {
button.icon-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
</style>
</style>

View file

@ -23,10 +23,10 @@
<span class="material-symbols text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
</div>
<div v-if="type === 'password' && isHovering" class="absolute top-0 right-0 h-full px-4 flex items-center justify-center">
<span class="material-symbols-outlined text-gray-400 cursor-pointer text-lg" @click.stop.prevent="showPassword = !showPassword">{{ !showPassword ? 'visibility' : 'visibility_off' }}</span>
<span class="material-symbols text-gray-400 cursor-pointer text-lg" @click.stop.prevent="showPassword = !showPassword">{{ !showPassword ? 'visibility' : 'visibility_off' }}</span>
</div>
<div v-else-if="showCopy" class="absolute top-0 right-0 h-full px-4 flex items-center justify-center">
<span class="material-symbols-outlined text-gray-400 cursor-pointer text-lg" @click.stop.prevent="copyToClipboard">{{ !hasCopied ? 'content_copy' : 'done' }}</span>
<span class="material-symbols text-gray-400 cursor-pointer text-lg" @click.stop.prevent="copyToClipboard">{{ !hasCopied ? 'content_copy' : 'done' }}</span>
</div>
</div>
</template>

View file

@ -1,7 +1,7 @@
<template>
<div class="w-full bg-opacity-5 border border-opacity-60 rounded-lg flex items-center relative py-4 pl-16" :class="wrapperClass">
<div class="absolute top-0 left-4 h-full flex items-center">
<span class="material-symbols-outlined text-2xl">{{ icon }}</span>
<span class="material-symbols text-2xl">{{ icon }}</span>
</div>
<slot />
</div>
@ -30,4 +30,4 @@ export default {
methods: {},
mounted() {}
}
</script>
</script>

View file

@ -3,67 +3,67 @@
<form class="w-full h-full px-2 md:px-4 py-6" @submit.prevent="submitForm">
<div class="flex flex-wrap -mx-1">
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="titleInput" v-model="details.title" :label="$strings.LabelTitle" />
<ui-text-input-with-label ref="titleInput" v-model="details.title" :label="$strings.LabelTitle" @input="handleInputChange" />
</div>
<div class="flex-grow px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" :label="$strings.LabelSubtitle" />
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" :label="$strings.LabelSubtitle" @input="handleInputChange" />
</div>
</div>
<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" />
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" :label="$strings.LabelAuthors" filter-key="authors" @input="handleInputChange" />
</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" />
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" :label="$strings.LabelPublishYear" @input="handleInputChange" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="flex-grow px-1">
<widgets-series-input-widget v-model="details.series" />
<widgets-series-input-widget v-model="details.series" @input="handleInputChange" />
</div>
</div>
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" :label="$strings.LabelDescription" class="mt-2" />
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" :label="$strings.LabelDescription" class="mt-2" @input="handleInputChange" />
<div class="flex flex-wrap mt-2 -mx-1">
<div class="w-full md:w-1/2 px-1">
<ui-multi-select ref="genresSelect" v-model="details.genres" :label="$strings.LabelGenres" :items="genres" />
<ui-multi-select ref="genresSelect" v-model="details.genres" :label="$strings.LabelGenres" :items="genres" @input="handleInputChange" />
</div>
<div class="flex-grow px-1 mt-2 md:mt-0">
<ui-multi-select ref="tagsSelect" v-model="newTags" :label="$strings.LabelTags" :items="tags" />
<ui-multi-select ref="tagsSelect" v-model="newTags" :label="$strings.LabelTags" :items="tags" @input="handleInputChange" />
</div>
</div>
<div class="flex flex-wrap mt-2 -mx-1">
<div class="w-full md:w-1/2 px-1">
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" :label="$strings.LabelNarrators" :items="narrators" />
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" :label="$strings.LabelNarrators" :items="narrators" @input="handleInputChange" />
</div>
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" @input="handleInputChange" />
</div>
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" @input="handleInputChange" />
</div>
</div>
<div class="flex flex-wrap mt-2 -mx-1">
<div class="w-full md:w-1/4 px-1">
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" :label="$strings.LabelPublisher" />
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" :label="$strings.LabelPublisher" @input="handleInputChange" />
</div>
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="languageInput" v-model="details.language" :label="$strings.LabelLanguage" />
<ui-text-input-with-label ref="languageInput" v-model="details.language" :label="$strings.LabelLanguage" @input="handleInputChange" />
</div>
<div class="flex-grow px-1 pt-6 mt-2 md:mt-0">
<div class="flex justify-center">
<ui-checkbox v-model="details.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
<ui-checkbox v-model="details.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" @input="handleInputChange" />
</div>
</div>
<div class="flex-grow px-1 pt-6 mt-2 md:mt-0">
<div class="flex justify-center">
<ui-checkbox v-model="details.abridged" :label="$strings.LabelAbridged" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
<ui-checkbox v-model="details.abridged" :label="$strings.LabelAbridged" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" @input="handleInputChange" />
</div>
</div>
</div>
@ -132,6 +132,12 @@ export default {
}
},
methods: {
handleInputChange() {
this.$emit('change', {
libraryItemId: this.libraryItem.id,
hasChanges: this.checkForChanges().hasChanges
})
},
getDetails() {
this.forceBlur()
return this.checkForChanges()
@ -172,6 +178,7 @@ export default {
}
}
}
this.handleInputChange()
},
forceBlur() {
if (this.$refs.titleInput) this.$refs.titleInput.blur()
@ -286,4 +293,4 @@ export default {
},
mounted() {}
}
</script>
</script>

View file

@ -1,9 +1,9 @@
<template>
<div>
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
<span class="material-symbols" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize" aria-label="Decrease Cover Size" role="button">remove</span>
<span class="material-symbols" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize" aria-label="Decrease Cover Size" role="button">&#xe15b;</span>
<p class="px-2 font-mono" style="font-size: 1rem">{{ bookCoverWidth }}</p>
<span class="material-symbols" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize" aria-label="Increase Cover Size" role="button">add</span>
<span class="material-symbols" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize" aria-label="Increase Cover Size" role="button">&#xe145;</span>
</div>
</div>
</template>

View file

@ -30,14 +30,14 @@
<div class="flex items-center justify-center">
<widgets-loading-spinner v-if="isValidating" class="mr-2" />
<span v-else class="material-symbols-outlined mr-2 text-xl" :class="isValid ? 'text-success' : 'text-error'">{{ isValid ? 'check_circle_outline' : 'error_outline' }}</span>
<span v-else class="material-symbols mr-2 text-xl" :class="isValid ? 'text-success' : 'text-error'">{{ isValid ? 'check_circle_outline' : 'error_outline' }}</span>
<p v-if="isValidating" class="text-gray-300 text-base md:text-lg text-center">{{ $strings.MessageCheckingCron }}</p>
<p v-else-if="customCronError" class="text-error text-base md:text-lg text-center">{{ customCronError }}</p>
<p v-else class="text-success text-base md:text-lg text-center">{{ $strings.MessageValidCronExpression }}</p>
</div>
</template>
<div v-if="cronExpression && isValid" class="flex items-center justify-center text-yellow-400 mt-2">
<span class="material-symbols-outlined mr-2 text-xl">event</span>
<span class="material-symbols mr-2 text-xl">event</span>
<p>{{ $strings.LabelNextScheduledRun }}: {{ nextRun }}</p>
</div>
</div>

View file

@ -3,45 +3,45 @@
<form class="w-full h-full px-4 py-6" @submit.prevent="submitForm">
<div class="flex -mx-1">
<div class="w-1/2 px-1">
<ui-text-input-with-label ref="titleInput" v-model="details.title" :label="$strings.LabelTitle" />
<ui-text-input-with-label ref="titleInput" v-model="details.title" :label="$strings.LabelTitle" @input="handleInputChange" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label ref="authorInput" v-model="details.author" :label="$strings.LabelAuthor" />
<ui-text-input-with-label ref="authorInput" v-model="details.author" :label="$strings.LabelAuthor" @input="handleInputChange" />
</div>
</div>
<ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" :label="$strings.LabelRSSFeedURL" class="mt-2" />
<ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" :label="$strings.LabelRSSFeedURL" class="mt-2" @input="handleInputChange" />
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" :label="$strings.LabelDescription" class="mt-2" />
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" :label="$strings.LabelDescription" class="mt-2" @input="handleInputChange" />
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-multi-select ref="genresSelect" v-model="details.genres" :label="$strings.LabelGenres" :items="genres" />
<ui-multi-select ref="genresSelect" v-model="details.genres" :label="$strings.LabelGenres" :items="genres" @input="handleInputChange" />
</div>
<div class="flex-grow px-1">
<ui-multi-select ref="tagsSelect" v-model="newTags" :label="$strings.LabelTags" :items="tags" />
<ui-multi-select ref="tagsSelect" v-model="newTags" :label="$strings.LabelTags" :items="tags" @input="handleInputChange" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-1/4 px-1">
<ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" :label="$strings.LabelReleaseDate" />
<ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" :label="$strings.LabelReleaseDate" @input="handleInputChange" />
</div>
<div class="w-1/4 px-1">
<ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" />
<ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" @input="handleInputChange" />
</div>
<div class="w-1/4 px-1">
<ui-text-input-with-label ref="languageInput" v-model="details.language" :label="$strings.LabelLanguage" />
<ui-text-input-with-label ref="languageInput" v-model="details.language" :label="$strings.LabelLanguage" @input="handleInputChange" />
</div>
<div class="flex-grow px-1 pt-6">
<div class="flex justify-center">
<ui-checkbox v-model="details.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
<ui-checkbox v-model="details.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" @input="handleInputChange" />
</div>
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-1/4 px-1">
<ui-dropdown :label="$strings.LabelPodcastType" v-model="details.type" :items="podcastTypes" small class="max-w-52" />
<ui-dropdown :label="$strings.LabelPodcastType" v-model="details.type" :items="podcastTypes" small class="max-w-52" @input="handleInputChange" />
</div>
</div>
</form>
@ -105,6 +105,12 @@ export default {
}
},
methods: {
handleInputChange() {
this.$emit('change', {
libraryItemId: this.libraryItem.id,
hasChanges: this.checkForChanges().hasChanges
})
},
getDetails() {
this.forceBlur()
return this.checkForChanges()
@ -136,6 +142,8 @@ export default {
}
}
}
this.handleInputChange()
},
forceBlur() {
if (this.$refs.titleInput) this.$refs.titleInput.blur()

View file

@ -0,0 +1,83 @@
/**
* Mixin for keyboard navigation in dropdown menus.
* This can be used in any component that has a dropdown menu with <li> items.
* The following example shows how to use this mixin in your component:
* <template>
* <div>
* <input type="text" @keydown="menuNavigationHandler">
* <ul ref="menu">
* <li v-for="(item, index) in itemsToShow" :key="index" :class="isMenuItemSelected(item) ? ... : ''" @click="clickedOption($event, item)">
* {{ item }}
* </li>
* </ul>
* </div>
* </template>
*
* This mixin assumes the following are defined in your component:
* itemsToShow: Array of items to show in the dropdown
* clickedOption: Event handler for when an item is clicked
* submitForm: Event handler for when the form is submitted
*
* It also assumes you have a ref="menu" on the menu element.
*/
export default {
data() {
return {
selectedMenuItemIndex: null
}
},
methods: {
menuNavigationHandler(event) {
let items = this.itemsToShow
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
if (!items.length) return
if (event.key === 'ArrowDown') {
if (this.selectedMenuItemIndex === null) {
this.selectedMenuItemIndex = 0
} else {
this.selectedMenuItemIndex = Math.min(this.selectedMenuItemIndex + 1, items.length - 1)
}
} else if (event.key === 'ArrowUp') {
if (this.selectedMenuItemIndex === null) {
this.selectedMenuItemIndex = items.length - 1
} else {
this.selectedMenuItemIndex = Math.max(this.selectedMenuItemIndex - 1, 0)
}
}
this.recalcScroll()
} else if (event.key === 'Enter') {
event.preventDefault()
if (this.selectedMenuItemIndex !== null) {
this.clickedOption(event, items[this.selectedMenuItemIndex])
} else {
this.submitForm()
}
} else {
this.selectedMenuItemIndex = null
}
},
recalcScroll() {
const menu = this.$refs.menu
if (!menu) return
var menuItems = menu.querySelectorAll('li')
if (!menuItems.length) return
var selectedItem = menuItems[this.selectedMenuItemIndex]
if (!selectedItem) return
var menuHeight = menu.offsetHeight
var itemHeight = selectedItem.offsetHeight
var itemTop = selectedItem.offsetTop
var itemBottom = itemTop + itemHeight
if (itemBottom > menu.scrollTop + menuHeight) {
let menuPaddingBottom = parseFloat(window.getComputedStyle(menu).paddingBottom)
menu.scrollTop = itemBottom - menuHeight + menuPaddingBottom
} else if (itemTop < menu.scrollTop) {
let menuPaddingTop = parseFloat(window.getComputedStyle(menu).paddingTop)
menu.scrollTop = itemTop - menuPaddingTop
}
},
isMenuItemSelected(item) {
return this.selectedMenuItemIndex !== null && this.itemsToShow[this.selectedMenuItemIndex] === item
}
}
}

View file

@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
"version": "2.12.3",
"version": "2.13.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.12.3",
"version": "2.13.3",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",

View file

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.12.3",
"version": "2.13.3",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",

View file

@ -117,10 +117,10 @@ export default {
},
submitChangePassword() {
if (this.newPassword !== this.confirmPassword) {
return this.$toast.error('New password and confirm password do not match')
return this.$toast.error(this.$strings.ToastUserPasswordMismatch)
}
if (this.password === this.newPassword) {
return this.$toast.error('Password and New Password cannot be the same')
return this.$toast.error(this.$strings.ToastUserPasswordMustChange)
}
this.changingPassword = true
this.$axios
@ -130,16 +130,16 @@ export default {
})
.then((res) => {
if (res.success) {
this.$toast.success('Password Changed Successfully')
this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess)
this.resetForm()
} else {
this.$toast.error(res.error || 'Unknown Error')
this.$toast.error(res.error || this.$strings.ToastUnknownError)
}
this.changingPassword = false
})
.catch((error) => {
console.error(error)
this.$toast.error('Api call failed')
this.$toast.error(this.$strings.ToastUnknownError)
this.changingPassword = false
})
}
@ -148,4 +148,4 @@ export default {
this.selectedLanguage = this.$languageCodes.current
}
}
</script>
</script>

View file

@ -71,7 +71,7 @@
<div class="flex items-center">
<ui-tooltip :text="$strings.MessageRemoveChapter" direction="bottom">
<button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)">
<span class="material-symbols-outlined text-base">remove</span>
<span class="material-symbols text-base">remove</span>
</button>
</ui-tooltip>
@ -84,14 +84,14 @@
<ui-tooltip :text="selectedChapterId === chapter.id && isPlayingChapter ? $strings.MessagePauseChapter : $strings.MessagePlayChapter" direction="bottom">
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150" @click="playChapter(chapter)">
<widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" />
<span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-symbols-outlined text-base">pause</span>
<span v-else class="material-symbols-outlined text-base">play_arrow</span>
<span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-symbols text-base">pause</span>
<span v-else class="material-symbols text-base">play_arrow</span>
</button>
</ui-tooltip>
<ui-tooltip v-if="chapter.error" :text="chapter.error" direction="left">
<button class="w-7 h-7 rounded-full flex items-center justify-center text-error">
<span class="material-symbols-outlined text-lg">error_outline</span>
<span class="material-symbols text-lg">error_outline</span>
</button>
</ui-tooltip>
</div>
@ -106,7 +106,7 @@
<div class="flex-grow" />
<ui-btn small @click="setChaptersFromTracks">{{ $strings.ButtonSetChaptersFromTracks }}</ui-btn>
<ui-tooltip :text="$strings.MessageSetChaptersFromTracksDescription" direction="top" class="flex items-center mx-1 cursor-default">
<span class="material-symbols-outlined text-xl text-gray-200">info</span>
<span class="material-symbols text-xl text-gray-200">info</span>
</ui-tooltip>
</div>
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
@ -189,7 +189,7 @@
<div class="flex items-center pt-2">
<ui-btn small color="primary" class="mr-1" @click="applyChapterNamesOnly">{{ $strings.ButtonMapChapterTitles }}</ui-btn>
<ui-tooltip :text="$strings.MessageMapChapterTitles" direction="top" class="flex items-center">
<span class="material-symbols-outlined text-xl text-gray-200">info</span>
<span class="material-symbols text-xl text-gray-200">info</span>
</ui-tooltip>
<div class="flex-grow" />
<ui-btn small color="success" @click="applyChapterData">{{ $strings.ButtonApplyChapters }}</ui-btn>
@ -560,7 +560,7 @@ export default {
.catch((error) => {
this.findingChapters = false
console.error('Failed to get chapter data', error)
this.$toast.error('Failed to find chapters')
this.$toast.error(this.$strings.ToastFailedToLoadData)
this.showFindChaptersModal = false
})
},
@ -611,7 +611,7 @@ export default {
.$post(`/api/items/${this.libraryItem.id}/chapters`, payload)
.then((data) => {
if (data.updated) {
this.$toast.success('Chapters removed')
this.$toast.success(this.$strings.ToastChaptersRemoved)
if (this.previousRoute) {
this.$router.push(this.previousRoute)
} else {
@ -623,7 +623,7 @@ export default {
})
.catch((error) => {
console.error('Failed to remove chapters', error)
this.$toast.error('Failed to remove chapters')
this.$toast.error(this.$strings.ToastRemoveFailed)
})
.finally(() => {
this.saving = false

View file

@ -331,11 +331,11 @@ export default {
this.$axios
.$delete(`/api/tools/item/${this.libraryItemId}/encode-m4b`)
.then(() => {
this.$toast.success('Encode canceled')
this.$toast.success(this.$strings.ToastEncodeCancelSucces)
})
.catch((error) => {
console.error('Failed to cancel encode', error)
this.$toast.error('Failed to cancel encode')
this.$toast.error(this.$strings.ToastEncodeCancelFailed)
})
.finally(() => {
this.isCancelingEncode = false

View file

@ -97,8 +97,8 @@
<div class="flex justify-center flex-wrap">
<template v-for="libraryItem in libraryItemCopies">
<div :key="libraryItem.id" class="w-full max-w-3xl border border-black-300 p-6 -ml-px -mt-px">
<widgets-book-details-edit v-if="libraryItem.mediaType === 'book'" :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" />
<widgets-podcast-details-edit v-else :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" />
<widgets-book-details-edit v-if="libraryItem.mediaType === 'book'" :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" @change="handleItemChange" />
<widgets-podcast-details-edit v-else :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" @change="handleItemChange" />
</div>
</template>
</div>
@ -108,7 +108,7 @@
<div :class="isScrollable ? 'fixed left-0 box-shadow-lg-up bg-primary' : ''" class="w-full h-20 px-4 flex items-center border-t border-bg z-40" :style="{ bottom: streamLibraryItem ? '165px' : '0px' }">
<div class="flex-grow" />
<ui-btn color="success" :padding-x="8" class="text-lg" :loading="isProcessing" @click.prevent="saveClick">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn color="success" :padding-x="8" class="text-lg" :loading="isProcessing" :disabled="!hasChanges" @click.prevent="saveClick">{{ $strings.ButtonSave }}</ui-btn>
</div>
</div>
</template>
@ -170,7 +170,8 @@ export default {
abridged: false
},
appendableKeys: ['authors', 'genres', 'tags', 'narrators', 'series'],
openMapOptions: false
openMapOptions: false,
itemsWithChanges: []
}
},
computed: {
@ -221,9 +222,19 @@ export default {
},
hasSelectedBatchUsage() {
return Object.values(this.selectedBatchUsage).some((b) => !!b)
},
hasChanges() {
return this.itemsWithChanges.length > 0
}
},
methods: {
handleItemChange(itemChange) {
if (!itemChange.hasChanges) {
this.itemsWithChanges = this.itemsWithChanges.filter((id) => id !== itemChange.libraryItemId)
} else if (!this.itemsWithChanges.includes(itemChange.libraryItemId)) {
this.itemsWithChanges.push(itemChange.libraryItemId)
}
},
blurBatchForm() {
if (this.$refs.seriesSelect && this.$refs.seriesSelect.isFocused) {
this.$refs.seriesSelect.forceBlur()
@ -283,38 +294,10 @@ export default {
removedSeriesItem(item) {},
newNarratorItem(item) {},
removedNarratorItem(item) {},
newTagItem(item) {
// if (item && !this.newTagItems.includes(item)) {
// this.newTagItems.push(item)
// }
},
removedTagItem(item) {
// If newly added, remove if not used on any other items
// if (item && this.newTagItems.includes(item)) {
// var usedByOtherAb = this.libraryItemCopies.find((ab) => {
// return ab.tags && ab.tags.includes(item)
// })
// if (!usedByOtherAb) {
// this.newTagItems = this.newTagItems.filter((t) => t !== item)
// }
// }
},
newGenreItem(item) {
// if (item && !this.newGenreItems.includes(item)) {
// this.newGenreItems.push(item)
// }
},
removedGenreItem(item) {
// If newly added, remove if not used on any other items
// if (item && this.newGenreItems.includes(item)) {
// var usedByOtherAb = this.libraryItemCopies.find((ab) => {
// return ab.book.genres && ab.book.genres.includes(item)
// })
// if (!usedByOtherAb) {
// this.newGenreItems = this.newGenreItems.filter((t) => t !== item)
// }
// }
},
newTagItem(item) {},
removedTagItem(item) {},
newGenreItem(item) {},
removedGenreItem(item) {},
init() {
// TODO: Better deep cloning of library items
this.libraryItemCopies = this.libraryItems.map((li) => {
@ -366,7 +349,7 @@ export default {
}
}
if (!updates.length) {
return this.$toast.warning('No updates were made')
return this.$toast.warning(this.$strings.ToastNoUpdatesNecessary)
}
console.log('Pushing updates', updates)
@ -376,6 +359,7 @@ export default {
.then((data) => {
this.isProcessing = false
if (data.updates) {
this.itemsWithChanges = []
this.$toast.success(`Successfully updated ${data.updates} items`)
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
} else {
@ -387,10 +371,28 @@ export default {
this.$toast.error('Failed to batch update')
this.isProcessing = false
})
},
beforeUnload(e) {
if (!e || !this.hasChanges) return
e.preventDefault()
e.returnValue = ''
}
},
beforeRouteLeave(to, from, next) {
if (this.hasChanges) {
next(false)
window.location = to.path
} else {
next()
}
},
mounted() {
this.init()
window.addEventListener('beforeunload', this.beforeUnload)
},
beforeDestroy() {
window.removeEventListener('beforeunload', this.beforeUnload)
}
}
</script>
@ -406,4 +408,4 @@ export default {
transform: translateY(-100%);
transition: all 150ms ease-in 0s;
}
</style>
</style>

View file

@ -3,7 +3,7 @@
<app-settings-content :header-text="$strings.HeaderBackups" :description="$strings.MessageBackupsDescription">
<div v-if="backupLocation" class="mb-4 max-w-full overflow-hidden">
<div class="flex items-center mb-0.5">
<span class="material-symbols-outlined text-2xl text-black-50 mr-2">folder</span>
<span class="material-symbols text-2xl text-black-50 mr-2">folder</span>
<span class="text-white text-opacity-60 uppercase text-sm whitespace-nowrap">{{ $strings.LabelBackupLocation }}:</span>
</div>
<div v-if="!showEditBackupPath" class="inline-flex items-center w-full overflow-hidden">
@ -33,7 +33,7 @@
<div v-if="enableBackups" class="mb-6">
<div class="flex items-center pl-0 sm:pl-6 mb-2">
<span class="material-symbols-outlined text-xl sm:text-2xl text-black-50 mr-2">schedule</span>
<span class="material-symbols text-xl sm:text-2xl text-black-50 mr-2">schedule</span>
<div class="w-32 min-w-32 sm:w-40 sm:min-w-40">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.HeaderSchedule }}:</span>
</div>
@ -44,7 +44,7 @@
</div>
<div v-if="nextBackupDate" class="flex items-center pl-0 sm:pl-6 py-0.5">
<span class="material-symbols-outlined text-xl sm:text-2xl text-black-50 mr-2">event</span>
<span class="material-symbols text-xl sm:text-2xl text-black-50 mr-2">event</span>
<div class="w-32 min-w-32 sm:w-40 sm:min-w-40">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNextBackupDate }}:</span>
</div>
@ -162,7 +162,7 @@ export default {
})
.catch((error) => {
console.error('Failed to save backup path', error)
const errorMsg = error.response?.data || 'Failed to save backup path'
const errorMsg = error.response?.data || this.$strings.ToastBackupPathUpdateFailed
this.$toast.error(errorMsg)
})
.finally(() => {
@ -171,11 +171,11 @@ export default {
},
updateBackupsSettings() {
if (isNaN(this.maxBackupSize) || this.maxBackupSize < 0) {
this.$toast.error('Invalid maximum backup size')
this.$toast.error(this.$strings.ToastBackupInvalidMaxSize)
return
}
if (isNaN(this.backupsToKeep) || this.backupsToKeep <= 0 || this.backupsToKeep > 99) {
this.$toast.error('Invalid number of backups to keep')
this.$toast.error(this.$strings.ToastBackupInvalidMaxKeep)
return
}
const updatePayload = {

View file

@ -109,7 +109,7 @@
</tr>
</table>
<div v-else-if="!loading" class="text-center py-4">
<p class="text-lg text-gray-100">No Devices</p>
<p class="text-lg text-gray-100">{{ $strings.MessageNoDevices }}</p>
</div>
</app-settings-content>
@ -199,7 +199,7 @@ export default {
},
deleteDeviceClick(device) {
const payload = {
message: `Are you sure you want to delete e-reader device "${device.name}"?`,
message: this.$getString('MessageConfirmDeleteDevice', [device.name]),
callback: (confirmed) => {
if (confirmed) {
this.deleteDevice(device)
@ -218,11 +218,10 @@ export default {
.$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')
this.$toast.error(this.$strings.ToastRemoveFailed)
})
.finally(() => {
this.deletingDeviceName = null
@ -246,11 +245,11 @@ export default {
this.$axios
.$post('/api/emails/test')
.then(() => {
this.$toast.success('Test Email Sent')
this.$toast.success(this.$strings.ToastDeviceTestEmailSuccess)
})
.catch((error) => {
console.error('Failed to send test email', error)
const errorMsg = error.response.data || 'Failed to send test email'
const errorMsg = error.response.data || this.$strings.ToastDeviceTestEmailFailed
this.$toast.error(errorMsg)
})
.finally(() => {
@ -289,11 +288,11 @@ export default {
this.newSettings = {
...data.settings
}
this.$toast.success('Email settings updated')
this.$toast.success(this.$strings.ToastEmailSettingsUpdateSuccess)
})
.catch((error) => {
console.error('Failed to update email settings', error)
this.$toast.error('Failed to update email settings')
this.$toast.error(this.$strings.ToastEmailSettingsUpdateFailed)
})
.finally(() => {
this.savingSettings = false

View file

@ -130,7 +130,7 @@ export default {
})
.catch((error) => {
console.error('Failed to rename genre', error)
this.$toast.error('Failed to rename genre')
this.$toast.error(this.$strings.ToastRenameFailed)
})
.finally(() => {
this.loading = false
@ -147,7 +147,7 @@ export default {
})
.catch((error) => {
console.error('Failed to remove genre', error)
this.$toast.error('Failed to remove genre')
this.$toast.error(this.$strings.ToastRemoveFailed)
})
.finally(() => {
this.loading = false

View file

@ -126,7 +126,7 @@ export default {
})
.catch((error) => {
console.error('Failed to rename tag', error)
this.$toast.error('Failed to rename tag')
this.$toast.error(this.$strings.ToastRenameFailed)
})
.finally(() => {
this.loading = false
@ -143,7 +143,7 @@ export default {
})
.catch((error) => {
console.error('Failed to remove tag', error)
this.$toast.error('Failed to remove tag')
this.$toast.error(this.$strings.ToastRemoveFailed)
})
.finally(() => {
this.loading = false

View file

@ -105,12 +105,12 @@ export default {
}
if (isNaN(this.maxNotificationQueue) || this.maxNotificationQueue <= 0) {
this.$toast.error('Max notification queue must be >= 0')
this.$toast.error(this.$strings.ToastNotificationQueueMaximum)
return false
}
if (isNaN(this.maxFailedAttempts) || this.maxFailedAttempts <= 0) {
this.$toast.error('Max failed attempts must be >= 0')
this.$toast.error(this.$strings.ToastNotificationFailedMaximum)
return false
}
@ -128,11 +128,11 @@ export default {
this.$axios
.$patch('/api/notifications', updatePayload)
.then(() => {
this.$toast.success('Notification settings updated')
this.$toast.success(this.$strings.ToastNotificationSettingsUpdateSuccess)
})
.catch((error) => {
console.error('Failed to update notification settings', error)
this.$toast.error('Failed to update notification settings')
this.$toast.error(this.$strings.ToastNotificationSettingsUpdateFailed)
})
.finally(() => {
this.savingSettings = false

View file

@ -290,7 +290,6 @@ export default {
this.$axios
.$post(`/api/sessions/batch/delete`, payload)
.then(() => {
this.$toast.success('Sessions removed')
if (isAllSessions) {
// If all sessions were removed from the current page then go to the previous page
if (this.currentPage > 0) {
@ -303,7 +302,7 @@ export default {
}
})
.catch((error) => {
const errorMsg = error.response?.data || 'Failed to remove sessions'
const errorMsg = error.response?.data || this.$strings.ToastRemoveFailed
this.$toast.error(errorMsg)
})
.finally(() => {
@ -358,12 +357,13 @@ export default {
})
if (!libraryItem) {
this.$toast.error('Failed to get library item')
this.$toast.error(this.$strings.ToastFailedToLoadData)
this.processingGoToTimestamp = false
return
}
if (session.episodeId && !libraryItem.media.episodes.some((ep) => ep.id === session.episodeId)) {
this.$toast.error('Failed to get podcast episode')
console.error('Episode not found in library item', session.episodeId, libraryItem.media.episodes)
this.$toast.error(this.$strings.ToastFailedToLoadData)
this.processingGoToTimestamp = false
return
}
@ -377,7 +377,7 @@ export default {
episodeId: episode.id,
title: episode.title,
subtitle: libraryItem.media.metadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.audioFile.duration || null,
coverPath: libraryItem.media.coverPath || null
}

View file

@ -20,7 +20,7 @@
<div class="flex p-2">
<div class="hidden sm:block">
<span class="hidden sm:block material-symbols-outlined text-5xl lg:text-6xl">event</span>
<span class="hidden sm:block material-symbols text-5xl lg:text-6xl">event</span>
</div>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ $formatNumber(totalDaysListened) }}</p>
@ -30,7 +30,7 @@
<div class="flex p-2">
<div class="hidden sm:block">
<span class="material-symbols-outlined text-5xl lg:text-6xl">watch_later</span>
<span class="material-symbols text-5xl lg:text-6xl">watch_later</span>
</div>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ $formatNumber(totalMinutesListening) }}</p>

View file

@ -127,12 +127,13 @@ export default {
})
if (!libraryItem) {
this.$toast.error('Failed to get library item')
this.$toast.error(this.$strings.ToastFailedToLoadData)
this.processingGoToTimestamp = false
return
}
if (session.episodeId && !libraryItem.media.episodes.some((ep) => ep.id === session.episodeId)) {
this.$toast.error('Failed to get podcast episode')
console.error('Episode not found in library item', session.episodeId, libraryItem.media.episodes)
this.$toast.error(this.$strings.ToastFailedToLoadData)
this.processingGoToTimestamp = false
return
}
@ -146,7 +147,7 @@ export default {
episodeId: episode.id,
title: episode.title,
subtitle: libraryItem.media.metadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.audioFile.duration || null,
coverPath: libraryItem.media.coverPath || null
}

View file

@ -39,16 +39,11 @@
><span :key="index" v-if="index < seriesList.length - 1">, </span>
</template>
<template v-if="!isVideo">
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">{{ $getString('LabelByAuthor', [podcastAuthor]) }}</p>
<p v-else-if="musicArtists.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
<nuxt-link v-for="(artist, index) in musicArtists" :key="index" :to="`/artist/${$encode(artist)}`" class="hover:underline">{{ artist }}<span v-if="index < musicArtists.length - 1">,&nbsp;</span></nuxt-link>
</p>
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
by <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>
</p>
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
</template>
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">{{ $getString('LabelByAuthor', [podcastAuthor]) }}</p>
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
by <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>
</p>
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
<content-library-item-details :library-item="libraryItem" />
</div>
@ -80,14 +75,14 @@
<p class="text-gray-400 text-xs pt-1">{{ $strings.LabelStarted }} {{ $formatDate(userProgressStartedAt, dateFormat) }}</p>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
<span class="material-symbols text-sm">close</span>
<span class="material-symbols text-sm">&#xe5cd;</span>
</div>
</div>
<!-- Icon buttons -->
<div class="flex items-center justify-center md:justify-start pt-4">
<ui-btn v-if="showPlayButton" :disabled="isStreaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playItem">
<span aria-hidden="true" v-show="!isStreaming" class="material-symbols fill text-2xl -ml-2 pr-1 text-white">play_arrow</span>
<span aria-hidden="true" v-show="!isStreaming" class="material-symbols fill text-2xl -ml-2 pr-1 text-white">&#xe037;</span>
{{ isStreaming ? $strings.ButtonPlaying : $strings.ButtonPlay }}
</ui-btn>
@ -106,10 +101,10 @@
</ui-btn>
<ui-tooltip v-if="userCanUpdate" :text="$strings.LabelEdit" direction="top">
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
<ui-icon-btn icon="&#xe3c9;" outlined class="mx-0.5" @click="editClick" />
</ui-tooltip>
<ui-tooltip v-if="!isPodcast && !isMusic" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
<ui-tooltip v-if="!isPodcast" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
</ui-tooltip>
@ -121,7 +116,7 @@
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
<template #default="{ showMenu, clickShowMenu, disabled }">
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-symbols text-2xl">more_horiz</span>
<span class="material-symbols text-2xl">&#xe5d3;</span>
</button>
</template>
</ui-context-menu-dropdown>
@ -129,9 +124,7 @@
<div class="my-4 w-full">
<p ref="description" id="item-description" dir="auto" class="text-base text-gray-100 whitespace-pre-line mb-1" :class="{ 'show-full': showFullDescription }">{{ description }}</p>
<button v-if="isDescriptionClamped" class="py-0.5 flex items-center text-slate-300 hover:text-white" @click="showFullDescription = !showFullDescription">
{{ showFullDescription ? $strings.ButtonReadLess : $strings.ButtonReadMore }} <span class="material-symbols text-xl pl-1">{{ showFullDescription ? 'expand_less' : 'expand_more' }}</span>
</button>
<button v-if="isDescriptionClamped" class="py-0.5 flex items-center text-slate-300 hover:text-white" @click="showFullDescription = !showFullDescription">{{ showFullDescription ? $strings.ButtonReadLess : $strings.ButtonReadMore }} <span class="material-symbols text-xl pl-1" v-html="showFullDescription ? 'expand_less' : '&#xe313;'" /></button>
</div>
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />
@ -222,12 +215,6 @@ export default {
isPodcast() {
return this.libraryItem.mediaType === 'podcast'
},
isVideo() {
return this.libraryItem.mediaType === 'video'
},
isMusic() {
return this.libraryItem.mediaType === 'music'
},
isMissing() {
return this.libraryItem.isMissing
},
@ -242,8 +229,6 @@ export default {
},
showPlayButton() {
if (this.isMissing || this.isInvalid) return false
if (this.isMusic) return !!this.audioFile
if (this.isVideo) return !!this.videoFile
if (this.isPodcast) return this.podcastEpisodes.length
return this.tracks.length
},
@ -294,9 +279,6 @@ export default {
authors() {
return this.mediaMetadata.authors || []
},
musicArtists() {
return this.mediaMetadata.artists || []
},
series() {
return this.mediaMetadata.series || []
},
@ -311,7 +293,7 @@ export default {
})
},
duration() {
if (!this.tracks.length && !this.audioFile) return 0
if (!this.tracks.length) return 0
return this.media.duration
},
libraryFiles() {
@ -323,18 +305,10 @@ export default {
ebookFile() {
return this.media.ebookFile
},
videoFile() {
return this.media.videoFile
},
audioFile() {
// Music track
return this.media.audioFile
},
description() {
return this.mediaMetadata.description || ''
},
userMediaProgress() {
if (this.isMusic) return null
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
userIsFinished() {
@ -486,23 +460,23 @@ export default {
this.$axios
.$get(`/api/podcasts/${this.libraryItemId}/clear-queue`)
.then(() => {
this.$toast.success('Episode download queue cleared')
this.$toast.success(this.$strings.ToastEpisodeDownloadQueueClearSuccess)
this.episodeDownloadQueued = []
})
.catch((error) => {
console.error('Failed to clear queue', error)
this.$toast.error('Failed to clear queue')
this.$toast.error(this.$strings.ToastEpisodeDownloadQueueClearFailed)
})
}
},
async findEpisodesClick() {
if (!this.mediaMetadata.feedUrl) {
return this.$toast.error('Podcast does not have an RSS Feed')
return this.$toast.error(this.$strings.ToastNoRSSFeed)
}
this.fetchingRSSFeed = true
var payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: this.mediaMetadata.feedUrl }).catch((error) => {
console.error('Failed to get feed', error)
this.$toast.error('Failed to get podcast feed')
this.$toast.error(this.$strings.ToastPodcastGetFeedFailed)
return null
})
this.fetchingRSSFeed = false
@ -511,7 +485,7 @@ export default {
console.log('Podcast feed', payload)
const podcastfeed = payload.podcast
if (!podcastfeed.episodes || !podcastfeed.episodes.length) {
this.$toast.info('No episodes found in RSS feed')
this.$toast.info(this.$strings.ToastPodcastNoEpisodesInFeed)
return
}
@ -580,7 +554,7 @@ export default {
episodeId: episode.id,
title: episode.title,
subtitle: this.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.audioFile.duration || null,
coverPath: this.libraryItem.media.coverPath || null
})
@ -624,13 +598,12 @@ export default {
},
clearProgressClick() {
if (!this.userMediaProgress) return
if (confirm(`Are you sure you want to reset your progress?`)) {
if (confirm(this.$strings.MessageConfirmResetProgress)) {
this.resettingProgress = true
this.$axios
.$delete(`/api/me/progress/${this.userMediaProgress.id}`)
.then(() => {
console.log('Progress reset complete')
this.$toast.success(`Your progress was reset`)
this.resettingProgress = false
})
.catch((error) => {
@ -724,12 +697,12 @@ export default {
this.$axios
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
.then(() => {
this.$toast.success('Item deleted')
this.$toast.success(this.$strings.ToastItemDeletedSuccess)
this.$router.replace(`/library/${this.libraryId}`)
})
.catch((error) => {
console.error('Failed to delete item', error)
this.$toast.error('Failed to delete item')
this.$toast.error(this.$strings.ToastItemDeleteFailed)
})
}
},

View file

@ -61,6 +61,8 @@ export default {
const bDesc = this.authorSortDesc ? -1 : 1
return this.authors.sort((a, b) => {
if (typeof a[sortProp] === 'number' && typeof b[sortProp] === 'number') {
// Fallback to name sort if equal
if (a[sortProp] === b[sortProp]) return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) * bDesc
return a[sortProp] > b[sortProp] ? bDesc : -bDesc
}
return a[sortProp]?.localeCompare(b[sortProp], undefined, { sensitivity: 'base' }) * bDesc

View file

@ -138,7 +138,7 @@ export default {
})
.catch((error) => {
console.error('Failed to remove narrator', error)
this.$toast.error('Failed to remove narrator')
this.$toast.error(this.$strings.ToastRemoveFailed)
this.loading = false
})
},
@ -158,4 +158,4 @@ export default {
},
beforeDestroy() {}
}
</script>
</script>

View file

@ -111,7 +111,7 @@ export default {
this.processing = true
const queuePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/episode-downloads`).catch((error) => {
console.error('Failed to get download queue', error)
this.$toast.error('Failed to get download queue')
this.$toast.error(this.$strings.ToastFailedToLoadData)
return null
})
this.processing = false

View file

@ -48,7 +48,7 @@
<p dir="auto" class="text-sm text-gray-200 mb-4 line-clamp-4" v-html="episode.subtitle || episode.description" />
<div class="flex items-center">
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="episode.progress && episode.progress.isFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick(episode)">
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="episode.progress?.isFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick(episode)">
<span v-if="episodeIdStreaming === episode.id" class="material-symbols text-2xl" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<span v-else class="material-symbols fill text-2xl text-success">play_arrow</span>
<p class="pl-2 pr-1 text-sm font-semibold">{{ getButtonText(episode) }}</p>
@ -56,9 +56,10 @@
<ui-tooltip v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" :text="playerQueueEpisodeIdMap[episode.id] ? $strings.MessageRemoveFromPlayerQueue : $strings.MessageAddToPlayerQueue" :class="playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''" direction="top">
<ui-icon-btn :icon="playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_play'" borderless @click="queueBtnClick(episode)" />
<!-- <button class="h-8 w-8 flex justify-center items-center mx-2" :class="playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''" @click.stop="queueBtnClick(episode)">
<span class="material-symbols-outlined text-2xl">{{ playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_add' }}</span>
</button> -->
</ui-tooltip>
<ui-tooltip :text="!!episode.progress?.isFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
<ui-read-icon-btn :disabled="episodesProcessingMap[episode.id]" :is-read="!!episode.progress?.isFinished" borderless class="mx-1 mt-0.5" @click="toggleEpisodeFinished(episode)" />
</ui-tooltip>
<ui-tooltip :text="$strings.LabelYourPlaylists" direction="top">
@ -98,6 +99,7 @@ export default {
data() {
return {
recentEpisodes: [],
episodesProcessingMap: {},
totalEpisodes: 0,
currentPage: 0,
processing: false,
@ -143,6 +145,44 @@ export default {
}
},
methods: {
async toggleEpisodeFinished(episode, confirmed = false) {
if (this.episodesProcessingMap[episode.id]) {
console.warn('Episode is already processing')
return
}
const isFinished = !!episode.progress?.isFinished
const itemProgressPercent = episode.progress?.progress || 0
if (!isFinished && itemProgressPercent > 0 && !confirmed) {
const payload = {
message: `Are you sure you want to mark "${episode.title}" as finished?`,
callback: (confirmed) => {
if (confirmed) {
this.toggleEpisodeFinished(episode, true)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
return
}
const updatePayload = {
isFinished: !isFinished
}
this.$set(this.episodesProcessingMap, episode.id, true)
this.$axios
.$patch(`/api/me/progress/${episode.libraryItemId}/${episode.id}`, updatePayload)
.catch((error) => {
console.error('Failed to update progress', error)
this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
})
.finally(() => {
this.$set(this.episodesProcessingMap, episode.id, false)
})
},
clickAddToPlaylist(episode) {
// Makeshift libraryItem
const libraryItem = {
@ -194,7 +234,7 @@ export default {
episodeId: episode.id,
title: episode.title,
subtitle: episode.podcast.metadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.duration || null,
coverPath: episode.podcast.coverPath || null
})
@ -211,11 +251,10 @@ export default {
this.processing = true
const episodePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/recent-episodes?limit=25&page=${page}`).catch((error) => {
console.error('Failed to get recent episodes', error)
this.$toast.error('Failed to get recent episodes')
this.$toast.error(this.$strings.ToastFailedToLoadData)
return null
})
this.processing = false
console.log('Episodes', episodePayload)
this.recentEpisodes = episodePayload.episodes || []
this.totalEpisodes = episodePayload.total
this.currentPage = page
@ -232,7 +271,7 @@ export default {
episodeId: episode.id,
title: episode.title,
subtitle: episode.podcast.metadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.duration || null,
coverPath: episode.podcast.coverPath || null
}

View file

@ -146,7 +146,7 @@ export default {
this.processing = true
var payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed }).catch((error) => {
console.error('Failed to get feed', error)
this.$toast.error('Failed to get podcast feed')
this.$toast.error(this.$strings.ToastPodcastGetFeedFailed)
return null
})
this.processing = false
@ -197,7 +197,7 @@ export default {
this.processing = true
const payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: podcast.feedUrl }).catch((error) => {
console.error('Failed to get feed', error)
this.$toast.error('Failed to get podcast feed')
this.$toast.error(this.$strings.ToastPodcastGetFeedFailed)
return null
})
this.processing = false

View file

@ -31,7 +31,7 @@
<div :key="author.id" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm text-white text-opacity-70 w-36 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}</nuxt-link>
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}</nuxt-link>
</p>
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * author.count) / mostUsedAuthorCount) + '%' }" />
@ -75,7 +75,7 @@
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.size) / largestItemSize) + '%' }" />
</div>
<div class="w-4 ml-3">
<p class="text-sm font-bold">{{ $bytesPretty(ab.size) }}</p>
<p class="text-sm font-bold whitespace-nowrap">{{ $bytesPretty(ab.size) }}</p>
</div>
</div>
</div>

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