mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-05-30 19:54:55 -04:00
chore: merge and resolve
This commit is contained in:
commit
e9e9a8ba75
@ -5,5 +5,6 @@ module.exports.config = {
|
|||||||
ConfigPath: Path.resolve('config'),
|
ConfigPath: Path.resolve('config'),
|
||||||
MetadataPath: Path.resolve('metadata'),
|
MetadataPath: Path.resolve('metadata'),
|
||||||
FFmpegPath: '/usr/bin/ffmpeg',
|
FFmpegPath: '/usr/bin/ffmpeg',
|
||||||
FFProbePath: '/usr/bin/ffprobe'
|
FFProbePath: '/usr/bin/ffprobe',
|
||||||
|
SkipBinariesCheck: true
|
||||||
}
|
}
|
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
|
name: 🐞 Bug Report
|
||||||
description: File a bug/issue
|
description: File a bug/issue and help us improve Audiobookshelf
|
||||||
title: "[Bug]: "
|
title: '[Bug]: '
|
||||||
labels: ["bug", "triage"]
|
labels: ['bug', 'triage']
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
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
|
- type: markdown
|
||||||
attributes:
|
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
|
- type: markdown
|
||||||
attributes:
|
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
|
- type: markdown
|
||||||
attributes:
|
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
|
- type: textarea
|
||||||
id: what-happened
|
id: what-happened
|
||||||
attributes:
|
attributes:
|
||||||
label: Describe the issue
|
label: What happened?
|
||||||
description: What happened & what did you expect to happen
|
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:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: steps-to-reproduce
|
id: steps-to-reproduce
|
||||||
attributes:
|
attributes:
|
||||||
label: Steps to reproduce the issue
|
label: Steps to reproduce the issue
|
||||||
value: "1. "
|
value: '1. '
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: '## Install Environment'
|
||||||
- type: input
|
- type: input
|
||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: Audiobookshelf version
|
label: Audiobookshelf version
|
||||||
description: Do not put 'Latest version', please put the actual version here
|
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:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
@ -46,6 +56,43 @@ body:
|
|||||||
- Debian/PPA
|
- Debian/PPA
|
||||||
- Windows Tray App
|
- Windows Tray App
|
||||||
- Built from source
|
- Built from source
|
||||||
- Other
|
- Other (list in "Additional Notes" box)
|
||||||
validations:
|
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
|
name: 🚀 Feature Request
|
||||||
description: Request a feature/enhancement
|
description: Request a feature/enhancement
|
||||||
title: "[Enhancement]: "
|
title: '[Enhancement]: '
|
||||||
labels: ["enhancement"]
|
labels: ['enhancement']
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
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
|
- type: markdown
|
||||||
attributes:
|
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
|
- type: textarea
|
||||||
id: describe
|
id: describe
|
||||||
attributes:
|
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:
|
validations:
|
||||||
required: true
|
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.*
|
sw.*
|
||||||
.DS_STORE
|
.DS_STORE
|
||||||
.idea/*
|
.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.formatOnSave": true,
|
||||||
"editor.detectIndentation": true,
|
"editor.detectIndentation": true,
|
||||||
"editor.tabSize": 2,
|
"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
|
# Package debian
|
||||||
pkg -t node18-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
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"
|
mv dist/debian.deb "dist/$OUTPUT_FILE"
|
||||||
chmod +x "dist/$OUTPUT_FILE"
|
|
||||||
|
|
||||||
echo "Finished! Filename: $OUTPUT_FILE"
|
echo "Finished! Filename: $OUTPUT_FILE"
|
||||||
|
@ -30,8 +30,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bookshelf-row {
|
.bookshelf-row {
|
||||||
/* Sidebar width + scrollbar width */
|
width: calc(100vw - (100vw - 100%));
|
||||||
width: calc(100vw - 88px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
@ -24,6 +24,7 @@
|
|||||||
word-wrap: normal;
|
word-wrap: normal;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-icons:not([class*="text-"]) {
|
.material-icons:not([class*="text-"]) {
|
||||||
|
@ -4,14 +4,14 @@
|
|||||||
<widgets-cover-size-widget class="fixed right-4 z-50" :style="{ bottom: streamLibraryItem ? '181px' : '16px' }" />
|
<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">
|
<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">
|
<div v-if="userIsAdminOrUp" class="flex">
|
||||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
<ui-btn to="/config" color="primary" class="w-52 mr-2">{{ $strings.ButtonConfigureScanner }}</ui-btn>
|
||||||
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
|
<ui-btn color="success" class="w-52" :loading="isScanningLibrary || tempIsScanning" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
|
<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>
|
</div>
|
||||||
<!-- Alternate plain view -->
|
<!-- Alternate plain view -->
|
||||||
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
||||||
@ -58,7 +58,8 @@ export default {
|
|||||||
scannerParseSubtitle: false,
|
scannerParseSubtitle: false,
|
||||||
wrapperClientWidth: 0,
|
wrapperClientWidth: 0,
|
||||||
shelves: [],
|
shelves: [],
|
||||||
lastItemIndexSelected: -1
|
lastItemIndexSelected: -1,
|
||||||
|
tempIsScanning: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -97,6 +98,9 @@ export default {
|
|||||||
},
|
},
|
||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
|
},
|
||||||
|
isScanningLibrary() {
|
||||||
|
return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.currentLibraryId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@ -273,14 +277,15 @@ export default {
|
|||||||
this.shelves = shelves
|
this.shelves = shelves
|
||||||
},
|
},
|
||||||
scan() {
|
scan() {
|
||||||
|
this.tempIsScanning = true
|
||||||
this.$store
|
this.$store
|
||||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
||||||
.then(() => {
|
|
||||||
this.$toast.success('Library scan started')
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to start scan', 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) {
|
userUpdated(user) {
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
<div class="w-full h-full pt-6">
|
<div class="w-full h-full pt-6">
|
||||||
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
|
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
|
||||||
<template v-for="(entity, index) in shelf.entities">
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
||||||
|
@ -98,6 +98,9 @@
|
|||||||
<template v-else-if="page === 'authors'">
|
<template v-else-if="page === 'authors'">
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn v-if="userCanUpdate && authors && authors.length && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</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() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
@ -455,6 +482,9 @@ export default {
|
|||||||
updateCollapseBookSeries() {
|
updateCollapseBookSeries() {
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
},
|
},
|
||||||
|
updateAuthorSort() {
|
||||||
|
this.saveSettings()
|
||||||
|
},
|
||||||
saveSettings() {
|
saveSettings() {
|
||||||
this.$store.dispatch('user/updateUserSettings', this.settings)
|
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>
|
<p class="text-center text-2xl mb-4 py-4">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
|
||||||
<div v-if="userIsAdminOrUp" class="flex">
|
<div v-if="userIsAdminOrUp" class="flex">
|
||||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">{{ $strings.ButtonConfigureScanner }}</ui-btn>
|
<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>
|
</div>
|
||||||
<div v-else-if="!totalShelves && initialized" class="w-full py-16">
|
<div v-else-if="!totalShelves && initialized" class="w-full py-16">
|
||||||
@ -62,7 +62,8 @@ export default {
|
|||||||
currScrollTop: 0,
|
currScrollTop: 0,
|
||||||
resizeTimeout: null,
|
resizeTimeout: null,
|
||||||
mountWindowWidth: 0,
|
mountWindowWidth: 0,
|
||||||
lastItemIndexSelected: -1
|
lastItemIndexSelected: -1,
|
||||||
|
tempIsScanning: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@ -208,6 +209,9 @@ export default {
|
|||||||
},
|
},
|
||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
|
},
|
||||||
|
isScanningLibrary() {
|
||||||
|
return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.currentLibraryId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@ -727,14 +731,15 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
scan() {
|
scan() {
|
||||||
|
this.tempIsScanning = true
|
||||||
this.$store
|
this.$store
|
||||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
|
.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
|
||||||
.then(() => {
|
|
||||||
this.$toast.success('Library scan started')
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to start scan', 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);
|
background: var(--bookshelf-divider-bg);
|
||||||
box-shadow: 2px 14px 8px #111111aa;
|
box-shadow: 2px 14px 8px #111111aa;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,25 +1,25 @@
|
|||||||
<template>
|
<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 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" />
|
<covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
<div class="flex items-start mb-6 lg:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0 w-full">
|
||||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
|
<div class="flex items-center">
|
||||||
{{ title }}
|
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
|
||||||
</nuxt-link>
|
{{ title }}
|
||||||
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
|
</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>
|
<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-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="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
|
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base truncate">
|
||||||
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
|
<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>
|
||||||
<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>
|
</div>
|
||||||
|
<div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-gray-400 flex items-center">
|
<div class="text-gray-400 flex items-center">
|
||||||
@ -29,7 +29,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-tooltip direction="top" :text="$strings.LabelClosePlayer">
|
<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>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<player-ui
|
<player-ui
|
||||||
@ -82,13 +82,11 @@ export default {
|
|||||||
sleepTimer: null,
|
sleepTimer: null,
|
||||||
displayTitle: null,
|
displayTitle: null,
|
||||||
currentPlaybackRate: 1,
|
currentPlaybackRate: 1,
|
||||||
syncFailedToast: null
|
syncFailedToast: null,
|
||||||
|
coverAspectRatio: 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
coverAspectRatio() {
|
|
||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
|
||||||
},
|
|
||||||
isSquareCover() {
|
isSquareCover() {
|
||||||
return this.coverAspectRatio === 1
|
return this.coverAspectRatio === 1
|
||||||
},
|
},
|
||||||
@ -138,7 +136,7 @@ export default {
|
|||||||
return this.streamLibraryItem?.mediaType === 'music'
|
return this.streamLibraryItem?.mediaType === 'music'
|
||||||
},
|
},
|
||||||
isExplicit() {
|
isExplicit() {
|
||||||
return this.mediaMetadata.explicit || false
|
return !!this.mediaMetadata.explicit
|
||||||
},
|
},
|
||||||
mediaMetadata() {
|
mediaMetadata() {
|
||||||
return this.media.metadata || {}
|
return this.media.metadata || {}
|
||||||
@ -457,6 +455,9 @@ export default {
|
|||||||
episodeId,
|
episodeId,
|
||||||
queueItems: payload.queueItems || []
|
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(() => {
|
this.$nextTick(() => {
|
||||||
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
||||||
})
|
})
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<div class="flex items-center mb-2">
|
||||||
<slot name="header-prefix"></slot>
|
<slot name="header-prefix"></slot>
|
||||||
<h1 class="text-xl">{{ headerText }}</h1>
|
<h1 class="text-xl">{{ headerText }}</h1>
|
||||||
|
@ -1,35 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<nuxt-link :to="`/author/${author.id}`">
|
<nuxt-link :to="`/author/${author?.id}`">
|
||||||
<div @mouseover="mouseover" @mouseleave="mouseleave">
|
<div cy-id="card" :style="{ width: width + 'px'}" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
<div cy-id="imageArea" :style="{ height: height + 'px' }" class=" bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||||
<!-- Image or placeholder -->
|
<!-- Image or placeholder -->
|
||||||
<covers-author-image :author="author" />
|
<covers-author-image :author="author"/>
|
||||||
|
|
||||||
<!-- Author name & num books overlay -->
|
<!-- 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 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>
|
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} {{ $strings.LabelBooks }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search icon btn -->
|
<!-- 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">
|
<ui-tooltip :text="$strings.ButtonQuickMatch" direction="bottom">
|
||||||
<span class="material-icons text-lg">search</span>
|
<span class="material-icons text-lg">search</span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</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">
|
<ui-tooltip :text="$strings.LabelEdit" direction="bottom">
|
||||||
<span class="material-icons text-lg">edit</span>
|
<span class="material-icons text-lg">edit</span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading spinner -->
|
<!-- 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="" />
|
<widgets-loading-spinner size="" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="nameBelow" class="w-full py-1 px-2">
|
<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>
|
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
@ -13,9 +13,9 @@
|
|||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p class="text-sm md:text-base">{{ book.publishedYear }}</p>
|
<p class="text-sm md:text-base">{{ book.publishedYear }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">by {{ book.author }}</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">Narrated by {{ book.narrator }}</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">Runtime: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</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-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">
|
<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">
|
<p class="leading-3 text-xs text-gray-400">
|
||||||
@ -29,9 +29,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="px-4 flex-grow">
|
<div v-else class="px-4 flex-grow">
|
||||||
<h1>
|
<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>
|
</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 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>
|
<p class="text-xs text-gray-400 leading-5">{{ book.trackCount }} Episodes</p>
|
||||||
</div>
|
</div>
|
||||||
@ -75,11 +75,11 @@ export default {
|
|||||||
let differenceInMinutes = currentBookDurationMinutes - this.book.duration
|
let differenceInMinutes = currentBookDurationMinutes - this.book.duration
|
||||||
if (differenceInMinutes < 0) {
|
if (differenceInMinutes < 0) {
|
||||||
differenceInMinutes = Math.abs(differenceInMinutes)
|
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) {
|
} 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: {
|
methods: {
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300" v-html="matchHtml" />
|
<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" />
|
<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" />
|
<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 === '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 === 'tags') return `<p class="truncate">${this.$strings.LabelTags}: ${html}</p>`
|
||||||
if (this.matchKey === 'subtitle') return `<p class="truncate">${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 === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
||||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${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>`
|
if (this.matchKey === 'series') return `<p class="truncate">${this.$strings.LabelSeries}: ${html}</p>`
|
||||||
@ -90,4 +90,4 @@ export default {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -21,15 +21,16 @@
|
|||||||
<div v-if="!isPodcast" class="flex items-end">
|
<div v-if="!isPodcast" class="flex items-end">
|
||||||
<ui-text-input-with-label v-model.trim="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
|
<ui-text-input-with-label v-model.trim="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
|
||||||
<ui-tooltip :text="$strings.LabelUploaderItemFetchMetadataHelp">
|
<ui-tooltip :text="$strings.LabelUploaderItemFetchMetadataHelp">
|
||||||
<div
|
<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">
|
||||||
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>
|
<span class="text-base text-white text-opacity-80 font-mono material-icons">sync</span>
|
||||||
</div>
|
</div>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="w-full">
|
<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" />
|
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -40,7 +41,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<div class="w-full">
|
<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" />
|
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs h-10" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -51,10 +55,10 @@
|
|||||||
<tables-uploaded-files-table v-if="item.ignoredFiles.length" :title="$strings.HeaderIgnoredFiles" :files="item.ignoredFiles" />
|
<tables-uploaded-files-table v-if="item.ignoredFiles.length" :title="$strings.HeaderIgnoredFiles" :files="item.ignoredFiles" />
|
||||||
</template>
|
</template>
|
||||||
<widgets-alert v-if="uploadSuccess" type="success">
|
<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>
|
||||||
<widgets-alert v-if="uploadFailed" type="error">
|
<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>
|
</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">
|
<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: {
|
props: {
|
||||||
item: {
|
item: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => { }
|
default: () => {}
|
||||||
},
|
},
|
||||||
mediaType: String,
|
mediaType: String,
|
||||||
processing: Boolean,
|
processing: Boolean,
|
||||||
@ -99,7 +103,7 @@ export default {
|
|||||||
if (this.isPodcast) return this.itemData.title
|
if (this.isPodcast) return this.itemData.title
|
||||||
|
|
||||||
const outputPathParts = [this.itemData.author, this.itemData.series, 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)
|
return Path.join(...cleanedOutputPathParts)
|
||||||
},
|
},
|
||||||
|
@ -1,128 +1,124 @@
|
|||||||
<template>
|
<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 -->
|
<!-- 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 class="absolute cover-bg" ref="coverBg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alternative bookshelf title/author/sort -->
|
<!-- 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' }">
|
<div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||||
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||||
<p ref="displayTitle" class="truncate">{{ displayTitle }}</p>
|
<p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p>
|
||||||
<widgets-explicit-indicator :explicit="isExplicit" />
|
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
<p cy-id="line2" 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="line3" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 * 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>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequenceList }}</p>
|
||||||
</div>
|
</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>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ booksInSeries }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
||||||
<div 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>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="text-gray-300 text-center">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cover Image -->
|
<!-- 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 -->
|
<!-- 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>
|
<div>
|
||||||
<p class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">
|
<p cy-id="placeholderTitleText" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
|
||||||
{{ titleCleaned }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</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' }">
|
<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 class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
<p cy-id="placeholderAuthorText" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No progress shown for collapsed series in library and podcasts (unless showing podcast episode) -->
|
<!-- No progress shown for 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>
|
<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>
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
<!-- Overlay is not shown if collapsing series in library -->
|
<!-- 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 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 v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
|
<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">
|
<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>
|
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">auto_stories</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Radio button -->
|
<!-- 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>
|
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- More Menu Icon -->
|
<!-- 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>
|
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
||||||
</div>
|
</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>
|
<span class="text-white/80" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ ebookFormat }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Processing/loading spinner overlay -->
|
<!-- 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" />
|
<widgets-loading-spinner size="la-lg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Series name overlay -->
|
<!-- 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>
|
<p v-if="seriesName" class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ seriesName }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error widget -->
|
<!-- 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">
|
<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>
|
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
||||||
</div>
|
</div>
|
||||||
</ui-tooltip>
|
</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>
|
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.5 + 'rem' }">rss_feed</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Series sequence -->
|
<!-- 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>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast Episode # -->
|
<!-- 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' }">
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
|
||||||
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast Num Episodes -->
|
<!-- 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>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast Num Episodes -->
|
<!-- 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>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -343,11 +339,22 @@ export default {
|
|||||||
if (!this.userProgress || this.userProgress.progress) return false
|
if (!this.userProgress || this.userProgress.progress) return false
|
||||||
return this.userProgress.ebookProgress > 0
|
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() {
|
userProgressPercent() {
|
||||||
if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0)
|
let progressPercent = this.itemIsFinished ? 1 : this.booksInSeries ? this.seriesProgressPercent : this.useEBookProgress ? this.userProgress?.ebookProgress || 0 : this.userProgress?.progress || 0
|
||||||
return this.userProgress ? Math.max(Math.min(1, this.userProgress.progress), 0) || 0 : 0
|
return Math.max(Math.min(1, progressPercent), 0)
|
||||||
},
|
},
|
||||||
itemIsFinished() {
|
itemIsFinished() {
|
||||||
|
if (this.booksInSeries) return this.seriesIsFinished
|
||||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||||
},
|
},
|
||||||
seriesIsFinished() {
|
seriesIsFinished() {
|
||||||
@ -358,7 +365,7 @@ export default {
|
|||||||
},
|
},
|
||||||
showError() {
|
showError() {
|
||||||
if (this.recentEpisode) return false // Dont show podcast error on episode card
|
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() {
|
libraryItemIdStreaming() {
|
||||||
return this.store.getters['getLibraryItemIdStreaming']
|
return this.store.getters['getLibraryItemIdStreaming']
|
||||||
@ -388,29 +395,13 @@ export default {
|
|||||||
isInvalid() {
|
isInvalid() {
|
||||||
return this._libraryItem.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() {
|
errorText() {
|
||||||
if (this.isMissing) return 'Item directory is missing!'
|
if (this.isMissing) return 'Item directory is missing!'
|
||||||
else if (this.isInvalid) {
|
else if (this.isInvalid) {
|
||||||
if (this.isPodcast) return 'Podcast has no episodes'
|
if (this.isPodcast) return 'Podcast has no episodes'
|
||||||
return 'Item has no audio tracks & ebook'
|
return 'Item has no audio tracks & ebook'
|
||||||
}
|
}
|
||||||
let txt = ''
|
return 'Unknown Error'
|
||||||
if (this.numMissingParts) {
|
|
||||||
txt += `${this.numMissingParts} missing parts.`
|
|
||||||
}
|
|
||||||
if (this.numInvalidAudioFiles) {
|
|
||||||
if (txt) txt += ' '
|
|
||||||
txt += `${this.numInvalidAudioFiles} invalid audio files.`
|
|
||||||
}
|
|
||||||
return txt || 'Unknown Error'
|
|
||||||
},
|
},
|
||||||
overlayWrapperClasslist() {
|
overlayWrapperClasslist() {
|
||||||
const classes = []
|
const classes = []
|
||||||
|
@ -1,28 +1,28 @@
|
|||||||
<template>
|
<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="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
|
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
|
||||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<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>
|
||||||
|
|
||||||
<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>
|
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
||||||
</div>
|
</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` }">
|
<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>
|
</div>
|
||||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
<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 class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
<p cy-id="detailBottomDisplayTitle" 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>
|
<p cy-id="detailBottomSortLine" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -119,9 +119,13 @@ export default {
|
|||||||
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
|
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
|
||||||
},
|
},
|
||||||
seriesPercentInProgress() {
|
seriesPercentInProgress() {
|
||||||
let totalFinishedAndInProgress = this.seriesBooksFinished.length
|
if (!this.books.length) return 0
|
||||||
if (this.hasSeriesBookInProgress) totalFinishedAndInProgress += 1
|
let progressPercent = 0
|
||||||
return Math.min(1, Math.max(0, totalFinishedAndInProgress / this.books.length))
|
this.seriesBookProgress.forEach((progress) => {
|
||||||
|
progressPercent += progress.isFinished ? 1 : progress.progress || 0
|
||||||
|
})
|
||||||
|
progressPercent /= this.books.length
|
||||||
|
return Math.min(1, Math.max(0, progressPercent))
|
||||||
},
|
},
|
||||||
isSeriesFinished() {
|
isSeriesFinished() {
|
||||||
return this.books.length === this.seriesBooksFinished.length
|
return this.books.length === this.seriesBooksFinished.length
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(name)}`">
|
||||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
<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">
|
<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>
|
<span class="material-icons-outlined text-[10rem]">record_voice_over</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Narrator name & num books overlay -->
|
<!-- Narrator name & num books overlay -->
|
||||||
<div class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
<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 cy-id="name" class="text-center font-semibold truncate text-gray-200" :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="numBooks" class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@ -21,8 +21,14 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
width: Number,
|
width: {
|
||||||
height: Number,
|
type: Number,
|
||||||
|
default: 150
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 100
|
||||||
|
},
|
||||||
sizeMultiplier: {
|
sizeMultiplier: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 1
|
default: 1
|
||||||
|
@ -89,6 +89,14 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</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 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">
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
|
||||||
@ -182,6 +190,9 @@ export default {
|
|||||||
narrators() {
|
narrators() {
|
||||||
return this.mediaMetadata.narrators || []
|
return this.mediaMetadata.narrators || []
|
||||||
},
|
},
|
||||||
|
language() {
|
||||||
|
return this.mediaMetadata.language || null
|
||||||
|
},
|
||||||
durationPretty() {
|
durationPretty() {
|
||||||
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)
|
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)
|
||||||
|
|
||||||
|
@ -37,12 +37,12 @@
|
|||||||
<span class="material-icons text-2xl">arrow_left</span>
|
<span class="material-icons text-2xl">arrow_left</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
|
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
|
||||||
<div class="flex items-center justify-center">
|
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<template v-for="item in sublistItems">
|
<template v-for="item in sublistItems">
|
||||||
@ -106,31 +106,37 @@ export default {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelGenre,
|
text: this.$strings.LabelGenre,
|
||||||
|
textPlural: this.$strings.LabelGenres,
|
||||||
value: 'genres',
|
value: 'genres',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelTag,
|
text: this.$strings.LabelTag,
|
||||||
|
textPlural: this.$strings.LabelTags,
|
||||||
value: 'tags',
|
value: 'tags',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelAuthor,
|
text: this.$strings.LabelAuthor,
|
||||||
|
textPlural: this.$strings.LabelAuthors,
|
||||||
value: 'authors',
|
value: 'authors',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelNarrator,
|
text: this.$strings.LabelNarrator,
|
||||||
|
textPlural: this.$strings.LabelNarrators,
|
||||||
value: 'narrators',
|
value: 'narrators',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelPublisher,
|
text: this.$strings.LabelPublisher,
|
||||||
|
textPlural: this.$strings.LabelPublishers,
|
||||||
value: 'publishers',
|
value: 'publishers',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelLanguage,
|
text: this.$strings.LabelLanguage,
|
||||||
|
textPlural: this.$strings.LabelLanguages,
|
||||||
value: 'languages',
|
value: 'languages',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
@ -149,36 +155,43 @@ export default {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelGenre,
|
text: this.$strings.LabelGenre,
|
||||||
|
textPlural: this.$strings.LabelGenres,
|
||||||
value: 'genres',
|
value: 'genres',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelTag,
|
text: this.$strings.LabelTag,
|
||||||
|
textPlural: this.$strings.LabelTags,
|
||||||
value: 'tags',
|
value: 'tags',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelSeries,
|
text: this.$strings.LabelSeries,
|
||||||
|
textPlural: this.$strings.LabelSeries,
|
||||||
value: 'series',
|
value: 'series',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelAuthor,
|
text: this.$strings.LabelAuthor,
|
||||||
|
textPlural: this.$strings.LabelAuthors,
|
||||||
value: 'authors',
|
value: 'authors',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelNarrator,
|
text: this.$strings.LabelNarrator,
|
||||||
|
textPlural: this.$strings.LabelNarrators,
|
||||||
value: 'narrators',
|
value: 'narrators',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelPublisher,
|
text: this.$strings.LabelPublisher,
|
||||||
|
textPlural: this.$strings.LabelPublishers,
|
||||||
value: 'publishers',
|
value: 'publishers',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelLanguage,
|
text: this.$strings.LabelLanguage,
|
||||||
|
textPlural: this.$strings.LabelLanguages,
|
||||||
value: 'languages',
|
value: 'languages',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
@ -227,14 +240,22 @@ export default {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelGenre,
|
text: this.$strings.LabelGenre,
|
||||||
|
textPlural: this.$strings.LabelGenres,
|
||||||
value: 'genres',
|
value: 'genres',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelTag,
|
text: this.$strings.LabelTag,
|
||||||
|
textPlural: this.$strings.LabelTags,
|
||||||
value: 'tags',
|
value: 'tags',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLanguage,
|
||||||
|
textPlural: this.$strings.LabelLanguages,
|
||||||
|
value: 'languages',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.ButtonIssues,
|
text: this.$strings.ButtonIssues,
|
||||||
value: 'issues',
|
value: 'issues',
|
||||||
@ -250,11 +271,13 @@ export default {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelGenre,
|
text: this.$strings.LabelGenre,
|
||||||
|
textPlural: this.$strings.LabelGenres,
|
||||||
value: 'genres',
|
value: 'genres',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelTag,
|
text: this.$strings.LabelTag,
|
||||||
|
textPlural: this.$strings.LabelTags,
|
||||||
value: 'tags',
|
value: 'tags',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
@ -274,6 +297,13 @@ export default {
|
|||||||
selectedItemSublist() {
|
selectedItemSublist() {
|
||||||
return this.selected?.includes('.') ? this.selected.split('.')[0] : null
|
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() {
|
selectedText() {
|
||||||
if (!this.selected) return ''
|
if (!this.selected) return ''
|
||||||
const parts = this.selected.split('.')
|
const parts = this.selected.split('.')
|
||||||
@ -368,9 +398,17 @@ export default {
|
|||||||
id: 'ebook',
|
id: 'ebook',
|
||||||
name: this.$strings.LabelHasEbook
|
name: this.$strings.LabelHasEbook
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'no-ebook',
|
||||||
|
name: this.$strings.LabelMissingEbook
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'supplementary',
|
id: 'supplementary',
|
||||||
name: this.$strings.LabelHasSupplementaryEbook
|
name: this.$strings.LabelHasSupplementaryEbook
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'no-supplementary',
|
||||||
|
name: this.$strings.LabelMissingSupplementaryEbook
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -492,4 +530,4 @@ export default {
|
|||||||
.libraryFilterMenu {
|
.libraryFilterMenu {
|
||||||
max-height: calc(100vh - 125px);
|
max-height: calc(100vh - 125px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -84,4 +84,4 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -101,9 +101,14 @@ export default {
|
|||||||
},
|
},
|
||||||
fullCoverUrl() {
|
fullCoverUrl() {
|
||||||
if (!this.libraryItem) return null
|
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)
|
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() {
|
cover() {
|
||||||
return this.media.coverPath || this.placeholderUrl
|
return this.media.coverPath || this.placeholderUrl
|
||||||
},
|
},
|
||||||
@ -126,9 +131,6 @@ export default {
|
|||||||
authorBottom() {
|
authorBottom() {
|
||||||
return 0.75 * this.sizeMultiplier
|
return 0.75 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
resolution() {
|
resolution() {
|
||||||
return `${this.naturalWidth}x${this.naturalHeight}px`
|
return `${this.naturalWidth}x${this.naturalHeight}px`
|
||||||
}
|
}
|
||||||
@ -136,7 +138,7 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
clickCover() {
|
clickCover() {
|
||||||
if (this.expandOnClick && this.libraryItem) {
|
if (this.expandOnClick && this.libraryItem) {
|
||||||
this.$store.commit('globals/setRawCoverPreviewModal', this.libraryItem.id)
|
this.$store.commit('globals/setRawCoverPreviewModal', this.rawCoverUrl)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setCoverBg() {
|
setCoverBg() {
|
||||||
|
@ -65,7 +65,7 @@ export default {
|
|||||||
return 0.8 * this.sizeMultiplier
|
return 0.8 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
resolution() {
|
resolution() {
|
||||||
return `${this.naturalWidth}x${this.naturalHeight}px`
|
return `${this.naturalWidth}×${this.naturalHeight}px`
|
||||||
},
|
},
|
||||||
placeholderUrl() {
|
placeholderUrl() {
|
||||||
const config = this.$config || this.$nuxt.$config
|
const config = this.$config || this.$nuxt.$config
|
||||||
|
@ -10,21 +10,21 @@
|
|||||||
<div class="w-full p-8">
|
<div class="w-full p-8">
|
||||||
<div class="flex py-2">
|
<div class="flex py-2">
|
||||||
<div class="w-1/2 px-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>
|
||||||
<div class="w-1/2 px-2">
|
<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-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>
|
</div>
|
||||||
<div v-show="!isEditingRoot" class="flex py-2">
|
<div v-show="!isEditingRoot" class="flex py-2">
|
||||||
<div class="w-1/2 px-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>
|
||||||
<div class="px-2 w-52">
|
<div class="px-2 w-52">
|
||||||
<ui-dropdown v-model="newUser.type" :label="$strings.LabelAccountType" :disabled="isEditingRoot" :items="accountTypes" small @input="userTypeUpdated" />
|
<ui-dropdown v-model="newUser.type" :label="$strings.LabelAccountType" :disabled="isEditingRoot" :items="accountTypes" small @input="userTypeUpdated" />
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="flex-grow" /> -->
|
|
||||||
<div class="flex items-center pt-4 px-2">
|
<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>
|
<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" />
|
<ui-toggle-switch labeledBy="user-enabled-toggle" v-model="newUser.isActive" :disabled="isEditingRoot" />
|
||||||
|
@ -34,11 +34,6 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
watch: {
|
|
||||||
value(newVal) {
|
|
||||||
this.$nextTick(this.scrollToChapter)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
computed: {
|
||||||
show: {
|
show: {
|
||||||
get() {
|
get() {
|
||||||
@ -53,7 +48,7 @@ export default {
|
|||||||
return this.playbackRate
|
return this.playbackRate
|
||||||
},
|
},
|
||||||
currentChapterId() {
|
currentChapterId() {
|
||||||
return this.currentChapter ? this.currentChapter.id : null
|
return this.currentChapter?.id || null
|
||||||
},
|
},
|
||||||
currentChapterStart() {
|
currentChapterStart() {
|
||||||
return (this.currentChapter?.start || 0) / this._playbackRate
|
return (this.currentChapter?.start || 0) / this._playbackRate
|
||||||
@ -74,6 +69,11 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
updated() {
|
||||||
|
if (this.value) {
|
||||||
|
this.$nextTick(this.scrollToChapter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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 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">
|
<div class="flex items-center">
|
||||||
<p class="text-base text-gray-200">{{ _session.displayTitle }}</p>
|
<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>
|
||||||
|
|
||||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||||
@ -88,10 +88,11 @@
|
|||||||
<p class="mb-1">{{ _session.mediaPlayer }}</p>
|
<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="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="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p>
|
||||||
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
|
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
|
||||||
<p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</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.sdkVersion" class="mb-1">SDK {{ $strings.LabelVersion }}: {{ deviceInfo.sdkVersion }}</p>
|
||||||
<p v-if="deviceInfo.deviceType" class="mb-1">{{ $strings.LabelType }}: {{ deviceInfo.deviceType }}</p>
|
<p v-if="deviceInfo.deviceType" class="mb-1">{{ $strings.LabelType }}: {{ deviceInfo.deviceType }}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -141,10 +142,14 @@ export default {
|
|||||||
if (!this.deviceInfo.osName) return null
|
if (!this.deviceInfo.osName) return null
|
||||||
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
|
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
|
||||||
},
|
},
|
||||||
clientDisplayName() {
|
deviceDisplayName() {
|
||||||
if (!this.deviceInfo.manufacturer || !this.deviceInfo.model) return null
|
if (!this.deviceInfo.manufacturer || !this.deviceInfo.model) return null
|
||||||
return `${this.deviceInfo.manufacturer} ${this.deviceInfo.model}`
|
return `${this.deviceInfo.manufacturer} ${this.deviceInfo.model}`
|
||||||
},
|
},
|
||||||
|
clientDisplayName() {
|
||||||
|
if (!this.deviceInfo.clientName) return null
|
||||||
|
return `${this.deviceInfo.clientName} ${this.deviceInfo.clientVersion || ''}`
|
||||||
|
},
|
||||||
playMethodName() {
|
playMethodName() {
|
||||||
const playMethod = this._session.playMethod
|
const playMethod = this._session.playMethod
|
||||||
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
|
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
|
||||||
|
@ -20,14 +20,11 @@ export default {
|
|||||||
this.$store.commit('globals/setShowRawCoverPreviewModal', val)
|
this.$store.commit('globals/setShowRawCoverPreviewModal', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
selectedLibraryItemId() {
|
|
||||||
return this.$store.state.globals.selectedLibraryItemId
|
|
||||||
},
|
|
||||||
rawCoverUrl() {
|
rawCoverUrl() {
|
||||||
return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.selectedLibraryItemId, null, true)
|
return this.$store.state.globals.selectedRawCoverUrl
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="w-40 p-2">
|
<div class="w-40 p-2">
|
||||||
<div class="w-full h-45 relative">
|
<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">
|
<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>
|
<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>
|
</div>
|
||||||
@ -30,9 +30,6 @@
|
|||||||
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
|
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="p-2">
|
||||||
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" :label="$strings.LabelDescription" :rows="8" />
|
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" :label="$strings.LabelDescription" :rows="8" />
|
||||||
</div>
|
</div>
|
||||||
@ -106,9 +103,9 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
init() {
|
init() {
|
||||||
this.imageUrl = ''
|
this.imageUrl = ''
|
||||||
this.authorCopy.name = this.author.name
|
this.authorCopy = {
|
||||||
this.authorCopy.asin = this.author.asin
|
...this.author
|
||||||
this.authorCopy.description = this.author.description
|
}
|
||||||
},
|
},
|
||||||
removeClick() {
|
removeClick() {
|
||||||
const payload = {
|
const payload = {
|
||||||
@ -171,7 +168,9 @@ export default {
|
|||||||
.$delete(`/api/authors/${this.authorId}/image`)
|
.$delete(`/api/authors/${this.authorId}/image`)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
|
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) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
@ -196,7 +195,9 @@ export default {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.imageUrl = ''
|
this.imageUrl = ''
|
||||||
this.$toast.success('Author image updated')
|
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) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
@ -231,8 +232,11 @@ export default {
|
|||||||
} else if (response.updated) {
|
} else if (response.updated) {
|
||||||
if (response.author.imagePath) {
|
if (response.author.imagePath) {
|
||||||
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
|
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
|
||||||
this.$store.commit('globals/showEditAuthorModal', response.author)
|
|
||||||
} else this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
|
} else this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
|
||||||
|
|
||||||
|
this.authorCopy = {
|
||||||
|
...response.author
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.$toast.info('No updates were made for Author')
|
this.$toast.info('No updates were made for Author')
|
||||||
}
|
}
|
||||||
@ -242,4 +246,4 @@ export default {
|
|||||||
mounted() {},
|
mounted() {},
|
||||||
beforeDestroy() {}
|
beforeDestroy() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -122,7 +122,7 @@ export default {
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to get collections', error)
|
console.error('Failed to get collections', error)
|
||||||
this.$toast.error('Failed to load collections')
|
this.$toast.error(this.$strings.ToastFailedToLoadData)
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
|
@ -46,7 +46,12 @@ export default {
|
|||||||
ereaderDevice: {
|
ereaderDevice: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
}
|
},
|
||||||
|
users: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
loadUsers: Function
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@ -56,8 +61,7 @@ export default {
|
|||||||
email: '',
|
email: '',
|
||||||
availabilityOption: 'adminAndUp',
|
availabilityOption: 'adminAndUp',
|
||||||
users: []
|
users: []
|
||||||
},
|
}
|
||||||
users: []
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@ -108,25 +112,13 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
availabilityOptionChanged(option) {
|
availabilityOptionChanged(option) {
|
||||||
if (option === 'specificUsers' && !this.users.length) {
|
if (option === 'specificUsers' && !this.users.length) {
|
||||||
this.loadUsers()
|
this.callLoadUsers()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async loadUsers() {
|
async callLoadUsers() {
|
||||||
this.processing = true
|
this.processing = true
|
||||||
this.users = await this.$axios
|
await this.loadUsers()
|
||||||
.$get('/api/users')
|
this.processing = false
|
||||||
.then((res) => {
|
|
||||||
return res.users.sort((a, b) => {
|
|
||||||
return a.createdAt - b.createdAt
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
submitForm() {
|
submitForm() {
|
||||||
this.$refs.ereaderNameInput.blur()
|
this.$refs.ereaderNameInput.blur()
|
||||||
@ -226,10 +218,6 @@ export default {
|
|||||||
this.newDevice.email = this.ereaderDevice.email
|
this.newDevice.email = this.ereaderDevice.email
|
||||||
this.newDevice.availabilityOption = this.ereaderDevice.availabilityOption || 'adminOrUp'
|
this.newDevice.availabilityOption = this.ereaderDevice.availabilityOption || 'adminOrUp'
|
||||||
this.newDevice.users = this.ereaderDevice.users || []
|
this.newDevice.users = this.ereaderDevice.users || []
|
||||||
|
|
||||||
if (this.newDevice.availabilityOption === 'specificUsers' && !this.users.length) {
|
|
||||||
this.loadUsers()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
this.newDevice.name = ''
|
this.newDevice.name = ''
|
||||||
this.newDevice.email = ''
|
this.newDevice.email = ''
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||||
<div class="w-full mb-4">
|
<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">
|
<div v-if="!chapters.length" class="py-4 text-center">
|
||||||
<p class="mb-8 text-xl">{{ $strings.MessageNoChapters }}</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -23,7 +23,7 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
return this.libraryItem?.media || {}
|
||||||
},
|
},
|
||||||
chapters() {
|
chapters() {
|
||||||
return this.media.chapters || []
|
return this.media.chapters || []
|
||||||
@ -32,6 +32,15 @@ export default {
|
|||||||
return this.$store.getters['user/getUserCanUpdate']
|
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>
|
<template>
|
||||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
|
<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="flex flex-col sm:flex-row mb-4">
|
||||||
<div class="relative">
|
<div class="relative self-center">
|
||||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, libraryItemUpdatedAt, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, libraryItemUpdatedAt, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
|
||||||
<!-- book cover overlay -->
|
<!-- book cover overlay -->
|
||||||
@ -14,7 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 class="flex items-center">
|
||||||
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 md:min-w-32">
|
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 md:min-w-32">
|
||||||
<ui-file-input ref="fileInput" @change="fileUploadSelected">
|
<ui-file-input ref="fileInput" @change="fileUploadSelected">
|
||||||
@ -49,20 +49,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="submitSearchForm">
|
<form @submit.prevent="submitSearchForm">
|
||||||
<div class="flex items-center justify-start -mx-1 h-20">
|
<div class="flex flex-wrap sm:flex-nowrap items-center justify-start -mx-1">
|
||||||
<div class="w-48 px-1">
|
<div class="w-48 flex-grow p-1">
|
||||||
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
|
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
|
||||||
</div>
|
</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" />
|
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
|
||||||
</div>
|
</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" />
|
<ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</form>
|
</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>
|
<p v-if="!coversFound.length">{{ $strings.MessageNoCoversFound }}</p>
|
||||||
<template v-for="cover in coversFound">
|
<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)">
|
<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">
|
<td class="text-center w-20 min-w-20">
|
||||||
<p>{{ episode.episode }}</p>
|
<p>{{ episode.episode }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td dir="auto">
|
||||||
{{ episode.title }}
|
{{ episode.title }}
|
||||||
</td>
|
</td>
|
||||||
<td class="font-mono text-center">
|
<td class="font-mono text-center">
|
||||||
|
@ -32,7 +32,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-xl pl-3">{{ $strings.HeaderUpdateDetails }}</p>
|
<p class="text-xl pl-3">{{ $strings.HeaderUpdateDetails }}</p>
|
||||||
</div>
|
</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">
|
<form @submit.prevent="submitMatchUpdate">
|
||||||
<div v-if="selectedMatchOrig.cover" class="flex flex-wrap md:flex-nowrap items-center justify-center">
|
<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">
|
<div class="flex flex-grow items-center py-2">
|
||||||
@ -42,15 +42,15 @@
|
|||||||
|
|
||||||
<div class="flex py-2">
|
<div class="flex py-2">
|
||||||
<div>
|
<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">
|
<a :href="selectedMatch.cover" target="_blank" class="bg-primary">
|
||||||
<covers-preview-cover :src="selectedMatch.cover" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-preview-cover :src="selectedMatch.cover" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="media.coverPath">
|
<div v-if="media.coverPath" class="ml-0.5">
|
||||||
<p class="text-center text-gray-200">Current</p>
|
<p class="text-center text-gray-200">{{ $strings.LabelCurrent }}</p>
|
||||||
<a :href="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" target="_blank" class="bg-primary">
|
<a :href="$store.getters['globals/getLibraryItemCoverSrc'](libraryItem, 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" />
|
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrc'](libraryItem, null, true)" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -79,7 +79,7 @@
|
|||||||
<div v-if="selectedMatchOrig.narrator" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.narrator" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.narrator" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.narrator" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-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>
|
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.narratorName || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -122,7 +122,7 @@
|
|||||||
<div v-if="selectedMatchOrig.tags" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.tags" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.tags" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.tags" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-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>
|
<p v-if="media.tags" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ media.tags.join(', ') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -180,14 +180,14 @@
|
|||||||
<ui-checkbox v-model="selectedMatchUsage.explicit" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.explicit" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.explicit != null }">
|
<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" />
|
<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>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.abridged != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.abridged == null }">
|
<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" />
|
<ui-checkbox v-model="selectedMatchUsage.abridged" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.abridged != null }">
|
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -280,6 +280,9 @@ export default {
|
|||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
|
filterData() {
|
||||||
|
return this.$store.state.libraries.filterData
|
||||||
|
},
|
||||||
providers() {
|
providers() {
|
||||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||||
return this.$store.state.scanners.providers
|
return this.$store.state.scanners.providers
|
||||||
@ -305,11 +308,16 @@ export default {
|
|||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.mediaType == 'podcast'
|
return this.mediaType == 'podcast'
|
||||||
},
|
},
|
||||||
|
narrators() {
|
||||||
|
return this.filterData.narrators || []
|
||||||
|
},
|
||||||
genres() {
|
genres() {
|
||||||
const filterData = this.$store.state.libraries.filterData || {}
|
const currentGenres = this.filterData.genres || []
|
||||||
const currentGenres = filterData.genres || []
|
|
||||||
const selectedMatchGenres = this.selectedMatch.genres || []
|
const selectedMatchGenres = this.selectedMatch.genres || []
|
||||||
return [...new Set([...currentGenres, ...selectedMatchGenres])]
|
return [...new Set([...currentGenres, ...selectedMatchGenres])]
|
||||||
|
},
|
||||||
|
tags() {
|
||||||
|
return this.filterData.tags || []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@ -479,6 +487,12 @@ export default {
|
|||||||
// match.genres = match.genres.join(',')
|
// match.genres = match.genres.join(',')
|
||||||
match.genres = match.genres.split(',').map((g) => g.trim())
|
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)
|
console.log('Select Match', match)
|
||||||
@ -508,7 +522,10 @@ export default {
|
|||||||
} else if (key === 'author' && !this.isPodcast) {
|
} else if (key === 'author' && !this.isPodcast) {
|
||||||
var authors = this.selectedMatch[key]
|
var authors = this.selectedMatch[key]
|
||||||
if (!Array.isArray(authors)) {
|
if (!Array.isArray(authors)) {
|
||||||
authors = authors.split(',').map((au) => au.trim())
|
authors = authors
|
||||||
|
.split(',')
|
||||||
|
.map((au) => au.trim())
|
||||||
|
.filter((au) => !!au)
|
||||||
}
|
}
|
||||||
var authorPayload = []
|
var authorPayload = []
|
||||||
authors.forEach((authorName) =>
|
authors.forEach((authorName) =>
|
||||||
@ -519,11 +536,11 @@ export default {
|
|||||||
)
|
)
|
||||||
updatePayload.metadata.authors = authorPayload
|
updatePayload.metadata.authors = authorPayload
|
||||||
} else if (key === 'narrator') {
|
} 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') {
|
} else if (key === 'genres') {
|
||||||
updatePayload.metadata.genres = [...this.selectedMatch[key]]
|
updatePayload.metadata.genres = [...this.selectedMatch[key]]
|
||||||
} else if (key === 'tags') {
|
} else if (key === 'tags') {
|
||||||
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
|
updatePayload.tags = this.selectedMatch[key]
|
||||||
} else if (key === 'itunesId') {
|
} else if (key === 'itunesId') {
|
||||||
updatePayload.metadata.itunesId = Number(this.selectedMatch[key])
|
updatePayload.metadata.itunesId = Number(this.selectedMatch[key])
|
||||||
} else {
|
} else {
|
||||||
@ -546,24 +563,11 @@ export default {
|
|||||||
// Persist in local storage
|
// Persist in local storage
|
||||||
localStorage.setItem('selectedMatchUsage', JSON.stringify(this.selectedMatchUsage))
|
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 (Object.keys(updatePayload).length) {
|
||||||
|
if (updatePayload.metadata.cover) {
|
||||||
|
updatePayload.url = updatePayload.metadata.cover
|
||||||
|
delete updatePayload.metadata.cover
|
||||||
|
}
|
||||||
const mediaUpdatePayload = updatePayload
|
const mediaUpdatePayload = updatePayload
|
||||||
const updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
|
const updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
|
||||||
console.error('Failed to update', error)
|
console.error('Failed to update', error)
|
||||||
|
@ -20,7 +20,7 @@
|
|||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
|
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
|
||||||
<ui-text-input ref="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.">
|
<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">
|
<p class="pl-4 text-base">
|
||||||
Max new episodes to download per check
|
Max new episodes to download per check
|
||||||
@ -129,9 +129,12 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.$refs.maxEpisodesInput && this.$refs.maxEpisodesInput.isFocused) {
|
|
||||||
|
if (this.$refs.maxEpisodesInput?.isFocused) {
|
||||||
this.$refs.maxEpisodesInput.blur()
|
this.$refs.maxEpisodesInput.blur()
|
||||||
return
|
}
|
||||||
|
if (this.$refs.maxEpisodesToDownloadInput?.isFocused) {
|
||||||
|
this.$refs.maxEpisodesToDownloadInput.blur()
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatePayload = {
|
const updatePayload = {
|
||||||
@ -140,9 +143,11 @@ export default {
|
|||||||
if (this.enableAutoDownloadEpisodes) {
|
if (this.enableAutoDownloadEpisodes) {
|
||||||
updatePayload.autoDownloadSchedule = this.cronExpression
|
updatePayload.autoDownloadSchedule = this.cronExpression
|
||||||
}
|
}
|
||||||
|
this.newMaxEpisodesToKeep = Number(this.newMaxEpisodesToKeep)
|
||||||
if (this.newMaxEpisodesToKeep !== this.maxEpisodesToKeep) {
|
if (this.newMaxEpisodesToKeep !== this.maxEpisodesToKeep) {
|
||||||
updatePayload.maxEpisodesToKeep = this.newMaxEpisodesToKeep
|
updatePayload.maxEpisodesToKeep = this.newMaxEpisodesToKeep
|
||||||
}
|
}
|
||||||
|
this.newMaxNewEpisodesToDownload = Number(this.newMaxNewEpisodesToDownload)
|
||||||
if (this.newMaxNewEpisodesToDownload !== this.maxNewEpisodesToDownload) {
|
if (this.newMaxNewEpisodesToDownload !== this.maxNewEpisodesToDownload) {
|
||||||
updatePayload.maxNewEpisodesToDownload = this.newMaxNewEpisodesToDownload
|
updatePayload.maxNewEpisodesToDownload = this.newMaxNewEpisodesToDownload
|
||||||
}
|
}
|
||||||
|
@ -127,6 +127,7 @@ export default {
|
|||||||
skipMatchingMediaWithIsbn: false,
|
skipMatchingMediaWithIsbn: false,
|
||||||
autoScanCronExpression: null,
|
autoScanCronExpression: null,
|
||||||
hideSingleBookSeries: false,
|
hideSingleBookSeries: false,
|
||||||
|
onlyShowLaterBooksInContinueSeries: false,
|
||||||
metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
|
metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -49,8 +49,30 @@
|
|||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -72,7 +94,9 @@ export default {
|
|||||||
skipMatchingMediaWithAsin: false,
|
skipMatchingMediaWithAsin: false,
|
||||||
skipMatchingMediaWithIsbn: false,
|
skipMatchingMediaWithIsbn: false,
|
||||||
audiobooksOnly: false,
|
audiobooksOnly: false,
|
||||||
|
epubsAllowScriptedContent: false,
|
||||||
hideSingleBookSeries: false,
|
hideSingleBookSeries: false,
|
||||||
|
onlyShowLaterBooksInContinueSeries: false,
|
||||||
podcastSearchRegion: 'us'
|
podcastSearchRegion: 'us'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -106,7 +130,9 @@ export default {
|
|||||||
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
||||||
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
|
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
|
||||||
audiobooksOnly: !!this.audiobooksOnly,
|
audiobooksOnly: !!this.audiobooksOnly,
|
||||||
|
epubsAllowScriptedContent: !!this.epubsAllowScriptedContent,
|
||||||
hideSingleBookSeries: !!this.hideSingleBookSeries,
|
hideSingleBookSeries: !!this.hideSingleBookSeries,
|
||||||
|
onlyShowLaterBooksInContinueSeries: !!this.onlyShowLaterBooksInContinueSeries,
|
||||||
podcastSearchRegion: this.podcastSearchRegion
|
podcastSearchRegion: this.podcastSearchRegion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -120,7 +146,9 @@ export default {
|
|||||||
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
|
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
|
||||||
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
||||||
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
|
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
|
||||||
|
this.epubsAllowScriptedContent = !!this.librarySettings.epubsAllowScriptedContent
|
||||||
this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries
|
this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries
|
||||||
|
this.onlyShowLaterBooksInContinueSeries = !!this.librarySettings.onlyShowLaterBooksInContinueSeries
|
||||||
this.podcastSearchRegion = this.librarySettings.podcastSearchRegion || 'us'
|
this.podcastSearchRegion = this.librarySettings.podcastSearchRegion || 'us'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -128,4 +156,4 @@ export default {
|
|||||||
this.init()
|
this.init()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -115,7 +115,7 @@ export default {
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to get playlists', error)
|
console.error('Failed to get playlists', error)
|
||||||
this.$toast.error('Failed to load user playlists')
|
this.$toast.error(this.$strings.ToastFailedToLoadData)
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
|
@ -15,8 +15,8 @@
|
|||||||
<p class="text-xs text-gray-300">{{ podcastAuthor }}</p>
|
<p class="text-xs text-gray-300">{{ podcastAuthor }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-lg font-semibold mb-6">{{ title }}</p>
|
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
|
||||||
<div v-if="description" class="default-style" v-html="description" />
|
<div v-if="description" dir="auto" class="default-style" v-html="description" />
|
||||||
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
|
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
|
@ -1,22 +1,30 @@
|
|||||||
<template>
|
<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" />
|
<div class="flex-grow" />
|
||||||
<template v-if="!loading">
|
<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">
|
<ui-tooltip direction="top" :text="$strings.ButtonPreviousChapter" class="mr-4 lg:mr-8">
|
||||||
<span class="material-icons text-2xl sm:text-3xl">first_page</span>
|
<button :aria-label="$strings.ButtonPreviousChapter" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter">
|
||||||
</button>
|
<span class="material-icons text-2xl sm:text-3xl">first_page</span>
|
||||||
<button :aria-label="$strings.ButtonJumpBackward" class="flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
</button>
|
||||||
<span class="material-icons text-2xl sm:text-3xl">replay_10</span>
|
</ui-tooltip>
|
||||||
</button>
|
<ui-tooltip direction="top" :text="$strings.ButtonJumpBackward">
|
||||||
<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">
|
<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>
|
<span class="material-icons text-2xl">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button :aria-label="$strings.ButtonJumpForward" class="flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
<ui-tooltip direction="top" :text="$strings.ButtonJumpForward">
|
||||||
<span class="material-icons text-2xl sm:text-3xl">forward_10</span>
|
<button :aria-label="$strings.ButtonJumpForward" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||||
</button>
|
<span class="material-icons text-2xl sm:text-3xl">forward_10</span>
|
||||||
<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">
|
</button>
|
||||||
<span class="material-icons text-2xl sm:text-3xl">last_page</span>
|
</ui-tooltip>
|
||||||
</button>
|
<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" />
|
<controls-playback-speed-control v-model="playbackRateInput" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
@ -57,7 +57,6 @@ export default {
|
|||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
duration: {
|
duration: {
|
||||||
immediate: true,
|
|
||||||
handler() {
|
handler() {
|
||||||
this.setChapterTicks()
|
this.setChapterTicks()
|
||||||
}
|
}
|
||||||
@ -205,10 +204,14 @@ export default {
|
|||||||
},
|
},
|
||||||
windowResize() {
|
windowResize() {
|
||||||
this.setTrackWidth()
|
this.setTrackWidth()
|
||||||
|
this.setChapterTicks()
|
||||||
|
this.updatePlayedTrackWidth()
|
||||||
|
this.updateBufferTrack()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.setTrackWidth()
|
this.setTrackWidth()
|
||||||
|
this.setChapterTicks()
|
||||||
window.addEventListener('resize', this.windowResize)
|
window.addEventListener('resize', this.windowResize)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full -mt-6">
|
<div class="w-full -mt-6">
|
||||||
<div class="w-full relative mb-1">
|
<div class="w-full relative mb-1">
|
||||||
<div class="absolute -top-10 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> -->
|
<!-- <span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span> -->
|
||||||
|
|
||||||
<ui-tooltip direction="top" :text="$strings.LabelVolume">
|
<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>
|
||||||
|
|
||||||
<ui-tooltip direction="top" :text="$strings.LabelSleepTimer">
|
<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>
|
<p class="font-mono text-sm hidden sm:block text-gray-100 pointer-events-auto"> / {{ progressPercent }}%</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p class="text-xs sm:text-sm text-gray-300 pt-0.5">
|
<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>
|
</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||||
|
@ -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 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 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">
|
<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" />
|
<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,
|
ebookLocation: this.page,
|
||||||
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
|
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)
|
console.error('ComicReader.updateProgress failed:', error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -334,7 +334,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
parseFilenames(filenames) {
|
parseFilenames(filenames) {
|
||||||
const acceptableImages = ['.jpeg', '.jpg', '.png']
|
const acceptableImages = ['.jpeg', '.jpg', '.png', '.webp']
|
||||||
var imageFiles = filenames.filter((f) => {
|
var imageFiles = filenames.filter((f) => {
|
||||||
return acceptableImages.includes((Path.extname(f) || '').toLowerCase())
|
return acceptableImages.includes((Path.extname(f) || '').toLowerCase())
|
||||||
})
|
})
|
||||||
@ -386,4 +386,4 @@ export default {
|
|||||||
.pagemenu {
|
.pagemenu {
|
||||||
max-height: calc(100% - 48px);
|
max-height: calc(100% - 48px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -46,7 +46,8 @@ export default {
|
|||||||
font: 'serif',
|
font: 'serif',
|
||||||
fontScale: 100,
|
fontScale: 100,
|
||||||
lineSpacing: 115,
|
lineSpacing: 115,
|
||||||
spread: 'auto'
|
spread: 'auto',
|
||||||
|
textStroke: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -63,6 +64,9 @@ export default {
|
|||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem?.id
|
return this.libraryItem?.id
|
||||||
},
|
},
|
||||||
|
allowScriptedContent() {
|
||||||
|
return this.$store.getters['libraries/getLibraryEpubsAllowScriptedContent']
|
||||||
|
},
|
||||||
hasPrev() {
|
hasPrev() {
|
||||||
return !this.rendition?.location?.atStart
|
return !this.rendition?.location?.atStart
|
||||||
},
|
},
|
||||||
@ -106,11 +110,14 @@ export default {
|
|||||||
|
|
||||||
const fontScale = this.ereaderSettings.fontScale / 100
|
const fontScale = this.ereaderSettings.fontScale / 100
|
||||||
|
|
||||||
|
const textStroke = this.ereaderSettings.textStroke / 100
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'*': {
|
'*': {
|
||||||
color: `${fontColor}!important`,
|
color: `${fontColor}!important`,
|
||||||
'background-color': `${backgroundColor}!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: {
|
a: {
|
||||||
color: `${fontColor}!important`
|
color: `${fontColor}!important`
|
||||||
@ -192,7 +199,7 @@ export default {
|
|||||||
*/
|
*/
|
||||||
updateProgress(payload) {
|
updateProgress(payload) {
|
||||||
if (!this.keepProgress) return
|
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)
|
console.error('EpubReader.updateProgress failed:', error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -316,7 +323,7 @@ export default {
|
|||||||
reader.rendition = reader.book.renderTo('viewer', {
|
reader.rendition = reader.book.renderTo('viewer', {
|
||||||
width: this.readerWidth,
|
width: this.readerWidth,
|
||||||
height: this.readerHeight * 0.8,
|
height: this.readerHeight * 0.8,
|
||||||
allowScriptedContent: true,
|
allowScriptedContent: this.allowScriptedContent,
|
||||||
spread: 'auto',
|
spread: 'auto',
|
||||||
snap: true,
|
snap: true,
|
||||||
manager: 'continuous',
|
manager: 'continuous',
|
||||||
|
@ -23,13 +23,10 @@
|
|||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="overflow-auto">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="text-center py-2 text-lg">
|
|
||||||
<p>{{ page }} / {{ numPages }}</p>
|
|
||||||
</div> -->
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -57,7 +54,8 @@ export default {
|
|||||||
rotate: 0,
|
rotate: 0,
|
||||||
loadedRatio: 0,
|
loadedRatio: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
numPages: 0
|
numPages: 0,
|
||||||
|
pdfDocInitParams: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -108,14 +106,6 @@ export default {
|
|||||||
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
|
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
|
||||||
}
|
}
|
||||||
return `/api/items/${this.libraryItemId}/ebook`
|
return `/api/items/${this.libraryItemId}/ebook`
|
||||||
},
|
|
||||||
pdfDocInitParams() {
|
|
||||||
return {
|
|
||||||
url: this.ebookUrl,
|
|
||||||
httpHeaders: {
|
|
||||||
Authorization: `Bearer ${this.userToken}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@ -136,7 +126,7 @@ export default {
|
|||||||
ebookLocation: this.page,
|
ebookLocation: this.page,
|
||||||
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
|
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)
|
console.error('EpubReader.updateProgress failed:', error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -149,6 +139,7 @@ export default {
|
|||||||
this.loadedRatio = progress
|
this.loadedRatio = progress
|
||||||
},
|
},
|
||||||
numPagesLoaded(e) {
|
numPagesLoaded(e) {
|
||||||
|
if (!e) return
|
||||||
this.numPages = e
|
this.numPages = e
|
||||||
},
|
},
|
||||||
prev() {
|
prev() {
|
||||||
@ -167,15 +158,25 @@ export default {
|
|||||||
resize() {
|
resize() {
|
||||||
this.windowWidth = window.innerWidth
|
this.windowWidth = window.innerWidth
|
||||||
this.windowHeight = window.innerHeight
|
this.windowHeight = window.innerHeight
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.pdfDocInitParams = {
|
||||||
|
url: this.ebookUrl,
|
||||||
|
httpHeaders: {
|
||||||
|
Authorization: `Bearer ${this.userToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.windowWidth = window.innerWidth
|
this.windowWidth = window.innerWidth
|
||||||
this.windowHeight = window.innerHeight
|
this.windowHeight = window.innerHeight
|
||||||
window.addEventListener('resize', this.resize)
|
window.addEventListener('resize', this.resize)
|
||||||
|
|
||||||
|
this.init()
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
window.removeEventListener('resize', this.resize)
|
window.removeEventListener('resize', this.resize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -98,6 +98,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" @input="settingsUpdated" />
|
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" @input="settingsUpdated" />
|
||||||
</div>
|
</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="flex items-center">
|
||||||
<div class="w-40">
|
<div class="w-40">
|
||||||
<p class="text-lg">{{ $strings.LabelLayout }}:</p>
|
<p class="text-lg">{{ $strings.LabelLayout }}:</p>
|
||||||
@ -130,7 +136,9 @@ export default {
|
|||||||
font: 'serif',
|
font: 'serif',
|
||||||
fontScale: 100,
|
fontScale: 100,
|
||||||
lineSpacing: 115,
|
lineSpacing: 115,
|
||||||
spread: 'auto'
|
fontBoldness: 100,
|
||||||
|
spread: 'auto',
|
||||||
|
textStroke: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -378,7 +386,12 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const settings = localStorage.getItem('ereaderSettings')
|
const settings = localStorage.getItem('ereaderSettings')
|
||||||
if (settings) {
|
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()
|
this.settingsUpdated()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -416,4 +429,4 @@ export default {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,45 +1,45 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap justify-center mt-6">
|
<div class="flex flex-wrap justify-center mt-6">
|
||||||
<div class="flex px-2">
|
<div class="flex p-2">
|
||||||
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
<svg class="h-14 w-14" viewBox="0 0 24 24">
|
||||||
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
|
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div class="px-2">
|
<div class="px-1">
|
||||||
<p class="text-4xl md:text-5xl font-bold">{{ totalItems }}</p>
|
<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>
|
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsInLibrary }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex px-4">
|
<div class="flex p-2">
|
||||||
<span class="material-icons text-7xl">show_chart</span>
|
<span class="material-icons text-5xl py-1">show_chart</span>
|
||||||
<div class="px-1">
|
<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>
|
<p class="text-xs md:text-sm text-white text-opacity-80">{{ useOverallHours ? $strings.LabelStatsOverallHours : $strings.LabelStatsOverallDays }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isBookLibrary" class="flex px-4">
|
<div v-if="isBookLibrary" class="flex p-2">
|
||||||
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</svg>
|
||||||
<div class="px-1">
|
<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>
|
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAuthors }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex px-4">
|
<div class="flex p-2">
|
||||||
<span class="material-icons-outlined text-6xl pt-1">insert_drive_file</span>
|
<span class="material-icons-outlined text-5xl pt-1">insert_drive_file</span>
|
||||||
<div class="px-1">
|
<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>
|
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelSize }} ({{ totalSizeMod }})</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex px-4">
|
<div class="flex p-2">
|
||||||
<span class="material-icons-outlined text-6xl pt-1">audio_file</span>
|
<span class="material-icons-outlined text-5xl pt-1">audio_file</span>
|
||||||
<div class="px-1">
|
<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>
|
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAudioTracks }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -271,7 +271,7 @@ export default {
|
|||||||
this.$emit('update:processing', true)
|
this.$emit('update:processing', true)
|
||||||
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => {
|
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => {
|
||||||
console.error('Failed to load stats for year', 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
|
return null
|
||||||
})
|
})
|
||||||
await this.initCanvas()
|
await this.initCanvas()
|
||||||
@ -282,4 +282,4 @@ export default {
|
|||||||
this.init()
|
this.init()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -7,9 +7,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center">
|
<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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- your year in review -->
|
<!-- your year in review -->
|
||||||
@ -20,24 +21,27 @@
|
|||||||
<!-- previous button -->
|
<!-- previous button -->
|
||||||
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
|
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" 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="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>
|
</ui-btn>
|
||||||
<!-- share button -->
|
<!-- share button -->
|
||||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview"> 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" />
|
<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>
|
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<!-- refresh button -->
|
<!-- refresh button -->
|
||||||
<ui-btn small :disabled="processingYearInReview" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReview">
|
<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>
|
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
<!-- next button -->
|
<!-- next button -->
|
||||||
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
|
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" 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>
|
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
@ -46,7 +50,7 @@
|
|||||||
<!-- your year in review short -->
|
<!-- your year in review short -->
|
||||||
<div class="w-full max-w-[800px] mx-auto my-4">
|
<div class="w-full max-w-[800px] mx-auto my-4">
|
||||||
<!-- share button -->
|
<!-- share button -->
|
||||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewShort" class="inline-flex sm:hidden items-center font-semibold mb-1" @click="shareYearInReviewShort"> 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" />
|
<stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -56,24 +60,25 @@
|
|||||||
<!-- previous button -->
|
<!-- previous button -->
|
||||||
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
|
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" 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="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>
|
</ui-btn>
|
||||||
<!-- share button -->
|
<!-- share button -->
|
||||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer"> 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" />
|
<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>
|
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<!-- refresh button -->
|
<!-- refresh button -->
|
||||||
<ui-btn small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReviewServer">
|
<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>
|
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
<!-- next button -->
|
<!-- next button -->
|
||||||
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
|
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" 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>
|
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
@ -250,7 +250,7 @@ export default {
|
|||||||
this.$emit('update:processing', true)
|
this.$emit('update:processing', true)
|
||||||
this.yearStats = await this.$axios.$get(`/api/stats/year/${this.year}`).catch((err) => {
|
this.yearStats = await this.$axios.$get(`/api/stats/year/${this.year}`).catch((err) => {
|
||||||
console.error('Failed to load stats for year', 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
|
return null
|
||||||
})
|
})
|
||||||
await this.initCanvas()
|
await this.initCanvas()
|
||||||
@ -261,4 +261,4 @@ export default {
|
|||||||
this.init()
|
this.init()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -180,7 +180,7 @@ export default {
|
|||||||
this.$emit('update:processing', true)
|
this.$emit('update:processing', true)
|
||||||
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => {
|
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => {
|
||||||
console.error('Failed to load stats for year', 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
|
return null
|
||||||
})
|
})
|
||||||
await this.initCanvas()
|
await this.initCanvas()
|
||||||
@ -191,4 +191,4 @@ export default {
|
|||||||
this.init()
|
this.init()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -94,11 +94,11 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.$delete(`/api/items/${this.libraryItemId}/file/${this.track.audioFile.ino}`)
|
.$delete(`/api/items/${this.libraryItemId}/file/${this.track.audioFile.ino}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('File deleted')
|
this.$toast.success(this.$strings.ToastDeleteFileSuccess)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to delete file', 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() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="text-center mt-4">
|
<div class="text-center mt-4 relative">
|
||||||
<div class="flex py-4">
|
<div class="flex py-4">
|
||||||
<ui-file-input ref="fileInput" class="mr-2" accept=".audiobookshelf" @change="backupUploaded">{{ $strings.ButtonUploadBackup }}</ui-file-input>
|
<ui-file-input ref="fileInput" class="mr-2" accept=".audiobookshelf" @change="backupUploaded">{{ $strings.ButtonUploadBackup }}</ui-file-input>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
@ -54,6 +54,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</prompt-dialog>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -64,6 +68,7 @@ export default {
|
|||||||
showConfirmApply: false,
|
showConfirmApply: false,
|
||||||
selectedBackup: null,
|
selectedBackup: null,
|
||||||
isBackingUp: false,
|
isBackingUp: false,
|
||||||
|
isApplyingBackup: false,
|
||||||
processing: false,
|
processing: false,
|
||||||
backups: []
|
backups: []
|
||||||
}
|
}
|
||||||
@ -85,19 +90,21 @@ export default {
|
|||||||
},
|
},
|
||||||
confirm() {
|
confirm() {
|
||||||
this.showConfirmApply = false
|
this.showConfirmApply = false
|
||||||
|
this.isApplyingBackup = true
|
||||||
|
|
||||||
this.$axios
|
this.$axios
|
||||||
.$get(`/api/backups/${this.selectedBackup.id}/apply`)
|
.$get(`/api/backups/${this.selectedBackup.id}/apply`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.isBackingUp = false
|
|
||||||
location.replace('/config/backups?backup=1')
|
location.replace('/config/backups?backup=1')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.isBackingUp = false
|
|
||||||
console.error('Failed to apply backup', error)
|
console.error('Failed to apply backup', error)
|
||||||
const errorMsg = error.response.data || this.$strings.ToastBackupRestoreFailed
|
const errorMsg = error.response.data || this.$strings.ToastBackupRestoreFailed
|
||||||
this.$toast.error(errorMsg)
|
this.$toast.error(errorMsg)
|
||||||
})
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.isApplyingBackup = false
|
||||||
|
})
|
||||||
},
|
},
|
||||||
deleteBackupClick(backup) {
|
deleteBackupClick(backup) {
|
||||||
if (confirm(this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]))) {
|
if (confirm(this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]))) {
|
||||||
@ -169,7 +176,7 @@ export default {
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to load backups', error)
|
console.error('Failed to load backups', error)
|
||||||
this.$toast.error('Failed to load backups')
|
this.$toast.error(this.$strings.ToastFailedToLoadData)
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
@ -180,7 +187,6 @@ export default {
|
|||||||
this.loadBackups()
|
this.loadBackups()
|
||||||
if (this.$route.query.backup) {
|
if (this.$route.query.backup) {
|
||||||
this.$toast.success('Backup applied successfully')
|
this.$toast.success('Backup applied successfully')
|
||||||
this.$router.replace('/config')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
<p class="pr-4">{{ $strings.HeaderChapters }}</p>
|
<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>
|
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ chapters.length }}</span>
|
||||||
<div class="flex-grow" />
|
<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' : ''">
|
<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>
|
<span class="material-icons text-4xl">expand_more</span>
|
||||||
</div>
|
</div>
|
||||||
@ -21,7 +21,7 @@
|
|||||||
<td class="text-left">
|
<td class="text-left">
|
||||||
<p class="px-4">{{ chapter.id }}</p>
|
<p class="px-4">{{ chapter.id }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td dir="auto">
|
||||||
{{ chapter.title }}
|
{{ chapter.title }}
|
||||||
</td>
|
</td>
|
||||||
<td class="font-mono text-center hover:underline cursor-pointer" @click.stop="goToTimestamp(chapter.start)">
|
<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)
|
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() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<div v-else-if="!processing" class="text-center py-8">
|
<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>
|
||||||
|
|
||||||
<div v-if="processing" class="absolute inset-0 h-full flex items-center justify-center bg-black/40 rounded-md">
|
<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
|
this.$axios
|
||||||
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
|
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('File deleted')
|
this.$toast.success(this.$strings.ToastDeleteFileSuccess)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to delete file', error)
|
console.error('Failed to delete file', error)
|
||||||
this.$toast.error('Failed to delete file')
|
this.$toast.error(this.$strings.ToastDeleteFileFailed)
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
@ -136,4 +136,4 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -89,11 +89,11 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
|
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('File deleted')
|
this.$toast.success(this.$strings.ToastDeleteFileSuccess)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to delete file', 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() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
<th class="w-32 hidden sm:table-cell">{{ $strings.LabelCreatedAt }}</th>
|
<th class="w-32 hidden sm:table-cell">{{ $strings.LabelCreatedAt }}</th>
|
||||||
<th class="w-32"></th>
|
<th class="w-32"></th>
|
||||||
</tr>
|
</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>
|
<td>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<widgets-online-indicator :value="!!usersOnline[user.id]" />
|
<widgets-online-indicator :value="!!usersOnline[user.id]" />
|
||||||
|
@ -38,7 +38,7 @@
|
|||||||
<div v-if="userCanUpdate" class="mx-1" :class="isHovering ? '' : 'ml-6'">
|
<div v-if="userCanUpdate" class="mx-1" :class="isHovering ? '' : 'ml-6'">
|
||||||
<ui-icon-btn icon="edit" borderless @click="clickEdit" />
|
<ui-icon-btn icon="edit" borderless @click="clickEdit" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="userCanDelete" class="mx-1">
|
<div class="mx-1" :class="isHovering ? '' : 'ml-6'">
|
||||||
<ui-icon-btn icon="close" borderless @click="removeClick" />
|
<ui-icon-btn icon="close" borderless @click="removeClick" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -75,8 +75,7 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
translateDistance() {
|
translateDistance() {
|
||||||
if (!this.userCanUpdate && !this.userCanDelete) return 'translate-x-0'
|
if (!this.userCanUpdate) return '-translate-x-12'
|
||||||
else if (!this.userCanUpdate || !this.userCanDelete) return '-translate-x-12'
|
|
||||||
return '-translate-x-24'
|
return '-translate-x-24'
|
||||||
},
|
},
|
||||||
libraryItem() {
|
libraryItem() {
|
||||||
@ -233,4 +232,4 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -20,7 +20,7 @@
|
|||||||
<td class="px-4">
|
<td class="px-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<nuxt-link :to="`/item/${downloadQueued.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ downloadQueued.podcastTitle }}</nuxt-link>
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@ -30,7 +30,7 @@
|
|||||||
<widgets-podcast-type-indicator :type="downloadQueued.episodeType" />
|
<widgets-podcast-type-indicator :type="downloadQueued.episodeType" />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4">
|
<td dir="auto" class="px-4">
|
||||||
{{ downloadQueued.episodeDisplayTitle }}
|
{{ downloadQueued.episodeDisplayTitle }}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-xs">
|
<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 :id="`lazy-episode-${index}`" class="w-full h-full cursor-pointer" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<div class="flex" @click="clickedEpisode">
|
<div class="flex" @click="clickedEpisode">
|
||||||
<div class="flex-grow">
|
<div class="flex-grow">
|
||||||
<div class="flex items-center">
|
<div dir="auto" class="flex items-center">
|
||||||
<span class="text-sm font-semibold">{{ episodeTitle }}</span>
|
<span class="text-sm font-semibold">{{ episodeTitle }}</span>
|
||||||
<widgets-podcast-type-indicator :type="episodeType" />
|
<widgets-podcast-type-indicator :type="episodeType" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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 />
|
<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">
|
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<transition name="menu">
|
<transition name="menu">
|
||||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg 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">
|
<template v-for="item in itemsToShow">
|
||||||
<li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" :id="'listbox-option-' + item.value" role="option" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)">
|
<li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" :id="'listbox-option-' + item.value" role="option" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@ -41,7 +41,11 @@ export default {
|
|||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
disabled: Boolean,
|
disabled: Boolean,
|
||||||
small: Boolean
|
small: Boolean,
|
||||||
|
menuMaxHeight: {
|
||||||
|
type: String,
|
||||||
|
default: '224px'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<input ref="fileInput" type="file" :accept="accept" class="hidden" @change="inputChanged" />
|
<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" />
|
<ui-icon-btn @click="clickUpload" icon="upload" class="block md:hidden" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -83,15 +83,21 @@ export default {
|
|||||||
},
|
},
|
||||||
async updateLibrary(library) {
|
async updateLibrary(library) {
|
||||||
var currLibraryId = this.currentLibraryId
|
var currLibraryId = this.currentLibraryId
|
||||||
|
if (currLibraryId === library.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.disabled = true
|
this.disabled = true
|
||||||
await this.$store.dispatch('libraries/fetch', library.id)
|
await this.$store.dispatch('libraries/fetch', library.id)
|
||||||
|
|
||||||
if (this.$route.name.startsWith('config')) {
|
if (this.$route.name.startsWith('config')) {
|
||||||
// No need to refresh
|
// No need to refresh
|
||||||
} else if (this.$route.name.startsWith('library')) {
|
} else if (this.$route.name.startsWith('library') && this.$route.name !== 'library-library-series-id') {
|
||||||
var newRoute = this.$route.path.replace(currLibraryId, library.id)
|
const newRoute = this.$route.path.replace(currLibraryId, library.id)
|
||||||
this.$router.push(newRoute)
|
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 {
|
} else {
|
||||||
this.$router.push(`/library/${library.id}`)
|
this.$router.push(`/library/${library.id}`)
|
||||||
}
|
}
|
||||||
@ -107,4 +113,4 @@ export default {
|
|||||||
.librariesDropdownMenu {
|
.librariesDropdownMenu {
|
||||||
max-height: calc(100vh - 75px);
|
max-height: calc(100vh - 75px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -11,13 +11,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{{ item }}
|
{{ item }}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</form>
|
</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">
|
<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">
|
<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">
|
<div class="flex items-center">
|
||||||
<span class="font-normal ml-3 block truncate">{{ item }}</span>
|
<span class="font-normal ml-3 block truncate">{{ item }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -54,7 +54,7 @@ export default {
|
|||||||
menuDisabled: {
|
menuDisabled: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@ -62,7 +62,9 @@ export default {
|
|||||||
currentSearch: null,
|
currentSearch: null,
|
||||||
typingTimeout: null,
|
typingTimeout: null,
|
||||||
isFocused: false,
|
isFocused: false,
|
||||||
menu: null
|
menu: null,
|
||||||
|
filteredItems: null,
|
||||||
|
selectedMenuItemIndex: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@ -91,24 +93,63 @@ export default {
|
|||||||
return classes.join(' ')
|
return classes.join(' ')
|
||||||
},
|
},
|
||||||
itemsToShow() {
|
itemsToShow() {
|
||||||
if (!this.currentSearch || !this.textInput) {
|
if (!this.currentSearch || !this.textInput || !this.filteredItems) {
|
||||||
return this.items
|
return this.items
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.items.filter((i) => {
|
return this.filteredItems
|
||||||
var iValue = String(i).toLowerCase()
|
|
||||||
return iValue.includes(this.currentSearch.toLowerCase())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
editItem(item) {
|
editItem(item) {
|
||||||
this.$emit('edit', 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)
|
clearTimeout(this.typingTimeout)
|
||||||
this.typingTimeout = setTimeout(() => {
|
this.typingTimeout = setTimeout(() => {
|
||||||
this.currentSearch = this.textInput
|
this.search()
|
||||||
}, 100)
|
}, 100)
|
||||||
this.setInputWidth()
|
this.setInputWidth()
|
||||||
},
|
},
|
||||||
@ -120,6 +161,24 @@ export default {
|
|||||||
this.recalcMenuPos()
|
this.recalcMenuPos()
|
||||||
}, 50)
|
}, 50)
|
||||||
},
|
},
|
||||||
|
recalcScroll() {
|
||||||
|
if (!this.menu) return
|
||||||
|
var menuItems = this.menu.querySelectorAll('li')
|
||||||
|
if (!menuItems.length) return
|
||||||
|
var selectedItem = menuItems[this.selectedMenuItemIndex]
|
||||||
|
if (!selectedItem) return
|
||||||
|
var menuHeight = this.menu.offsetHeight
|
||||||
|
var itemHeight = selectedItem.offsetHeight
|
||||||
|
var itemTop = selectedItem.offsetTop
|
||||||
|
var itemBottom = itemTop + itemHeight
|
||||||
|
if (itemBottom > this.menu.scrollTop + menuHeight) {
|
||||||
|
let menuPaddingBottom = parseFloat(window.getComputedStyle(this.menu).paddingBottom)
|
||||||
|
this.menu.scrollTop = itemBottom - menuHeight + menuPaddingBottom
|
||||||
|
} else if (itemTop < this.menu.scrollTop) {
|
||||||
|
let menuPaddingTop = parseFloat(window.getComputedStyle(this.menu).paddingTop)
|
||||||
|
this.menu.scrollTop = itemTop - menuPaddingTop
|
||||||
|
}
|
||||||
|
},
|
||||||
recalcMenuPos() {
|
recalcMenuPos() {
|
||||||
if (!this.menu || !this.$refs.inputWrapper) return
|
if (!this.menu || !this.$refs.inputWrapper) return
|
||||||
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
|
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
|
||||||
@ -208,7 +267,10 @@ export default {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
e.preventDefault()
|
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
|
var newSelected = null
|
||||||
if (this.selected.includes(itemValue)) {
|
if (this.selected.includes(itemValue)) {
|
||||||
@ -219,6 +281,7 @@ export default {
|
|||||||
}
|
}
|
||||||
this.textInput = null
|
this.textInput = null
|
||||||
this.currentSearch = null
|
this.currentSearch = null
|
||||||
|
this.selectedMenuItemIndex = null
|
||||||
this.$emit('input', newSelected)
|
this.$emit('input', newSelected)
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.recalcMenuPos()
|
this.recalcMenuPos()
|
||||||
@ -239,12 +302,21 @@ export default {
|
|||||||
this.recalcMenuPos()
|
this.recalcMenuPos()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
resetInput() {
|
||||||
|
this.textInput = null
|
||||||
|
this.currentSearch = null
|
||||||
|
this.selectedMenuItemIndex = null
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.blur()
|
||||||
|
})
|
||||||
|
},
|
||||||
insertNewItem(item) {
|
insertNewItem(item) {
|
||||||
this.selected.push(item)
|
this.selected.push(item)
|
||||||
this.$emit('input', this.selected)
|
this.$emit('input', this.selected)
|
||||||
this.$emit('newItem', item)
|
this.$emit('newItem', item)
|
||||||
this.textInput = null
|
this.textInput = null
|
||||||
this.currentSearch = null
|
this.currentSearch = null
|
||||||
|
this.selectedMenuItemIndex = null
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.blur()
|
this.blur()
|
||||||
})
|
})
|
||||||
@ -252,15 +324,19 @@ export default {
|
|||||||
submitForm() {
|
submitForm() {
|
||||||
if (!this.textInput) return
|
if (!this.textInput) return
|
||||||
|
|
||||||
var cleaned = this.textInput.trim()
|
const cleaned = this.textInput.trim()
|
||||||
var matchesItem = this.items.find((i) => {
|
if (!cleaned) {
|
||||||
return i === cleaned
|
this.resetInput()
|
||||||
})
|
|
||||||
if (matchesItem) {
|
|
||||||
this.clickedOption(null, matchesItem)
|
|
||||||
} else {
|
} 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() {
|
scroll() {
|
||||||
this.recalcMenuPos()
|
this.recalcMenuPos()
|
||||||
@ -287,4 +363,4 @@ input:read-only {
|
|||||||
color: #aaa;
|
color: #aaa;
|
||||||
background-color: #444;
|
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">
|
<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>
|
<span class="material-icons text-white hover:text-success pt-px pr-px" style="font-size: 1.1rem" @click.stop="addItem">add</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</form>
|
</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">
|
<ul ref="menu" v-show="showMenu" class="absolute z-60 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in itemsToShow">
|
<template v-for="item in itemsToShow">
|
||||||
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" 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">
|
<div class="flex items-center">
|
||||||
<span class="font-normal ml-3 block truncate">{{ item.name }}</span>
|
<span class="font-normal ml-3 block truncate">{{ item.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -63,7 +63,8 @@ export default {
|
|||||||
typingTimeout: null,
|
typingTimeout: null,
|
||||||
isFocused: false,
|
isFocused: false,
|
||||||
menu: null,
|
menu: null,
|
||||||
items: []
|
items: [],
|
||||||
|
selectedMenuItemIndex: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@ -122,7 +123,35 @@ export default {
|
|||||||
|
|
||||||
this.items = results || []
|
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)
|
clearTimeout(this.typingTimeout)
|
||||||
this.typingTimeout = setTimeout(() => {
|
this.typingTimeout = setTimeout(() => {
|
||||||
this.search()
|
this.search()
|
||||||
@ -137,6 +166,24 @@ export default {
|
|||||||
this.recalcMenuPos()
|
this.recalcMenuPos()
|
||||||
}, 50)
|
}, 50)
|
||||||
},
|
},
|
||||||
|
recalcScroll() {
|
||||||
|
if (!this.menu) return
|
||||||
|
var menuItems = this.menu.querySelectorAll('li')
|
||||||
|
if (!menuItems.length) return
|
||||||
|
var selectedItem = menuItems[this.selectedMenuItemIndex]
|
||||||
|
if (!selectedItem) return
|
||||||
|
var menuHeight = this.menu.offsetHeight
|
||||||
|
var itemHeight = selectedItem.offsetHeight
|
||||||
|
var itemTop = selectedItem.offsetTop
|
||||||
|
var itemBottom = itemTop + itemHeight
|
||||||
|
if (itemBottom > this.menu.scrollTop + menuHeight) {
|
||||||
|
let menuPaddingBottom = parseFloat(window.getComputedStyle(this.menu).paddingBottom)
|
||||||
|
this.menu.scrollTop = itemBottom - menuHeight + menuPaddingBottom
|
||||||
|
} else if (itemTop < this.menu.scrollTop) {
|
||||||
|
let menuPaddingTop = parseFloat(window.getComputedStyle(this.menu).paddingTop)
|
||||||
|
this.menu.scrollTop = itemTop - menuPaddingTop
|
||||||
|
}
|
||||||
|
},
|
||||||
recalcMenuPos() {
|
recalcMenuPos() {
|
||||||
if (!this.menu || !this.$refs.inputWrapper) return
|
if (!this.menu || !this.$refs.inputWrapper) return
|
||||||
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
|
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
|
||||||
@ -228,7 +275,10 @@ export default {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
e.preventDefault()
|
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
|
let newSelected = null
|
||||||
if (this.getIsSelected(item.id)) {
|
if (this.getIsSelected(item.id)) {
|
||||||
@ -244,6 +294,7 @@ export default {
|
|||||||
}
|
}
|
||||||
this.textInput = null
|
this.textInput = null
|
||||||
this.currentSearch = null
|
this.currentSearch = null
|
||||||
|
this.selectedMenuItemIndex = null
|
||||||
|
|
||||||
this.$emit('input', newSelected)
|
this.$emit('input', newSelected)
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
@ -271,6 +322,7 @@ export default {
|
|||||||
this.$emit('newItem', item)
|
this.$emit('newItem', item)
|
||||||
this.textInput = null
|
this.textInput = null
|
||||||
this.currentSearch = null
|
this.currentSearch = null
|
||||||
|
this.selectedMenuItemIndex = null
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.blur()
|
this.blur()
|
||||||
})
|
})
|
||||||
@ -291,6 +343,7 @@ export default {
|
|||||||
name: this.textInput
|
name: this.textInput
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (this.$refs.input) this.$refs.input.style.width = '24px'
|
||||||
},
|
},
|
||||||
scroll() {
|
scroll() {
|
||||||
this.recalcMenuPos()
|
this.recalcMenuPos()
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="relative">
|
<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">
|
<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>
|
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
||||||
</div>
|
</div>
|
||||||
@ -33,6 +33,7 @@ export default {
|
|||||||
textCenter: Boolean,
|
textCenter: Boolean,
|
||||||
clearable: Boolean,
|
clearable: Boolean,
|
||||||
inputId: String,
|
inputId: String,
|
||||||
|
inputName: String,
|
||||||
step: [String, Number],
|
step: [String, Number],
|
||||||
min: [String, Number]
|
min: [String, Number]
|
||||||
},
|
},
|
||||||
@ -117,4 +118,4 @@ input:read-only {
|
|||||||
input::-webkit-calendar-picker-indicator {
|
input::-webkit-calendar-picker-indicator {
|
||||||
filter: invert(1);
|
filter: invert(1);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
>{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em></label
|
>{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em></label
|
||||||
>
|
>
|
||||||
</slot>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -14,6 +14,7 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
value: [String, Number],
|
value: [String, Number],
|
||||||
label: String,
|
label: String,
|
||||||
|
placeholder: String,
|
||||||
note: String,
|
note: String,
|
||||||
type: {
|
type: {
|
||||||
type: String,
|
type: String,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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="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">
|
<div class="flex items-center">
|
||||||
<template v-for="(digit, index) in digitDisplay">
|
<template v-for="(digit, index) in digitDisplay">
|
||||||
@ -174,7 +174,7 @@ export default {
|
|||||||
return this.increaseFocused()
|
return this.increaseFocused()
|
||||||
} else if (evt.key === 'ArrowDown') {
|
} else if (evt.key === 'ArrowDown') {
|
||||||
return this.decreaseFocused()
|
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()
|
return this.removeFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,4 +209,4 @@ export default {
|
|||||||
.digit-focused {
|
.digit-focused {
|
||||||
background-color: #555;
|
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>
|
<span class="material-icons text-2xl">chevron_right</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||||
<div class="flex" :style="{ height: height + 'px' }">
|
<div class="flex space-x-4" :style="{ height: height + 'px' }">
|
||||||
<template v-for="(item, index) in items">
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -10,8 +10,8 @@
|
|||||||
<span class="material-icons text-2xl">chevron_right</span>
|
<span class="material-icons text-2xl">chevron_right</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||||
<div class="flex" :style="{ height: height + 'px' }">
|
<div class="flex space-x-4" :style="{ height: height + 'px' }">
|
||||||
<template v-for="(item, index) in items">
|
<template v-for="(item, index) in items">
|
||||||
<cards-lazy-book-card
|
<cards-lazy-book-card
|
||||||
:key="item.recentEpisode.id"
|
:key="item.recentEpisode.id"
|
||||||
@ -23,7 +23,7 @@
|
|||||||
:book-cover-aspect-ratio="bookCoverAspectRatio"
|
:book-cover-aspect-ratio="bookCoverAspectRatio"
|
||||||
:bookshelf-view="bookshelfView"
|
:bookshelf-view="bookshelfView"
|
||||||
:continue-listening-shelf="continueListeningShelf"
|
:continue-listening-shelf="continueListeningShelf"
|
||||||
class="relative mx-2"
|
class="relative"
|
||||||
@edit="editEpisode"
|
@edit="editEpisode"
|
||||||
@editPodcast="editPodcast"
|
@editPodcast="editPodcast"
|
||||||
@select="selectItem"
|
@select="selectItem"
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" viewBox="0 0 512 512" class="ml-1">
|
||||||
<path
|
<path
|
||||||
fill="white"
|
fill="white"
|
||||||
@ -40,9 +40,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {},
|
||||||
explicit: Boolean
|
|
||||||
},
|
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
|
@ -10,10 +10,24 @@
|
|||||||
<span class="material-icons text-2xl">chevron_right</span>
|
<span class="material-icons text-2xl">chevron_right</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||||
<div class="flex" :style="{ height: height + 'px' }">
|
<div class="flex space-x-4" :style="{ height: height + 'px' }">
|
||||||
<template v-for="(item, index) in items">
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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-for="(item, index) in items">
|
||||||
<template v-if="item.subitems">
|
<template v-if="item.subitems">
|
||||||
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
|
<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() {}
|
beforeDestroy() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -10,10 +10,10 @@
|
|||||||
<span class="material-icons text-2xl">chevron_right</span>
|
<span class="material-icons text-2xl">chevron_right</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||||
<div class="flex" :style="{ height: height + 'px' }">
|
<div class="flex space-x-4" :style="{ height: height + 'px' }">
|
||||||
<template v-for="item in items">
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -10,10 +10,10 @@
|
|||||||
<span class="material-icons text-2xl">chevron_right</span>
|
<span class="material-icons text-2xl">chevron_right</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||||
<div class="flex" :style="{ height: height + 'px' }">
|
<div class="flex space-x-4" :style="{ height: height + 'px' }">
|
||||||
<template v-for="(item, index) in items">
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</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