Merge branch 'advplyr:master' into feat/all-stats-page

This commit is contained in:
Vito0912 2024-12-29 20:28:49 +01:00 committed by GitHub
commit df15c22c6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
336 changed files with 21709 additions and 13151 deletions

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

@ -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:

2
.gitignore vendored
View file

@ -7,6 +7,7 @@
/podcasts/ /podcasts/
/media/ /media/
/metadata/ /metadata/
/plugins/
/client/.nuxt/ /client/.nuxt/
/client/dist/ /client/dist/
/dist/ /dist/
@ -16,6 +17,7 @@
/ffmpeg* /ffmpeg*
/ffprobe* /ffprobe*
/unicode* /unicode*
/libnusqlite3*
sw.* sw.*
.DS_STORE .DS_STORE

View file

@ -16,15 +16,30 @@ RUN apk update && \
tzdata \ tzdata \
ffmpeg \ ffmpeg \
make \ make \
gcompat \
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

@ -1,6 +1,6 @@
<template> <template>
<div class="w-full h-16 bg-primary relative"> <div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60"> <div id="appbar" role="toolbar" aria-label="Appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
<div class="flex h-full items-center"> <div class="flex h-full items-center">
<nuxt-link to="/"> <nuxt-link to="/">
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" /> <img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" />
@ -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

@ -17,7 +17,7 @@
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e"> <div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e">
<template v-for="(shelf, index) in supportedShelves"> <template v-for="(shelf, index) in supportedShelves">
<widgets-item-slider :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :type="shelf.type" class="bookshelf-row pl-8e my-6e" @selectEntity="(payload) => selectEntity(payload, index)"> <widgets-item-slider :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :type="shelf.type" class="bookshelf-row pl-8e my-6e" @selectEntity="(payload) => selectEntity(payload, index)">
<p class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</p> <h2 class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</h2>
</widgets-item-slider> </widgets-item-slider>
</template> </template>
</div> </div>
@ -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">
@ -37,18 +37,18 @@
<div class="relative"> <div class="relative">
<div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md"> <div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }"> <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
<p :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</p> <h2 :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</h2>
</div> </div>
</div> </div>
<div class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></div> <div class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></div>
</div> </div>
<div v-show="canScrollLeft && !isScrolling" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft"> <button v-show="canScrollLeft && !isScrolling" :aria-label="$strings.ButtonScrollLeft" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft">
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_left</span> <span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_left</span>
</div> </button>
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight"> <button v-show="canScrollRight && !isScrolling" :aria-label="$strings.ButtonScrollRight" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight">
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_right</span> <span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_right</span>
</div> </button>
</div> </div>
</template> </template>

View file

@ -30,7 +30,7 @@
<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 text-lg">&#xe431;</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
@ -42,15 +42,18 @@
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'"> <nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">{{ $strings.ButtonAdd }}</p> <p class="text-sm">{{ $strings.ButtonAdd }}</p>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">{{ $strings.ButtonDownloadQueue }}</p>
</nuxt-link>
</div> </div>
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8"> <div id="toolbar" role="toolbar" aria-label="Library Toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
<!-- Series books page --> <!-- Series books page -->
<template v-if="selectedSeries"> <template v-if="selectedSeries">
<p class="pl-2 text-base md:text-lg"> <p class="pl-2 text-base md:text-lg">
{{ 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 +65,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 +83,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 +95,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 +122,7 @@ export default {
type: Object, type: Object,
default: () => null default: () => null
}, },
searchQuery: String, searchQuery: String
authors: {
type: Array,
default: () => []
}
}, },
data() { data() {
return { return {
@ -246,9 +247,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 === ''
}, },
@ -270,8 +268,11 @@ export default {
isPodcastLatestPage() { isPodcastLatestPage() {
return this.$route.name === 'library-library-podcast-latest' return this.$route.name === 'library-library-podcast-latest'
}, },
isPodcastDownloadQueuePage() {
return this.$route.name === 'library-library-podcast-download-queue'
},
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 +282,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() {
@ -473,20 +474,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
@ -504,7 +513,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)`)
@ -514,6 +523,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() {
@ -522,13 +535,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

View file

@ -1,6 +1,6 @@
<template> <template>
<div> <div role="toolbar" aria-orientation="vertical" aria-label="Config Sidebar">
<div class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside"> <div role="navigation" aria-label="Config Navigation" class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside">
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer"> <div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
<span class="material-symbols text-2xl">arrow_back</span> <span class="material-symbols text-2xl">arrow_back</span>
</div> </div>
@ -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: {{ $config.version }}</a> <a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ versionData.latestVersion }}</a>
</div> </div>
</div> </div>
</template> </template>

View file

@ -2,6 +2,10 @@
<div id="bookshelf" ref="bookshelf" class="w-full overflow-y-auto" :style="{ fontSize: sizeMultiplier + 'rem' }"> <div id="bookshelf" ref="bookshelf" class="w-full overflow-y-auto" :style="{ fontSize: sizeMultiplier + 'rem' }">
<template v-for="shelf in totalShelves"> <template v-for="shelf in totalShelves">
<div :key="shelf" :id="`shelf-${shelf - 1}`" class="w-full px-4e sm:px-8e relative" :class="{ bookshelfRow: !isAlternativeBookshelfView }" :style="{ height: shelfHeight + 'px' }"> <div :key="shelf" :id="`shelf-${shelf - 1}`" class="w-full px-4e sm:px-8e relative" :class="{ bookshelfRow: !isAlternativeBookshelfView }" :style="{ height: shelfHeight + 'px' }">
<!-- Card skeletons -->
<template v-for="entityIndex in entitiesInShelf(shelf)">
<div :key="entityIndex" class="w-full h-full absolute rounded z-5 top-0 left-0 bg-primary box-shadow-book" :style="{ transform: entityTransform(entityIndex), width: cardWidth + 'px', height: coverHeight + 'px' }" />
</template>
<div v-if="!isAlternativeBookshelfView" class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20 h-6e" /> <div v-if="!isAlternativeBookshelfView" class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20 h-6e" />
</div> </div>
</template> </template>
@ -65,7 +69,13 @@ export default {
tempIsScanning: false, tempIsScanning: false,
cardWidth: 0, cardWidth: 0,
cardHeight: 0, cardHeight: 0,
resizeObserver: null coverHeight: 0,
resizeObserver: null,
lastScrollTop: 0,
lastTimestamp: 0,
postScrollTimeout: null,
currFirstEntityIndex: -1,
currLastEntityIndex: -1
} }
}, },
watch: { watch: {
@ -91,6 +101,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 +122,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')
}, },
@ -164,9 +181,6 @@ export default {
bookWidth() { bookWidth() {
return this.cardWidth return this.cardWidth
}, },
bookHeight() {
return this.cardHeight
},
shelfPadding() { shelfPadding() {
if (this.bookshelfWidth < 640) return 32 * this.sizeMultiplier if (this.bookshelfWidth < 640) return 32 * this.sizeMultiplier
return 64 * this.sizeMultiplier return 64 * this.sizeMultiplier
@ -177,9 +191,6 @@ export default {
entityWidth() { entityWidth() {
return this.cardWidth return this.cardWidth
}, },
entityHeight() {
return this.cardHeight
},
shelfPaddingHeight() { shelfPaddingHeight() {
return 16 return 16
}, },
@ -217,6 +228,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() {
@ -345,50 +358,53 @@ export default {
} }
}, },
loadPage(page) { loadPage(page) {
this.pagesLoaded[page] = true if (!this.pagesLoaded[page]) this.pagesLoaded[page] = this.fetchEntites(page)
this.fetchEntites(page) return this.pagesLoaded[page]
}, },
showHideBookPlaceholder(index, show) { showHideBookPlaceholder(index, show) {
var el = document.getElementById(`book-${index}-placeholder`) var el = document.getElementById(`book-${index}-placeholder`)
if (el) el.style.display = show ? 'flex' : 'none' if (el) el.style.display = show ? 'flex' : 'none'
}, },
mountEntites(fromIndex, toIndex) { mountEntities(fromIndex, toIndex) {
for (let i = fromIndex; i < toIndex; i++) { for (let i = fromIndex; i < toIndex; i++) {
if (!this.entityIndexesMounted.includes(i)) { if (!this.entityIndexesMounted.includes(i)) {
this.cardsHelpers.mountEntityCard(i) this.cardsHelpers.mountEntityCard(i)
} }
} }
}, },
handleScroll(scrollTop) { getVisibleIndices(scrollTop) {
this.currScrollTop = scrollTop const firstShelfIndex = Math.floor(scrollTop / this.shelfHeight)
var firstShelfIndex = Math.floor(scrollTop / this.shelfHeight) const lastShelfIndex = Math.min(Math.ceil((scrollTop + this.bookshelfHeight) / this.shelfHeight), this.totalShelves - 1)
var lastShelfIndex = Math.ceil((scrollTop + this.bookshelfHeight) / this.shelfHeight) const firstEntityIndex = firstShelfIndex * this.entitiesPerShelf
lastShelfIndex = Math.min(this.totalShelves - 1, lastShelfIndex) const lastEntityIndex = Math.min(lastShelfIndex * this.entitiesPerShelf + this.entitiesPerShelf, this.totalEntities)
return { firstEntityIndex, lastEntityIndex }
var firstBookIndex = firstShelfIndex * this.entitiesPerShelf },
var lastBookIndex = lastShelfIndex * this.entitiesPerShelf + this.entitiesPerShelf postScroll() {
lastBookIndex = Math.min(this.totalEntities, lastBookIndex) const { firstEntityIndex, lastEntityIndex } = this.getVisibleIndices(this.currScrollTop)
var firstBookPage = Math.floor(firstBookIndex / this.booksPerFetch)
var lastBookPage = Math.floor(lastBookIndex / this.booksPerFetch)
if (!this.pagesLoaded[firstBookPage]) {
// console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex)
this.loadPage(firstBookPage)
}
if (!this.pagesLoaded[lastBookPage]) {
// console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
this.loadPage(lastBookPage)
}
this.entityIndexesMounted = this.entityIndexesMounted.filter((_index) => { this.entityIndexesMounted = this.entityIndexesMounted.filter((_index) => {
if (_index < firstBookIndex || _index >= lastBookIndex) { if (_index < firstEntityIndex || _index >= lastEntityIndex) {
var el = document.getElementById(`book-card-${_index}`) var el = this.entityComponentRefs[_index]
if (el) el.remove() if (el && el.$el) el.$el.remove()
return false return false
} }
return true return true
}) })
this.mountEntites(firstBookIndex, lastBookIndex) },
handleScroll(scrollTop) {
this.currScrollTop = scrollTop
const { firstEntityIndex, lastEntityIndex } = this.getVisibleIndices(scrollTop)
if (firstEntityIndex === this.currFirstEntityIndex && lastEntityIndex === this.currLastEntityIndex) return
this.currFirstEntityIndex = firstEntityIndex
this.currLastEntityIndex = lastEntityIndex
clearTimeout(this.postScrollTimeout)
const firstPage = Math.floor(firstEntityIndex / this.booksPerFetch)
const lastPage = Math.floor(lastEntityIndex / this.booksPerFetch)
Promise.all([this.loadPage(firstPage), this.loadPage(lastPage)])
.then(() => this.mountEntities(firstEntityIndex, lastEntityIndex))
.catch((error) => console.error('Failed to load page', error))
this.postScrollTimeout = setTimeout(this.postScroll, 500)
}, },
async resetEntities() { async resetEntities() {
if (this.isFetchingEntities) { if (this.isFetchingEntities) {
@ -396,8 +412,6 @@ export default {
return return
} }
this.destroyEntityComponents() this.destroyEntityComponents()
this.entityIndexesMounted = []
this.entityComponentRefs = {}
this.pagesLoaded = {} this.pagesLoaded = {}
this.entities = [] this.entities = []
this.totalShelves = 0 this.totalShelves = 0
@ -407,40 +421,21 @@ export default {
this.initialized = false this.initialized = false
this.initSizeData() this.initSizeData()
this.pagesLoaded[0] = true await this.loadPage(0)
await this.fetchEntites(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf) var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntites(0, lastBookIndex) this.mountEntities(0, lastBookIndex)
}, },
remountEntities() { async rebuild() {
for (const key in this.entityComponentRefs) {
if (this.entityComponentRefs[key]) {
this.entityComponentRefs[key].destroy()
}
}
this.entityComponentRefs = {}
this.entityIndexesMounted.forEach((i) => {
this.cardsHelpers.mountEntityCard(i)
})
},
rebuild() {
this.initSizeData() this.initSizeData()
var lastBookIndex = Math.min(this.totalEntities, this.booksPerFetch) var lastBookIndex = Math.min(this.totalEntities, this.booksPerFetch)
this.entityIndexesMounted = [] this.destroyEntityComponents()
for (let i = 0; i < lastBookIndex; i++) { await this.loadPage(0)
this.entityIndexesMounted.push(i)
if (!this.entities[i]) {
const page = Math.floor(i / this.booksPerFetch)
this.loadPage(page)
}
}
var bookshelfEl = document.getElementById('bookshelf') var bookshelfEl = document.getElementById('bookshelf')
if (bookshelfEl) { if (bookshelfEl) {
bookshelfEl.scrollTop = 0 bookshelfEl.scrollTop = 0
} }
this.mountEntities(0, lastBookIndex)
this.$nextTick(this.remountEntities)
}, },
buildSearchParams() { buildSearchParams() {
if (this.page === 'search' || this.page === 'collections') { if (this.page === 'search' || this.page === 'collections') {
@ -457,6 +452,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)
@ -501,12 +499,29 @@ export default {
if (wasUpdated) { if (wasUpdated) {
this.resetEntities() this.resetEntities()
} else if (settings.bookshelfCoverSize !== this.currentBookWidth) { } else if (settings.bookshelfCoverSize !== this.currentBookWidth) {
this.executeRebuild() this.rebuild()
} }
}, },
getScrollRate() {
const currentTimestamp = Date.now()
const timeDelta = currentTimestamp - this.lastTimestamp
const scrollDelta = this.currScrollTop - this.lastScrollTop
const scrollRate = Math.abs(scrollDelta) / (timeDelta || 1)
this.lastScrollTop = this.currScrollTop
this.lastTimestamp = currentTimestamp
return scrollRate
},
scroll(e) { scroll(e) {
if (!e || !e.target) return if (!e || !e.target) return
var { scrollTop } = e.target clearTimeout(this.scrollTimeout)
const { scrollTop } = e.target
const scrollRate = this.getScrollRate()
if (scrollRate > 5) {
this.scrollTimeout = setTimeout(() => {
this.handleScroll(scrollTop)
}, 25)
return
}
this.handleScroll(scrollTop) this.handleScroll(scrollTop)
}, },
libraryItemAdded(libraryItem) { libraryItemAdded(libraryItem) {
@ -601,6 +616,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)
@ -627,13 +670,14 @@ export default {
}, },
updatePagesLoaded() { updatePagesLoaded() {
let numPages = Math.ceil(this.totalEntities / this.booksPerFetch) let numPages = Math.ceil(this.totalEntities / this.booksPerFetch)
this.pagesLoaded = {}
for (let page = 0; page < numPages; page++) { for (let page = 0; page < numPages; page++) {
let numEntities = Math.min(this.totalEntities - page * this.booksPerFetch, this.booksPerFetch) let numEntities = Math.min(this.totalEntities - page * this.booksPerFetch, this.booksPerFetch)
this.pagesLoaded[page] = true this.pagesLoaded[page] = Promise.resolve()
for (let i = 0; i < numEntities; i++) { for (let i = 0; i < numEntities; i++) {
const index = page * this.booksPerFetch + i const index = page * this.booksPerFetch + i
if (!this.entities[index]) { if (!this.entities[index]) {
this.pagesLoaded[page] = false if (this.pagesLoaded[page]) delete this.pagesLoaded[page]
break break
} }
} }
@ -648,7 +692,6 @@ export default {
var entitiesPerShelfBefore = this.entitiesPerShelf var entitiesPerShelfBefore = this.entitiesPerShelf
var { clientHeight, clientWidth } = bookshelf var { clientHeight, clientWidth } = bookshelf
// console.log('Init bookshelf width', clientWidth, 'window width', window.innerWidth)
this.mountWindowWidth = window.innerWidth this.mountWindowWidth = window.innerWidth
this.bookshelfHeight = clientHeight this.bookshelfHeight = clientHeight
this.bookshelfWidth = clientWidth this.bookshelfWidth = clientWidth
@ -673,10 +716,9 @@ export default {
this.initSizeData(bookshelf) this.initSizeData(bookshelf)
this.checkUpdateSearchParams() this.checkUpdateSearchParams()
this.pagesLoaded[0] = true await this.loadPage(0)
await this.fetchEntites(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf) var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntites(0, lastBookIndex) this.mountEntities(0, lastBookIndex)
// Set last scroll position for this bookshelf page // Set last scroll position for this bookshelf page
if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) { if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {
@ -707,7 +749,7 @@ export default {
var bookshelf = document.getElementById('bookshelf') var bookshelf = document.getElementById('bookshelf')
if (bookshelf) { if (bookshelf) {
this.init(bookshelf) this.init(bookshelf)
bookshelf.addEventListener('scroll', this.scroll) bookshelf.addEventListener('scroll', this.scroll, { passive: true })
} }
}) })
@ -727,6 +769,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 +801,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 {
@ -764,10 +812,14 @@ export default {
}, },
destroyEntityComponents() { destroyEntityComponents() {
for (const key in this.entityComponentRefs) { for (const key in this.entityComponentRefs) {
if (this.entityComponentRefs[key] && this.entityComponentRefs[key].destroy) { const ref = this.entityComponentRefs[key]
this.entityComponentRefs[key].destroy() if (ref && ref.destroy) {
if (ref.$el) ref.$el.remove()
ref.destroy()
} }
} }
this.entityComponentRefs = {}
this.entityIndexesMounted = []
}, },
scan() { scan() {
this.tempIsScanning = true this.tempIsScanning = true
@ -780,6 +832,14 @@ export default {
.finally(() => { .finally(() => {
this.tempIsScanning = false this.tempIsScanning = false
}) })
},
entitiesInShelf(shelf) {
return shelf == this.totalShelves ? this.totalEntities % this.entitiesPerShelf || this.entitiesPerShelf : this.entitiesPerShelf
},
entityTransform(entityIndex) {
const shelfOffsetY = this.shelfPaddingHeight * this.sizeMultiplier
const shelfOffsetX = (entityIndex - 1) * this.totalEntityCardWidth + this.bookshelfMarginLeft
return `translate3d(${shelfOffsetX}px, ${shelfOffsetY}px, 0px)`
} }
}, },
async mounted() { async mounted() {

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>
@ -55,7 +53,6 @@
@showBookmarks="showBookmarks" @showBookmarks="showBookmarks"
@showSleepTimer="showSleepTimerModal = true" @showSleepTimer="showSleepTimerModal = true"
@showPlayerQueueItems="showPlayerQueueItemsModal = true" @showPlayerQueueItems="showPlayerQueueItemsModal = true"
@showPlayerSettings="showPlayerSettingsModal = true"
/> />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" /> <modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
@ -63,8 +60,6 @@
<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" /> <modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" />
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
</div> </div>
</template> </template>
@ -83,7 +78,6 @@ export default {
currentTime: 0, currentTime: 0,
showSleepTimerModal: false, showSleepTimerModal: false,
showPlayerQueueItemsModal: false, showPlayerQueueItemsModal: false,
showPlayerSettingsModal: false,
sleepTimerSet: false, sleepTimerSet: false,
sleepTimerRemaining: 0, sleepTimerRemaining: 0,
sleepTimerType: null, sleepTimerType: null,
@ -140,9 +134,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
}, },
@ -172,11 +163,7 @@ 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() {
if (!this.isMusic) return null
return this.mediaMetadata.artists.join(', ')
}, },
hasNextItemInQueue() { hasNextItemInQueue() {
return this.currentPlayerQueueIndex < this.playerQueueItems.length - 1 return this.currentPlayerQueueIndex < this.playerQueueItems.length - 1
@ -260,7 +247,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
@ -534,7 +521,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) {

View file

@ -1,9 +1,9 @@
<template> <template>
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }"> <div role="toolbar" aria-orientation="vertical" aria-label="Library Sidebar" class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar --> <!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" /> <div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
<div id="siderail-buttons-container" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden"> <div id="siderail-buttons-container" role="navigation" aria-label="Library Navigation" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden">
<nuxt-link :to="`/library/${currentLibraryId}`" 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="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> <nuxt-link :to="`/library/${currentLibraryId}`" 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="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
@ -58,7 +58,7 @@
<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"
@ -95,14 +95,6 @@
<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 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">&#xf090;</span> <span class="material-symbols text-2xl">&#xf090;</span>
@ -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' }"> <article 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 -->
@ -34,13 +34,13 @@
</div> </div>
</div> </div>
</nuxt-link> </nuxt-link>
</div> </article>
</template> </template>
<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: {
@ -67,35 +68,41 @@ export default {
cardHeight() { cardHeight() {
return this.height * this.sizeMultiplier return this.height * this.sizeMultiplier
}, },
coverHeight() {
return this.cardHeight
},
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 +128,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

@ -1,5 +1,5 @@
<template> <template>
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> <article ref="card" :id="`book-card-${index}`" tabindex="0" :aria-label="displayTitle" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded overflow-hidden z-10 bg-primary box-shadow-book" :style="{ height: coverHeight + 'px ' }"> <div :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded overflow-hidden z-10 bg-primary box-shadow-book" :style="{ height: coverHeight + 'px ' }">
<!-- When cover image does not fill --> <!-- When cover image does not fill -->
<div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary"> <div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
@ -14,21 +14,21 @@
</div> </div>
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10"> <div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
<div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }"> <div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" aria-hidden="true" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }">
<p :style="{ fontSize: 0.8 + 'em' }" class="text-gray-300 text-center">{{ title }}</p> <p :style="{ fontSize: 0.8 + 'em' }" class="text-gray-300 text-center">{{ title }}</p>
</div> </div>
<!-- Cover Image --> <!-- Cover Image -->
<img cy-id="coverImage" v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" /> <img cy-id="coverImage" v-if="libraryItem" :alt="`${displayTitle}, ${$strings.LabelCover}`" ref="cover" aria-hidden="true" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<!-- Placeholder Cover Title & Author --> <!-- Placeholder Cover Title & Author -->
<div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }"> <div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }">
<div> <div>
<p cy-id="placeholderTitleText" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p> <p cy-id="placeholderTitleText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
</div> </div>
</div> </div>
<div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }"> <div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }">
<p cy-id="placeholderAuthorText" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p> <p cy-id="placeholderAuthorText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
</div> </div>
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #78350f"> <div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #78350f">
@ -93,11 +93,11 @@
<!-- rss feed icon --> <!-- rss feed icon -->
<div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 + 'em' }"> <div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 + 'em' }">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span> <span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span>
</div> </div>
<!-- media item shared icon --> <!-- media item shared icon -->
<div cy-id="mediaItemShare" v-if="mediaItemShare && !isSelectionMode && !isHovering" class="absolute text-success left-0 z-10" :style="{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }"> <div cy-id="mediaItemShare" v-if="mediaItemShare && !isSelectionMode && !isHovering" class="absolute text-success left-0 z-10" :style="{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">public</span> <span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">public</span>
</div> </div>
<!-- Series sequence --> <!-- Series sequence -->
@ -114,7 +114,7 @@
<!-- Podcast Num Episodes --> <!-- Podcast Num Episodes -->
<div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }"> <div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }">
<p :style="{ fontSize: 0.8 + 'em' }">{{ numEpisodes }}</p> <p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfEpisodes">{{ numEpisodes }}</p>
</div> </div>
<!-- Podcast Num Episodes --> <!-- Podcast Num Episodes -->
@ -128,7 +128,7 @@
<div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full"> <div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full">
<div :style="{ fontSize: 0.9 + 'em' }"> <div :style="{ fontSize: 0.9 + 'em' }">
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center"> <ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
<p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p> <p cy-id="title" ref="displayTitle" aria-hidden="true" class="truncate">{{ displayTitle }}</p>
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" /> <widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
</ui-tooltip> </ui-tooltip>
</div> </div>
@ -138,7 +138,7 @@
<p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || '&nbsp;' }}</p> <p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || '&nbsp;' }}</p>
<p cy-id="line3" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p> <p cy-id="line3" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
</div> </div>
</div> </article>
</template> </template>
<script> <script>
@ -226,9 +226,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
}, },
@ -328,7 +325,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 ''
@ -336,7 +333,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 || ''
@ -346,14 +342,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
@ -364,7 +360,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)
}, },
@ -420,7 +415,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
@ -464,8 +459,6 @@ export default {
return this.store.getters['user/getIsAdminOrUp'] return this.store.getters['user/getIsAdminOrUp']
}, },
moreMenuItems() { moreMenuItems() {
if (this.isMusic) return []
if (this.recentEpisode) { if (this.recentEpisode) {
const items = [ const items = [
{ {
@ -710,7 +703,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)
@ -755,18 +748,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
@ -823,7 +816,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
@ -841,7 +834,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
@ -856,7 +849,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
} }
@ -906,11 +899,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
@ -1016,7 +1009,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

@ -1,5 +1,5 @@
<template> <template>
<div ref="card" :id="`collection-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> <div ref="card" :id="`collection-card-${index}`" role="button" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="relative" :style="{ height: coverHeight + 'px' }"> <div class="relative" :style="{ height: coverHeight + 'px' }">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" /> <div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden"> <div class="w-full h-full bg-primary relative rounded overflow-hidden">

View file

@ -1,5 +1,5 @@
<template> <template>
<div ref="card" :id="`playlist-card-${index}`" :style="{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> <div ref="card" :id="`playlist-card-${index}`" role="button" :style="{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="relative" :style="{ height: coverHeight + 'px' }"> <div class="relative" :style="{ height: coverHeight + 'px' }">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" /> <div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden"> <div class="w-full h-full bg-primary relative rounded overflow-hidden">

View file

@ -1,5 +1,5 @@
<template> <template>
<div cy-id="card" ref="card" :id="`series-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> <article cy-id="card" ref="card" :id="`series-card-${index}`" tabindex="0" :aria-label="displayTitle" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div cy-id="covers-area" class="relative" :style="{ height: coverHeight + 'px' }"> <div cy-id="covers-area" class="relative" :style="{ height: coverHeight + 'px' }">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" /> <div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0"> <div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
@ -7,12 +7,12 @@
</div> </div>
<div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd"> <div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
<p :style="{ fontSize: 0.8 + 'em' }">{{ books.length }}</p> <p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfBooks">{{ books.length }}</p>
</div> </div>
<div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" /> <div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
<div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: '1em' }"> <div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" aria-hidden="true" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: '1em' }">
<p :style="{ fontSize: 1.2 + 'em' }">{{ displayTitle }}</p> <p :style="{ fontSize: 1.2 + 'em' }">{{ displayTitle }}</p>
</div> </div>
@ -21,14 +21,14 @@
<div cy-id="standardBottomText" v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }"> <div cy-id="standardBottomText" v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }"> <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
<p cy-id="standardBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p> <p cy-id="standardBottomDisplayTitle" class="truncate" aria-hidden="true" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
</div> </div>
</div> </div>
<div cy-id="detailBottomText" v-else class="relative z-30 left-0 right-0 mx-auto py-1e rounded-md text-center"> <div cy-id="detailBottomText" v-else class="relative z-30 left-0 right-0 mx-auto py-1e rounded-md text-center">
<p cy-id="detailBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p> <p cy-id="detailBottomDisplayTitle" class="truncate" aria-hidden="true" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
<p cy-id="detailBottomSortLine" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p> <p cy-id="detailBottomSortLine" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
</div> </div>
</div> </article>
</template> </template>
<script> <script>
@ -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

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

@ -1,15 +1,15 @@
<template> <template>
<div class=""> <div class="">
<div class="w-full relative sm:w-80"> <div class="w-full relative sm:w-80">
<form @submit.prevent="submitSearch"> <form role="search" @submit.prevent="submitSearch">
<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"> <button :aria-hidden="!search" 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">&#xe8b6;</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>
</button>
</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" @mousedown.stop.prevent>
<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">
<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

@ -1,28 +1,30 @@
<template> <template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside"> <div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu"> <div class="relative h-7">
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between"> <span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span> <span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
</span> </span>
</button>
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none"> <span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg> </svg>
</span> </span>
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected"> <button v-else :aria-label="$strings.ButtonClearFilter" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
<span class="material-symbols" style="font-size: 1.1rem">close</span> <span class="material-symbols" style="font-size: 1.1rem">close</span>
</div>
</button> </button>
</div>
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu"> <div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul v-show="!sublist" class="h-full w-full" role="menu">
<template v-for="item in selectItems"> <template v-for="item in selectItems">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)"> <li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" :aria-haspopup="item.sublist ? '' : 'menu'" @click="clickedOption(item)">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span> <span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
</div> </div>
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center"> <div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
<span class="material-symbols text-2xl">arrow_right</span> <span class="material-symbols text-2xl" :aria-label="$strings.LabelMore">arrow_right</span>
</div> </div>
<!-- selected checkmark icon --> <!-- selected checkmark icon -->
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none"> <div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
@ -31,8 +33,8 @@
</li> </li>
</template> </template>
</ul> </ul>
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul v-show="sublist" class="h-full w-full" role="menu">
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="option" @click="sublist = null"> <li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="menuitem" @click="sublist = null">
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center"> <div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
<span class="material-symbols text-2xl">arrow_left</span> <span class="material-symbols text-2xl">arrow_left</span>
</div> </div>
@ -40,13 +42,13 @@
<span class="font-normal block truncate">{{ $strings.ButtonBack }}</span> <span class="font-normal block truncate">{{ $strings.ButtonBack }}</span>
</div> </div>
</li> </li>
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option"> <li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="menuitem">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<span class="font-normal block truncate py-2">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span> <span class="font-normal block truncate py-2">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span>
</div> </div>
</li> </li>
<template v-for="item in sublistItems"> <template v-for="item in sublistItems">
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedSublistOption(item.value)"> <li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedSublistOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span> <span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
</div> </div>
@ -98,9 +100,6 @@ export default {
isPodcast() { isPodcast() {
return this.libraryMediaType === 'podcast' return this.libraryMediaType === 'podcast'
}, },
isMusic() {
return this.libraryMediaType === 'music'
},
seriesItems() { seriesItems() {
return [ return [
{ {
@ -192,6 +191,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 +279,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 +346,9 @@ export default {
publishers() { publishers() {
return this.filterData.publishers || [] return this.filterData.publishers || []
}, },
publishedDecades() {
return this.filterData.publishedDecades || []
},
progress() { progress() {
return [ return [
{ {
@ -433,21 +415,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 +436,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

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

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

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

@ -121,6 +121,8 @@ export default {
var img = document.createElement('img') var img = document.createElement('img')
img.src = src img.src = src
img.alt = `${this.name}, ${this.$strings.LabelCover}`
img.ariaHidden = true
img.className = 'absolute top-0 left-0 w-full h-full' img.className = 'absolute top-0 left-0 w-full h-full'
img.style.objectFit = showCoverBg ? 'contain' : 'cover' img.style.objectFit = showCoverBg ? 'contain' : 'cover'

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,10 +360,11 @@ export default {
update: type === 'admin', update: type === 'admin',
delete: type === 'admin', delete: type === 'admin',
upload: type === 'admin', upload: type === 'admin',
accessExplicitContent: true, accessExplicitContent: type === 'admin',
accessAllLibraries: true, accessAllLibraries: true,
accessAllTags: true, accessAllTags: true,
selectedTagsNotAccessible: false selectedTagsNotAccessible: false,
createEreader: type === 'admin'
} }
}, },
init() { init() {
@ -386,8 +396,9 @@ export default {
upload: false, upload: false,
accessAllLibraries: true, accessAllLibraries: true,
accessAllTags: true, accessAllTags: true,
accessExplicitContent: true, accessExplicitContent: false,
selectedTagsNotAccessible: 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

@ -54,8 +54,7 @@ export default {
options: { options: {
provider: undefined, provider: undefined,
overrideDetails: true, overrideDetails: true,
overrideCover: true, overrideCover: true
overrideDefaults: true
} }
} }
}, },
@ -99,8 +98,8 @@ export default {
init() { init() {
// If we don't have a set provider (first open of dialog) or we've switched library, set // If we don't have a set provider (first open of dialog) or we've switched library, set
// the selected provider to the current library default provider // the selected provider to the current library default provider
if (!this.options.provider || this.options.lastUsedLibrary != this.currentLibraryId) { if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) {
this.options.lastUsedLibrary = this.currentLibraryId this.lastUsedLibrary = this.currentLibraryId
this.options.provider = this.libraryProvider this.options.provider = this.libraryProvider
} }
}, },
@ -116,10 +115,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

@ -1,8 +1,8 @@
<template> <template>
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 61" @click="clickClose"> <div ref="wrapper" role="dialog" aria-modal="true" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 61" @click="clickClose">
<div class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300"> <button type="button" class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" aria-label="Close modal">
<span class="material-symbols text-2xl md:text-4xl">close</span> <span class="material-symbols text-2xl md:text-4xl">close</span>
</div> </button>
<div ref="content" class="text-white"> <div ref="content" class="text-white">
<form v-if="selectedSeries" @submit.prevent="submitSeriesForm"> <form v-if="selectedSeries" @submit.prevent="submitSeriesForm">
<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>

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

@ -1,12 +1,12 @@
<template> <template>
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`"> <div ref="wrapper" role="dialog" aria-modal="true" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" /> <div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose"> <button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
<span class="material-symbols text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span> <span class="material-symbols text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
</button> </button>
<slot name="outer" /> <slot name="outer" />
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" aria-modal="true" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg"> <div ref="content" tabindex="0" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white outline-none" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
<slot /> <slot />
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center"> <div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
<ui-loading-indicator /> <ui-loading-indicator />
@ -126,6 +126,9 @@ export default {
this.$eventBus.$on('modal-hotkey', this.hotkey) this.$eventBus.$on('modal-hotkey', this.hotkey)
this.$store.commit('setOpenModal', this.name) this.$store.commit('setOpenModal', this.name)
// Set focus to the modal content
this.content.focus()
}, },
setHide() { setHide() {
if (this.content) this.content.style.transform = 'scale(0)' if (this.content) this.content.style.transform = 'scale(0)'

View file

@ -59,12 +59,19 @@ export default {
setJumpBackwardAmount(val) { setJumpBackwardAmount(val) {
this.jumpBackwardAmount = val this.jumpBackwardAmount = val
this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val }) this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val })
}
}, },
mounted() { settingsUpdated() {
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
} }
},
mounted() {
this.settingsUpdated()
this.$eventBus.$on('user-settings', this.settingsUpdated)
},
beforeDestroy() {
this.$eventBus.$off('user-settings', this.settingsUpdated)
}
} }
</script> </script>

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

@ -2,7 +2,7 @@
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'"> <modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
<template #outer> <template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden"> <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="text-3xl text-white truncate">Changelog</p> <h1 class="text-3xl text-white truncate">Changelog</h1>
</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">
@ -13,7 +13,7 @@
</p> </p>
<div class="custom-text" v-html="getChangelog(release)" /> <div class="custom-text" v-html="getChangelog(release)" />
</div> </div>
<div v-if="release !== releasesToShow[releasesToShow.length - 1]" class="border-b border-black-300 my-8" /> <div v-if="release !== releasesToShow[releasesToShow.length - 1]" :key="`${release.name}-divider`" class="border-b border-black-300 my-8" />
</template> </template>
</div> </div>
</modals-modal> </modals-modal>

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

@ -2,24 +2,24 @@
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="marginTop"> <modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="marginTop">
<template #outer> <template #outer>
<div class="absolute top-0 left-0 p-4 landscape:px-4 landscape:py-2 md:portrait:p-5 lg:p-5 w-2/3 overflow-hidden pointer-events-none"> <div class="absolute top-0 left-0 p-4 landscape:px-4 landscape:py-2 md:portrait:p-5 lg:p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</p> <h1 class="text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</h1>
</div> </div>
</template> </template>
<div class="absolute -top-10 left-0 z-10 w-full flex"> <div role="tablist" class="absolute -top-10 left-0 z-10 w-full flex">
<template v-for="tab in availableTabs"> <template v-for="tab in availableTabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div> <button :key="tab.id" role="tab" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</button>
</template> </template>
</div> </div>
<div role="tabpanel" class="w-full h-full max-h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
</div>
<div v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6"> <div v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
<div class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goPrevBook" @mousedown.prevent>arrow_back_ios</div> <button class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" :aria-label="$strings.ButtonNext" @click.stop.prevent="goPrevBook" @mousedown.prevent>arrow_back_ios</button>
</div> </div>
<div v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6"> <div v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
<div class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div> <button class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" :aria-label="$strings.ButtonPrevious" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</button>
</div>
<div class="w-full h-full max-h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
</div> </div>
</modals-modal> </modals-modal>
</template> </template>

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

@ -60,7 +60,7 @@
<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"> <p v-if="mediaMetadata.title" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('title', mediaMetadata.title)">{{ mediaMetadata.title || '' }}</a> {{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('title', mediaMetadata.title)">{{ mediaMetadata.title || '' }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -69,7 +69,7 @@
<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"> <p v-if="mediaMetadata.subtitle" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('subtitle', mediaMetadata.subtitle)">{{ mediaMetadata.subtitle }}</a> {{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('subtitle', mediaMetadata.subtitle)">{{ mediaMetadata.subtitle }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -78,7 +78,7 @@
<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"> <p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('author', mediaMetadata.authorName)">{{ mediaMetadata.authorName }}</a> {{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('author', mediaMetadata.authorName)">{{ mediaMetadata.authorName }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -87,7 +87,7 @@
<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"> <p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('narrator', mediaMetadata.narrators)">{{ mediaMetadata.narratorName }}</a> {{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('narrator', mediaMetadata.narrators)">{{ mediaMetadata.narratorName }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -96,7 +96,7 @@
<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"> <p v-if="mediaMetadata.description" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('description', mediaMetadata.description)">{{ mediaMetadata.description.substr(0, 100) + (mediaMetadata.description.length > 100 ? '...' : '') }}</a> {{ $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> </p>
</div> </div>
</div> </div>
@ -105,7 +105,7 @@
<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"> <p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publisher', mediaMetadata.publisher)">{{ mediaMetadata.publisher }}</a> {{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publisher', mediaMetadata.publisher)">{{ mediaMetadata.publisher }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -114,7 +114,7 @@
<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"> <p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publishedYear', mediaMetadata.publishedYear)">{{ mediaMetadata.publishedYear }}</a> {{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publishedYear', mediaMetadata.publishedYear)">{{ mediaMetadata.publishedYear }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -124,7 +124,7 @@
<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"> <p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('series', mediaMetadata.series)">{{ mediaMetadata.seriesName }}</a> {{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('series', mediaMetadata.series)">{{ mediaMetadata.seriesName }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -133,7 +133,7 @@
<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?.length" class="text-xs ml-1 text-white text-opacity-60"> <p v-if="mediaMetadata.genres?.length" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('genres', mediaMetadata.genres)">{{ mediaMetadata.genres.join(', ') }}</a> {{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('genres', mediaMetadata.genres)">{{ mediaMetadata.genres.join(', ') }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -142,7 +142,7 @@
<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?.length" class="text-xs ml-1 text-white text-opacity-60"> <p v-if="media.tags?.length" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('tags', media.tags)">{{ media.tags.join(', ') }}</a> {{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('tags', media.tags)">{{ media.tags.join(', ') }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -151,7 +151,7 @@
<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"> <p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('language', mediaMetadata.language)">{{ mediaMetadata.language }}</a> {{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('language', mediaMetadata.language)">{{ mediaMetadata.language }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -160,7 +160,7 @@
<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"> <p v-if="mediaMetadata.isbn" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('isbn', mediaMetadata.isbn)">{{ mediaMetadata.isbn }}</a> {{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('isbn', mediaMetadata.isbn)">{{ mediaMetadata.isbn }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -169,7 +169,7 @@
<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"> <p v-if="mediaMetadata.asin" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('asin', mediaMetadata.asin)">{{ mediaMetadata.asin }}</a> {{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('asin', mediaMetadata.asin)">{{ mediaMetadata.asin }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -179,7 +179,7 @@
<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"> <p v-if="mediaMetadata.itunesId" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('itunesId', mediaMetadata.itunesId)">{{ mediaMetadata.itunesId }}</a> {{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('itunesId', mediaMetadata.itunesId)">{{ mediaMetadata.itunesId }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -188,7 +188,7 @@
<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"> <p v-if="mediaMetadata.feedUrl" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('feedUrl', mediaMetadata.feedUrl)">{{ mediaMetadata.feedUrl }}</a> {{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('feedUrl', mediaMetadata.feedUrl)">{{ mediaMetadata.feedUrl }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -197,7 +197,7 @@
<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"> <p v-if="mediaMetadata.itunesPageUrl" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('itunesPageUrl', mediaMetadata.itunesPageUrl)">{{ mediaMetadata.itunesPageUrl }}</a> {{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('itunesPageUrl', mediaMetadata.itunesPageUrl)">{{ mediaMetadata.itunesPageUrl }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -206,7 +206,7 @@
<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"> <p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('releaseDate', mediaMetadata.releaseDate)">{{ mediaMetadata.releaseDate }}</a> {{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('releaseDate', mediaMetadata.releaseDate)">{{ mediaMetadata.releaseDate }}</a>
</p> </p>
</div> </div>
</div> </div>
@ -397,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()
@ -618,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

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

@ -10,9 +10,9 @@
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p> <p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p>
<div class="w-full relative"> <div class="w-full relative">
<ui-text-input v-model="currentFeed.feedUrl" readonly /> <ui-text-input :value="feedUrl" readonly />
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeed.feedUrl)">content_copy</span> <span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feedUrl)">content_copy</span>
</div> </div>
<div v-if="currentFeed.meta" class="mt-5"> <div v-if="currentFeed.meta" class="mt-5">
@ -111,8 +111,11 @@ export default {
userIsAdminOrUp() { userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']
}, },
feedUrl() {
return this.currentFeed ? `${window.origin}${this.$config.routerBasePath}${this.currentFeed.feedUrl}` : ''
},
demoFeedUrl() { demoFeedUrl() {
return `${window.origin}/feed/${this.newFeedSlug}` return `${window.origin}${this.$config.routerBasePath}/feed/${this.newFeedSlug}`
}, },
isHttp() { isHttp() {
return window.origin.startsWith('http://') return window.origin.startsWith('http://')
@ -121,14 +124,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 +142,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

@ -5,8 +5,8 @@
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p> <p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p>
<div class="w-full relative"> <div class="w-full relative">
<ui-text-input v-model="feed.feedUrl" readonly /> <ui-text-input :value="feedUrl" readonly />
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feed.feedUrl)">content_copy</span> <span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feedUrl)">content_copy</span>
</div> </div>
<div v-if="feed.meta" class="mt-5"> <div v-if="feed.meta" class="mt-5">
@ -70,6 +70,9 @@ export default {
}, },
_feed() { _feed() {
return this.feed || {} return this.feed || {}
},
feedUrl() {
return this.feed ? `${window.origin}${this.$config.routerBasePath}${this.feed.feedUrl}` : ''
} }
}, },
methods: { methods: {

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">
@ -25,14 +25,13 @@
<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,7 +40,6 @@ export default {
props: { props: {
loading: Boolean, loading: Boolean,
seekLoading: Boolean, seekLoading: Boolean,
playbackRate: Number,
paused: Boolean, paused: Boolean,
hasNextChapter: Boolean, hasNextChapter: Boolean,
hasNextItemInQueue: Boolean hasNextItemInQueue: Boolean
@ -50,14 +48,6 @@ export default {
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)
}, },
@ -89,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>
@ -37,7 +37,7 @@
</ui-tooltip> </ui-tooltip>
<ui-tooltip direction="top" :text="$strings.LabelViewPlayerSettings"> <ui-tooltip direction="top" :text="$strings.LabelViewPlayerSettings">
<button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerSettings')"> <button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="showPlayerSettings">
<span class="material-symbols text-2xl sm:text-2.5xl">settings_slow_motion</span> <span class="material-symbols text-2xl sm:text-2.5xl">settings_slow_motion</span>
</button> </button>
</ui-tooltip> </ui-tooltip>
@ -48,18 +48,24 @@
<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" />
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
</div> </div>
</template> </template>
@ -92,6 +98,7 @@ export default {
audioEl: null, audioEl: null,
seekLoading: false, seekLoading: false,
showChaptersModal: false, showChaptersModal: false,
showPlayerSettingsModal: false,
currentTime: 0, currentTime: 0,
duration: 0 duration: 0
} }
@ -178,22 +185,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
@ -240,6 +231,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)
}, },
@ -321,6 +318,9 @@ export default {
if (!this.chapters.length) return if (!this.chapters.length) return
this.showChaptersModal = !this.showChaptersModal this.showChaptersModal = !this.showChaptersModal
}, },
showPlayerSettings() {
this.showPlayerSettingsModal = !this.showPlayerSettingsModal
},
init() { init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1 this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 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

@ -1,9 +1,9 @@
<template> <template>
<div> <div>
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center"> <div v-if="processing" role="img" :aria-label="$strings.MessageLoading" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
<widgets-loading-spinner /> <widgets-loading-spinner />
</div> </div>
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" /> <img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" :aria-label="$getString('LabelPersonalYearReview', [variant + 1])" />
</div> </div>
</template> </template>
@ -261,7 +261,7 @@ 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 {

View file

@ -7,7 +7,7 @@
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<p class="hidden md:block text-xl font-semibold">{{ $getString('HeaderYearReview', [yearInReviewYear]) }}</p> <h1 class="hidden md:block text-xl font-semibold">{{ $getString('HeaderYearReview', [yearInReviewYear]) }}</h1>
<div class="hidden md:block flex-grow" /> <div class="hidden md:block flex-grow" />
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? $strings.LabelYearReviewHide : $strings.LabelYearReviewShow }}</ui-btn> <ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? $strings.LabelYearReviewHide : $strings.LabelYearReviewShow }}</ui-btn>
</div> </div>
@ -16,17 +16,22 @@
<div v-if="showYearInReview"> <div v-if="showYearInReview">
<div class="w-full h-px bg-slate-200/10 my-4" /> <div class="w-full h-px bg-slate-200/10 my-4" />
<div class="flex items-center justify-center mb-2 max-w-[800px] mx-auto"> <div v-if="availableYears.length > 1" class="mb-2 py-2 max-w-[800px] mx-auto">
<!-- year selector -->
<ui-dropdown v-model="yearInReviewYear" small :items="availableYears" :disabled="processingYearInReview" class="max-w-24" @input="yearInReviewYearChanged" />
</div>
<div role="toolbar" class="flex items-center justify-center mb-2 max-w-[800px] mx-auto">
<!-- previous button --> <!-- previous button -->
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--"> <ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" :aria-label="$strings.ButtonPrevious" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
<span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span> <span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
<span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span> <span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
</ui-btn> </ui-btn>
<!-- share button --> <!-- share button -->
<ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview">{{ $strings.ButtonShare }} </ui-btn> <ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview">{{ $strings.ButtonShare }} </ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
<p class="hidden sm:block text-lg font-semibold">{{ $getString('LabelPersonalYearReview', [yearInReviewVariant + 1]) }}</p> <h2 class="hidden sm:block text-lg font-semibold">{{ $getString('LabelPersonalYearReview', [yearInReviewVariant + 1]) }}</h2>
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p> <p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
@ -36,7 +41,7 @@
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span> <span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
</ui-btn> </ui-btn>
<!-- 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" :aria-label="$strings.ButtonNext" 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 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>
@ -46,23 +51,23 @@
<!-- your year in review short --> <!-- your year in review short -->
<div class="w-full max-w-[800px] mx-auto my-4"> <div class="w-full max-w-[800px] mx-auto my-4">
<!-- share button --> <!-- share button -->
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewShort" class="inline-flex sm:hidden items-center font-semibold mb-1" @click="shareYearInReviewShort">{{ $strings.ButtonShare }}</ui-btn> <ui-btn v-if="showShareButton" small :disabled="processingYearInReviewShort" class="inline-flex items-center font-semibold mb-1" @click="shareYearInReviewShort">{{ $strings.ButtonShare }}</ui-btn>
<stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" /> <stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" />
</div> </div>
<!-- your server in review --> <!-- your server in review -->
<div v-if="isAdminOrUp" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10"> <div v-if="isAdminOrUp" role="toolbar" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10">
<div class="flex items-center justify-center mb-2"> <div class="flex items-center justify-center mb-2">
<!-- previous button --> <!-- previous button -->
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--"> <ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" :aria-label="$strings.ButtonPrevious" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
<span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span> <span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
<span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span> <span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
</ui-btn> </ui-btn>
<!-- share button --> <!-- share button -->
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer">{{ $strings.ButtonShare }} </ui-btn> <ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer">{{ $strings.ButtonShare }} </ui-btn>
<div class="flex-grow" /> <div class="flex-grow" />
<p class="hidden sm:block text-lg font-semibold">{{ $getString('LabelServerYearReview', [yearInReviewServerVariant + 1]) }}</p> <h2 class="hidden sm:block text-lg font-semibold">{{ $getString('LabelServerYearReview', [yearInReviewServerVariant + 1]) }}</h2>
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p> <p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
@ -72,7 +77,7 @@
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span> <span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
</ui-btn> </ui-btn>
<!-- 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" :aria-label="$strings.ButtonNext" 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 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>
@ -88,6 +93,7 @@ export default {
data() { data() {
return { return {
showYearInReview: false, showYearInReview: false,
availableYears: [],
yearInReviewYear: 0, yearInReviewYear: 0,
yearInReviewVariant: 0, yearInReviewVariant: 0,
yearInReviewServerVariant: 0, yearInReviewServerVariant: 0,
@ -100,6 +106,9 @@ export default {
computed: { computed: {
isAdminOrUp() { isAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']
},
user() {
return this.$store.state.user.user
} }
}, },
methods: { methods: {
@ -112,25 +121,57 @@ export default {
shareYearInReviewShort() { shareYearInReviewShort() {
this.$refs.yearInReviewShort.share() this.$refs.yearInReviewShort.share()
}, },
yearInReviewYearChanged() {
this.$nextTick(() => {
this.refreshYearInReview()
this.refreshYearInReviewServer()
})
},
refreshYearInReviewServer() { refreshYearInReviewServer() {
if (this.$refs.yearInReviewServer != null) {
this.$refs.yearInReviewServer.refresh() this.$refs.yearInReviewServer.refresh()
}
}, },
refreshYearInReview() { refreshYearInReview() {
if (this.$refs.yearInReview != null && this.$refs.yearInReviewShort != null) {
this.$refs.yearInReview.refresh() this.$refs.yearInReview.refresh()
this.$refs.yearInReviewShort.refresh() this.$refs.yearInReviewShort.refresh()
}
}, },
clickShowYearInReview() { clickShowYearInReview() {
this.showYearInReview = !this.showYearInReview this.showYearInReview = !this.showYearInReview
},
getAvailableYears() {
if (this.user) {
const oldestDate = this.user.createdAt
if (oldestDate) {
const date = new Date(oldestDate)
const oldestYear = date.getFullYear()
const currentYear = new Date().getFullYear()
const years = []
for (let year = currentYear; year >= oldestYear; year--) {
years.push({ value: year, text: year.toString() })
}
return years
}
}
// Fallback on error
return [{ value: this.yearInReviewYear, text: this.yearInReviewYear.toString() }]
} }
}, },
beforeMount() { beforeMount() {
this.yearInReviewYear = new Date().getFullYear() this.yearInReviewYear = new Date().getFullYear()
// When not December show previous year // When not December show previous year
if (new Date().getMonth() < 11) { if (new Date().getMonth() < 11) {
this.yearInReviewYear-- this.yearInReviewYear--
} }
}, },
mounted() { mounted() {
this.availableYears = this.getAvailableYears()
if (typeof navigator.share !== 'undefined' && navigator.share) { if (typeof navigator.share !== 'undefined' && navigator.share) {
this.showShareButton = true this.showShareButton = true
} else { } else {

View file

@ -1,9 +1,9 @@
<template> <template>
<div> <div>
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center"> <div v-if="processing" role="img" :aria-label="$strings.MessageLoading" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
<widgets-loading-spinner /> <widgets-loading-spinner />
</div> </div>
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" /> <img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" :aria-label="$getString('LabelServerYearReview', [variant + 1])" />
</div> </div>
</template> </template>
@ -237,7 +237,7 @@ 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 {

View file

@ -167,7 +167,7 @@ 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 {

View file

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

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

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

@ -120,6 +120,7 @@ export default {
this.users = res.users.sort((a, b) => { this.users = res.users.sort((a, b) => {
return a.createdAt - b.createdAt return a.createdAt - b.createdAt
}) })
this.$emit('numUsers', this.users.length)
}) })
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)

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.episodeTitle}" 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 [
@ -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
} }
@ -372,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
}) })
@ -514,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() {
@ -536,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

@ -1,7 +1,7 @@
<template> <template>
<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-label="$strings.LabelMore" aria-haspopup="menu" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-symbols text-2xl" :class="iconClass">&#xe5d4;</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">
@ -10,12 +10,12 @@
</slot> </slot>
<transition name="menu"> <transition name="menu">
<div v-show="showMenu" ref="menuWrapper" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }"> <div v-show="showMenu" ref="menuWrapper" role="menu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }">
<template v-for="(item, index) in items"> <template v-for="(item, index) in items">
<template v-if="item.subitems"> <template v-if="item.subitems">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop> <button :key="index" role="menuitem" aria-haspopup="menu" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default w-full" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
<p>{{ item.text }}</p> <p>{{ item.text }}</p>
</div> </button>
<div <div
v-if="mouseoverItemIndex === index" v-if="mouseoverItemIndex === index"
:key="`subitems-${index}`" :key="`subitems-${index}`"
@ -25,14 +25,14 @@
:class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'" :class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'"
:style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }" :style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }"
> >
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.action, subitem.data)"> <button v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(subitem.action, subitem.data)">
<p>{{ subitem.text }}</p> <p>{{ subitem.text }}</p>
</div> </button>
</div> </div>
</template> </template>
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)"> <button v-else :key="index" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(item.action)">
<p class="text-left">{{ item.text }}</p> <p class="text-left">{{ item.text }}</p>
</div> </button>
</template> </template>
</div> </div>
</transition> </transition>

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="relative w-full" v-click-outside="clickOutsideObj"> <div class="relative w-full" v-click-outside="clickOutsideObj">
<p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p> <p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<button type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu"> <button type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="menu" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="flex items-center"> <span class="flex items-center">
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span> <span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
<span v-if="selectedSubtext">:&nbsp;</span> <span v-if="selectedSubtext">:&nbsp;</span>
@ -13,9 +13,9 @@
</button> </button>
<transition name="menu"> <transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="listbox" :style="{ maxHeight: menuMaxHeight }"> <ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="menu" :style="{ maxHeight: menuMaxHeight }">
<template v-for="item in itemsToShow"> <template v-for="item in itemsToShow">
<li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" :id="'listbox-option-' + item.value" role="option" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)"> <li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" role="menuitem" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="ml-3 block truncate font-sans text-sm" :class="{ 'font-semibold': item.subtext }">{{ item.text }}</span> <span class="ml-3 block truncate font-sans text-sm" :class="{ 'font-semibold': item.subtext }">{{ item.text }}</span>
<span v-if="item.subtext">:&nbsp;</span> <span v-if="item.subtext">:&nbsp;</span>

View file

@ -1,5 +1,5 @@
<template> <template>
<button class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn"> <button :aria-label="ariaLabel" class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100"> <div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24"> <svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<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" />
@ -28,7 +28,8 @@ export default {
size: { size: {
type: Number, type: Number,
default: 9 default: 9
} },
ariaLabel: String
}, },
data() { data() {
return {} return {}

View file

@ -4,7 +4,7 @@
type="button" type="button"
:disabled="disabled" :disabled="disabled"
class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200"
aria-haspopup="listbox" aria-haspopup="menu"
:aria-expanded="showMenu" :aria-expanded="showMenu"
:aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name" :aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name"
@click.stop.prevent="clickShowMenu" @click.stop.prevent="clickShowMenu"
@ -16,9 +16,9 @@
</button> </button>
<transition name="menu"> <transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="listbox"> <ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="menu">
<template v-for="library in librariesFiltered"> <template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="option" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)"> <li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="menuitem" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
<div class="flex items-center px-2"> <div class="flex items-center px-2">
<ui-library-icon :icon="library.icon" class="mr-1.5" /> <ui-library-icon :icon="library.icon" class="mr-1.5" />
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span> <span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>

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

@ -1,17 +1,17 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p> <label :for="identifier" class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</label>
<div ref="wrapper" class="relative"> <div ref="wrapper" class="relative">
<form @submit.prevent="submitForm"> <form @submit.prevent="submitForm">
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent> <div ref="inputWrapper" role="list" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative"> <div v-for="item in selected" :key="item" role="listitem" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative">
<div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 px-1 bg-bg bg-opacity-75 flex items-center justify-end opacity-0 hover:opacity-100"> <div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 px-1 bg-bg bg-opacity-75 flex items-center justify-end opacity-0 hover:opacity-100" :class="{ 'opacity-100': inputFocused }">
<span v-if="showEdit" class="material-symbols text-white hover:text-warning cursor-pointer" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span> <button v-if="showEdit" type="button" :aria-label="$strings.ButtonEdit" class="material-symbols text-white hover:text-warning cursor-pointer" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</button>
<span class="material-symbols text-white hover:text-error cursor-pointer" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span> <button type="button" :aria-label="$strings.ButtonRemove" class="material-symbols text-white hover:text-error focus:text-error cursor-pointer" style="font-size: 1.1rem" @click.stop="removeItem(item)" @keydown.enter.stop.prevent="removeItem(item)" @focus="setInputFocused(true)" @blur="setInputFocused(false)" tabindex="0">close</button>
</div> </div>
{{ item }} {{ item }}
</div> </div>
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" class="h-full bg-primary focus:outline-none px-1 w-6" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" /> <input v-show="!readonly" v-model="textInput" ref="input" :id="identifier" :disabled="disabled" class="h-full bg-primary focus:outline-none px-1 w-6" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
</div> </div>
</form> </form>
@ -66,7 +66,8 @@ export default {
typingTimeout: null, typingTimeout: null,
isFocused: false, isFocused: false,
menu: null, menu: null,
filteredItems: null filteredItems: null,
inputFocused: false
} }
}, },
watch: { watch: {
@ -100,6 +101,9 @@ export default {
} }
return this.filteredItems return this.filteredItems
},
identifier() {
return Math.random().toString(36).substring(2)
} }
}, },
methods: { methods: {
@ -129,6 +133,9 @@ export default {
}, 100) }, 100)
this.setInputWidth() this.setInputWidth()
}, },
setInputFocused(focused) {
this.inputFocused = focused
},
setInputWidth() { setInputWidth() {
setTimeout(() => { setTimeout(() => {
var value = this.$refs.input.value var value = this.$refs.input.value

View file

@ -1,20 +1,20 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p> <label :for="identifier" class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</label>
<div ref="wrapper" class="relative"> <div ref="wrapper" class="relative">
<form @submit.prevent="submitForm"> <form @submit.prevent="submitForm">
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-0.5" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent> <div ref="inputWrapper" role="list" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-0.5" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selected" :key="item.id" class="rounded-full px-2 py-0.5 m-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center justify-center relative min-w-12"> <div v-for="item in selected" :key="item.id" role="listitem" class="rounded-full px-2 py-0.5 m-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center justify-center relative min-w-12">
<div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer"> <div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer" :class="{ 'opacity-100': inputFocused }">
<span v-if="showEdit" class="material-symbols text-base text-white hover:text-warning mr-1" @click.stop="editItem(item)">edit</span> <button v-if="showEdit" type="button" :aria-label="$strings.ButtonEdit" class="material-symbols text-base text-white hover:text-warning focus:text-warning mr-1" @click.stop="editItem(item)" @keydown.enter.stop.prevent="editItem(item)" @focus="setInputFocused(true)" @blur="setInputFocused(false)" tabindex="0">edit</button>
<span class="material-symbols text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.id)">close</span> <button type="button" :aria-label="$strings.ButtonRemove" class="material-symbols text-white hover:text-error focus:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.id)" @keydown.enter.stop="removeItem(item.id)" @focus="setInputFocused(true)" @blur="setInputFocused(false)" tabindex="0">close</button>
</div> </div>
{{ item[textKey] }} {{ item[textKey] }}
</div> </div>
<div v-if="showEdit && !disabled" class="rounded-full cursor-pointer w-6 h-6 mx-0.5 bg-bg flex items-center justify-center"> <div v-if="showEdit && !disabled" class="rounded-full cursor-pointer w-6 h-6 mx-0.5 bg-bg flex items-center justify-center">
<span class="material-symbols text-white hover:text-success pt-px pr-px" style="font-size: 1.1rem" @click.stop="addItem">add</span> <button type="button" :aria-label="$strings.ButtonAdd" class="material-symbols text-white hover:text-success focus:text-success pt-px pr-px" style="font-size: 1.1rem" @click.stop="addItem" @keydown.enter.stop="addItem" tabindex="0">add</button>
</div> </div>
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" class="h-full bg-primary focus:outline-none px-1 w-6" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" /> <input v-show="!readonly" v-model="textInput" ref="input" :id="identifier" :disabled="disabled" class="h-full bg-primary focus:outline-none px-1 w-6" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
</div> </div>
</form> </form>
@ -65,6 +65,7 @@ export default {
currentSearch: null, currentSearch: null,
typingTimeout: null, typingTimeout: null,
isFocused: false, isFocused: false,
inputFocused: false,
menu: null, menu: null,
items: [] items: []
} }
@ -102,6 +103,9 @@ export default {
}, },
filterData() { filterData() {
return this.$store.state.libraries.filterData || {} return this.$store.state.libraries.filterData || {}
},
identifier() {
return Math.random().toString(36).substring(2)
} }
}, },
methods: { methods: {
@ -114,6 +118,9 @@ export default {
getIsSelected(itemValue) { getIsSelected(itemValue) {
return !!this.selected.find((i) => i.id === itemValue) return !!this.selected.find((i) => i.id === itemValue)
}, },
setInputFocused(focused) {
this.inputFocused = focused
},
search() { search() {
if (!this.textInput) return if (!this.textInput) return
this.currentSearch = this.textInput this.currentSearch = this.textInput

View file

@ -1,5 +1,5 @@
<template> <template>
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn"> <button :aria-label="isRead ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
<div class="w-5 h-5 text-white relative"> <div class="w-5 h-5 text-white relative">
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)"> <svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
<path d="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" /> <path d="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" />
@ -24,12 +24,12 @@ export default {
computed: {}, computed: {},
methods: { methods: {
clickBtn(e) { clickBtn(e) {
e.stopPropagation()
if (this.disabled) { if (this.disabled) {
e.preventDefault() e.preventDefault()
return return
} }
this.$emit('click') this.$emit('click')
e.stopPropagation()
} }
}, },
mounted() {} mounted() {}

View file

@ -57,7 +57,8 @@ export default {
inputName: String, inputName: String,
showCopy: Boolean, showCopy: Boolean,
step: [String, Number], step: [String, Number],
min: [String, Number] min: [String, Number],
customInputClass: String
}, },
data() { data() {
return { return {
@ -82,6 +83,7 @@ export default {
_list.push(`py-${this.paddingY}`) _list.push(`py-${this.paddingY}`)
if (this.noSpinner) _list.push('no-spinner') if (this.noSpinner) _list.push('no-spinner')
if (this.textCenter) _list.push('text-center') if (this.textCenter) _list.push('text-center')
if (this.customInputClass) _list.push(this.customInputClass)
return _list.join(' ') return _list.join(' ')
}, },
actualType() { actualType() {

View file

@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<button :aria-labelledby="labeledBy" role="checkbox" type="button" class="border rounded-full border-black-100 flex items-center cursor-pointer w-10 justify-start" :aria-checked="toggleValue" :class="className" @click="clickToggle"> <button :aria-labelledby="labeledBy" :aria-label="label" role="checkbox" type="button" class="border rounded-full border-black-100 flex items-center cursor-pointer justify-start" :style="{ width: buttonWidth + 'px' }" :aria-checked="toggleValue" :class="className" @click="clickToggle">
<span class="rounded-full border w-5 h-5 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span> <span class="rounded-full border border-black-50 shadow transform transition-transform duration-100" :style="{ width: cursorHeightWidth + 'px', height: cursorHeightWidth + 'px' }" :class="switchClassName"></span>
</button> </button>
</div> </div>
</template> </template>
@ -19,7 +19,12 @@ export default {
default: 'primary' default: 'primary'
}, },
disabled: Boolean, disabled: Boolean,
labeledBy: String labeledBy: String,
label: String,
size: {
type: String,
default: 'md'
}
}, },
computed: { computed: {
toggleValue: { toggleValue: {
@ -37,6 +42,13 @@ export default {
switchClassName() { switchClassName() {
var bgColor = this.disabled ? 'bg-gray-300' : 'bg-white' var bgColor = this.disabled ? 'bg-gray-300' : 'bg-white'
return this.toggleValue ? 'translate-x-5 ' + bgColor : bgColor return this.toggleValue ? 'translate-x-5 ' + bgColor : bgColor
},
cursorHeightWidth() {
if (this.size === 'sm') return 16
return 20
},
buttonWidth() {
return this.cursorHeightWidth * 2
} }
}, },
methods: { methods: {

View file

@ -1,14 +1,11 @@
<template> <template>
<ui-tooltip v-if="alreadyInLibrary" :text="$strings.LabelAlreadyInYourLibrary" direction="top"> <ui-tooltip :text="$strings.LabelAlreadyInYourLibrary" direction="top" class="inline-flex">
<span class="material-symbols ml-1 text-success" style="font-size: 0.8rem">check_circle</span> <span class="material-symbols ml-1 text-sm text-success">check_circle</span>
</ui-tooltip> </ui-tooltip>
</template> </template>
<script> <script>
export default { export default {
props: {
alreadyInLibrary: Boolean
},
data() { data() {
return {} return {}
}, },

View file

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

View file

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

View file

@ -3,10 +3,10 @@
<div class="flex items-center py-3e"> <div class="flex items-center py-3e">
<slot /> <slot />
<div class="flex-grow" /> <div class="flex-grow" />
<button cy-id="leftScrollButton" v-if="isScrollable" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft"> <button cy-id="leftScrollButton" v-if="isScrollable" :aria-label="$strings.ButtonScrollLeft" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_left</span> <span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_left</span>
</button> </button>
<button cy-id="rightScrollButton" v-if="isScrollable" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight"> <button cy-id="rightScrollButton" v-if="isScrollable" :aria-label="$strings.ButtonScrollRight" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_right</span> <span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_right</span>
</button> </button>
</div> </div>
@ -65,7 +65,7 @@ export default {
}, },
authors: { authors: {
component: 'cards-author-card', component: 'cards-author-card',
itemPropName: 'author', itemPropName: 'author-mount',
itemIdFunc: (item) => item.id itemIdFunc: (item) => item.id
}, },
narrators: { narrators: {

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