Merge branch 'master' into book-preview-on-hover

This commit is contained in:
Greg Lorenzen 2024-11-25 17:23:32 -08:00 committed by GitHub
commit 9e85525c5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
355 changed files with 21974 additions and 14135 deletions

View file

@ -6,5 +6,5 @@ module.exports.config = {
MetadataPath: Path.resolve('metadata'), MetadataPath: Path.resolve('metadata'),
FFmpegPath: '/usr/bin/ffmpeg', FFmpegPath: '/usr/bin/ffmpeg',
FFProbePath: '/usr/bin/ffprobe', FFProbePath: '/usr/bin/ffprobe',
SkipBinariesCheck: true SkipBinariesCheck: false
} }

33
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,33 @@
<!--
For Work In Progress Pull Requests, please use the Draft PR feature,
see https://github.blog/2019-02-14-introducing-draft-pull-requests/ for further details.
If you do not follow this template, the PR may be closed without review.
Please ensure all checks pass.
If you are a new contributor, the workflows will need to be manually approved before they run.
-->
## Brief summary
<!-- Please provide a brief summary of what your PR attempts to achieve. -->
## Which issue is fixed?
<!-- Which issue number does this PR fix? Ex: "Fixes #1234" -->
## In-depth Description
<!--
Describe your solution in more depth.
How does it work? Why is this the best solution?
Does it solve a problem that affects multiple users or is this an edge case for your setup?
-->
## How have you tested this?
<!-- Please describe in detail with reproducible steps how you tested your changes. -->
## Screenshots
<!-- If your PR includes any changes to the web client, please include screenshots or a short video from before and after your changes. -->

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

@ -0,0 +1,20 @@
name: Close fixed issues on release.
on:
release:
types: [published]
permissions:
contents: read
issues: write
jobs:
comment:
runs-on: ubuntu-latest
steps:
- name: Close issues marked as fixed upon a release.
uses: gcampbell-msft/fixed-pending-release@7fa1b75a0c04bcd4b375110522878e5f6100cff5
with:
label: 'awaiting release'
removeLabel: true
applyToAll: true
message: Fixed in [${releaseTag}](${releaseUrl}).

View file

@ -1,11 +1,25 @@
name: "CodeQL" name: 'CodeQL'
on: on:
push: push:
branches: ['master'] branches: ['master']
# Only build when files in these directories have been changed
paths:
- client/**
- server/**
- test/**
- index.js
- package.json
pull_request: pull_request:
# The branches below must be a subset of the branches above # The branches below must be a subset of the branches above
branches: ['master'] branches: ['master']
# Only build when files in these directories have been changed
paths:
- client/**
- server/**
- test/**
- index.js
- package.json
schedule: schedule:
- cron: '16 5 * * 4' - cron: '16 5 * * 4'
@ -43,7 +57,6 @@ jobs:
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality # queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
@ -62,4 +75,4 @@ jobs:
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2 uses: github/codeql-action/analyze@v2
with: with:
category: "/language:${{matrix.language}}" category: '/language:${{matrix.language}}'

View file

@ -70,6 +70,7 @@ jobs:
uses: docker/build-push-action@v3 uses: docker/build-push-action@v3
with: with:
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }} tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true

View file

@ -5,6 +5,13 @@ on:
push: push:
branches-ignore: branches-ignore:
- 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests - 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests
# Only build when files in these directories have been changed
paths:
- client/**
- server/**
- test/**
- index.js
- package.json
jobs: jobs:
build: build:

View file

@ -1,13 +1,15 @@
name: API linting 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: on:
pull_request:
push: push:
paths: paths:
- docs/ - 'docs/**'
pull_request:
paths: # This action only needs read permissions
- docs/ permissions:
contents: read
jobs: jobs:
build: build:

1
.gitignore vendored
View file

@ -16,6 +16,7 @@
/ffmpeg* /ffmpeg*
/ffprobe* /ffprobe*
/unicode* /unicode*
/libnusqlite3*
sw.* sw.*
.DS_STORE .DS_STORE

View file

@ -18,12 +18,28 @@ RUN apk update && \
make \ make \
python3 \ python3 \
g++ \ g++ \
tini tini \
unzip
COPY --from=build /client/dist /client/dist COPY --from=build /client/dist /client/dist
COPY index.js package* / COPY index.js package* /
COPY server server COPY server server
ARG TARGETPLATFORM
ENV NUSQLITE3_DIR="/usr/local/lib/nusqlite3"
ENV NUSQLITE3_PATH="${NUSQLITE3_DIR}/libnusqlite3.so"
RUN case "$TARGETPLATFORM" in \
"linux/amd64") \
curl -L -o /tmp/library.zip "https://github.com/mikiher/nunicode-sqlite/releases/download/v1.2/libnusqlite3-linux-musl-x64.zip" ;; \
"linux/arm64") \
curl -L -o /tmp/library.zip "https://github.com/mikiher/nunicode-sqlite/releases/download/v1.2/libnusqlite3-linux-musl-arm64.zip" ;; \
*) echo "Unsupported platform: $TARGETPLATFORM" && exit 1 ;; \
esac && \
unzip /tmp/library.zip -d $NUSQLITE3_DIR && \
rm /tmp/library.zip
RUN npm ci --only=production RUN npm ci --only=production
RUN apk del make python3 g++ RUN apk del make python3 g++

View file

@ -2,14 +2,7 @@
font-family: 'Material Symbols Rounded'; font-family: 'Material Symbols Rounded';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
src: url(~static/fonts/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].woff2) format('woff2'); src: url(~static/fonts/MaterialSymbolsRounded.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');
} }
.material-symbols { .material-symbols {
@ -32,26 +25,6 @@
'FILL' 1 '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 */ /* cyrillic-ext */
@font-face { @font-face {
font-family: 'Source Sans Pro'; font-family: 'Source Sans Pro';

View file

@ -16,7 +16,7 @@
<div class="flex-grow" /> <div class="flex-grow" />
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center"> <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> </ui-tooltip>
<div v-if="isChromecastInitialized" class="w-6 min-w-6 h-6 ml-2 mr-1 sm:mx-2 cursor-pointer"> <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> <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"> <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"> <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> </ui-tooltip>
</nuxt-link> </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"> <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"> <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> </ui-tooltip>
</nuxt-link> </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"> <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"> <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> </ui-tooltip>
</nuxt-link> </nuxt-link>
@ -47,7 +47,7 @@
<span class="block truncate">{{ username }}</span> <span class="block truncate">{{ username }}</span>
</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="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> </span>
</nuxt-link> </nuxt-link>
</div> </div>
@ -264,7 +264,6 @@ export default {
libraryItems.forEach((item) => { libraryItems.forEach((item) => {
let subtitle = '' let subtitle = ''
if (item.mediaType === 'book') subtitle = item.media.metadata.authors.map((au) => au.name).join(', ') 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({ queueItems.push({
libraryItemId: item.id, libraryItemId: item.id,
libraryId: item.libraryId, libraryId: item.libraryId,
@ -332,13 +331,13 @@ export default {
libraryItemIds: this.selectedMediaItems.map((i) => i.id) libraryItemIds: this.selectedMediaItems.map((i) => i.id)
}) })
.then(() => { .then(() => {
this.$toast.success('Batch delete success') this.$toast.success(this.$strings.ToastBatchDeleteSuccess)
this.$store.commit('globals/resetSelectedMediaItems', []) this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection') this.$eventBus.$emit('bookshelf_clear_selection')
}) })
.catch((error) => { .catch((error) => {
console.error('Batch delete failed', error) console.error('Batch delete failed', error)
this.$toast.error('Batch delete failed') this.$toast.error(this.$strings.ToastBatchDeleteFailed)
}) })
.finally(() => { .finally(() => {
this.$store.commit('setProcessingBatch', false) this.$store.commit('setProcessingBatch', false)

View file

@ -347,6 +347,13 @@ export default {
libraryItemsAdded(libraryItems) { libraryItemsAdded(libraryItems) {
console.log('libraryItems added', libraryItems) console.log('libraryItems added', libraryItems)
// First items added to library
const isThisLibrary = libraryItems.some((li) => li.libraryId === this.currentLibraryId)
if (!this.shelves.length && !this.search && isThisLibrary) {
this.fetchCategories()
return
}
const recentlyAddedShelf = this.shelves.find((shelf) => shelf.id === 'recently-added') const recentlyAddedShelf = this.shelves.find((shelf) => shelf.id === 'recently-added')
if (!recentlyAddedShelf) return if (!recentlyAddedShelf) return

View file

@ -24,7 +24,7 @@
</div> </div>
<div v-if="shelf.type === 'authors'" class="flex items-center"> <div v-if="shelf.type === 'authors'" class="flex items-center">
<template v-for="entity in shelf.entities"> <template v-for="entity in shelf.entities">
<cards-author-card :key="entity.id" :author="entity" @hook:updated="updatedBookCard" class="mx-2e" @edit="editAuthor" /> <cards-author-card :key="entity.id" :authorMount="entity" @hook:updated="updatedBookCard" class="mx-2e" @edit="editAuthor" />
</template> </template>
</div> </div>
<div v-if="shelf.type === 'narrators'" class="flex items-center"> <div v-if="shelf.type === 'narrators'" class="flex items-center">

View file

@ -24,13 +24,13 @@
</nuxt-link> </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'"> <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> <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>
<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'"> <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> <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>
<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'"> <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/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> <p v-if="isAuthorsPage" class="text-sm">{{ $strings.ButtonAuthors }}</p>
<svg v-else class="w-5 h-5" viewBox="0 0 24 24"> <svg v-else class="w-5 h-5" viewBox="0 0 24 24">
<path <path
@ -50,7 +50,7 @@
{{ seriesName }} {{ seriesName }}
</p> </p>
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3"> <div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
<span class="font-mono">{{ numShowing }}</span> <span class="font-mono">{{ $formatNumber(numShowing) }}</span>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
@ -62,8 +62,8 @@
<ui-context-menu-dropdown v-if="!isBatchSelecting && seriesContextMenuItems.length" :items="seriesContextMenuItems" class="mx-px" @action="seriesContextMenuAction" /> <ui-context-menu-dropdown v-if="!isBatchSelecting && seriesContextMenuItems.length" :items="seriesContextMenuItems" class="mx-px" @action="seriesContextMenuAction" />
</template> </template>
<!-- library & collections page --> <!-- library & collections page -->
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome"> <template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome && !isAuthorsPage">
<p class="hidden md:block">{{ numShowing }} {{ entityName }}</p> <p class="hidden md:block">{{ $formatNumber(numShowing) }} {{ entityName }}</p>
<div class="flex-grow hidden sm:inline-block" /> <div class="flex-grow hidden sm:inline-block" />
@ -80,7 +80,7 @@
<controls-sort-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesSortBy" :descending.sync="settings.seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" /> <controls-sort-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesSortBy" :descending.sync="settings.seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" />
<!-- issues page remove all button --> <!-- issues page remove all button -->
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn> <ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ $formatNumber(numShowing) }} {{ entityName }}</ui-btn>
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" /> <ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
</template> </template>
@ -92,12 +92,14 @@
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" /> <ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
</template> </template>
<!-- authors page --> <!-- authors page -->
<template v-else-if="page === 'authors'"> <template v-else-if="isAuthorsPage">
<div class="flex-grow" /> <p class="hidden md:block">{{ $formatNumber(numShowing) }} {{ entityName }}</p>
<ui-btn v-if="userCanUpdate && authors?.length && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
<div class="flex-grow hidden sm:inline-block" />
<ui-btn v-if="userCanUpdate && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
<!-- author sort select --> <!-- author sort select -->
<controls-sort-select v-if="authors?.length" v-model="settings.authorSortBy" :descending.sync="settings.authorSortDesc" :items="authorSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateAuthorSort" /> <controls-sort-select v-model="settings.authorSortBy" :descending.sync="settings.authorSortDesc" :items="authorSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateAuthorSort" />
</template> </template>
<!-- home page --> <!-- home page -->
<template v-else-if="isHome"> <template v-else-if="isHome">
@ -117,11 +119,7 @@ export default {
type: Object, type: Object,
default: () => null default: () => null
}, },
searchQuery: String, searchQuery: String
authors: {
type: Array,
default: () => []
}
}, },
data() { data() {
return { return {
@ -160,6 +158,7 @@ export default {
this.addSubtitlesMenuItem(items) this.addSubtitlesMenuItem(items)
this.addDetailsOnHoverMenuItem(items) this.addDetailsOnHoverMenuItem(items)
this.addCollapseSubSeriesMenuItem(items)
return items return items
}, },
@ -246,9 +245,6 @@ export default {
isPodcastLibrary() { isPodcastLibrary() {
return this.currentLibraryMediaType === 'podcast' return this.currentLibraryMediaType === 'podcast'
}, },
isMusicLibrary() {
return this.currentLibraryMediaType === 'music'
},
isLibraryPage() { isLibraryPage() {
return this.page === '' return this.page === ''
}, },
@ -271,7 +267,7 @@ export default {
return this.$route.name === 'library-library-podcast-latest' return this.$route.name === 'library-library-podcast-latest'
}, },
isAuthorsPage() { isAuthorsPage() {
return this.$route.name === 'library-library-authors' return this.page === 'authors'
}, },
isAlbumsPage() { isAlbumsPage() {
return this.page === 'albums' return this.page === 'albums'
@ -281,13 +277,13 @@ export default {
}, },
entityName() { entityName() {
if (this.isAlbumsPage) return 'Albums' if (this.isAlbumsPage) return 'Albums'
if (this.isMusicLibrary) return 'Tracks'
if (this.isPodcastLibrary) return this.$strings.LabelPodcasts if (this.isPodcastLibrary) return this.$strings.LabelPodcasts
if (!this.page) return this.$strings.LabelBooks if (!this.page) return this.$strings.LabelBooks
if (this.isSeriesPage) return this.$strings.LabelSeries if (this.isSeriesPage) return this.$strings.LabelSeries
if (this.isCollectionsPage) return this.$strings.LabelCollections if (this.isCollectionsPage) return this.$strings.LabelCollections
if (this.isPlaylistsPage) return this.$strings.LabelPlaylists if (this.isPlaylistsPage) return this.$strings.LabelPlaylists
if (this.isAuthorsPage) return this.$strings.LabelAuthors
return '' return ''
}, },
seriesId() { seriesId() {
@ -388,6 +384,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) { handleSubtitlesAction(action) {
if (action === 'show-subtitles') { if (action === 'show-subtitles') {
this.settings.showSubtitles = true this.settings.showSubtitles = true
@ -427,6 +438,19 @@ export default {
} }
return false 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 }) { contextMenuAction({ action }) {
if (action === 'export-opml') { if (action === 'export-opml') {
this.exportOPML() this.exportOPML()
@ -461,6 +485,8 @@ export default {
return return
} else if (this.handleDetailsOnHoverAction(action)) { } else if (this.handleDetailsOnHoverAction(action)) {
return return
} else if (this.handleCollapseSubSeriesAction(action)) {
return
} }
}, },
showOpenSeriesRSSFeed() { showOpenSeriesRSSFeed() {
@ -476,20 +502,28 @@ export default {
this.$axios this.$axios
.$get(`/api/me/series/${this.seriesId}/readd-to-continue-listening`) .$get(`/api/me/series/${this.seriesId}/readd-to-continue-listening`)
.then(() => { .then(() => {
this.$toast.success('Series re-added to continue listening') this.$toast.success(this.$strings.ToastItemUpdateSuccess)
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to re-add series to continue listening', 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.ToastFailedToUpdate)
}) })
.finally(() => { .finally(() => {
this.processingSeries = false this.processingSeries = false
}) })
}, },
async fetchAllAuthors() {
// fetch all authors from the server, in the order that they are currently displayed
const response = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/authors?sort=${this.settings.authorSortBy}&desc=${this.settings.authorSortDesc}`)
return response.authors
},
async matchAllAuthors() { async matchAllAuthors() {
this.processingAuthors = true this.processingAuthors = true
for (const author of this.authors) { try {
const authors = await this.fetchAllAuthors()
for (const author of authors) {
const payload = {} const payload = {}
if (author.asin) payload.asin = author.asin if (author.asin) payload.asin = author.asin
else payload.q = author.name else payload.q = author.name
@ -507,7 +541,7 @@ export default {
}) })
if (!response) { if (!response) {
console.error(`Author ${author.name} not found`) 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) { } else if (response.updated) {
if (response.author.imagePath) console.log(`Author ${response.author.name} was 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)`) else console.log(`Author ${response.author.name} was updated (no image found)`)
@ -517,6 +551,10 @@ export default {
this.$eventBus.$emit(`searching-author-${author.id}`, false) this.$eventBus.$emit(`searching-author-${author.id}`, false)
} }
} catch (error) {
console.error('Failed to match all authors', error)
this.$toast.error(this.$strings.ToastMatchAllAuthorsFailed)
}
this.processingAuthors = false this.processingAuthors = false
}, },
removeAllIssues() { removeAllIssues() {
@ -525,13 +563,13 @@ export default {
this.$axios this.$axios
.$delete(`/api/libraries/${this.currentLibraryId}/issues`) .$delete(`/api/libraries/${this.currentLibraryId}/issues`)
.then(() => { .then(() => {
this.$toast.success('Removed library items with issues') this.$toast.success(this.$strings.ToastRemoveItemsWithIssuesSuccess)
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`) this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
this.$store.dispatch('libraries/fetch', this.currentLibraryId) this.$store.dispatch('libraries/fetch', this.currentLibraryId)
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to remove library items with issues', 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(() => { .finally(() => {
this.processingIssues = false this.processingIssues = false
@ -587,7 +625,7 @@ export default {
updateCollapseSeries() { updateCollapseSeries() {
this.saveSettings() this.saveSettings()
}, },
updateCollapseBookSeries() { updateCollapseSubSeries() {
this.saveSettings() this.saveSettings()
}, },
updateShowSubtitles() { updateShowSubtitles() {

View file

@ -10,7 +10,7 @@
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </nuxt-link>
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" /> <modals-changelog-view-modal v-model="showChangelogModal" :versionData="versionData" />
</div> </div>
<div class="w-44 h-12 px-4 border-t bg-bg border-black border-opacity-20 fixed left-0 flex flex-col justify-center" :class="wrapperClass" :style="{ bottom: streamLibraryItem ? '160px' : '0px' }"> <div class="w-44 h-12 px-4 border-t bg-bg border-black border-opacity-20 fixed left-0 flex flex-col justify-center" :class="wrapperClass" :style="{ bottom: streamLibraryItem ? '160px' : '0px' }">
@ -19,7 +19,7 @@
<p class="text-xs text-gray-300 italic">{{ Source }}</p> <p class="text-xs text-gray-300 italic">{{ Source }}</p>
</div> </div>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a> <a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ versionData.latestVersion }}</a>
</div> </div>
</div> </div>
</template> </template>
@ -156,15 +156,9 @@ export default {
hasUpdate() { hasUpdate() {
return !!this.versionData.hasUpdate return !!this.versionData.hasUpdate
}, },
latestVersion() {
return this.versionData.latestVersion
},
githubTagUrl() { githubTagUrl() {
return this.versionData.githubTagUrl return this.versionData.githubTagUrl
}, },
currentVersionChangelog() {
return this.versionData.currentVersionChangelog || 'No Changelog Available'
},
streamLibraryItem() { streamLibraryItem() {
return this.$store.state.streamLibraryItem return this.$store.state.streamLibraryItem
} }

View file

@ -91,6 +91,7 @@ export default {
if (this.page === 'series') return this.$strings.MessageBookshelfNoSeries if (this.page === 'series') return this.$strings.MessageBookshelfNoSeries
if (this.page === 'collections') return this.$strings.MessageBookshelfNoCollections if (this.page === 'collections') return this.$strings.MessageBookshelfNoCollections
if (this.page === 'playlists') return this.$strings.MessageNoUserPlaylists if (this.page === 'playlists') return this.$strings.MessageNoUserPlaylists
if (this.page === 'authors') return this.$strings.MessageNoAuthors
if (this.hasFilter) { if (this.hasFilter) {
if (this.filterName === 'Issues') return this.$strings.MessageNoIssues if (this.filterName === 'Issues') return this.$strings.MessageNoIssues
else if (this.filterName === 'Feed-open') return this.$strings.MessageBookshelfNoRSSFeeds else if (this.filterName === 'Feed-open') return this.$strings.MessageBookshelfNoRSSFeeds
@ -111,6 +112,12 @@ export default {
seriesFilterBy() { seriesFilterBy() {
return this.$store.getters['user/getUserSetting']('seriesFilterBy') return this.$store.getters['user/getUserSetting']('seriesFilterBy')
}, },
authorSortBy() {
return this.$store.getters['user/getUserSetting']('authorSortBy')
},
authorSortDesc() {
return !!this.$store.getters['user/getUserSetting']('authorSortDesc')
},
orderBy() { orderBy() {
return this.$store.getters['user/getUserSetting']('orderBy') return this.$store.getters['user/getUserSetting']('orderBy')
}, },
@ -217,6 +224,8 @@ export default {
this.$store.commit('globals/setEditCollection', entity) this.$store.commit('globals/setEditCollection', entity)
} else if (this.entityName === 'playlists') { } else if (this.entityName === 'playlists') {
this.$store.commit('globals/setEditPlaylist', entity) this.$store.commit('globals/setEditPlaylist', entity)
} else if (this.entityName === 'authors') {
this.$store.commit('globals/showEditAuthorModal', entity)
} }
}, },
clearSelectedEntities() { clearSelectedEntities() {
@ -457,6 +466,9 @@ export default {
if (this.collapseBookSeries) { if (this.collapseBookSeries) {
searchParams.set('collapseseries', 1) searchParams.set('collapseseries', 1)
} }
} else if (this.page === 'authors') {
searchParams.set('sort', this.authorSortBy)
searchParams.set('desc', this.authorSortDesc ? 1 : 0)
} else { } else {
if (this.filterBy && this.filterBy !== 'all') { if (this.filterBy && this.filterBy !== 'all') {
searchParams.set('filter', this.filterBy) searchParams.set('filter', this.filterBy)
@ -601,6 +613,34 @@ export default {
this.executeRebuild() this.executeRebuild()
} }
}, },
authorAdded(author) {
if (this.entityName !== 'authors') return
console.log(`[LazyBookshelf] authorAdded ${author.id}`, author)
this.resetEntities()
},
authorUpdated(author) {
if (this.entityName !== 'authors') return
console.log(`[LazyBookshelf] authorUpdated ${author.id}`, author)
const indexOf = this.entities.findIndex((ent) => ent && ent.id === author.id)
if (indexOf >= 0) {
this.entities[indexOf] = author
if (this.entityComponentRefs[indexOf]) {
this.entityComponentRefs[indexOf].setEntity(author)
}
}
},
authorRemoved(author) {
if (this.entityName !== 'authors') return
console.log(`[LazyBookshelf] authorRemoved ${author.id}`, author)
const indexOf = this.entities.findIndex((ent) => ent && ent.id === author.id)
if (indexOf >= 0) {
this.entities = this.entities.filter((ent) => ent.id !== author.id)
this.totalEntities--
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
this.executeRebuild()
}
},
shareOpen(mediaItemShare) { shareOpen(mediaItemShare) {
if (this.entityName === 'items' || this.entityName === 'series-books') { if (this.entityName === 'items' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent?.media?.id === mediaItemShare.mediaItemId) var indexOf = this.entities.findIndex((ent) => ent?.media?.id === mediaItemShare.mediaItemId)
@ -727,6 +767,9 @@ export default {
this.$root.socket.on('playlist_added', this.playlistAdded) this.$root.socket.on('playlist_added', this.playlistAdded)
this.$root.socket.on('playlist_updated', this.playlistUpdated) this.$root.socket.on('playlist_updated', this.playlistUpdated)
this.$root.socket.on('playlist_removed', this.playlistRemoved) this.$root.socket.on('playlist_removed', this.playlistRemoved)
this.$root.socket.on('author_added', this.authorAdded)
this.$root.socket.on('author_updated', this.authorUpdated)
this.$root.socket.on('author_removed', this.authorRemoved)
this.$root.socket.on('share_open', this.shareOpen) this.$root.socket.on('share_open', this.shareOpen)
this.$root.socket.on('share_closed', this.shareClosed) this.$root.socket.on('share_closed', this.shareClosed)
} else { } else {
@ -756,6 +799,9 @@ export default {
this.$root.socket.off('playlist_added', this.playlistAdded) this.$root.socket.off('playlist_added', this.playlistAdded)
this.$root.socket.off('playlist_updated', this.playlistUpdated) this.$root.socket.off('playlist_updated', this.playlistUpdated)
this.$root.socket.off('playlist_removed', this.playlistRemoved) this.$root.socket.off('playlist_removed', this.playlistRemoved)
this.$root.socket.off('author_added', this.authorAdded)
this.$root.socket.off('author_updated', this.authorUpdated)
this.$root.socket.off('author_removed', this.authorRemoved)
this.$root.socket.off('share_open', this.shareOpen) this.$root.socket.off('share_open', this.shareOpen)
this.$root.socket.off('share_closed', this.shareClosed) this.$root.socket.off('share_closed', this.shareClosed)
} else { } else {

View file

@ -1,10 +1,9 @@
<template> <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 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"> <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" /> <covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
</div> </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="min-w-0 w-full">
<div class="flex items-center"> <div class="flex items-center">
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate"> <nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
@ -12,10 +11,9 @@
</nuxt-link> </nuxt-link>
<widgets-explicit-indicator v-if="isExplicit" /> <widgets-explicit-indicator v-if="isExplicit" />
</div> </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> <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-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"> <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> <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</div> </div>
@ -43,12 +41,14 @@
:sleep-timer-remaining="sleepTimerRemaining" :sleep-timer-remaining="sleepTimerRemaining"
:sleep-timer-type="sleepTimerType" :sleep-timer-type="sleepTimerType"
:is-podcast="isPodcast" :is-podcast="isPodcast"
:hasNextItemInQueue="hasNextItemInQueue"
@playPause="playPause" @playPause="playPause"
@jumpForward="jumpForward" @jumpForward="jumpForward"
@jumpBackward="jumpBackward" @jumpBackward="jumpBackward"
@setVolume="setVolume" @setVolume="setVolume"
@setPlaybackRate="setPlaybackRate" @setPlaybackRate="setPlaybackRate"
@seek="seek" @seek="seek"
@nextItemInQueue="playNextItemInQueue"
@close="closePlayer" @close="closePlayer"
@showBookmarks="showBookmarks" @showBookmarks="showBookmarks"
@showSleepTimer="showSleepTimerModal = true" @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-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" /> <modals-player-settings-modal v-model="showPlayerSettingsModal" />
</div> </div>
@ -138,9 +138,6 @@ export default {
isPodcast() { isPodcast() {
return this.streamLibraryItem?.mediaType === 'podcast' return this.streamLibraryItem?.mediaType === 'podcast'
}, },
isMusic() {
return this.streamLibraryItem?.mediaType === 'music'
},
isExplicit() { isExplicit() {
return !!this.mediaMetadata.explicit return !!this.mediaMetadata.explicit
}, },
@ -170,11 +167,17 @@ export default {
}, },
podcastAuthor() { podcastAuthor() {
if (!this.isPodcast) return null if (!this.isPodcast) return null
return this.mediaMetadata.author || 'Unknown' return this.mediaMetadata.author || this.$strings.LabelUnknown
}, },
musicArtists() { hasNextItemInQueue() {
if (!this.isMusic) return null return this.currentPlayerQueueIndex < this.playerQueueItems.length - 1
return this.mediaMetadata.artists.join(', ') },
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() { playerQueueItems() {
return this.$store.state.playerQueueItems || [] return this.$store.state.playerQueueItems || []
@ -248,7 +251,7 @@ export default {
sleepTimerEnd() { sleepTimerEnd() {
this.clearSleepTimer() this.clearSleepTimer()
this.playerHandler.pause() this.playerHandler.pause()
this.$toast.info('Sleep Timer Done.. zZzzZz') this.$toast.info(this.$strings.ToastSleepTimerDone)
}, },
cancelSleepTimer() { cancelSleepTimer() {
this.showSleepTimerModal = false this.showSleepTimerModal = false
@ -460,6 +463,30 @@ export default {
this.playerHandler.switchPlayer() 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) { async playLibraryItem(payload) {
const libraryItemId = payload.libraryItemId const libraryItemId = payload.libraryItemId
const episodeId = payload.episodeId || null const episodeId = payload.episodeId || null
@ -498,7 +525,7 @@ export default {
}, },
showFailedProgressSyncs() { showFailedProgressSyncs() {
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast) if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' }) this.syncFailedToast = this.$toast(this.$strings.ToastProgressIsNotBeingSynced, { timeout: false, type: 'error' })
}, },
sessionClosedEvent(sessionId) { sessionClosedEvent(sessionId) {
if (this.playerHandler.currentSessionId === sessionId) { if (this.playerHandler.currentSessionId === sessionId) {
@ -512,6 +539,7 @@ export default {
this.$eventBus.$on('cast-session-active', this.castSessionActive) this.$eventBus.$on('cast-session-active', this.castSessionActive)
this.$eventBus.$on('playback-seek', this.seek) this.$eventBus.$on('playback-seek', this.seek)
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate) 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('play-item', this.playLibraryItem)
this.$eventBus.$on('pause-item', this.pauseItem) this.$eventBus.$on('pause-item', this.pauseItem)
}, },
@ -519,6 +547,7 @@ export default {
this.$eventBus.$off('cast-session-active', this.castSessionActive) this.$eventBus.$off('cast-session-active', this.castSessionActive)
this.$eventBus.$off('playback-seek', this.seek) this.$eventBus.$off('playback-seek', this.seek)
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate) 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('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem) this.$eventBus.$off('pause-item', this.pauseItem)
} }

View file

@ -15,7 +15,7 @@
</nuxt-link> </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'"> <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> <p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p>
@ -43,7 +43,7 @@
</nuxt-link> </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'"> <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> <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
@ -51,14 +51,14 @@
</nuxt-link> </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'"> <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> <p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg class="w-6 h-6" viewBox="0 0 24 24"> <svg class="w-6 h-6" viewBox="0 0 24 24">
<path <path
fill="currentColor" fill="currentColor"
@ -72,7 +72,7 @@
</nuxt-link> </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'"> <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> <p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p>
@ -80,7 +80,7 @@
</nuxt-link> </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'"> <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> <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" /> <div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </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'"> <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> <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonDownloadQueue }}</p>
@ -172,9 +164,6 @@ export default {
isPodcastLibrary() { isPodcastLibrary() {
return this.currentLibraryMediaType === 'podcast' return this.currentLibraryMediaType === 'podcast'
}, },
isMusicLibrary() {
return this.currentLibraryMediaType === 'music'
},
isPodcastDownloadQueuePage() { isPodcastDownloadQueuePage() {
return this.$route.name === 'library-library-podcast-download-queue' return this.$route.name === 'library-library-podcast-download-queue'
}, },
@ -184,9 +173,6 @@ export default {
isPodcastLatestPage() { isPodcastLatestPage() {
return this.$route.name === 'library-library-podcast-latest' return this.$route.name === 'library-library-podcast-latest'
}, },
isMusicAlbumsPage() {
return this.paramId === 'albums'
},
homePage() { homePage() {
return this.$route.name === 'library-library' return this.$route.name === 'library-library'
}, },
@ -194,7 +180,7 @@ export default {
return this.$route.name === 'library-library-series-id' || this.paramId === 'series' return this.$route.name === 'library-library-series-id' || this.paramId === 'series'
}, },
isAuthorsPage() { isAuthorsPage() {
return this.$route.name === 'library-library-authors' return this.libraryBookshelfPage && this.paramId === 'authors'
}, },
isNarratorsPage() { isNarratorsPage() {
return this.$route.name === 'library-library-narrators' return this.$route.name === 'library-library-narrators'

View file

@ -1,6 +1,6 @@
<template> <template>
<div :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }"> <div class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
<nuxt-link :to="`/author/${author.id}`"> <nuxt-link :to="`/author/${author?.id}`">
<div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave"> <div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave">
<div cy-id="imageArea" :style="{ height: cardHeight + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden"> <div cy-id="imageArea" :style="{ height: cardHeight + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
<!-- Image or placeholder --> <!-- Image or placeholder -->
@ -40,7 +40,7 @@
<script> <script>
export default { export default {
props: { props: {
author: { authorMount: {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
@ -57,7 +57,8 @@ export default {
data() { data() {
return { return {
searching: false, searching: false,
isHovering: false isHovering: false,
author: null
} }
}, },
computed: { computed: {
@ -68,34 +69,37 @@ export default {
return this.height * this.sizeMultiplier return this.height * this.sizeMultiplier
}, },
userToken() { userToken() {
return this.$store.getters['user/getToken'] return this.store.getters['user/getToken']
}, },
_author() { _author() {
return this.author || {} return this.author || {}
}, },
authorId() { authorId() {
return this._author.id return this._author?.id || ''
}, },
name() { name() {
return this._author.name || '' return this._author?.name || ''
}, },
asin() { asin() {
return this._author.asin || '' return this._author?.asin || ''
}, },
numBooks() { numBooks() {
return this._author.numBooks || 0 return this._author?.numBooks || 0
},
store() {
return this.$store || this.$nuxt.$store
}, },
userCanUpdate() { userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate'] return this.store.getters['user/getUserCanUpdate']
}, },
currentLibraryId() { currentLibraryId() {
return this.$store.state.libraries.currentLibraryId return this.store.state.libraries.currentLibraryId
}, },
libraryProvider() { libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google' return this.store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
}, },
sizeMultiplier() { sizeMultiplier() {
return this.$store.getters['user/getSizeMultiplier'] return this.store.getters['user/getSizeMultiplier']
} }
}, },
methods: { methods: {
@ -121,24 +125,54 @@ export default {
return null return null
}) })
if (!response) { if (!response) {
this.$toast.error(`Author ${this.name} not found`) this.$toast.error(this.$getString('ToastAuthorNotFound', [this.name]))
} else if (response.updated) { } else if (response.updated) {
if (response.author.imagePath) this.$toast.success(`Author ${response.author.name} was updated`) if (response.author.imagePath) {
else this.$toast.success(`Author ${response.author.name} was updated (no image found)`) this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
} else { } else {
this.$toast.info(`No updates were made for Author ${response.author.name}`) this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
}
} else {
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
} }
this.searching = false this.searching = false
}, },
setSearching(isSearching) { setSearching(isSearching) {
this.searching = isSearching this.searching = isSearching
},
setEntity(author) {
this.removeListeners()
this.author = author
this.addListeners()
},
addListeners() {
if (this.author) {
this.$eventBus.$on(`searching-author-${this.authorId}`, this.setSearching)
} }
}, },
removeListeners() {
if (this.author) {
this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching)
}
},
destroy() {
// destroy the vue listeners, etc
this.$destroy()
// remove the element from the DOM
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
} else if (this.$el && this.$el.remove) {
this.$el.remove()
}
},
setSelectionMode(val) {}
},
mounted() { mounted() {
this.$eventBus.$on(`searching-author-${this.authorId}`, this.setSearching) if (this.authorMount) this.setEntity(this.authorMount)
}, },
beforeDestroy() { beforeDestroy() {
this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching) this.removeListeners()
} }
} }
</script> </script>

View file

@ -8,6 +8,7 @@
<p class="truncate text-sm">{{ title }}</p> <p class="truncate text-sm">{{ title }}</p>
<p class="truncate text-xs text-gray-300">{{ description }}</p> <p class="truncate text-xs text-gray-300">{{ description }}</p>
<p v-if="specialMessage" class="truncate text-xs text-gray-300">{{ specialMessage }}</p>
<p v-if="isFailed && failedMessage" class="text-xs truncate text-red-500">{{ failedMessage }}</p> <p v-if="isFailed && failedMessage" class="text-xs truncate text-red-500">{{ failedMessage }}</p>
<p v-else-if="!isFinished && cancelingScan" class="text-xs truncate">Canceling...</p> <p v-else-if="!isFinished && cancelingScan" class="text-xs truncate">Canceling...</p>
@ -26,7 +27,16 @@ export default {
}, },
data() { data() {
return { return {
cancelingScan: false cancelingScan: false,
specialMessage: ''
}
},
watch: {
task: {
immediate: true,
handler() {
this.initTask()
}
} }
}, },
computed: { computed: {
@ -34,14 +44,17 @@ export default {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']
}, },
title() { title() {
if (this.task.titleKey && this.$strings[this.task.titleKey]) {
return this.$getString(this.task.titleKey, this.task.titleSubs)
}
return this.task.title || 'No Title' return this.task.title || 'No Title'
}, },
description() { description() {
if (this.task.descriptionKey && this.$strings[this.task.descriptionKey]) {
return this.$getString(this.task.descriptionKey, this.task.descriptionSubs)
}
return this.task.description || '' return this.task.description || ''
}, },
details() {
return this.task.details || 'Unknown'
},
isFinished() { isFinished() {
return !!this.task.isFinished return !!this.task.isFinished
}, },
@ -52,6 +65,9 @@ export default {
return this.isFinished && !this.isFailed return this.isFinished && !this.isFailed
}, },
failedMessage() { failedMessage() {
if (this.task.errorKey && this.$strings[this.task.errorKey]) {
return this.$getString(this.task.errorKey, this.task.errorSubs)
}
return this.task.error || '' return this.task.error || ''
}, },
action() { action() {
@ -87,6 +103,21 @@ export default {
} }
}, },
methods: { methods: {
initTask() {
// special message for library scan tasks
if (this.task?.data?.scanResults) {
const scanResults = this.task.data.scanResults
const strs = []
if (scanResults.added) strs.push(this.$getString('MessageTaskScanItemsAdded', [scanResults.added]))
if (scanResults.updated) strs.push(this.$getString('MessageTaskScanItemsUpdated', [scanResults.updated]))
if (scanResults.missing) strs.push(this.$getString('MessageTaskScanItemsMissing', [scanResults.missing]))
const changesDetected = strs.length > 0 ? strs.join(', ') : this.$strings.MessageTaskScanNoChangesNeeded
const timeElapsed = scanResults.elapsed ? ` (${this.$elapsedPretty(scanResults.elapsed / 1000, false, true)})` : ''
this.specialMessage = `${changesDetected}${timeElapsed}`
} else {
this.specialMessage = ''
}
},
cancelScan() { cancelScan() {
const libraryId = this.task?.data?.libraryId const libraryId = this.task?.data?.libraryId
if (!libraryId) { if (!libraryId) {

View file

@ -203,23 +203,6 @@ export default {
// This method returns immediately without waiting for the DOM to update // This method returns immediately without waiting for the DOM to update
return this.coverWidth 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() { sizeMultiplier() {
return this.store.getters['user/getSizeMultiplier'] return this.store.getters['user/getSizeMultiplier']
}, },
@ -245,9 +228,6 @@ export default {
isPodcast() { isPodcast() {
return this.mediaType === 'podcast' || this.store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast' return this.mediaType === 'podcast' || this.store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
}, },
isMusic() {
return this.mediaType === 'music'
},
isExplicit() { isExplicit() {
return this.mediaMetadata.explicit || false return this.mediaMetadata.explicit || false
}, },
@ -350,7 +330,7 @@ export default {
}, },
displaySubtitle() { displaySubtitle() {
if (!this.libraryItem) return '\u00A0' if (!this.libraryItem) return '\u00A0'
if (this.collapsedSeries) return this.collapsedSeries.numBooks === 1 ? '1 book' : `${this.collapsedSeries.numBooks} books` if (this.collapsedSeries) return `${this.collapsedSeries.numBooks} ${this.$strings.LabelBooks}`
if (this.mediaMetadata.subtitle) return this.mediaMetadata.subtitle if (this.mediaMetadata.subtitle) return this.mediaMetadata.subtitle
if (this.mediaMetadata.seriesName) return this.mediaMetadata.seriesName if (this.mediaMetadata.seriesName) return this.mediaMetadata.seriesName
return '' return ''
@ -358,7 +338,6 @@ export default {
displayLineTwo() { displayLineTwo() {
if (this.recentEpisode) return this.title if (this.recentEpisode) return this.title
if (this.isPodcast) return this.author if (this.isPodcast) return this.author
if (this.isMusic) return this.artist
if (this.collapsedSeries) return '' if (this.collapsedSeries) return ''
if (this.isAuthorBookshelfView) { if (this.isAuthorBookshelfView) {
return this.mediaMetadata.publishedYear || '' return this.mediaMetadata.publishedYear || ''
@ -368,14 +347,14 @@ export default {
}, },
displaySortLine() { displaySortLine() {
if (this.collapsedSeries) return null if (this.collapsedSeries) return null
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat) if (this.orderBy === 'mtimeMs') return this.$getString('LabelFileModifiedDate', [this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat)])
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat) if (this.orderBy === 'birthtimeMs') return this.$getString('LabelFileBornDate', [this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat)])
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt, this.dateFormat) if (this.orderBy === 'addedAt') return this.$getString('LabelAddedDate', [this.$formatDate(this._libraryItem.addedAt, this.dateFormat)])
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false) if (this.orderBy === 'media.duration') return this.$strings.LabelDuration + ': ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size) if (this.orderBy === 'size') return this.$strings.LabelSize + ': ' + this.$bytesPretty(this._libraryItem.size)
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes` if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} ` + this.$strings.LabelEpisodes
if (this.orderBy === 'media.metadata.publishedYear') { 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 '\u00A0'
} }
return null return null
@ -386,7 +365,6 @@ export default {
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id) return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
}, },
userProgress() { userProgress() {
if (this.isMusic) return null
if (this.episodeProgress) return this.episodeProgress if (this.episodeProgress) return this.episodeProgress
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId) return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
}, },
@ -442,7 +420,7 @@ export default {
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
}, },
showPlayButton() { 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() { showSmallEBookIcon() {
return !this.isSelectionMode && this.ebookFormat return !this.isSelectionMode && this.ebookFormat
@ -489,8 +467,6 @@ export default {
return this.store.getters['user/getUserSetting']('showDetailsOnHover') return this.store.getters['user/getUserSetting']('showDetailsOnHover')
}, },
moreMenuItems() { moreMenuItems() {
if (this.isMusic) return []
if (this.recentEpisode) { if (this.recentEpisode) {
const items = [ const items = [
{ {
@ -735,7 +711,7 @@ export default {
toggleFinished(confirmed = false) { toggleFinished(confirmed = false) {
if (!this.itemIsFinished && this.userProgressPercent > 0 && !confirmed) { if (!this.itemIsFinished && this.userProgressPercent > 0 && !confirmed) {
const payload = { const payload = {
message: `Are you sure you want to mark "${this.displayTitle}" as finished?`, message: this.$getString('MessageConfirmMarkItemFinished', [this.displayTitle]),
callback: (confirmed) => { callback: (confirmed) => {
if (confirmed) { if (confirmed) {
this.toggleFinished(true) this.toggleFinished(true)
@ -780,18 +756,18 @@ export default {
.then((data) => { .then((data) => {
var result = data.result var result = data.result
if (!result) { if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`) this.$toast.error(this.$getString('ToastRescanFailed', [this.displayTitle]))
} else if (result === 'UPDATED') { } else if (result === 'UPDATED') {
this.$toast.success(`Re-Scan complete item was updated`) this.$toast.success(this.$strings.ToastRescanUpdated)
} else if (result === 'UPTODATE') { } 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') { } else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete item was removed`) this.$toast.error(this.$strings.ToastRescanRemoved)
} }
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to scan library item', error) console.error('Failed to scan library item', error)
this.$toast.error('Failed to scan library item') this.$toast.error(this.$strings.ToastScanFailed)
}) })
.finally(() => { .finally(() => {
this.processing = false this.processing = false
@ -848,7 +824,7 @@ export default {
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to remove series from home', error) console.error('Failed to remove series from home', error)
this.$toast.error('Failed to update user') this.$toast.error(this.$strings.ToastFailedToUpdate)
}) })
.finally(() => { .finally(() => {
this.processing = false this.processing = false
@ -866,7 +842,7 @@ export default {
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to hide item from home', error) console.error('Failed to hide item from home', error)
this.$toast.error('Failed to update user') this.$toast.error(this.$strings.ToastFailedToUpdate)
}) })
.finally(() => { .finally(() => {
this.processing = false this.processing = false
@ -881,7 +857,7 @@ export default {
episodeId: this.recentEpisode.id, episodeId: this.recentEpisode.id,
title: this.recentEpisode.title, title: this.recentEpisode.title,
subtitle: this.mediaMetadata.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, duration: this.recentEpisode.audioFile.duration || null,
coverPath: this.media.coverPath || null coverPath: this.media.coverPath || null
} }
@ -931,11 +907,11 @@ export default {
axios axios
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`) .$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
.then(() => { .then(() => {
this.$toast.success('Item deleted') this.$toast.success(this.$strings.ToastItemDeletedSuccess)
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to delete item', error) console.error('Failed to delete item', error)
this.$toast.error('Failed to delete item') this.$toast.error(this.$strings.ToastItemDeletedFailed)
}) })
.finally(() => { .finally(() => {
this.processing = false this.processing = false
@ -1041,7 +1017,7 @@ export default {
episodeId: episode.id, episodeId: episode.id,
title: episode.title, title: episode.title,
subtitle: this.mediaMetadata.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, duration: episode.audioFile.duration || null,
coverPath: this.media.coverPath || null coverPath: this.media.coverPath || null
}) })

View file

@ -57,23 +57,11 @@ export default {
return this.store.getters['libraries/getBookCoverAspectRatio'] return this.store.getters['libraries/getBookCoverAspectRatio']
}, },
cardWidth() { cardWidth() {
return this.width || this.coverHeight * 2 return this.width || (this.coverHeight / this.bookCoverAspectRatio) * 2
}, },
coverHeight() { coverHeight() {
return this.height * this.sizeMultiplier 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() { labelFontSize() {
if (this.width < 160) return 0.75 if (this.width < 160) return 0.75
return 0.9 return 0.9

View file

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

View file

@ -3,7 +3,7 @@
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(name)}`"> <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 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"> <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> </div>
<!-- Narrator name & num books overlay --> <!-- Narrator name & num books overlay -->

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="flex h-full px-1 overflow-hidden"> <div class="flex h-full px-1 overflow-hidden">
<div class="w-10 h-10 flex items-center justify-center"> <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>
<div class="flex-grow px-2 narratorSearchCardContent h-full"> <div class="flex-grow px-2 narratorSearchCardContent h-full">
<p class="truncate text-sm">{{ narrator }}</p> <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> <p class="text-base md:text-lg font-semibold pr-4">{{ eventName }}</p>
<div class="flex-grow" /> <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" @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">Fire & Fail</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-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-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">Enable</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 :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" /> <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 this.$axios
.$get(`/api/notifications/test?fail=${intentionallyFail ? 1 : 0}`) .$get(`/api/notifications/test?fail=${intentionallyFail ? 1 : 0}`)
.then(() => { .then(() => {
this.$toast.success('Triggered onTest Event') this.$toast.success(this.$strings.ToastNotificationTestTriggerSuccess)
}) })
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
const errorMsg = error.response ? error.response.data : null 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(() => { .finally(() => {
this.testing = false this.testing = false
@ -91,7 +91,7 @@ export default {
// End testing functions // End testing functions
sendTestClick() { sendTestClick() {
const payload = { const payload = {
message: `Trigger this notification with test data?`, message: this.$strings.MessageConfirmNotificationTestTrigger,
callback: (confirmed) => { callback: (confirmed) => {
if (confirmed) { if (confirmed) {
this.sendTest() this.sendTest()
@ -106,12 +106,12 @@ export default {
this.$axios this.$axios
.$get(`/api/notifications/${this.notification.id}/test`) .$get(`/api/notifications/${this.notification.id}/test`)
.then(() => { .then(() => {
this.$toast.success('Triggered test notification') this.$toast.success(this.$strings.ToastNotificationTestTriggerSuccess)
}) })
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
const errorMsg = error.response ? error.response.data : null 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(() => { .finally(() => {
this.sendingTest = false this.sendingTest = false
@ -127,11 +127,10 @@ export default {
.$patch(`/api/notifications/${this.notification.id}`, payload) .$patch(`/api/notifications/${this.notification.id}`, payload)
.then((updatedSettings) => { .then((updatedSettings) => {
this.$emit('update', updatedSettings) this.$emit('update', updatedSettings)
this.$toast.success('Notification enabled')
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to update notification', error) console.error('Failed to update notification', error)
this.$toast.error('Failed to update notification') this.$toast.error(this.$strings.ToastFailedToUpdate)
}) })
.finally(() => { .finally(() => {
this.enabling = false this.enabling = false
@ -139,7 +138,7 @@ export default {
}, },
deleteNotificationClick() { deleteNotificationClick() {
const payload = { const payload = {
message: `Are you sure you want to delete this notification?`, message: this.$strings.MessageConfirmDeleteNotification,
callback: (confirmed) => { callback: (confirmed) => {
if (confirmed) { if (confirmed) {
this.deleteNotification() this.deleteNotification()
@ -155,11 +154,10 @@ export default {
.$delete(`/api/notifications/${this.notification.id}`) .$delete(`/api/notifications/${this.notification.id}`)
.then((updatedSettings) => { .then((updatedSettings) => {
this.$emit('update', updatedSettings) this.$emit('update', updatedSettings)
this.$toast.success('Deleted notification')
}) })
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
this.$toast.error('Failed to delete notification') this.$toast.error(this.$strings.ToastNotificationDeleteFailed)
}) })
.finally(() => { .finally(() => {
this.deleting = false this.deleting = false

View file

@ -27,38 +27,6 @@
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=publishers.${$encode(publisher)}`" class="hover:underline">{{ publisher }}</nuxt-link> <nuxt-link :to="`/library/${libraryId}/bookshelf?filter=publishers.${$encode(publisher)}`" class="hover:underline">{{ publisher }}</nuxt-link>
</div> </div>
</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 v-if="podcastType" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32"> <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> <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> <nuxt-link :to="`/library/${libraryId}/bookshelf?filter=languages.${$encode(language)}`" class="hover:underline">{{ language }}</nuxt-link>
</div> </div>
</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"> <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> <span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
</div> </div>
@ -134,10 +102,6 @@ export default {
isPodcast() { isPodcast() {
return this.libraryItem.mediaType === 'podcast' return this.libraryItem.mediaType === 'podcast'
}, },
audioFile() {
// Music track
return this.media.audioFile
},
media() { media() {
return this.libraryItem.media || {} return this.libraryItem.media || {}
}, },
@ -168,25 +132,6 @@ export default {
publisher() { publisher() {
return this.mediaMetadata.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() { narrators() {
return this.mediaMetadata.narrators || [] return this.mediaMetadata.narrators || []
}, },

View file

@ -5,11 +5,11 @@
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" /> <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> </form>
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear"> <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> <span v-else class="material-symbols" style="font-size: 1.2rem">close</span>
</div> </div>
</div> </div>
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu"> <div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu" @mousedown.stop.prevent>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li v-if="isTyping" class="py-2 px-2"> <li v-if="isTyping" class="py-2 px-2">
<p>{{ $strings.MessageThinking }}</p> <p>{{ $strings.MessageThinking }}</p>
@ -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> <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"> <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"> <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" /> <cards-author-search-card :author="item" />
</nuxt-link> </nuxt-link>
</li> </li>
@ -157,7 +157,7 @@ export default {
clearTimeout(this.focusTimeout) clearTimeout(this.focusTimeout)
this.focusTimeout = setTimeout(() => { this.focusTimeout = setTimeout(() => {
this.showMenu = false this.showMenu = false
}, 200) }, 100)
}, },
async runSearch(value) { async runSearch(value) {
this.lastSearch = value this.lastSearch = value

View file

@ -98,9 +98,6 @@ export default {
isPodcast() { isPodcast() {
return this.libraryMediaType === 'podcast' return this.libraryMediaType === 'podcast'
}, },
isMusic() {
return this.libraryMediaType === 'music'
},
seriesItems() { seriesItems() {
return [ return [
{ {
@ -192,6 +189,12 @@ export default {
value: 'publishers', value: 'publishers',
sublist: true sublist: true
}, },
{
text: this.$strings.LabelPublishedDecade,
textPlural: this.$strings.LabelPublishedDecades,
value: 'publishedDecades',
sublist: true
},
{ {
text: this.$strings.LabelLanguage, text: this.$strings.LabelLanguage,
textPlural: this.$strings.LabelLanguages, textPlural: this.$strings.LabelLanguages,
@ -274,35 +277,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() { selectItems() {
if (this.isSeries) return this.seriesItems if (this.isSeries) return this.seriesItems
if (this.isPodcast) return this.podcastItems if (this.isPodcast) return this.podcastItems
if (this.isMusic) return this.musicItems
return this.bookItems return this.bookItems
}, },
selectedItemSublist() { selectedItemSublist() {
@ -367,6 +344,9 @@ export default {
publishers() { publishers() {
return this.filterData.publishers || [] return this.filterData.publishers || []
}, },
publishedDecades() {
return this.filterData.publishedDecades || []
},
progress() { progress() {
return [ return [
{ {
@ -433,21 +413,17 @@ export default {
id: 'isbn', id: 'isbn',
name: 'ISBN' name: 'ISBN'
}, },
{
id: 'subtitle',
name: this.$strings.LabelSubtitle
},
{ {
id: 'authors', id: 'authors',
name: this.$strings.LabelAuthor name: this.$strings.LabelAuthor
}, },
{ {
id: 'publishedYear', id: 'chapters',
name: this.$strings.LabelPublishYear name: this.$strings.LabelChapters
}, },
{ {
id: 'series', id: 'cover',
name: this.$strings.LabelSeries name: this.$strings.LabelCover
}, },
{ {
id: 'description', id: 'description',
@ -458,24 +434,32 @@ export default {
name: this.$strings.LabelGenres name: this.$strings.LabelGenres
}, },
{ {
id: 'tags', id: 'language',
name: this.$strings.LabelTags name: this.$strings.LabelLanguage
}, },
{ {
id: 'narrators', id: 'narrators',
name: this.$strings.LabelNarrator name: this.$strings.LabelNarrator
}, },
{
id: 'publishedYear',
name: this.$strings.LabelPublishYear
},
{ {
id: 'publisher', id: 'publisher',
name: this.$strings.LabelPublisher name: this.$strings.LabelPublisher
}, },
{ {
id: 'language', id: 'series',
name: this.$strings.LabelLanguage name: this.$strings.LabelSeries
}, },
{ {
id: 'cover', id: 'subtitle',
name: this.$strings.LabelCover name: this.$strings.LabelSubtitle
},
{
id: 'tags',
name: this.$strings.LabelTags
} }
] ]
}, },

View file

@ -56,9 +56,6 @@ export default {
isPodcast() { isPodcast() {
return this.libraryMediaType === 'podcast' return this.libraryMediaType === 'podcast'
}, },
isMusic() {
return this.libraryMediaType === 'music'
},
podcastItems() { podcastItems() {
return [ 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() { selectItems() {
let items = null let items = null
if (this.isPodcast) { if (this.isPodcast) {
items = this.podcastItems items = this.podcastItems
} else if (this.isMusic) {
items = this.musicItems
} else if (this.$store.getters['user/getUserSetting']('filterBy').startsWith('series.')) { } else if (this.$store.getters['user/getUserSetting']('filterBy').startsWith('series.')) {
items = this.seriesItems items = this.seriesItems
} else { } else {

View file

@ -1,9 +1,9 @@
<template> <template>
<div ref="wrapper" class="relative ml-4 sm:ml-8" v-click-outside="clickOutside"> <div ref="wrapper" class="relative ml-4 sm:ml-8" v-click-outside="clickOutside">
<div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)"> <div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)">
<span class="font-mono uppercase text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base">x</span></span> <span class="text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base">x</span></span>
</div> </div>
<div v-show="showMenu" class="absolute -top-20 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" :style="{ left: menuLeft + 'px' }"> <div v-show="showMenu" class="absolute -top-[5.5rem] z-20 bg-bg border-black-200 border shadow-xl rounded-lg" :style="{ left: menuLeft + 'px' }">
<div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }"> <div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }">
<div class="arrow-down" /> <div class="arrow-down" />
</div> </div>
@ -11,12 +11,12 @@
<template v-for="rate in rates"> <template v-for="rate in rates">
<div :key="rate" class="h-full border-black-300 w-11 cursor-pointer border rounded-sm" :class="value === rate ? 'bg-black-100' : 'hover:bg-black hover:bg-opacity-10'" style="min-width: 44px; max-width: 44px" @click="set(rate)"> <div :key="rate" class="h-full border-black-300 w-11 cursor-pointer border rounded-sm" :class="value === rate ? 'bg-black-100' : 'hover:bg-black hover:bg-opacity-10'" style="min-width: 44px; max-width: 44px" @click="set(rate)">
<div class="w-full h-full flex justify-center items-center"> <div class="w-full h-full flex justify-center items-center">
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm">x</span></p> <p class="text-xs text-center">{{ rate }}<span class="text-sm">x</span></p>
</div> </div>
</div> </div>
</template> </template>
</div> </div>
<div class="w-full py-1 px-4"> <div class="w-full py-1 px-1">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" /> <ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" />
<p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl">x</span></p> <p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl">x</span></p>
@ -41,7 +41,7 @@ export default {
currentPlaybackRate: 0, currentPlaybackRate: 0,
MIN_SPEED: 0.5, MIN_SPEED: 0.5,
MAX_SPEED: 10, MAX_SPEED: 10,
menuLeft: -92, menuLeft: -96,
arrowLeft: 0 arrowLeft: 0
} }
}, },
@ -89,9 +89,9 @@ export default {
if (boundingBox.left + 110 > window.innerWidth - 10) { if (boundingBox.left + 110 > window.innerWidth - 10) {
this.menuLeft = window.innerWidth - 230 - boundingBox.left this.menuLeft = window.innerWidth - 230 - boundingBox.left
this.arrowLeft = Math.abs(this.menuLeft) - 92 this.arrowLeft = Math.abs(this.menuLeft) - 96
} else { } else {
this.menuLeft = -92 this.menuLeft = -96
this.arrowLeft = 0 this.arrowLeft = 0
} }
}, },

View file

@ -4,10 +4,10 @@
<span class="material-symbols text-2xl sm:text-3xl">{{ volumeIcon }}</span> <span class="material-symbols text-2xl sm:text-3xl">{{ volumeIcon }}</span>
</button> </button>
<transition name="menux"> <transition name="menux">
<div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px"> <div v-show="isOpen" class="volumeMenu h-28 absolute bottom-2 w-6 py-2 bg-bg shadow-sm rounded-lg" style="top: -116px">
<div ref="volumeTrack" class="h-1 w-full bg-gray-500 my-2.5 relative cursor-pointer rounded-full" @mousedown="mousedownTrack" @click="clickVolumeTrack"> <div ref="volumeTrack" class="w-1 h-full bg-gray-500 mx-2.5 relative cursor-pointer rounded-full" @mousedown="mousedownTrack" @click="clickVolumeTrack">
<div class="bg-gray-100 h-full absolute left-0 top-0 pointer-events-none rounded-full" :style="{ width: volume * trackWidth + 'px' }" /> <div class="bg-gray-100 w-full absolute left-0 bottom-0 pointer-events-none rounded-full" :style="{ height: volume * trackHeight + 'px' }" />
<div class="w-2.5 h-2.5 bg-white shadow-sm rounded-full absolute pointer-events-none" :class="isDragging ? 'transform scale-125 origin-center' : ''" :style="{ left: cursorLeft + 'px', top: '-3px' }" /> <div class="w-2.5 h-2.5 bg-white shadow-sm rounded-full absolute pointer-events-none" :class="isDragging ? 'transform scale-125 origin-center' : ''" :style="{ bottom: cursorBottom + 'px', left: '-3px' }" />
</div> </div>
</div> </div>
</transition> </transition>
@ -24,10 +24,10 @@ export default {
isOpen: false, isOpen: false,
isDragging: false, isDragging: false,
isHovering: false, isHovering: false,
posX: 0, posY: 0,
lastValue: 0.5, lastValue: 0.5,
isMute: false, isMute: false,
trackWidth: 112 - 20, trackHeight: 112 - 20,
openTimeout: null openTimeout: null
} }
}, },
@ -45,9 +45,9 @@ export default {
this.$emit('input', val) this.$emit('input', val)
} }
}, },
cursorLeft() { cursorBottom() {
var left = this.trackWidth * this.volume var bottom = this.trackHeight * this.volume
return left - 3 return bottom - 3
}, },
volumeIcon() { volumeIcon() {
if (this.volume <= 0) return 'volume_mute' if (this.volume <= 0) return 'volume_mute'
@ -89,17 +89,10 @@ export default {
}, 600) }, 600)
}, },
mousemove(e) { mousemove(e) {
var diff = this.posX - e.x var diff = this.posY - e.y
this.posX = e.x this.posY = e.y
var volShift = 0 var volShift = diff / this.trackHeight
if (diff < 0) { var newVol = this.volume + volShift
// Volume up
volShift = diff / this.trackWidth
} else {
// volume down
volShift = diff / this.trackWidth
}
var newVol = this.volume - volShift
newVol = Math.min(Math.max(0, newVol), 1) newVol = Math.min(Math.max(0, newVol), 1)
this.volume = newVol this.volume = newVol
e.preventDefault() e.preventDefault()
@ -113,8 +106,8 @@ export default {
}, },
mousedownTrack(e) { mousedownTrack(e) {
this.isDragging = true this.isDragging = true
this.posX = e.x this.posY = e.y
var vol = e.offsetX / this.trackWidth var vol = 1 - e.offsetY / this.trackHeight
vol = Math.min(Math.max(vol, 0), 1) vol = Math.min(Math.max(vol, 0), 1)
this.volume = vol this.volume = vol
document.body.addEventListener('mousemove', this.mousemove) document.body.addEventListener('mousemove', this.mousemove)
@ -137,7 +130,7 @@ export default {
this.clickVolumeIcon() this.clickVolumeIcon()
}, },
clickVolumeTrack(e) { clickVolumeTrack(e) {
var vol = e.offsetX / this.trackWidth var vol = 1 - e.offsetY / this.trackHeight
vol = Math.min(Math.max(vol, 0), 1) vol = Math.min(Math.max(vol, 0), 1)
this.volume = vol this.volume = vol
} }
@ -147,7 +140,7 @@ export default {
this.isMute = true this.isMute = true
} }
const storageVolume = localStorage.getItem('volume') const storageVolume = localStorage.getItem('volume')
if (storageVolume) { if (storageVolume && !isNaN(storageVolume)) {
this.volume = parseFloat(storageVolume) this.volume = parseFloat(storageVolume)
} }
}, },

View file

@ -56,24 +56,15 @@ export default {
}, },
imgSrc() { imgSrc() {
if (!this.imagePath) return null if (!this.imagePath) return null
if (process.env.NODE_ENV !== 'production') { return `${this.$config.routerBasePath}/api/authors/${this.authorId}/image?ts=${this.updatedAt}`
// Testing
return `http://localhost:3333${this.$config.routerBasePath}/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
}
return `/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
} }
}, },
methods: { methods: {
imageLoaded() { imageLoaded() {
var aspectRatio = 1.25
if (this.$refs.wrapper) {
aspectRatio = this.$refs.wrapper.clientHeight / this.$refs.wrapper.clientWidth
}
if (this.$refs.img) { if (this.$refs.img) {
var { naturalWidth, naturalHeight } = this.$refs.img var { naturalWidth, naturalHeight } = this.$refs.img
var imgAr = naturalHeight / naturalWidth var imgAr = naturalHeight / naturalWidth
var arDiff = Math.abs(imgAr - aspectRatio) if (imgAr < 0.5 || imgAr > 2) {
if (arDiff > 0.15) {
this.showCoverBg = true this.showCoverBg = true
} else { } else {
this.showCoverBg = false this.showCoverBg = false

View file

@ -69,6 +69,15 @@
</div> </div>
</div> </div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p id="ereader-permissions-toggle">{{ $strings.LabelPermissionsCreateEreader }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch labeledBy="ereader-permissions-toggle" v-model="newUser.permissions.createEreader" />
</div>
</div>
<div class="flex items-center my-2 max-w-md"> <div class="flex items-center my-2 max-w-md">
<div class="w-1/2"> <div class="w-1/2">
<p id="explicit-content-permissions-toggle">{{ $strings.LabelPermissionsAccessExplicitContent }}</p> <p id="explicit-content-permissions-toggle">{{ $strings.LabelPermissionsAccessExplicitContent }}</p>
@ -111,7 +120,7 @@
</div> </div>
<div class="flex pt-4 px-2"> <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> <ui-btn v-if="isEditingRoot" small class="flex items-center" to="/account">{{ $strings.ButtonChangeRootPassword }}</ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn> <ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
@ -212,19 +221,19 @@ export default {
}, },
unlinkOpenID() { unlinkOpenID() {
const payload = { const payload = {
message: 'Are you sure you want to unlink this user from OpenID?', message: this.$strings.MessageConfirmUnlinkOpenId,
callback: (confirmed) => { callback: (confirmed) => {
if (confirmed) { if (confirmed) {
this.unlinkingFromOpenID = true this.unlinkingFromOpenID = true
this.$axios this.$axios
.$patch(`/api/users/${this.account.id}/openid-unlink`) .$patch(`/api/users/${this.account.id}/openid-unlink`)
.then(() => { .then(() => {
this.$toast.success('User unlinked from OpenID') this.$toast.success(this.$strings.ToastUnlinkOpenIdSuccess)
this.show = false this.show = false
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to unlink user from OpenID', 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(() => { .finally(() => {
this.unlinkingFromOpenID = false this.unlinkingFromOpenID = false
@ -265,15 +274,15 @@ export default {
}, },
submitForm() { submitForm() {
if (!this.newUser.username) { if (!this.newUser.username) {
this.$toast.error('Enter a username') this.$toast.error(this.$strings.ToastNewUserUsernameError)
return return
} }
if (!this.newUser.permissions.accessAllLibraries && !this.newUser.librariesAccessible.length) { 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 return
} }
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsSelected.length) { 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 return
} }
@ -296,7 +305,7 @@ export default {
.then((data) => { .then((data) => {
this.processing = false this.processing = false
if (data.error) { if (data.error) {
this.$toast.error(`${this.$strings.ToastAccountUpdateFailed}: ${data.error}`) this.$toast.error(`${this.$strings.ToastFailedToUpdate}: ${data.error}`)
} else { } else {
console.log('Account updated', data.user) console.log('Account updated', data.user)
@ -313,12 +322,12 @@ export default {
this.processing = false this.processing = false
console.error('Failed to update account', error) console.error('Failed to update account', error)
var errMsg = error.response ? error.response.data || '' : '' var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || 'Failed to update account') this.$toast.error(errMsg || this.$strings.ToastFailedToUpdate)
}) })
}, },
submitCreateAccount() { submitCreateAccount() {
if (!this.newUser.password) { 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 return
} }
@ -329,9 +338,9 @@ export default {
.then((data) => { .then((data) => {
this.processing = false this.processing = false
if (data.error) { if (data.error) {
this.$toast.error(`Failed to create account: ${data.error}`) this.$toast.error(this.$strings.ToastNewUserCreatedFailed + ': ' + data.error)
} else { } else {
this.$toast.success('New account created') this.$toast.success(this.$strings.ToastNewUserCreatedSuccess)
this.show = false this.show = false
} }
}) })
@ -351,9 +360,11 @@ export default {
update: type === 'admin', update: type === 'admin',
delete: type === 'admin', delete: type === 'admin',
upload: type === 'admin', upload: type === 'admin',
accessExplicitContent: type === 'admin',
accessAllLibraries: true, accessAllLibraries: true,
accessAllTags: true, accessAllTags: true,
selectedTagsNotAccessible: false selectedTagsNotAccessible: false,
createEreader: type === 'admin'
} }
}, },
init() { init() {
@ -385,7 +396,9 @@ export default {
upload: false, upload: false,
accessAllLibraries: true, accessAllLibraries: true,
accessAllTags: true, accessAllTags: true,
selectedTagsNotAccessible: false accessExplicitContent: false,
selectedTagsNotAccessible: false,
createEreader: false
}, },
librariesAccessible: [], librariesAccessible: [],
itemTagsSelected: [] itemTagsSelected: []

View file

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

View file

@ -4,7 +4,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p class="text-base text-gray-200 truncate">{{ metadata.filename }}</p> <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-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>
<div class="w-full h-px bg-white bg-opacity-10 my-4" /> <div class="w-full h-px bg-white bg-opacity-10 my-4" />
@ -159,7 +159,7 @@ export default {
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to get ffprobe data', error) console.error('Failed to get ffprobe data', error)
this.$toast.error('FFProbe failed') this.$toast.error(this.$strings.ToastFailedToLoadData)
}) })
.finally(() => { .finally(() => {
this.probingFile = false this.probingFile = false

View file

@ -9,7 +9,7 @@
<widgets-cron-expression-builder ref="expressionBuilder" v-model="newCronExpression" @input="expressionUpdated" /> <widgets-cron-expression-builder ref="expressionBuilder" v-model="newCronExpression" @input="expressionUpdated" />
<div class="flex items-center justify-end"> <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>
</div> </div>
</modals-modal> </modals-modal>

View file

@ -116,10 +116,10 @@ export default {
libraryItemIds: this.selectedBookIds libraryItemIds: this.selectedBookIds
}) })
.then(() => { .then(() => {
this.$toast.info('Batch quick match of ' + this.selectedBookIds.length + ' books started!') this.$toast.info(this.$getString('ToastBatchQuickMatchStarted', [this.selectedBookIds.length]))
}) })
.catch((error) => { .catch((error) => {
this.$toast.error('Batch quick match failed') this.$toast.error(this.$strings.ToastBatchQuickMatchFailed)
console.error('Failed to batch quick match', error) console.error('Failed to batch quick match', error)
}) })
.finally(() => { .finally(() => {

View file

@ -94,7 +94,7 @@ export default {
this.$toast.success(this.$strings.ToastBookmarkRemoveSuccess) this.$toast.success(this.$strings.ToastBookmarkRemoveSuccess)
}) })
.catch((error) => { .catch((error) => {
this.$toast.error(this.$strings.ToastBookmarkRemoveFailed) this.$toast.error(this.$strings.ToastRemoveFailed)
console.error(error) console.error(error)
}) })
this.show = false this.show = false
@ -110,7 +110,7 @@ export default {
this.$toast.success(this.$strings.ToastBookmarkUpdateSuccess) this.$toast.success(this.$strings.ToastBookmarkUpdateSuccess)
}) })
.catch((error) => { .catch((error) => {
this.$toast.error(this.$strings.ToastBookmarkUpdateFailed) this.$toast.error(this.$strings.ToastFailedToUpdate)
console.error(error) console.error(error)
}) })
this.show = false 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="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
<div class="flex"> <div class="flex">
<div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80"> <div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80">
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!isNewSeries" :label="$strings.LabelSeriesName" /> <ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!isNewSeries" :label="$strings.LabelSeriesName" @input="seriesNameInputHandler" />
</div> </div>
<div class="w-24 sm:w-28 md:w-40 p-1"> <div class="w-24 sm:w-28 md:w-40 p-1">
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" :label="$strings.LabelSequence" /> <ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" :label="$strings.LabelSequence" />
@ -66,6 +66,11 @@ export default {
} }
}, },
methods: { methods: {
seriesNameInputHandler() {
if (this.$refs.sequenceInput) {
this.$refs.sequenceInput.setFocus()
}
},
setInputFocus() { setInputFocus() {
if (this.isNewSeries) { if (this.isNewSeries) {
// Focus on series input if new series // Focus on series input if new series

View file

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

View file

@ -112,11 +112,11 @@ export default {
return this.$store.state.user.user return this.$store.state.user.user
}, },
demoShareUrl() { demoShareUrl() {
return `${window.origin}/share/${this.newShareSlug}` return `${window.origin}${this.$config.routerBasePath}/share/${this.newShareSlug}`
}, },
currentShareUrl() { currentShareUrl() {
if (!this.currentShare) return '' if (!this.currentShare) return ''
return `${window.origin}/share/${this.currentShare.slug}` return `${window.origin}${this.$config.routerBasePath}/share/${this.currentShare.slug}`
}, },
currentShareTimeRemaining() { currentShareTimeRemaining() {
if (!this.currentShare) return 'Error' if (!this.currentShare) return 'Error'
@ -165,7 +165,7 @@ export default {
}, },
openShare() { openShare() {
if (!this.newShareSlug) { if (!this.newShareSlug) {
this.$toast.error('Slug is required') this.$toast.error(this.$strings.ToastSlugRequired)
return return
} }
const payload = { const payload = {

View file

@ -15,7 +15,7 @@
</template> </template>
<form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime"> <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-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> </form>
</div> </div>
<div v-if="timerSet" class="w-full p-4"> <div v-if="timerSet" class="w-full p-4">

View file

@ -78,14 +78,13 @@ export default {
if (data.error) { if (data.error) {
this.$toast.error(data.error) this.$toast.error(data.error)
} else { } else {
this.$toast.success('Cover Uploaded')
this.resetCoverPreview() this.resetCoverPreview()
} }
this.processingUpload = false this.processingUpload = false
}) })
.catch((error) => { .catch((error) => {
console.error('Failed', 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.$toast.error(errorMsg)
this.processingUpload = false 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) => { 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) 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) this.$toast.error(errorMsg)
return false return false
}) })

View file

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

View file

@ -6,10 +6,15 @@
</div> </div>
</template> </template>
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh"> <div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
<template v-for="release in releasesToShow">
<div :key="release.name">
<p class="text-xl font-bold pb-4"> <p class="text-xl font-bold pb-4">
Changelog <a :href="currentTagUrl" target="_blank" class="hover:underline">v{{ currentVersionNumber }}</a> ({{ currentVersionPubDate }}) Changelog <a :href="`https://github.com/advplyr/audiobookshelf/releases/tag/${release.name}`" target="_blank" class="hover:underline">{{ release.name }}</a> ({{ $formatDate(release.pubdate, dateFormat) }})
</p> </p>
<div class="custom-text" v-html="compiledMarkedown" /> <div class="custom-text" v-html="getChangelog(release)" />
</div>
<div v-if="release !== releasesToShow[releasesToShow.length - 1]" class="border-b border-black-300 my-8" />
</template>
</div> </div>
</modals-modal> </modals-modal>
</template> </template>
@ -37,24 +42,15 @@ export default {
dateFormat() { dateFormat() {
return this.$store.state.serverSettings.dateFormat return this.$store.state.serverSettings.dateFormat
}, },
changelog() { releasesToShow() {
return this.versionData?.currentVersionChangelog || 'No Changelog Available' return this.versionData?.releasesToShow || []
}, }
compiledMarkedown() { },
return marked.parse(this.changelog, { gfm: true, breaks: true }) methods: {
}, getChangelog(release) {
currentVersionPubDate() { return marked.parse(release.changelog || 'No Changelog Available', { gfm: true, breaks: true })
if (!this.versionData?.currentVersionPubDate) return 'Unknown release date'
return `${this.$formatDate(this.versionData.currentVersionPubDate, this.dateFormat)}`
},
currentTagUrl() {
return this.versionData?.currentTagUrl
},
currentVersionNumber() {
return this.$config.version
} }
}, },
methods: {},
mounted() {} mounted() {}
} }
</script> </script>

View file

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

View file

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

View file

@ -125,12 +125,12 @@ export default {
this.$refs.ereaderEmailInput.blur() this.$refs.ereaderEmailInput.blur()
if (!this.newDevice.name?.trim() || !this.newDevice.email?.trim()) { if (!this.newDevice.name?.trim() || !this.newDevice.email?.trim()) {
this.$toast.error('Name and email required') this.$toast.error(this.$strings.ToastNameEmailRequired)
return return
} }
if (this.newDevice.availabilityOption === 'specificUsers' && !this.newDevice.users.length) { 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 return
} }
if (this.newDevice.availabilityOption !== 'specificUsers') { if (this.newDevice.availabilityOption !== 'specificUsers') {
@ -142,14 +142,14 @@ export default {
if (!this.ereaderDevice) { if (!this.ereaderDevice) {
if (this.existingDevices.some((d) => d.name === this.newDevice.name)) { 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 return
} }
this.submitCreate() this.submitCreate()
} else { } else {
if (this.ereaderDevice.name !== this.newDevice.name && this.existingDevices.some((d) => d.name === this.newDevice.name)) { 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 return
} }
@ -174,12 +174,11 @@ export default {
.$post(`/api/emails/ereader-devices`, payload) .$post(`/api/emails/ereader-devices`, payload)
.then((data) => { .then((data) => {
this.$emit('update', data.ereaderDevices) this.$emit('update', data.ereaderDevices)
this.$toast.success('Device updated')
this.show = false this.show = false
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to update device', error) console.error('Failed to update device', error)
this.$toast.error('Failed to update device') this.$toast.error(this.$strings.ToastFailedToUpdate)
}) })
.finally(() => { .finally(() => {
this.processing = false this.processing = false
@ -201,12 +200,11 @@ export default {
.$post('/api/emails/ereader-devices', payload) .$post('/api/emails/ereader-devices', payload)
.then((data) => { .then((data) => {
this.$emit('update', data.ereaderDevices || []) this.$emit('update', data.ereaderDevices || [])
this.$toast.success('Device added')
this.show = false this.show = false
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to add device', error) console.error('Failed to add device', error)
this.$toast.error('Failed to add device') this.$toast.error(this.$strings.ToastDeviceAddFailed)
}) })
.finally(() => { .finally(() => {
this.processing = false this.processing = false

View file

@ -0,0 +1,188 @@
<template>
<modals-modal ref="modal" v-model="show" name="ereader-device-edit" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<form @submit.prevent="submitForm">
<div class="w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300">
<div class="w-full px-3 py-5 md:p-12">
<div class="flex items-center -mx-1 mb-4">
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="ereaderNameInput" v-model="newDevice.name" :disabled="processing" :label="$strings.LabelName" />
</div>
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="ereaderEmailInput" v-model="newDevice.email" :disabled="processing" :label="$strings.LabelEmail" />
</div>
</div>
<div class="flex items-center pt-4">
<div class="flex-grow" />
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
</div>
</div>
</div>
</form>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
existingDevices: {
type: Array,
default: () => []
},
ereaderDevice: {
type: Object,
default: () => null
}
},
data() {
return {
processing: false,
newDevice: {
name: '',
email: '',
availabilityOption: 'adminAndUp',
users: []
}
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
user() {
return this.$store.state.user.user
},
title() {
return !this.ereaderDevice ? 'Create Device' : 'Update Device'
}
},
methods: {
submitForm() {
this.$refs.ereaderNameInput.blur()
this.$refs.ereaderEmailInput.blur()
if (!this.newDevice.name?.trim() || !this.newDevice.email?.trim()) {
this.$toast.error(this.$strings.ToastNameEmailRequired)
return
}
this.newDevice.name = this.newDevice.name.trim()
this.newDevice.email = this.newDevice.email.trim()
// Only catches duplicate names for the current user
// Duplicates with other users caught on server side
if (!this.ereaderDevice) {
if (this.existingDevices.some((d) => d.name === this.newDevice.name)) {
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(this.$strings.ToastDeviceNameAlreadyExists)
return
}
this.submitUpdate()
}
},
submitUpdate() {
this.processing = true
const existingDevicesWithoutThisOne = this.existingDevices.filter((d) => d.name !== this.ereaderDevice.name)
const payload = {
ereaderDevices: [
...existingDevicesWithoutThisOne,
{
...this.newDevice
}
]
}
this.$axios
.$post(`/api/me/ereader-devices`, payload)
.then((data) => {
this.$emit('update', data.ereaderDevices)
this.show = false
})
.catch((error) => {
console.error('Failed to update device', error)
if (error.response?.data?.toLowerCase().includes('duplicate')) {
this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)
} else {
this.$toast.error(this.$strings.ToastDeviceAddFailed)
}
})
.finally(() => {
this.processing = false
})
},
submitCreate() {
this.processing = true
const payload = {
ereaderDevices: [
...this.existingDevices,
{
...this.newDevice
}
]
}
this.$axios
.$post('/api/me/ereader-devices', payload)
.then((data) => {
this.$emit('update', data.ereaderDevices || [])
this.show = false
})
.catch((error) => {
console.error('Failed to add device', error)
if (error.response?.data?.toLowerCase().includes('duplicate')) {
this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)
} else {
this.$toast.error(this.$strings.ToastDeviceAddFailed)
}
})
.finally(() => {
this.processing = false
})
},
init() {
if (this.ereaderDevice) {
this.newDevice.name = this.ereaderDevice.name
this.newDevice.email = this.ereaderDevice.email
this.newDevice.availabilityOption = this.ereaderDevice.availabilityOption || 'specificUsers'
this.newDevice.users = this.ereaderDevice.users || [this.user.id]
} else {
this.newDevice.name = ''
this.newDevice.email = ''
this.newDevice.availabilityOption = 'specificUsers'
this.newDevice.users = [this.user.id]
}
}
},
mounted() {}
}
</script>

View file

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

View file

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

View file

@ -6,7 +6,7 @@
<ui-text-input-with-label ref="maxEpisodesInput" v-model="maxEpisodesToDownload" :disabled="checkingNewEpisodes" type="number" :label="$strings.LabelLimit" class="w-16 mr-2" input-class="h-10"> <ui-text-input-with-label ref="maxEpisodesInput" v-model="maxEpisodesToDownload" :disabled="checkingNewEpisodes" type="number" :label="$strings.LabelLimit" class="w-16 mr-2" input-class="h-10">
<div class="flex -mb-0.5"> <div class="flex -mb-0.5">
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': checkingNewEpisodes }">{{ $strings.LabelLimit }}</p> <p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': checkingNewEpisodes }">{{ $strings.LabelLimit }}</p>
<ui-tooltip direction="top" text="Max # of episodes to download. Use 0 for unlimited."> <ui-tooltip direction="top" :text="$strings.LabelMaxEpisodesToDownload">
<span class="material-symbols text-base">info</span> <span class="material-symbols text-base">info</span>
</ui-tooltip> </ui-tooltip>
</div> </div>
@ -99,7 +99,7 @@ export default {
if (this.maxEpisodesToDownload < 0) { if (this.maxEpisodesToDownload < 0) {
this.maxEpisodesToDownload = 3 this.maxEpisodesToDownload = 3
this.$toast.error('Invalid max episodes to download') this.$toast.error(this.$strings.ToastInvalidMaxEpisodesToDownload)
return return
} }
@ -120,9 +120,9 @@ export default {
.then((response) => { .then((response) => {
if (response.episodes && response.episodes.length) { if (response.episodes && response.episodes.length) {
console.log('New episodes', response.episodes.length) console.log('New episodes', response.episodes.length)
this.$toast.success(`${response.episodes.length} new episodes found!`) this.$toast.success(this.$getString('ToastNewEpisodesFound', [response.episodes.length]))
} else { } else {
this.$toast.info('No new episodes found') this.$toast.info(this.$strings.ToastNoNewEpisodesFound)
} }
this.checkingNewEpisodes = false this.checkingNewEpisodes = false
}) })

View file

@ -59,49 +59,63 @@
<ui-checkbox v-model="selectedMatchUsage.title" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.title" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" :label="$strings.LabelTitle" /> <ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" :label="$strings.LabelTitle" />
<p v-if="mediaMetadata.title" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.title || '' }}</p> <p v-if="mediaMetadata.title" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('title', mediaMetadata.title)">{{ mediaMetadata.title || '' }}</a>
</p>
</div> </div>
</div> </div>
<div v-if="selectedMatchOrig.subtitle" class="flex items-center py-2"> <div v-if="selectedMatchOrig.subtitle" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.subtitle" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.subtitle" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" :label="$strings.LabelSubtitle" /> <ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" :label="$strings.LabelSubtitle" />
<p v-if="mediaMetadata.subtitle" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.subtitle || '' }}</p> <p v-if="mediaMetadata.subtitle" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('subtitle', mediaMetadata.subtitle)">{{ mediaMetadata.subtitle }}</a>
</p>
</div> </div>
</div> </div>
<div v-if="selectedMatchOrig.author" class="flex items-center py-2"> <div v-if="selectedMatchOrig.author" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.author" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.author" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" :label="$strings.LabelAuthor" /> <ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" :label="$strings.LabelAuthor" />
<p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.authorName || '' }}</p> <p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('author', mediaMetadata.authorName)">{{ mediaMetadata.authorName }}</a>
</p>
</div> </div>
</div> </div>
<div v-if="selectedMatchOrig.narrator" class="flex items-center py-2"> <div v-if="selectedMatchOrig.narrator" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.narrator" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.narrator" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4"> <div class="flex-grow ml-4">
<ui-multi-select v-model="selectedMatch.narrator" :items="narrators" :disabled="!selectedMatchUsage.narrator" :label="$strings.LabelNarrators" /> <ui-multi-select v-model="selectedMatch.narrator" :items="narrators" :disabled="!selectedMatchUsage.narrator" :label="$strings.LabelNarrators" />
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.narratorName || '' }}</p> <p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('narrator', mediaMetadata.narrators)">{{ mediaMetadata.narratorName }}</a>
</p>
</div> </div>
</div> </div>
<div v-if="selectedMatchOrig.description" class="flex items-center py-2"> <div v-if="selectedMatchOrig.description" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.description" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.description" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4"> <div class="flex-grow ml-4">
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" :label="$strings.LabelDescription" /> <ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" :label="$strings.LabelDescription" />
<p v-if="mediaMetadata.description" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.description.substr(0, 100) + (mediaMetadata.description.length > 100 ? '...' : '') }}</p> <p v-if="mediaMetadata.description" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('description', mediaMetadata.description)">{{ mediaMetadata.description.substr(0, 100) + (mediaMetadata.description.length > 100 ? '...' : '') }}</a>
</p>
</div> </div>
</div> </div>
<div v-if="selectedMatchOrig.publisher" class="flex items-center py-2"> <div v-if="selectedMatchOrig.publisher" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.publisher" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.publisher" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" :label="$strings.LabelPublisher" /> <ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" :label="$strings.LabelPublisher" />
<p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.publisher || '' }}</p> <p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publisher', mediaMetadata.publisher)">{{ mediaMetadata.publisher }}</a>
</p>
</div> </div>
</div> </div>
<div v-if="selectedMatchOrig.publishedYear" class="flex items-center py-2"> <div v-if="selectedMatchOrig.publishedYear" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.publishedYear" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.publishedYear" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" :label="$strings.LabelPublishYear" /> <ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" :label="$strings.LabelPublishYear" />
<p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.publishedYear || '' }}</p> <p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publishedYear', mediaMetadata.publishedYear)">{{ mediaMetadata.publishedYear }}</a>
</p>
</div> </div>
</div> </div>
@ -109,42 +123,54 @@
<ui-checkbox v-model="selectedMatchUsage.series" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.series" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4"> <div class="flex-grow ml-4">
<widgets-series-input-widget v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" /> <widgets-series-input-widget v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" />
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.seriesName || '' }}</p> <p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('series', mediaMetadata.series)">{{ mediaMetadata.seriesName }}</a>
</p>
</div> </div>
</div> </div>
<div v-if="selectedMatchOrig.genres && selectedMatchOrig.genres.length" class="flex items-center py-2"> <div v-if="selectedMatchOrig.genres?.length" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4"> <div class="flex-grow ml-4">
<ui-multi-select v-model="selectedMatch.genres" :items="genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" /> <ui-multi-select v-model="selectedMatch.genres" :items="genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
<p v-if="mediaMetadata.genres" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.genres.join(', ') }}</p> <p v-if="mediaMetadata.genres?.length" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('genres', mediaMetadata.genres)">{{ mediaMetadata.genres.join(', ') }}</a>
</p>
</div> </div>
</div> </div>
<div v-if="selectedMatchOrig.tags" class="flex items-center py-2"> <div v-if="selectedMatchOrig.tags" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.tags" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.tags" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4"> <div class="flex-grow ml-4">
<ui-multi-select v-model="selectedMatch.tags" :items="tags" :disabled="!selectedMatchUsage.tags" :label="$strings.LabelTags" /> <ui-multi-select v-model="selectedMatch.tags" :items="tags" :disabled="!selectedMatchUsage.tags" :label="$strings.LabelTags" />
<p v-if="media.tags" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ media.tags.join(', ') }}</p> <p v-if="media.tags?.length" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('tags', media.tags)">{{ media.tags.join(', ') }}</a>
</p>
</div> </div>
</div> </div>
<div v-if="selectedMatchOrig.language" class="flex items-center py-2"> <div v-if="selectedMatchOrig.language" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.language" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.language" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.language" :disabled="!selectedMatchUsage.language" :label="$strings.LabelLanguage" /> <ui-text-input-with-label v-model="selectedMatch.language" :disabled="!selectedMatchUsage.language" :label="$strings.LabelLanguage" />
<p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.language || '' }}</p> <p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('language', mediaMetadata.language)">{{ mediaMetadata.language }}</a>
</p>
</div> </div>
</div> </div>
<div v-if="selectedMatchOrig.isbn" class="flex items-center py-2"> <div v-if="selectedMatchOrig.isbn" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.isbn" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.isbn" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" /> <ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" />
<p v-if="mediaMetadata.isbn" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.isbn || '' }}</p> <p v-if="mediaMetadata.isbn" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('isbn', mediaMetadata.isbn)">{{ mediaMetadata.isbn }}</a>
</p>
</div> </div>
</div> </div>
<div v-if="selectedMatchOrig.asin" class="flex items-center py-2"> <div v-if="selectedMatchOrig.asin" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.asin" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.asin" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" /> <ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" />
<p v-if="mediaMetadata.asin" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.asin || '' }}</p> <p v-if="mediaMetadata.asin" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('asin', mediaMetadata.asin)">{{ mediaMetadata.asin }}</a>
</p>
</div> </div>
</div> </div>
@ -152,28 +178,36 @@
<ui-checkbox v-model="selectedMatchUsage.itunesId" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.itunesId" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.itunesId" type="number" :disabled="!selectedMatchUsage.itunesId" label="iTunes ID" /> <ui-text-input-with-label v-model="selectedMatch.itunesId" type="number" :disabled="!selectedMatchUsage.itunesId" label="iTunes ID" />
<p v-if="mediaMetadata.itunesId" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.itunesId || '' }}</p> <p v-if="mediaMetadata.itunesId" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('itunesId', mediaMetadata.itunesId)">{{ mediaMetadata.itunesId }}</a>
</p>
</div> </div>
</div> </div>
<div v-if="selectedMatchOrig.feedUrl" class="flex items-center py-2"> <div v-if="selectedMatchOrig.feedUrl" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.feedUrl" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.feedUrl" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.feedUrl" :disabled="!selectedMatchUsage.feedUrl" label="RSS Feed URL" /> <ui-text-input-with-label v-model="selectedMatch.feedUrl" :disabled="!selectedMatchUsage.feedUrl" label="RSS Feed URL" />
<p v-if="mediaMetadata.feedUrl" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.feedUrl || '' }}</p> <p v-if="mediaMetadata.feedUrl" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('feedUrl', mediaMetadata.feedUrl)">{{ mediaMetadata.feedUrl }}</a>
</p>
</div> </div>
</div> </div>
<div v-if="selectedMatchOrig.itunesPageUrl" class="flex items-center py-2"> <div v-if="selectedMatchOrig.itunesPageUrl" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.itunesPageUrl" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.itunesPageUrl" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.itunesPageUrl" :disabled="!selectedMatchUsage.itunesPageUrl" label="iTunes Page URL" /> <ui-text-input-with-label v-model="selectedMatch.itunesPageUrl" :disabled="!selectedMatchUsage.itunesPageUrl" label="iTunes Page URL" />
<p v-if="mediaMetadata.itunesPageUrl" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.itunesPageUrl || '' }}</p> <p v-if="mediaMetadata.itunesPageUrl" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('itunesPageUrl', mediaMetadata.itunesPageUrl)">{{ mediaMetadata.itunesPageUrl }}</a>
</p>
</div> </div>
</div> </div>
<div v-if="selectedMatchOrig.releaseDate" class="flex items-center py-2"> <div v-if="selectedMatchOrig.releaseDate" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.releaseDate" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.releaseDate" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4"> <div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.releaseDate" :disabled="!selectedMatchUsage.releaseDate" :label="$strings.LabelReleaseDate" /> <ui-text-input-with-label v-model="selectedMatch.releaseDate" :disabled="!selectedMatchUsage.releaseDate" :label="$strings.LabelReleaseDate" />
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.releaseDate || '' }}</p> <p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('releaseDate', mediaMetadata.releaseDate)">{{ mediaMetadata.releaseDate }}</a>
</p>
</div> </div>
</div> </div>
<div v-if="selectedMatchOrig.explicit != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.explicit == null }"> <div v-if="selectedMatchOrig.explicit != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.explicit == null }">
@ -281,7 +315,7 @@ export default {
return this.$store.getters['libraries/getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
filterData() { filterData() {
return this.$store.state.libraries.filterData return this.$store.state.libraries.filterData || {}
}, },
providers() { providers() {
if (this.isPodcast) return this.$store.state.scanners.podcastProviders if (this.isPodcast) return this.$store.state.scanners.podcastProviders
@ -321,6 +355,13 @@ export default {
} }
}, },
methods: { methods: {
setMatchFieldValue(field, value) {
if (Array.isArray(value)) {
this.selectedMatch[field] = [...value]
} else {
this.selectedMatch[field] = value
}
},
selectAllToggled(val) { selectAllToggled(val) {
for (const key in this.selectedMatchUsage) { for (const key in this.selectedMatchUsage) {
this.selectedMatchUsage[key] = val this.selectedMatchUsage[key] = val
@ -356,7 +397,7 @@ export default {
}, },
submitSearch() { submitSearch() {
if (!this.searchTitle) { if (!this.searchTitle) {
this.$toast.warning('Search title is required') this.$toast.warning(this.$strings.ToastTitleRequired)
return return
} }
this.persistProvider() this.persistProvider()
@ -577,12 +618,12 @@ export default {
if (updateResult.updated) { if (updateResult.updated) {
this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess) this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)
} else { } else {
this.$toast.info(this.$strings.ToastItemDetailsUpdateUnneeded) this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
} }
this.clearSelectedMatch() this.clearSelectedMatch()
this.$emit('selectTab', 'details') this.$emit('selectTab', 'details')
} else { } else {
this.$toast.error(this.$strings.ToastItemDetailsUpdateFailed) this.$toast.error(this.$strings.ToastFailedToUpdate)
} }
} else { } else {
this.clearSelectedMatch() this.clearSelectedMatch()

View file

@ -2,28 +2,28 @@
<div class="w-full h-full relative"> <div class="w-full h-full relative">
<div id="scheduleWrapper" class="w-full overflow-y-auto px-2 py-4 md:px-6 md:py-6"> <div id="scheduleWrapper" class="w-full overflow-y-auto px-2 py-4 md:px-6 md:py-6">
<template v-if="!feedUrl"> <template v-if="!feedUrl">
<widgets-alert type="warning" class="text-base mb-4">No RSS feed URL is set for this podcast</widgets-alert> <widgets-alert type="warning" class="text-base mb-4">{{ $strings.ToastPodcastNoRssFeed }}</widgets-alert>
</template> </template>
<template v-if="feedUrl || autoDownloadEpisodes"> <template v-if="feedUrl || autoDownloadEpisodes">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<p class="text-base md:text-xl font-semibold">Schedule Automatic Episode Downloads</p> <p class="text-base md:text-xl font-semibold">{{ $strings.HeaderScheduleEpisodeDownloads }}</p>
<ui-checkbox v-model="enableAutoDownloadEpisodes" label="Enable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" /> <ui-checkbox v-model="enableAutoDownloadEpisodes" :label="$strings.LabelEnable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
</div> </div>
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2"> <div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
<ui-text-input ref="maxEpisodesInput" type="number" v-model="newMaxEpisodesToKeep" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updatedMaxEpisodesToKeep" /> <ui-text-input ref="maxEpisodesInput" type="number" v-model="newMaxEpisodesToKeep" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updatedMaxEpisodesToKeep" />
<ui-tooltip text="Value of 0 sets no max limit. After a new episode is auto-downloaded this will delete the oldest episode if you have more than X episodes. <br>This will only delete 1 episode per new download."> <ui-tooltip :text="$strings.LabelMaxEpisodesToKeepHelp">
<p class="pl-4 text-base"> <p class="pl-4 text-base">
Max episodes to keep {{ $strings.LabelMaxEpisodesToKeep }}
<span class="material-symbols icon-text">info</span> <span class="material-symbols icon-text">info</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2"> <div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
<ui-text-input ref="maxEpisodesToDownloadInput" type="number" v-model="newMaxNewEpisodesToDownload" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updateMaxNewEpisodesToDownload" /> <ui-text-input ref="maxEpisodesToDownloadInput" type="number" v-model="newMaxNewEpisodesToDownload" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updateMaxNewEpisodesToDownload" />
<ui-tooltip text="Value of 0 sets no max limit. When checking for new episodes this is the max number of episodes that will be downloaded."> <ui-tooltip :text="$strings.LabelUseZeroForUnlimited">
<p class="pl-4 text-base"> <p class="pl-4 text-base">
Max new episodes to download per check {{ $strings.LabelMaxEpisodesToDownloadPerCheck }}
<span class="material-symbols icon-text">info</span> <span class="material-symbols icon-text">info</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
@ -36,7 +36,7 @@
<div v-if="feedUrl || autoDownloadEpisodes" class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg border-t border-white border-opacity-5"> <div v-if="feedUrl || autoDownloadEpisodes" class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg border-t border-white border-opacity-5">
<div class="flex items-center px-2 md:px-4"> <div class="flex items-center px-2 md:px-4">
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn @click="save" :disabled="!isUpdated" :color="isUpdated ? 'success' : 'primary'" class="mx-2">{{ isUpdated ? 'Save' : 'No update necessary' }}</ui-btn> <ui-btn @click="save" :disabled="!isUpdated" :color="isUpdated ? 'success' : 'primary'" class="mx-2">{{ isUpdated ? $strings.ButtonSave : $strings.MessageNoUpdatesWereNecessary }}</ui-btn>
</div> </div>
</div> </div>
</div> </div>
@ -163,7 +163,7 @@ export default {
this.isProcessing = false this.isProcessing = false
if (updateResult) { if (updateResult) {
if (updateResult.updated) { if (updateResult.updated) {
this.$toast.success('Item details updated') this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)
return true return true
} else { } else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary) this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)

View file

@ -33,18 +33,18 @@
<span class="material-symbols text-lg ml-2">launch</span> <span class="material-symbols text-lg ml-2">launch</span>
</ui-btn> </ui-btn>
<ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">Quick Embed</ui-btn> <ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">{{ $strings.ButtonQuickEmbed }}</ui-btn>
</div> </div>
</div> </div>
<!-- queued alert --> <!-- queued alert -->
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mt-4"> <widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mt-4">
<p class="text-lg">Queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p> <p class="text-lg">{{ $getString('MessageQuickEmbedQueue', [queuedEmbedLIds.length]) }}</p>
</widgets-alert> </widgets-alert>
<!-- processing alert --> <!-- processing alert -->
<widgets-alert v-if="isEmbedTaskRunning" type="warning" class="mt-4"> <widgets-alert v-if="isEmbedTaskRunning" type="warning" class="mt-4">
<p class="text-lg">Currently embedding metadata</p> <p class="text-lg">{{ $strings.MessageQuickEmbedInProgress }}</p>
</widgets-alert> </widgets-alert>
</div> </div>
@ -113,7 +113,7 @@ export default {
methods: { methods: {
quickEmbed() { quickEmbed() {
const payload = { const payload = {
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?', message: this.$strings.MessageConfirmQuickEmbed,
callback: (confirmed) => { callback: (confirmed) => {
if (confirmed) { if (confirmed) {
this.$axios this.$axios

View file

@ -111,7 +111,6 @@ export default {
}, },
updateLibrary(library) { updateLibrary(library) {
this.mapLibraryToCopy(library) this.mapLibraryToCopy(library)
console.log('Updated library', this.libraryCopy)
}, },
getNewLibraryData() { getNewLibraryData() {
return { return {
@ -128,7 +127,9 @@ export default {
autoScanCronExpression: null, autoScanCronExpression: null,
hideSingleBookSeries: false, hideSingleBookSeries: false,
onlyShowLaterBooksInContinueSeries: false, onlyShowLaterBooksInContinueSeries: false,
metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'] metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'],
markAsFinishedPercentComplete: null,
markAsFinishedTimeRemaining: 10
} }
} }
}, },
@ -156,11 +157,11 @@ export default {
}, },
validate() { validate() {
if (!this.libraryCopy.name) { if (!this.libraryCopy.name) {
this.$toast.error('Library must have a name') this.$toast.error(this.$strings.ToastNameRequired)
return false return false
} }
if (!this.libraryCopy.folders.length) { if (!this.libraryCopy.folders.length) {
this.$toast.error('Library must have at least 1 path') this.$toast.error(this.$strings.ToastMustHaveAtLeastOnePath)
return false return false
} }
@ -205,7 +206,7 @@ export default {
submitUpdateLibrary() { submitUpdateLibrary() {
var newLibraryPayload = this.getLibraryUpdatePayload() var newLibraryPayload = this.getLibraryUpdatePayload()
if (!Object.keys(newLibraryPayload).length) { if (!Object.keys(newLibraryPayload).length) {
this.$toast.info('No updates are necessary') this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
return return
} }
@ -222,7 +223,7 @@ export default {
if (error.response && error.response.data) { if (error.response && error.response.data) {
this.$toast.error(error.response.data) this.$toast.error(error.response.data)
} else { } else {
this.$toast.error(this.$strings.ToastLibraryUpdateFailed) this.$toast.error(this.$strings.ToastFailedToUpdate)
} }
this.processing = false this.processing = false
}) })
@ -236,7 +237,6 @@ export default {
this.show = false this.show = false
this.$toast.success(this.$getString('ToastLibraryCreateSuccess', [res.name])) this.$toast.success(this.$getString('ToastLibraryCreateSuccess', [res.name]))
if (!this.$store.state.libraries.currentLibraryId) { if (!this.$store.state.libraries.currentLibraryId) {
console.log('Setting initially library id', res.id)
// First library added // First library added
this.$store.dispatch('libraries/fetch', res.id) this.$store.dispatch('libraries/fetch', res.id)
} }

View file

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

View file

@ -1,79 +1,95 @@
<template> <template>
<div class="w-full h-full px-1 md:px-4 py-1 mb-4"> <div class="w-full h-full px-1 md:px-4 py-1 mb-4">
<div class="flex items-center py-3"> <div class="flex flex-wrap">
<ui-toggle-switch v-model="useSquareBookCovers" @input="formUpdated" /> <div class="flex items-center p-2 w-full md:w-1/2">
<ui-toggle-switch v-model="useSquareBookCovers" size="sm" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsSquareBookCoversHelp"> <ui-tooltip :text="$strings.LabelSettingsSquareBookCoversHelp">
<p class="pl-4 text-base"> <p class="pl-4 text-sm">
{{ $strings.LabelSettingsSquareBookCovers }} {{ $strings.LabelSettingsSquareBookCovers }}
<span class="material-symbols icon-text text-sm">info</span> <span class="material-symbols icon-text text-sm">info</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div class="py-3"> <div class="p-2 w-full md:w-1/2">
<div class="flex items-center"> <div class="flex items-center">
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="enableWatcher" @input="formUpdated" /> <ui-toggle-switch v-if="!globalWatcherDisabled" v-model="enableWatcher" size="sm" @input="formUpdated" />
<ui-toggle-switch v-else disabled :value="false" /> <ui-toggle-switch v-else disabled size="sm" :value="false" />
<p class="pl-4 text-base">{{ $strings.LabelSettingsEnableWatcherForLibrary }}</p> <p class="pl-4 text-sm">{{ $strings.LabelSettingsEnableWatcherForLibrary }}</p>
</div> </div>
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p> <p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p>
</div> </div>
<div v-if="isBookLibrary" class="flex items-center py-3"> <div v-if="isBookLibrary" class="flex items-center p-2 w-full md:w-1/2">
<ui-toggle-switch v-model="audiobooksOnly" @input="formUpdated" /> <ui-toggle-switch v-model="audiobooksOnly" size="sm" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsAudiobooksOnlyHelp"> <ui-tooltip :text="$strings.LabelSettingsAudiobooksOnlyHelp">
<p class="pl-4 text-base"> <p class="pl-4 text-sm">
{{ $strings.LabelSettingsAudiobooksOnly }} {{ $strings.LabelSettingsAudiobooksOnly }}
<span class="material-symbols icon-text text-sm">info</span> <span class="material-symbols icon-text text-sm">info</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
<div v-if="isBookLibrary" class="py-3"> <div v-if="isBookLibrary" class="p-2 w-full md:w-1/2">
<div class="flex items-center"> <div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" /> <ui-toggle-switch v-model="skipMatchingMediaWithAsin" size="sm" @input="formUpdated" />
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithASIN }}</p> <p class="pl-4 text-sm">{{ $strings.LabelSettingsSkipMatchingBooksWithASIN }}</p>
</div> </div>
</div> </div>
<div v-if="isBookLibrary" class="py-3"> <div v-if="isBookLibrary" class="p-2 w-full md:w-1/2">
<div class="flex items-center"> <div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" /> <ui-toggle-switch v-model="skipMatchingMediaWithIsbn" size="sm" @input="formUpdated" />
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithISBN }}</p> <p class="pl-4 text-sm">{{ $strings.LabelSettingsSkipMatchingBooksWithISBN }}</p>
</div> </div>
</div> </div>
<div v-if="isBookLibrary" class="py-3"> <div v-if="isBookLibrary" class="p-2 w-full md:w-1/2">
<div class="flex items-center"> <div class="flex items-center">
<ui-toggle-switch v-model="hideSingleBookSeries" @input="formUpdated" /> <ui-toggle-switch v-model="hideSingleBookSeries" size="sm" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsHideSingleBookSeriesHelp"> <ui-tooltip :text="$strings.LabelSettingsHideSingleBookSeriesHelp">
<p class="pl-4 text-base"> <p class="pl-4 text-sm">
{{ $strings.LabelSettingsHideSingleBookSeries }} {{ $strings.LabelSettingsHideSingleBookSeries }}
<span class="material-symbols icon-text text-sm">info</span> <span class="material-symbols icon-text text-sm">info</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
</div> </div>
<div v-if="isBookLibrary" class="py-3"> <div v-if="isBookLibrary" class="p-2 w-full md:w-1/2">
<div class="flex items-center"> <div class="flex items-center">
<ui-toggle-switch v-model="onlyShowLaterBooksInContinueSeries" @input="formUpdated" /> <ui-toggle-switch v-model="onlyShowLaterBooksInContinueSeries" size="sm" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp"> <ui-tooltip :text="$strings.LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp">
<p class="pl-4 text-base"> <p class="pl-4 text-sm">
{{ $strings.LabelSettingsOnlyShowLaterBooksInContinueSeries }} {{ $strings.LabelSettingsOnlyShowLaterBooksInContinueSeries }}
<span class="material-symbols icon-text text-sm">info</span> <span class="material-symbols icon-text text-sm">info</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
</div> </div>
<div v-if="isBookLibrary" class="py-3"> <div v-if="isBookLibrary" class="p-2 w-full md:w-1/2">
<div class="flex items-center"> <div class="flex items-center">
<ui-toggle-switch v-model="epubsAllowScriptedContent" @input="formUpdated" /> <ui-toggle-switch v-model="epubsAllowScriptedContent" size="sm" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsEpubsAllowScriptedContentHelp"> <ui-tooltip :text="$strings.LabelSettingsEpubsAllowScriptedContentHelp">
<p class="pl-4 text-base"> <p class="pl-4 text-sm">
{{ $strings.LabelSettingsEpubsAllowScriptedContent }} {{ $strings.LabelSettingsEpubsAllowScriptedContent }}
<span class="material-symbols icon-text text-sm">info</span> <span class="material-symbols icon-text text-sm">info</span>
</p> </p>
</ui-tooltip> </ui-tooltip>
</div> </div>
</div> </div>
<div v-if="isPodcastLibrary" class="py-3"> <div v-if="isPodcastLibrary" class="p-2 w-full md:w-1/2">
<ui-dropdown :label="$strings.LabelPodcastSearchRegion" v-model="podcastSearchRegion" :items="$podcastSearchRegionOptions" small class="max-w-72" menu-max-height="200px" @input="formUpdated" /> <ui-dropdown :label="$strings.LabelPodcastSearchRegion" v-model="podcastSearchRegion" :items="$podcastSearchRegionOptions" small class="max-w-72" menu-max-height="200px" @input="formUpdated" />
</div> </div>
<div class="p-2 w-full flex items-center space-x-2 flex-wrap">
<div>
<ui-dropdown v-model="markAsFinishedWhen" :items="maskAsFinishedWhenItems" :label="$strings.LabelSettingsLibraryMarkAsFinishedWhen" small class="w-72 min-w-72 text-sm" menu-max-height="200px" @input="markAsFinishedWhenChanged" />
</div>
<div class="w-16">
<div>
<label class="px-1 text-sm font-semibold"></label>
<div class="relative">
<ui-text-input v-model="markAsFinishedValue" type="number" label="" no-spinner custom-input-class="pr-5" @input="markAsFinishedChanged" />
<div class="absolute top-0 bottom-0 right-4 flex items-center">{{ markAsFinishedWhen === 'timeRemaining' ? '' : '%' }}</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>
@ -97,7 +113,9 @@ export default {
epubsAllowScriptedContent: false, epubsAllowScriptedContent: false,
hideSingleBookSeries: false, hideSingleBookSeries: false,
onlyShowLaterBooksInContinueSeries: false, onlyShowLaterBooksInContinueSeries: false,
podcastSearchRegion: 'us' podcastSearchRegion: 'us',
markAsFinishedWhen: 'timeRemaining',
markAsFinishedValue: 10
} }
}, },
computed: { computed: {
@ -119,10 +137,34 @@ export default {
providers() { providers() {
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers return this.$store.state.scanners.providers
},
maskAsFinishedWhenItems() {
return [
{
text: this.$strings.LabelSettingsLibraryMarkAsFinishedTimeRemaining,
value: 'timeRemaining'
},
{
text: this.$strings.LabelSettingsLibraryMarkAsFinishedPercentComplete,
value: 'percentComplete'
}
]
} }
}, },
methods: { methods: {
markAsFinishedWhenChanged(val) {
if (val === 'percentComplete' && this.markAsFinishedValue > 100) {
this.markAsFinishedValue = 100
}
this.formUpdated()
},
markAsFinishedChanged(val) {
this.formUpdated()
},
getLibraryData() { getLibraryData() {
let markAsFinishedTimeRemaining = this.markAsFinishedWhen === 'timeRemaining' ? Number(this.markAsFinishedValue) : null
let markAsFinishedPercentComplete = this.markAsFinishedWhen === 'percentComplete' ? Number(this.markAsFinishedValue) : null
return { return {
settings: { settings: {
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD, coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
@ -133,7 +175,9 @@ export default {
epubsAllowScriptedContent: !!this.epubsAllowScriptedContent, epubsAllowScriptedContent: !!this.epubsAllowScriptedContent,
hideSingleBookSeries: !!this.hideSingleBookSeries, hideSingleBookSeries: !!this.hideSingleBookSeries,
onlyShowLaterBooksInContinueSeries: !!this.onlyShowLaterBooksInContinueSeries, onlyShowLaterBooksInContinueSeries: !!this.onlyShowLaterBooksInContinueSeries,
podcastSearchRegion: this.podcastSearchRegion podcastSearchRegion: this.podcastSearchRegion,
markAsFinishedTimeRemaining: markAsFinishedTimeRemaining,
markAsFinishedPercentComplete: markAsFinishedPercentComplete
} }
} }
}, },
@ -150,6 +194,11 @@ export default {
this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries
this.onlyShowLaterBooksInContinueSeries = !!this.librarySettings.onlyShowLaterBooksInContinueSeries this.onlyShowLaterBooksInContinueSeries = !!this.librarySettings.onlyShowLaterBooksInContinueSeries
this.podcastSearchRegion = this.librarySettings.podcastSearchRegion || 'us' this.podcastSearchRegion = this.librarySettings.podcastSearchRegion || 'us'
this.markAsFinishedWhen = this.librarySettings.markAsFinishedTimeRemaining ? 'timeRemaining' : 'percentComplete'
if (!this.librarySettings.markAsFinishedTimeRemaining && !this.librarySettings.markAsFinishedPercentComplete) {
this.markAsFinishedWhen = 'timeRemaining'
}
this.markAsFinishedValue = this.librarySettings.markAsFinishedTimeRemaining || this.librarySettings.markAsFinishedPercentComplete || 10
} }
}, },
mounted() { mounted() {

View file

@ -3,13 +3,13 @@
<div class="w-full border border-black-200 p-4 my-8"> <div class="w-full border border-black-200 p-4 my-8">
<div class="flex flex-wrap items-center"> <div class="flex flex-wrap items-center">
<div> <div>
<p class="text-lg">Remove metadata files in library item folders</p> <p class="text-lg">{{ $strings.LabelRemoveMetadataFile }}</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Remove all metadata.json or metadata.abs files in your {{ mediaType }} folders</p> <p class="max-w-sm text-sm pt-2 text-gray-300">{{ $getString('LabelRemoveMetadataFileHelp', [mediaType]) }}</p>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<div> <div>
<ui-btn class="mb-4 block" @click.stop="removeAllMetadataClick('json')">Remove all metadata.json</ui-btn> <ui-btn class="mb-4 block" @click.stop="removeAllMetadataClick('json')">{{ $strings.LabelRemoveAllMetadataJson }}</ui-btn>
<ui-btn @click.stop="removeAllMetadataClick('abs')">Remove all metadata.abs</ui-btn> <ui-btn @click.stop="removeAllMetadataClick('abs')">{{ $strings.LabelRemoveAllMetadataAbs }}</ui-btn>
</div> </div>
</div> </div>
</div> </div>
@ -43,7 +43,7 @@ export default {
methods: { methods: {
removeAllMetadataClick(ext) { removeAllMetadataClick(ext) {
const payload = { const payload = {
message: `Are you sure you want to remove all metadata.${ext} files in your library item folders?`, message: this.$getString('MessageConfirmRemoveMetadataFiles', [ext]),
persistent: true, persistent: true,
callback: (confirmed) => { callback: (confirmed) => {
if (confirmed) { if (confirmed) {
@ -60,16 +60,16 @@ export default {
.$post(`/api/libraries/${this.libraryId}/remove-metadata?ext=${ext}`) .$post(`/api/libraries/${this.libraryId}/remove-metadata?ext=${ext}`)
.then((data) => { .then((data) => {
if (!data.found) { if (!data.found) {
this.$toast.info(`No metadata.${ext} files were found in library`) this.$toast.info(this.$getString('ToastMetadataFilesRemovedNoneFound', [ext]))
} else if (!data.removed) { } else if (!data.removed) {
this.$toast.success(`No metadata.${ext} files removed`) this.$toast.success(this.$getString('ToastMetadataFilesRemovedNoneRemoved', [ext]))
} else { } else {
this.$toast.success(`Successfully removed ${data.removed} metadata.${ext} files`) this.$toast.success(this.$getString('ToastMetadataFilesRemovedSuccess', [data.removed, ext]))
} }
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to remove metadata files', error) console.error('Failed to remove metadata files', error)
this.$toast.error('Failed to remove metadata files') this.$toast.error(this.$getString('ToastMetadataFilesRemovedError', [ext]))
}) })
.finally(() => { .finally(() => {
this.$emit('update:processing', false) this.$emit('update:processing', false)

View file

@ -77,7 +77,13 @@ export default {
return this.notificationData.events || [] return this.notificationData.events || []
}, },
eventOptions() { eventOptions() {
return this.notificationEvents.map((e) => ({ value: e.name, text: e.name, subtext: e.description })) return this.notificationEvents.map((e) => {
return {
value: e.name,
text: e.name,
subtext: this.$strings[e.descriptionKey] || e.description
}
})
}, },
selectedEventData() { selectedEventData() {
return this.notificationEvents.find((e) => e.name === this.newNotification.eventName) return this.notificationEvents.find((e) => e.name === this.newNotification.eventName)
@ -86,7 +92,7 @@ export default {
return this.selectedEventData && this.selectedEventData.requiresLibrary return this.selectedEventData && this.selectedEventData.requiresLibrary
}, },
title() { title() {
return this.isNew ? 'Create Notification' : 'Update Notification' return this.isNew ? this.$strings.HeaderNotificationCreate : this.$strings.HeaderNotificationUpdate
}, },
availableVariables() { availableVariables() {
return this.selectedEventData ? this.selectedEventData.variables || null : null return this.selectedEventData ? this.selectedEventData.variables || null : null
@ -106,7 +112,7 @@ export default {
this.$refs.urlsInput?.forceBlur() this.$refs.urlsInput?.forceBlur()
if (!this.newNotification.urls.length) { if (!this.newNotification.urls.length) {
this.$toast.error('Must enter an Apprise URL') this.$toast.error(this.$strings.ToastAppriseUrlRequired)
return return
} }
@ -127,12 +133,12 @@ export default {
.$patch(`/api/notifications/${payload.id}`, payload) .$patch(`/api/notifications/${payload.id}`, payload)
.then((updatedSettings) => { .then((updatedSettings) => {
this.$emit('update', updatedSettings) this.$emit('update', updatedSettings)
this.$toast.success('Notification updated') this.$toast.success(this.$strings.ToastNotificationUpdateSuccess)
this.show = false this.show = false
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to update notification', error) console.error('Failed to update notification', error)
this.$toast.error('Failed to update notification') this.$toast.error(this.$strings.ToastFailedToUpdate)
}) })
.finally(() => { .finally(() => {
this.processing = false this.processing = false
@ -149,12 +155,11 @@ export default {
.$post('/api/notifications', payload) .$post('/api/notifications', payload)
.then((updatedSettings) => { .then((updatedSettings) => {
this.$emit('update', updatedSettings) this.$emit('update', updatedSettings)
this.$toast.success('Notification created')
this.show = false this.show = false
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to create notification', error) console.error('Failed to create notification', error)
this.$toast.error('Failed to create notification') this.$toast.error(this.$strings.ToastNotificationCreateFailed)
}) })
.finally(() => { .finally(() => {
this.processing = false this.processing = false

View file

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

View file

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

View file

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

View file

@ -156,7 +156,12 @@ export default {
return this.selectedFolder.fullPath return this.selectedFolder.fullPath
}, },
podcastTypes() { podcastTypes() {
return this.$store.state.globals.podcastTypes || [] return this.$store.state.globals.podcastTypes.map((e) => {
return {
text: this.$strings[e.descriptionKey] || e.text,
value: e.value
}
})
} }
}, },
methods: { methods: {

View file

@ -18,6 +18,23 @@
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p> <p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
<div v-if="description" dir="auto" class="default-style" v-html="description" /> <div v-if="description" dir="auto" class="default-style" v-html="description" />
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p> <p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
<div class="w-full h-px bg-white/5 my-4" />
<div class="flex items-center">
<div class="flex-grow">
<p class="font-semibold text-xs mb-1">{{ $strings.LabelFilename }}</p>
<p class="mb-2 text-xs">
{{ audioFileFilename }}
</p>
</div>
<div class="flex-grow">
<p class="font-semibold text-xs mb-1">{{ $strings.LabelSize }}</p>
<p class="mb-2 text-xs">
{{ audioFileSize }}
</p>
</div>
</div>
</div> </div>
</modals-modal> </modals-modal>
</template> </template>
@ -54,7 +71,7 @@ export default {
return this.episode.description || '' return this.episode.description || ''
}, },
media() { media() {
return this.libraryItem ? this.libraryItem.media || {} : {} return this.libraryItem?.media || {}
}, },
mediaMetadata() { mediaMetadata() {
return this.media.metadata || {} return this.media.metadata || {}
@ -65,6 +82,14 @@ export default {
podcastAuthor() { podcastAuthor() {
return this.mediaMetadata.author return this.mediaMetadata.author
}, },
audioFileFilename() {
return this.episode.audioFile?.metadata?.filename || ''
},
audioFileSize() {
const size = this.episode.audioFile?.metadata?.size || 0
return this.$bytesPretty(size)
},
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
} }

View file

@ -33,11 +33,11 @@
</div> </div>
<div v-if="enclosureUrl" class="pb-4 pt-6"> <div v-if="enclosureUrl" class="pb-4 pt-6">
<ui-text-input-with-label :value="enclosureUrl" readonly class="text-xs"> <ui-text-input-with-label :value="enclosureUrl" readonly class="text-xs">
<label class="px-1 text-xs text-gray-200 font-semibold">Episode URL from RSS feed</label> <label class="px-1 text-xs text-gray-200 font-semibold">{{ $strings.LabelEpisodeUrlFromRssFeed }}</label>
</ui-text-input-with-label> </ui-text-input-with-label>
</div> </div>
<div v-else class="py-4"> <div v-else class="py-4">
<p class="text-xs text-gray-300 font-semibold">Episode not linked to RSS feed episode</p> <p class="text-xs text-gray-300 font-semibold">{{ $strings.LabelEpisodeNotLinkedToRssFeed }}</p>
</div> </div>
</div> </div>
</template> </template>
@ -97,7 +97,12 @@ export default {
return this.enclosure.url return this.enclosure.url
}, },
episodeTypes() { episodeTypes() {
return this.$store.state.globals.episodeTypes || [] return this.$store.state.globals.episodeTypes.map((e) => {
return {
text: this.$strings[e.descriptionKey] || e.text,
value: e.value
}
})
} }
}, },
methods: { methods: {
@ -142,7 +147,7 @@ export default {
const updatedDetails = this.getUpdatePayload() const updatedDetails = this.getUpdatePayload()
if (!Object.keys(updatedDetails).length) { if (!Object.keys(updatedDetails).length) {
this.$toast.info('No changes were made') this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
return false return false
} }
return this.updateDetails(updatedDetails) return this.updateDetails(updatedDetails)
@ -152,14 +157,14 @@ export default {
const updateResult = await this.$axios.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, updatedDetails).catch((error) => { const updateResult = await this.$axios.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, updatedDetails).catch((error) => {
console.error('Failed update episode', error) console.error('Failed update episode', error)
this.isProcessing = false this.isProcessing = false
this.$toast.error(error?.response?.data || 'Failed to update episode') this.$toast.error(error?.response?.data || this.$strings.ToastFailedToUpdate)
return false return false
}) })
this.isProcessing = false this.isProcessing = false
if (updateResult) { if (updateResult) {
if (updateResult) { if (updateResult) {
this.$toast.success('Podcast episode updated') this.$toast.success(this.$strings.ToastItemUpdateSuccess)
return true return true
} else { } else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary) this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)

View file

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

View file

@ -121,14 +121,14 @@ export default {
methods: { methods: {
openFeed() { openFeed() {
if (!this.newFeedSlug) { if (!this.newFeedSlug) {
this.$toast.error('Must set a feed slug') this.$toast.error(this.$strings.ToastSlugRequired)
return return
} }
const sanitized = this.$sanitizeSlug(this.newFeedSlug) const sanitized = this.$sanitizeSlug(this.newFeedSlug)
if (this.newFeedSlug !== sanitized) { if (this.newFeedSlug !== sanitized) {
this.newFeedSlug = sanitized this.newFeedSlug = sanitized
this.$toast.warning('Slug had to be modified - Run again') this.$toast.warning(this.$strings.ToastSlugMustChange)
return return
} }
@ -139,7 +139,7 @@ export default {
slug: this.newFeedSlug, slug: this.newFeedSlug,
metadataDetails: this.metadataDetails metadataDetails: this.metadataDetails
} }
if (this.$isDev) payload.serverAddress = `http://localhost:3333${this.$config.routerBasePath}` if (this.$isDev) payload.serverAddress = process.env.serverUrl
console.log('Payload', payload) console.log('Payload', payload)
this.$axios this.$axios

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="flex items-center pt-4 pb-2 lg:pt-0 lg:pb-2"> <div class="flex justify-center pt-4 pb-2 lg:pt-0 lg:pb-2">
<div class="flex-grow" /> <div class="flex items-center justify-center flex-grow">
<template v-if="!loading"> <template v-if="!loading">
<ui-tooltip direction="top" :text="$strings.ButtonPreviousChapter" class="mr-4 lg:mr-8"> <ui-tooltip direction="top" :text="$strings.ButtonPreviousChapter" class="mr-4 lg:mr-8">
<button :aria-label="$strings.ButtonPreviousChapter" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter"> <button :aria-label="$strings.ButtonPreviousChapter" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter">
@ -20,19 +20,18 @@
<span class="material-symbols text-2xl sm:text-3xl">forward_media</span> <span class="material-symbols text-2xl sm:text-3xl">forward_media</span>
</button> </button>
</ui-tooltip> </ui-tooltip>
<ui-tooltip direction="top" :text="$strings.ButtonNextChapter" class="ml-4 lg:ml-8"> <ui-tooltip direction="top" :text="hasNextLabel" 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"> <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> <span class="material-symbols text-2xl sm:text-3xl">last_page</span>
</button> </button>
</ui-tooltip> </ui-tooltip>
<controls-playback-speed-control v-model="playbackRateInput" @input="playbackRateUpdated" @change="playbackRateChanged" />
</template> </template>
<template v-else> <template v-else>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin"> <div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
<span class="material-symbols text-2xl">autorenew</span> <span class="material-symbols text-2xl">autorenew</span>
</div> </div>
</template> </template>
<div class="flex-grow" /> </div>
</div> </div>
</template> </template>
@ -41,27 +40,26 @@ export default {
props: { props: {
loading: Boolean, loading: Boolean,
seekLoading: Boolean, seekLoading: Boolean,
playbackRate: Number,
paused: Boolean, paused: Boolean,
hasNextChapter: Boolean hasNextChapter: Boolean,
hasNextItemInQueue: Boolean
}, },
data() { data() {
return {} return {}
}, },
computed: { computed: {
playbackRateInput: {
get() {
return this.playbackRate
},
set(val) {
this.$emit('update:playbackRate', val)
}
},
jumpForwardText() { jumpForwardText() {
return this.getJumpText('jumpForwardAmount', this.$strings.ButtonJumpForward) return this.getJumpText('jumpForwardAmount', this.$strings.ButtonJumpForward)
}, },
jumpBackwardText() { jumpBackwardText() {
return this.getJumpText('jumpBackwardAmount', this.$strings.ButtonJumpBackward) 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: { methods: {
@ -71,9 +69,9 @@ export default {
prevChapter() { prevChapter() {
this.$emit('prevChapter') this.$emit('prevChapter')
}, },
nextChapter() { next() {
if (!this.hasNextChapter) return if (!this.hasNext) return
this.$emit('nextChapter') this.$emit('next')
}, },
jumpBackward() { jumpBackward() {
this.$emit('jumpBackward') this.$emit('jumpBackward')
@ -81,15 +79,6 @@ export default {
jumpForward() { jumpForward() {
this.$emit('jumpForward') this.$emit('jumpForward')
}, },
playbackRateUpdated(playbackRate) {
this.$emit('setPlaybackRate', playbackRate)
},
playbackRateChanged(playbackRate) {
this.$emit('setPlaybackRate', playbackRate)
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
console.error('Failed to update settings', err)
})
},
getJumpText(setting, prefix) { getJumpText(setting, prefix) {
const amount = this.$store.getters['user/getUserSetting'](setting) const amount = this.$store.getters['user/getUserSetting'](setting)
if (!amount) return prefix if (!amount) return prefix

View file

@ -2,9 +2,9 @@
<div class="w-full -mt-6"> <div class="w-full -mt-6">
<div class="w-full relative mb-1"> <div class="w-full relative mb-1">
<div class="absolute -top-10 lg:top-0 right-0 lg:right-2 flex items-center h-full"> <div class="absolute -top-10 lg:top-0 right-0 lg:right-2 flex items-center h-full">
<!-- <span class="material-symbols text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span> --> <controls-playback-speed-control v-model="playbackRate" @input="setPlaybackRate" @change="playbackRateChanged" class="mx-2 block" />
<ui-tooltip direction="top" :text="$strings.LabelVolume"> <ui-tooltip direction="left" :text="$strings.LabelVolume">
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden sm:block" /> <controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden sm:block" />
</ui-tooltip> </ui-tooltip>
@ -13,7 +13,7 @@
<span v-if="!sleepTimerSet" class="material-symbols text-2xl">snooze</span> <span v-if="!sleepTimerSet" class="material-symbols text-2xl">snooze</span>
<div v-else class="flex items-center"> <div v-else class="flex items-center">
<span class="material-symbols text-lg text-warning">snooze</span> <span class="material-symbols text-lg text-warning">snooze</span>
<p class="text-sm sm:text-lg text-warning font-mono font-semibold text-center px-0.5 sm:pb-0.5 sm:min-w-8">{{ sleepTimerRemainingString }}</p> <p class="text-sm sm:text-lg text-warning font-semibold text-center px-0.5 sm:pb-0.5 sm:min-w-8">{{ sleepTimerRemainingString }}</p>
</div> </div>
</button> </button>
</ui-tooltip> </ui-tooltip>
@ -43,21 +43,25 @@
</ui-tooltip> </ui-tooltip>
</div> </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> </div>
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" :playback-rate="playbackRate" @seek="seek" /> <player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" :playback-rate="playbackRate" @seek="seek" />
<div class="flex"> <div class="relative flex items-center justify-between">
<div class="flex-grow flex items-center">
<p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p> <p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p>
<p class="font-mono text-sm hidden sm:block text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p> <p class="font-mono text-sm hidden sm:block text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p>
<div class="flex-grow" /> </div>
<div class="absolute left-1/2 transform -translate-x-1/2">
<p class="text-xs sm:text-sm text-gray-300 pt-0.5 px-2 truncate"> <p class="text-xs sm:text-sm text-gray-300 pt-0.5 px-2 truncate">
{{ currentChapterName }} <span v-if="useChapterTrack" class="text-xs text-gray-400">&nbsp;({{ $getString('LabelPlayerChapterNumberMarker', [currentChapterIndex + 1, chapters.length]) }})</span> {{ currentChapterName }} <span v-if="useChapterTrack" class="text-xs text-gray-400">&nbsp;({{ $getString('LabelPlayerChapterNumberMarker', [currentChapterIndex + 1, chapters.length]) }})</span>
</p> </p>
<div class="flex-grow" /> </div>
<div class="flex-grow flex items-center justify-end">
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p> <p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
</div> </div>
</div>
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" /> <modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
</div> </div>
@ -82,7 +86,8 @@ export default {
sleepTimerType: String, sleepTimerType: String,
isPodcast: Boolean, isPodcast: Boolean,
hideBookmarks: Boolean, hideBookmarks: Boolean,
hideSleepTimer: Boolean hideSleepTimer: Boolean,
hasNextItemInQueue: Boolean
}, },
data() { data() {
return { return {
@ -145,7 +150,7 @@ export default {
return Math.round((100 * time) / duration) return Math.round((100 * time) / duration)
}, },
currentChapterName() { currentChapterName() {
return this.currentChapter ? this.currentChapter.title : '' return this.currentChapter?.title || ''
}, },
currentChapterDuration() { currentChapterDuration() {
if (!this.currentChapter) return 0 if (!this.currentChapter) return 0
@ -177,22 +182,6 @@ export default {
methods: { methods: {
toggleFullscreen(isFullscreen) { toggleFullscreen(isFullscreen) {
this.$store.commit('setPlayerIsFullscreen', 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) { setDuration(duration) {
this.duration = duration this.duration = duration
@ -239,6 +228,12 @@ export default {
this.playbackRate = Number((this.playbackRate - 0.1).toFixed(1)) this.playbackRate = Number((this.playbackRate - 0.1).toFixed(1))
this.setPlaybackRate(this.playbackRate) this.setPlaybackRate(this.playbackRate)
}, },
playbackRateChanged(playbackRate) {
this.setPlaybackRate(playbackRate)
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
console.error('Failed to update settings', err)
})
},
setPlaybackRate(playbackRate) { setPlaybackRate(playbackRate) {
this.$emit('setPlaybackRate', playbackRate) this.$emit('setPlaybackRate', playbackRate)
}, },
@ -278,10 +273,13 @@ export default {
this.seek(this.currentChapter.start) this.seek(this.currentChapter.start)
} }
}, },
nextChapter() { goToNext() {
if (!this.currentChapter || !this.hasNextChapter) return if (this.hasNextChapter) {
var nextChapter = this.chapters[this.currentChapterIndex + 1] const nextChapter = this.chapters[this.currentChapterIndex + 1]
this.seek(nextChapter.start) this.seek(nextChapter.start)
} else if (this.hasNextItemInQueue) {
this.$emit('nextItemInQueue')
}
}, },
setStreamReady() { setStreamReady() {
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(1) if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(1)

View file

@ -35,22 +35,22 @@
<div class="flex justify-between pt-12"> <div class="flex justify-between pt-12">
<div> <div>
<p class="text-sm text-center">{{ $strings.LabelStatsWeekListening }}</p> <p class="text-sm text-center">{{ $strings.LabelStatsWeekListening }}</p>
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ totalMinutesListeningThisWeek }}</p> <p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ $formatNumber(totalMinutesListeningThisWeek) }}</p>
<p class="text-sm text-center">{{ $strings.LabelStatsMinutes }}</p> <p class="text-sm text-center">{{ $strings.LabelStatsMinutes }}</p>
</div> </div>
<div> <div>
<p class="text-sm text-center">{{ $strings.LabelStatsDailyAverage }}</p> <p class="text-sm text-center">{{ $strings.LabelStatsDailyAverage }}</p>
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ averageMinutesPerDay }}</p> <p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ $formatNumber(averageMinutesPerDay) }}</p>
<p class="text-sm text-center">{{ $strings.LabelStatsMinutes }}</p> <p class="text-sm text-center">{{ $strings.LabelStatsMinutes }}</p>
</div> </div>
<div> <div>
<p class="text-sm text-center">{{ $strings.LabelStatsBestDay }}</p> <p class="text-sm text-center">{{ $strings.LabelStatsBestDay }}</p>
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ mostListenedDay }}</p> <p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ $formatNumber(mostListenedDay) }}</p>
<p class="text-sm text-center">{{ $strings.LabelStatsMinutes }}</p> <p class="text-sm text-center">{{ $strings.LabelStatsMinutes }}</p>
</div> </div>
<div> <div>
<p class="text-sm text-center">{{ $strings.LabelStatsDays }}</p> <p class="text-sm text-center">{{ $strings.LabelStatsDays }}</p>
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ daysInARow }}</p> <p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ $formatNumber(daysInARow) }}</p>
<p class="text-sm text-center">{{ $strings.LabelStatsInARow }}</p> <p class="text-sm text-center">{{ $strings.LabelStatsInARow }}</p>
</div> </div>
</div> </div>

View file

@ -29,7 +29,7 @@
</div> </div>
<div class="flex p-2"> <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"> <div class="px-1">
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(totalSizeNum) }}</p> <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> <p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelSize }} ({{ totalSizeMod }})</p>
@ -37,7 +37,7 @@
</div> </div>
<div class="flex p-2"> <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"> <div class="px-1">
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(numAudioTracks) }}</p> <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> <p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAudioTracks }}</p>

View file

@ -73,7 +73,7 @@ export default {
const addIcon = (icon, color, fontSize, x, y) => { const addIcon = (icon, color, fontSize, x, y) => {
ctx.fillStyle = color ctx.fillStyle = color
ctx.font = `${fontSize} Material Symbols Outlined` ctx.font = `${fontSize} Material Symbols Rounded`
ctx.fillText(icon, x, y) ctx.fillText(icon, x, y)
} }
@ -132,6 +132,8 @@ export default {
ctx.restore() ctx.restore()
} }
const twoColumnWidth = 210
ctx.globalAlpha = 1 ctx.globalAlpha = 1
ctx.textBaseline = 'middle' ctx.textBaseline = 'middle'
@ -150,12 +152,12 @@ export default {
// Top text // Top text
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28) addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51) addText(`${this.year} ${this.$strings.StatsYearInReview}`, '18px', 'bold', 'white', '1px', 65, 51)
// Top left box // Top left box
createRoundedRect(50, 100, 340, 160) createRoundedRect(50, 100, 340, 160)
addText(this.yearStats.numBooksFinished, '64px', 'bold', 'white', '0px', 160, 165) addText(this.yearStats.numBooksFinished, '64px', 'bold', 'white', '0px', 160, 165)
addText('books finished', '28px', 'normal', tanColor, '0px', 160, 210) addText(this.$strings.StatsBooksFinished, '28px', 'normal', tanColor, '0px', 160, 210, twoColumnWidth)
const readIconPath = new Path2D() const readIconPath = new Path2D()
readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 2, d: 2, e: 100, f: 160 }) readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 2, d: 2, e: 100, f: 160 })
ctx.fillStyle = '#ffffff' ctx.fillStyle = '#ffffff'
@ -164,40 +166,40 @@ export default {
// Box top right // Box top right
createRoundedRect(410, 100, 340, 160) createRoundedRect(410, 100, 340, 160)
addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165) addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165)
addText('spent listening', '28px', 'normal', tanColor, '0px', 500, 205) addText(this.$strings.StatsSpentListening, '28px', 'normal', tanColor, '0px', 500, 205, twoColumnWidth)
addIcon('watch_later', 'white', '52px', 440, 180) addIcon('watch_later', 'white', '52px', 440, 180)
// Box bottom left // Box bottom left
createRoundedRect(50, 280, 340, 160) createRoundedRect(50, 280, 340, 160)
addText(this.yearStats.totalListeningSessions, '64px', 'bold', 'white', '0px', 160, 345) addText(this.yearStats.totalListeningSessions, '64px', 'bold', 'white', '0px', 160, 345)
addText('sessions', '28px', 'normal', tanColor, '1px', 160, 390) addText(this.$strings.StatsSessions, '28px', 'normal', tanColor, '1px', 160, 390, twoColumnWidth)
addIcon('headphones', 'white', '52px', 95, 360) addIcon('headphones', 'white', '52px', 95, 360)
// Box bottom right // Box bottom right
createRoundedRect(410, 280, 340, 160) createRoundedRect(410, 280, 340, 160)
addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345) addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345)
addText('books listened to', '28px', 'normal', tanColor, '0px', 500, 390) addText(this.$strings.StatsBooksListenedTo, '28px', 'normal', tanColor, '0px', 500, 390, twoColumnWidth)
addIcon('local_library', 'white', '52px', 440, 360) addIcon('local_library', 'white', '52px', 440, 360)
if (!this.variant) { if (!this.variant) {
// Text stats // Text stats
const topNarrator = this.yearStats.mostListenedNarrator const topNarrator = this.yearStats.mostListenedNarrator
if (topNarrator) { if (topNarrator) {
addText('TOP NARRATOR', '24px', 'normal', tanColor, '1px', 70, 520) addText(this.$strings.StatsTopNarrator, '24px', 'normal', tanColor, '1px', 70, 520, 330)
addText(topNarrator.name, '36px', 'bolder', 'white', '0px', 70, 564, 330) addText(topNarrator.name, '36px', 'bolder', 'white', '0px', 70, 564, 330)
addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '24px', 'lighter', 'white', '1px', 70, 599) addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '24px', 'lighter', 'white', '1px', 70, 599)
} }
const topGenre = this.yearStats.topGenres[0] const topGenre = this.yearStats.topGenres[0]
if (topGenre) { if (topGenre) {
addText('TOP GENRE', '24px', 'normal', tanColor, '1px', 430, 520) addText(this.$strings.StatsTopGenre, '24px', 'normal', tanColor, '1px', 430, 520, 330)
addText(topGenre.genre, '36px', 'bolder', 'white', '0px', 430, 564, 330) addText(topGenre.genre, '36px', 'bolder', 'white', '0px', 430, 564, 330)
addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '24px', 'lighter', 'white', '1px', 430, 599) addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '24px', 'lighter', 'white', '1px', 430, 599)
} }
const topAuthor = this.yearStats.topAuthors[0] const topAuthor = this.yearStats.topAuthors[0]
if (topAuthor) { if (topAuthor) {
addText('TOP AUTHOR', '24px', 'normal', tanColor, '1px', 70, 670) addText(this.$strings.StatsTopAuthor, '24px', 'normal', tanColor, '1px', 70, 670, 330)
addText(topAuthor.name, '36px', 'bolder', 'white', '0px', 70, 714, 330) addText(topAuthor.name, '36px', 'bolder', 'white', '0px', 70, 714, 330)
addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749) addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749)
} }
@ -205,7 +207,7 @@ export default {
if (this.yearStats.mostListenedMonth?.time) { if (this.yearStats.mostListenedMonth?.time) {
const jsdate = new Date(this.year, this.yearStats.mostListenedMonth.month, 1) const jsdate = new Date(this.year, this.yearStats.mostListenedMonth.month, 1)
const monthName = this.$formatJsDate(jsdate, 'LLLL') const monthName = this.$formatJsDate(jsdate, 'LLLL')
addText('TOP MONTH', '24px', 'normal', tanColor, '1px', 430, 670) addText(this.$strings.StatsTopMonth, '24px', 'normal', tanColor, '1px', 430, 670, 330)
addText(monthName, '36px', 'bolder', 'white', '0px', 430, 714, 330) addText(monthName, '36px', 'bolder', 'white', '0px', 430, 714, 330)
addText(this.$elapsedPrettyExtended(this.yearStats.mostListenedMonth.time, true, false), '24px', 'lighter', 'white', '1px', 430, 749) addText(this.$elapsedPrettyExtended(this.yearStats.mostListenedMonth.time, true, false), '24px', 'lighter', 'white', '1px', 430, 749)
} }
@ -214,7 +216,7 @@ export default {
finishedBookCoverImgs = Object.values(finishedBookCoverImgs) finishedBookCoverImgs = Object.values(finishedBookCoverImgs)
if (finishedBookCoverImgs.length > 0) { if (finishedBookCoverImgs.length > 0) {
ctx.textAlign = 'center' ctx.textAlign = 'center'
addText('Some books finished this year...', '28px', 'normal', tanColor, '0px', canvas.width / 2, 530) addText(this.$strings.StatsBooksFinishedThisYear, '28px', 'normal', tanColor, '0px', canvas.width / 2, 530)
for (let i = 0; i < Math.min(5, finishedBookCoverImgs.length); i++) { for (let i = 0; i < Math.min(5, finishedBookCoverImgs.length); i++) {
let imgToAdd = finishedBookCoverImgs[i] let imgToAdd = finishedBookCoverImgs[i]
@ -224,14 +226,14 @@ export default {
} else if (this.variant === 2) { } else if (this.variant === 2) {
// Text stats // Text stats
if (this.yearStats.topAuthors.length) { if (this.yearStats.topAuthors.length) {
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 524) addText(this.$strings.StatsTopAuthors, '24px', 'normal', tanColor, '1px', 70, 524)
for (let i = 0; i < this.yearStats.topAuthors.length; i++) { for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 584 + i * 60, 330) addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 584 + i * 60, 330)
} }
} }
if (this.yearStats.topGenres.length) { if (this.yearStats.topGenres.length) {
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 524) addText(this.$strings.StatsTopGenres, '24px', 'normal', tanColor, '1px', 430, 524)
for (let i = 0; i < this.yearStats.topGenres.length; i++) { for (let i = 0; i < this.yearStats.topGenres.length; i++) {
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 584 + i * 60, 330) addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 584 + i * 60, 330)
} }
@ -259,11 +261,11 @@ export default {
.catch((error) => { .catch((error) => {
console.error('Failed to share', error) console.error('Failed to share', error)
if (error.name !== 'AbortError') { if (error.name !== 'AbortError') {
this.$toast.error('Failed to share: ' + error.message) this.$toast.error(this.$strings.ToastFailedToShare + ': ' + error.message)
} }
}) })
} else { } else {
this.$toast.error('Cannot share natively on this device') this.$toast.error(this.$strings.ToastErrorCannotShare)
} }
}) })
}, },

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"> <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 --> <!-- hack to get icon fonts loaded on init -->
<div class="h-0 w-0 overflow-hidden opacity-0"> <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" /> <span class="abs-icons icon-audiobookshelf" />
</div> </div>
@ -38,7 +38,7 @@
<!-- next button --> <!-- next button -->
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++"> <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="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> </ui-btn>
</div> </div>
<stats-year-in-review ref="yearInReview" :variant="yearInReviewVariant" :year="yearInReviewYear" :processing.sync="processingYearInReview" /> <stats-year-in-review ref="yearInReview" :variant="yearInReviewVariant" :year="yearInReviewYear" :processing.sync="processingYearInReview" />
@ -74,7 +74,7 @@
<!-- next button --> <!-- next button -->
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++"> <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="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> </ui-btn>
</div> </div>
</div> </div>

View file

@ -123,6 +123,8 @@ export default {
ctx.restore() ctx.restore()
} }
const threeColumnTextWidth = 200
ctx.globalAlpha = 1 ctx.globalAlpha = 1
ctx.textBaseline = 'middle' ctx.textBaseline = 'middle'
@ -141,33 +143,33 @@ export default {
// Top text // Top text
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28) addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51) addText(`${this.year} ${this.$strings.StatsYearInReview}`, '18px', 'bold', 'white', '1px', 65, 51)
// Top left box // Top left box
createRoundedRect(40, 100, 230, 100) createRoundedRect(40, 100, 230, 100)
ctx.textAlign = 'center' ctx.textAlign = 'center'
addText(this.yearStats.numBooksAdded, '48px', 'bold', 'white', '0px', 155, 140) addText(this.yearStats.numBooksAdded, '48px', 'bold', 'white', '0px', 155, 140)
addText('books added', '18px', 'normal', tanColor, '0px', 155, 170) addText(this.$strings.StatsBooksAdded, '18px', 'normal', tanColor, '0px', 155, 170, threeColumnTextWidth)
// Box top right // Box top right
createRoundedRect(285, 100, 230, 100) createRoundedRect(285, 100, 230, 100)
addText(this.yearStats.numAuthorsAdded, '48px', 'bold', 'white', '0px', 400, 140) addText(this.yearStats.numAuthorsAdded, '48px', 'bold', 'white', '0px', 400, 140)
addText('authors added', '18px', 'normal', tanColor, '0px', 400, 170) addText(this.$strings.StatsAuthorsAdded, '18px', 'normal', tanColor, '0px', 400, 170, threeColumnTextWidth)
// Box bottom left // Box bottom left
createRoundedRect(530, 100, 230, 100) createRoundedRect(530, 100, 230, 100)
addText(this.yearStats.numListeningSessions, '48px', 'bold', 'white', '0px', 645, 140) addText(this.yearStats.numListeningSessions, '48px', 'bold', 'white', '0px', 645, 140)
addText('sessions', '18px', 'normal', tanColor, '1px', 645, 170) addText(this.$strings.StatsSessions, '18px', 'normal', tanColor, '1px', 645, 170, threeColumnTextWidth)
// Text stats // Text stats
if (this.yearStats.totalBooksAddedSize) { if (this.yearStats.totalBooksAddedSize) {
addText('Your book collection grew to...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 260) addText(this.$strings.StatsCollectionGrewTo, '24px', 'normal', tanColor, '0px', canvas.width / 2, 260)
addText(this.$bytesPretty(this.yearStats.totalBooksSize), '36px', 'bolder', 'white', '0px', canvas.width / 2, 300) addText(this.$bytesPretty(this.yearStats.totalBooksSize), '36px', 'bolder', 'white', '0px', canvas.width / 2, 300)
addText('+' + this.$bytesPretty(this.yearStats.totalBooksAddedSize), '20px', 'lighter', 'white', '0px', canvas.width / 2, 330) addText('+' + this.$bytesPretty(this.yearStats.totalBooksAddedSize), '20px', 'lighter', 'white', '0px', canvas.width / 2, 330)
} }
if (this.yearStats.totalBooksAddedDuration) { if (this.yearStats.totalBooksAddedDuration) {
addText('With a total duration of...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 400) addText(this.$strings.StatsTotalDuration, '24px', 'normal', tanColor, '0px', canvas.width / 2, 400)
addText(this.$elapsedPrettyExtended(this.yearStats.totalBooksDuration, true, false), '36px', 'bolder', 'white', '0px', canvas.width / 2, 440) addText(this.$elapsedPrettyExtended(this.yearStats.totalBooksDuration, true, false), '36px', 'bolder', 'white', '0px', canvas.width / 2, 440)
addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470) addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470)
} }
@ -176,7 +178,7 @@ export default {
// Bottom images // Bottom images
imgsToAdd = Object.values(imgsToAdd) imgsToAdd = Object.values(imgsToAdd)
if (imgsToAdd.length > 0) { if (imgsToAdd.length > 0) {
addText('Some additions include...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 540) addText(this.$strings.StatsBooksAdditional, '24px', 'normal', tanColor, '0px', canvas.width / 2, 540)
for (let i = 0; i < Math.min(5, imgsToAdd.length); i++) { for (let i = 0; i < Math.min(5, imgsToAdd.length); i++) {
let imgToAdd = imgsToAdd[i] let imgToAdd = imgsToAdd[i]
@ -187,14 +189,14 @@ export default {
// Text stats // Text stats
ctx.textAlign = 'left' ctx.textAlign = 'left'
if (this.yearStats.topAuthors.length) { if (this.yearStats.topAuthors.length) {
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549) addText(this.$strings.StatsTopAuthors, '24px', 'normal', tanColor, '1px', 70, 549, 330)
for (let i = 0; i < this.yearStats.topAuthors.length; i++) { for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330) addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
} }
} }
if (this.yearStats.topNarrators.length) { if (this.yearStats.topNarrators.length) {
addText('TOP NARRATORS', '24px', 'normal', tanColor, '1px', 430, 549) addText(this.$strings.StatsTopNarrators, '24px', 'normal', tanColor, '1px', 430, 549)
for (let i = 0; i < this.yearStats.topNarrators.length; i++) { for (let i = 0; i < this.yearStats.topNarrators.length; i++) {
addText(this.yearStats.topNarrators[i].name, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330) addText(this.yearStats.topNarrators[i].name, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
} }
@ -203,14 +205,14 @@ export default {
// Text stats // Text stats
ctx.textAlign = 'left' ctx.textAlign = 'left'
if (this.yearStats.topAuthors.length) { if (this.yearStats.topAuthors.length) {
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549) addText(this.$strings.StatsTopAuthors, '24px', 'normal', tanColor, '1px', 70, 549, 330)
for (let i = 0; i < this.yearStats.topAuthors.length; i++) { for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330) addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
} }
} }
if (this.yearStats.topGenres.length) { if (this.yearStats.topGenres.length) {
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 549) addText(this.$strings.StatsTopGenres, '24px', 'normal', tanColor, '1px', 430, 549)
for (let i = 0; i < this.yearStats.topGenres.length; i++) { for (let i = 0; i < this.yearStats.topGenres.length; i++) {
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330) addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
} }
@ -235,11 +237,11 @@ export default {
.catch((error) => { .catch((error) => {
console.error('Failed to share', error) console.error('Failed to share', error)
if (error.name !== 'AbortError') { if (error.name !== 'AbortError') {
this.$toast.error('Failed to share: ' + error.message) this.$toast.error(this.$strings.ToastFailedToShare + ': ' + error.message)
} }
}) })
} else { } else {
this.$toast.error('Cannot share natively on this device') this.$toast.error(this.$strings.ToastErrorCannotShare)
} }
}) })
}, },

View file

@ -64,7 +64,7 @@ export default {
const addIcon = (icon, color, fontSize, x, y) => { const addIcon = (icon, color, fontSize, x, y) => {
ctx.fillStyle = color ctx.fillStyle = color
ctx.font = `${fontSize} Material Symbols Outlined` ctx.font = `${fontSize} Material Symbols Rounded`
ctx.fillText(icon, x, y) ctx.fillText(icon, x, y)
} }
@ -113,6 +113,8 @@ export default {
ctx.restore() ctx.restore()
} }
const twoColumnWidth = 180
ctx.globalAlpha = 1 ctx.globalAlpha = 1
ctx.textBaseline = 'middle' ctx.textBaseline = 'middle'
@ -131,12 +133,12 @@ export default {
// Top text // Top text
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28) addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51) addText(`${this.year} ${this.$strings.StatsYearInReview}`, '18px', 'bold', 'white', '1px', 65, 51)
// Top left box // Top left box
createRoundedRect(15, 75, 280, 110) createRoundedRect(15, 75, 280, 110)
addText(this.yearStats.numBooksFinished, '48px', 'bold', 'white', '0px', 105, 120) addText(this.yearStats.numBooksFinished, '48px', 'bold', 'white', '0px', 105, 120)
addText('books finished', '20px', 'normal', tanColor, '0px', 105, 155) addText(this.$strings.StatsBooksFinished, '20px', 'normal', tanColor, '0px', 105, 155, twoColumnWidth)
const readIconPath = new Path2D() const readIconPath = new Path2D()
readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 1.5, d: 1.5, e: 55, f: 115 }) readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 1.5, d: 1.5, e: 55, f: 115 })
ctx.fillStyle = '#ffffff' ctx.fillStyle = '#ffffff'
@ -144,7 +146,7 @@ export default {
createRoundedRect(305, 75, 280, 110) createRoundedRect(305, 75, 280, 110)
addText(this.yearStats.numBooksListened, '48px', 'bold', 'white', '0px', 400, 120) addText(this.yearStats.numBooksListened, '48px', 'bold', 'white', '0px', 400, 120)
addText('books listened to', '20px', 'normal', tanColor, '0px', 400, 155) addText(this.$strings.StatsBooksListenedTo, '20px', 'normal', tanColor, '0px', 400, 155, twoColumnWidth)
addIcon('local_library', 'white', '42px', 345, 130) addIcon('local_library', 'white', '42px', 345, 130)
this.canvas = canvas this.canvas = canvas
@ -165,11 +167,11 @@ export default {
.catch((error) => { .catch((error) => {
console.error('Failed to share', error) console.error('Failed to share', error)
if (error.name !== 'AbortError') { if (error.name !== 'AbortError') {
this.$toast.error('Failed to share: ' + error.message) this.$toast.error(this.$strings.ToastFailedToShare + ': ' + error.message)
} }
}) })
} else { } else {
this.$toast.error('Cannot share natively on this device') this.$toast.error(this.$strings.ToastErrorCannotShare)
} }
}) })
}, },

View file

@ -23,7 +23,7 @@
<div class="w-full flex flex-row items-center justify-center"> <div class="w-full flex flex-row items-center justify-center">
<ui-btn v-if="backup.serverVersion && backup.key" small color="primary" @click="applyBackup(backup)">{{ $strings.ButtonRestore }}</ui-btn> <ui-btn v-if="backup.serverVersion && 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"> <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> </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> <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() { mounted() {
this.loadBackups() this.loadBackups()
if (this.$route.query.backup) { 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" /> <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> <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' : ''"> <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>
</div> </div>
<transition name="slide"> <transition name="slide">

View file

@ -78,7 +78,7 @@ export default {
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to update collection', error) console.error('Failed to update collection', error)
this.$toast.error('Failed to save collection books order') this.$toast.error(this.$strings.ToastFailedToUpdate)
}) })
}, },
editBook(book) { editBook(book) {

View file

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

View file

@ -8,7 +8,7 @@
<div class="flex-grow" /> <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> <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' : ''"> <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>
</div> </div>
<transition name="slide"> <transition name="slide">
@ -18,7 +18,7 @@
<th class="text-left px-4">{{ $strings.LabelPath }}</th> <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 w-24 min-w-24">{{ $strings.LabelSize }}</th>
<th class="text-left px-4 w-24"> <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>
<th v-if="showMoreColumn" class="text-center w-16"></th> <th v-if="showMoreColumn" class="text-center w-16"></th>
</tr> </tr>

View file

@ -1,7 +1,7 @@
<template> <template>
<tr> <tr>
<td class="px-4"> <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>
<td> <td>
{{ $bytesPretty(file.metadata.size) }} {{ $bytesPretty(file.metadata.size) }}

View file

@ -8,7 +8,7 @@
<div class="flex-grow" /> <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> <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' : ''"> <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>
</div> </div>
<transition name="slide"> <transition name="slide">

View file

@ -92,7 +92,7 @@ export default {
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to update playlist', error) console.error('Failed to update playlist', error)
this.$toast.error('Failed to save playlist items order') this.$toast.error(this.$strings.ToastFailedToUpdate)
}) })
}, },
init() { init() {

View file

@ -11,7 +11,7 @@
<ui-btn small color="primary">{{ $strings.ButtonManageTracks }}</ui-btn> <ui-btn small color="primary">{{ $strings.ButtonManageTracks }}</ui-btn>
</nuxt-link> </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' : ''"> <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>
</div> </div>
<transition name="slide"> <transition name="slide">

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)"> <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> <button type="button" :aria-label="$getString('ButtonUserEdit', [user.username])" class="material-symbols text-base">edit</button>
</div> </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> <button type="button" :aria-label="$getString('ButtonUserDelete', [user.username])" class="material-symbols text-base">delete</button>
</div> </div>
</div> </div>
@ -157,10 +157,6 @@ export default {
this.init() this.init()
}, },
beforeDestroy() { beforeDestroy() {
if (this.$refs.accountModal) {
this.$refs.accountModal.close()
}
if (this.$root.socket) { if (this.$root.socket) {
this.$root.socket.off('user_added', this.addUpdateUser) this.$root.socket.off('user_added', this.addUpdateUser)
this.$root.socket.off('user_updated', this.addUpdateUser) this.$root.socket.off('user_updated', this.addUpdateUser)

View file

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

View file

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

View file

@ -12,10 +12,10 @@
</div> </div>
<div class="h-8 flex items-center"> <div class="h-8 flex items-center">
<div class="w-full inline-flex justify-between max-w-xl"> <div class="w-full inline-flex justify-between max-w-xl">
<p v-if="episode?.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p> <p v-if="episode?.season" class="text-sm text-gray-300">{{ $getString('LabelSeasonNumber', [episode.season]) }}</p>
<p v-if="episode?.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p> <p v-if="episode?.episode" class="text-sm text-gray-300">{{ $getString('LabelEpisodeNumber', [episode.episode]) }}</p>
<p v-if="episode?.chapters?.length" class="text-sm text-gray-300">{{ episode.chapters.length }} Chapters</p> <p v-if="episode?.chapters?.length" class="text-sm text-gray-300">{{ $getString('LabelChapterCount', [episode.chapters.length]) }}</p>
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p> <p v-if="publishedAt" class="text-sm text-gray-300">{{ $getString('LabelPublishedDate', [$formatDate(publishedAt, dateFormat)]) }}</p>
</div> </div>
</div> </div>
@ -132,13 +132,13 @@ export default {
return this.store.state.streamIsPlaying && this.isStreaming return this.store.state.streamIsPlaying && this.isStreaming
}, },
timeRemaining() { timeRemaining() {
if (this.streamIsPlaying) return 'Playing' if (this.streamIsPlaying) return this.$strings.ButtonPlaying
if (!this.itemProgress) return this.$elapsedPretty(this.episode?.duration || 0) if (!this.itemProgress) return this.$elapsedPretty(this.episode?.duration || 0)
if (this.userIsFinished) return 'Finished' if (this.userIsFinished) return this.$strings.LabelFinished
const duration = this.itemProgress.duration || this.episode?.duration || 0 const duration = this.itemProgress.duration || this.episode?.duration || 0
const remaining = Math.floor(duration - this.itemProgress.currentTime) const remaining = Math.floor(duration - this.itemProgress.currentTime)
return `${this.$elapsedPretty(remaining)} left` return this.$getString('LabelTimeLeft', [this.$elapsedPretty(remaining)])
} }
}, },
methods: { methods: {
@ -182,7 +182,7 @@ export default {
toggleFinished(confirmed = false) { toggleFinished(confirmed = false) {
if (!this.userIsFinished && this.itemProgressPercent > 0 && !confirmed) { if (!this.userIsFinished && this.itemProgressPercent > 0 && !confirmed) {
const payload = { const payload = {
message: `Are you sure you want to mark "${this.title}" as finished?`, message: this.$getString('MessageConfirmMarkItemFinished', [this.episodeTitle]),
callback: (confirmed) => { callback: (confirmed) => {
if (confirmed) { if (confirmed) {
this.toggleFinished(true) this.toggleFinished(true)

View file

@ -25,7 +25,6 @@
</template> </template>
</div> </div>
</div> </div>
<!-- <p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p> -->
<div v-if="episodes.length" class="w-full py-3 mx-auto flex"> <div v-if="episodes.length" class="w-full py-3 mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow"> <form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" /> <ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
@ -93,17 +92,18 @@ export default {
}, },
computed: { computed: {
contextMenuItems() { contextMenuItems() {
if (!this.userIsAdminOrUp) return [] const menuItems = []
return [ if (this.userIsAdminOrUp) {
{ menuItems.push({
text: 'Quick match all episodes', text: this.$strings.MessageQuickMatchAllEpisodes,
action: 'quick-match-episodes' action: 'quick-match-episodes'
}, })
{ }
menuItems.push({
text: this.allEpisodesFinished ? this.$strings.MessageMarkAllEpisodesNotFinished : this.$strings.MessageMarkAllEpisodesFinished, text: this.allEpisodesFinished ? this.$strings.MessageMarkAllEpisodesNotFinished : this.$strings.MessageMarkAllEpisodesFinished,
action: 'batch-mark-as-finished' action: 'batch-mark-as-finished'
} })
] return menuItems
}, },
sortItems() { sortItems() {
return [ return [
@ -246,7 +246,7 @@ export default {
message: newIsFinished ? this.$strings.MessageConfirmMarkAllEpisodesFinished : this.$strings.MessageConfirmMarkAllEpisodesNotFinished, message: newIsFinished ? this.$strings.MessageConfirmMarkAllEpisodesFinished : this.$strings.MessageConfirmMarkAllEpisodesNotFinished,
callback: (confirmed) => { callback: (confirmed) => {
if (confirmed) { if (confirmed) {
this.batchUpdateEpisodesFinished(this.episodesSorted, newIsFinished) this.batchUpdateEpisodesFinished(this.episodesCopy, newIsFinished)
} }
}, },
type: 'yesNo' type: 'yesNo'
@ -261,21 +261,21 @@ export default {
this.processing = true this.processing = true
const payload = { const payload = {
message: 'Quick matching episodes will overwrite details if a match is found. Only unmatched episodes will be updated. Are you sure?', message: this.$strings.MessageConfirmQuickMatchEpisodes,
callback: (confirmed) => { callback: (confirmed) => {
if (confirmed) { if (confirmed) {
this.$axios this.$axios
.$post(`/api/podcasts/${this.libraryItem.id}/match-episodes?override=1`) .$post(`/api/podcasts/${this.libraryItem.id}/match-episodes?override=1`)
.then((data) => { .then((data) => {
if (data.numEpisodesUpdated) { if (data.numEpisodesUpdated) {
this.$toast.success(`${data.numEpisodesUpdated} episodes updated`) this.$toast.success(this.$getString('ToastEpisodeUpdateSuccess', [data.numEpisodesUpdated]))
} else { } else {
this.$toast.info('No changes were made') this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
} }
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to request match episodes', error) console.error('Failed to request match episodes', error)
this.$toast.error('Failed to match episodes') this.$toast.error(this.$strings.ToastFailedToMatch)
}) })
} }
this.processing = false this.processing = false
@ -295,7 +295,7 @@ export default {
episodeId: episode.id, episodeId: episode.id,
title: episode.title, title: episode.title,
subtitle: this.mediaMetadata.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, duration: episode.audioFile.duration || null,
coverPath: this.media.coverPath || null coverPath: this.media.coverPath || null
} }
@ -305,6 +305,7 @@ export default {
this.batchUpdateEpisodesFinished(this.selectedEpisodes, !this.selectedIsFinished) this.batchUpdateEpisodesFinished(this.selectedEpisodes, !this.selectedIsFinished)
}, },
batchUpdateEpisodesFinished(episodes, newIsFinished) { batchUpdateEpisodesFinished(episodes, newIsFinished) {
if (!episodes.length) return
this.processing = true this.processing = true
const updateProgressPayloads = episodes.map((episode) => { const updateProgressPayloads = episodes.map((episode) => {
@ -371,7 +372,7 @@ export default {
episodeId: episode.id, episodeId: episode.id,
title: episode.title, title: episode.title,
subtitle: this.mediaMetadata.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, duration: episode.audioFile.duration || null,
coverPath: this.media.coverPath || null coverPath: this.media.coverPath || null
}) })
@ -513,6 +514,10 @@ export default {
} }
}, },
filterSortChanged() { filterSortChanged() {
// Save filterKey and sortKey to local storage
localStorage.setItem('podcastEpisodesFilter', this.filterKey)
localStorage.setItem('podcastEpisodesSortBy', this.sortKey + (this.sortDesc ? '-desc' : ''))
this.init() this.init()
}, },
refresh() { refresh() {
@ -535,6 +540,11 @@ export default {
} }
}, },
mounted() { mounted() {
this.filterKey = localStorage.getItem('podcastEpisodesFilter') || 'incomplete'
const sortBy = localStorage.getItem('podcastEpisodesSortBy') || 'publishedAt-desc'
this.sortKey = sortBy.split('-')[0]
this.sortDesc = sortBy.split('-')[1] === 'desc'
this.episodesCopy = this.episodes.map((ep) => ({ ...ep })) this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
this.initListeners() this.initListeners()
this.init() this.init()

View file

@ -2,7 +2,7 @@
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj"> <div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing"> <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"> <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> </button>
<div v-else class="h-full w-full flex items-center justify-center"> <div v-else class="h-full w-full flex items-center justify-center">
<widgets-loading-spinner /> <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" /> <path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg> </svg>
</div> </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> </button>
</template> </template>

View file

@ -4,18 +4,18 @@
<div ref="wrapper" class="relative"> <div ref="wrapper" class="relative">
<form @submit.prevent="submitForm"> <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'"> <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> </div>
</form> </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"> <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"> <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"> <div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item }}</span> <span class="font-normal ml-3 block truncate">{{ item }}</span>
</div> </div>
<span v-if="input === item" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> <span v-if="input === item" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-symbols text-xl">checkmark</span> <span class="material-symbols text-xl">check</span>
</span> </span>
</li> </li>
</template> </template>
@ -30,7 +30,10 @@
</template> </template>
<script> <script>
import menuKeyboardNavigationMixin from '@/mixins/menuKeyboardNavigation'
export default { export default {
mixins: [menuKeyboardNavigationMixin],
props: { props: {
value: [String, Number], value: [String, Number],
disabled: Boolean, disabled: Boolean,
@ -81,6 +84,9 @@ export default {
} }
}, },
methods: { methods: {
keydownHandler(e) {
this.menuNavigationHandler(e)
},
setFocus() { setFocus() {
if (this.$refs.input && this.editable) this.$refs.input.focus() 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 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>
<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>
</div> </div>
</template> </template>
@ -17,7 +17,12 @@ export default {
props: { props: {
text: { text: {
type: String, type: String,
default: 'Please Wait...' default: null
}
},
computed: {
message() {
return this.text || this.$strings.MessagePleaseWait
} }
} }
} }

View file

@ -22,7 +22,7 @@
<span class="font-normal ml-3 block truncate">{{ item }}</span> <span class="font-normal ml-3 block truncate">{{ item }}</span>
</div> </div>
<span v-if="selected.includes(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> <span v-if="selected.includes(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-symbols text-xl">checkmark</span> <span class="material-symbols text-xl">check</span>
</span> </span>
</li> </li>
</template> </template>
@ -37,7 +37,10 @@
</template> </template>
<script> <script>
import menuKeyboardNavigationMixin from '@/mixins/menuKeyboardNavigation'
export default { export default {
mixins: [menuKeyboardNavigationMixin],
props: { props: {
value: { value: {
type: Array, type: Array,
@ -63,8 +66,7 @@ export default {
typingTimeout: null, typingTimeout: null,
isFocused: false, isFocused: false,
menu: null, menu: null,
filteredItems: null, filteredItems: null
selectedMenuItemIndex: null
} }
}, },
watch: { watch: {
@ -119,34 +121,8 @@ export default {
this.filteredItems = results || [] this.filteredItems = results || []
}, },
keydownInput(event) { keydownInput(event) {
let items = this.itemsToShow this.menuNavigationHandler(event)
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
clearTimeout(this.typingTimeout) clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => { this.typingTimeout = setTimeout(() => {
this.search() this.search()
@ -161,24 +137,6 @@ export default {
this.recalcMenuPos() this.recalcMenuPos()
}, 50) }, 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() { recalcMenuPos() {
if (!this.menu || !this.$refs.inputWrapper) return if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect() var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
@ -317,9 +275,6 @@ export default {
this.textInput = null this.textInput = null
this.currentSearch = null this.currentSearch = null
this.selectedMenuItemIndex = null this.selectedMenuItemIndex = null
this.$nextTick(() => {
this.blur()
})
}, },
submitForm() { submitForm() {
if (!this.textInput) return if (!this.textInput) return

View file

@ -18,7 +18,7 @@
<p class="font-normal ml-3 block truncate">{{ item.text }}</p> <p class="font-normal ml-3 block truncate">{{ item.text }}</p>
<div v-if="selected.includes(item.value)" class="text-yellow-400 absolute inset-y-0 right-0 my-auto w-5 h-5 mr-3 overflow-hidden"> <div v-if="selected.includes(item.value)" class="text-yellow-400 absolute inset-y-0 right-0 my-auto w-5 h-5 mr-3 overflow-hidden">
<span class="material-symbols text-xl">checkmark</span> <span class="material-symbols text-xl">check</span>
</div> </div>
</li> </li>
</template> </template>

View file

@ -20,12 +20,12 @@
<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"> <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"> <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"> <div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.name }}</span> <span class="font-normal ml-3 block truncate">{{ item.name }}</span>
</div> </div>
<span v-if="getIsSelected(item.id)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> <span v-if="getIsSelected(item.id)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-symbols text-xl">checkmark</span> <span class="material-symbols text-xl">check</span>
</span> </span>
</li> </li>
</template> </template>
@ -40,7 +40,10 @@
</template> </template>
<script> <script>
import menuKeyboardNavigationMixin from '@/mixins/menuKeyboardNavigation'
export default { export default {
mixins: [menuKeyboardNavigationMixin],
props: { props: {
value: { value: {
type: Array, type: Array,
@ -63,8 +66,7 @@ export default {
typingTimeout: null, typingTimeout: null,
isFocused: false, isFocused: false,
menu: null, menu: null,
items: [], items: []
selectedMenuItemIndex: null
} }
}, },
watch: { watch: {
@ -124,34 +126,7 @@ export default {
this.items = results || [] this.items = results || []
}, },
keydownInput(event) { keydownInput(event) {
let items = this.itemsToShow this.menuNavigationHandler(event)
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
clearTimeout(this.typingTimeout) clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => { this.typingTimeout = setTimeout(() => {
this.search() this.search()
@ -166,24 +141,6 @@ export default {
this.recalcMenuPos() this.recalcMenuPos()
}, 50) }, 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() { recalcMenuPos() {
if (!this.menu || !this.$refs.inputWrapper) return if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect() var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
@ -323,9 +280,6 @@ export default {
this.textInput = null this.textInput = null
this.currentSearch = null this.currentSearch = null
this.selectedMenuItemIndex = null this.selectedMenuItemIndex = null
this.$nextTick(() => {
this.blur()
})
}, },
submitForm() { submitForm() {
if (!this.textInput) return if (!this.textInput) return

View file

@ -15,7 +15,7 @@
<span class="font-normal ml-3 block truncate">{{ item.name }}</span> <span class="font-normal ml-3 block truncate">{{ item.name }}</span>
</div> </div>
<span v-if="isItemSelected(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> <span v-if="isItemSelected(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-symbols text-xl">checkmark</span> <span class="material-symbols text-xl">check</span>
</span> </span>
</li> </li>
</template> </template>

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