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:
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:
# 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:
- cron: '16 5 * * 4'
@ -21,45 +35,44 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
language: ['javascript']
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Use only 'java' to analyze code written in Java, Kotlin or both
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# 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
# 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
# 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)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# 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)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: '/language:${{matrix.language}}'

View file

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

View file

@ -5,6 +5,13 @@ on:
push:
branches-ignore:
- '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:
build:

2
.gitignore vendored
View file

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

View file

@ -11,20 +11,35 @@ FROM node:20-alpine
ENV NODE_ENV=production
RUN apk update && \
apk add --no-cache --update \
curl \
tzdata \
ffmpeg \
make \
gcompat \
python3 \
g++ \
tini
apk add --no-cache --update \
curl \
tzdata \
ffmpeg \
make \
python3 \
g++ \
tini \
unzip
COPY --from=build /client/dist /client/dist
COPY index.js package* /
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 apk del make python3 g++

View file

@ -1,6 +1,6 @@
<template>
<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">
<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" />
@ -264,7 +264,6 @@ export default {
libraryItems.forEach((item) => {
let subtitle = ''
if (item.mediaType === 'book') subtitle = item.media.metadata.authors.map((au) => au.name).join(', ')
else if (item.mediaType === 'music') subtitle = item.media.metadata.artists.join(', ')
queueItems.push({
libraryItemId: item.id,
libraryId: item.libraryId,
@ -332,13 +331,13 @@ export default {
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
})
.then(() => {
this.$toast.success('Batch delete success')
this.$toast.success(this.$strings.ToastBatchDeleteSuccess)
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
})
.catch((error) => {
console.error('Batch delete failed', error)
this.$toast.error('Batch delete failed')
this.$toast.error(this.$strings.ToastBatchDeleteFailed)
})
.finally(() => {
this.$store.commit('setProcessingBatch', false)

View file

@ -17,7 +17,7 @@
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e">
<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)">
<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>
</template>
</div>
@ -347,6 +347,13 @@ export default {
libraryItemsAdded(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')
if (!recentlyAddedShelf) return

View file

@ -24,7 +24,7 @@
</div>
<div v-if="shelf.type === 'authors'" class="flex items-center">
<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>
</div>
<div v-if="shelf.type === 'narrators'" class="flex items-center">
@ -37,18 +37,18 @@
<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="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 class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></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>
</div>
<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>
<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>
</div>
</button>
</div>
</template>

View file

@ -30,7 +30,7 @@
<p v-if="isCollectionsPage" class="text-sm">{{ $strings.ButtonCollections }}</p>
<span v-else class="material-symbols text-lg">&#xe431;</span>
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="flex-grow h-full flex justify-center items-center" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<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>
<svg v-else class="w-5 h-5" viewBox="0 0 24 24">
<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'">
<p class="text-sm">{{ $strings.ButtonAdd }}</p>
</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 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 -->
<template v-if="selectedSeries">
<p class="pl-2 text-base md:text-lg">
{{ seriesName }}
</p>
<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 class="flex-grow" />
@ -62,8 +65,8 @@
<ui-context-menu-dropdown v-if="!isBatchSelecting && seriesContextMenuItems.length" :items="seriesContextMenuItems" class="mx-px" @action="seriesContextMenuAction" />
</template>
<!-- library & collections page -->
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome">
<p class="hidden md:block">{{ numShowing }} {{ entityName }}</p>
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome && !isAuthorsPage">
<p class="hidden md:block">{{ $formatNumber(numShowing) }} {{ entityName }}</p>
<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" />
<!-- 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" />
</template>
@ -92,12 +95,14 @@
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
</template>
<!-- authors page -->
<template v-else-if="page === 'authors'">
<div class="flex-grow" />
<ui-btn v-if="userCanUpdate && authors?.length && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
<template v-else-if="isAuthorsPage">
<p class="hidden md:block">{{ $formatNumber(numShowing) }} {{ entityName }}</p>
<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 -->
<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>
<!-- home page -->
<template v-else-if="isHome">
@ -117,11 +122,7 @@ export default {
type: Object,
default: () => null
},
searchQuery: String,
authors: {
type: Array,
default: () => []
}
searchQuery: String
},
data() {
return {
@ -246,9 +247,6 @@ export default {
isPodcastLibrary() {
return this.currentLibraryMediaType === 'podcast'
},
isMusicLibrary() {
return this.currentLibraryMediaType === 'music'
},
isLibraryPage() {
return this.page === ''
},
@ -270,8 +268,11 @@ export default {
isPodcastLatestPage() {
return this.$route.name === 'library-library-podcast-latest'
},
isPodcastDownloadQueuePage() {
return this.$route.name === 'library-library-podcast-download-queue'
},
isAuthorsPage() {
return this.$route.name === 'library-library-authors'
return this.page === 'authors'
},
isAlbumsPage() {
return this.page === 'albums'
@ -281,13 +282,13 @@ export default {
},
entityName() {
if (this.isAlbumsPage) return 'Albums'
if (this.isMusicLibrary) return 'Tracks'
if (this.isPodcastLibrary) return this.$strings.LabelPodcasts
if (!this.page) return this.$strings.LabelBooks
if (this.isSeriesPage) return this.$strings.LabelSeries
if (this.isCollectionsPage) return this.$strings.LabelCollections
if (this.isPlaylistsPage) return this.$strings.LabelPlaylists
if (this.isAuthorsPage) return this.$strings.LabelAuthors
return ''
},
seriesId() {
@ -473,46 +474,58 @@ export default {
this.$axios
.$get(`/api/me/series/${this.seriesId}/readd-to-continue-listening`)
.then(() => {
this.$toast.success('Series re-added to continue listening')
this.$toast.success(this.$strings.ToastItemUpdateSuccess)
})
.catch((error) => {
console.error('Failed to re-add series to continue listening', error)
this.$toast.error('Failed to re-add series to continue listening')
this.$toast.error(this.$strings.ToastFailedToUpdate)
})
.finally(() => {
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() {
this.processingAuthors = true
for (const author of this.authors) {
const payload = {}
if (author.asin) payload.asin = author.asin
else payload.q = author.name
try {
const authors = await this.fetchAllAuthors()
payload.region = 'us'
if (this.libraryProvider.startsWith('audible.')) {
payload.region = this.libraryProvider.split('.').pop() || 'us'
for (const author of authors) {
const payload = {}
if (author.asin) payload.asin = author.asin
else payload.q = author.name
payload.region = 'us'
if (this.libraryProvider.startsWith('audible.')) {
payload.region = this.libraryProvider.split('.').pop() || 'us'
}
this.$eventBus.$emit(`searching-author-${author.id}`, true)
var response = await this.$axios.$post(`/api/authors/${author.id}/match`, payload).catch((error) => {
console.error('Failed', error)
return null
})
if (!response) {
console.error(`Author ${author.name} not found`)
this.$toast.error(this.$getString('ToastAuthorNotFound', [author.name]))
} else if (response.updated) {
if (response.author.imagePath) console.log(`Author ${response.author.name} was updated`)
else console.log(`Author ${response.author.name} was updated (no image found)`)
} else {
console.log(`No updates were made for Author ${response.author.name}`)
}
this.$eventBus.$emit(`searching-author-${author.id}`, false)
}
this.$eventBus.$emit(`searching-author-${author.id}`, true)
var response = await this.$axios.$post(`/api/authors/${author.id}/match`, payload).catch((error) => {
console.error('Failed', error)
return null
})
if (!response) {
console.error(`Author ${author.name} not found`)
this.$toast.error(`Author ${author.name} not found`)
} else if (response.updated) {
if (response.author.imagePath) console.log(`Author ${response.author.name} was updated`)
else console.log(`Author ${response.author.name} was updated (no image found)`)
} else {
console.log(`No updates were made for Author ${response.author.name}`)
}
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
},
@ -522,13 +535,13 @@ export default {
this.$axios
.$delete(`/api/libraries/${this.currentLibraryId}/issues`)
.then(() => {
this.$toast.success('Removed library items with issues')
this.$toast.success(this.$strings.ToastRemoveItemsWithIssuesSuccess)
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
})
.catch((error) => {
console.error('Failed to remove library items with issues', error)
this.$toast.error('Failed to remove library items with issues')
this.$toast.error(this.$strings.ToastRemoveItemsWithIssuesFailed)
})
.finally(() => {
this.processingIssues = false

View file

@ -1,6 +1,6 @@
<template>
<div>
<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="toolbar" aria-orientation="vertical" aria-label="Config Sidebar">
<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">
<span class="material-symbols text-2xl">arrow_back</span>
</div>
@ -19,7 +19,7 @@
<p class="text-xs text-gray-300 italic">{{ Source }}</p>
</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>
</template>

View file

@ -2,6 +2,10 @@
<div id="bookshelf" ref="bookshelf" class="w-full overflow-y-auto" :style="{ fontSize: sizeMultiplier + 'rem' }">
<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' }">
<!-- 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>
</template>
@ -65,7 +69,13 @@ export default {
tempIsScanning: false,
cardWidth: 0,
cardHeight: 0,
resizeObserver: null
coverHeight: 0,
resizeObserver: null,
lastScrollTop: 0,
lastTimestamp: 0,
postScrollTimeout: null,
currFirstEntityIndex: -1,
currLastEntityIndex: -1
}
},
watch: {
@ -91,6 +101,7 @@ export default {
if (this.page === 'series') return this.$strings.MessageBookshelfNoSeries
if (this.page === 'collections') return this.$strings.MessageBookshelfNoCollections
if (this.page === 'playlists') return this.$strings.MessageNoUserPlaylists
if (this.page === 'authors') return this.$strings.MessageNoAuthors
if (this.hasFilter) {
if (this.filterName === 'Issues') return this.$strings.MessageNoIssues
else if (this.filterName === 'Feed-open') return this.$strings.MessageBookshelfNoRSSFeeds
@ -111,6 +122,12 @@ export default {
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() {
return this.$store.getters['user/getUserSetting']('orderBy')
},
@ -164,9 +181,6 @@ export default {
bookWidth() {
return this.cardWidth
},
bookHeight() {
return this.cardHeight
},
shelfPadding() {
if (this.bookshelfWidth < 640) return 32 * this.sizeMultiplier
return 64 * this.sizeMultiplier
@ -177,9 +191,6 @@ export default {
entityWidth() {
return this.cardWidth
},
entityHeight() {
return this.cardHeight
},
shelfPaddingHeight() {
return 16
},
@ -217,6 +228,8 @@ export default {
this.$store.commit('globals/setEditCollection', entity)
} else if (this.entityName === 'playlists') {
this.$store.commit('globals/setEditPlaylist', entity)
} else if (this.entityName === 'authors') {
this.$store.commit('globals/showEditAuthorModal', entity)
}
},
clearSelectedEntities() {
@ -345,50 +358,53 @@ export default {
}
},
loadPage(page) {
this.pagesLoaded[page] = true
this.fetchEntites(page)
if (!this.pagesLoaded[page]) this.pagesLoaded[page] = this.fetchEntites(page)
return this.pagesLoaded[page]
},
showHideBookPlaceholder(index, show) {
var el = document.getElementById(`book-${index}-placeholder`)
if (el) el.style.display = show ? 'flex' : 'none'
},
mountEntites(fromIndex, toIndex) {
mountEntities(fromIndex, toIndex) {
for (let i = fromIndex; i < toIndex; i++) {
if (!this.entityIndexesMounted.includes(i)) {
this.cardsHelpers.mountEntityCard(i)
}
}
},
handleScroll(scrollTop) {
this.currScrollTop = scrollTop
var firstShelfIndex = Math.floor(scrollTop / this.shelfHeight)
var lastShelfIndex = Math.ceil((scrollTop + this.bookshelfHeight) / this.shelfHeight)
lastShelfIndex = Math.min(this.totalShelves - 1, lastShelfIndex)
var firstBookIndex = firstShelfIndex * this.entitiesPerShelf
var lastBookIndex = lastShelfIndex * this.entitiesPerShelf + this.entitiesPerShelf
lastBookIndex = Math.min(this.totalEntities, lastBookIndex)
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)
}
getVisibleIndices(scrollTop) {
const firstShelfIndex = Math.floor(scrollTop / this.shelfHeight)
const lastShelfIndex = Math.min(Math.ceil((scrollTop + this.bookshelfHeight) / this.shelfHeight), this.totalShelves - 1)
const firstEntityIndex = firstShelfIndex * this.entitiesPerShelf
const lastEntityIndex = Math.min(lastShelfIndex * this.entitiesPerShelf + this.entitiesPerShelf, this.totalEntities)
return { firstEntityIndex, lastEntityIndex }
},
postScroll() {
const { firstEntityIndex, lastEntityIndex } = this.getVisibleIndices(this.currScrollTop)
this.entityIndexesMounted = this.entityIndexesMounted.filter((_index) => {
if (_index < firstBookIndex || _index >= lastBookIndex) {
var el = document.getElementById(`book-card-${_index}`)
if (el) el.remove()
if (_index < firstEntityIndex || _index >= lastEntityIndex) {
var el = this.entityComponentRefs[_index]
if (el && el.$el) el.$el.remove()
return false
}
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() {
if (this.isFetchingEntities) {
@ -396,8 +412,6 @@ export default {
return
}
this.destroyEntityComponents()
this.entityIndexesMounted = []
this.entityComponentRefs = {}
this.pagesLoaded = {}
this.entities = []
this.totalShelves = 0
@ -407,40 +421,21 @@ export default {
this.initialized = false
this.initSizeData()
this.pagesLoaded[0] = true
await this.fetchEntites(0)
await this.loadPage(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntites(0, lastBookIndex)
this.mountEntities(0, lastBookIndex)
},
remountEntities() {
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() {
async rebuild() {
this.initSizeData()
var lastBookIndex = Math.min(this.totalEntities, this.booksPerFetch)
this.entityIndexesMounted = []
for (let i = 0; i < lastBookIndex; i++) {
this.entityIndexesMounted.push(i)
if (!this.entities[i]) {
const page = Math.floor(i / this.booksPerFetch)
this.loadPage(page)
}
}
this.destroyEntityComponents()
await this.loadPage(0)
var bookshelfEl = document.getElementById('bookshelf')
if (bookshelfEl) {
bookshelfEl.scrollTop = 0
}
this.$nextTick(this.remountEntities)
this.mountEntities(0, lastBookIndex)
},
buildSearchParams() {
if (this.page === 'search' || this.page === 'collections') {
@ -457,6 +452,9 @@ export default {
if (this.collapseBookSeries) {
searchParams.set('collapseseries', 1)
}
} else if (this.page === 'authors') {
searchParams.set('sort', this.authorSortBy)
searchParams.set('desc', this.authorSortDesc ? 1 : 0)
} else {
if (this.filterBy && this.filterBy !== 'all') {
searchParams.set('filter', this.filterBy)
@ -501,12 +499,29 @@ export default {
if (wasUpdated) {
this.resetEntities()
} 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) {
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)
},
libraryItemAdded(libraryItem) {
@ -601,6 +616,34 @@ export default {
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) {
if (this.entityName === 'items' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent?.media?.id === mediaItemShare.mediaItemId)
@ -627,13 +670,14 @@ export default {
},
updatePagesLoaded() {
let numPages = Math.ceil(this.totalEntities / this.booksPerFetch)
this.pagesLoaded = {}
for (let page = 0; page < numPages; page++) {
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++) {
const index = page * this.booksPerFetch + i
if (!this.entities[index]) {
this.pagesLoaded[page] = false
if (this.pagesLoaded[page]) delete this.pagesLoaded[page]
break
}
}
@ -648,7 +692,6 @@ export default {
var entitiesPerShelfBefore = this.entitiesPerShelf
var { clientHeight, clientWidth } = bookshelf
// console.log('Init bookshelf width', clientWidth, 'window width', window.innerWidth)
this.mountWindowWidth = window.innerWidth
this.bookshelfHeight = clientHeight
this.bookshelfWidth = clientWidth
@ -673,10 +716,9 @@ export default {
this.initSizeData(bookshelf)
this.checkUpdateSearchParams()
this.pagesLoaded[0] = true
await this.fetchEntites(0)
await this.loadPage(0)
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
if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {
@ -707,7 +749,7 @@ export default {
var bookshelf = document.getElementById('bookshelf')
if (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_updated', this.playlistUpdated)
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_closed', this.shareClosed)
} else {
@ -756,6 +801,9 @@ export default {
this.$root.socket.off('playlist_added', this.playlistAdded)
this.$root.socket.off('playlist_updated', this.playlistUpdated)
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_closed', this.shareClosed)
} else {
@ -764,10 +812,14 @@ export default {
},
destroyEntityComponents() {
for (const key in this.entityComponentRefs) {
if (this.entityComponentRefs[key] && this.entityComponentRefs[key].destroy) {
this.entityComponentRefs[key].destroy()
const ref = this.entityComponentRefs[key]
if (ref && ref.destroy) {
if (ref.$el) ref.$el.remove()
ref.destroy()
}
}
this.entityComponentRefs = {}
this.entityIndexesMounted = []
},
scan() {
this.tempIsScanning = true
@ -780,6 +832,14 @@ export default {
.finally(() => {
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() {

View file

@ -1,10 +1,9 @@
<template>
<div v-if="streamLibraryItem" id="mediaPlayerContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 lg:h-40 z-50 bg-primary px-2 lg:px-4 pb-1 lg:pb-4 pt-2">
<div id="videoDock" />
<div class="absolute left-2 top-2 lg:left-4 cursor-pointer">
<covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
</div>
<div class="flex items-start mb-6 lg:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
<div class="flex items-start mb-6 lg:mb-0" :class="isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
<div class="min-w-0 w-full">
<div class="flex items-center">
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
@ -12,10 +11,9 @@
</nuxt-link>
<widgets-explicit-indicator v-if="isExplicit" />
</div>
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center w-1/2 sm:w-4/5 lg:w-2/5">
<div class="text-gray-400 flex items-center w-1/2 sm:w-4/5 lg:w-2/5">
<span class="material-symbols text-sm">person</span>
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
<div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base truncate">
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</div>
@ -55,7 +53,6 @@
@showBookmarks="showBookmarks"
@showSleepTimer="showSleepTimerModal = true"
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
@showPlayerSettings="showPlayerSettingsModal = true"
/>
<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-player-queue-items-modal v-model="showPlayerQueueItemsModal" />
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
</div>
</template>
@ -83,7 +78,6 @@ export default {
currentTime: 0,
showSleepTimerModal: false,
showPlayerQueueItemsModal: false,
showPlayerSettingsModal: false,
sleepTimerSet: false,
sleepTimerRemaining: 0,
sleepTimerType: null,
@ -140,9 +134,6 @@ export default {
isPodcast() {
return this.streamLibraryItem?.mediaType === 'podcast'
},
isMusic() {
return this.streamLibraryItem?.mediaType === 'music'
},
isExplicit() {
return !!this.mediaMetadata.explicit
},
@ -172,11 +163,7 @@ export default {
},
podcastAuthor() {
if (!this.isPodcast) return null
return this.mediaMetadata.author || 'Unknown'
},
musicArtists() {
if (!this.isMusic) return null
return this.mediaMetadata.artists.join(', ')
return this.mediaMetadata.author || this.$strings.LabelUnknown
},
hasNextItemInQueue() {
return this.currentPlayerQueueIndex < this.playerQueueItems.length - 1
@ -260,7 +247,7 @@ export default {
sleepTimerEnd() {
this.clearSleepTimer()
this.playerHandler.pause()
this.$toast.info('Sleep Timer Done.. zZzzZz')
this.$toast.info(this.$strings.ToastSleepTimerDone)
},
cancelSleepTimer() {
this.showSleepTimerModal = false
@ -534,7 +521,7 @@ export default {
},
showFailedProgressSyncs() {
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) {
if (this.playerHandler.currentSessionId === sessionId) {

View file

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

View file

@ -1,6 +1,6 @@
<template>
<div :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
<nuxt-link :to="`/author/${author.id}`">
<article class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
<nuxt-link :to="`/author/${author?.id}`">
<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">
<!-- Image or placeholder -->
@ -34,13 +34,13 @@
</div>
</div>
</nuxt-link>
</div>
</article>
</template>
<script>
export default {
props: {
author: {
authorMount: {
type: Object,
default: () => {}
},
@ -57,7 +57,8 @@ export default {
data() {
return {
searching: false,
isHovering: false
isHovering: false,
author: null
}
},
computed: {
@ -67,35 +68,41 @@ export default {
cardHeight() {
return this.height * this.sizeMultiplier
},
coverHeight() {
return this.cardHeight
},
userToken() {
return this.$store.getters['user/getToken']
return this.store.getters['user/getToken']
},
_author() {
return this.author || {}
},
authorId() {
return this._author.id
return this._author?.id || ''
},
name() {
return this._author.name || ''
return this._author?.name || ''
},
asin() {
return this._author.asin || ''
return this._author?.asin || ''
},
numBooks() {
return this._author.numBooks || 0
return this._author?.numBooks || 0
},
store() {
return this.$store || this.$nuxt.$store
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
return this.store.getters['user/getUserCanUpdate']
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
return this.store.state.libraries.currentLibraryId
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
return this.store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
},
sizeMultiplier() {
return this.$store.getters['user/getSizeMultiplier']
return this.store.getters['user/getSizeMultiplier']
}
},
methods: {
@ -121,24 +128,54 @@ export default {
return null
})
if (!response) {
this.$toast.error(`Author ${this.name} not found`)
this.$toast.error(this.$getString('ToastAuthorNotFound', [this.name]))
} else if (response.updated) {
if (response.author.imagePath) this.$toast.success(`Author ${response.author.name} was updated`)
else this.$toast.success(`Author ${response.author.name} was updated (no image found)`)
if (response.author.imagePath) {
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
} else {
this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
}
} else {
this.$toast.info(`No updates were made for Author ${response.author.name}`)
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
}
this.searching = false
},
setSearching(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() {
this.$eventBus.$on(`searching-author-${this.authorId}`, this.setSearching)
if (this.authorMount) this.setEntity(this.authorMount)
},
beforeDestroy() {
this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching)
this.removeListeners()
}
}
</script>

View file

@ -8,6 +8,7 @@
<p class="truncate text-sm">{{ title }}</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-else-if="!isFinished && cancelingScan" class="text-xs truncate">Canceling...</p>
@ -26,7 +27,16 @@ export default {
},
data() {
return {
cancelingScan: false
cancelingScan: false,
specialMessage: ''
}
},
watch: {
task: {
immediate: true,
handler() {
this.initTask()
}
}
},
computed: {
@ -34,14 +44,17 @@ export default {
return this.$store.getters['user/getIsAdminOrUp']
},
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'
},
description() {
if (this.task.descriptionKey && this.$strings[this.task.descriptionKey]) {
return this.$getString(this.task.descriptionKey, this.task.descriptionSubs)
}
return this.task.description || ''
},
details() {
return this.task.details || 'Unknown'
},
isFinished() {
return !!this.task.isFinished
},
@ -52,6 +65,9 @@ export default {
return this.isFinished && !this.isFailed
},
failedMessage() {
if (this.task.errorKey && this.$strings[this.task.errorKey]) {
return this.$getString(this.task.errorKey, this.task.errorSubs)
}
return this.task.error || ''
},
action() {
@ -87,6 +103,21 @@ export default {
}
},
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() {
const libraryId = this.task?.data?.libraryId
if (!libraryId) {

View file

@ -1,5 +1,5 @@
<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 ' }">
<!-- 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">
@ -14,21 +14,21 @@
</div>
<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>
</div>
<!-- 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 -->
<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>
<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 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 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 -->
<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>
<!-- 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' }">
<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>
<!-- Series sequence -->
@ -114,7 +114,7 @@
<!-- 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' }">
<p :style="{ fontSize: 0.8 + 'em' }">{{ numEpisodes }}</p>
<p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfEpisodes">{{ numEpisodes }}</p>
</div>
<!-- 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 :style="{ fontSize: 0.9 + 'em' }">
<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" />
</ui-tooltip>
</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="line3" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
</div>
</div>
</article>
</template>
<script>
@ -226,9 +226,6 @@ export default {
isPodcast() {
return this.mediaType === 'podcast' || this.store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
},
isMusic() {
return this.mediaType === 'music'
},
isExplicit() {
return this.mediaMetadata.explicit || false
},
@ -328,7 +325,7 @@ export default {
},
displaySubtitle() {
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.seriesName) return this.mediaMetadata.seriesName
return ''
@ -336,7 +333,6 @@ export default {
displayLineTwo() {
if (this.recentEpisode) return this.title
if (this.isPodcast) return this.author
if (this.isMusic) return this.artist
if (this.collapsedSeries) return ''
if (this.isAuthorBookshelfView) {
return this.mediaMetadata.publishedYear || ''
@ -346,14 +342,14 @@ export default {
},
displaySortLine() {
if (this.collapsedSeries) return null
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt, this.dateFormat)
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
if (this.orderBy === 'mtimeMs') return this.$getString('LabelFileModifiedDate', [this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat)])
if (this.orderBy === 'birthtimeMs') return this.$getString('LabelFileBornDate', [this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat)])
if (this.orderBy === 'addedAt') return this.$getString('LabelAddedDate', [this.$formatDate(this._libraryItem.addedAt, this.dateFormat)])
if (this.orderBy === 'media.duration') return this.$strings.LabelDuration + ': ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return this.$strings.LabelSize + ': ' + this.$bytesPretty(this._libraryItem.size)
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} ` + this.$strings.LabelEpisodes
if (this.orderBy === 'media.metadata.publishedYear') {
if (this.mediaMetadata.publishedYear) return 'Published ' + this.mediaMetadata.publishedYear
if (this.mediaMetadata.publishedYear) return this.$getString('LabelPublishedDate', [this.mediaMetadata.publishedYear])
return '\u00A0'
}
return null
@ -364,7 +360,6 @@ export default {
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
},
userProgress() {
if (this.isMusic) return null
if (this.episodeProgress) return this.episodeProgress
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
@ -420,7 +415,7 @@ export default {
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.ebookFormat
@ -464,8 +459,6 @@ export default {
return this.store.getters['user/getIsAdminOrUp']
},
moreMenuItems() {
if (this.isMusic) return []
if (this.recentEpisode) {
const items = [
{
@ -710,7 +703,7 @@ export default {
toggleFinished(confirmed = false) {
if (!this.itemIsFinished && this.userProgressPercent > 0 && !confirmed) {
const payload = {
message: `Are you sure you want to mark "${this.displayTitle}" as finished?`,
message: this.$getString('MessageConfirmMarkItemFinished', [this.displayTitle]),
callback: (confirmed) => {
if (confirmed) {
this.toggleFinished(true)
@ -755,18 +748,18 @@ export default {
.then((data) => {
var result = data.result
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
this.$toast.error(this.$getString('ToastRescanFailed', [this.displayTitle]))
} else if (result === 'UPDATED') {
this.$toast.success(`Re-Scan complete item was updated`)
this.$toast.success(this.$strings.ToastRescanUpdated)
} else if (result === 'UPTODATE') {
this.$toast.success(`Re-Scan complete item was up to date`)
this.$toast.success(this.$strings.ToastRescanUpToDate)
} else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete item was removed`)
this.$toast.error(this.$strings.ToastRescanRemoved)
}
})
.catch((error) => {
console.error('Failed to scan library item', error)
this.$toast.error('Failed to scan library item')
this.$toast.error(this.$strings.ToastScanFailed)
})
.finally(() => {
this.processing = false
@ -823,7 +816,7 @@ export default {
})
.catch((error) => {
console.error('Failed to remove series from home', error)
this.$toast.error('Failed to update user')
this.$toast.error(this.$strings.ToastFailedToUpdate)
})
.finally(() => {
this.processing = false
@ -841,7 +834,7 @@ export default {
})
.catch((error) => {
console.error('Failed to hide item from home', error)
this.$toast.error('Failed to update user')
this.$toast.error(this.$strings.ToastFailedToUpdate)
})
.finally(() => {
this.processing = false
@ -856,7 +849,7 @@ export default {
episodeId: this.recentEpisode.id,
title: this.recentEpisode.title,
subtitle: this.mediaMetadata.title,
caption: this.recentEpisode.publishedAt ? `Published ${this.$formatDate(this.recentEpisode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
caption: this.recentEpisode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(this.recentEpisode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: this.recentEpisode.audioFile.duration || null,
coverPath: this.media.coverPath || null
}
@ -906,11 +899,11 @@ export default {
axios
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
.then(() => {
this.$toast.success('Item deleted')
this.$toast.success(this.$strings.ToastItemDeletedSuccess)
})
.catch((error) => {
console.error('Failed to delete item', error)
this.$toast.error('Failed to delete item')
this.$toast.error(this.$strings.ToastItemDeletedFailed)
})
.finally(() => {
this.processing = false
@ -1016,7 +1009,7 @@ export default {
episodeId: episode.id,
title: episode.title,
subtitle: this.mediaMetadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.audioFile.duration || null,
coverPath: this.media.coverPath || null
})

View file

@ -1,5 +1,5 @@
<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="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden">

View file

@ -1,5 +1,5 @@
<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="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden">

View file

@ -1,5 +1,5 @@
<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 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">
@ -7,12 +7,12 @@
</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">
<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 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>
</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 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 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>
</div>
</div>
</article>
</template>
<script>
@ -96,7 +96,7 @@ export default {
displaySortLine() {
switch (this.orderBy) {
case 'addedAt':
return `${this.$strings.LabelAdded} ${this.$formatDate(this.addedAt, this.dateFormat)}`
return this.$getString('LabelAddedDate', [this.$formatDate(this.addedAt, this.dateFormat)])
case 'totalDuration':
return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}`
case 'lastBookUpdated':

View file

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

View file

@ -27,38 +27,6 @@
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=publishers.${$encode(publisher)}`" class="hover:underline">{{ publisher }}</nuxt-link>
</div>
</div>
<div v-if="musicAlbum" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Album</span>
</div>
<div>
{{ musicAlbum }}
</div>
</div>
<div v-if="musicAlbumArtist" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span>
</div>
<div>
{{ musicAlbumArtist }}
</div>
</div>
<div v-if="musicTrackPretty" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Track</span>
</div>
<div>
{{ musicTrackPretty }}
</div>
</div>
<div v-if="musicDiscPretty" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Disc</span>
</div>
<div>
{{ musicDiscPretty }}
</div>
</div>
<div v-if="podcastType" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPodcastType }}</span>
@ -97,7 +65,7 @@
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=languages.${$encode(language)}`" class="hover:underline">{{ language }}</nuxt-link>
</div>
</div>
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
<div v-if="tracks.length || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
</div>
@ -134,10 +102,6 @@ export default {
isPodcast() {
return this.libraryItem.mediaType === 'podcast'
},
audioFile() {
// Music track
return this.media.audioFile
},
media() {
return this.libraryItem.media || {}
},
@ -168,25 +132,6 @@ export default {
publisher() {
return this.mediaMetadata.publisher || ''
},
musicArtists() {
return this.mediaMetadata.artists || []
},
musicAlbum() {
return this.mediaMetadata.album || ''
},
musicAlbumArtist() {
return this.mediaMetadata.albumArtist || ''
},
musicTrackPretty() {
if (!this.mediaMetadata.trackNumber) return null
if (!this.mediaMetadata.trackTotal) return this.mediaMetadata.trackNumber
return `${this.mediaMetadata.trackNumber} / ${this.mediaMetadata.trackTotal}`
},
musicDiscPretty() {
if (!this.mediaMetadata.discNumber) return null
if (!this.mediaMetadata.discTotal) return this.mediaMetadata.discNumber
return `${this.mediaMetadata.discNumber} / ${this.mediaMetadata.discTotal}`
},
narrators() {
return this.mediaMetadata.narrators || []
},
@ -220,4 +165,4 @@ export default {
methods: {},
mounted() {}
}
</script>
</script>

View file

@ -1,15 +1,15 @@
<template>
<div class="">
<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" />
</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-else class="material-symbols" style="font-size: 1.2rem">close</span>
</div>
</button>
</div>
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu" @mousedown.stop.prevent>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li v-if="isTyping" class="py-2 px-2">
<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>
<template v-for="item in authorResults">
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`">
<nuxt-link :to="`/author/${item.id}`">
<cards-author-search-card :author="item" />
</nuxt-link>
</li>
@ -157,7 +157,7 @@ export default {
clearTimeout(this.focusTimeout)
this.focusTimeout = setTimeout(() => {
this.showMenu = false
}, 200)
}, 100)
},
async runSearch(value) {
this.lastSearch = value

View file

@ -1,28 +1,30 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
</span>
<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="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</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">
<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" />
</svg>
</span>
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
<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>
</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">
<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">
<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">
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
</div>
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
<span class="material-symbols text-2xl">arrow_right</span>
<span class="material-symbols text-2xl" :aria-label="$strings.LabelMore">arrow_right</span>
</div>
<!-- selected checkmark icon -->
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
@ -31,8 +33,8 @@
</li>
</template>
</ul>
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="option" @click="sublist = null">
<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="menuitem" @click="sublist = null">
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
<span class="material-symbols text-2xl">arrow_left</span>
</div>
@ -40,13 +42,13 @@
<span class="font-normal block truncate">{{ $strings.ButtonBack }}</span>
</div>
</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">
<span class="font-normal block truncate py-2">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span>
</div>
</li>
<template v-for="item in sublistItems">
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedSublistOption(item.value)">
<li :key="item.value" class="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">
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
</div>
@ -98,9 +100,6 @@ export default {
isPodcast() {
return this.libraryMediaType === 'podcast'
},
isMusic() {
return this.libraryMediaType === 'music'
},
seriesItems() {
return [
{
@ -192,6 +191,12 @@ export default {
value: 'publishers',
sublist: true
},
{
text: this.$strings.LabelPublishedDecade,
textPlural: this.$strings.LabelPublishedDecades,
value: 'publishedDecades',
sublist: true
},
{
text: this.$strings.LabelLanguage,
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() {
if (this.isSeries) return this.seriesItems
if (this.isPodcast) return this.podcastItems
if (this.isMusic) return this.musicItems
return this.bookItems
},
selectedItemSublist() {
@ -367,6 +346,9 @@ export default {
publishers() {
return this.filterData.publishers || []
},
publishedDecades() {
return this.filterData.publishedDecades || []
},
progress() {
return [
{
@ -433,21 +415,17 @@ export default {
id: 'isbn',
name: 'ISBN'
},
{
id: 'subtitle',
name: this.$strings.LabelSubtitle
},
{
id: 'authors',
name: this.$strings.LabelAuthor
},
{
id: 'publishedYear',
name: this.$strings.LabelPublishYear
id: 'chapters',
name: this.$strings.LabelChapters
},
{
id: 'series',
name: this.$strings.LabelSeries
id: 'cover',
name: this.$strings.LabelCover
},
{
id: 'description',
@ -458,24 +436,32 @@ export default {
name: this.$strings.LabelGenres
},
{
id: 'tags',
name: this.$strings.LabelTags
id: 'language',
name: this.$strings.LabelLanguage
},
{
id: 'narrators',
name: this.$strings.LabelNarrator
},
{
id: 'publishedYear',
name: this.$strings.LabelPublishYear
},
{
id: 'publisher',
name: this.$strings.LabelPublisher
},
{
id: 'language',
name: this.$strings.LabelLanguage
id: 'series',
name: this.$strings.LabelSeries
},
{
id: 'cover',
name: this.$strings.LabelCover
id: 'subtitle',
name: this.$strings.LabelSubtitle
},
{
id: 'tags',
name: this.$strings.LabelTags
}
]
},

View file

@ -1,20 +1,20 @@
<template>
<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="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>
</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">
<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">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-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>
</li>
</template>
@ -56,9 +56,6 @@ export default {
isPodcast() {
return this.libraryMediaType === 'podcast'
},
isMusic() {
return this.libraryMediaType === 'music'
},
podcastItems() {
return [
{
@ -148,40 +145,10 @@ export default {
}
]
},
musicItems() {
return [
{
text: this.$strings.LabelTitle,
value: 'media.metadata.title'
},
{
text: this.$strings.LabelAddedAt,
value: 'addedAt'
},
{
text: this.$strings.LabelSize,
value: 'size'
},
{
text: this.$strings.LabelDuration,
value: 'media.duration'
},
{
text: this.$strings.LabelFileBirthtime,
value: 'birthtimeMs'
},
{
text: this.$strings.LabelFileModified,
value: 'mtimeMs'
}
]
},
selectItems() {
let items = null
if (this.isPodcast) {
items = this.podcastItems
} else if (this.isMusic) {
items = this.musicItems
} else if (this.$store.getters['user/getUserSetting']('filterBy').startsWith('series.')) {
items = this.seriesItems
} else {

View file

@ -1,9 +1,9 @@
<template>
<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)">
<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 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="arrow-down" />
</div>
@ -11,12 +11,12 @@
<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 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>
</template>
</div>
<div class="w-full py-1 px-4">
<div class="w-full py-1 px-1">
<div class="flex items-center justify-between">
<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>
@ -41,7 +41,7 @@ export default {
currentPlaybackRate: 0,
MIN_SPEED: 0.5,
MAX_SPEED: 10,
menuLeft: -92,
menuLeft: -96,
arrowLeft: 0
}
},
@ -89,9 +89,9 @@ export default {
if (boundingBox.left + 110 > window.innerWidth - 10) {
this.menuLeft = window.innerWidth - 230 - boundingBox.left
this.arrowLeft = Math.abs(this.menuLeft) - 92
this.arrowLeft = Math.abs(this.menuLeft) - 96
} else {
this.menuLeft = -92
this.menuLeft = -96
this.arrowLeft = 0
}
},
@ -109,4 +109,4 @@ export default {
this.currentPlaybackRate = this.playbackRate
}
}
</script>
</script>

View file

@ -1,20 +1,20 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
<span class="material-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>
</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">
<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">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-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>
</li>
</template>

View file

@ -4,10 +4,10 @@
<span class="material-symbols text-2xl sm:text-3xl">{{ volumeIcon }}</span>
</button>
<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 ref="volumeTrack" class="h-1 w-full bg-gray-500 my-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="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 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="w-1 h-full bg-gray-500 mx-2.5 relative cursor-pointer rounded-full" @mousedown="mousedownTrack" @click="clickVolumeTrack">
<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="{ bottom: cursorBottom + 'px', left: '-3px' }" />
</div>
</div>
</transition>
@ -24,10 +24,10 @@ export default {
isOpen: false,
isDragging: false,
isHovering: false,
posX: 0,
posY: 0,
lastValue: 0.5,
isMute: false,
trackWidth: 112 - 20,
trackHeight: 112 - 20,
openTimeout: null
}
},
@ -45,9 +45,9 @@ export default {
this.$emit('input', val)
}
},
cursorLeft() {
var left = this.trackWidth * this.volume
return left - 3
cursorBottom() {
var bottom = this.trackHeight * this.volume
return bottom - 3
},
volumeIcon() {
if (this.volume <= 0) return 'volume_mute'
@ -89,17 +89,10 @@ export default {
}, 600)
},
mousemove(e) {
var diff = this.posX - e.x
this.posX = e.x
var volShift = 0
if (diff < 0) {
// Volume up
volShift = diff / this.trackWidth
} else {
// volume down
volShift = diff / this.trackWidth
}
var newVol = this.volume - volShift
var diff = this.posY - e.y
this.posY = e.y
var volShift = diff / this.trackHeight
var newVol = this.volume + volShift
newVol = Math.min(Math.max(0, newVol), 1)
this.volume = newVol
e.preventDefault()
@ -113,8 +106,8 @@ export default {
},
mousedownTrack(e) {
this.isDragging = true
this.posX = e.x
var vol = e.offsetX / this.trackWidth
this.posY = e.y
var vol = 1 - e.offsetY / this.trackHeight
vol = Math.min(Math.max(vol, 0), 1)
this.volume = vol
document.body.addEventListener('mousemove', this.mousemove)
@ -137,7 +130,7 @@ export default {
this.clickVolumeIcon()
},
clickVolumeTrack(e) {
var vol = e.offsetX / this.trackWidth
var vol = 1 - e.offsetY / this.trackHeight
vol = Math.min(Math.max(vol, 0), 1)
this.volume = vol
}
@ -147,7 +140,7 @@ export default {
this.isMute = true
}
const storageVolume = localStorage.getItem('volume')
if (storageVolume) {
if (storageVolume && !isNaN(storageVolume)) {
this.volume = parseFloat(storageVolume)
}
},
@ -157,4 +150,4 @@ export default {
document.body.removeEventListener('mouseup', this.mouseup)
}
}
</script>
</script>

View file

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

View file

@ -121,6 +121,8 @@ export default {
var img = document.createElement('img')
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.style.objectFit = showCoverBg ? 'contain' : 'cover'

View file

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

View file

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

View file

@ -4,7 +4,7 @@
<div class="flex items-center justify-between">
<p class="text-base text-gray-200 truncate">{{ metadata.filename }}</p>
<ui-btn v-if="ffprobeData" small class="ml-2" @click="ffprobeData = null">{{ $strings.ButtonReset }}</ui-btn>
<ui-btn v-else-if="userIsAdminOrUp" small :loading="probingFile" class="ml-2" @click="getFFProbeData">Probe Audio File</ui-btn>
<ui-btn v-else-if="userIsAdminOrUp" small :loading="probingFile" class="ml-2" @click="getFFProbeData">{{ $strings.ButtonProbeAudioFile }}</ui-btn>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
@ -159,7 +159,7 @@ export default {
})
.catch((error) => {
console.error('Failed to get ffprobe data', error)
this.$toast.error('FFProbe failed')
this.$toast.error(this.$strings.ToastFailedToLoadData)
})
.finally(() => {
this.probingFile = false

View file

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

View file

@ -54,8 +54,7 @@ export default {
options: {
provider: undefined,
overrideDetails: true,
overrideCover: true,
overrideDefaults: true
overrideCover: true
}
}
},
@ -99,8 +98,8 @@ export default {
init() {
// 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
if (!this.options.provider || this.options.lastUsedLibrary != this.currentLibraryId) {
this.options.lastUsedLibrary = this.currentLibraryId
if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) {
this.lastUsedLibrary = this.currentLibraryId
this.options.provider = this.libraryProvider
}
},
@ -116,10 +115,10 @@ export default {
libraryItemIds: this.selectedBookIds
})
.then(() => {
this.$toast.info('Batch quick match of ' + this.selectedBookIds.length + ' books started!')
this.$toast.info(this.$getString('ToastBatchQuickMatchStarted', [this.selectedBookIds.length]))
})
.catch((error) => {
this.$toast.error('Batch quick match failed')
this.$toast.error(this.$strings.ToastBatchQuickMatchFailed)
console.error('Failed to batch quick match', error)
})
.finally(() => {

View file

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

View file

@ -1,8 +1,8 @@
<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 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">
<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">
<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>
</div>
</button>
<div ref="content" class="text-white">
<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>

View file

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

View file

@ -1,12 +1,12 @@
<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" />
<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>
</button>
<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 />
<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 />
@ -126,6 +126,9 @@ export default {
this.$eventBus.$on('modal-hotkey', this.hotkey)
this.$store.commit('setOpenModal', this.name)
// Set focus to the modal content
this.content.focus()
},
setHide() {
if (this.content) this.content.style.transform = 'scale(0)'

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="text-3xl text-white truncate">Changelog</p>
<h1 class="text-3xl text-white truncate">Changelog</h1>
</div>
</template>
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
@ -13,7 +13,7 @@
</p>
<div class="custom-text" v-html="getChangelog(release)" />
</div>
<div v-if="release !== releasesToShow[releasesToShow.length - 1]" class="border-b border-black-300 my-8" />
<div v-if="release !== releasesToShow[releasesToShow.length - 1]" :key="`${release.name}-divider`" class="border-b border-black-300 my-8" />
</template>
</div>
</modals-modal>

View file

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

View file

@ -106,7 +106,7 @@ export default {
.catch((error) => {
console.error('Failed to remove collection', error)
this.processing = false
this.$toast.error(this.$strings.ToastCollectionRemoveFailed)
this.$toast.error(this.$strings.ToastRemoveFailed)
})
}
},
@ -115,7 +115,7 @@ export default {
return
}
if (!this.newCollectionName) {
return this.$toast.error('Collection must have a name')
return this.$toast.error(this.$strings.ToastNameRequired)
}
this.processing = true
@ -135,7 +135,7 @@ export default {
.catch((error) => {
console.error('Failed to update collection', error)
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()
if (!this.newDevice.name?.trim() || !this.newDevice.email?.trim()) {
this.$toast.error('Name and email required')
this.$toast.error(this.$strings.ToastNameEmailRequired)
return
}
if (this.newDevice.availabilityOption === 'specificUsers' && !this.newDevice.users.length) {
this.$toast.error('Must select at least one user')
this.$toast.error(this.$strings.ToastSelectAtLeastOneUser)
return
}
if (this.newDevice.availabilityOption !== 'specificUsers') {
@ -142,14 +142,14 @@ export default {
if (!this.ereaderDevice) {
if (this.existingDevices.some((d) => d.name === this.newDevice.name)) {
this.$toast.error('Ereader device with that name already exists')
this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)
return
}
this.submitCreate()
} else {
if (this.ereaderDevice.name !== this.newDevice.name && this.existingDevices.some((d) => d.name === this.newDevice.name)) {
this.$toast.error('Ereader device with that name already exists')
this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)
return
}
@ -174,12 +174,11 @@ export default {
.$post(`/api/emails/ereader-devices`, payload)
.then((data) => {
this.$emit('update', data.ereaderDevices)
this.$toast.success('Device updated')
this.show = false
})
.catch((error) => {
console.error('Failed to update device', error)
this.$toast.error('Failed to update device')
this.$toast.error(this.$strings.ToastFailedToUpdate)
})
.finally(() => {
this.processing = false
@ -201,12 +200,11 @@ export default {
.$post('/api/emails/ereader-devices', payload)
.then((data) => {
this.$emit('update', data.ereaderDevices || [])
this.$toast.success('Device added')
this.show = false
})
.catch((error) => {
console.error('Failed to add device', error)
this.$toast.error('Failed to add device')
this.$toast.error(this.$strings.ToastDeviceAddFailed)
})
.finally(() => {
this.processing = false

View file

@ -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">
<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">
<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>
</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">
<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>
</div>
<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>
</div>
<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>
<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 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 v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
<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 v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
<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>
</modals-modal>
</template>

View file

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

View file

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

View file

@ -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">
<div class="flex -mb-0.5">
<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>
</ui-tooltip>
</div>
@ -99,7 +99,7 @@ export default {
if (this.maxEpisodesToDownload < 0) {
this.maxEpisodesToDownload = 3
this.$toast.error('Invalid max episodes to download')
this.$toast.error(this.$strings.ToastInvalidMaxEpisodesToDownload)
return
}
@ -120,9 +120,9 @@ export default {
.then((response) => {
if (response.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 {
this.$toast.info('No new episodes found')
this.$toast.info(this.$strings.ToastNoNewEpisodesFound)
}
this.checkingNewEpisodes = false
})
@ -141,4 +141,4 @@ export default {
this.setLastEpisodeCheckInput()
}
}
</script>
</script>

View file

@ -60,7 +60,7 @@
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" :label="$strings.LabelTitle" />
<p v-if="mediaMetadata.title" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <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>
</div>
</div>
@ -69,7 +69,7 @@
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" :label="$strings.LabelSubtitle" />
<p v-if="mediaMetadata.subtitle" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <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>
</div>
</div>
@ -78,7 +78,7 @@
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" :label="$strings.LabelAuthor" />
<p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <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>
</div>
</div>
@ -87,7 +87,7 @@
<div class="flex-grow ml-4">
<ui-multi-select v-model="selectedMatch.narrator" :items="narrators" :disabled="!selectedMatchUsage.narrator" :label="$strings.LabelNarrators" />
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <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>
</div>
</div>
@ -96,7 +96,7 @@
<div class="flex-grow ml-4">
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" :label="$strings.LabelDescription" />
<p v-if="mediaMetadata.description" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <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>
</div>
</div>
@ -105,7 +105,7 @@
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" :label="$strings.LabelPublisher" />
<p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <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>
</div>
</div>
@ -114,7 +114,7 @@
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" :label="$strings.LabelPublishYear" />
<p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <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>
</div>
</div>
@ -124,7 +124,7 @@
<div class="flex-grow ml-4">
<widgets-series-input-widget v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" />
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <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>
</div>
</div>
@ -133,7 +133,7 @@
<div class="flex-grow ml-4">
<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">
{{ $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>
</div>
</div>
@ -142,7 +142,7 @@
<div class="flex-grow ml-4">
<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">
{{ $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>
</div>
</div>
@ -151,7 +151,7 @@
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.language" :disabled="!selectedMatchUsage.language" :label="$strings.LabelLanguage" />
<p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <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>
</div>
</div>
@ -160,7 +160,7 @@
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" />
<p v-if="mediaMetadata.isbn" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <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>
</div>
</div>
@ -169,7 +169,7 @@
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" />
<p v-if="mediaMetadata.asin" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <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>
</div>
</div>
@ -179,7 +179,7 @@
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.itunesId" type="number" :disabled="!selectedMatchUsage.itunesId" label="iTunes ID" />
<p v-if="mediaMetadata.itunesId" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <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>
</div>
</div>
@ -188,7 +188,7 @@
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.feedUrl" :disabled="!selectedMatchUsage.feedUrl" label="RSS Feed URL" />
<p v-if="mediaMetadata.feedUrl" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <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>
</div>
</div>
@ -197,7 +197,7 @@
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.itunesPageUrl" :disabled="!selectedMatchUsage.itunesPageUrl" label="iTunes Page URL" />
<p v-if="mediaMetadata.itunesPageUrl" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <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>
</div>
</div>
@ -206,7 +206,7 @@
<div class="flex-grow ml-4">
<ui-text-input-with-label v-model="selectedMatch.releaseDate" :disabled="!selectedMatchUsage.releaseDate" :label="$strings.LabelReleaseDate" />
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <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>
</div>
</div>
@ -397,7 +397,7 @@ export default {
},
submitSearch() {
if (!this.searchTitle) {
this.$toast.warning('Search title is required')
this.$toast.warning(this.$strings.ToastTitleRequired)
return
}
this.persistProvider()
@ -618,12 +618,12 @@ export default {
if (updateResult.updated) {
this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)
} else {
this.$toast.info(this.$strings.ToastItemDetailsUpdateUnneeded)
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
}
this.clearSelectedMatch()
this.$emit('selectTab', 'details')
} else {
this.$toast.error(this.$strings.ToastItemDetailsUpdateFailed)
this.$toast.error(this.$strings.ToastFailedToUpdate)
}
} else {
this.clearSelectedMatch()

View file

@ -2,28 +2,28 @@
<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">
<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 v-if="feedUrl || autoDownloadEpisodes">
<div class="flex items-center justify-between mb-4">
<p class="text-base md:text-xl font-semibold">Schedule Automatic Episode Downloads</p>
<ui-checkbox v-model="enableAutoDownloadEpisodes" label="Enable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
<p class="text-base md:text-xl font-semibold">{{ $strings.HeaderScheduleEpisodeDownloads }}</p>
<ui-checkbox v-model="enableAutoDownloadEpisodes" :label="$strings.LabelEnable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
</div>
<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-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">
Max episodes to keep
{{ $strings.LabelMaxEpisodesToKeep }}
<span class="material-symbols icon-text">info</span>
</p>
</ui-tooltip>
</div>
<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-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">
Max new episodes to download per check
{{ $strings.LabelMaxEpisodesToDownloadPerCheck }}
<span class="material-symbols icon-text">info</span>
</p>
</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 class="flex items-center px-2 md:px-4">
<div class="flex-grow" />
<ui-btn @click="save" :disabled="!isUpdated" :color="isUpdated ? 'success' : 'primary'" class="mx-2">{{ isUpdated ? 'Save' : 'No update necessary' }}</ui-btn>
<ui-btn @click="save" :disabled="!isUpdated" :color="isUpdated ? 'success' : 'primary'" class="mx-2">{{ isUpdated ? $strings.ButtonSave : $strings.MessageNoUpdatesWereNecessary }}</ui-btn>
</div>
</div>
</div>
@ -163,7 +163,7 @@ export default {
this.isProcessing = false
if (updateResult) {
if (updateResult.updated) {
this.$toast.success('Item details updated')
this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)
return true
} else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)

View file

@ -33,18 +33,18 @@
<span class="material-symbols text-lg ml-2">launch</span>
</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>
<!-- queued alert -->
<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>
<!-- processing alert -->
<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>
</div>
@ -113,7 +113,7 @@ export default {
methods: {
quickEmbed() {
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) => {
if (confirmed) {
this.$axios

View file

@ -111,7 +111,6 @@ export default {
},
updateLibrary(library) {
this.mapLibraryToCopy(library)
console.log('Updated library', this.libraryCopy)
},
getNewLibraryData() {
return {
@ -128,7 +127,9 @@ export default {
autoScanCronExpression: null,
hideSingleBookSeries: 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() {
if (!this.libraryCopy.name) {
this.$toast.error('Library must have a name')
this.$toast.error(this.$strings.ToastNameRequired)
return false
}
if (!this.libraryCopy.folders.length) {
this.$toast.error('Library must have at least 1 path')
this.$toast.error(this.$strings.ToastMustHaveAtLeastOnePath)
return false
}
@ -205,7 +206,7 @@ export default {
submitUpdateLibrary() {
var newLibraryPayload = this.getLibraryUpdatePayload()
if (!Object.keys(newLibraryPayload).length) {
this.$toast.info('No updates are necessary')
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
return
}
@ -222,7 +223,7 @@ export default {
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
} else {
this.$toast.error(this.$strings.ToastLibraryUpdateFailed)
this.$toast.error(this.$strings.ToastFailedToUpdate)
}
this.processing = false
})
@ -236,7 +237,6 @@ export default {
this.show = false
this.$toast.success(this.$getString('ToastLibraryCreateSuccess', [res.name]))
if (!this.$store.state.libraries.currentLibraryId) {
console.log('Setting initially library id', res.id)
// First library added
this.$store.dispatch('libraries/fetch', res.id)
}
@ -264,4 +264,4 @@ export default {
.tab.tab-selected {
height: 41px;
}
</style>
</style>

View file

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

View file

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

View file

@ -3,13 +3,13 @@
<div class="w-full border border-black-200 p-4 my-8">
<div class="flex flex-wrap items-center">
<div>
<p class="text-lg">Remove metadata files in library item folders</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="text-lg">{{ $strings.LabelRemoveMetadataFile }}</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $getString('LabelRemoveMetadataFileHelp', [mediaType]) }}</p>
</div>
<div class="flex-grow" />
<div>
<ui-btn class="mb-4 block" @click.stop="removeAllMetadataClick('json')">Remove all metadata.json</ui-btn>
<ui-btn @click.stop="removeAllMetadataClick('abs')">Remove all metadata.abs</ui-btn>
<ui-btn class="mb-4 block" @click.stop="removeAllMetadataClick('json')">{{ $strings.LabelRemoveAllMetadataJson }}</ui-btn>
<ui-btn @click.stop="removeAllMetadataClick('abs')">{{ $strings.LabelRemoveAllMetadataAbs }}</ui-btn>
</div>
</div>
</div>
@ -43,7 +43,7 @@ export default {
methods: {
removeAllMetadataClick(ext) {
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,
callback: (confirmed) => {
if (confirmed) {
@ -60,16 +60,16 @@ export default {
.$post(`/api/libraries/${this.libraryId}/remove-metadata?ext=${ext}`)
.then((data) => {
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) {
this.$toast.success(`No metadata.${ext} files removed`)
this.$toast.success(this.$getString('ToastMetadataFilesRemovedNoneRemoved', [ext]))
} else {
this.$toast.success(`Successfully removed ${data.removed} metadata.${ext} files`)
this.$toast.success(this.$getString('ToastMetadataFilesRemovedSuccess', [data.removed, ext]))
}
})
.catch((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(() => {
this.$emit('update:processing', false)
@ -78,4 +78,4 @@ export default {
},
mounted() {}
}
</script>
</script>

View file

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

View file

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

View file

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

View file

@ -18,6 +18,23 @@
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
<div v-if="description" dir="auto" class="default-style" v-html="description" />
<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>
</modals-modal>
</template>
@ -54,7 +71,7 @@ export default {
return this.episode.description || ''
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
return this.libraryItem?.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
@ -65,6 +82,14 @@ export default {
podcastAuthor() {
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() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
}

View file

@ -33,11 +33,11 @@
</div>
<div v-if="enclosureUrl" class="pb-4 pt-6">
<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>
</div>
<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>
</template>
@ -97,7 +97,12 @@ export default {
return this.enclosure.url
},
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: {
@ -142,7 +147,7 @@ export default {
const updatedDetails = this.getUpdatePayload()
if (!Object.keys(updatedDetails).length) {
this.$toast.info('No changes were made')
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
return false
}
return this.updateDetails(updatedDetails)
@ -152,14 +157,14 @@ export default {
const updateResult = await this.$axios.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, updatedDetails).catch((error) => {
console.error('Failed update episode', error)
this.isProcessing = false
this.$toast.error(error?.response?.data || 'Failed to update episode')
this.$toast.error(error?.response?.data || this.$strings.ToastFailedToUpdate)
return false
})
this.isProcessing = false
if (updateResult) {
if (updateResult) {
this.$toast.success('Podcast episode updated')
this.$toast.success(this.$strings.ToastItemUpdateSuccess)
return true
} else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)

View file

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

View file

@ -10,9 +10,9 @@
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p>
<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 v-if="currentFeed.meta" class="mt-5">
@ -111,8 +111,11 @@ export default {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
feedUrl() {
return this.currentFeed ? `${window.origin}${this.$config.routerBasePath}${this.currentFeed.feedUrl}` : ''
},
demoFeedUrl() {
return `${window.origin}/feed/${this.newFeedSlug}`
return `${window.origin}${this.$config.routerBasePath}/feed/${this.newFeedSlug}`
},
isHttp() {
return window.origin.startsWith('http://')
@ -121,14 +124,14 @@ export default {
methods: {
openFeed() {
if (!this.newFeedSlug) {
this.$toast.error('Must set a feed slug')
this.$toast.error(this.$strings.ToastSlugRequired)
return
}
const sanitized = this.$sanitizeSlug(this.newFeedSlug)
if (this.newFeedSlug !== sanitized) {
this.newFeedSlug = sanitized
this.$toast.warning('Slug had to be modified - Run again')
this.$toast.warning(this.$strings.ToastSlugMustChange)
return
}
@ -139,7 +142,7 @@ export default {
slug: this.newFeedSlug,
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)
this.$axios

View file

@ -5,8 +5,8 @@
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p>
<div class="w-full relative">
<ui-text-input v-model="feed.feedUrl" readonly />
<span class="material-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>
<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(feedUrl)">content_copy</span>
</div>
<div v-if="feed.meta" class="mt-5">
@ -70,6 +70,9 @@ export default {
},
_feed() {
return this.feed || {}
},
feedUrl() {
return this.feed ? `${window.origin}${this.$config.routerBasePath}${this.feed.feedUrl}` : ''
}
},
methods: {

View file

@ -1,38 +1,37 @@
<template>
<div class="flex items-center pt-4 pb-2 lg:pt-0 lg:pb-2">
<div class="flex-grow" />
<template v-if="!loading">
<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">
<span class="material-symbols text-2xl sm:text-3xl">first_page</span>
<div class="flex justify-center pt-4 pb-2 lg:pt-0 lg:pb-2">
<div class="flex items-center justify-center flex-grow">
<template v-if="!loading">
<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">
<span class="material-symbols text-2xl sm:text-3xl">first_page</span>
</button>
</ui-tooltip>
<ui-tooltip direction="top" :text="jumpBackwardText">
<button :aria-label="jumpForwardText" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
<span class="material-symbols text-2xl sm:text-3xl">replay</span>
</button>
</ui-tooltip>
<button :aria-label="paused ? $strings.ButtonPlay : $strings.ButtonPause" class="p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 lg:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
<span class="material-symbols fill text-2xl">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
</button>
</ui-tooltip>
<ui-tooltip direction="top" :text="jumpBackwardText">
<button :aria-label="jumpForwardText" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
<span class="material-symbols text-2xl sm:text-3xl">replay</span>
</button>
</ui-tooltip>
<button :aria-label="paused ? $strings.ButtonPlay : $strings.ButtonPause" class="p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 lg:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
<span class="material-symbols fill text-2xl">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
</button>
<ui-tooltip direction="top" :text="jumpForwardText">
<button :aria-label="jumpForwardText" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
<span class="material-symbols text-2xl sm:text-3xl">forward_media</span>
</button>
</ui-tooltip>
<ui-tooltip direction="top" :text="hasNextLabel" class="ml-4 lg:ml-8">
<button :aria-label="hasNextLabel" :disabled="!hasNext" class="text-gray-300 disabled:text-gray-500" @mousedown.prevent @mouseup.prevent @click.stop="next">
<span class="material-symbols text-2xl sm:text-3xl">last_page</span>
</button>
</ui-tooltip>
<controls-playback-speed-control v-model="playbackRateInput" @input="playbackRateUpdated" @change="playbackRateChanged" />
</template>
<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">
<span class="material-symbols text-2xl">autorenew</span>
</div>
</template>
<div class="flex-grow" />
<ui-tooltip direction="top" :text="jumpForwardText">
<button :aria-label="jumpForwardText" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
<span class="material-symbols text-2xl sm:text-3xl">forward_media</span>
</button>
</ui-tooltip>
<ui-tooltip direction="top" :text="hasNextLabel" class="ml-4 lg:ml-8">
<button :aria-label="hasNextLabel" :disabled="!hasNext" class="text-gray-300 disabled:text-gray-500" @mousedown.prevent @mouseup.prevent @click.stop="next">
<span class="material-symbols text-2xl sm:text-3xl">last_page</span>
</button>
</ui-tooltip>
</template>
<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">
<span class="material-symbols text-2xl">autorenew</span>
</div>
</template>
</div>
</div>
</template>
@ -41,7 +40,6 @@ export default {
props: {
loading: Boolean,
seekLoading: Boolean,
playbackRate: Number,
paused: Boolean,
hasNextChapter: Boolean,
hasNextItemInQueue: Boolean
@ -50,14 +48,6 @@ export default {
return {}
},
computed: {
playbackRateInput: {
get() {
return this.playbackRate
},
set(val) {
this.$emit('update:playbackRate', val)
}
},
jumpForwardText() {
return this.getJumpText('jumpForwardAmount', this.$strings.ButtonJumpForward)
},
@ -89,15 +79,6 @@ export default {
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) {
const amount = this.$store.getters['user/getUserSetting'](setting)
if (!amount) return prefix

View file

@ -2,9 +2,9 @@
<div class="w-full -mt-6">
<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">
<!-- <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" />
</ui-tooltip>
@ -13,7 +13,7 @@
<span v-if="!sleepTimerSet" class="material-symbols text-2xl">snooze</span>
<div v-else class="flex items-center">
<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>
</button>
</ui-tooltip>
@ -37,7 +37,7 @@
</ui-tooltip>
<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>
</button>
</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" />
<div class="flex">
<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>
<div class="flex-grow" />
<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>
</p>
<div class="flex-grow" />
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
<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 class="font-mono text-sm hidden sm:block text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p>
</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">
{{ currentChapterName }} <span v-if="useChapterTrack" class="text-xs text-gray-400">&nbsp;({{ $getString('LabelPlayerChapterNumberMarker', [currentChapterIndex + 1, chapters.length]) }})</span>
</p>
</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>
</div>
</div>
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
</div>
</template>
@ -92,6 +98,7 @@ export default {
audioEl: null,
seekLoading: false,
showChaptersModal: false,
showPlayerSettingsModal: false,
currentTime: 0,
duration: 0
}
@ -178,22 +185,6 @@ export default {
methods: {
toggleFullscreen(isFullscreen) {
this.$store.commit('setPlayerIsFullscreen', isFullscreen)
var videoPlayerEl = document.getElementById('video-player')
if (videoPlayerEl) {
if (isFullscreen) {
videoPlayerEl.style.width = '100vw'
videoPlayerEl.style.height = '100vh'
videoPlayerEl.style.top = '0px'
videoPlayerEl.style.left = '0px'
} else {
videoPlayerEl.style.width = '384px'
videoPlayerEl.style.height = '216px'
videoPlayerEl.style.top = 'unset'
videoPlayerEl.style.bottom = '80px'
videoPlayerEl.style.left = '16px'
}
}
},
setDuration(duration) {
this.duration = duration
@ -240,6 +231,12 @@ export default {
this.playbackRate = Number((this.playbackRate - 0.1).toFixed(1))
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) {
this.$emit('setPlaybackRate', playbackRate)
},
@ -321,6 +318,9 @@ export default {
if (!this.chapters.length) return
this.showChaptersModal = !this.showChaptersModal
},
showPlayerSettings() {
this.showPlayerSettingsModal = !this.showPlayerSettingsModal
},
init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1

View file

@ -35,22 +35,22 @@
<div class="flex justify-between pt-12">
<div>
<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>
</div>
<div>
<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>
</div>
<div>
<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>
</div>
<div>
<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>
</div>
</div>

View file

@ -1,9 +1,9 @@
<template>
<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 />
</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>
</template>
@ -261,7 +261,7 @@ export default {
.catch((error) => {
console.error('Failed to share', error)
if (error.name !== 'AbortError') {
this.$toast.error('Failed to share: ' + error.message)
this.$toast.error(this.$strings.ToastFailedToShare + ': ' + error.message)
}
})
} else {

View file

@ -7,7 +7,7 @@
</div>
<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" />
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? $strings.LabelYearReviewHide : $strings.LabelYearReviewShow }}</ui-btn>
</div>
@ -16,17 +16,22 @@
<div v-if="showYearInReview">
<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 -->
<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="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
</ui-btn>
<!-- 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" />
<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>
<div class="flex-grow" />
@ -36,7 +41,7 @@
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
</ui-btn>
<!-- 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="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
</ui-btn>
@ -46,23 +51,23 @@
<!-- your year in review short -->
<div class="w-full max-w-[800px] mx-auto my-4">
<!-- 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" />
</div>
<!-- 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">
<!-- 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="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
</ui-btn>
<!-- 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" />
<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>
<div class="flex-grow" />
@ -72,7 +77,7 @@
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
</ui-btn>
<!-- 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="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
</ui-btn>
@ -88,6 +93,7 @@ export default {
data() {
return {
showYearInReview: false,
availableYears: [],
yearInReviewYear: 0,
yearInReviewVariant: 0,
yearInReviewServerVariant: 0,
@ -100,6 +106,9 @@ export default {
computed: {
isAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
user() {
return this.$store.state.user.user
}
},
methods: {
@ -112,25 +121,57 @@ export default {
shareYearInReviewShort() {
this.$refs.yearInReviewShort.share()
},
yearInReviewYearChanged() {
this.$nextTick(() => {
this.refreshYearInReview()
this.refreshYearInReviewServer()
})
},
refreshYearInReviewServer() {
this.$refs.yearInReviewServer.refresh()
if (this.$refs.yearInReviewServer != null) {
this.$refs.yearInReviewServer.refresh()
}
},
refreshYearInReview() {
this.$refs.yearInReview.refresh()
this.$refs.yearInReviewShort.refresh()
if (this.$refs.yearInReview != null && this.$refs.yearInReviewShort != null) {
this.$refs.yearInReview.refresh()
this.$refs.yearInReviewShort.refresh()
}
},
clickShowYearInReview() {
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() {
this.yearInReviewYear = new Date().getFullYear()
// When not December show previous year
if (new Date().getMonth() < 11) {
this.yearInReviewYear--
}
},
mounted() {
this.availableYears = this.getAvailableYears()
if (typeof navigator.share !== 'undefined' && navigator.share) {
this.showShareButton = true
} else {

View file

@ -1,9 +1,9 @@
<template>
<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 />
</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>
</template>
@ -237,7 +237,7 @@ export default {
.catch((error) => {
console.error('Failed to share', error)
if (error.name !== 'AbortError') {
this.$toast.error('Failed to share: ' + error.message)
this.$toast.error(this.$strings.ToastFailedToShare + ': ' + error.message)
}
})
} else {

View file

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

View file

@ -186,7 +186,7 @@ export default {
mounted() {
this.loadBackups()
if (this.$route.query.backup) {
this.$toast.success('Backup applied successfully')
this.$toast.success(this.$strings.ToastBackupAppliedSuccess)
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -25,7 +25,6 @@
</template>
</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">
<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" />
@ -93,17 +92,18 @@ export default {
},
computed: {
contextMenuItems() {
if (!this.userIsAdminOrUp) return []
return [
{
text: 'Quick match all episodes',
const menuItems = []
if (this.userIsAdminOrUp) {
menuItems.push({
text: this.$strings.MessageQuickMatchAllEpisodes,
action: 'quick-match-episodes'
},
{
text: this.allEpisodesFinished ? this.$strings.MessageMarkAllEpisodesNotFinished : this.$strings.MessageMarkAllEpisodesFinished,
action: 'batch-mark-as-finished'
}
]
})
}
menuItems.push({
text: this.allEpisodesFinished ? this.$strings.MessageMarkAllEpisodesNotFinished : this.$strings.MessageMarkAllEpisodesFinished,
action: 'batch-mark-as-finished'
})
return menuItems
},
sortItems() {
return [
@ -261,21 +261,21 @@ export default {
this.processing = true
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) => {
if (confirmed) {
this.$axios
.$post(`/api/podcasts/${this.libraryItem.id}/match-episodes?override=1`)
.then((data) => {
if (data.numEpisodesUpdated) {
this.$toast.success(`${data.numEpisodesUpdated} episodes updated`)
this.$toast.success(this.$getString('ToastEpisodeUpdateSuccess', [data.numEpisodesUpdated]))
} else {
this.$toast.info('No changes were made')
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
}
})
.catch((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
@ -295,7 +295,7 @@ export default {
episodeId: episode.id,
title: episode.title,
subtitle: this.mediaMetadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.audioFile.duration || null,
coverPath: this.media.coverPath || null
}
@ -372,7 +372,7 @@ export default {
episodeId: episode.id,
title: episode.title,
subtitle: this.mediaMetadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
duration: episode.audioFile.duration || null,
coverPath: this.media.coverPath || null
})
@ -514,6 +514,10 @@ export default {
}
},
filterSortChanged() {
// Save filterKey and sortKey to local storage
localStorage.setItem('podcastEpisodesFilter', this.filterKey)
localStorage.setItem('podcastEpisodesSortBy', this.sortKey + (this.sortDesc ? '-desc' : ''))
this.init()
},
refresh() {
@ -536,6 +540,11 @@ export default {
}
},
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.initListeners()
this.init()

View file

@ -1,7 +1,7 @@
<template>
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing">
<button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<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>
</button>
<div v-else class="h-full w-full flex items-center justify-center">
@ -10,12 +10,12 @@
</slot>
<transition name="menu">
<div v-show="showMenu" ref="menuWrapper" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }">
<div v-show="showMenu" 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-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>
</div>
</button>
<div
v-if="mouseoverItemIndex === index"
:key="`subitems-${index}`"
@ -25,14 +25,14 @@
:class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'"
:style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }"
>
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.action, subitem.data)">
<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>
</div>
</button>
</div>
</template>
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
<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>
</div>
</button>
</template>
</div>
</transition>

View file

@ -1,7 +1,7 @@
<template>
<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>
<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="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
<span v-if="selectedSubtext">:&nbsp;</span>
@ -13,9 +13,9 @@
</button>
<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">
<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">
<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>
@ -119,4 +119,4 @@ export default {
},
mounted() {}
}
</script>
</script>

View file

@ -1,5 +1,5 @@
<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">
<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" />
@ -28,7 +28,8 @@ export default {
size: {
type: Number,
default: 9
}
},
ariaLabel: String
},
data() {
return {}

View file

@ -4,7 +4,7 @@
type="button"
:disabled="disabled"
class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200"
aria-haspopup="listbox"
aria-haspopup="menu"
:aria-expanded="showMenu"
:aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name"
@click.stop.prevent="clickShowMenu"
@ -16,9 +16,9 @@
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="listbox">
<ul v-show="showMenu" class="absolute z-10 -mt-px 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">
<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">
<ui-library-icon :icon="library.icon" class="mr-1.5" />
<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>
<div class="text-gray-200 text-xs font-light mt-2 text-center">{{ text }}</div>
<div class="text-gray-200 text-xs font-light mt-2 text-center">{{ message }}</div>
</div>
</div>
</template>
@ -17,7 +17,12 @@ export default {
props: {
text: {
type: String,
default: 'Please Wait...'
default: null
}
},
computed: {
message() {
return this.text || this.$strings.MessagePleaseWait
}
}
}
@ -67,4 +72,4 @@ export default {
transform: translate(24px, 0);
}
}
</style>
</style>

View file

@ -1,17 +1,17 @@
<template>
<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">
<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 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-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">
<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>
<span class="material-symbols text-white hover:text-error cursor-pointer" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
<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" 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" :class="{ 'opacity-100': inputFocused }">
<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>
<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>
{{ item }}
</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>
</form>
@ -66,7 +66,8 @@ export default {
typingTimeout: null,
isFocused: false,
menu: null,
filteredItems: null
filteredItems: null,
inputFocused: false
}
},
watch: {
@ -100,6 +101,9 @@ export default {
}
return this.filteredItems
},
identifier() {
return Math.random().toString(36).substring(2)
}
},
methods: {
@ -129,6 +133,9 @@ export default {
}, 100)
this.setInputWidth()
},
setInputFocused(focused) {
this.inputFocused = focused
},
setInputWidth() {
setTimeout(() => {
var value = this.$refs.input.value

View file

@ -1,20 +1,20 @@
<template>
<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">
<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 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-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">
<span v-if="showEdit" class="material-symbols text-base text-white hover:text-warning mr-1" @click.stop="editItem(item)">edit</span>
<span class="material-symbols text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.id)">close</span>
<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" 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" :class="{ 'opacity-100': inputFocused }">
<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>
<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>
{{ item[textKey] }}
</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">
<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>
<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>
</form>
@ -65,6 +65,7 @@ export default {
currentSearch: null,
typingTimeout: null,
isFocused: false,
inputFocused: false,
menu: null,
items: []
}
@ -102,6 +103,9 @@ export default {
},
filterData() {
return this.$store.state.libraries.filterData || {}
},
identifier() {
return Math.random().toString(36).substring(2)
}
},
methods: {
@ -114,6 +118,9 @@ export default {
getIsSelected(itemValue) {
return !!this.selected.find((i) => i.id === itemValue)
},
setInputFocused(focused) {
this.inputFocused = focused
},
search() {
if (!this.textInput) return
this.currentSearch = this.textInput

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
<template>
<div>
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
<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>
<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>

View file

@ -3,10 +3,10 @@
<div class="flex items-center py-3e">
<slot />
<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>
</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>
</button>
</div>
@ -65,7 +65,7 @@ export default {
},
authors: {
component: 'cards-author-card',
itemPropName: 'author',
itemPropName: 'author-mount',
itemIdFunc: (item) => item.id
},
narrators: {

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