mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-05-24 01:13:00 -04:00
chore: merge and resolve
This commit is contained in:
commit
e9e9a8ba75
@ -5,5 +5,6 @@ module.exports.config = {
|
||||
ConfigPath: Path.resolve('config'),
|
||||
MetadataPath: Path.resolve('metadata'),
|
||||
FFmpegPath: '/usr/bin/ffmpeg',
|
||||
FFProbePath: '/usr/bin/ffprobe'
|
||||
FFProbePath: '/usr/bin/ffprobe',
|
||||
SkipBinariesCheck: true
|
||||
}
|
8
.editorconfig
Normal file
8
.editorconfig
Normal file
@ -0,0 +1,8 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
73
.github/ISSUE_TEMPLATE/bug.yaml
vendored
73
.github/ISSUE_TEMPLATE/bug.yaml
vendored
@ -1,40 +1,50 @@
|
||||
name: 🐞 Bug Report
|
||||
description: File a bug/issue
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
description: File a bug/issue and help us improve Audiobookshelf
|
||||
title: '[Bug]: '
|
||||
labels: ['bug', 'triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "### Please first search for your issue and check the [docs](https://audiobookshelf.org/docs)."
|
||||
value: 'Thank you for filing a bug report! 🐛'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
|
||||
value: 'Please first search for your issue and check the [docs](https://audiobookshelf.org/docs).'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "### Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug."
|
||||
value: 'Report issues with the mobile app [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose).'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "## Be as descriptive as you can. Include screenshots, error logs, browser, file types, everything you can think of that might be relevant."
|
||||
value: 'Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug.'
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: Describe the issue
|
||||
description: What happened & what did you expect to happen
|
||||
label: What happened?
|
||||
placeholder: Tell us what you see!
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-was-expected
|
||||
attributes:
|
||||
label: What did you expect to happen?
|
||||
placeholder: Tell us what you expected to see! Be as descriptive as you can and include screenshots if applicable.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: Steps to reproduce the issue
|
||||
value: "1. "
|
||||
value: '1. '
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: '## Install Environment'
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Audiobookshelf version
|
||||
description: Do not put 'Latest version', please put the actual version here
|
||||
placeholder: "e.g. v1.6.60"
|
||||
placeholder: 'e.g. v1.6.60'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
@ -46,6 +56,43 @@ body:
|
||||
- Debian/PPA
|
||||
- Windows Tray App
|
||||
- Built from source
|
||||
- Other
|
||||
- Other (list in "Additional Notes" box)
|
||||
validations:
|
||||
required: true
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: server-os
|
||||
attributes:
|
||||
label: What OS is your Audiobookshelf server hosted from?
|
||||
options:
|
||||
- Windows
|
||||
- macOS
|
||||
- Linux
|
||||
- Other (list in "Additional Notes" box)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: desktop-browsers
|
||||
attributes:
|
||||
label: If the issue is being seen in the UI, what browsers are you seeing the problem on?
|
||||
options:
|
||||
- Chrome
|
||||
- Firefox
|
||||
- Safari
|
||||
- Edge
|
||||
- Firefox for Android
|
||||
- Chrome for Android
|
||||
- Safari on iOS
|
||||
- Other (list in "Additional Notes" box)
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: Please include any relevant logs here. This field is automatically formatted into code, so you do not need to include any backticks.
|
||||
placeholder: Paste logs here
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: additional-notes
|
||||
attributes:
|
||||
label: Additional Notes
|
||||
description: Anything else you want to add?
|
||||
placeholder: 'e.g. I have tried X, Y, and Z.'
|
||||
|
56
.github/ISSUE_TEMPLATE/feature.yml
vendored
56
.github/ISSUE_TEMPLATE/feature.yml
vendored
@ -1,17 +1,63 @@
|
||||
name: 🚀 Feature Request
|
||||
description: Request a feature/enhancement
|
||||
title: "[Enhancement]: "
|
||||
labels: ["enhancement"]
|
||||
title: '[Enhancement]: '
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "### Please first search in both issues & discussions for your enhancement."
|
||||
value: '#### *Mobile app features should be [requested here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)*.'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "### Mobile app features should be requested [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
|
||||
value: '## Web/Server Feature Request Description'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: 'Please first search in both issues & discussions for your enhancement.'
|
||||
- type: dropdown
|
||||
id: enhancment-type
|
||||
attributes:
|
||||
label: Type of Enhancement
|
||||
options:
|
||||
- Server Backend
|
||||
- Web Interface/Frontend
|
||||
- Documentation
|
||||
- type: textarea
|
||||
id: describe
|
||||
attributes:
|
||||
label: Describe the feature/enhancement
|
||||
label: Describe the Feature/Enhancement
|
||||
description: Please help us understand what you want.
|
||||
placeholder: What is your vision?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: the-why
|
||||
attributes:
|
||||
label: Why would this be helpful?
|
||||
description: Please help us understand why this would enhance your experience.
|
||||
placeholder: Explain the "why" or "use case".
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: image
|
||||
attributes:
|
||||
label: Future Implementation (Screenshot)
|
||||
description: Please help us visualize by including a doodle or screenshot.
|
||||
placeholder: How could this look?
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: '## Web/Server Current Implementation'
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Audiobookshelf Server Version
|
||||
description: Do not put 'Latest version', please put your current version number here
|
||||
placeholder: 'e.g. v1.6.60'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: current-image
|
||||
attributes:
|
||||
label: Current Implementation (Screenshot)
|
||||
description: What page were you looking at when you thought of this enhancement?
|
||||
placeholder: If an image is not applicable, please explain why.
|
||||
|
65
.github/workflows/codeql.yml
vendored
Normal file
65
.github/workflows/codeql.yml
vendored
Normal file
@ -0,0 +1,65 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ 'master' ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ 'master' ]
|
||||
schedule:
|
||||
- cron: '16 5 * * 4'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
30
.github/workflows/i18n-integration.yml
vendored
Normal file
30
.github/workflows/i18n-integration.yml
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
name: Verify all i18n files are alphabetized
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- client/strings/** # Should only check if any strings changed
|
||||
push:
|
||||
paths:
|
||||
- client/strings/** # Should only check if any strings changed
|
||||
|
||||
jobs:
|
||||
update_translations:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Check out the repository
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Set up node to run the javascript
|
||||
- name: Set up node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
# The only argument is the `directory`, which is where the i18n files are
|
||||
# stored.
|
||||
- name: Run Update JSON Files action
|
||||
uses: audiobookshelf/audiobookshelf-i18n-updater@v1.2.0
|
||||
with:
|
||||
directory: "client/strings/" # Adjust the directory path as needed
|
17
.github/workflows/notify-abs-windows.yml
vendored
Normal file
17
.github/workflows/notify-abs-windows.yml
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
name: Dispatch an abs-windows event
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
abs-windows-dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send a remote repository dispatch event
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.ABS_WINDOWS_PAT }}
|
||||
repository: mikiher/audiobookshelf-windows
|
||||
event-type: build-windows
|
37
.github/workflows/unit-tests.yml
vendored
Normal file
37
.github/workflows/unit-tests.yml
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
name: Run Unit Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Branch/Tag/SHA to test'
|
||||
required: true
|
||||
pull_request:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
run-unit-tests:
|
||||
name: Run Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout (push/pull request)
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
|
||||
- name: Checkout (workflow_dispatch)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -19,3 +19,4 @@
|
||||
sw.*
|
||||
.DS_STORE
|
||||
.idea/*
|
||||
tailwind.compiled.css
|
17
.prettierrc
Normal file
17
.prettierrc
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 400,
|
||||
"proseWrap": "never",
|
||||
"trailingComma": "none",
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.html"],
|
||||
"options": {
|
||||
"singleQuote": false,
|
||||
"wrapAttributes": false,
|
||||
"sortAttributes": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
7
.vscode/extensions.json
vendored
Normal file
7
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode",
|
||||
"octref.vetur"
|
||||
]
|
||||
}
|
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@ -17,5 +17,11 @@
|
||||
"editor.formatOnSave": true,
|
||||
"editor.detectIndentation": true,
|
||||
"editor.tabSize": 2,
|
||||
"javascript.format.semicolons": "remove"
|
||||
"javascript.format.semicolons": "remove",
|
||||
"[javascript][json][jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "octref.vetur"
|
||||
}
|
||||
}
|
@ -50,9 +50,8 @@ echo "$controlfile" > dist/debian/DEBIAN/control;
|
||||
# Package debian
|
||||
pkg -t node18-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
||||
|
||||
fakeroot dpkg-deb --build dist/debian
|
||||
fakeroot dpkg-deb -Zxz --build dist/debian
|
||||
|
||||
mv dist/debian.deb "dist/$OUTPUT_FILE"
|
||||
chmod +x "dist/$OUTPUT_FILE"
|
||||
|
||||
echo "Finished! Filename: $OUTPUT_FILE"
|
||||
|
@ -30,8 +30,7 @@
|
||||
}
|
||||
|
||||
.bookshelf-row {
|
||||
/* Sidebar width + scrollbar width */
|
||||
width: calc(100vw - 88px);
|
||||
width: calc(100vw - (100vw - 100%));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
@ -24,6 +24,7 @@
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.material-icons:not([class*="text-"]) {
|
||||
|
@ -4,14 +4,14 @@
|
||||
<widgets-cover-size-widget class="fixed right-4 z-50" :style="{ bottom: streamLibraryItem ? '181px' : '16px' }" />
|
||||
|
||||
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||
<p class="text-center text-2xl mb-4 py-4">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
|
||||
<div v-if="userIsAdminOrUp" class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">{{ $strings.ButtonConfigureScanner }}</ui-btn>
|
||||
<ui-btn color="success" class="w-52" :loading="isScanningLibrary || tempIsScanning" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
|
||||
<p class="text-center text-xl py-4">No results for query</p>
|
||||
<p class="text-center text-xl py-4">{{ $strings.MessageBookshelfNoResultsForQuery }}</p>
|
||||
</div>
|
||||
<!-- Alternate plain view -->
|
||||
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
||||
@ -58,7 +58,8 @@ export default {
|
||||
scannerParseSubtitle: false,
|
||||
wrapperClientWidth: 0,
|
||||
shelves: [],
|
||||
lastItemIndexSelected: -1
|
||||
lastItemIndexSelected: -1,
|
||||
tempIsScanning: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@ -97,6 +98,9 @@ export default {
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
isScanningLibrary() {
|
||||
return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.currentLibraryId)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@ -273,14 +277,15 @@ export default {
|
||||
this.shelves = shelves
|
||||
},
|
||||
scan() {
|
||||
this.tempIsScanning = true
|
||||
this.$store
|
||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
||||
.then(() => {
|
||||
this.$toast.success('Library scan started')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to start scan', error)
|
||||
this.$toast.error('Failed to start scan')
|
||||
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
|
||||
})
|
||||
.finally(() => {
|
||||
this.tempIsScanning = false
|
||||
})
|
||||
},
|
||||
userUpdated(user) {
|
||||
|
@ -4,7 +4,7 @@
|
||||
<div class="w-full h-full pt-6">
|
||||
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
|
||||
<template v-for="(entity, index) in shelf.entities">
|
||||
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" :continue-listening-shelf="continueListeningShelf" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editItem" />
|
||||
<cards-lazy-book-card :key="`${entity.id}-${index}`" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" :continue-listening-shelf="continueListeningShelf" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editItem" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
||||
|
@ -98,6 +98,9 @@
|
||||
<template v-else-if="page === 'authors'">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="userCanUpdate && authors && authors.length && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
|
||||
|
||||
<!-- author sort select -->
|
||||
<controls-sort-select v-if="authors && 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" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@ -183,6 +186,30 @@ export default {
|
||||
}
|
||||
]
|
||||
},
|
||||
authorSortItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelAuthorFirstLast,
|
||||
value: 'name'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAuthorLastFirst,
|
||||
value: 'lastFirst'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelNumberOfBooks,
|
||||
value: 'numBooks'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAddedAt,
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelUpdatedAt,
|
||||
value: 'updatedAt'
|
||||
}
|
||||
]
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
@ -455,6 +482,9 @@ export default {
|
||||
updateCollapseBookSeries() {
|
||||
this.saveSettings()
|
||||
},
|
||||
updateAuthorSort() {
|
||||
this.saveSettings()
|
||||
},
|
||||
saveSettings() {
|
||||
this.$store.dispatch('user/updateUserSettings', this.settings)
|
||||
},
|
||||
|
@ -10,7 +10,7 @@
|
||||
<p class="text-center text-2xl mb-4 py-4">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
|
||||
<div v-if="userIsAdminOrUp" class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">{{ $strings.ButtonConfigureScanner }}</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>
|
||||
<ui-btn color="success" class="w-52" :loading="isScanningLibrary || tempIsScanning" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!totalShelves && initialized" class="w-full py-16">
|
||||
@ -62,7 +62,8 @@ export default {
|
||||
currScrollTop: 0,
|
||||
resizeTimeout: null,
|
||||
mountWindowWidth: 0,
|
||||
lastItemIndexSelected: -1
|
||||
lastItemIndexSelected: -1,
|
||||
tempIsScanning: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -208,6 +209,9 @@ export default {
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
isScanningLibrary() {
|
||||
return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.currentLibraryId)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@ -727,14 +731,15 @@ export default {
|
||||
}
|
||||
},
|
||||
scan() {
|
||||
this.tempIsScanning = true
|
||||
this.$store
|
||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
|
||||
.then(() => {
|
||||
this.$toast.success('Library scan started')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to start scan', error)
|
||||
this.$toast.error('Failed to start scan')
|
||||
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
|
||||
})
|
||||
.finally(() => {
|
||||
this.tempIsScanning = false
|
||||
})
|
||||
}
|
||||
},
|
||||
@ -775,4 +780,4 @@ export default {
|
||||
background: var(--bookshelf-divider-bg);
|
||||
box-shadow: 2px 14px 8px #111111aa;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
@ -1,25 +1,25 @@
|
||||
<template>
|
||||
<div v-if="streamLibraryItem" id="mediaPlayerContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
|
||||
<div v-if="streamLibraryItem" id="mediaPlayerContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 lg:h-40 z-50 bg-primary px-2 lg:px-4 pb-1 lg:pb-4 pt-2">
|
||||
<div id="videoDock" />
|
||||
<div class="absolute left-2 top-2 md:left-4 cursor-pointer">
|
||||
<div class="absolute left-2 top-2 lg:left-4 cursor-pointer">
|
||||
<covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||
</div>
|
||||
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
||||
<div class="min-w-0">
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
|
||||
{{ title }}
|
||||
</nuxt-link>
|
||||
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
|
||||
<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="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">
|
||||
{{ title }}
|
||||
</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">
|
||||
<span class="material-icons text-sm">person</span>
|
||||
<div class="flex items-center">
|
||||
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
|
||||
<div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
|
||||
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</div>
|
||||
<div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</div>
|
||||
<widgets-explicit-indicator :explicit="isExplicit"></widgets-explicit-indicator>
|
||||
<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">, </span></nuxt-link>
|
||||
</div>
|
||||
<div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</div>
|
||||
</div>
|
||||
|
||||
<div class="text-gray-400 flex items-center">
|
||||
@ -29,7 +29,7 @@
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-tooltip direction="top" :text="$strings.LabelClosePlayer">
|
||||
<button :aria-label="$strings.LabelClosePlayer" class="material-icons sm:px-2 py-1 md:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</button>
|
||||
<button :aria-label="$strings.LabelClosePlayer" class="material-icons sm:px-2 py-1 lg:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</button>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<player-ui
|
||||
@ -82,13 +82,11 @@ export default {
|
||||
sleepTimer: null,
|
||||
displayTitle: null,
|
||||
currentPlaybackRate: 1,
|
||||
syncFailedToast: null
|
||||
syncFailedToast: null,
|
||||
coverAspectRatio: 1
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
isSquareCover() {
|
||||
return this.coverAspectRatio === 1
|
||||
},
|
||||
@ -138,7 +136,7 @@ export default {
|
||||
return this.streamLibraryItem?.mediaType === 'music'
|
||||
},
|
||||
isExplicit() {
|
||||
return this.mediaMetadata.explicit || false
|
||||
return !!this.mediaMetadata.explicit
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
@ -457,6 +455,9 @@ export default {
|
||||
episodeId,
|
||||
queueItems: payload.queueItems || []
|
||||
})
|
||||
// Set cover aspect ratio for this item's library since the library may change
|
||||
this.coverAspectRatio = this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
||||
})
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-2 sm:p-4 mb-8">
|
||||
<div class="flex items-center mb-2">
|
||||
<slot name="header-prefix"></slot>
|
||||
<h1 class="text-xl">{{ headerText }}</h1>
|
||||
|
@ -1,35 +1,35 @@
|
||||
<template>
|
||||
<nuxt-link :to="`/author/${author.id}`">
|
||||
<div @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
<nuxt-link :to="`/author/${author?.id}`">
|
||||
<div cy-id="card" :style="{ width: width + 'px'}" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div cy-id="imageArea" :style="{ height: height + 'px' }" class=" bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
<!-- Image or placeholder -->
|
||||
<covers-author-image :author="author" />
|
||||
<covers-author-image :author="author"/>
|
||||
|
||||
<!-- Author name & num books overlay -->
|
||||
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
||||
<div cy-id="textInline" v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} {{ $strings.LabelBooks }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Search icon btn -->
|
||||
<div v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="searchAuthor">
|
||||
<div cy-id="match" v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="searchAuthor">
|
||||
<ui-tooltip :text="$strings.ButtonQuickMatch" direction="bottom">
|
||||
<span class="material-icons text-lg">search</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)">
|
||||
<div cy-id="edit" v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)">
|
||||
<ui-tooltip :text="$strings.LabelEdit" direction="bottom">
|
||||
<span class="material-icons text-lg">edit</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Loading spinner -->
|
||||
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<div cy-id="spinner" v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<widgets-loading-spinner size="" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="nameBelow" class="w-full py-1 px-2">
|
||||
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
<div cy-id="nameBelow" v-show="nameBelow" class="w-full py-1 px-2">
|
||||
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
|
@ -13,9 +13,9 @@
|
||||
<div class="flex-grow" />
|
||||
<p class="text-sm md:text-base">{{ book.publishedYear }}</p>
|
||||
</div>
|
||||
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">by {{ book.author }}</p>
|
||||
<p v-if="book.narrator" class="text-gray-400 text-xs">Narrated by {{ book.narrator }}</p>
|
||||
<p v-if="book.duration" class="text-gray-400 text-xs">Runtime: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</p>
|
||||
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">{{ $getString('LabelByAuthor', [book.author]) }}</p>
|
||||
<p v-if="book.narrator" class="text-gray-400 text-xs">{{ $strings.LabelNarrators }}: {{ book.narrator }}</p>
|
||||
<p v-if="book.duration" class="text-gray-400 text-xs">{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</p>
|
||||
<div v-if="book.series?.length" class="flex py-1 -mx-1">
|
||||
<div v-for="(series, index) in book.series" :key="index" class="bg-white bg-opacity-10 rounded-full px-1 py-0.5 mx-1">
|
||||
<p class="leading-3 text-xs text-gray-400">
|
||||
@ -29,9 +29,9 @@
|
||||
</div>
|
||||
<div v-else class="px-4 flex-grow">
|
||||
<h1>
|
||||
<div class="flex items-center">{{ book.title }}<widgets-explicit-indicator :explicit="book.explicit" /></div>
|
||||
<div class="flex items-center">{{ book.title }}<widgets-explicit-indicator v-if="book.explicit" /></div>
|
||||
</h1>
|
||||
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p>
|
||||
<p class="text-base text-gray-300 whitespace-nowrap truncate">{{ $getString('LabelByAuthor', [book.author]) }}</p>
|
||||
<p v-if="book.genres" class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ book.trackCount }} Episodes</p>
|
||||
</div>
|
||||
@ -75,11 +75,11 @@ export default {
|
||||
let differenceInMinutes = currentBookDurationMinutes - this.book.duration
|
||||
if (differenceInMinutes < 0) {
|
||||
differenceInMinutes = Math.abs(differenceInMinutes)
|
||||
return `(${this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)} shorter)`
|
||||
return this.$getString('LabelDurationComparisonLonger', [this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)])
|
||||
} else if (differenceInMinutes > 0) {
|
||||
return `(${this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)} longer)`
|
||||
return this.$getString('LabelDurationComparisonShorter', [this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)])
|
||||
}
|
||||
return '(exact match)'
|
||||
return this.$strings.LabelDurationComparisonExactMatch
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -7,7 +7,7 @@
|
||||
|
||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300" v-html="matchHtml" />
|
||||
|
||||
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
|
||||
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">{{ $getString('LabelByAuthor', [authorName]) }}</p>
|
||||
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
||||
|
||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||
@ -69,7 +69,7 @@ export default {
|
||||
if (this.matchKey === 'episode') return `<p class="truncate">${this.$strings.LabelEpisode}: ${html}</p>`
|
||||
if (this.matchKey === 'tags') return `<p class="truncate">${this.$strings.LabelTags}: ${html}</p>`
|
||||
if (this.matchKey === 'subtitle') return `<p class="truncate">${html}</p>`
|
||||
if (this.matchKey === 'authors') return `by ${html}`
|
||||
if (this.matchKey === 'authors') this.$getString('LabelByAuthor', [html])
|
||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||
if (this.matchKey === 'series') return `<p class="truncate">${this.$strings.LabelSeries}: ${html}</p>`
|
||||
@ -90,4 +90,4 @@ export default {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
@ -21,15 +21,16 @@
|
||||
<div v-if="!isPodcast" class="flex items-end">
|
||||
<ui-text-input-with-label v-model.trim="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
|
||||
<ui-tooltip :text="$strings.LabelUploaderItemFetchMetadataHelp">
|
||||
<div
|
||||
class="ml-2 mb-1 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer"
|
||||
@click="fetchMetadata">
|
||||
<div class="ml-2 mb-1 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer" @click="fetchMetadata">
|
||||
<span class="text-base text-white text-opacity-80 font-mono material-icons">sync</span>
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-else class="w-full">
|
||||
<p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p>
|
||||
<p class="px-1 text-sm font-semibold">
|
||||
{{ $strings.LabelDirectory }}
|
||||
<em class="font-normal text-xs pl-2">(auto)</em>
|
||||
</p>
|
||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
@ -40,7 +41,10 @@
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<div class="w-full">
|
||||
<label class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></label>
|
||||
<label class="px-1 text-sm font-semibold">
|
||||
{{ $strings.LabelDirectory }}
|
||||
<em class="font-normal text-xs pl-2">(auto)</em>
|
||||
</label>
|
||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs h-10" />
|
||||
</div>
|
||||
</div>
|
||||
@ -51,10 +55,10 @@
|
||||
<tables-uploaded-files-table v-if="item.ignoredFiles.length" :title="$strings.HeaderIgnoredFiles" :files="item.ignoredFiles" />
|
||||
</template>
|
||||
<widgets-alert v-if="uploadSuccess" type="success">
|
||||
<p class="text-base">{{ $strings.MessageUploaderItemSuccess }}</p>
|
||||
<p class="text-base">"{{ itemData.title }}" {{ $strings.MessageUploaderItemSuccess }}</p>
|
||||
</widgets-alert>
|
||||
<widgets-alert v-if="uploadFailed" type="error">
|
||||
<p class="text-base">{{ $strings.MessageUploaderItemFailed }}</p>
|
||||
<p class="text-base">"{{ itemData.title }}" {{ $strings.MessageUploaderItemFailed }}</p>
|
||||
</widgets-alert>
|
||||
|
||||
<div v-if="isNonInteractable" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
|
||||
@ -70,7 +74,7 @@ export default {
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
default: () => { }
|
||||
default: () => {}
|
||||
},
|
||||
mediaType: String,
|
||||
processing: Boolean,
|
||||
@ -99,7 +103,7 @@ export default {
|
||||
if (this.isPodcast) return this.itemData.title
|
||||
|
||||
const outputPathParts = [this.itemData.author, this.itemData.series, this.itemData.title]
|
||||
const cleanedOutputPathParts = outputPathParts.filter(Boolean).map(part => this.$sanitizeFilename(part))
|
||||
const cleanedOutputPathParts = outputPathParts.filter(Boolean).map((part) => this.$sanitizeFilename(part))
|
||||
|
||||
return Path.join(...cleanedOutputPathParts)
|
||||
},
|
||||
|
@ -1,128 +1,124 @@
|
||||
<template>
|
||||
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 bg-primary cursor-pointer box-shadow-book" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="absolute rounded-sm z-10 bg-primary cursor-pointer box-shadow-book" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<!-- When cover image does not fill -->
|
||||
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
||||
<div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
||||
<div class="absolute cover-bg" ref="coverBg" />
|
||||
</div>
|
||||
|
||||
<!-- Alternative bookshelf title/author/sort -->
|
||||
<div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<div cy-id="detailBottom" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||
<p ref="displayTitle" class="truncate">{{ displayTitle }}</p>
|
||||
<widgets-explicit-indicator :explicit="isExplicit" />
|
||||
<p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p>
|
||||
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
<p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
||||
<p cy-id="line3" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</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 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #78350f">
|
||||
<div cy-id="seriesSequenceList" v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #78350f">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequenceList }}</p>
|
||||
</div>
|
||||
<div v-else-if="booksInSeries" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #cd9d49dd">
|
||||
<div cy-id="booksInSeries" v-else-if="booksInSeries" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #cd9d49dd">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ booksInSeries }}</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
||||
<div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
|
||||
<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: sizeMultiplier * 0.5 + 'rem' }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="text-gray-300 text-center">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Cover Image -->
|
||||
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="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-show="libraryItem" ref="cover" :src="bookCoverSrc" class="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 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 + 'rem' }">
|
||||
<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 + 'rem' }">
|
||||
<div>
|
||||
<p class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">
|
||||
{{ titleCleaned }}
|
||||
</p>
|
||||
<p cy-id="placeholderTitleText" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
|
||||
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||
<div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
|
||||
<p cy-id="placeholderAuthorText" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No progress shown for collapsed series in library and podcasts (unless showing podcast episode) -->
|
||||
<div v-if="!booksInSeries && (!isPodcast || episodeProgress)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
<!-- Finished progress bar for collapsed series -->
|
||||
<div v-else-if="booksInSeries && seriesIsFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
<!-- No progress shown for podcasts (unless showing podcast episode) -->
|
||||
<div cy-id="progressBar" v-if="!isPodcast || episodeProgress" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<!-- Overlay is not shown if collapsing series in library -->
|
||||
<div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen) && !processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
|
||||
<div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div cy-id="overlay" v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen) && !processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded md:block" :class="overlayWrapperClasslist">
|
||||
<div cy-id="playButton" v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
|
||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="showReadButton" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div cy-id="readButton" v-show="showReadButton" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="clickReadEBook">
|
||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">auto_stories</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-150 top-0 right-0" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
||||
<div cy-id="editButton" v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-150 top-0 right-0" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||
</div>
|
||||
|
||||
<!-- Radio button -->
|
||||
<div v-if="!isAuthorBookshelfView" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick">
|
||||
<div cy-id="selectedRadioButton" v-if="!isAuthorBookshelfView" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick">
|
||||
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- More Menu Icon -->
|
||||
<div ref="moreIcon" v-show="!isSelectionMode && moreMenuItems.length" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
||||
<div cy-id="moreButton" ref="moreIcon" v-show="!isSelectionMode && moreMenuItems.length" class="md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
||||
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ebookFormat" class="absolute" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }">
|
||||
<div cy-id="ebookFormat" v-if="ebookFormat" class="absolute" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }">
|
||||
<span class="text-white/80" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ ebookFormat }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing/loading spinner overlay -->
|
||||
<div v-if="processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 rounded flex items-center justify-center">
|
||||
<div cy-id="loadingSpinner" v-if="processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 rounded flex items-center justify-center">
|
||||
<widgets-loading-spinner size="la-lg" />
|
||||
</div>
|
||||
|
||||
<!-- Series name overlay -->
|
||||
<div v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
|
||||
<div cy-id="seriesNameOverlay" v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
|
||||
<p v-if="seriesName" class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ seriesName }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Error widget -->
|
||||
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0 z-10">
|
||||
<ui-tooltip cy-id="ErrorTooltip" v-if="showError" :text="errorText" class="absolute bottom-4 left-0 z-10">
|
||||
<div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
||||
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
|
||||
<div v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }">
|
||||
<div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.5 + 'rem' }">rss_feed</span>
|
||||
</div>
|
||||
|
||||
<!-- Series sequence -->
|
||||
<div v-if="seriesSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<div cy-id="seriesSequence" v-if="seriesSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Episode # -->
|
||||
<div v-if="recentEpisodeNumber !== null && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<div cy-id="podcastEpisodeNumber" v-if="recentEpisodeNumber !== null && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
|
||||
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
<div v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<div 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 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
<div v-else-if="numEpisodesIncomplete && !isHovering && !isSelectionMode" class="absolute rounded-full bg-yellow-400 text-black font-semibold box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<div cy-id="numEpisodesIncomplete" v-else-if="numEpisodesIncomplete && !isHovering && !isSelectionMode" class="absolute rounded-full bg-yellow-400 text-black font-semibold box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -343,11 +339,22 @@ export default {
|
||||
if (!this.userProgress || this.userProgress.progress) return false
|
||||
return this.userProgress.ebookProgress > 0
|
||||
},
|
||||
seriesProgressPercent() {
|
||||
if (!this.libraryItemIdsInSeries.length) return 0
|
||||
let progressPercent = 0
|
||||
const useEBookProgress = this.useEBookProgress
|
||||
this.libraryItemIdsInSeries.forEach((lid) => {
|
||||
const progress = this.store.getters['user/getUserMediaProgress'](lid)
|
||||
if (progress) progressPercent += progress.isFinished ? 1 : useEBookProgress ? progress.ebookProgress || 0 : progress.progress || 0
|
||||
})
|
||||
return progressPercent / this.libraryItemIdsInSeries.length
|
||||
},
|
||||
userProgressPercent() {
|
||||
if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0)
|
||||
return this.userProgress ? Math.max(Math.min(1, this.userProgress.progress), 0) || 0 : 0
|
||||
let progressPercent = this.itemIsFinished ? 1 : this.booksInSeries ? this.seriesProgressPercent : this.useEBookProgress ? this.userProgress?.ebookProgress || 0 : this.userProgress?.progress || 0
|
||||
return Math.max(Math.min(1, progressPercent), 0)
|
||||
},
|
||||
itemIsFinished() {
|
||||
if (this.booksInSeries) return this.seriesIsFinished
|
||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||
},
|
||||
seriesIsFinished() {
|
||||
@ -358,7 +365,7 @@ export default {
|
||||
},
|
||||
showError() {
|
||||
if (this.recentEpisode) return false // Dont show podcast error on episode card
|
||||
return this.numInvalidAudioFiles || this.numMissingParts || this.isMissing || this.isInvalid
|
||||
return this.isMissing || this.isInvalid
|
||||
},
|
||||
libraryItemIdStreaming() {
|
||||
return this.store.getters['getLibraryItemIdStreaming']
|
||||
@ -388,29 +395,13 @@ export default {
|
||||
isInvalid() {
|
||||
return this._libraryItem.isInvalid
|
||||
},
|
||||
numMissingParts() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numMissingParts
|
||||
},
|
||||
numInvalidAudioFiles() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numInvalidAudioFiles
|
||||
},
|
||||
errorText() {
|
||||
if (this.isMissing) return 'Item directory is missing!'
|
||||
else if (this.isInvalid) {
|
||||
if (this.isPodcast) return 'Podcast has no episodes'
|
||||
return 'Item has no audio tracks & ebook'
|
||||
}
|
||||
let txt = ''
|
||||
if (this.numMissingParts) {
|
||||
txt += `${this.numMissingParts} missing parts.`
|
||||
}
|
||||
if (this.numInvalidAudioFiles) {
|
||||
if (txt) txt += ' '
|
||||
txt += `${this.numInvalidAudioFiles} invalid audio files.`
|
||||
}
|
||||
return txt || 'Unknown Error'
|
||||
return 'Unknown Error'
|
||||
},
|
||||
overlayWrapperClasslist() {
|
||||
const classes = []
|
||||
|
@ -1,28 +1,28 @@
|
||||
<template>
|
||||
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div cy-id="card" ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<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">
|
||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
|
||||
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
|
||||
<div cy-id="seriesLengthMarker" class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
|
||||
|
||||
<div v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||
<div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||
|
||||
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||
<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: `${sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
||||
</div>
|
||||
|
||||
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
|
||||
<span cy-id="rssFeedMarker" v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
|
||||
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
|
||||
<div cy-id="standardBottomText" v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
|
||||
<p cy-id="standardBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
<div cy-id="detailBottomText" v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p cy-id="detailBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
||||
<p cy-id="detailBottomSortLine" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -119,9 +119,13 @@ export default {
|
||||
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
|
||||
},
|
||||
seriesPercentInProgress() {
|
||||
let totalFinishedAndInProgress = this.seriesBooksFinished.length
|
||||
if (this.hasSeriesBookInProgress) totalFinishedAndInProgress += 1
|
||||
return Math.min(1, Math.max(0, totalFinishedAndInProgress / this.books.length))
|
||||
if (!this.books.length) return 0
|
||||
let progressPercent = 0
|
||||
this.seriesBookProgress.forEach((progress) => {
|
||||
progressPercent += progress.isFinished ? 1 : progress.progress || 0
|
||||
})
|
||||
progressPercent /= this.books.length
|
||||
return Math.min(1, Math.max(0, progressPercent))
|
||||
},
|
||||
isSeriesFinished() {
|
||||
return this.books.length === this.seriesBooksFinished.length
|
||||
|
@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
|
||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(name)}`">
|
||||
<div cy-id="card" :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
<div class="absolute inset-0 w-full h-full flex items-center justify-center pointer-events-none opacity-40">
|
||||
<span class="material-icons-outlined text-[10rem]">record_voice_over</span>
|
||||
</div>
|
||||
|
||||
<!-- Narrator name & num books overlay -->
|
||||
<div class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||
<p cy-id="name" class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
<p cy-id="numBooks" class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
@ -21,8 +21,14 @@ export default {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
width: Number,
|
||||
height: Number,
|
||||
width: {
|
||||
type: Number,
|
||||
default: 150
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 100
|
||||
},
|
||||
sizeMultiplier: {
|
||||
type: Number,
|
||||
default: 1
|
||||
|
@ -89,6 +89,14 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="language" 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.LabelLanguage }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<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 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>
|
||||
@ -182,6 +190,9 @@ export default {
|
||||
narrators() {
|
||||
return this.mediaMetadata.narrators || []
|
||||
},
|
||||
language() {
|
||||
return this.mediaMetadata.language || null
|
||||
},
|
||||
durationPretty() {
|
||||
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)
|
||||
|
||||
|
@ -37,12 +37,12 @@
|
||||
<span class="material-icons text-2xl">arrow_left</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-normal block truncate">Back</span>
|
||||
<span class="font-normal block truncate">{{ $strings.ButtonBack }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
|
||||
<span class="font-normal block truncate py-2">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<template v-for="item in sublistItems">
|
||||
@ -106,31 +106,37 @@ export default {
|
||||
},
|
||||
{
|
||||
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.LabelAuthor,
|
||||
textPlural: this.$strings.LabelAuthors,
|
||||
value: 'authors',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelNarrator,
|
||||
textPlural: this.$strings.LabelNarrators,
|
||||
value: 'narrators',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelPublisher,
|
||||
textPlural: this.$strings.LabelPublishers,
|
||||
value: 'publishers',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLanguage,
|
||||
textPlural: this.$strings.LabelLanguages,
|
||||
value: 'languages',
|
||||
sublist: true
|
||||
},
|
||||
@ -149,36 +155,43 @@ export default {
|
||||
},
|
||||
{
|
||||
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.LabelSeries,
|
||||
textPlural: this.$strings.LabelSeries,
|
||||
value: 'series',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAuthor,
|
||||
textPlural: this.$strings.LabelAuthors,
|
||||
value: 'authors',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelNarrator,
|
||||
textPlural: this.$strings.LabelNarrators,
|
||||
value: 'narrators',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelPublisher,
|
||||
textPlural: this.$strings.LabelPublishers,
|
||||
value: 'publishers',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLanguage,
|
||||
textPlural: this.$strings.LabelLanguages,
|
||||
value: 'languages',
|
||||
sublist: true
|
||||
},
|
||||
@ -227,14 +240,22 @@ export default {
|
||||
},
|
||||
{
|
||||
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.LabelLanguage,
|
||||
textPlural: this.$strings.LabelLanguages,
|
||||
value: 'languages',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.ButtonIssues,
|
||||
value: 'issues',
|
||||
@ -250,11 +271,13 @@ export default {
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelGenre,
|
||||
textPlural: this.$strings.LabelGenres,
|
||||
value: 'genres',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelTag,
|
||||
textPlural: this.$strings.LabelTags,
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
@ -274,6 +297,13 @@ export default {
|
||||
selectedItemSublist() {
|
||||
return this.selected?.includes('.') ? this.selected.split('.')[0] : null
|
||||
},
|
||||
selectedSublistText() {
|
||||
if (!this.sublist) {
|
||||
return ''
|
||||
}
|
||||
const sublistItem = this.selectItems.find((i) => i.value === this.sublist)
|
||||
return sublistItem?.textPlural || sublistItem?.text || ''
|
||||
},
|
||||
selectedText() {
|
||||
if (!this.selected) return ''
|
||||
const parts = this.selected.split('.')
|
||||
@ -368,9 +398,17 @@ export default {
|
||||
id: 'ebook',
|
||||
name: this.$strings.LabelHasEbook
|
||||
},
|
||||
{
|
||||
id: 'no-ebook',
|
||||
name: this.$strings.LabelMissingEbook
|
||||
},
|
||||
{
|
||||
id: 'supplementary',
|
||||
name: this.$strings.LabelHasSupplementaryEbook
|
||||
},
|
||||
{
|
||||
id: 'no-supplementary',
|
||||
name: this.$strings.LabelMissingSupplementaryEbook
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -492,4 +530,4 @@ export default {
|
||||
.libraryFilterMenu {
|
||||
max-height: calc(100vh - 125px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
@ -84,4 +84,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
@ -101,9 +101,14 @@ export default {
|
||||
},
|
||||
fullCoverUrl() {
|
||||
if (!this.libraryItem) return null
|
||||
var store = this.$store || this.$nuxt.$store
|
||||
const store = this.$store || this.$nuxt.$store
|
||||
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl)
|
||||
},
|
||||
rawCoverUrl() {
|
||||
if (!this.libraryItem) return null
|
||||
const store = this.$store || this.$nuxt.$store
|
||||
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl, true)
|
||||
},
|
||||
cover() {
|
||||
return this.media.coverPath || this.placeholderUrl
|
||||
},
|
||||
@ -126,9 +131,6 @@ export default {
|
||||
authorBottom() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
resolution() {
|
||||
return `${this.naturalWidth}x${this.naturalHeight}px`
|
||||
}
|
||||
@ -136,7 +138,7 @@ export default {
|
||||
methods: {
|
||||
clickCover() {
|
||||
if (this.expandOnClick && this.libraryItem) {
|
||||
this.$store.commit('globals/setRawCoverPreviewModal', this.libraryItem.id)
|
||||
this.$store.commit('globals/setRawCoverPreviewModal', this.rawCoverUrl)
|
||||
}
|
||||
},
|
||||
setCoverBg() {
|
||||
|
@ -65,7 +65,7 @@ export default {
|
||||
return 0.8 * this.sizeMultiplier
|
||||
},
|
||||
resolution() {
|
||||
return `${this.naturalWidth}x${this.naturalHeight}px`
|
||||
return `${this.naturalWidth}×${this.naturalHeight}px`
|
||||
},
|
||||
placeholderUrl() {
|
||||
const config = this.$config || this.$nuxt.$config
|
||||
|
@ -10,21 +10,21 @@
|
||||
<div class="w-full p-8">
|
||||
<div class="flex py-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="newUser.username" :label="$strings.LabelUsername" />
|
||||
<ui-text-input-with-label v-model.trim="newUser.username" :label="$strings.LabelUsername" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? $strings.LabelPassword : $strings.LabelChangePassword" type="password" />
|
||||
<ui-text-input-with-label v-else v-model="newUser.email" :label="$strings.LabelEmail" />
|
||||
<ui-text-input-with-label v-else v-model.trim="newUser.email" :label="$strings.LabelEmail" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!isEditingRoot" class="flex py-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="newUser.email" :label="$strings.LabelEmail" />
|
||||
<ui-text-input-with-label v-model.trim="newUser.email" :label="$strings.LabelEmail" />
|
||||
</div>
|
||||
<div class="px-2 w-52">
|
||||
<ui-dropdown v-model="newUser.type" :label="$strings.LabelAccountType" :disabled="isEditingRoot" :items="accountTypes" small @input="userTypeUpdated" />
|
||||
</div>
|
||||
<!-- <div class="flex-grow" /> -->
|
||||
|
||||
<div class="flex items-center pt-4 px-2">
|
||||
<p class="px-3 font-semibold" id="user-enabled-toggle" :class="isEditingRoot ? 'text-gray-300' : ''">{{ $strings.LabelEnable }}</p>
|
||||
<ui-toggle-switch labeledBy="user-enabled-toggle" v-model="newUser.isActive" :disabled="isEditingRoot" />
|
||||
|
@ -34,11 +34,6 @@ export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
watch: {
|
||||
value(newVal) {
|
||||
this.$nextTick(this.scrollToChapter)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
@ -53,7 +48,7 @@ export default {
|
||||
return this.playbackRate
|
||||
},
|
||||
currentChapterId() {
|
||||
return this.currentChapter ? this.currentChapter.id : null
|
||||
return this.currentChapter?.id || null
|
||||
},
|
||||
currentChapterStart() {
|
||||
return (this.currentChapter?.start || 0) / this._playbackRate
|
||||
@ -74,6 +69,11 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
if (this.value) {
|
||||
this.$nextTick(this.scrollToChapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -8,7 +8,7 @@
|
||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||
<div class="flex items-center">
|
||||
<p class="text-base text-gray-200">{{ _session.displayTitle }}</p>
|
||||
<p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">by {{ _session.displayAuthor }}</p>
|
||||
<p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">{{ $getString('LabelByAuthor', [_session.displayAuthor]) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
@ -88,10 +88,11 @@
|
||||
<p class="mb-1">{{ _session.mediaPlayer }}</p>
|
||||
|
||||
<p v-if="hasDeviceInfo" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelDevice }}</p>
|
||||
<p v-if="clientDisplayName" class="mb-1">{{ clientDisplayName }}</p>
|
||||
<p v-if="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p>
|
||||
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
|
||||
<p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p>
|
||||
<p v-if="clientDisplayName" class="mb-1">{{ clientDisplayName }}</p>
|
||||
<p v-if="deviceDisplayName" class="mb-1">{{ deviceDisplayName }}</p>
|
||||
<p v-if="deviceInfo.sdkVersion" class="mb-1">SDK {{ $strings.LabelVersion }}: {{ deviceInfo.sdkVersion }}</p>
|
||||
<p v-if="deviceInfo.deviceType" class="mb-1">{{ $strings.LabelType }}: {{ deviceInfo.deviceType }}</p>
|
||||
</div>
|
||||
@ -141,10 +142,14 @@ export default {
|
||||
if (!this.deviceInfo.osName) return null
|
||||
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
|
||||
},
|
||||
clientDisplayName() {
|
||||
deviceDisplayName() {
|
||||
if (!this.deviceInfo.manufacturer || !this.deviceInfo.model) return null
|
||||
return `${this.deviceInfo.manufacturer} ${this.deviceInfo.model}`
|
||||
},
|
||||
clientDisplayName() {
|
||||
if (!this.deviceInfo.clientName) return null
|
||||
return `${this.deviceInfo.clientName} ${this.deviceInfo.clientVersion || ''}`
|
||||
},
|
||||
playMethodName() {
|
||||
const playMethod = this._session.playMethod
|
||||
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
|
||||
|
@ -20,14 +20,11 @@ export default {
|
||||
this.$store.commit('globals/setShowRawCoverPreviewModal', val)
|
||||
}
|
||||
},
|
||||
selectedLibraryItemId() {
|
||||
return this.$store.state.globals.selectedLibraryItemId
|
||||
},
|
||||
rawCoverUrl() {
|
||||
return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.selectedLibraryItemId, null, true)
|
||||
return this.$store.state.globals.selectedRawCoverUrl
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
@ -9,7 +9,7 @@
|
||||
<div class="flex">
|
||||
<div class="w-40 p-2">
|
||||
<div class="w-full h-45 relative">
|
||||
<covers-author-image :author="author" />
|
||||
<covers-author-image :author="authorCopy" />
|
||||
<div v-if="userCanDelete && !processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
|
||||
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
|
||||
</div>
|
||||
@ -30,9 +30,6 @@
|
||||
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="p-2">
|
||||
<ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" :label="$strings.LabelPhotoPathURL" />
|
||||
</div> -->
|
||||
<div class="p-2">
|
||||
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" :label="$strings.LabelDescription" :rows="8" />
|
||||
</div>
|
||||
@ -106,9 +103,9 @@ export default {
|
||||
methods: {
|
||||
init() {
|
||||
this.imageUrl = ''
|
||||
this.authorCopy.name = this.author.name
|
||||
this.authorCopy.asin = this.author.asin
|
||||
this.authorCopy.description = this.author.description
|
||||
this.authorCopy = {
|
||||
...this.author
|
||||
}
|
||||
},
|
||||
removeClick() {
|
||||
const payload = {
|
||||
@ -171,7 +168,9 @@ export default {
|
||||
.$delete(`/api/authors/${this.authorId}/image`)
|
||||
.then((data) => {
|
||||
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
|
||||
this.$store.commit('globals/showEditAuthorModal', data.author)
|
||||
|
||||
this.authorCopy.updatedAt = data.author.updatedAt
|
||||
this.authorCopy.imagePath = data.author.imagePath
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
@ -196,7 +195,9 @@ export default {
|
||||
.then((data) => {
|
||||
this.imageUrl = ''
|
||||
this.$toast.success('Author image updated')
|
||||
this.$store.commit('globals/showEditAuthorModal', data.author)
|
||||
|
||||
this.authorCopy.updatedAt = data.author.updatedAt
|
||||
this.authorCopy.imagePath = data.author.imagePath
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
@ -231,8 +232,11 @@ export default {
|
||||
} else if (response.updated) {
|
||||
if (response.author.imagePath) {
|
||||
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
|
||||
this.$store.commit('globals/showEditAuthorModal', response.author)
|
||||
} else this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
|
||||
|
||||
this.authorCopy = {
|
||||
...response.author
|
||||
}
|
||||
} else {
|
||||
this.$toast.info('No updates were made for Author')
|
||||
}
|
||||
@ -242,4 +246,4 @@ export default {
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
@ -122,7 +122,7 @@ export default {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to get collections', error)
|
||||
this.$toast.error('Failed to load collections')
|
||||
this.$toast.error(this.$strings.ToastFailedToLoadData)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
|
@ -46,7 +46,12 @@ export default {
|
||||
ereaderDevice: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
users: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
loadUsers: Function
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -56,8 +61,7 @@ export default {
|
||||
email: '',
|
||||
availabilityOption: 'adminAndUp',
|
||||
users: []
|
||||
},
|
||||
users: []
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -108,25 +112,13 @@ export default {
|
||||
methods: {
|
||||
availabilityOptionChanged(option) {
|
||||
if (option === 'specificUsers' && !this.users.length) {
|
||||
this.loadUsers()
|
||||
this.callLoadUsers()
|
||||
}
|
||||
},
|
||||
async loadUsers() {
|
||||
async callLoadUsers() {
|
||||
this.processing = true
|
||||
this.users = await this.$axios
|
||||
.$get('/api/users')
|
||||
.then((res) => {
|
||||
return res.users.sort((a, b) => {
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return []
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
await this.loadUsers()
|
||||
this.processing = false
|
||||
},
|
||||
submitForm() {
|
||||
this.$refs.ereaderNameInput.blur()
|
||||
@ -226,10 +218,6 @@ export default {
|
||||
this.newDevice.email = this.ereaderDevice.email
|
||||
this.newDevice.availabilityOption = this.ereaderDevice.availabilityOption || 'adminOrUp'
|
||||
this.newDevice.users = this.ereaderDevice.users || []
|
||||
|
||||
if (this.newDevice.availabilityOption === 'specificUsers' && !this.users.length) {
|
||||
this.loadUsers()
|
||||
}
|
||||
} else {
|
||||
this.newDevice.name = ''
|
||||
this.newDevice.email = ''
|
||||
|
@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div class="w-full mb-4">
|
||||
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" keep-open />
|
||||
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" keep-open @close="closeModal" />
|
||||
<div v-if="!chapters.length" class="py-4 text-center">
|
||||
<p class="mb-8 text-xl">{{ $strings.MessageNoChapters }}</p>
|
||||
<ui-btn v-if="userCanUpdate" :to="`/audiobook/${libraryItem.id}/chapters`">{{ $strings.ButtonAddChapters }}</ui-btn>
|
||||
<ui-btn v-if="userCanUpdate" :to="`/audiobook/${libraryItem.id}/chapters`" @click="clickAddChapters">{{ $strings.ButtonAddChapters }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -23,7 +23,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
return this.libraryItem?.media || {}
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
@ -32,6 +32,15 @@ export default {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {}
|
||||
methods: {
|
||||
closeModal() {
|
||||
this.$emit('close')
|
||||
},
|
||||
clickAddChapters() {
|
||||
if (this.$route.name === 'audiobook-id-chapters' && this.$route.params?.id === this.libraryItem?.id) {
|
||||
this.closeModal()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
|
||||
<div class="flex flex-wrap mb-4">
|
||||
<div class="relative">
|
||||
<div class="flex flex-col sm:flex-row mb-4">
|
||||
<div class="relative self-center">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, libraryItemUpdatedAt, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
|
||||
<!-- book cover overlay -->
|
||||
@ -14,7 +14,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
|
||||
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-6 md:mt-0">
|
||||
<div class="flex items-center">
|
||||
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 md:min-w-32">
|
||||
<ui-file-input ref="fileInput" @change="fileUploadSelected">
|
||||
@ -49,20 +49,20 @@
|
||||
</div>
|
||||
</div>
|
||||
<form @submit.prevent="submitSearchForm">
|
||||
<div class="flex items-center justify-start -mx-1 h-20">
|
||||
<div class="w-48 px-1">
|
||||
<div class="flex flex-wrap sm:flex-nowrap items-center justify-start -mx-1">
|
||||
<div class="w-48 flex-grow p-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
|
||||
</div>
|
||||
<div class="w-72 px-1">
|
||||
<div class="w-72 flex-grow p-1">
|
||||
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
|
||||
</div>
|
||||
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 px-1">
|
||||
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 flex-grow p-1">
|
||||
<ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
|
||||
</div>
|
||||
<ui-btn class="mt-5 ml-1" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
|
||||
<ui-btn class="mt-5 ml-1 md:min-w-24" :padding-x="4" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-80 overflow-y-scroll mt-2 max-w-full">
|
||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center sm:max-h-80 sm:overflow-y-scroll mt-2 max-w-full">
|
||||
<p v-if="!coversFound.length">{{ $strings.MessageNoCoversFound }}</p>
|
||||
<template v-for="cover in coversFound">
|
||||
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === coverPath ? 'border-yellow-300' : ''" @click="updateCover(cover)">
|
||||
|
@ -29,7 +29,7 @@
|
||||
<td class="text-center w-20 min-w-20">
|
||||
<p>{{ episode.episode }}</p>
|
||||
</td>
|
||||
<td>
|
||||
<td dir="auto">
|
||||
{{ episode.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
|
@ -32,7 +32,7 @@
|
||||
</div>
|
||||
<p class="text-xl pl-3">{{ $strings.HeaderUpdateDetails }}</p>
|
||||
</div>
|
||||
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
|
||||
<ui-checkbox v-model="selectAll" :label="$strings.LabelSelectAll" checkbox-bg="bg" @input="selectAllToggled" />
|
||||
<form @submit.prevent="submitMatchUpdate">
|
||||
<div v-if="selectedMatchOrig.cover" class="flex flex-wrap md:flex-nowrap items-center justify-center">
|
||||
<div class="flex flex-grow items-center py-2">
|
||||
@ -42,15 +42,15 @@
|
||||
|
||||
<div class="flex py-2">
|
||||
<div>
|
||||
<p class="text-center text-gray-200">New</p>
|
||||
<p class="text-center text-gray-200">{{ $strings.LabelNew }}</p>
|
||||
<a :href="selectedMatch.cover" target="_blank" class="bg-primary">
|
||||
<covers-preview-cover :src="selectedMatch.cover" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="media.coverPath">
|
||||
<p class="text-center text-gray-200">Current</p>
|
||||
<a :href="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" target="_blank" class="bg-primary">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div v-if="media.coverPath" class="ml-0.5">
|
||||
<p class="text-center text-gray-200">{{ $strings.LabelCurrent }}</p>
|
||||
<a :href="$store.getters['globals/getLibraryItemCoverSrc'](libraryItem, null, true)" target="_blank" class="bg-primary">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrc'](libraryItem, null, true)" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -79,7 +79,7 @@
|
||||
<div v-if="selectedMatchOrig.narrator" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.narrator" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.narrator" :disabled="!selectedMatchUsage.narrator" :label="$strings.LabelNarrators" />
|
||||
<ui-multi-select v-model="selectedMatch.narrator" :items="narrators" :disabled="!selectedMatchUsage.narrator" :label="$strings.LabelNarrators" />
|
||||
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.narratorName || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -122,7 +122,7 @@
|
||||
<div v-if="selectedMatchOrig.tags" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.tags" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.tags" :disabled="!selectedMatchUsage.tags" :label="$strings.LabelTags" />
|
||||
<ui-multi-select v-model="selectedMatch.tags" :items="tags" :disabled="!selectedMatchUsage.tags" :label="$strings.LabelTags" />
|
||||
<p v-if="media.tags" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ media.tags.join(', ') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -180,14 +180,14 @@
|
||||
<ui-checkbox v-model="selectedMatchUsage.explicit" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.explicit != null }">
|
||||
<ui-checkbox v-model="selectedMatch.explicit" :label="$strings.LabelExplicit" :disabled="!selectedMatchUsage.explicit" :checkbox-bg="!selectedMatchUsage.explicit ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
<p v-if="mediaMetadata.explicit != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.explicit ? 'Explicit (checked)' : 'Not Explicit (unchecked)' }}</p>
|
||||
<p v-if="mediaMetadata.explicit != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.explicit ? $strings.LabelExplicitChecked : $strings.LabelExplicitUnchecked }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.abridged != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.abridged == null }">
|
||||
<ui-checkbox v-model="selectedMatchUsage.abridged" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.abridged != null }">
|
||||
<ui-checkbox v-model="selectedMatch.abridged" :label="$strings.LabelAbridged" :disabled="!selectedMatchUsage.abridged" :checkbox-bg="!selectedMatchUsage.abridged ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
<p v-if="mediaMetadata.abridged != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.abridged ? 'Abridged (checked)' : 'Unabridged (unchecked)' }}</p>
|
||||
<p v-if="mediaMetadata.abridged != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.abridged ? $strings.LabelAbridgedChecked : $strings.LabelAbridgedUnchecked }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -280,6 +280,9 @@ export default {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
filterData() {
|
||||
return this.$store.state.libraries.filterData
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
@ -305,11 +308,16 @@ export default {
|
||||
isPodcast() {
|
||||
return this.mediaType == 'podcast'
|
||||
},
|
||||
narrators() {
|
||||
return this.filterData.narrators || []
|
||||
},
|
||||
genres() {
|
||||
const filterData = this.$store.state.libraries.filterData || {}
|
||||
const currentGenres = filterData.genres || []
|
||||
const currentGenres = this.filterData.genres || []
|
||||
const selectedMatchGenres = this.selectedMatch.genres || []
|
||||
return [...new Set([...currentGenres, ...selectedMatchGenres])]
|
||||
},
|
||||
tags() {
|
||||
return this.filterData.tags || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@ -479,6 +487,12 @@ export default {
|
||||
// match.genres = match.genres.join(',')
|
||||
match.genres = match.genres.split(',').map((g) => g.trim())
|
||||
}
|
||||
if (match.tags && !Array.isArray(match.tags)) {
|
||||
match.tags = match.tags.split(',').map((g) => g.trim())
|
||||
}
|
||||
if (match.narrator && !Array.isArray(match.narrator)) {
|
||||
match.narrator = match.narrator.split(',').map((g) => g.trim())
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Select Match', match)
|
||||
@ -508,7 +522,10 @@ export default {
|
||||
} else if (key === 'author' && !this.isPodcast) {
|
||||
var authors = this.selectedMatch[key]
|
||||
if (!Array.isArray(authors)) {
|
||||
authors = authors.split(',').map((au) => au.trim())
|
||||
authors = authors
|
||||
.split(',')
|
||||
.map((au) => au.trim())
|
||||
.filter((au) => !!au)
|
||||
}
|
||||
var authorPayload = []
|
||||
authors.forEach((authorName) =>
|
||||
@ -519,11 +536,11 @@ export default {
|
||||
)
|
||||
updatePayload.metadata.authors = authorPayload
|
||||
} else if (key === 'narrator') {
|
||||
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||
updatePayload.metadata.narrators = this.selectedMatch[key]
|
||||
} else if (key === 'genres') {
|
||||
updatePayload.metadata.genres = [...this.selectedMatch[key]]
|
||||
} else if (key === 'tags') {
|
||||
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||
updatePayload.tags = this.selectedMatch[key]
|
||||
} else if (key === 'itunesId') {
|
||||
updatePayload.metadata.itunesId = Number(this.selectedMatch[key])
|
||||
} else {
|
||||
@ -546,24 +563,11 @@ export default {
|
||||
// Persist in local storage
|
||||
localStorage.setItem('selectedMatchUsage', JSON.stringify(this.selectedMatchUsage))
|
||||
|
||||
if (updatePayload.metadata.cover) {
|
||||
const coverPayload = {
|
||||
url: updatePayload.metadata.cover
|
||||
}
|
||||
const success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
if (success) {
|
||||
this.$toast.success(this.$strings.ToastItemCoverUpdateSuccess)
|
||||
} else {
|
||||
this.$toast.error(this.$strings.ToastItemCoverUpdateFailed)
|
||||
}
|
||||
console.log('Updated cover')
|
||||
delete updatePayload.metadata.cover
|
||||
}
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
if (updatePayload.metadata.cover) {
|
||||
updatePayload.url = updatePayload.metadata.cover
|
||||
delete updatePayload.metadata.cover
|
||||
}
|
||||
const mediaUpdatePayload = updatePayload
|
||||
const updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
|
@ -20,7 +20,7 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
|
||||
<ui-text-input ref="maxEpisodesInput" type="number" v-model="newMaxNewEpisodesToDownload" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updateMaxNewEpisodesToDownload" />
|
||||
<ui-text-input ref="maxEpisodesToDownloadInput" type="number" v-model="newMaxNewEpisodesToDownload" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updateMaxNewEpisodesToDownload" />
|
||||
<ui-tooltip text="Value of 0 sets no max limit. When checking for new episodes this is the max number of episodes that will be downloaded.">
|
||||
<p class="pl-4 text-base">
|
||||
Max new episodes to download per check
|
||||
@ -129,9 +129,12 @@ export default {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.$refs.maxEpisodesInput && this.$refs.maxEpisodesInput.isFocused) {
|
||||
|
||||
if (this.$refs.maxEpisodesInput?.isFocused) {
|
||||
this.$refs.maxEpisodesInput.blur()
|
||||
return
|
||||
}
|
||||
if (this.$refs.maxEpisodesToDownloadInput?.isFocused) {
|
||||
this.$refs.maxEpisodesToDownloadInput.blur()
|
||||
}
|
||||
|
||||
const updatePayload = {
|
||||
@ -140,9 +143,11 @@ export default {
|
||||
if (this.enableAutoDownloadEpisodes) {
|
||||
updatePayload.autoDownloadSchedule = this.cronExpression
|
||||
}
|
||||
this.newMaxEpisodesToKeep = Number(this.newMaxEpisodesToKeep)
|
||||
if (this.newMaxEpisodesToKeep !== this.maxEpisodesToKeep) {
|
||||
updatePayload.maxEpisodesToKeep = this.newMaxEpisodesToKeep
|
||||
}
|
||||
this.newMaxNewEpisodesToDownload = Number(this.newMaxNewEpisodesToDownload)
|
||||
if (this.newMaxNewEpisodesToDownload !== this.maxNewEpisodesToDownload) {
|
||||
updatePayload.maxNewEpisodesToDownload = this.newMaxNewEpisodesToDownload
|
||||
}
|
||||
|
@ -127,6 +127,7 @@ export default {
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
autoScanCronExpression: null,
|
||||
hideSingleBookSeries: false,
|
||||
onlyShowLaterBooksInContinueSeries: false,
|
||||
metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
|
||||
}
|
||||
}
|
||||
|
@ -49,8 +49,30 @@
|
||||
</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 }}
|
||||
<span class="material-icons icon-text text-sm">info_outlined</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-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isPodcastLibrary" class="py-3">
|
||||
<ui-dropdown :label="$strings.LabelPodcastSearchRegion" v-model="podcastSearchRegion" :items="$podcastSearchRegionOptions" small class="max-w-52" @input="formUpdated" />
|
||||
<ui-dropdown :label="$strings.LabelPodcastSearchRegion" v-model="podcastSearchRegion" :items="$podcastSearchRegionOptions" small class="max-w-72" menu-max-height="200px" @input="formUpdated" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -72,7 +94,9 @@ export default {
|
||||
skipMatchingMediaWithAsin: false,
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
audiobooksOnly: false,
|
||||
epubsAllowScriptedContent: false,
|
||||
hideSingleBookSeries: false,
|
||||
onlyShowLaterBooksInContinueSeries: false,
|
||||
podcastSearchRegion: 'us'
|
||||
}
|
||||
},
|
||||
@ -106,7 +130,9 @@ export default {
|
||||
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
||||
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
|
||||
audiobooksOnly: !!this.audiobooksOnly,
|
||||
epubsAllowScriptedContent: !!this.epubsAllowScriptedContent,
|
||||
hideSingleBookSeries: !!this.hideSingleBookSeries,
|
||||
onlyShowLaterBooksInContinueSeries: !!this.onlyShowLaterBooksInContinueSeries,
|
||||
podcastSearchRegion: this.podcastSearchRegion
|
||||
}
|
||||
}
|
||||
@ -120,7 +146,9 @@ export default {
|
||||
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
|
||||
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
||||
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
|
||||
this.epubsAllowScriptedContent = !!this.librarySettings.epubsAllowScriptedContent
|
||||
this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries
|
||||
this.onlyShowLaterBooksInContinueSeries = !!this.librarySettings.onlyShowLaterBooksInContinueSeries
|
||||
this.podcastSearchRegion = this.librarySettings.podcastSearchRegion || 'us'
|
||||
}
|
||||
},
|
||||
@ -128,4 +156,4 @@ export default {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
@ -115,7 +115,7 @@ export default {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to get playlists', error)
|
||||
this.$toast.error('Failed to load user playlists')
|
||||
this.$toast.error(this.$strings.ToastFailedToLoadData)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
|
@ -15,8 +15,8 @@
|
||||
<p class="text-xs text-gray-300">{{ podcastAuthor }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-lg font-semibold mb-6">{{ title }}</p>
|
||||
<div v-if="description" class="default-style" v-html="description" />
|
||||
<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>
|
||||
</modals-modal>
|
||||
|
@ -1,22 +1,30 @@
|
||||
<template>
|
||||
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
|
||||
<div class="flex items-center pt-4 pb-2 lg:pt-0 lg:pb-2">
|
||||
<div class="flex-grow" />
|
||||
<template v-if="!loading">
|
||||
<button :aria-label="$strings.ButtonPreviousChapter" class="flex items-center justify-center text-gray-300 mr-4 md:mr-8" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter">
|
||||
<span class="material-icons text-2xl sm:text-3xl">first_page</span>
|
||||
</button>
|
||||
<button :aria-label="$strings.ButtonJumpBackward" class="flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||
<span class="material-icons text-2xl sm:text-3xl">replay_10</span>
|
||||
</button>
|
||||
<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 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
||||
<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-icons text-2xl sm:text-3xl">first_page</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<ui-tooltip direction="top" :text="$strings.ButtonJumpBackward">
|
||||
<button :aria-label="$strings.ButtonJumpBackward" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||
<span class="material-icons text-2xl sm:text-3xl">replay_10</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-icons text-2xl">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
||||
</button>
|
||||
<button :aria-label="$strings.ButtonJumpForward" class="flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||
<span class="material-icons text-2xl sm:text-3xl">forward_10</span>
|
||||
</button>
|
||||
<button :aria-label="$strings.ButtonNextChapter" class="flex items-center justify-center ml-4 md:ml-8" :disabled="!hasNextChapter" :class="hasNextChapter ? 'text-gray-300' : 'text-gray-500'" @mousedown.prevent @mouseup.prevent @click.stop="nextChapter">
|
||||
<span class="material-icons text-2xl sm:text-3xl">last_page</span>
|
||||
</button>
|
||||
<ui-tooltip direction="top" :text="$strings.ButtonJumpForward">
|
||||
<button :aria-label="$strings.ButtonJumpForward" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||
<span class="material-icons text-2xl sm:text-3xl">forward_10</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<ui-tooltip direction="top" :text="$strings.ButtonNextChapter" class="ml-4 lg:ml-8">
|
||||
<button :aria-label="$strings.ButtonNextChapter" :disabled="!hasNextChapter" :class="hasNextChapter ? 'text-gray-300' : 'text-gray-500'" @mousedown.prevent @mouseup.prevent @click.stop="nextChapter">
|
||||
<span class="material-icons 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>
|
||||
|
@ -57,7 +57,6 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
duration: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.setChapterTicks()
|
||||
}
|
||||
@ -205,10 +204,14 @@ export default {
|
||||
},
|
||||
windowResize() {
|
||||
this.setTrackWidth()
|
||||
this.setChapterTicks()
|
||||
this.updatePlayedTrackWidth()
|
||||
this.updateBufferTrack()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setTrackWidth()
|
||||
this.setChapterTicks()
|
||||
window.addEventListener('resize', this.windowResize)
|
||||
},
|
||||
beforeDestroy() {
|
||||
|
@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="w-full -mt-6">
|
||||
<div class="w-full relative mb-1">
|
||||
<div class="absolute -top-10 md:top-0 right-0 lg:right-2 flex items-center h-full">
|
||||
<div class="absolute -top-10 lg:top-0 right-0 lg:right-2 flex items-center h-full">
|
||||
<!-- <span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span> -->
|
||||
|
||||
<ui-tooltip direction="top" :text="$strings.LabelVolume">
|
||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
|
||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden sm:block" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip direction="top" :text="$strings.LabelSleepTimer">
|
||||
@ -53,7 +53,7 @@
|
||||
<p class="font-mono text-sm hidden sm:block text-gray-100 pointer-events-auto"> / {{ progressPercent }}%</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-xs sm:text-sm text-gray-300 pt-0.5">
|
||||
{{ currentChapterName }} <span v-if="useChapterTrack" class="text-xs text-gray-400"> ({{ currentChapterIndex + 1 }} of {{ chapters.length }})</span>
|
||||
{{ currentChapterName }} <span v-if="useChapterTrack" class="text-xs text-gray-400"> ({{ $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>
|
||||
|
@ -3,7 +3,7 @@
|
||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||
<div ref="content" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickedOutside">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<p class="text-lg mb-6 mt-2 px-1" v-html="message" />
|
||||
<p id="confirm-prompt-message" class="text-lg mb-6 mt-2 px-1" v-html="message" />
|
||||
|
||||
<ui-checkbox v-if="checkboxLabel" v-model="checkboxValue" checkbox-bg="bg" :label="checkboxLabel" label-class="pl-2 text-base" class="mb-6 px-1" />
|
||||
|
||||
@ -131,4 +131,14 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#confirm-prompt-message code {
|
||||
font-size: 1rem;
|
||||
border-radius: 6px;
|
||||
background-color: rgb(82, 82, 82);
|
||||
color: white;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
</style>
|
||||
|
@ -179,7 +179,7 @@ export default {
|
||||
ebookLocation: this.page,
|
||||
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
|
||||
}
|
||||
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
|
||||
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload, { progress: false }).catch((error) => {
|
||||
console.error('ComicReader.updateProgress failed:', error)
|
||||
})
|
||||
},
|
||||
@ -334,7 +334,7 @@ export default {
|
||||
}
|
||||
},
|
||||
parseFilenames(filenames) {
|
||||
const acceptableImages = ['.jpeg', '.jpg', '.png']
|
||||
const acceptableImages = ['.jpeg', '.jpg', '.png', '.webp']
|
||||
var imageFiles = filenames.filter((f) => {
|
||||
return acceptableImages.includes((Path.extname(f) || '').toLowerCase())
|
||||
})
|
||||
@ -386,4 +386,4 @@ export default {
|
||||
.pagemenu {
|
||||
max-height: calc(100% - 48px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
@ -46,7 +46,8 @@ export default {
|
||||
font: 'serif',
|
||||
fontScale: 100,
|
||||
lineSpacing: 115,
|
||||
spread: 'auto'
|
||||
spread: 'auto',
|
||||
textStroke: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -63,6 +64,9 @@ export default {
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id
|
||||
},
|
||||
allowScriptedContent() {
|
||||
return this.$store.getters['libraries/getLibraryEpubsAllowScriptedContent']
|
||||
},
|
||||
hasPrev() {
|
||||
return !this.rendition?.location?.atStart
|
||||
},
|
||||
@ -106,11 +110,14 @@ export default {
|
||||
|
||||
const fontScale = this.ereaderSettings.fontScale / 100
|
||||
|
||||
const textStroke = this.ereaderSettings.textStroke / 100
|
||||
|
||||
return {
|
||||
'*': {
|
||||
color: `${fontColor}!important`,
|
||||
'background-color': `${backgroundColor}!important`,
|
||||
'line-height': lineSpacing * fontScale + 'rem!important'
|
||||
'line-height': lineSpacing * fontScale + 'rem!important',
|
||||
'-webkit-text-stroke': textStroke + 'px ' + fontColor + '!important'
|
||||
},
|
||||
a: {
|
||||
color: `${fontColor}!important`
|
||||
@ -192,7 +199,7 @@ export default {
|
||||
*/
|
||||
updateProgress(payload) {
|
||||
if (!this.keepProgress) return
|
||||
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
|
||||
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload, { progress: false }).catch((error) => {
|
||||
console.error('EpubReader.updateProgress failed:', error)
|
||||
})
|
||||
},
|
||||
@ -316,7 +323,7 @@ export default {
|
||||
reader.rendition = reader.book.renderTo('viewer', {
|
||||
width: this.readerWidth,
|
||||
height: this.readerHeight * 0.8,
|
||||
allowScriptedContent: true,
|
||||
allowScriptedContent: this.allowScriptedContent,
|
||||
spread: 'auto',
|
||||
snap: true,
|
||||
manager: 'continuous',
|
||||
|
@ -23,13 +23,10 @@
|
||||
<div class="flex items-center justify-center">
|
||||
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="overflow-auto">
|
||||
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
|
||||
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="pdfDocInitParams" :page="page" :rotate="rotate" @progress="progressEvt" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
|
||||
<pdf v-if="pdfDocInitParams" ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="pdfDocInitParams" :page="page" :rotate="rotate" @progress="progressEvt" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="text-center py-2 text-lg">
|
||||
<p>{{ page }} / {{ numPages }}</p>
|
||||
</div> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -57,7 +54,8 @@ export default {
|
||||
rotate: 0,
|
||||
loadedRatio: 0,
|
||||
page: 1,
|
||||
numPages: 0
|
||||
numPages: 0,
|
||||
pdfDocInitParams: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@ -108,14 +106,6 @@ export default {
|
||||
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
|
||||
}
|
||||
return `/api/items/${this.libraryItemId}/ebook`
|
||||
},
|
||||
pdfDocInitParams() {
|
||||
return {
|
||||
url: this.ebookUrl,
|
||||
httpHeaders: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@ -136,7 +126,7 @@ export default {
|
||||
ebookLocation: this.page,
|
||||
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
|
||||
}
|
||||
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
|
||||
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload, { progress: false }).catch((error) => {
|
||||
console.error('EpubReader.updateProgress failed:', error)
|
||||
})
|
||||
},
|
||||
@ -149,6 +139,7 @@ export default {
|
||||
this.loadedRatio = progress
|
||||
},
|
||||
numPagesLoaded(e) {
|
||||
if (!e) return
|
||||
this.numPages = e
|
||||
},
|
||||
prev() {
|
||||
@ -167,15 +158,25 @@ export default {
|
||||
resize() {
|
||||
this.windowWidth = window.innerWidth
|
||||
this.windowHeight = window.innerHeight
|
||||
},
|
||||
init() {
|
||||
this.pdfDocInitParams = {
|
||||
url: this.ebookUrl,
|
||||
httpHeaders: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.windowWidth = window.innerWidth
|
||||
this.windowHeight = window.innerHeight
|
||||
window.addEventListener('resize', this.resize)
|
||||
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.resize)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
@ -98,6 +98,12 @@
|
||||
</div>
|
||||
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-40">
|
||||
<p class="text-lg">{{ $strings.LabelFontBoldness }}:</p>
|
||||
</div>
|
||||
<ui-range-input v-model="ereaderSettings.textStroke" :min="0" :max="300" :step="5" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-40">
|
||||
<p class="text-lg">{{ $strings.LabelLayout }}:</p>
|
||||
@ -130,7 +136,9 @@ export default {
|
||||
font: 'serif',
|
||||
fontScale: 100,
|
||||
lineSpacing: 115,
|
||||
spread: 'auto'
|
||||
fontBoldness: 100,
|
||||
spread: 'auto',
|
||||
textStroke: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -378,7 +386,12 @@ export default {
|
||||
try {
|
||||
const settings = localStorage.getItem('ereaderSettings')
|
||||
if (settings) {
|
||||
this.ereaderSettings = JSON.parse(settings)
|
||||
const _ereaderSettings = JSON.parse(settings)
|
||||
for (const key in this.ereaderSettings) {
|
||||
if (_ereaderSettings[key] !== undefined) {
|
||||
this.ereaderSettings[key] = _ereaderSettings[key]
|
||||
}
|
||||
}
|
||||
this.settingsUpdated()
|
||||
}
|
||||
} catch (error) {
|
||||
@ -416,4 +429,4 @@ export default {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
@ -1,45 +1,45 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap justify-center mt-6">
|
||||
<div class="flex px-2">
|
||||
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
||||
<div class="flex p-2">
|
||||
<svg class="h-14 w-14" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
|
||||
</svg>
|
||||
<div class="px-2">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalItems }}</p>
|
||||
<div class="px-1">
|
||||
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(totalItems) }}</p>
|
||||
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsInLibrary }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex px-4">
|
||||
<span class="material-icons text-7xl">show_chart</span>
|
||||
<div class="flex p-2">
|
||||
<span class="material-icons text-5xl py-1">show_chart</span>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalTime }}</p>
|
||||
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(totalTime) }}</p>
|
||||
<p class="text-xs md:text-sm text-white text-opacity-80">{{ useOverallHours ? $strings.LabelStatsOverallHours : $strings.LabelStatsOverallDays }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isBookLibrary" class="flex px-4">
|
||||
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
||||
<div v-if="isBookLibrary" class="flex p-2">
|
||||
<svg class="h-14 w-14" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" />
|
||||
</svg>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalAuthors }}</p>
|
||||
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(totalAuthors) }}</p>
|
||||
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAuthors }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex px-4">
|
||||
<span class="material-icons-outlined text-6xl pt-1">insert_drive_file</span>
|
||||
<div class="flex p-2">
|
||||
<span class="material-icons-outlined text-5xl pt-1">insert_drive_file</span>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalSizeNum }}</p>
|
||||
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(totalSizeNum) }}</p>
|
||||
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelSize }} ({{ totalSizeMod }})</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex px-4">
|
||||
<span class="material-icons-outlined text-6xl pt-1">audio_file</span>
|
||||
<div class="flex p-2">
|
||||
<span class="material-icons-outlined text-5xl pt-1">audio_file</span>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ numAudioTracks }}</p>
|
||||
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(numAudioTracks) }}</p>
|
||||
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAudioTracks }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -271,7 +271,7 @@ export default {
|
||||
this.$emit('update:processing', true)
|
||||
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => {
|
||||
console.error('Failed to load stats for year', err)
|
||||
this.$toast.error('Failed to load year stats')
|
||||
this.$toast.error(this.$strings.ToastFailedToLoadData)
|
||||
return null
|
||||
})
|
||||
await this.initCanvas()
|
||||
@ -282,4 +282,4 @@ export default {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
@ -7,9 +7,10 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<p class="hidden md:block text-xl font-semibold">{{ yearInReviewYear }} Year in Review</p>
|
||||
<p class="hidden md:block text-xl font-semibold">{{ $getString('HeaderYearReview', [yearInReviewYear]) }}</p>
|
||||
<div class="hidden md:block flex-grow" />
|
||||
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? 'Hide Year in Review' : 'See Year in Review' }}</ui-btn>
|
||||
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? $strings.LabelYearReviewHide :
|
||||
$strings.LabelYearReviewShow }}</ui-btn>
|
||||
</div>
|
||||
|
||||
<!-- your year in review -->
|
||||
@ -20,24 +21,27 @@
|
||||
<!-- previous button -->
|
||||
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
|
||||
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="hidden sm:inline-block pr-2">Previous</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"> Share </ui-btn>
|
||||
<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>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<p class="hidden sm:block text-lg font-semibold">Your Year in Review ({{ yearInReviewVariant + 1 }})</p>
|
||||
<p class="hidden sm:block text-lg font-semibold">{{ $getString('LabelPersonalYearReview', [yearInReviewVariant + 1]) }}
|
||||
</p>
|
||||
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<!-- refresh button -->
|
||||
<ui-btn small :disabled="processingYearInReview" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReview">
|
||||
<span class="hidden sm:inline-block">Refresh</span>
|
||||
<span class="hidden sm:inline-block">{{ $strings.ButtonRefresh }}</span>
|
||||
<span class="material-icons 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++">
|
||||
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||
<span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span>
|
||||
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
@ -46,7 +50,7 @@
|
||||
<!-- 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"> Share </ui-btn>
|
||||
<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>
|
||||
<stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" />
|
||||
</div>
|
||||
|
||||
@ -56,24 +60,25 @@
|
||||
<!-- previous button -->
|
||||
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
|
||||
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="hidden sm:inline-block pr-2">Previous</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"> Share </ui-btn>
|
||||
<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>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<p class="hidden sm:block text-lg font-semibold">Server Year in Review ({{ yearInReviewServerVariant + 1 }})</p>
|
||||
<p class="hidden sm:block text-lg font-semibold">{{ $getString('LabelServerYearReview', [yearInReviewServerVariant + 1]) }}</p>
|
||||
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<!-- refresh button -->
|
||||
<ui-btn small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReviewServer">
|
||||
<span class="hidden sm:inline-block">Refresh</span>
|
||||
<span class="hidden sm:inline-block">{{ $strings.ButtonRefresh }}</span>
|
||||
<span class="material-icons 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++">
|
||||
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||
<span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span>
|
||||
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
|
@ -250,7 +250,7 @@ export default {
|
||||
this.$emit('update:processing', true)
|
||||
this.yearStats = await this.$axios.$get(`/api/stats/year/${this.year}`).catch((err) => {
|
||||
console.error('Failed to load stats for year', err)
|
||||
this.$toast.error('Failed to load year stats')
|
||||
this.$toast.error(this.$strings.ToastFailedToLoadData)
|
||||
return null
|
||||
})
|
||||
await this.initCanvas()
|
||||
@ -261,4 +261,4 @@ export default {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
@ -180,7 +180,7 @@ export default {
|
||||
this.$emit('update:processing', true)
|
||||
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => {
|
||||
console.error('Failed to load stats for year', err)
|
||||
this.$toast.error('Failed to load year stats')
|
||||
this.$toast.error(this.$strings.ToastFailedToLoadData)
|
||||
return null
|
||||
})
|
||||
await this.initCanvas()
|
||||
@ -191,4 +191,4 @@ export default {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
@ -94,11 +94,11 @@ export default {
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}/file/${this.track.audioFile.ino}`)
|
||||
.then(() => {
|
||||
this.$toast.success('File deleted')
|
||||
this.$toast.success(this.$strings.ToastDeleteFileSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete file', error)
|
||||
this.$toast.error('Failed to delete file')
|
||||
this.$toast.error(this.$strings.ToastDeleteFileFailed)
|
||||
})
|
||||
}
|
||||
},
|
||||
@ -112,4 +112,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="text-center mt-4">
|
||||
<div class="text-center mt-4 relative">
|
||||
<div class="flex py-4">
|
||||
<ui-file-input ref="fileInput" class="mr-2" accept=".audiobookshelf" @change="backupUploaded">{{ $strings.ButtonUploadBackup }}</ui-file-input>
|
||||
<div class="flex-grow" />
|
||||
@ -54,6 +54,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</prompt-dialog>
|
||||
|
||||
<div v-if="isApplyingBackup" class="absolute inset-0 w-full h-full flex items-center justify-center bg-black/20 rounded-md">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -64,6 +68,7 @@ export default {
|
||||
showConfirmApply: false,
|
||||
selectedBackup: null,
|
||||
isBackingUp: false,
|
||||
isApplyingBackup: false,
|
||||
processing: false,
|
||||
backups: []
|
||||
}
|
||||
@ -85,19 +90,21 @@ export default {
|
||||
},
|
||||
confirm() {
|
||||
this.showConfirmApply = false
|
||||
this.isApplyingBackup = true
|
||||
|
||||
this.$axios
|
||||
.$get(`/api/backups/${this.selectedBackup.id}/apply`)
|
||||
.then(() => {
|
||||
this.isBackingUp = false
|
||||
location.replace('/config/backups?backup=1')
|
||||
})
|
||||
.catch((error) => {
|
||||
this.isBackingUp = false
|
||||
console.error('Failed to apply backup', error)
|
||||
const errorMsg = error.response.data || this.$strings.ToastBackupRestoreFailed
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isApplyingBackup = false
|
||||
})
|
||||
},
|
||||
deleteBackupClick(backup) {
|
||||
if (confirm(this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]))) {
|
||||
@ -169,7 +176,7 @@ export default {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load backups', error)
|
||||
this.$toast.error('Failed to load backups')
|
||||
this.$toast.error(this.$strings.ToastFailedToLoadData)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
@ -180,7 +187,6 @@ export default {
|
||||
this.loadBackups()
|
||||
if (this.$route.query.backup) {
|
||||
this.$toast.success('Backup applied successfully')
|
||||
this.$router.replace('/config')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@
|
||||
<p class="pr-4">{{ $strings.HeaderChapters }}</p>
|
||||
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ chapters.length }}</span>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="userCanUpdate" small :to="`/audiobook/${libraryItemId}/chapters`" color="primary" class="mr-2">{{ $strings.ButtonEditChapters }}</ui-btn>
|
||||
<ui-btn v-if="userCanUpdate" small :to="`/audiobook/${libraryItemId}/chapters`" color="primary" class="mr-2" @click="clickEditChapters">{{ $strings.ButtonEditChapters }}</ui-btn>
|
||||
<div v-if="!keepOpen" class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="expanded ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-4xl">expand_more</span>
|
||||
</div>
|
||||
@ -21,7 +21,7 @@
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ chapter.id }}</p>
|
||||
</td>
|
||||
<td>
|
||||
<td dir="auto">
|
||||
{{ chapter.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center hover:underline cursor-pointer" @click.stop="goToTimestamp(chapter.start)">
|
||||
@ -107,8 +107,14 @@ export default {
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
}
|
||||
},
|
||||
clickEditChapters() {
|
||||
// Used for Chapters tab in modal
|
||||
if (this.$route.name === 'audiobook-id-chapters' && this.$route.params?.id === this.libraryItem?.id) {
|
||||
this.$emit('close')
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
@ -21,7 +21,7 @@
|
||||
</tr>
|
||||
</table>
|
||||
<div v-else-if="!processing" class="text-center py-8">
|
||||
<p class="text-lg">No custom metadata providers</p>
|
||||
<p class="text-lg">{{ $strings.LabelNoCustomMetadataProviders }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="processing" class="absolute inset-0 h-full flex items-center justify-center bg-black/40 rounded-md">
|
||||
|
@ -115,11 +115,11 @@ export default {
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
|
||||
.then(() => {
|
||||
this.$toast.success('File deleted')
|
||||
this.$toast.success(this.$strings.ToastDeleteFileSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete file', error)
|
||||
this.$toast.error('Failed to delete file')
|
||||
this.$toast.error(this.$strings.ToastDeleteFileFailed)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
@ -136,4 +136,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
@ -89,11 +89,11 @@ export default {
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
|
||||
.then(() => {
|
||||
this.$toast.success('File deleted')
|
||||
this.$toast.success(this.$strings.ToastDeleteFileSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete file', error)
|
||||
this.$toast.error('Failed to delete file')
|
||||
this.$toast.error(this.$strings.ToastDeleteFileFailed)
|
||||
})
|
||||
}
|
||||
},
|
||||
@ -107,4 +107,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
@ -10,7 +10,7 @@
|
||||
<th class="w-32 hidden sm:table-cell">{{ $strings.LabelCreatedAt }}</th>
|
||||
<th class="w-32"></th>
|
||||
</tr>
|
||||
<tr v-for="user in users" :key="user.id" class="cursor-pointer" :class="user.isActive ? '' : 'bg-error bg-opacity-20'" @click="$router.push(`/config/users/${user.id}`)">
|
||||
<tr v-for="user in users" :key="user.id" class="cursor-pointer" :class="user.isActive ? '' : '!bg-error/10'" @click="$router.push(`/config/users/${user.id}`)">
|
||||
<td>
|
||||
<div class="flex items-center">
|
||||
<widgets-online-indicator :value="!!usersOnline[user.id]" />
|
||||
|
@ -38,7 +38,7 @@
|
||||
<div v-if="userCanUpdate" class="mx-1" :class="isHovering ? '' : 'ml-6'">
|
||||
<ui-icon-btn icon="edit" borderless @click="clickEdit" />
|
||||
</div>
|
||||
<div v-if="userCanDelete" class="mx-1">
|
||||
<div class="mx-1" :class="isHovering ? '' : 'ml-6'">
|
||||
<ui-icon-btn icon="close" borderless @click="removeClick" />
|
||||
</div>
|
||||
</div>
|
||||
@ -75,8 +75,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
translateDistance() {
|
||||
if (!this.userCanUpdate && !this.userCanDelete) return 'translate-x-0'
|
||||
else if (!this.userCanUpdate || !this.userCanDelete) return '-translate-x-12'
|
||||
if (!this.userCanUpdate) return '-translate-x-12'
|
||||
return '-translate-x-24'
|
||||
},
|
||||
libraryItem() {
|
||||
@ -233,4 +232,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
@ -20,7 +20,7 @@
|
||||
<td class="px-4">
|
||||
<div class="flex items-center">
|
||||
<nuxt-link :to="`/item/${downloadQueued.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ downloadQueued.podcastTitle }}</nuxt-link>
|
||||
<widgets-explicit-indicator :explicit="downloadQueued.podcastExplicit" />
|
||||
<widgets-explicit-indicator v-if="downloadQueued.podcastExplicit" />
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@ -30,7 +30,7 @@
|
||||
<widgets-podcast-type-indicator :type="downloadQueued.episodeType" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4">
|
||||
<td dir="auto" class="px-4">
|
||||
{{ downloadQueued.episodeDisplayTitle }}
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
|
@ -2,7 +2,7 @@
|
||||
<div :id="`lazy-episode-${index}`" class="w-full h-full cursor-pointer" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div class="flex" @click="clickedEpisode">
|
||||
<div class="flex-grow">
|
||||
<div class="flex items-center">
|
||||
<div dir="auto" class="flex items-center">
|
||||
<span class="text-sm font-semibold">{{ episodeTitle }}</span>
|
||||
<widgets-podcast-type-indicator :type="episodeType" />
|
||||
</div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<nuxt-link v-if="to" :to="to" class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
|
||||
<nuxt-link v-if="to" :to="to" class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList" @click.native="click">
|
||||
<slot />
|
||||
<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">
|
||||
|
@ -13,7 +13,7 @@
|
||||
</button>
|
||||
|
||||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="listbox">
|
||||
<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 }">
|
||||
<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)">
|
||||
<div class="flex items-center">
|
||||
@ -41,7 +41,11 @@ export default {
|
||||
default: () => []
|
||||
},
|
||||
disabled: Boolean,
|
||||
small: Boolean
|
||||
small: Boolean,
|
||||
menuMaxHeight: {
|
||||
type: String,
|
||||
default: '224px'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<input ref="fileInput" type="file" :accept="accept" class="hidden" @change="inputChanged" />
|
||||
<ui-btn @click="clickUpload" color="primary" class="hidden md:block" type="text"><slot /></ui-btn>
|
||||
<ui-btn @click="clickUpload" color="primary" class="hidden md:block w-full" type="text"><slot /></ui-btn>
|
||||
<ui-icon-btn @click="clickUpload" icon="upload" class="block md:hidden" />
|
||||
</div>
|
||||
</template>
|
||||
|
@ -83,15 +83,21 @@ export default {
|
||||
},
|
||||
async updateLibrary(library) {
|
||||
var currLibraryId = this.currentLibraryId
|
||||
if (currLibraryId === library.id) {
|
||||
return
|
||||
}
|
||||
|
||||
this.disabled = true
|
||||
await this.$store.dispatch('libraries/fetch', library.id)
|
||||
|
||||
if (this.$route.name.startsWith('config')) {
|
||||
// No need to refresh
|
||||
} else if (this.$route.name.startsWith('library')) {
|
||||
var newRoute = this.$route.path.replace(currLibraryId, library.id)
|
||||
} else if (this.$route.name.startsWith('library') && this.$route.name !== 'library-library-series-id') {
|
||||
const newRoute = this.$route.path.replace(currLibraryId, library.id)
|
||||
this.$router.push(newRoute)
|
||||
} else if (this.$route.name === 'library-library-series-id' && library.mediaType === 'book') {
|
||||
// For series item page redirect to root series page
|
||||
this.$router.push(`/library/${library.id}/bookshelf/series`)
|
||||
} else {
|
||||
this.$router.push(`/library/${library.id}`)
|
||||
}
|
||||
@ -107,4 +113,4 @@ export default {
|
||||
.librariesDropdownMenu {
|
||||
max-height: calc(100vh - 75px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
@ -11,13 +11,13 @@
|
||||
</div>
|
||||
{{ item }}
|
||||
</div>
|
||||
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
|
||||
<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" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ul ref="menu" v-show="showMenu" class="absolute z-60 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in itemsToShow">
|
||||
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="itemsToShow[selectedMenuItemIndex] === item ? 'text-yellow-300' : ''" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ item }}</span>
|
||||
</div>
|
||||
@ -54,7 +54,7 @@ export default {
|
||||
menuDisabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -62,7 +62,9 @@ export default {
|
||||
currentSearch: null,
|
||||
typingTimeout: null,
|
||||
isFocused: false,
|
||||
menu: null
|
||||
menu: null,
|
||||
filteredItems: null,
|
||||
selectedMenuItemIndex: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -91,24 +93,63 @@ export default {
|
||||
return classes.join(' ')
|
||||
},
|
||||
itemsToShow() {
|
||||
if (!this.currentSearch || !this.textInput) {
|
||||
if (!this.currentSearch || !this.textInput || !this.filteredItems) {
|
||||
return this.items
|
||||
}
|
||||
|
||||
return this.items.filter((i) => {
|
||||
var iValue = String(i).toLowerCase()
|
||||
return iValue.includes(this.currentSearch.toLowerCase())
|
||||
})
|
||||
return this.filteredItems
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editItem(item) {
|
||||
this.$emit('edit', item)
|
||||
},
|
||||
keydownInput() {
|
||||
search() {
|
||||
if (!this.textInput) {
|
||||
this.filteredItems = null
|
||||
return
|
||||
}
|
||||
this.currentSearch = this.textInput
|
||||
|
||||
const results = this.items.filter((i) => {
|
||||
var iValue = String(i).toLowerCase()
|
||||
return iValue.includes(this.currentSearch.toLowerCase())
|
||||
})
|
||||
|
||||
this.filteredItems = results || []
|
||||
},
|
||||
keydownInput(event) {
|
||||
let items = this.itemsToShow
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
if (!items.length) return
|
||||
if (event.key === 'ArrowDown') {
|
||||
if (this.selectedMenuItemIndex === null) {
|
||||
this.selectedMenuItemIndex = 0
|
||||
} else {
|
||||
this.selectedMenuItemIndex = Math.min(this.selectedMenuItemIndex + 1, items.length - 1)
|
||||
}
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
if (this.selectedMenuItemIndex === null) {
|
||||
this.selectedMenuItemIndex = items.length - 1
|
||||
} else {
|
||||
this.selectedMenuItemIndex = Math.max(this.selectedMenuItemIndex - 1, 0)
|
||||
}
|
||||
}
|
||||
this.recalcScroll()
|
||||
return
|
||||
} else if (event.key === 'Enter') {
|
||||
if (this.selectedMenuItemIndex !== null) {
|
||||
this.clickedOption(event, items[this.selectedMenuItemIndex])
|
||||
} else {
|
||||
this.submitForm()
|
||||
}
|
||||
return
|
||||
}
|
||||
this.selectedMenuItemIndex = null
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.currentSearch = this.textInput
|
||||
this.search()
|
||||
}, 100)
|
||||
this.setInputWidth()
|
||||
},
|
||||
@ -120,6 +161,24 @@ export default {
|
||||
this.recalcMenuPos()
|
||||
}, 50)
|
||||
},
|
||||
recalcScroll() {
|
||||
if (!this.menu) return
|
||||
var menuItems = this.menu.querySelectorAll('li')
|
||||
if (!menuItems.length) return
|
||||
var selectedItem = menuItems[this.selectedMenuItemIndex]
|
||||
if (!selectedItem) return
|
||||
var menuHeight = this.menu.offsetHeight
|
||||
var itemHeight = selectedItem.offsetHeight
|
||||
var itemTop = selectedItem.offsetTop
|
||||
var itemBottom = itemTop + itemHeight
|
||||
if (itemBottom > this.menu.scrollTop + menuHeight) {
|
||||
let menuPaddingBottom = parseFloat(window.getComputedStyle(this.menu).paddingBottom)
|
||||
this.menu.scrollTop = itemBottom - menuHeight + menuPaddingBottom
|
||||
} else if (itemTop < this.menu.scrollTop) {
|
||||
let menuPaddingTop = parseFloat(window.getComputedStyle(this.menu).paddingTop)
|
||||
this.menu.scrollTop = itemTop - menuPaddingTop
|
||||
}
|
||||
},
|
||||
recalcMenuPos() {
|
||||
if (!this.menu || !this.$refs.inputWrapper) return
|
||||
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
|
||||
@ -208,7 +267,10 @@ export default {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
if (this.$refs.input) this.$refs.input.focus()
|
||||
if (this.$refs.input) {
|
||||
this.$refs.input.style.width = '24px'
|
||||
this.$refs.input.focus()
|
||||
}
|
||||
|
||||
var newSelected = null
|
||||
if (this.selected.includes(itemValue)) {
|
||||
@ -219,6 +281,7 @@ export default {
|
||||
}
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.selectedMenuItemIndex = null
|
||||
this.$emit('input', newSelected)
|
||||
this.$nextTick(() => {
|
||||
this.recalcMenuPos()
|
||||
@ -239,12 +302,21 @@ export default {
|
||||
this.recalcMenuPos()
|
||||
})
|
||||
},
|
||||
resetInput() {
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.selectedMenuItemIndex = null
|
||||
this.$nextTick(() => {
|
||||
this.blur()
|
||||
})
|
||||
},
|
||||
insertNewItem(item) {
|
||||
this.selected.push(item)
|
||||
this.$emit('input', this.selected)
|
||||
this.$emit('newItem', item)
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.selectedMenuItemIndex = null
|
||||
this.$nextTick(() => {
|
||||
this.blur()
|
||||
})
|
||||
@ -252,15 +324,19 @@ export default {
|
||||
submitForm() {
|
||||
if (!this.textInput) return
|
||||
|
||||
var cleaned = this.textInput.trim()
|
||||
var matchesItem = this.items.find((i) => {
|
||||
return i === cleaned
|
||||
})
|
||||
if (matchesItem) {
|
||||
this.clickedOption(null, matchesItem)
|
||||
const cleaned = this.textInput.trim()
|
||||
if (!cleaned) {
|
||||
this.resetInput()
|
||||
} else {
|
||||
this.insertNewItem(this.textInput)
|
||||
const matchesItem = this.items.find((i) => i === cleaned)
|
||||
if (matchesItem) {
|
||||
this.clickedOption(null, matchesItem)
|
||||
} else {
|
||||
this.insertNewItem(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.$refs.input) this.$refs.input.style.width = '24px'
|
||||
},
|
||||
scroll() {
|
||||
this.recalcMenuPos()
|
||||
@ -287,4 +363,4 @@ input:read-only {
|
||||
color: #aaa;
|
||||
background-color: #444;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
@ -14,13 +14,13 @@
|
||||
<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-icons text-white hover:text-success pt-px pr-px" style="font-size: 1.1rem" @click.stop="addItem">add</span>
|
||||
</div>
|
||||
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
|
||||
<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" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ul ref="menu" v-show="showMenu" class="absolute z-60 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in itemsToShow">
|
||||
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="itemsToShow[selectedMenuItemIndex] === item ? 'text-yellow-300' : ''" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ item.name }}</span>
|
||||
</div>
|
||||
@ -63,7 +63,8 @@ export default {
|
||||
typingTimeout: null,
|
||||
isFocused: false,
|
||||
menu: null,
|
||||
items: []
|
||||
items: [],
|
||||
selectedMenuItemIndex: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -122,7 +123,35 @@ export default {
|
||||
|
||||
this.items = results || []
|
||||
},
|
||||
keydownInput() {
|
||||
keydownInput(event) {
|
||||
let items = this.itemsToShow
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
if (!items.length) return
|
||||
if (event.key === 'ArrowDown') {
|
||||
if (this.selectedMenuItemIndex === null) {
|
||||
this.selectedMenuItemIndex = 0
|
||||
} else {
|
||||
this.selectedMenuItemIndex = Math.min(this.selectedMenuItemIndex + 1, items.length - 1)
|
||||
}
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
if (this.selectedMenuItemIndex === null) {
|
||||
this.selectedMenuItemIndex = items.length - 1
|
||||
} else {
|
||||
this.selectedMenuItemIndex = Math.max(this.selectedMenuItemIndex - 1, 0)
|
||||
}
|
||||
}
|
||||
this.recalcScroll()
|
||||
return
|
||||
} else if (event.key === 'Enter') {
|
||||
if (this.selectedMenuItemIndex !== null) {
|
||||
this.clickedOption(event, items[this.selectedMenuItemIndex])
|
||||
} else {
|
||||
this.submitForm()
|
||||
}
|
||||
return
|
||||
}
|
||||
this.selectedMenuItemIndex = null
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.search()
|
||||
@ -137,6 +166,24 @@ export default {
|
||||
this.recalcMenuPos()
|
||||
}, 50)
|
||||
},
|
||||
recalcScroll() {
|
||||
if (!this.menu) return
|
||||
var menuItems = this.menu.querySelectorAll('li')
|
||||
if (!menuItems.length) return
|
||||
var selectedItem = menuItems[this.selectedMenuItemIndex]
|
||||
if (!selectedItem) return
|
||||
var menuHeight = this.menu.offsetHeight
|
||||
var itemHeight = selectedItem.offsetHeight
|
||||
var itemTop = selectedItem.offsetTop
|
||||
var itemBottom = itemTop + itemHeight
|
||||
if (itemBottom > this.menu.scrollTop + menuHeight) {
|
||||
let menuPaddingBottom = parseFloat(window.getComputedStyle(this.menu).paddingBottom)
|
||||
this.menu.scrollTop = itemBottom - menuHeight + menuPaddingBottom
|
||||
} else if (itemTop < this.menu.scrollTop) {
|
||||
let menuPaddingTop = parseFloat(window.getComputedStyle(this.menu).paddingTop)
|
||||
this.menu.scrollTop = itemTop - menuPaddingTop
|
||||
}
|
||||
},
|
||||
recalcMenuPos() {
|
||||
if (!this.menu || !this.$refs.inputWrapper) return
|
||||
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
|
||||
@ -228,7 +275,10 @@ export default {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
if (this.$refs.input) this.$refs.input.focus()
|
||||
if (this.$refs.input) {
|
||||
this.$refs.input.style.width = '24px'
|
||||
this.$refs.input.focus()
|
||||
}
|
||||
|
||||
let newSelected = null
|
||||
if (this.getIsSelected(item.id)) {
|
||||
@ -244,6 +294,7 @@ export default {
|
||||
}
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.selectedMenuItemIndex = null
|
||||
|
||||
this.$emit('input', newSelected)
|
||||
this.$nextTick(() => {
|
||||
@ -271,6 +322,7 @@ export default {
|
||||
this.$emit('newItem', item)
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.selectedMenuItemIndex = null
|
||||
this.$nextTick(() => {
|
||||
this.blur()
|
||||
})
|
||||
@ -291,6 +343,7 @@ export default {
|
||||
name: this.textInput
|
||||
})
|
||||
}
|
||||
if (this.$refs.input) this.$refs.input.style.width = '24px'
|
||||
},
|
||||
scroll() {
|
||||
this.recalcMenuPos()
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative">
|
||||
<input :id="inputId" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<input :id="inputId" :name="inputName" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" dir="auto" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
||||
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
||||
</div>
|
||||
@ -33,6 +33,7 @@ export default {
|
||||
textCenter: Boolean,
|
||||
clearable: Boolean,
|
||||
inputId: String,
|
||||
inputName: String,
|
||||
step: [String, Number],
|
||||
min: [String, Number]
|
||||
},
|
||||
@ -117,4 +118,4 @@ input:read-only {
|
||||
input::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
@ -5,7 +5,7 @@
|
||||
>{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em></label
|
||||
>
|
||||
</slot>
|
||||
<ui-text-input :placeholder="label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
|
||||
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -14,6 +14,7 @@ export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
label: String,
|
||||
placeholder: String,
|
||||
note: String,
|
||||
type: {
|
||||
type: String,
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<textarea ref="input" v-model="inputValue" :rows="rows" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
|
||||
<textarea ref="input" v-model="inputValue" :rows="rows" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" dir="auto" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div tabindex="0" @focus="focusDigit('second0')" class="relative">
|
||||
<div class="rounded text-gray-200 border w-full px-3 py-2" :class="focusedDigit ? 'bg-primary bg-opacity-50 border-gray-300' : 'bg-primary border-gray-600'" @click="clickInput" v-click-outside="clickOutsideObj">
|
||||
<div class="flex items-center">
|
||||
<template v-for="(digit, index) in digitDisplay">
|
||||
@ -174,7 +174,7 @@ export default {
|
||||
return this.increaseFocused()
|
||||
} else if (evt.key === 'ArrowDown') {
|
||||
return this.decreaseFocused()
|
||||
} else if (evt.key === 'Enter' || evt.key === 'Escape') {
|
||||
} else if (evt.key === 'Enter' || evt.key === 'Escape' || evt.key === 'Tab') {
|
||||
return this.removeFocus()
|
||||
}
|
||||
|
||||
@ -209,4 +209,4 @@ export default {
|
||||
.digit-focused {
|
||||
background-color: #555;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
@ -1,84 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4">
|
||||
<p class="text-sm mb-2">
|
||||
{{ $strings.LabelMissingParts }} <span class="text-sm">({{ missingParts.length }})</span>
|
||||
</p>
|
||||
<p class="text-sm font-mono">{{ missingPartChunks.join(', ') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="invalidParts.length" class="bg-error border-red-800 shadow-md p-4">
|
||||
<p class="text-sm mb-2">
|
||||
{{ $strings.LabelInvalidParts }} <span class="text-sm">({{ invalidParts.length }})</span>
|
||||
</p>
|
||||
<div>
|
||||
<p v-for="part in invalidParts" :key="part.filename" class="text-sm font-mono">{{ part.filename }}: {{ part.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tables-tracks-table :title="$strings.LabelStatsAudioTracks" :tracks="tracksWithAudioFile" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItemId: String,
|
||||
media: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
isFile: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
tracksWithAudioFile() {
|
||||
return this.media.tracks.map((track) => {
|
||||
track.audioFile = this.media.audioFiles.find((af) => af.metadata.path === track.metadata.path)
|
||||
return track
|
||||
})
|
||||
},
|
||||
missingPartChunks() {
|
||||
if (this.missingParts === 1) return this.missingParts[0]
|
||||
var chunks = []
|
||||
|
||||
var currentIndex = this.missingParts[0]
|
||||
var currentChunk = [this.missingParts[0]]
|
||||
|
||||
for (let i = 1; i < this.missingParts.length; i++) {
|
||||
var partIndex = this.missingParts[i]
|
||||
if (currentIndex === partIndex - 1) {
|
||||
currentChunk.push(partIndex)
|
||||
currentIndex = partIndex
|
||||
} else {
|
||||
// console.log('Chunk ended', currentChunk.join(', '), currentIndex, partIndex)
|
||||
if (currentChunk.length === 0) {
|
||||
console.error('How is current chunk 0?', currentChunk.join(', '))
|
||||
}
|
||||
chunks.push(currentChunk)
|
||||
currentChunk = [partIndex]
|
||||
currentIndex = partIndex
|
||||
}
|
||||
}
|
||||
if (currentChunk.length) {
|
||||
chunks.push(currentChunk)
|
||||
}
|
||||
chunks = chunks.map((chunk) => {
|
||||
if (chunk.length === 1) return chunk[0]
|
||||
else return `${chunk[0]}-${chunk[chunk.length - 1]}`
|
||||
})
|
||||
return chunks
|
||||
},
|
||||
missingParts() {
|
||||
return this.media.missingParts || []
|
||||
},
|
||||
invalidParts() {
|
||||
return this.media.invalidParts || []
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
@ -10,10 +10,10 @@
|
||||
<span class="material-icons text-2xl">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex space-x-4" :style="{ height: height + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-author-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :author="item" :height="cardHeight" :width="cardWidth" class="relative mx-2" @edit="editAuthor" @hook:updated="setScrollVars" />
|
||||
<cards-author-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :author="item" :height="cardHeight" :width="cardWidth" class="relative" @edit="editAuthor" @hook:updated="setScrollVars" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -10,8 +10,8 @@
|
||||
<span class="material-icons text-2xl">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex space-x-4" :style="{ height: height + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-lazy-book-card
|
||||
:key="item.recentEpisode.id"
|
||||
@ -23,7 +23,7 @@
|
||||
:book-cover-aspect-ratio="bookCoverAspectRatio"
|
||||
:bookshelf-view="bookshelfView"
|
||||
:continue-listening-shelf="continueListeningShelf"
|
||||
class="relative mx-2"
|
||||
class="relative"
|
||||
@edit="editEpisode"
|
||||
@editPodcast="editPodcast"
|
||||
@select="selectItem"
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<ui-tooltip v-if="explicit" :text="$strings.LabelExplicit" direction="top">
|
||||
<ui-tooltip :text="$strings.LabelExplicit" direction="top">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" viewBox="0 0 512 512" class="ml-1">
|
||||
<path
|
||||
fill="white"
|
||||
@ -40,9 +40,7 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
explicit: Boolean
|
||||
},
|
||||
props: {},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
|
@ -10,10 +10,24 @@
|
||||
<span class="material-icons text-2xl">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex space-x-4" :style="{ height: height + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-lazy-book-card :key="item.id + '-' + shelfId" :ref="`slider-item-${item.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" :continue-listening-shelf="continueListeningShelf" class="relative mx-2" @edit="editItem" @select="selectItem" @hook:updated="setScrollVars" />
|
||||
<cards-lazy-book-card
|
||||
:key="item.id + '-' + shelfId + '-' + index"
|
||||
:ref="`slider-item-${item.id}`"
|
||||
:index="index"
|
||||
:book-mount="item"
|
||||
:height="cardHeight"
|
||||
:width="cardWidth"
|
||||
:book-cover-aspect-ratio="bookCoverAspectRatio"
|
||||
:bookshelf-view="bookshelfView"
|
||||
:continue-listening-shelf="continueListeningShelf"
|
||||
class="relative"
|
||||
@edit="editItem"
|
||||
@select="selectItem"
|
||||
@hook:updated="setScrollVars"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="absolute bg-bg rounded-md py-1 border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" :style="{ width: menuWidth + 'px' }" style="top: 0; left: 0">
|
||||
<div ref="wrapper" class="absolute bg-bg rounded-md py-1 border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" :style="{ width: menuWidth + 'px' }">
|
||||
<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>
|
||||
@ -94,4 +94,4 @@ export default {
|
||||
},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
@ -10,10 +10,10 @@
|
||||
<span class="material-icons text-2xl">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex space-x-4" :style="{ height: height + 'px' }">
|
||||
<template v-for="item in items">
|
||||
<cards-narrator-card :key="item.name" :ref="`slider-item-${item.name}`" :narrator="item" :height="cardHeight" :width="cardWidth" class="relative mx-2" @hook:updated="setScrollVars" />
|
||||
<cards-narrator-card :key="item.name" :ref="`slider-item-${item.name}`" :narrator="item" :height="cardHeight" :width="cardWidth" class="relative" @hook:updated="setScrollVars" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -10,10 +10,10 @@
|
||||
<span class="material-icons text-2xl">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex space-x-4" :style="{ height: height + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-lazy-series-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :series-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="$constants.BookshelfView.DETAIL" class="relative mx-2" @hook:updated="setScrollVars" />
|
||||
<cards-lazy-series-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :series-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="$constants.BookshelfView.DETAIL" class="relative" @hook:updated="setScrollVars" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
11
client/cypress.config.js
Normal file
11
client/cypress.config.js
Normal file
@ -0,0 +1,11 @@
|
||||
const { defineConfig } = require("cypress")
|
||||
|
||||
module.exports = defineConfig({
|
||||
component: {
|
||||
devServer: {
|
||||
framework: "nuxt",
|
||||
bundler: "webpack"
|
||||
},
|
||||
specPattern: "cypress/tests/**/*.cy.js"
|
||||
}
|
||||
})
|
BIN
client/cypress/fixtures/images/book_placeholder.jpg
Normal file
BIN
client/cypress/fixtures/images/book_placeholder.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 105 KiB |
BIN
client/cypress/fixtures/images/cover1.jpg
Normal file
BIN
client/cypress/fixtures/images/cover1.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 184 KiB |
BIN
client/cypress/fixtures/images/cover2.jpg
Normal file
BIN
client/cypress/fixtures/images/cover2.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 315 KiB |
31
client/cypress/support/commands.js
Normal file
31
client/cypress/support/commands.js
Normal file
@ -0,0 +1,31 @@
|
||||
// ***********************************************
|
||||
// This example commands.js shows you how to
|
||||
// create various custom commands and overwrite
|
||||
// existing commands.
|
||||
//
|
||||
// For more comprehensive examples of custom
|
||||
// commands please read more here:
|
||||
// https://on.cypress.io/custom-commands
|
||||
// ***********************************************
|
||||
//
|
||||
//
|
||||
// -- This is a parent command --
|
||||
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a child command --
|
||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a dual command --
|
||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
Cypress.Commands.overwriteQuery('get', function (originalFn, ...args) {
|
||||
if (args.length > 0 && typeof args[0] === 'string' && args[0].startsWith('&')) {
|
||||
args[0] = `[cy-id="${args[0].substring(1)}"]`
|
||||
}
|
||||
return originalFn.apply(this, args)
|
||||
})
|
12
client/cypress/support/component-index.html
Normal file
12
client/cypress/support/component-index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>Components App</title>
|
||||
</head>
|
||||
<body class="text-white bg-bg">
|
||||
<div data-cy-root></div>
|
||||
</body>
|
||||
</html>
|
38
client/cypress/support/component.js
Normal file
38
client/cypress/support/component.js
Normal file
@ -0,0 +1,38 @@
|
||||
// ***********************************************************
|
||||
// This example support/component.js is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// behavior that modifies Cypress.
|
||||
//
|
||||
// You can change the location of this file or turn off
|
||||
// automatically serving support files with the
|
||||
// 'supportFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
import '../../assets/app.css'
|
||||
import './tailwind.compiled.css'
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import './commands'
|
||||
import Vue from 'vue'
|
||||
|
||||
import { Constants } from '../../plugins/constants'
|
||||
import Strings from '../../strings/en-us.json'
|
||||
import '../../plugins/utils'
|
||||
import '../../plugins/init.client'
|
||||
|
||||
import { mount } from 'cypress/vue2'
|
||||
|
||||
//Cypress.Commands.add('mount', mount)
|
||||
Cypress.Commands.add('mount', (component, options = {}) => {
|
||||
|
||||
Vue.prototype.$constants = Constants
|
||||
Vue.prototype.$strings = Strings
|
||||
|
||||
return mount(component, options)
|
||||
})
|
||||
|
||||
// Example use:
|
||||
// cy.mount(MyComponent)
|
4672
client/cypress/support/tailwind.compiled.css
Normal file
4672
client/cypress/support/tailwind.compiled.css
Normal file
File diff suppressed because it is too large
Load Diff
191
client/cypress/tests/components/cards/AuthorCard.cy.js
Normal file
191
client/cypress/tests/components/cards/AuthorCard.cy.js
Normal file
@ -0,0 +1,191 @@
|
||||
// Import the necessary dependencies
|
||||
import AuthorCard from '@/components/cards/AuthorCard.vue'
|
||||
import AuthorImage from '@/components/covers/AuthorImage.vue'
|
||||
import Tooltip from '@/components/ui/Tooltip.vue'
|
||||
import LoadingSpinner from '@/components/widgets/LoadingSpinner.vue'
|
||||
|
||||
describe('AuthorCard', () => {
|
||||
const author = {
|
||||
id: 1,
|
||||
name: 'John Doe',
|
||||
numBooks: 5
|
||||
}
|
||||
|
||||
const propsData = {
|
||||
author,
|
||||
width: 192 * 0.8,
|
||||
height: 192,
|
||||
sizeMultiplier: 1,
|
||||
nameBelow: false
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$strings: {
|
||||
LabelBooks: 'Books',
|
||||
ButtonQuickMatch: 'Quick Match'
|
||||
},
|
||||
$store: {
|
||||
getters: {
|
||||
'user/getUserCanUpdate': true,
|
||||
'libraries/getLibraryProvider': () => 'audible.us'
|
||||
},
|
||||
state: {
|
||||
libraries: {
|
||||
currentLibraryId: 'library-123'
|
||||
}
|
||||
}
|
||||
},
|
||||
$eventBus: {
|
||||
$on: () => { },
|
||||
$off: () => { },
|
||||
},
|
||||
}
|
||||
|
||||
const stubs = {
|
||||
'covers-author-image': AuthorImage,
|
||||
'ui-tooltip': Tooltip,
|
||||
'widgets-loading-spinner': LoadingSpinner
|
||||
}
|
||||
|
||||
const mountOptions = { propsData, mocks, stubs }
|
||||
|
||||
it('renders the component', () => {
|
||||
cy.mount(AuthorCard, mountOptions)
|
||||
|
||||
cy.get('&textInline').should('be.visible')
|
||||
cy.get('&match').should('be.hidden')
|
||||
cy.get('&edit').should('be.hidden')
|
||||
cy.get('&nameBelow').should('be.hidden')
|
||||
cy.get('&card').should(($el) => {
|
||||
const width = $el.width()
|
||||
const height = $el.height()
|
||||
expect(width).to.be.closeTo(propsData.width, 0.01)
|
||||
expect(height).to.be.closeTo(propsData.height, 0.01)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the component with the author name below', () => {
|
||||
const updatedPropsData = { ...propsData, nameBelow: true }
|
||||
cy.mount(AuthorCard, { ...mountOptions, propsData: updatedPropsData })
|
||||
|
||||
cy.get('&textInline').should('be.hidden')
|
||||
cy.get('&match').should('be.hidden')
|
||||
cy.get('&edit').should('be.hidden')
|
||||
let nameBelowHeight
|
||||
cy.get('&nameBelow')
|
||||
.should('be.visible')
|
||||
.and('have.text', 'John Doe')
|
||||
.and(($el) => {
|
||||
const height = $el.height()
|
||||
const width = $el.width()
|
||||
const sizeMultiplier = propsData.sizeMultiplier
|
||||
const defaultFontSize = 16
|
||||
const defaultLineHeight = 1.5
|
||||
const fontSizeMultiplier = 0.75
|
||||
const px2 = 16
|
||||
expect(height).to.be.closeTo(defaultFontSize * fontSizeMultiplier * sizeMultiplier * defaultLineHeight, 0.01)
|
||||
nameBelowHeight = height
|
||||
expect(width).to.be.closeTo(propsData.width - px2, 0.01)
|
||||
})
|
||||
cy.get('&card').should(($el) => {
|
||||
const width = $el.width()
|
||||
const height = $el.height()
|
||||
const py1 = 8
|
||||
expect(width).to.be.closeTo(propsData.width, 0.01)
|
||||
expect(height).to.be.closeTo(propsData.height + nameBelowHeight + py1, 0.01)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders quick-match and edit buttons on mouse hover', () => {
|
||||
cy.mount(AuthorCard, mountOptions)
|
||||
|
||||
// before mouseover
|
||||
cy.get('&match').should('be.hidden')
|
||||
cy.get('&edit').should('be.hidden')
|
||||
// after mouseover
|
||||
cy.get('&card').trigger('mouseover')
|
||||
cy.get('&match').should('be.visible')
|
||||
cy.get('&edit').should('be.visible')
|
||||
// after mouseleave
|
||||
cy.get('&card').trigger('mouseleave')
|
||||
cy.get('&match').should('be.hidden')
|
||||
cy.get('&edit').should('be.hidden')
|
||||
|
||||
})
|
||||
|
||||
it('renders the component with spinner while searching', () => {
|
||||
const data = () => { return { searching: true, isHovering: false } }
|
||||
cy.mount(AuthorCard, { ...mountOptions, data })
|
||||
|
||||
cy.get('&textInline').should('be.hidden')
|
||||
cy.get('&match').should('be.hidden')
|
||||
cy.get('&edit').should('be.hidden')
|
||||
cy.get('&spinner').should('be.visible')
|
||||
})
|
||||
|
||||
it('toasts after quick match with no updates', () => {
|
||||
const updatedMocks = {
|
||||
...mocks,
|
||||
$axios: {
|
||||
$post: cy.stub().resolves({ updated: false, author: { name: 'John Doe' } })
|
||||
},
|
||||
$toast: {
|
||||
success: cy.spy().as('success'),
|
||||
error: cy.spy().as('error'),
|
||||
info: cy.spy().as('info')
|
||||
}
|
||||
}
|
||||
cy.mount(AuthorCard, { ...mountOptions, mocks: updatedMocks })
|
||||
cy.get('&card').trigger('mouseover')
|
||||
cy.get('&match').click()
|
||||
|
||||
cy.get('&spinner').should('be.hidden')
|
||||
cy.get('@success').should('not.have.been.called')
|
||||
cy.get('@error').should('not.have.been.called')
|
||||
cy.get('@info').should('have.been.called')
|
||||
})
|
||||
|
||||
it('toasts after quick match with updates and no image', () => {
|
||||
const updatedMocks = {
|
||||
...mocks,
|
||||
$axios: {
|
||||
$post: cy.stub().resolves({ updated: true, author: { name: 'John Doe' } })
|
||||
},
|
||||
$toast: {
|
||||
success: cy.stub().as('success'),
|
||||
error: cy.spy().as('error'),
|
||||
info: cy.spy().as('info')
|
||||
}
|
||||
}
|
||||
cy.mount(AuthorCard, { ...mountOptions, mocks: updatedMocks })
|
||||
cy.get('&card').trigger('mouseover')
|
||||
cy.get('&match').click()
|
||||
|
||||
cy.get('&spinner').should('be.hidden')
|
||||
cy.get('@success').should('have.been.calledOnceWithExactly', 'Author John Doe was updated (no image found)')
|
||||
cy.get('@error').should('not.have.been.called')
|
||||
cy.get('@info').should('not.have.been.called')
|
||||
})
|
||||
|
||||
it('toasts after quick match with updates including image', () => {
|
||||
const updatedMocks = {
|
||||
...mocks,
|
||||
$axios: {
|
||||
$post: cy.stub().resolves({ updated: true, author: { name: 'John Doe', imagePath: "path/to/image" } })
|
||||
},
|
||||
$toast: {
|
||||
success: cy.stub().as('success'),
|
||||
error: cy.spy().as('error'),
|
||||
info: cy.spy().as('info')
|
||||
}
|
||||
}
|
||||
cy.mount(AuthorCard, { ...mountOptions, mocks: updatedMocks })
|
||||
cy.get('&card').trigger('mouseover')
|
||||
cy.get('&match').click()
|
||||
|
||||
cy.get('&spinner').should('be.hidden')
|
||||
cy.get('@success').should('have.been.calledOnceWithExactly', 'Author John Doe was updated')
|
||||
cy.get('@error').should('not.have.been.called')
|
||||
cy.get('@info').should('not.have.been.called')
|
||||
})
|
||||
})
|
342
client/cypress/tests/components/cards/LazyBookCard.cy.js
Normal file
342
client/cypress/tests/components/cards/LazyBookCard.cy.js
Normal file
@ -0,0 +1,342 @@
|
||||
import LazyBookCard from '@/components/cards/LazyBookCard'
|
||||
import Tooltip from '@/components/ui/Tooltip.vue'
|
||||
import ExplicitIndicator from '@/components/widgets/ExplicitIndicator.vue'
|
||||
import LoadingSpinner from '@/components/widgets/LoadingSpinner.vue'
|
||||
import { Constants } from '@/plugins/constants'
|
||||
|
||||
function createMountOptions() {
|
||||
const book = {
|
||||
id: '1',
|
||||
ino: '281474976785140',
|
||||
libraryId: 'library-123',
|
||||
folderId: 'folder-123',
|
||||
path: '/path/to/book',
|
||||
relPath: 'book',
|
||||
isFile: false,
|
||||
mtimeMs: 1689017292016,
|
||||
ctimeMs: 1689017292016,
|
||||
birthtimeMs: 1689017281555,
|
||||
addedAt: 1700154928492,
|
||||
updatedAt: 1713300533345,
|
||||
isMissing: false,
|
||||
isInvalid: false,
|
||||
mediaType: 'book',
|
||||
media: {
|
||||
id: 'book1',
|
||||
metadata: {
|
||||
title: 'The Fellowship of the Ring',
|
||||
titleIgnorePrefix: 'Fellowship of the Ring',
|
||||
subtitle: 'LOTR, Book 1',
|
||||
authorName: 'J. R. R. Tolkien',
|
||||
authorNameLF: 'Tolkien, J. R. R.',
|
||||
narratorName: 'Andy Sirkis',
|
||||
genres: ['Science Fiction & Fantasy'],
|
||||
publishedYear: '2017',
|
||||
publishedDate: null,
|
||||
publisher: 'Book Publisher',
|
||||
description: 'Book Description',
|
||||
isbn: null,
|
||||
asin: 'B075LXMLNV',
|
||||
language: 'English',
|
||||
explicit: false,
|
||||
abridged: false
|
||||
},
|
||||
coverPath: null,
|
||||
tags: ['Fantasy', 'Adventure'],
|
||||
numTracks: 1,
|
||||
numAudioFiles: 1,
|
||||
numChapters: 31,
|
||||
duration: 64410,
|
||||
size: 511206878
|
||||
},
|
||||
numFiles: 4,
|
||||
size: 511279587
|
||||
}
|
||||
|
||||
const propsData = {
|
||||
index: 0,
|
||||
bookMount: book,
|
||||
bookCoverAspectRatio: 1,
|
||||
bookshelfView: Constants.BookshelfView.DETAIL,
|
||||
continueListeningShelf: false,
|
||||
filterBy: null,
|
||||
width: 192,
|
||||
height: 192,
|
||||
sortingIgnorePrefix: false,
|
||||
orderBy: null
|
||||
}
|
||||
|
||||
const stubs = {
|
||||
'ui-tooltip': Tooltip,
|
||||
'widgets-explicit-indicator': ExplicitIndicator,
|
||||
'widgets-loading-spinner': LoadingSpinner
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$config: {
|
||||
routerBasePath: 'https://my.server.com'
|
||||
},
|
||||
$store: {
|
||||
commit: () => {},
|
||||
getters: {
|
||||
'user/getUserCanUpdate': true,
|
||||
'user/getUserCanDelete': true,
|
||||
'user/getUserCanDownload': true,
|
||||
'user/getIsAdminOrUp': true,
|
||||
'user/getUserMediaProgress': (id) => null,
|
||||
'libraries/getLibraryProvider': () => 'audible.us',
|
||||
'globals/getLibraryItemCoverSrc': () => 'https://my.server.com/book_placeholder.jpg',
|
||||
getLibraryItemsStreaming: () => null,
|
||||
getIsMediaQueued: () => false,
|
||||
getIsStreamingFromDifferentLibrary: () => false
|
||||
},
|
||||
state: {
|
||||
libraries: {
|
||||
currentLibraryId: 'library-123'
|
||||
},
|
||||
processingBatch: false,
|
||||
serverSettings: {
|
||||
dateFormat: 'MM/dd/yyyy'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { propsData, stubs, mocks }
|
||||
}
|
||||
|
||||
describe('LazyBookCard', () => {
|
||||
let mountOptions = null
|
||||
beforeEach(() => {
|
||||
mountOptions = createMountOptions()
|
||||
// cy.intercept(
|
||||
// 'https://my.server.com/**/*',
|
||||
// { middleware: true },
|
||||
// (req) => {
|
||||
// req.on('before:response', (res) => {
|
||||
// // force all API responses to not be cached
|
||||
// res.headers['cache-control'] = 'no-store'
|
||||
// })
|
||||
// }
|
||||
// )
|
||||
})
|
||||
|
||||
before(() => {
|
||||
// Put placeholder image is in the browser cache
|
||||
mountOptions = createMountOptions()
|
||||
cy.intercept('https://my.server.com/book_placeholder.jpg', { fixture: 'images/book_placeholder.jpg' }).as('bookCover')
|
||||
cy.mount(LazyBookCard, mountOptions)
|
||||
cy.wait('@bookCover')
|
||||
|
||||
// Put cover1 (aspect ratio 1.6) image in the browser cache
|
||||
mountOptions = createMountOptions()
|
||||
mountOptions.mocks.$store.getters['globals/getLibraryItemCoverSrc'] = () => 'https://my.server.com/cover1.jpg'
|
||||
cy.intercept('https://my.server.com/cover1.jpg', { fixture: 'images/cover1.jpg' }).as('bookCover1')
|
||||
cy.mount(LazyBookCard, mountOptions)
|
||||
cy.wait('@bookCover1')
|
||||
|
||||
// Put cover2 (aspect ratio 1) image in the browser cache
|
||||
mountOptions = createMountOptions()
|
||||
mountOptions.mocks.$store.getters['globals/getLibraryItemCoverSrc'] = () => 'https://my.server.com/cover2.jpg'
|
||||
cy.intercept('https://my.server.com/cover2.jpg', { fixture: 'images/cover2.jpg' }).as('bookCover2')
|
||||
cy.mount(LazyBookCard, mountOptions)
|
||||
cy.wait('@bookCover2')
|
||||
})
|
||||
|
||||
it('renders the component correctly', () => {
|
||||
cy.mount(LazyBookCard, mountOptions)
|
||||
|
||||
cy.get('&titleImageNotReady').should('be.hidden')
|
||||
cy.get('&coverImage').should('have.css', 'opacity', '1')
|
||||
cy.get('&coverBg').should('be.hidden')
|
||||
cy.get('&overlay').should('be.hidden')
|
||||
cy.get('&detailBottom').should('be.visible')
|
||||
cy.get('&title').should('have.text', 'The Fellowship of the Ring')
|
||||
cy.get('&explicitIndicator').should('not.exist')
|
||||
cy.get('&line2').should('have.text', 'J. R. R. Tolkien')
|
||||
cy.get('&line3').should('not.exist')
|
||||
cy.get('seriesSequenceList').should('not.exist')
|
||||
cy.get('&booksInSeries').should('not.exist')
|
||||
cy.get('&placeholderTitle').should('be.visible')
|
||||
cy.get('&placeholderTitleText').should('have.text', 'The Fellowship of the Ring')
|
||||
cy.get('&placeholderAuthor').should('be.visible')
|
||||
cy.get('&placeholderAuthorText').should('have.text', 'J. R. R. Tolkien')
|
||||
cy.get('&progressBar').should('be.hidden')
|
||||
cy.get('&finishedProgressBar').should('not.exist')
|
||||
cy.get('&loadingSpinner').should('not.exist')
|
||||
cy.get('&seriesNameOverlay').should('not.exist')
|
||||
cy.get('&errorTooltip').should('not.exist')
|
||||
cy.get('&rssFeed').should('not.exist')
|
||||
cy.get('&seriesSequence').should('not.exist')
|
||||
cy.get('&podcastEpisdeNumber').should('not.exist')
|
||||
|
||||
// this should actually fail, since the height does not cover
|
||||
// the detailBottom element, currently rendered outside the card's area,
|
||||
// and requires complex layout calculations outside of the component.
|
||||
// todo: fix the component to render the detailBottom element inside the card's area
|
||||
cy.get('#book-card-0').should(($el) => {
|
||||
const width = $el.width()
|
||||
const height = $el.height()
|
||||
expect(width).to.be.closeTo(mountOptions.propsData.width, 0.01)
|
||||
expect(height).to.be.closeTo(mountOptions.propsData.height, 0.01)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows overlay on mouseover', () => {
|
||||
cy.mount(LazyBookCard, mountOptions)
|
||||
cy.get('#book-card-0').trigger('mouseover')
|
||||
|
||||
cy.get('&titleImageNotReady').should('be.hidden')
|
||||
cy.get('&overlay').should('be.visible')
|
||||
cy.get('&playButton').should('be.visible')
|
||||
cy.get('&readButton').should('be.hidden')
|
||||
cy.get('&editButton').should('be.visible')
|
||||
cy.get('&selectedRadioButton').should('be.visible').and('have.text', 'radio_button_unchecked')
|
||||
cy.get('&moreButton').should('be.visible')
|
||||
cy.get('&ebookFormat').should('not.exist')
|
||||
})
|
||||
|
||||
it('routes to item page when clicked', () => {
|
||||
mountOptions.mocks.$router = { push: cy.stub().as('routerPush') }
|
||||
cy.mount(LazyBookCard, mountOptions)
|
||||
cy.get('#book-card-0').click()
|
||||
|
||||
cy.get('@routerPush').should('have.been.calledOnceWithExactly', '/item/1')
|
||||
})
|
||||
|
||||
it('shows titleImageNotReady and sets opacity 0 on coverImage when image not ready', () => {
|
||||
cy.mount(LazyBookCard, mountOptions)
|
||||
|
||||
cy.get('&titleImageNotReady').should('be.visible')
|
||||
cy.get('&coverImage').should('have.css', 'opacity', '0')
|
||||
})
|
||||
|
||||
it('shows coverBg when coverImage has different aspect ratio', () => {
|
||||
mountOptions.mocks.$store.getters['globals/getLibraryItemCoverSrc'] = () => 'https://my.server.com/cover1.jpg'
|
||||
cy.mount(LazyBookCard, mountOptions)
|
||||
|
||||
cy.get('&coverBg').should('be.visible')
|
||||
cy.get('&coverImage').should('have.class', 'object-contain')
|
||||
})
|
||||
|
||||
it('hides coverBg when coverImage has same aspect ratio', () => {
|
||||
mountOptions.mocks.$store.getters['globals/getLibraryItemCoverSrc'] = () => 'https://my.server.com/cover2.jpg'
|
||||
cy.mount(LazyBookCard, mountOptions)
|
||||
|
||||
cy.get('&coverBg').should('be.hidden')
|
||||
cy.get('&coverImage').should('have.class', 'object-fill')
|
||||
})
|
||||
|
||||
// The logic for displaying placeholder title and author seems incorrect.
|
||||
// It is currently based on existence of coverPath, but should be based weater the actual cover image is placeholder or not.
|
||||
// todo: fix the logic to display placeholder title and author based on the actual cover image.
|
||||
it('hides placeholderTitle and placeholderAuthor when book has cover', () => {
|
||||
mountOptions.mocks.$store.getters['globals/getLibraryItemCoverSrc'] = () => 'https://my.server.com/cover1.jpg'
|
||||
mountOptions.propsData.bookMount.media.coverPath = 'cover1.jpg'
|
||||
cy.mount(LazyBookCard, mountOptions)
|
||||
|
||||
cy.get('&placeholderTitle').should('not.exist')
|
||||
cy.get('&placeholderAuthor').should('not.exist')
|
||||
})
|
||||
|
||||
it('hides detailBottom when bookShelfView is STANDARD', () => {
|
||||
mountOptions.propsData.bookshelfView = Constants.BookshelfView.STANDARD
|
||||
cy.mount(LazyBookCard, mountOptions)
|
||||
|
||||
cy.get('&detailBottom').should('not.exist')
|
||||
})
|
||||
|
||||
it('shows explicit indicator when book is explicit', () => {
|
||||
mountOptions.propsData.bookMount.media.metadata.explicit = true
|
||||
cy.mount(LazyBookCard, mountOptions)
|
||||
|
||||
cy.get('&explicitIndicator').should('be.visible')
|
||||
})
|
||||
|
||||
describe('when collapsedSeries is present', () => {
|
||||
beforeEach(() => {
|
||||
mountOptions.propsData.bookMount.collapsedSeries = {
|
||||
id: 'series-123',
|
||||
name: 'The Lord of the Rings',
|
||||
nameIgnorePrefix: 'Lord of the Rings',
|
||||
numBooks: 3,
|
||||
libraryItemIds: ['1', '2', '3']
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the collpased series', () => {
|
||||
cy.mount(LazyBookCard, mountOptions)
|
||||
|
||||
cy.get('&seriesSequenceList').should('not.exist')
|
||||
cy.get('&booksInSeries').should('be.visible').and('have.text', '3')
|
||||
cy.get('&title').should('be.visible').and('have.text', 'The Lord of the Rings')
|
||||
cy.get('&line2').should('be.visible').and('have.text', '\u00a0')
|
||||
cy.get('&progressBar').should('be.hidden')
|
||||
})
|
||||
|
||||
it('shows the seriesNameOverlay on mouseover', () => {
|
||||
mountOptions.propsData.bookMount.media.metadata.series = {
|
||||
id: 'series-456',
|
||||
name: 'Middle Earth Chronicles',
|
||||
sequence: 1
|
||||
}
|
||||
cy.mount(LazyBookCard, mountOptions)
|
||||
cy.get('#book-card-0').trigger('mouseover')
|
||||
|
||||
cy.get('&seriesNameOverlay').should('be.visible').and('have.text', 'Middle Earth Chronicles')
|
||||
})
|
||||
|
||||
it('shows the seriesSequenceList when collapsed series has a sequence list', () => {
|
||||
mountOptions.propsData.bookMount.collapsedSeries.seriesSequenceList = '1-3'
|
||||
cy.mount(LazyBookCard, mountOptions)
|
||||
|
||||
cy.get('&seriesSequenceList').should('be.visible').and('have.text', '#1-3')
|
||||
cy.get('&booksInSeries').should('not.exist')
|
||||
})
|
||||
|
||||
it('routes to the series page when clicked', () => {
|
||||
mountOptions.mocks.$router = { push: cy.stub().as('routerPush') }
|
||||
cy.mount(LazyBookCard, mountOptions)
|
||||
cy.get('#book-card-0').click()
|
||||
|
||||
cy.get('@routerPush').should('have.been.calledOnceWithExactly', '/library/library-123/series/series-123')
|
||||
})
|
||||
|
||||
it('shows the series progress bar when series has progress', () => {
|
||||
mountOptions.mocks.$store.getters['user/getUserMediaProgress'] = (id) => {
|
||||
switch (id) {
|
||||
case '1':
|
||||
return { isFinished: true }
|
||||
case '2':
|
||||
return { progress: 0.5 }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
cy.mount(LazyBookCard, mountOptions)
|
||||
|
||||
cy.get('&progressBar')
|
||||
.should('be.visible')
|
||||
.and('have.class', 'bg-yellow-400')
|
||||
.and(($el) => {
|
||||
const width = $el.width()
|
||||
expect(width).to.be.closeTo(((1 + 0.5) / 3) * mountOptions.propsData.width, 0.01)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows full green progress bar when all books are finished', () => {
|
||||
mountOptions.mocks.$store.getters['user/getUserMediaProgress'] = (id) => {
|
||||
return { isFinished: true }
|
||||
}
|
||||
cy.mount(LazyBookCard, mountOptions)
|
||||
|
||||
cy.get('&progressBar')
|
||||
.should('be.visible')
|
||||
.and('have.class', 'bg-success')
|
||||
.and(($el) => {
|
||||
const width = $el.width()
|
||||
expect(width).to.be.equal(mountOptions.propsData.width)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user