mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-01 04:00:45 -05:00
Compare commits
19 Commits
stringify_
...
sqlite
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4dc1c1f03 | ||
|
|
633e83a4ab | ||
|
|
d745e6b656 | ||
|
|
b62e88c4ed | ||
|
|
258b9ec54e | ||
|
|
54ca58e610 | ||
|
|
2131a65299 | ||
|
|
243bc7b0d0 | ||
|
|
b8de041497 | ||
|
|
8287822354 | ||
|
|
0f83a292f6 | ||
|
|
c738e35a8c | ||
|
|
b2e1e24ca5 | ||
|
|
c7f457da3e | ||
|
|
bed3758268 | ||
|
|
a1a923df94 | ||
|
|
bbf324ea83 | ||
|
|
adc4309951 | ||
|
|
b8ab72a141 |
@@ -1,12 +1,4 @@
|
|||||||
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
|
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16
|
||||||
ARG VARIANT=20
|
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||||
FROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} as base
|
&& apt-get install ffmpeg gnupg2 -y
|
||||||
|
|
||||||
# Setup the node environment
|
|
||||||
ENV NODE_ENV=development
|
ENV NODE_ENV=development
|
||||||
|
|
||||||
# Install additional OS packages.
|
|
||||||
RUN apt-get update && \
|
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \
|
|
||||||
curl tzdata ffmpeg && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
// Using port 3333 is important when running the client web app separately
|
|
||||||
const Path = require('path')
|
|
||||||
module.exports.config = {
|
|
||||||
Port: 3333,
|
|
||||||
ConfigPath: Path.resolve('config'),
|
|
||||||
MetadataPath: Path.resolve('metadata'),
|
|
||||||
FFmpegPath: '/usr/bin/ffmpeg',
|
|
||||||
FFProbePath: '/usr/bin/ffprobe',
|
|
||||||
SkipBinariesCheck: false
|
|
||||||
}
|
|
||||||
@@ -1,40 +1,12 @@
|
|||||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
|
||||||
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
|
|
||||||
{
|
{
|
||||||
"name": "Audiobookshelf",
|
"build": { "dockerfile": "Dockerfile" },
|
||||||
"build": {
|
"mounts": [
|
||||||
"dockerfile": "Dockerfile",
|
"source=abs-node-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
|
||||||
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
|
],
|
||||||
// Append -bullseye or -buster to pin to an OS version.
|
"features": {
|
||||||
// Use -bullseye variants on local arm64/Apple Silicon.
|
"fish": "latest"
|
||||||
"args": {
|
|
||||||
"VARIANT": "20"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"mounts": [
|
"extensions": [
|
||||||
"source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume",
|
"eamodio.gitlens"
|
||||||
"source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume"
|
]
|
||||||
],
|
|
||||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
|
||||||
// "features": {},
|
|
||||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
|
||||||
"forwardPorts": [
|
|
||||||
3000,
|
|
||||||
3333
|
|
||||||
],
|
|
||||||
// Use 'postCreateCommand' to run commands after the container is created.
|
|
||||||
"postCreateCommand": "sh .devcontainer/post-create.sh",
|
|
||||||
// Configure tool-specific properties.
|
|
||||||
"customizations": {
|
|
||||||
// Configure properties specific to VS Code.
|
|
||||||
"vscode": {
|
|
||||||
// Add the IDs of extensions you want installed when the container is created.
|
|
||||||
"extensions": [
|
|
||||||
"dbaeumer.vscode-eslint",
|
|
||||||
"octref.vetur"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
|
||||||
// "remoteUser": "root"
|
|
||||||
}
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# Mark the working directory as safe for use with git
|
|
||||||
git config --global --add safe.directory $PWD
|
|
||||||
|
|
||||||
# If there is no dev.js file, create it
|
|
||||||
if [ ! -f dev.js ]; then
|
|
||||||
cp .devcontainer/dev.js .
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update permissions for node_modules folders
|
|
||||||
# https://code.visualstudio.com/remote/advancedcontainers/improve-performance#_use-a-targeted-named-volume
|
|
||||||
if [ -d node_modules ]; then
|
|
||||||
sudo chown $(id -u):$(id -g) node_modules
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d client/node_modules ]; then
|
|
||||||
sudo chown $(id -u):$(id -g) client/node_modules
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install packages for the server
|
|
||||||
if [ -f package.json ]; then
|
|
||||||
npm ci
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install packages and build the client
|
|
||||||
if [ -f client/package.json ]; then
|
|
||||||
(cd client; npm ci; npm run generate)
|
|
||||||
fi
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
charset = utf-8
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
5
.gitattributes
vendored
5
.gitattributes
vendored
@@ -1,5 +0,0 @@
|
|||||||
# Set the default behavior, in case people don't have core.autocrlf set.
|
|
||||||
* text=auto
|
|
||||||
|
|
||||||
# Declare files that will always have CRLF line endings on checkout.
|
|
||||||
.devcontainer/post-create.sh text eol=lf
|
|
||||||
74
.github/ISSUE_TEMPLATE/bug.yaml
vendored
74
.github/ISSUE_TEMPLATE/bug.yaml
vendored
@@ -1,50 +1,40 @@
|
|||||||
name: 🐞 Bug Report
|
name: 🐞 Bug Report
|
||||||
description: File a bug/issue and help us improve Audiobookshelf
|
description: File a bug/issue
|
||||||
title: '[Bug]: '
|
title: "[Bug]: "
|
||||||
labels: ['bug', 'triage']
|
labels: ["bug", "triage"]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: 'Thank you for filing a bug report! 🐛'
|
value: "### Please first search for your issue and check the [docs](https://audiobookshelf.org/docs)."
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: 'Please first search for your issue and check the [docs](https://audiobookshelf.org/docs).'
|
value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: 'Report issues with the mobile app [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose).'
|
value: "### Join the [discord server](https://discord.gg/pJsjuNCKRq) for questions or if you are not sure about a bug."
|
||||||
- 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: "## Be as descriptive as you can. Include screenshots, error logs, browser, file types, everything you can think of that might be relevant."
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: what-happened
|
id: what-happened
|
||||||
attributes:
|
attributes:
|
||||||
label: What happened?
|
label: Describe the issue
|
||||||
placeholder: Tell us what you see!
|
description: What happened & what did you expect to happen
|
||||||
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
|
||||||
@@ -54,45 +44,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
- Docker
|
- Docker
|
||||||
- Debian/PPA
|
- Debian/PPA
|
||||||
- Windows Tray App
|
|
||||||
- Built from source
|
- Built from source
|
||||||
- Other (list in "Additional Notes" box)
|
- Other
|
||||||
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.'
|
|
||||||
|
|||||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
5
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,5 +1,8 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Discord
|
- name: Discord
|
||||||
url: https://discord.gg/HQgCbd6E75
|
url: https://discord.gg/pJsjuNCKRq
|
||||||
|
about: Ask questions, get help troubleshooting, and join the Abs community here.
|
||||||
|
- name: Matrix
|
||||||
|
url: https://matrix.to/#/#audiobookshelf:matrix.org
|
||||||
about: Ask questions, get help troubleshooting, and join the Abs community here.
|
about: Ask questions, get help troubleshooting, and join the Abs community here.
|
||||||
|
|||||||
56
.github/ISSUE_TEMPLATE/feature.yml
vendored
56
.github/ISSUE_TEMPLATE/feature.yml
vendored
@@ -1,63 +1,17 @@
|
|||||||
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: '#### *Mobile app features should be [requested here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)*.'
|
value: "### Please first search in both issues & discussions for your enhancement."
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: '## Web/Server Feature Request Description'
|
value: "### Mobile app features should be requested [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
|
||||||
- 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.
|
|
||||||
|
|||||||
33
.github/pull_request_template.md
vendored
33
.github/pull_request_template.md
vendored
@@ -1,33 +0,0 @@
|
|||||||
<!--
|
|
||||||
For Work In Progress Pull Requests, please use the Draft PR feature,
|
|
||||||
see https://github.blog/2019-02-14-introducing-draft-pull-requests/ for further details.
|
|
||||||
|
|
||||||
If you do not follow this template, the PR may be closed without review.
|
|
||||||
|
|
||||||
Please ensure all checks pass.
|
|
||||||
If you are a new contributor, the workflows will need to be manually approved before they run.
|
|
||||||
-->
|
|
||||||
|
|
||||||
## Brief summary
|
|
||||||
|
|
||||||
<!-- Please provide a brief summary of what your PR attempts to achieve. -->
|
|
||||||
|
|
||||||
## Which issue is fixed?
|
|
||||||
|
|
||||||
<!-- Which issue number does this PR fix? Ex: "Fixes #1234" -->
|
|
||||||
|
|
||||||
## In-depth Description
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Describe your solution in more depth.
|
|
||||||
How does it work? Why is this the best solution?
|
|
||||||
Does it solve a problem that affects multiple users or is this an edge case for your setup?
|
|
||||||
-->
|
|
||||||
|
|
||||||
## How have you tested this?
|
|
||||||
|
|
||||||
<!-- Please describe in detail with reproducible steps how you tested your changes. -->
|
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||
<!-- If your PR includes any changes to the web client, please include screenshots or a short video from before and after your changes. -->
|
|
||||||
55
.github/workflows/apply_comments.yaml
vendored
55
.github/workflows/apply_comments.yaml
vendored
@@ -1,55 +0,0 @@
|
|||||||
name: Add issue comments by label
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types:
|
|
||||||
- labeled
|
|
||||||
jobs:
|
|
||||||
help-wanted:
|
|
||||||
if: github.event.label.name == 'help wanted'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
steps:
|
|
||||||
- name: Help wanted comment
|
|
||||||
run: gh issue comment "$NUMBER" --body "$BODY"
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
GH_REPO: ${{ github.repository }}
|
|
||||||
NUMBER: ${{ github.event.issue.number }}
|
|
||||||
BODY: >
|
|
||||||
This issue is not able to be completed due to limited bandwidth or access to the required test hardware.
|
|
||||||
|
|
||||||
This issue is available for anyone to work on.
|
|
||||||
|
|
||||||
|
|
||||||
config-issue:
|
|
||||||
if: github.event.label.name == 'config-issue'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
steps:
|
|
||||||
- name: Config issue comment
|
|
||||||
run: gh issue close "$NUMBER" --reason "not planned" --comment "$BODY"
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
GH_REPO: ${{ github.repository }}
|
|
||||||
NUMBER: ${{ github.event.issue.number }}
|
|
||||||
BODY: >
|
|
||||||
After reviewing this issue, this appears to be a problem with your setup and not Audiobookshelf. This issue is being closed to keep the issue tracker focused on Audiobookshelf itself. Please reach out on the Audiobookshelf Discord for community support.
|
|
||||||
|
|
||||||
Some common search terms to help you find the solution to your problem:
|
|
||||||
- Reverse proxy
|
|
||||||
- Enabling websockets
|
|
||||||
- SSL (https vs http)
|
|
||||||
- Configuring a static IP
|
|
||||||
- `localhost` versus IP address
|
|
||||||
- hairpin NAT
|
|
||||||
- VPN
|
|
||||||
- firewall ports
|
|
||||||
- public versus private network
|
|
||||||
- bridge versus host mode
|
|
||||||
- Docker networking
|
|
||||||
- DNS (such as EAI_AGAIN errors)
|
|
||||||
|
|
||||||
After you have followed these steps, please post the solution or steps you followed to fix the problem to help others in the future, or show that it is a problem with Audiobookshelf so we can reopen the issue.
|
|
||||||
|
|
||||||
20
.github/workflows/close-issues-on-release.yml
vendored
20
.github/workflows/close-issues-on-release.yml
vendored
@@ -1,20 +0,0 @@
|
|||||||
name: Close fixed issues on release.
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
issues: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
comment:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Close issues marked as fixed upon a release.
|
|
||||||
uses: gcampbell-msft/fixed-pending-release@7fa1b75a0c04bcd4b375110522878e5f6100cff5
|
|
||||||
with:
|
|
||||||
label: 'awaiting release'
|
|
||||||
removeLabel: true
|
|
||||||
applyToAll: true
|
|
||||||
message: Fixed in [${releaseTag}](${releaseUrl}).
|
|
||||||
42
.github/workflows/close_blank_issues.yaml
vendored
42
.github/workflows/close_blank_issues.yaml
vendored
@@ -1,42 +0,0 @@
|
|||||||
name: Close Issues not using a template
|
|
||||||
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
close_issue:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check issue headings
|
|
||||||
uses: actions/github-script@v6
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const issueBody = context.payload.issue.body || "";
|
|
||||||
|
|
||||||
// Match Markdown headings (e.g., # Heading, ## Heading)
|
|
||||||
const headingRegex = /^(#{1,6})\s.+/gm;
|
|
||||||
const headings = [...issueBody.matchAll(headingRegex)];
|
|
||||||
|
|
||||||
if (headings.length < 3) {
|
|
||||||
// Post a comment
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.payload.issue.number,
|
|
||||||
body: "Thank you for opening an issue! To help us review your request efficiently, please use one of the provided issue templates. If you're seeking information or have a general question, consider opening a Discussion or joining the conversation on our Discord. Thanks!"
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close the issue
|
|
||||||
await github.rest.issues.update({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.payload.issue.number,
|
|
||||||
state: "closed"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
78
.github/workflows/codeql.yml
vendored
78
.github/workflows/codeql.yml
vendored
@@ -1,78 +0,0 @@
|
|||||||
name: 'CodeQL'
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ['master']
|
|
||||||
# Only build when files in these directories have been changed
|
|
||||||
paths:
|
|
||||||
- client/**
|
|
||||||
- server/**
|
|
||||||
- test/**
|
|
||||||
- index.js
|
|
||||||
- package.json
|
|
||||||
pull_request:
|
|
||||||
# The branches below must be a subset of the branches above
|
|
||||||
branches: ['master']
|
|
||||||
# Only build when files in these directories have been changed
|
|
||||||
paths:
|
|
||||||
- client/**
|
|
||||||
- server/**
|
|
||||||
- test/**
|
|
||||||
- index.js
|
|
||||||
- package.json
|
|
||||||
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}}'
|
|
||||||
3
.github/workflows/docker-build.yml
vendored
3
.github/workflows/docker-build.yml
vendored
@@ -70,9 +70,8 @@ jobs:
|
|||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
|
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
push: true
|
push: true
|
||||||
cache-from: type=local,src=/tmp/.buildx-cache
|
cache-from: type=local,src=/tmp/.buildx-cache
|
||||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||||
|
|||||||
30
.github/workflows/i18n-integration.yml
vendored
30
.github/workflows/i18n-integration.yml
vendored
@@ -1,30 +0,0 @@
|
|||||||
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.3.0
|
|
||||||
with:
|
|
||||||
directory: 'client/strings/' # Adjust the directory path as needed
|
|
||||||
17
.github/workflows/integration-test.yml
vendored
17
.github/workflows/integration-test.yml
vendored
@@ -4,14 +4,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches-ignore:
|
branches-ignore:
|
||||||
- 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests
|
- 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests
|
||||||
# Only build when files in these directories have been changed
|
|
||||||
paths:
|
|
||||||
- client/**
|
|
||||||
- server/**
|
|
||||||
- test/**
|
|
||||||
- index.js
|
|
||||||
- package.json
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -23,10 +16,10 @@ jobs:
|
|||||||
- name: setup nade
|
- name: setup nade
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 16
|
||||||
|
|
||||||
- name: install pkg (using yao-pkg fork for targetting node20)
|
- name: install pkg
|
||||||
run: npm install -g @yao-pkg/pkg
|
run: npm install -g pkg
|
||||||
|
|
||||||
- name: get client dependencies
|
- name: get client dependencies
|
||||||
working-directory: client
|
working-directory: client
|
||||||
@@ -40,7 +33,7 @@ jobs:
|
|||||||
run: npm ci --only=production
|
run: npm ci --only=production
|
||||||
|
|
||||||
- name: build binary
|
- name: build binary
|
||||||
run: pkg -t node20-linux-x64 -o audiobookshelf .
|
run: pkg -t node18-linux-x64 -o audiobookshelf .
|
||||||
|
|
||||||
- name: run audiobookshelf
|
- name: run audiobookshelf
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
32
.github/workflows/lint-openapi.yml
vendored
32
.github/workflows/lint-openapi.yml
vendored
@@ -1,32 +0,0 @@
|
|||||||
name: API linting
|
|
||||||
|
|
||||||
# Run on pull requests or pushes when there is a change to any OpenAPI files in docs/
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'docs/**'
|
|
||||||
|
|
||||||
# This action only needs read permissions
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
# Check out the repository
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
# Set up node to run the javascript
|
|
||||||
- name: Set up node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
# Install Redocly CLI
|
|
||||||
- name: Install Redocly CLI
|
|
||||||
run: npm install -g @redocly/cli@latest
|
|
||||||
# Perform linting for exploded spec
|
|
||||||
- name: Run linting for exploded spec
|
|
||||||
run: redocly lint docs/root.yaml --format=github-actions
|
|
||||||
# Perform linting for bundled spec
|
|
||||||
- name: Run linting for bundled spec
|
|
||||||
run: redocly lint docs/openapi.json --format=github-actions
|
|
||||||
17
.github/workflows/notify-abs-windows.yml
vendored
17
.github/workflows/notify-abs-windows.yml
vendored
@@ -1,17 +0,0 @@
|
|||||||
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
37
.github/workflows/unit-tests.yml
vendored
@@ -1,37 +0,0 @@
|
|||||||
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
|
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,25 +1,17 @@
|
|||||||
.env
|
.env
|
||||||
/dev.js
|
dev.js
|
||||||
**/node_modules/
|
node_modules/
|
||||||
/config/
|
/config/
|
||||||
/audiobooks/
|
/audiobooks/
|
||||||
/audiobooks2/
|
/audiobooks2/
|
||||||
/podcasts/
|
/podcasts/
|
||||||
/media/
|
/media/
|
||||||
/metadata/
|
/metadata/
|
||||||
/plugins/
|
test/
|
||||||
/client/.nuxt/
|
/client/.nuxt/
|
||||||
/client/dist/
|
/client/dist/
|
||||||
/dist/
|
/dist/
|
||||||
/deploy/
|
/deploy/
|
||||||
/coverage/
|
|
||||||
/.nyc_output/
|
|
||||||
/ffmpeg*
|
|
||||||
/ffprobe*
|
|
||||||
/unicode*
|
|
||||||
/libnusqlite3*
|
|
||||||
|
|
||||||
sw.*
|
sw.*
|
||||||
.DS_STORE
|
.DS_STORE
|
||||||
.idea/*
|
|
||||||
tailwind.compiled.css
|
|
||||||
|
|||||||
17
.prettierrc
17
.prettierrc
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"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
7
.vscode/extensions.json
vendored
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": [
|
|
||||||
"EditorConfig.EditorConfig",
|
|
||||||
"esbenp.prettier-vscode",
|
|
||||||
"octref.vetur"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
44
.vscode/launch.json
vendored
44
.vscode/launch.json
vendored
@@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
// Use IntelliSense to learn about possible attributes.
|
|
||||||
// Hover to view descriptions of existing attributes.
|
|
||||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"name": "Debug server",
|
|
||||||
"runtimeExecutable": "npm",
|
|
||||||
"args": [
|
|
||||||
"run",
|
|
||||||
"dev"
|
|
||||||
],
|
|
||||||
"skipFiles": [
|
|
||||||
"<node_internals>/**"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"name": "Debug client (nuxt)",
|
|
||||||
"runtimeExecutable": "npm",
|
|
||||||
"args": [
|
|
||||||
"run",
|
|
||||||
"dev"
|
|
||||||
],
|
|
||||||
"cwd": "${workspaceFolder}/client",
|
|
||||||
"skipFiles": [
|
|
||||||
"${workspaceFolder}/<node_internals>/**"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"compounds": [
|
|
||||||
{
|
|
||||||
"name": "Debug server and client (nuxt)",
|
|
||||||
"configurations": [
|
|
||||||
"Debug server",
|
|
||||||
"Debug client (nuxt)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@@ -16,12 +16,5 @@
|
|||||||
},
|
},
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.detectIndentation": true,
|
"editor.detectIndentation": true,
|
||||||
"editor.tabSize": 2,
|
"editor.tabSize": 2
|
||||||
"javascript.format.semicolons": "remove",
|
|
||||||
"[javascript][json][jsonc]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
|
||||||
},
|
|
||||||
"[vue]": {
|
|
||||||
"editor.defaultFormatter": "octref.vetur"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
40
.vscode/tasks.json
vendored
40
.vscode/tasks.json
vendored
@@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "2.0.0",
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"path": "client",
|
|
||||||
"type": "npm",
|
|
||||||
"script": "generate",
|
|
||||||
"detail": "nuxt generate",
|
|
||||||
"label": "Build client",
|
|
||||||
"group": {
|
|
||||||
"kind": "build",
|
|
||||||
"isDefault": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dependsOn": [
|
|
||||||
"Build client"
|
|
||||||
],
|
|
||||||
"type": "npm",
|
|
||||||
"script": "dev",
|
|
||||||
"detail": "nodemon --watch server index.js",
|
|
||||||
"label": "Run server",
|
|
||||||
"group": {
|
|
||||||
"kind": "test",
|
|
||||||
"isDefault": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "client",
|
|
||||||
"type": "npm",
|
|
||||||
"script": "dev",
|
|
||||||
"detail": "nuxt",
|
|
||||||
"label": "Run Live-reload client",
|
|
||||||
"group": {
|
|
||||||
"kind": "test",
|
|
||||||
"isDefault": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
50
Dockerfile
50
Dockerfile
@@ -1,55 +1,37 @@
|
|||||||
### STAGE 0: Build client ###
|
### STAGE 0: Build client ###
|
||||||
FROM node:20-alpine AS build
|
FROM node:16-alpine AS build
|
||||||
WORKDIR /client
|
WORKDIR /client
|
||||||
COPY /client /client
|
COPY /client /client
|
||||||
RUN npm ci && npm cache clean --force
|
RUN npm ci && npm cache clean --force
|
||||||
RUN npm run generate
|
RUN npm run generate
|
||||||
|
|
||||||
### STAGE 1: Build server ###
|
### STAGE 1: Build server ###
|
||||||
FROM node:20-alpine
|
FROM sandreas/tone:v0.1.2 AS tone
|
||||||
|
FROM node:16-alpine
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
apk add --no-cache --update \
|
apk add --no-cache --update \
|
||||||
curl \
|
curl \
|
||||||
tzdata \
|
tzdata \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
make \
|
make \
|
||||||
python3 \
|
python3 \
|
||||||
g++ \
|
g++
|
||||||
tini \
|
|
||||||
unzip
|
|
||||||
|
|
||||||
|
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
|
||||||
COPY --from=build /client/dist /client/dist
|
COPY --from=build /client/dist /client/dist
|
||||||
COPY index.js package* /
|
COPY index.js package* /
|
||||||
COPY server server
|
COPY server server
|
||||||
|
|
||||||
ARG TARGETPLATFORM
|
|
||||||
|
|
||||||
ENV NUSQLITE3_DIR="/usr/local/lib/nusqlite3"
|
|
||||||
ENV NUSQLITE3_PATH="${NUSQLITE3_DIR}/libnusqlite3.so"
|
|
||||||
|
|
||||||
RUN case "$TARGETPLATFORM" in \
|
|
||||||
"linux/amd64") \
|
|
||||||
curl -L -o /tmp/library.zip "https://github.com/mikiher/nunicode-sqlite/releases/download/v1.2/libnusqlite3-linux-musl-x64.zip" ;; \
|
|
||||||
"linux/arm64") \
|
|
||||||
curl -L -o /tmp/library.zip "https://github.com/mikiher/nunicode-sqlite/releases/download/v1.2/libnusqlite3-linux-musl-arm64.zip" ;; \
|
|
||||||
*) echo "Unsupported platform: $TARGETPLATFORM" && exit 1 ;; \
|
|
||||||
esac && \
|
|
||||||
unzip /tmp/library.zip -d $NUSQLITE3_DIR && \
|
|
||||||
rm /tmp/library.zip
|
|
||||||
|
|
||||||
RUN npm ci --only=production
|
RUN npm ci --only=production
|
||||||
|
|
||||||
RUN apk del make python3 g++
|
RUN apk del make python3 g++
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
HEALTHCHECK \
|
||||||
ENV PORT=80
|
--interval=30s \
|
||||||
ENV CONFIG_PATH="/config"
|
--timeout=3s \
|
||||||
ENV METADATA_PATH="/metadata"
|
--start-period=10s \
|
||||||
ENV SOURCE="docker"
|
CMD curl -f http://127.0.0.1/healthcheck || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["tini", "--"]
|
|
||||||
CMD ["node", "index.js"]
|
CMD ["node", "index.js"]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
set -e
|
set -e
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|
||||||
|
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg"
|
||||||
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
|
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
|
||||||
CONFIG_PATH="/etc/default/audiobookshelf"
|
CONFIG_PATH="/etc/default/audiobookshelf"
|
||||||
DEFAULT_PORT=13378
|
DEFAULT_PORT=13378
|
||||||
@@ -45,11 +46,43 @@ add_group() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_ffmpeg() {
|
||||||
|
echo "Starting FFMPEG Install"
|
||||||
|
|
||||||
|
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz --output-document=ffmpeg-git-amd64-static.tar.xz"
|
||||||
|
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.2/tone-0.1.2-linux-x64.tar.gz --output-document=tone-0.1.2-linux-x64.tar.gz"
|
||||||
|
|
||||||
|
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
||||||
|
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
|
||||||
|
mkdir "$FFMPEG_INSTALL_DIR"
|
||||||
|
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
|
||||||
|
cd "$FFMPEG_INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
$WGET
|
||||||
|
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1
|
||||||
|
rm ffmpeg-git-amd64-static.tar.xz
|
||||||
|
|
||||||
|
# Temp downloading tone library to the ffmpeg dir
|
||||||
|
echo "Getting tone.."
|
||||||
|
$WGET_TONE
|
||||||
|
tar xvf tone-0.1.2-linux-x64.tar.gz --strip-components=1
|
||||||
|
rm tone-0.1.2-linux-x64.tar.gz
|
||||||
|
|
||||||
|
echo "Good to go on Ffmpeg (& tone)... hopefully"
|
||||||
|
}
|
||||||
|
|
||||||
setup_config() {
|
setup_config() {
|
||||||
if [ -f "$CONFIG_PATH" ]; then
|
if [ -f "$CONFIG_PATH" ]; then
|
||||||
echo "Existing config found."
|
echo "Existing config found."
|
||||||
cat $CONFIG_PATH
|
cat $CONFIG_PATH
|
||||||
|
|
||||||
|
# TONE_PATH variable added in 2.1.6, if it doesnt exist then add it
|
||||||
|
if ! grep -q "TONE_PATH" "$CONFIG_PATH"; then
|
||||||
|
echo "Adding TONE_PATH to existing config"
|
||||||
|
echo "TONE_PATH=$FFMPEG_INSTALL_DIR/tone" >> "$CONFIG_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
else
|
else
|
||||||
|
|
||||||
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
|
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
|
||||||
@@ -63,6 +96,9 @@ setup_config() {
|
|||||||
|
|
||||||
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
|
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
|
||||||
CONFIG_PATH=$DEFAULT_DATA_DIR/config
|
CONFIG_PATH=$DEFAULT_DATA_DIR/config
|
||||||
|
FFMPEG_PATH=$FFMPEG_INSTALL_DIR/ffmpeg
|
||||||
|
FFPROBE_PATH=$FFMPEG_INSTALL_DIR/ffprobe
|
||||||
|
TONE_PATH=$FFMPEG_INSTALL_DIR/tone
|
||||||
PORT=$DEFAULT_PORT
|
PORT=$DEFAULT_PORT
|
||||||
HOST=$DEFAULT_HOST"
|
HOST=$DEFAULT_HOST"
|
||||||
|
|
||||||
@@ -79,3 +115,5 @@ add_group 'audiobookshelf' ''
|
|||||||
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
|
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
|
||||||
|
|
||||||
setup_config
|
setup_config
|
||||||
|
|
||||||
|
install_ffmpeg
|
||||||
|
|||||||
@@ -48,10 +48,11 @@ Description: $DESCRIPTION"
|
|||||||
echo "$controlfile" > dist/debian/DEBIAN/control;
|
echo "$controlfile" > dist/debian/DEBIAN/control;
|
||||||
|
|
||||||
# Package debian
|
# Package debian
|
||||||
pkg -t node20-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
pkg -t node16-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
||||||
|
|
||||||
fakeroot dpkg-deb -Zxz --build dist/debian
|
fakeroot dpkg-deb --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"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
@import './absicons.css';
|
@import './absicons.css';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bookshelf-texture-img: url(~static/textures/wood_default.jpg);
|
--bookshelf-texture-img: url(/textures/wood_default.jpg);
|
||||||
--bookshelf-divider-bg: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%);
|
--bookshelf-divider-bg: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +30,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bookshelf-row {
|
.bookshelf-row {
|
||||||
width: calc(100vw - (100vw - 100%));
|
/* Sidebar width + scrollbar width */
|
||||||
|
width: calc(100vw - 88px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -92,10 +93,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Firefox */
|
/* Firefox */
|
||||||
input[type='number'] {
|
input[type=number] {
|
||||||
-moz-appearance: textfield;
|
-moz-appearance: textfield;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.tracksTable {
|
.tracksTable {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -110,7 +112,7 @@ input[type='number'] {
|
|||||||
background-color: #373838;
|
background-color: #373838;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable tr:hover:not(:has(th)) {
|
.tracksTable tr:hover {
|
||||||
background-color: #474747;
|
background-color: #474747;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,10 +178,6 @@ input[type='number'] {
|
|||||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||||
}
|
}
|
||||||
|
|
||||||
.box-shadow-progressbar {
|
|
||||||
box-shadow: 0px -1px 4px rgb(62, 50, 2, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shadow-height {
|
.shadow-height {
|
||||||
height: calc(100% - 4px);
|
height: calc(100% - 4px);
|
||||||
}
|
}
|
||||||
@@ -207,6 +205,7 @@ Bookshelf Label
|
|||||||
color: #fce3a6;
|
color: #fce3a6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.cover-bg {
|
.cover-bg {
|
||||||
width: calc(100% + 40px);
|
width: calc(100% + 40px);
|
||||||
height: calc(100% + 40px);
|
height: calc(100% + 40px);
|
||||||
@@ -218,6 +217,22 @@ Bookshelf Label
|
|||||||
filter: blur(20px);
|
filter: blur(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.episode-subtitle {
|
||||||
|
word-break: break-word;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
line-height: 16px;
|
||||||
|
/* fallback */
|
||||||
|
max-height: 32px;
|
||||||
|
/* fallback */
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
/* number of lines to show */
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
|
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
|
||||||
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
|
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
|
||||||
padding-top: 104px;
|
padding-top: 104px;
|
||||||
@@ -229,24 +244,4 @@ Bookshelf Label
|
|||||||
|
|
||||||
.no-bars .Vue-Toastification__container.top-right {
|
.no-bars .Vue-Toastification__container.top-right {
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.abs-btn::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 6px;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: rgba(255, 255, 255, 0);
|
|
||||||
transition: all 0.1s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.abs-btn:hover:not(:disabled)::before {
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.abs-btn:disabled::before {
|
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
@@ -52,17 +52,4 @@
|
|||||||
text-indent: 0px !important;
|
text-indent: 0px !important;
|
||||||
text-align: start !important;
|
text-align: start !important;
|
||||||
text-align-last: start !important;
|
text-align-last: start !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.default-style.less-spacing p {
|
|
||||||
margin-block-start: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.default-style.less-spacing ul {
|
|
||||||
margin-block-start: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.default-style.less-spacing ol {
|
|
||||||
margin-block-start: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Material Symbols Rounded';
|
font-family: 'Material Icons';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url(~static/fonts/MaterialSymbolsRounded.woff2) format('woff2');
|
src: url(~static/fonts/MaterialIcons.woff2) format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-symbols {
|
@font-face {
|
||||||
font-family: 'Material Symbols Rounded';
|
font-family: 'Material Icons Outlined';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url(~static/fonts/MaterialIconsOutlined.woff2) format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons {
|
||||||
|
font-family: 'Material Icons';
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -17,12 +24,28 @@
|
|||||||
word-wrap: normal;
|
word-wrap: normal;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
vertical-align: top;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-symbols.fill {
|
.material-icons:not([class*="text-"]) {
|
||||||
font-variation-settings:
|
font-size: 1.5rem;
|
||||||
'FILL' 1
|
}
|
||||||
|
|
||||||
|
.material-icons-outlined {
|
||||||
|
font-family: 'Material Icons Outlined';
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-transform: none;
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
word-wrap: normal;
|
||||||
|
direction: ltr;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons-outlined:not([class*="text-"]) {
|
||||||
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* cyrillic-ext */
|
/* cyrillic-ext */
|
||||||
@@ -293,4 +316,4 @@
|
|||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
@@ -446,7 +446,7 @@ trix-editor .attachment__metadata .attachment__size {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.trix-content {
|
.trix-content {
|
||||||
line-height: inherit;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trix-content * {
|
.trix-content * {
|
||||||
@@ -455,13 +455,6 @@ trix-editor .attachment__metadata .attachment__size {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trix-content p {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trix-content h1 {
|
.trix-content h1 {
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
@@ -567,4 +560,4 @@ trix-editor .attachment__metadata .attachment__size {
|
|||||||
.trix-content .attachment-gallery.attachment-gallery--4 .attachment {
|
.trix-content .attachment-gallery.attachment-gallery--4 .attachment {
|
||||||
flex-basis: 50%;
|
flex-basis: 50%;
|
||||||
max-width: 50%;
|
max-width: 50%;
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-16 bg-primary relative">
|
<div class="w-full h-16 bg-primary relative">
|
||||||
<div id="appbar" role="toolbar" aria-label="Appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
|
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
|
||||||
<div class="flex h-full items-center">
|
<div class="flex h-full items-center">
|
||||||
<nuxt-link to="/">
|
<nuxt-link to="/">
|
||||||
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" />
|
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link to="/">
|
<nuxt-link to="/">
|
||||||
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
|
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf <span v-if="showExperimentalFeatures" class="material-icons text-lg text-warning pr-1">logo_dev</span></h1>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<ui-libraries-dropdown class="mr-2" />
|
<ui-libraries-dropdown class="mr-2" />
|
||||||
@@ -15,30 +15,30 @@
|
|||||||
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
|
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
|
<widgets-notification-widget class="hidden md:block" />
|
||||||
|
|
||||||
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
|
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
|
||||||
<span class="material-symbols text-2xl text-warning text-opacity-50"> cast </span>
|
<span class="material-icons-outlined text-2xl text-warning text-opacity-50"> cast </span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<div v-if="isChromecastInitialized" class="w-6 min-w-6 h-6 ml-2 mr-1 sm:mx-2 cursor-pointer">
|
<div v-if="isChromecastInitialized" class="w-6 min-w-6 h-6 ml-2 mr-1 sm:mx-2 cursor-pointer">
|
||||||
<google-cast-launcher></google-cast-launcher>
|
<google-cast-launcher></google-cast-launcher>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<widgets-notification-widget class="hidden md:block" />
|
|
||||||
|
|
||||||
<nuxt-link v-if="currentLibrary" to="/config/stats" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
|
<nuxt-link v-if="currentLibrary" to="/config/stats" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
|
||||||
<ui-tooltip :text="$strings.HeaderYourStats" direction="bottom" class="flex items-center">
|
<ui-tooltip :text="$strings.HeaderYourStats" direction="bottom" class="flex items-center">
|
||||||
<span class="material-symbols text-2xl" aria-label="User Stats" role="button"></span>
|
<span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||||
<ui-tooltip :text="$strings.ButtonUpload" direction="bottom" class="flex items-center">
|
<ui-tooltip :text="$strings.ButtonUpload" direction="bottom" class="flex items-center">
|
||||||
<span class="material-symbols text-2xl" aria-label="Upload Media" role="button"></span>
|
<span class="material-icons text-2xl" aria-label="Upload Media" role="button">upload</span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||||
<ui-tooltip :text="$strings.HeaderSettings" direction="bottom" class="flex items-center">
|
<ui-tooltip :text="$strings.HeaderSettings" direction="bottom" class="flex items-center">
|
||||||
<span class="material-symbols text-2xl" aria-label="System Settings" role="button"></span>
|
<span class="material-icons text-2xl" aria-label="System Settings" role="button">settings</span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
<span class="block truncate">{{ username }}</span>
|
<span class="block truncate">{{ username }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="h-full md:ml-3 md:absolute inset-y-0 md:right-0 flex items-center justify-center md:pr-2 pointer-events-none">
|
<span class="h-full md:ml-3 md:absolute inset-y-0 md:right-0 flex items-center justify-center md:pr-2 pointer-events-none">
|
||||||
<span class="material-symbols text-xl text-gray-100"></span>
|
<span class="material-icons text-xl text-gray-100">person</span>
|
||||||
</span>
|
</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,9 +55,12 @@
|
|||||||
<h1 class="text-lg md:text-2xl px-4">{{ $getString('MessageItemsSelected', [numMediaItemsSelected]) }}</h1>
|
<h1 class="text-lg md:text-2xl px-4">{{ $getString('MessageItemsSelected', [numMediaItemsSelected]) }}</h1>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn v-if="!isPodcastLibrary && selectedMediaItemsArePlayable" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playSelectedItems">
|
<ui-btn v-if="!isPodcastLibrary && selectedMediaItemsArePlayable" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playSelectedItems">
|
||||||
<span class="material-symbols fill text-2xl -ml-2 pr-1 text-white">play_arrow</span>
|
<span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
|
||||||
{{ $strings.ButtonPlay }}
|
{{ $strings.ButtonPlay }}
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
|
<ui-tooltip v-if="userIsAdminOrUp && isBookLibrary" :text="$strings.ButtonQuickMatch" direction="bottom">
|
||||||
|
<ui-icon-btn :disabled="processingBatch" icon="auto_awesome" @click="batchAutoMatchClick" class="mx-1.5" />
|
||||||
|
</ui-tooltip>
|
||||||
<ui-tooltip v-if="isBookLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
|
<ui-tooltip v-if="isBookLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
|
||||||
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
|
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
@@ -72,11 +75,8 @@
|
|||||||
<ui-tooltip v-if="userCanDelete" :text="$strings.ButtonRemove" direction="bottom">
|
<ui-tooltip v-if="userCanDelete" :text="$strings.ButtonRemove" direction="bottom">
|
||||||
<ui-icon-btn :disabled="processingBatch" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
<ui-icon-btn :disabled="processingBatch" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom">
|
||||||
<ui-context-menu-dropdown v-if="contextMenuItems.length && !processingBatch" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
|
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||||
|
|
||||||
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom" class="flex items-center">
|
|
||||||
<span class="material-symbols text-3xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,6 +149,9 @@ export default {
|
|||||||
processingBatch() {
|
processingBatch() {
|
||||||
return this.$store.state.processingBatch
|
return this.$store.state.processingBatch
|
||||||
},
|
},
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
isChromecastEnabled() {
|
isChromecastEnabled() {
|
||||||
return this.$store.getters['getServerSetting']('chromecastEnabled')
|
return this.$store.getters['getServerSetting']('chromecastEnabled')
|
||||||
},
|
},
|
||||||
@@ -157,90 +160,9 @@ export default {
|
|||||||
},
|
},
|
||||||
isHttps() {
|
isHttps() {
|
||||||
return location.protocol === 'https:' || process.env.NODE_ENV === 'development'
|
return location.protocol === 'https:' || process.env.NODE_ENV === 'development'
|
||||||
},
|
|
||||||
contextMenuItems() {
|
|
||||||
if (!this.userIsAdminOrUp) return []
|
|
||||||
|
|
||||||
const options = [
|
|
||||||
{
|
|
||||||
text: this.$strings.ButtonQuickMatch,
|
|
||||||
action: 'quick-match'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
if (!this.isPodcastLibrary && this.selectedMediaItemsArePlayable) {
|
|
||||||
options.push({
|
|
||||||
text: this.$strings.ButtonQuickEmbedMetadata,
|
|
||||||
action: 'quick-embed'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
options.push({
|
|
||||||
text: this.$strings.ButtonReScan,
|
|
||||||
action: 'rescan'
|
|
||||||
})
|
|
||||||
|
|
||||||
return options
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
requestBatchQuickEmbed() {
|
|
||||||
const payload = {
|
|
||||||
message: this.$strings.MessageConfirmQuickEmbed,
|
|
||||||
callback: (confirmed) => {
|
|
||||||
if (confirmed) {
|
|
||||||
this.$axios
|
|
||||||
.$post(`/api/tools/batch/embed-metadata`, {
|
|
||||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
console.log('Audio metadata embed started')
|
|
||||||
this.cancelSelectionMode()
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Audio metadata embed failed', error)
|
|
||||||
const errorMsg = error.response.data || 'Failed to embed metadata'
|
|
||||||
this.$toast.error(errorMsg)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'yesNo'
|
|
||||||
}
|
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
|
||||||
contextMenuAction({ action }) {
|
|
||||||
if (action === 'quick-embed') {
|
|
||||||
this.requestBatchQuickEmbed()
|
|
||||||
} else if (action === 'quick-match') {
|
|
||||||
this.batchAutoMatchClick()
|
|
||||||
} else if (action === 'rescan') {
|
|
||||||
this.batchRescan()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async batchRescan() {
|
|
||||||
const payload = {
|
|
||||||
message: this.$getString('MessageConfirmReScanLibraryItems', [this.selectedMediaItems.length]),
|
|
||||||
callback: (confirmed) => {
|
|
||||||
if (confirmed) {
|
|
||||||
this.$axios
|
|
||||||
.$post(`/api/items/batch/scan`, {
|
|
||||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
console.log('Batch Re-Scan started')
|
|
||||||
this.cancelSelectionMode()
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Batch Re-Scan failed', error)
|
|
||||||
const errorMsg = error.response.data || 'Failed to batch re-scan'
|
|
||||||
this.$toast.error(errorMsg)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'yesNo'
|
|
||||||
}
|
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
|
||||||
async playSelectedItems() {
|
async playSelectedItems() {
|
||||||
this.$store.commit('setProcessingBatch', true)
|
this.$store.commit('setProcessingBatch', true)
|
||||||
|
|
||||||
@@ -264,6 +186,7 @@ export default {
|
|||||||
libraryItems.forEach((item) => {
|
libraryItems.forEach((item) => {
|
||||||
let subtitle = ''
|
let subtitle = ''
|
||||||
if (item.mediaType === 'book') subtitle = item.media.metadata.authors.map((au) => au.name).join(', ')
|
if (item.mediaType === 'book') subtitle = item.media.metadata.authors.map((au) => au.name).join(', ')
|
||||||
|
else if (item.mediaType === 'music') subtitle = item.media.metadata.artists.join(', ')
|
||||||
queueItems.push({
|
queueItems.push({
|
||||||
libraryItemId: item.id,
|
libraryItemId: item.id,
|
||||||
libraryId: item.libraryId,
|
libraryId: item.libraryId,
|
||||||
@@ -302,51 +225,38 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success(this.$strings.ToastBatchUpdateSuccess)
|
this.$toast.success('Batch update success!')
|
||||||
this.$store.commit('setProcessingBatch', false)
|
this.$store.commit('setProcessingBatch', false)
|
||||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.$toast.error(this.$strings.ToastBatchUpdateFailed)
|
this.$toast.error('Batch update failed')
|
||||||
console.error('Failed to batch update read/not read', error)
|
console.error('Failed to batch update read/not read', error)
|
||||||
this.$store.commit('setProcessingBatch', false)
|
this.$store.commit('setProcessingBatch', false)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
batchDeleteClick() {
|
batchDeleteClick() {
|
||||||
const payload = {
|
const audiobookText = this.numMediaItemsSelected > 1 ? `these ${this.numMediaItemsSelected} items` : 'this item'
|
||||||
message: this.$getString('MessageConfirmDeleteLibraryItems', [this.numMediaItemsSelected]),
|
const confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf`
|
||||||
checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox,
|
if (confirm(confirmMsg)) {
|
||||||
yesButtonText: this.$strings.ButtonDelete,
|
this.$store.commit('setProcessingBatch', true)
|
||||||
yesButtonColor: 'error',
|
this.$axios
|
||||||
checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0),
|
.$post(`/api/items/batch/delete`, {
|
||||||
callback: (confirmed, hardDelete) => {
|
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||||
if (confirmed) {
|
})
|
||||||
localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)
|
.then(() => {
|
||||||
|
this.$toast.success('Batch delete success!')
|
||||||
this.$store.commit('setProcessingBatch', true)
|
this.$store.commit('setProcessingBatch', false)
|
||||||
|
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||||
this.$axios
|
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||||
.$post(`/api/items/batch/delete?hard=${hardDelete ? 1 : 0}`, {
|
})
|
||||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
.catch((error) => {
|
||||||
})
|
this.$toast.error('Batch delete failed')
|
||||||
.then(() => {
|
console.error('Failed to batch delete', error)
|
||||||
this.$toast.success(this.$strings.ToastBatchDeleteSuccess)
|
this.$store.commit('setProcessingBatch', false)
|
||||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
})
|
||||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Batch delete failed', error)
|
|
||||||
this.$toast.error(this.$strings.ToastBatchDeleteFailed)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.$store.commit('setProcessingBatch', false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'yesNo'
|
|
||||||
}
|
}
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
},
|
||||||
batchEditClick() {
|
batchEditClick() {
|
||||||
this.$router.push('/batch')
|
this.$router.push('/batch')
|
||||||
|
|||||||
@@ -1,30 +1,39 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative" :style="{ fontSize: sizeMultiplier + 'rem' }">
|
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
|
||||||
<!-- Cover size widget -->
|
<!-- Cover size widget -->
|
||||||
<widgets-cover-size-widget class="fixed right-4 z-50" :style="{ bottom: streamLibraryItem ? '181px' : '16px' }" />
|
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
|
||||||
|
|
||||||
<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">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
|
<p class="text-center text-2xl mb-4 py-4">{{ libraryName }} Library is empty!</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">Configure Scanner</ui-btn>
|
||||||
<ui-btn color="success" class="w-52" :loading="isScanningLibrary || tempIsScanning" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>
|
<ui-btn color="success" class="w-52" @click="scan">Scan Library</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">{{ $strings.MessageBookshelfNoResultsForQuery }}</p>
|
<p class="text-center text-xl py-4">No results for query</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Alternate plain view -->
|
<!-- Alternate plain view -->
|
||||||
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e">
|
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
||||||
<template v-for="(shelf, index) in supportedShelves">
|
<template v-for="(shelf, index) in shelves">
|
||||||
<widgets-item-slider :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :type="shelf.type" class="bookshelf-row pl-8e my-6e" @selectEntity="(payload) => selectEntity(payload, index)">
|
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||||
<h2 class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</h2>
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||||
</widgets-item-slider>
|
</widgets-item-slider>
|
||||||
|
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||||
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||||
|
</widgets-episode-slider>
|
||||||
|
<widgets-series-slider v-else-if="shelf.type === 'series'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||||
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||||
|
</widgets-series-slider>
|
||||||
|
<widgets-authors-slider v-else-if="shelf.type === 'authors'" :key="index + '.'" :items="shelf.entities" :height="192 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||||
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||||
|
</widgets-authors-slider>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<!-- Regular bookshelf view -->
|
<!-- Regular bookshelf view -->
|
||||||
<div v-else class="w-full">
|
<div v-else class="w-full">
|
||||||
<template v-for="(shelf, index) in supportedShelves">
|
<template v-for="(shelf, index) in shelves">
|
||||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" @selectEntity="(payload) => selectEntity(payload, index)" />
|
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening'" @selectEntity="(payload) => selectEntity(payload, index)" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,23 +55,19 @@ export default {
|
|||||||
scannerParseSubtitle: false,
|
scannerParseSubtitle: false,
|
||||||
wrapperClientWidth: 0,
|
wrapperClientWidth: 0,
|
||||||
shelves: [],
|
shelves: [],
|
||||||
lastItemIndexSelected: -1,
|
lastItemIndexSelected: -1
|
||||||
tempIsScanning: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
supportedShelves() {
|
|
||||||
return this.shelves.filter((shelf) => ['book', 'podcast', 'episode', 'series', 'authors', 'narrators'].includes(shelf.type))
|
|
||||||
},
|
|
||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
currentLibraryMediaType() {
|
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
|
||||||
},
|
|
||||||
libraryName() {
|
libraryName() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||||
},
|
},
|
||||||
@@ -81,16 +86,11 @@ export default {
|
|||||||
return this.coverAspectRatio == 1
|
return this.coverAspectRatio == 1
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
return this.$store.getters['user/getSizeMultiplier']
|
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||||
|
return this.bookCoverWidth / baseSize
|
||||||
},
|
},
|
||||||
selectedMediaItems() {
|
selectedMediaItems() {
|
||||||
return this.$store.state.globals.selectedMediaItems || []
|
return this.$store.state.globals.selectedMediaItems || []
|
||||||
},
|
|
||||||
streamLibraryItem() {
|
|
||||||
return this.$store.state.streamLibraryItem
|
|
||||||
},
|
|
||||||
isScanningLibrary() {
|
|
||||||
return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.currentLibraryId)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -167,19 +167,8 @@ export default {
|
|||||||
this.loaded = true
|
this.loaded = true
|
||||||
},
|
},
|
||||||
async fetchCategories() {
|
async fetchCategories() {
|
||||||
// Sets the limit for the number of items to be displayed based on the viewport width.
|
|
||||||
const viewportWidth = window.innerWidth
|
|
||||||
let limit
|
|
||||||
if (viewportWidth >= 3240) {
|
|
||||||
limit = 15
|
|
||||||
} else if (viewportWidth >= 2880 && viewportWidth < 3240) {
|
|
||||||
limit = 12
|
|
||||||
}
|
|
||||||
|
|
||||||
const limitQuery = limit ? `&limit=${limit}` : ''
|
|
||||||
|
|
||||||
const categories = await this.$axios
|
const categories = await this.$axios
|
||||||
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed,numEpisodesIncomplete,share${limitQuery}`)
|
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed`)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
return data
|
return data
|
||||||
})
|
})
|
||||||
@@ -196,8 +185,8 @@ export default {
|
|||||||
this.shelves = categories
|
this.shelves = categories
|
||||||
},
|
},
|
||||||
async setShelvesFromSearch() {
|
async setShelvesFromSearch() {
|
||||||
const shelves = []
|
var shelves = []
|
||||||
if (this.results.books?.length) {
|
if (this.results.books && this.results.books.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'books',
|
id: 'books',
|
||||||
label: 'Books',
|
label: 'Books',
|
||||||
@@ -207,7 +196,7 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.results.podcasts?.length) {
|
if (this.results.podcasts && this.results.podcasts.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'podcasts',
|
id: 'podcasts',
|
||||||
label: 'Podcasts',
|
label: 'Podcasts',
|
||||||
@@ -217,7 +206,7 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.results.series?.length) {
|
if (this.results.series && this.results.series.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'series',
|
id: 'series',
|
||||||
label: 'Series',
|
label: 'Series',
|
||||||
@@ -232,7 +221,7 @@ export default {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.results.tags?.length) {
|
if (this.results.tags && this.results.tags.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'tags',
|
id: 'tags',
|
||||||
label: 'Tags',
|
label: 'Tags',
|
||||||
@@ -247,7 +236,7 @@ export default {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.results.authors?.length) {
|
if (this.results.authors && this.results.authors.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'authors',
|
id: 'authors',
|
||||||
label: 'Authors',
|
label: 'Authors',
|
||||||
@@ -261,32 +250,17 @@ export default {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.results.narrators?.length) {
|
|
||||||
shelves.push({
|
|
||||||
id: 'narrators',
|
|
||||||
label: 'Narrators',
|
|
||||||
labelStringKey: 'LabelNarrators',
|
|
||||||
type: 'narrators',
|
|
||||||
entities: this.results.narrators.map((n) => {
|
|
||||||
return {
|
|
||||||
...n,
|
|
||||||
type: 'narrator'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
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(this.$strings.ToastLibraryScanFailedToStart)
|
this.$toast.error('Failed to start scan')
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.tempIsScanning = false
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
userUpdated(user) {
|
userUpdated(user) {
|
||||||
@@ -295,8 +269,7 @@ export default {
|
|||||||
}
|
}
|
||||||
if (user.mediaProgress.length) {
|
if (user.mediaProgress.length) {
|
||||||
const mediaProgressToHide = user.mediaProgress.filter((mp) => mp.hideFromContinueListening)
|
const mediaProgressToHide = user.mediaProgress.filter((mp) => mp.hideFromContinueListening)
|
||||||
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-listening')
|
this.removeItemsFromContinueListening(mediaProgressToHide)
|
||||||
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-reading')
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
libraryItemAdded(libraryItem) {
|
libraryItemAdded(libraryItem) {
|
||||||
@@ -346,23 +319,9 @@ export default {
|
|||||||
},
|
},
|
||||||
libraryItemsAdded(libraryItems) {
|
libraryItemsAdded(libraryItems) {
|
||||||
console.log('libraryItems added', libraryItems)
|
console.log('libraryItems added', libraryItems)
|
||||||
|
// TODO: Check if audiobook would be on this shelf
|
||||||
// First items added to library
|
if (!this.search) {
|
||||||
const isThisLibrary = libraryItems.some((li) => li.libraryId === this.currentLibraryId)
|
|
||||||
if (!this.shelves.length && !this.search && isThisLibrary) {
|
|
||||||
this.fetchCategories()
|
this.fetchCategories()
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const recentlyAddedShelf = this.shelves.find((shelf) => shelf.id === 'recently-added')
|
|
||||||
if (!recentlyAddedShelf) return
|
|
||||||
|
|
||||||
// Add new library item to the recently added shelf
|
|
||||||
for (const libraryItem of libraryItems) {
|
|
||||||
if (libraryItem.libraryId === this.currentLibraryId && !recentlyAddedShelf.entities.some((ent) => ent.id === libraryItem.id)) {
|
|
||||||
// Add to front of array
|
|
||||||
recentlyAddedShelf.entities.unshift(libraryItem)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
libraryItemsUpdated(items) {
|
libraryItemsUpdated(items) {
|
||||||
@@ -370,12 +329,6 @@ export default {
|
|||||||
this.libraryItemUpdated(li)
|
this.libraryItemUpdated(li)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
episodeAdded(episodeWithLibraryItem) {
|
|
||||||
const isThisLibrary = episodeWithLibraryItem.libraryItem?.libraryId === this.currentLibraryId
|
|
||||||
if (!this.search && isThisLibrary) {
|
|
||||||
this.fetchCategories()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
removeAllSeriesFromContinueSeries(seriesIds) {
|
removeAllSeriesFromContinueSeries(seriesIds) {
|
||||||
this.shelves.forEach((shelf) => {
|
this.shelves.forEach((shelf) => {
|
||||||
if (shelf.type == 'book' && shelf.id == 'continue-series') {
|
if (shelf.type == 'book' && shelf.id == 'continue-series') {
|
||||||
@@ -387,8 +340,8 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
removeItemsFromContinueListeningReading(mediaProgressItems, categoryId) {
|
removeItemsFromContinueListening(mediaProgressItems) {
|
||||||
const continueListeningShelf = this.shelves.find((s) => s.id === categoryId)
|
const continueListeningShelf = this.shelves.find((s) => s.id === 'continue-listening')
|
||||||
if (continueListeningShelf) {
|
if (continueListeningShelf) {
|
||||||
if (continueListeningShelf.type === 'book') {
|
if (continueListeningShelf.type === 'book') {
|
||||||
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
|
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
|
||||||
@@ -403,6 +356,17 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// this.shelves.forEach((shelf) => {
|
||||||
|
// if (shelf.id == 'continue-listening') {
|
||||||
|
// if (shelf.type == 'book') {
|
||||||
|
// // Filter out books from continue listening shelf
|
||||||
|
// shelf.entities = shelf.entities.filter((ent) => {
|
||||||
|
// if (mediaProgressItems.some(mp => mp.libraryItemId === ent.id)) return false
|
||||||
|
// return true
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// })
|
||||||
},
|
},
|
||||||
authorUpdated(author) {
|
authorUpdated(author) {
|
||||||
this.shelves.forEach((shelf) => {
|
this.shelves.forEach((shelf) => {
|
||||||
@@ -426,36 +390,6 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
shareOpen(mediaItemShare) {
|
|
||||||
this.shelves.forEach((shelf) => {
|
|
||||||
if (shelf.type == 'book') {
|
|
||||||
shelf.entities = shelf.entities.map((ent) => {
|
|
||||||
if (ent.media.id === mediaItemShare.mediaItemId) {
|
|
||||||
return {
|
|
||||||
...ent,
|
|
||||||
mediaItemShare
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ent
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
shareClosed(mediaItemShare) {
|
|
||||||
this.shelves.forEach((shelf) => {
|
|
||||||
if (shelf.type == 'book') {
|
|
||||||
shelf.entities = shelf.entities.map((ent) => {
|
|
||||||
if (ent.media.id === mediaItemShare.mediaItemId) {
|
|
||||||
return {
|
|
||||||
...ent,
|
|
||||||
mediaItemShare: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ent
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
initListeners() {
|
initListeners() {
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
this.$root.socket.on('user_updated', this.userUpdated)
|
this.$root.socket.on('user_updated', this.userUpdated)
|
||||||
@@ -466,9 +400,6 @@ export default {
|
|||||||
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
||||||
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
||||||
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
||||||
this.$root.socket.on('episode_added', this.episodeAdded)
|
|
||||||
this.$root.socket.on('share_open', this.shareOpen)
|
|
||||||
this.$root.socket.on('share_closed', this.shareClosed)
|
|
||||||
} else {
|
} else {
|
||||||
console.error('Error socket not initialized')
|
console.error('Error socket not initialized')
|
||||||
}
|
}
|
||||||
@@ -483,9 +414,6 @@ export default {
|
|||||||
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
||||||
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
||||||
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
||||||
this.$root.socket.off('episode_added', this.episodeAdded)
|
|
||||||
this.$root.socket.off('share_open', this.shareOpen)
|
|
||||||
this.$root.socket.off('share_closed', this.shareClosed)
|
|
||||||
} else {
|
} else {
|
||||||
console.error('Error socket not initialized')
|
console.error('Error socket not initialized')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,63 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div ref="shelf" class="w-full max-w-full bookshelf-row categorizedBookshelfRow relative overflow-x-scroll no-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft + 'em' }" @scroll="scrolled">
|
<div ref="shelf" class="w-full max-w-full bookshelf-row categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
|
||||||
<div class="w-full h-full pt-6e">
|
<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" :book-mount="entity" :continue-listening-shelf="continueListeningShelf" class="relative mx-2e" @hook:updated="updatedBookCard" @select="selectItem" @edit="editItem" />
|
<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" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
<div v-if="shelf.type === 'episode'" 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.recentEpisode.id" :ref="`shelf-episode-${entity.recentEpisode.id}`" :index="index" :book-mount="entity" :continue-listening-shelf="continueListeningShelf" class="relative mx-2e" @hook:updated="updatedBookCard" @select="selectItem" @editPodcast="editItem" @edit="editEpisode" />
|
<cards-lazy-book-card
|
||||||
|
:key="entity.recentEpisode.id"
|
||||||
|
:ref="`shelf-episode-${entity.recentEpisode.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"
|
||||||
|
@editPodcast="editItem"
|
||||||
|
@edit="editEpisode"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'series'" class="flex items-center">
|
<div v-if="shelf.type === 'series'" class="flex items-center">
|
||||||
<template v-for="entity in shelf.entities">
|
<template v-for="entity in shelf.entities">
|
||||||
<cards-lazy-series-card :key="entity.name" :series-mount="entity" class="relative mx-2e" @hook:updated="updatedBookCard" />
|
<cards-lazy-series-card :key="entity.name" :series-mount="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'tags'" class="flex items-center">
|
<div v-if="shelf.type === 'tags'" class="flex items-center">
|
||||||
<template v-for="entity in shelf.entities">
|
<template v-for="entity in shelf.entities">
|
||||||
<cards-group-card :key="entity.name" :group="entity" class="relative mx-2e" @hook:updated="updatedBookCard" />
|
<cards-group-card :key="entity.name" :group="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'authors'" class="flex items-center">
|
<div v-if="shelf.type === 'authors'" class="flex items-center">
|
||||||
<template v-for="entity in shelf.entities">
|
<template v-for="entity in shelf.entities">
|
||||||
<cards-author-card :key="entity.id" :authorMount="entity" @hook:updated="updatedBookCard" class="mx-2e" @edit="editAuthor" />
|
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div v-if="shelf.type === 'narrators'" class="flex items-center">
|
|
||||||
<template v-for="entity in shelf.entities">
|
|
||||||
<cards-narrator-card :key="entity.name" :narrator="entity" @hook:updated="updatedBookCard" class="mx-2e" />
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative">
|
|
||||||
<div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md">
|
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
|
|
||||||
<h2 :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></div>
|
<div class="absolute text-center categoryPlacard transform z-30 bottom-px left-4 md:left-8 w-44 rounded-md" style="height: 22px">
|
||||||
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
|
||||||
|
<p class="transform text-sm">{{ $strings[shelf.labelStringKey] }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bookshelfDividerCategorized h-6 w-full absolute bottom-0 left-0 right-0 z-20"></div>
|
||||||
|
|
||||||
|
<div v-show="canScrollLeft && !isScrolling" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollLeft">
|
||||||
|
<span class="material-icons text-6xl text-white">chevron_left</span>
|
||||||
|
</div>
|
||||||
|
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollRight">
|
||||||
|
<span class="material-icons text-6xl text-white">chevron_right</span>
|
||||||
</div>
|
</div>
|
||||||
<button v-show="canScrollLeft && !isScrolling" :aria-label="$strings.ButtonScrollLeft" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft">
|
|
||||||
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_left</span>
|
|
||||||
</button>
|
|
||||||
<button v-show="canScrollRight && !isScrolling" :aria-label="$strings.ButtonScrollRight" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight">
|
|
||||||
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_right</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -60,6 +69,9 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
|
sizeMultiplier: Number,
|
||||||
|
bookCoverWidth: Number,
|
||||||
|
bookCoverAspectRatio: Number,
|
||||||
continueListeningShelf: Boolean
|
continueListeningShelf: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -72,8 +84,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
sizeMultiplier() {
|
bookCoverHeight() {
|
||||||
return this.$store.getters['user/getSizeMultiplier']
|
return this.bookCoverWidth * this.bookCoverAspectRatio
|
||||||
|
},
|
||||||
|
shelfHeight() {
|
||||||
|
return this.bookCoverHeight + 48
|
||||||
},
|
},
|
||||||
paddingLeft() {
|
paddingLeft() {
|
||||||
if (window.innerWidth < 768) return 1
|
if (window.innerWidth < 768) return 1
|
||||||
@@ -99,7 +114,6 @@ export default {
|
|||||||
this.$store.commit('showEditModal', libraryItem)
|
this.$store.commit('showEditModal', libraryItem)
|
||||||
},
|
},
|
||||||
editEpisode({ libraryItem, episode }) {
|
editEpisode({ libraryItem, episode }) {
|
||||||
this.$store.commit('setEpisodeTableEpisodeIds', [episode.id])
|
|
||||||
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||||
@@ -198,13 +212,13 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.book-shelf-arrow-right {
|
.book-shelf-arrow-right {
|
||||||
height: calc(100% - 1.5em);
|
height: calc(100% - 24px);
|
||||||
background: rgb(48, 48, 48);
|
background: rgb(48, 48, 48);
|
||||||
background: linear-gradient(90deg, rgba(48, 48, 48, 0) 0%, rgba(25, 25, 25, 0.25) 8%, rgba(17, 17, 17, 0.4) 28%, rgba(17, 17, 17, 0.6) 71%, rgba(10, 10, 10, 0.6) 86%, rgba(0, 0, 0, 0.7) 100%);
|
background: linear-gradient(90deg, rgba(48, 48, 48, 0) 0%, rgba(25, 25, 25, 0.25) 8%, rgba(17, 17, 17, 0.4) 28%, rgba(17, 17, 17, 0.6) 71%, rgba(10, 10, 10, 0.6) 86%, rgba(0, 0, 0, 0.7) 100%);
|
||||||
}
|
}
|
||||||
.book-shelf-arrow-left {
|
.book-shelf-arrow-left {
|
||||||
height: calc(100% - 1.5em);
|
height: calc(100% - 24px);
|
||||||
background: rgb(48, 48, 48);
|
background: rgb(48, 48, 48);
|
||||||
background: linear-gradient(-90deg, rgba(48, 48, 48, 0) 0%, rgba(25, 25, 25, 0.25) 8%, rgba(17, 17, 17, 0.4) 28%, rgba(17, 17, 17, 0.6) 71%, rgba(10, 10, 10, 0.6) 86%, rgba(0, 0, 0, 0.7) 100%);
|
background: linear-gradient(-90deg, rgba(48, 48, 48, 0) 0%, rgba(25, 25, 25, 0.25) 8%, rgba(17, 17, 17, 0.4) 28%, rgba(17, 17, 17, 0.6) 71%, rgba(10, 10, 10, 0.6) 86%, rgba(0, 0, 0, 0.7) 100%);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -22,15 +22,11 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||||
</svg>
|
</svg>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="flex-grow h-full flex justify-center items-center" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
|
||||||
<p v-if="isPlaylistsPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonPlaylists }}</p>
|
|
||||||
<span v-else class="material-symbols text-lg"></span>
|
|
||||||
</nuxt-link>
|
|
||||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="isCollectionsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="isCollectionsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
<p v-if="isCollectionsPage" class="text-sm">{{ $strings.ButtonCollections }}</p>
|
<p v-if="isCollectionsPage" class="text-sm">{{ $strings.ButtonCollections }}</p>
|
||||||
<span v-else class="material-symbols text-lg"></span>
|
<span v-else class="material-icons-outlined text-lg">collections_bookmark</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/authors`" class="flex-grow h-full flex justify-center items-center" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="flex-grow h-full flex justify-center items-center" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
<p v-if="isAuthorsPage" class="text-sm">{{ $strings.ButtonAuthors }}</p>
|
<p v-if="isAuthorsPage" class="text-sm">{{ $strings.ButtonAuthors }}</p>
|
||||||
<svg v-else class="w-5 h-5" viewBox="0 0 24 24">
|
<svg v-else class="w-5 h-5" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
@@ -40,22 +36,20 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
<p class="text-sm">{{ $strings.ButtonAdd }}</p>
|
<p class="text-sm">{{ $strings.ButtonSearch }}</p>
|
||||||
</nuxt-link>
|
|
||||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
|
||||||
<p class="text-sm">{{ $strings.ButtonDownloadQueue }}</p>
|
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
<div id="toolbar" role="toolbar" aria-label="Library Toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
|
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||||
<!-- Series books page -->
|
<!-- Series books page -->
|
||||||
<template v-if="selectedSeries">
|
<template v-if="selectedSeries">
|
||||||
<p class="pl-2 text-base md:text-lg">
|
<p class="pl-2 text-base md:text-lg">
|
||||||
{{ seriesName }}
|
{{ seriesName }}
|
||||||
</p>
|
</p>
|
||||||
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
|
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
|
||||||
<span class="font-mono">{{ $formatNumber(numShowing) }}</span>
|
<span class="font-mono">{{ numShowing }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
<ui-checkbox v-if="!isBatchSelecting" v-model="settings.collapseBookSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseBookSeries" />
|
||||||
|
|
||||||
<!-- RSS feed -->
|
<!-- RSS feed -->
|
||||||
<ui-tooltip v-if="seriesRssFeed" :text="$strings.LabelOpenRSSFeed" direction="top">
|
<ui-tooltip v-if="seriesRssFeed" :text="$strings.LabelOpenRSSFeed" direction="top">
|
||||||
@@ -65,11 +59,14 @@
|
|||||||
<ui-context-menu-dropdown v-if="!isBatchSelecting && seriesContextMenuItems.length" :items="seriesContextMenuItems" class="mx-px" @action="seriesContextMenuAction" />
|
<ui-context-menu-dropdown v-if="!isBatchSelecting && seriesContextMenuItems.length" :items="seriesContextMenuItems" class="mx-px" @action="seriesContextMenuAction" />
|
||||||
</template>
|
</template>
|
||||||
<!-- library & collections page -->
|
<!-- library & collections page -->
|
||||||
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome && !isAuthorsPage">
|
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome">
|
||||||
<p class="hidden md:block">{{ $formatNumber(numShowing) }} {{ entityName }}</p>
|
<p class="hidden md:block">{{ numShowing }} {{ entityName }}</p>
|
||||||
|
|
||||||
<div class="flex-grow hidden sm:inline-block" />
|
<div class="flex-grow hidden sm:inline-block" />
|
||||||
|
|
||||||
|
<!-- collapse series checkbox -->
|
||||||
|
<ui-checkbox v-if="isLibraryPage && isBookLibrary && !isBatchSelecting" v-model="settings.collapseSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
|
||||||
|
|
||||||
<!-- library filter select -->
|
<!-- library filter select -->
|
||||||
<controls-library-filter-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
|
<controls-library-filter-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
|
||||||
|
|
||||||
@@ -83,31 +80,18 @@
|
|||||||
<controls-sort-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesSortBy" :descending.sync="settings.seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" />
|
<controls-sort-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesSortBy" :descending.sync="settings.seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" />
|
||||||
|
|
||||||
<!-- issues page remove all button -->
|
<!-- issues page remove all button -->
|
||||||
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ $formatNumber(numShowing) }} {{ entityName }}</ui-btn>
|
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
|
||||||
|
|
||||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
|
||||||
</template>
|
</template>
|
||||||
<!-- search page -->
|
<!-- search page -->
|
||||||
<template v-else-if="page === 'search'">
|
<template v-else-if="page === 'search'">
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p>{{ $strings.MessageSearchResultsFor }} "{{ searchQuery }}"</p>
|
<p>{{ $strings.MessageSearchResultsFor }} "{{ searchQuery }}"</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
|
||||||
</template>
|
</template>
|
||||||
<!-- authors page -->
|
<!-- authors page -->
|
||||||
<template v-else-if="isAuthorsPage">
|
<template v-else-if="page === 'authors'">
|
||||||
<p class="hidden md:block">{{ $formatNumber(numShowing) }} {{ entityName }}</p>
|
|
||||||
|
|
||||||
<div class="flex-grow hidden sm:inline-block" />
|
|
||||||
<ui-btn v-if="userCanUpdate && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
|
|
||||||
|
|
||||||
<!-- author sort select -->
|
|
||||||
<controls-sort-select v-model="settings.authorSortBy" :descending.sync="settings.authorSortDesc" :items="authorSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateAuthorSort" />
|
|
||||||
</template>
|
|
||||||
<!-- home page -->
|
|
||||||
<template v-else-if="isHome">
|
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
<ui-btn v-if="userCanUpdate && authors && authors.length && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,7 +106,11 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
},
|
},
|
||||||
searchQuery: String
|
searchQuery: String,
|
||||||
|
authors: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -154,14 +142,11 @@ export default {
|
|||||||
|
|
||||||
if (this.isSeriesRemovedFromContinueListening) {
|
if (this.isSeriesRemovedFromContinueListening) {
|
||||||
items.push({
|
items.push({
|
||||||
text: this.$strings.LabelReAddSeriesToContinueListening,
|
text: 'Re-Add Series to Continue Listening',
|
||||||
action: 're-add-to-continue-listening'
|
action: 're-add-to-continue-listening'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
this.addSubtitlesMenuItem(items)
|
|
||||||
this.addCollapseSubSeriesMenuItem(items)
|
|
||||||
|
|
||||||
return items
|
return items
|
||||||
},
|
},
|
||||||
seriesSortItems() {
|
seriesSortItems() {
|
||||||
@@ -178,45 +163,9 @@ export default {
|
|||||||
text: this.$strings.LabelAddedAt,
|
text: this.$strings.LabelAddedAt,
|
||||||
value: 'addedAt'
|
value: 'addedAt'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
text: this.$strings.LabelLastBookAdded,
|
|
||||||
value: 'lastBookAdded'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelLastBookUpdated,
|
|
||||||
value: 'lastBookUpdated'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelTotalDuration,
|
text: this.$strings.LabelTotalDuration,
|
||||||
value: 'totalDuration'
|
value: 'totalDuration'
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelRandomly,
|
|
||||||
value: 'random'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -229,15 +178,9 @@ export default {
|
|||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
},
|
||||||
userCanDownload() {
|
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
|
||||||
},
|
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
libraryProvider() {
|
|
||||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
|
||||||
},
|
|
||||||
currentLibraryMediaType() {
|
currentLibraryMediaType() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
},
|
},
|
||||||
@@ -247,6 +190,9 @@ export default {
|
|||||||
isPodcastLibrary() {
|
isPodcastLibrary() {
|
||||||
return this.currentLibraryMediaType === 'podcast'
|
return this.currentLibraryMediaType === 'podcast'
|
||||||
},
|
},
|
||||||
|
isMusicLibrary() {
|
||||||
|
return this.currentLibraryMediaType === 'music'
|
||||||
|
},
|
||||||
isLibraryPage() {
|
isLibraryPage() {
|
||||||
return this.page === ''
|
return this.page === ''
|
||||||
},
|
},
|
||||||
@@ -268,11 +214,8 @@ export default {
|
|||||||
isPodcastLatestPage() {
|
isPodcastLatestPage() {
|
||||||
return this.$route.name === 'library-library-podcast-latest'
|
return this.$route.name === 'library-library-podcast-latest'
|
||||||
},
|
},
|
||||||
isPodcastDownloadQueuePage() {
|
|
||||||
return this.$route.name === 'library-library-podcast-download-queue'
|
|
||||||
},
|
|
||||||
isAuthorsPage() {
|
isAuthorsPage() {
|
||||||
return this.page === 'authors'
|
return this.$route.name === 'library-library-authors'
|
||||||
},
|
},
|
||||||
isAlbumsPage() {
|
isAlbumsPage() {
|
||||||
return this.page === 'albums'
|
return this.page === 'albums'
|
||||||
@@ -282,13 +225,13 @@ export default {
|
|||||||
},
|
},
|
||||||
entityName() {
|
entityName() {
|
||||||
if (this.isAlbumsPage) return 'Albums'
|
if (this.isAlbumsPage) return 'Albums'
|
||||||
|
if (this.isMusicLibrary) return 'Tracks'
|
||||||
|
|
||||||
if (this.isPodcastLibrary) return this.$strings.LabelPodcasts
|
if (this.isPodcastLibrary) return this.$strings.LabelPodcasts
|
||||||
if (!this.page) return this.$strings.LabelBooks
|
if (!this.page) return this.$strings.LabelBooks
|
||||||
if (this.isSeriesPage) return this.$strings.LabelSeries
|
if (this.isSeriesPage) return this.$strings.LabelSeries
|
||||||
if (this.isCollectionsPage) return this.$strings.LabelCollections
|
if (this.isCollectionsPage) return this.$strings.LabelCollections
|
||||||
if (this.isPlaylistsPage) return this.$strings.LabelPlaylists
|
if (this.isPlaylistsPage) return this.$strings.LabelPlaylists
|
||||||
if (this.isAuthorsPage) return this.$strings.LabelAuthors
|
|
||||||
return ''
|
return ''
|
||||||
},
|
},
|
||||||
seriesId() {
|
seriesId() {
|
||||||
@@ -322,125 +265,10 @@ export default {
|
|||||||
},
|
},
|
||||||
isIssuesFilter() {
|
isIssuesFilter() {
|
||||||
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
||||||
},
|
|
||||||
contextMenuItems() {
|
|
||||||
const items = []
|
|
||||||
|
|
||||||
if (this.isPodcastLibrary && this.isLibraryPage && this.userCanDownload) {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.LabelExportOPML,
|
|
||||||
action: 'export-opml'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.addSubtitlesMenuItem(items)
|
|
||||||
this.addCollapseSeriesMenuItem(items)
|
|
||||||
|
|
||||||
return items
|
|
||||||
},
|
|
||||||
showPlaylists() {
|
|
||||||
return this.$store.state.libraries.numUserPlaylists > 0
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
addSubtitlesMenuItem(items) {
|
seriesContextMenuAction(action) {
|
||||||
if (this.isBookLibrary && (!this.page || this.page === 'search')) {
|
|
||||||
if (this.settings.showSubtitles) {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.LabelHideSubtitles,
|
|
||||||
action: 'hide-subtitles'
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.LabelShowSubtitles,
|
|
||||||
action: 'show-subtitles'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
addCollapseSeriesMenuItem(items) {
|
|
||||||
if (this.isLibraryPage && this.isBookLibrary && !this.isBatchSelecting) {
|
|
||||||
if (this.settings.collapseSeries) {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.LabelExpandSeries,
|
|
||||||
action: 'expand-series'
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.LabelCollapseSeries,
|
|
||||||
action: 'collapse-series'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
addCollapseSubSeriesMenuItem(items) {
|
|
||||||
if (this.selectedSeries && this.isBookLibrary && !this.isBatchSelecting) {
|
|
||||||
if (this.settings.collapseBookSeries) {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.LabelExpandSubSeries,
|
|
||||||
action: 'expand-sub-series'
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.LabelCollapseSubSeries,
|
|
||||||
action: 'collapse-sub-series'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
handleSubtitlesAction(action) {
|
|
||||||
if (action === 'show-subtitles') {
|
|
||||||
this.settings.showSubtitles = true
|
|
||||||
this.updateShowSubtitles()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (action === 'hide-subtitles') {
|
|
||||||
this.settings.showSubtitles = false
|
|
||||||
this.updateShowSubtitles()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
handleCollapseSeriesAction(action) {
|
|
||||||
if (action === 'collapse-series') {
|
|
||||||
this.settings.collapseSeries = true
|
|
||||||
this.updateCollapseSeries()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (action === 'expand-series') {
|
|
||||||
this.settings.collapseSeries = false
|
|
||||||
this.updateCollapseSeries()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
handleCollapseSubSeriesAction(action) {
|
|
||||||
if (action === 'collapse-sub-series') {
|
|
||||||
this.settings.collapseBookSeries = true
|
|
||||||
this.updateCollapseSubSeries()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (action === 'expand-sub-series') {
|
|
||||||
this.settings.collapseBookSeries = false
|
|
||||||
this.updateCollapseSubSeries()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
contextMenuAction({ action }) {
|
|
||||||
if (action === 'export-opml') {
|
|
||||||
this.exportOPML()
|
|
||||||
return
|
|
||||||
} else if (this.handleSubtitlesAction(action)) {
|
|
||||||
return
|
|
||||||
} else if (this.handleCollapseSeriesAction(action)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
},
|
|
||||||
exportOPML() {
|
|
||||||
this.$downloadFile(`/api/libraries/${this.currentLibraryId}/opml?token=${this.$store.getters['user/getToken']}`, null, true)
|
|
||||||
},
|
|
||||||
seriesContextMenuAction({ action }) {
|
|
||||||
if (action === 'open-rss-feed') {
|
if (action === 'open-rss-feed') {
|
||||||
this.showOpenSeriesRSSFeed()
|
this.showOpenSeriesRSSFeed()
|
||||||
} else if (action === 're-add-to-continue-listening') {
|
} else if (action === 're-add-to-continue-listening') {
|
||||||
@@ -455,10 +283,6 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.markSeriesFinished()
|
this.markSeriesFinished()
|
||||||
} else if (this.handleSubtitlesAction(action)) {
|
|
||||||
return
|
|
||||||
} else if (this.handleCollapseSubSeriesAction(action)) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
showOpenSeriesRSSFeed() {
|
showOpenSeriesRSSFeed() {
|
||||||
@@ -474,58 +298,42 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.$get(`/api/me/series/${this.seriesId}/readd-to-continue-listening`)
|
.$get(`/api/me/series/${this.seriesId}/readd-to-continue-listening`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success(this.$strings.ToastItemUpdateSuccess)
|
this.$toast.success('Series re-added to continue listening')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to re-add series to continue listening', error)
|
console.error('Failed to re-add series to continue listening', error)
|
||||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
this.$toast.error('Failed to re-add series to continue listening')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.processingSeries = false
|
this.processingSeries = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async fetchAllAuthors() {
|
|
||||||
// fetch all authors from the server, in the order that they are currently displayed
|
|
||||||
const response = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/authors?sort=${this.settings.authorSortBy}&desc=${this.settings.authorSortDesc}`)
|
|
||||||
return response.authors
|
|
||||||
},
|
|
||||||
async matchAllAuthors() {
|
async matchAllAuthors() {
|
||||||
this.processingAuthors = true
|
this.processingAuthors = true
|
||||||
|
|
||||||
try {
|
for (const author of this.authors) {
|
||||||
const authors = await this.fetchAllAuthors()
|
const payload = {}
|
||||||
|
if (author.asin) payload.asin = author.asin
|
||||||
|
else payload.q = author.name
|
||||||
|
console.log('Payload', payload, 'author', author)
|
||||||
|
|
||||||
for (const author of authors) {
|
this.$eventBus.$emit(`searching-author-${author.id}`, true)
|
||||||
const payload = {}
|
|
||||||
if (author.asin) payload.asin = author.asin
|
|
||||||
else payload.q = author.name
|
|
||||||
|
|
||||||
payload.region = 'us'
|
var response = await this.$axios.$post(`/api/authors/${author.id}/match`, payload).catch((error) => {
|
||||||
if (this.libraryProvider.startsWith('audible.')) {
|
console.error('Failed', error)
|
||||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
return null
|
||||||
}
|
})
|
||||||
|
if (!response) {
|
||||||
this.$eventBus.$emit(`searching-author-${author.id}`, true)
|
console.error(`Author ${author.name} not found`)
|
||||||
|
this.$toast.error(`Author ${author.name} not found`)
|
||||||
var response = await this.$axios.$post(`/api/authors/${author.id}/match`, payload).catch((error) => {
|
} else if (response.updated) {
|
||||||
console.error('Failed', error)
|
if (response.author.imagePath) console.log(`Author ${response.author.name} was updated`)
|
||||||
return null
|
else console.log(`Author ${response.author.name} was updated (no image found)`)
|
||||||
})
|
} else {
|
||||||
if (!response) {
|
console.log(`No updates were made for Author ${response.author.name}`)
|
||||||
console.error(`Author ${author.name} not found`)
|
|
||||||
this.$toast.error(this.$getString('ToastAuthorNotFound', [author.name]))
|
|
||||||
} else if (response.updated) {
|
|
||||||
if (response.author.imagePath) console.log(`Author ${response.author.name} was updated`)
|
|
||||||
else console.log(`Author ${response.author.name} was updated (no image found)`)
|
|
||||||
} else {
|
|
||||||
console.log(`No updates were made for Author ${response.author.name}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$eventBus.$emit(`searching-author-${author.id}`, false)
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to match all authors', error)
|
this.$eventBus.$emit(`searching-author-${author.id}`, false)
|
||||||
this.$toast.error(this.$strings.ToastMatchAllAuthorsFailed)
|
|
||||||
}
|
}
|
||||||
this.processingAuthors = false
|
this.processingAuthors = false
|
||||||
},
|
},
|
||||||
@@ -535,13 +343,13 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.$delete(`/api/libraries/${this.currentLibraryId}/issues`)
|
.$delete(`/api/libraries/${this.currentLibraryId}/issues`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success(this.$strings.ToastRemoveItemsWithIssuesSuccess)
|
this.$toast.success('Removed library items with issues')
|
||||||
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
|
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
|
||||||
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
|
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to remove library items with issues', error)
|
console.error('Failed to remove library items with issues', error)
|
||||||
this.$toast.error(this.$strings.ToastRemoveItemsWithIssuesFailed)
|
this.$toast.error('Failed to remove library items with issues')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.processingIssues = false
|
this.processingIssues = false
|
||||||
@@ -597,13 +405,7 @@ export default {
|
|||||||
updateCollapseSeries() {
|
updateCollapseSeries() {
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
},
|
},
|
||||||
updateCollapseSubSeries() {
|
updateCollapseBookSeries() {
|
||||||
this.saveSettings()
|
|
||||||
},
|
|
||||||
updateShowSubtitles() {
|
|
||||||
this.saveSettings()
|
|
||||||
},
|
|
||||||
updateAuthorSort() {
|
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
},
|
},
|
||||||
saveSettings() {
|
saveSettings() {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div role="toolbar" aria-orientation="vertical" aria-label="Config Sidebar">
|
<div>
|
||||||
<div role="navigation" aria-label="Config Navigation" class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside">
|
<div class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside">
|
||||||
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
|
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
|
||||||
<span class="material-symbols text-2xl">arrow_back</span>
|
<span class="material-icons text-2xl">arrow_back</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-3 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-3 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||||
@@ -10,16 +10,16 @@
|
|||||||
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<modals-changelog-view-modal v-model="showChangelogModal" :versionData="versionData" />
|
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-44 h-12 px-4 border-t bg-bg border-black border-opacity-20 fixed left-0 flex flex-col justify-center" :class="wrapperClass" :style="{ bottom: streamLibraryItem ? '160px' : '0px' }">
|
<div class="w-44 h-12 px-4 border-t bg-bg border-black border-opacity-20 fixed left-0 flex flex-col justify-center" :class="wrapperClass" :style="{ bottom: streamLibraryItem ? '160px' : '0px' }">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex justify-between">
|
||||||
<button type="button" class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</button>
|
<p class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</p>
|
||||||
|
|
||||||
<p class="text-xs text-gray-300 italic">{{ Source }}</p>
|
<p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p>
|
||||||
</div>
|
</div>
|
||||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ versionData.latestVersion }}</a>
|
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -90,33 +90,18 @@ export default {
|
|||||||
title: this.$strings.HeaderNotifications,
|
title: this.$strings.HeaderNotifications,
|
||||||
path: '/config/notifications'
|
path: '/config/notifications'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'config-email',
|
|
||||||
title: this.$strings.HeaderEmail,
|
|
||||||
path: '/config/email'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'config-item-metadata-utils',
|
id: 'config-item-metadata-utils',
|
||||||
title: this.$strings.HeaderItemMetadataUtils,
|
title: this.$strings.HeaderItemMetadataUtils,
|
||||||
path: '/config/item-metadata-utils'
|
path: '/config/item-metadata-utils'
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'config-rss-feeds',
|
|
||||||
title: this.$strings.HeaderRSSFeeds,
|
|
||||||
path: '/config/rss-feeds'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'config-authentication',
|
|
||||||
title: this.$strings.HeaderAuthentication,
|
|
||||||
path: '/config/authentication'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
if (this.currentLibraryId) {
|
if (this.currentLibraryId) {
|
||||||
configRoutes.push({
|
configRoutes.push({
|
||||||
id: 'library-stats',
|
id: 'config-library-stats',
|
||||||
title: this.$strings.HeaderLibraryStats,
|
title: this.$strings.HeaderLibraryStats,
|
||||||
path: `/library/${this.currentLibraryId}/stats`
|
path: '/config/library-stats'
|
||||||
})
|
})
|
||||||
configRoutes.push({
|
configRoutes.push({
|
||||||
id: 'config-stats',
|
id: 'config-stats',
|
||||||
@@ -156,9 +141,15 @@ export default {
|
|||||||
hasUpdate() {
|
hasUpdate() {
|
||||||
return !!this.versionData.hasUpdate
|
return !!this.versionData.hasUpdate
|
||||||
},
|
},
|
||||||
|
latestVersion() {
|
||||||
|
return this.versionData.latestVersion
|
||||||
|
},
|
||||||
githubTagUrl() {
|
githubTagUrl() {
|
||||||
return this.versionData.githubTagUrl
|
return this.versionData.githubTagUrl
|
||||||
},
|
},
|
||||||
|
currentVersionChangelog() {
|
||||||
|
return this.versionData.currentVersionChangelog || 'No Changelog Available'
|
||||||
|
},
|
||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
}
|
}
|
||||||
@@ -176,4 +167,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="bookshelf" ref="bookshelf" class="w-full overflow-y-auto" :style="{ fontSize: sizeMultiplier + 'rem' }">
|
<div id="bookshelf" class="w-full overflow-y-auto">
|
||||||
<template v-for="shelf in totalShelves">
|
<template v-for="shelf in totalShelves">
|
||||||
<div :key="shelf" :id="`shelf-${shelf - 1}`" class="w-full px-4e sm:px-8e relative" :class="{ bookshelfRow: !isAlternativeBookshelfView }" :style="{ height: shelfHeight + 'px' }">
|
<div :key="shelf" :id="`shelf-${shelf - 1}`" class="w-full px-4 sm:px-8 relative" :class="{ bookshelfRow: !isAlternativeBookshelfView }" :style="{ height: shelfHeight + 'px' }">
|
||||||
<!-- Card skeletons -->
|
<div v-if="!isAlternativeBookshelfView" class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20" :class="`h-${shelfDividerHeightIndex}`" />
|
||||||
<template v-for="entityIndex in entitiesInShelf(shelf)">
|
|
||||||
<div :key="entityIndex" class="w-full h-full absolute rounded z-5 top-0 left-0 bg-primary box-shadow-book" :style="{ transform: entityTransform(entityIndex), width: cardWidth + 'px', height: coverHeight + 'px' }" />
|
|
||||||
</template>
|
|
||||||
<div v-if="!isAlternativeBookshelfView" class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20 h-6e" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -14,26 +10,18 @@
|
|||||||
<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" :loading="isScanningLibrary || tempIsScanning" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>
|
<ui-btn color="success" class="w-52" @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">
|
||||||
<p class="text-xl text-center">{{ emptyMessage }}</p>
|
<p class="text-xl text-center">{{ emptyMessage }}</p>
|
||||||
<div v-if="entityName === 'collections' || entityName === 'playlists'" class="flex justify-center mt-4">
|
|
||||||
{{ emptyMessageHelp }}
|
|
||||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
|
||||||
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
|
|
||||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
|
||||||
</a>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
<!-- Clear filter only available on Library bookshelf -->
|
<!-- Clear filter only available on Library bookshelf -->
|
||||||
<div v-if="entityName === 'items'" class="flex justify-center mt-2">
|
<div v-if="entityName === 'items'" class="flex justify-center mt-2">
|
||||||
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">{{ $strings.ButtonClearFilter }}</ui-btn>
|
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">{{ $strings.ButtonClearFilter }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<widgets-cover-size-widget class="fixed right-4 z-50" :style="{ bottom: streamLibraryItem ? '181px' : '16px' }" />
|
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -61,9 +49,10 @@ export default {
|
|||||||
entityIndexesMounted: [],
|
entityIndexesMounted: [],
|
||||||
entityComponentRefs: {},
|
entityComponentRefs: {},
|
||||||
currentBookWidth: 0,
|
currentBookWidth: 0,
|
||||||
|
pageLoadQueue: [],
|
||||||
isFetchingEntities: false,
|
isFetchingEntities: false,
|
||||||
scrollTimeout: null,
|
scrollTimeout: null,
|
||||||
booksPerFetch: 0,
|
booksPerFetch: 100,
|
||||||
totalShelves: 0,
|
totalShelves: 0,
|
||||||
bookshelfMarginLeft: 0,
|
bookshelfMarginLeft: 0,
|
||||||
isSelectionMode: false,
|
isSelectionMode: false,
|
||||||
@@ -73,17 +62,7 @@ export default {
|
|||||||
currScrollTop: 0,
|
currScrollTop: 0,
|
||||||
resizeTimeout: null,
|
resizeTimeout: null,
|
||||||
mountWindowWidth: 0,
|
mountWindowWidth: 0,
|
||||||
lastItemIndexSelected: -1,
|
lastItemIndexSelected: -1
|
||||||
tempIsScanning: false,
|
|
||||||
cardWidth: 0,
|
|
||||||
cardHeight: 0,
|
|
||||||
coverHeight: 0,
|
|
||||||
resizeObserver: null,
|
|
||||||
lastScrollTop: 0,
|
|
||||||
lastTimestamp: 0,
|
|
||||||
postScrollTimeout: null,
|
|
||||||
currFirstEntityIndex: -1,
|
|
||||||
currLastEntityIndex: -1
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -99,6 +78,9 @@ export default {
|
|||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
libraryMediaType() {
|
libraryMediaType() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
},
|
},
|
||||||
@@ -109,7 +91,6 @@ export default {
|
|||||||
if (this.page === 'series') return this.$strings.MessageBookshelfNoSeries
|
if (this.page === 'series') return this.$strings.MessageBookshelfNoSeries
|
||||||
if (this.page === 'collections') return this.$strings.MessageBookshelfNoCollections
|
if (this.page === 'collections') return this.$strings.MessageBookshelfNoCollections
|
||||||
if (this.page === 'playlists') return this.$strings.MessageNoUserPlaylists
|
if (this.page === 'playlists') return this.$strings.MessageNoUserPlaylists
|
||||||
if (this.page === 'authors') return this.$strings.MessageNoAuthors
|
|
||||||
if (this.hasFilter) {
|
if (this.hasFilter) {
|
||||||
if (this.filterName === 'Issues') return this.$strings.MessageNoIssues
|
if (this.filterName === 'Issues') return this.$strings.MessageNoIssues
|
||||||
else if (this.filterName === 'Feed-open') return this.$strings.MessageBookshelfNoRSSFeeds
|
else if (this.filterName === 'Feed-open') return this.$strings.MessageBookshelfNoRSSFeeds
|
||||||
@@ -117,11 +98,6 @@ export default {
|
|||||||
}
|
}
|
||||||
return this.$strings.MessageNoResults
|
return this.$strings.MessageNoResults
|
||||||
},
|
},
|
||||||
emptyMessageHelp() {
|
|
||||||
if (this.page === 'collections') return this.$strings.MessageBookshelfNoCollectionsHelp
|
|
||||||
if (this.page === 'playlists') return this.$strings.MessageNoUserPlaylistsHelp
|
|
||||||
return ''
|
|
||||||
},
|
|
||||||
entityName() {
|
entityName() {
|
||||||
if (!this.page) return 'items'
|
if (!this.page) return 'items'
|
||||||
return this.page
|
return this.page
|
||||||
@@ -135,12 +111,6 @@ export default {
|
|||||||
seriesFilterBy() {
|
seriesFilterBy() {
|
||||||
return this.$store.getters['user/getUserSetting']('seriesFilterBy')
|
return this.$store.getters['user/getUserSetting']('seriesFilterBy')
|
||||||
},
|
},
|
||||||
authorSortBy() {
|
|
||||||
return this.$store.getters['user/getUserSetting']('authorSortBy')
|
|
||||||
},
|
|
||||||
authorSortDesc() {
|
|
||||||
return !!this.$store.getters['user/getUserSetting']('authorSortDesc')
|
|
||||||
},
|
|
||||||
orderBy() {
|
orderBy() {
|
||||||
return this.$store.getters['user/getUserSetting']('orderBy')
|
return this.$store.getters['user/getUserSetting']('orderBy')
|
||||||
},
|
},
|
||||||
@@ -192,40 +162,52 @@ export default {
|
|||||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||||
},
|
},
|
||||||
bookWidth() {
|
bookWidth() {
|
||||||
return this.cardWidth
|
const coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||||
|
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return coverSize * 1.6
|
||||||
|
return coverSize
|
||||||
|
},
|
||||||
|
bookHeight() {
|
||||||
|
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return this.bookWidth
|
||||||
|
return this.bookWidth * 1.6
|
||||||
},
|
},
|
||||||
shelfPadding() {
|
shelfPadding() {
|
||||||
if (this.bookshelfWidth < 640) return 32 * this.sizeMultiplier
|
if (this.bookshelfWidth < 640) return 32
|
||||||
return 64 * this.sizeMultiplier
|
return 64
|
||||||
},
|
},
|
||||||
totalPadding() {
|
totalPadding() {
|
||||||
return this.shelfPadding * 2
|
return this.shelfPadding * 2
|
||||||
},
|
},
|
||||||
entityWidth() {
|
entityWidth() {
|
||||||
return this.cardWidth
|
if (this.entityName === 'series' || this.entityName === 'collections') {
|
||||||
|
if (this.bookWidth * 2 > this.bookshelfWidth - this.shelfPadding) return this.bookWidth * 1.6
|
||||||
|
return this.bookWidth * 2
|
||||||
|
}
|
||||||
|
return this.bookWidth
|
||||||
},
|
},
|
||||||
shelfPaddingHeight() {
|
entityHeight() {
|
||||||
return 16
|
return this.bookHeight
|
||||||
|
},
|
||||||
|
shelfDividerHeightIndex() {
|
||||||
|
return 6
|
||||||
},
|
},
|
||||||
shelfHeight() {
|
shelfHeight() {
|
||||||
const dividerHeight = this.isAlternativeBookshelfView ? 0 : 24 // h-6
|
if (this.isAlternativeBookshelfView) {
|
||||||
return this.cardHeight + (this.shelfPaddingHeight + dividerHeight) * this.sizeMultiplier
|
const isItemEntity = this.entityName === 'series-books' || this.entityName === 'items'
|
||||||
|
const extraTitleSpace = isItemEntity ? 80 : this.entityName === 'albums' ? 60 : 40
|
||||||
|
return this.entityHeight + extraTitleSpace * this.sizeMultiplier
|
||||||
|
}
|
||||||
|
return this.entityHeight + 40
|
||||||
},
|
},
|
||||||
totalEntityCardWidth() {
|
totalEntityCardWidth() {
|
||||||
// Includes margin
|
// Includes margin
|
||||||
return this.entityWidth + 24 * this.sizeMultiplier
|
return this.entityWidth + 24
|
||||||
},
|
},
|
||||||
selectedMediaItems() {
|
selectedMediaItems() {
|
||||||
return this.$store.state.globals.selectedMediaItems || []
|
return this.$store.state.globals.selectedMediaItems || []
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
return this.$store.getters['user/getSizeMultiplier']
|
const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||||
},
|
return this.entityWidth / baseSize
|
||||||
streamLibraryItem() {
|
|
||||||
return this.$store.state.streamLibraryItem
|
|
||||||
},
|
|
||||||
isScanningLibrary() {
|
|
||||||
return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.currentLibraryId)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -241,8 +223,6 @@ export default {
|
|||||||
this.$store.commit('globals/setEditCollection', entity)
|
this.$store.commit('globals/setEditCollection', entity)
|
||||||
} else if (this.entityName === 'playlists') {
|
} else if (this.entityName === 'playlists') {
|
||||||
this.$store.commit('globals/setEditPlaylist', entity)
|
this.$store.commit('globals/setEditPlaylist', entity)
|
||||||
} else if (this.entityName === 'authors') {
|
|
||||||
this.$store.commit('globals/showEditAuthorModal', entity)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
clearSelectedEntities() {
|
clearSelectedEntities() {
|
||||||
@@ -336,12 +316,12 @@ export default {
|
|||||||
this.currentSFQueryString = this.buildSearchParams()
|
this.currentSFQueryString = this.buildSearchParams()
|
||||||
}
|
}
|
||||||
|
|
||||||
let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
|
const entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
|
||||||
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||||
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete,share`
|
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed`
|
||||||
|
|
||||||
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
||||||
console.error('failed to fetch items', error)
|
console.error('failed to fetch books', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -371,53 +351,50 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
loadPage(page) {
|
loadPage(page) {
|
||||||
if (!this.pagesLoaded[page]) this.pagesLoaded[page] = this.fetchEntites(page)
|
this.pagesLoaded[page] = true
|
||||||
return this.pagesLoaded[page]
|
this.fetchEntites(page)
|
||||||
},
|
},
|
||||||
showHideBookPlaceholder(index, show) {
|
showHideBookPlaceholder(index, show) {
|
||||||
var el = document.getElementById(`book-${index}-placeholder`)
|
var el = document.getElementById(`book-${index}-placeholder`)
|
||||||
if (el) el.style.display = show ? 'flex' : 'none'
|
if (el) el.style.display = show ? 'flex' : 'none'
|
||||||
},
|
},
|
||||||
mountEntities(fromIndex, toIndex) {
|
mountEntites(fromIndex, toIndex) {
|
||||||
for (let i = fromIndex; i < toIndex; i++) {
|
for (let i = fromIndex; i < toIndex; i++) {
|
||||||
if (!this.entityIndexesMounted.includes(i)) {
|
if (!this.entityIndexesMounted.includes(i)) {
|
||||||
this.cardsHelpers.mountEntityCard(i)
|
this.cardsHelpers.mountEntityCard(i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getVisibleIndices(scrollTop) {
|
handleScroll(scrollTop) {
|
||||||
const firstShelfIndex = Math.floor(scrollTop / this.shelfHeight)
|
this.currScrollTop = scrollTop
|
||||||
const lastShelfIndex = Math.min(Math.ceil((scrollTop + this.bookshelfHeight) / this.shelfHeight), this.totalShelves - 1)
|
var firstShelfIndex = Math.floor(scrollTop / this.shelfHeight)
|
||||||
const firstEntityIndex = firstShelfIndex * this.entitiesPerShelf
|
var lastShelfIndex = Math.ceil((scrollTop + this.bookshelfHeight) / this.shelfHeight)
|
||||||
const lastEntityIndex = Math.min(lastShelfIndex * this.entitiesPerShelf + this.entitiesPerShelf, this.totalEntities)
|
lastShelfIndex = Math.min(this.totalShelves - 1, lastShelfIndex)
|
||||||
return { firstEntityIndex, lastEntityIndex }
|
|
||||||
},
|
var firstBookIndex = firstShelfIndex * this.entitiesPerShelf
|
||||||
postScroll() {
|
var lastBookIndex = lastShelfIndex * this.entitiesPerShelf + this.entitiesPerShelf
|
||||||
const { firstEntityIndex, lastEntityIndex } = this.getVisibleIndices(this.currScrollTop)
|
lastBookIndex = Math.min(this.totalEntities, lastBookIndex)
|
||||||
|
|
||||||
|
var firstBookPage = Math.floor(firstBookIndex / this.booksPerFetch)
|
||||||
|
var lastBookPage = Math.floor(lastBookIndex / this.booksPerFetch)
|
||||||
|
if (!this.pagesLoaded[firstBookPage]) {
|
||||||
|
// console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex)
|
||||||
|
this.loadPage(firstBookPage)
|
||||||
|
}
|
||||||
|
if (!this.pagesLoaded[lastBookPage]) {
|
||||||
|
// console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
|
||||||
|
this.loadPage(lastBookPage)
|
||||||
|
}
|
||||||
|
|
||||||
this.entityIndexesMounted = this.entityIndexesMounted.filter((_index) => {
|
this.entityIndexesMounted = this.entityIndexesMounted.filter((_index) => {
|
||||||
if (_index < firstEntityIndex || _index >= lastEntityIndex) {
|
if (_index < firstBookIndex || _index >= lastBookIndex) {
|
||||||
var el = this.entityComponentRefs[_index]
|
var el = document.getElementById(`book-card-${_index}`)
|
||||||
if (el && el.$el) el.$el.remove()
|
if (el) el.remove()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
},
|
this.mountEntites(firstBookIndex, lastBookIndex)
|
||||||
handleScroll(scrollTop) {
|
|
||||||
this.currScrollTop = scrollTop
|
|
||||||
const { firstEntityIndex, lastEntityIndex } = this.getVisibleIndices(scrollTop)
|
|
||||||
if (firstEntityIndex === this.currFirstEntityIndex && lastEntityIndex === this.currLastEntityIndex) return
|
|
||||||
this.currFirstEntityIndex = firstEntityIndex
|
|
||||||
this.currLastEntityIndex = lastEntityIndex
|
|
||||||
|
|
||||||
clearTimeout(this.postScrollTimeout)
|
|
||||||
const firstPage = Math.floor(firstEntityIndex / this.booksPerFetch)
|
|
||||||
const lastPage = Math.floor(lastEntityIndex / this.booksPerFetch)
|
|
||||||
Promise.all([this.loadPage(firstPage), this.loadPage(lastPage)])
|
|
||||||
.then(() => this.mountEntities(firstEntityIndex, lastEntityIndex))
|
|
||||||
.catch((error) => console.error('Failed to load page', error))
|
|
||||||
|
|
||||||
this.postScrollTimeout = setTimeout(this.postScroll, 500)
|
|
||||||
},
|
},
|
||||||
async resetEntities() {
|
async resetEntities() {
|
||||||
if (this.isFetchingEntities) {
|
if (this.isFetchingEntities) {
|
||||||
@@ -425,6 +402,8 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.destroyEntityComponents()
|
this.destroyEntityComponents()
|
||||||
|
this.entityIndexesMounted = []
|
||||||
|
this.entityComponentRefs = {}
|
||||||
this.pagesLoaded = {}
|
this.pagesLoaded = {}
|
||||||
this.entities = []
|
this.entities = []
|
||||||
this.totalShelves = 0
|
this.totalShelves = 0
|
||||||
@@ -434,21 +413,36 @@ export default {
|
|||||||
this.initialized = false
|
this.initialized = false
|
||||||
|
|
||||||
this.initSizeData()
|
this.initSizeData()
|
||||||
await this.loadPage(0)
|
this.pagesLoaded[0] = true
|
||||||
|
await this.fetchEntites(0)
|
||||||
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
||||||
this.mountEntities(0, lastBookIndex)
|
this.mountEntites(0, lastBookIndex)
|
||||||
},
|
},
|
||||||
async rebuild() {
|
remountEntities() {
|
||||||
|
for (const key in this.entityComponentRefs) {
|
||||||
|
if (this.entityComponentRefs[key]) {
|
||||||
|
this.entityComponentRefs[key].destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.entityComponentRefs = {}
|
||||||
|
this.entityIndexesMounted.forEach((i) => {
|
||||||
|
this.cardsHelpers.mountEntityCard(i)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
rebuild() {
|
||||||
this.initSizeData()
|
this.initSizeData()
|
||||||
|
|
||||||
var lastBookIndex = Math.min(this.totalEntities, this.booksPerFetch)
|
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
||||||
this.destroyEntityComponents()
|
this.entityIndexesMounted = []
|
||||||
await this.loadPage(0)
|
for (let i = 0; i < lastBookIndex; i++) {
|
||||||
|
this.entityIndexesMounted.push(i)
|
||||||
|
}
|
||||||
var bookshelfEl = document.getElementById('bookshelf')
|
var bookshelfEl = document.getElementById('bookshelf')
|
||||||
if (bookshelfEl) {
|
if (bookshelfEl) {
|
||||||
bookshelfEl.scrollTop = 0
|
bookshelfEl.scrollTop = 0
|
||||||
}
|
}
|
||||||
this.mountEntities(0, lastBookIndex)
|
|
||||||
|
this.$nextTick(this.remountEntities)
|
||||||
},
|
},
|
||||||
buildSearchParams() {
|
buildSearchParams() {
|
||||||
if (this.page === 'search' || this.page === 'collections') {
|
if (this.page === 'search' || this.page === 'collections') {
|
||||||
@@ -465,9 +459,6 @@ export default {
|
|||||||
if (this.collapseBookSeries) {
|
if (this.collapseBookSeries) {
|
||||||
searchParams.set('collapseseries', 1)
|
searchParams.set('collapseseries', 1)
|
||||||
}
|
}
|
||||||
} else if (this.page === 'authors') {
|
|
||||||
searchParams.set('sort', this.authorSortBy)
|
|
||||||
searchParams.set('desc', this.authorSortDesc ? 1 : 0)
|
|
||||||
} else {
|
} else {
|
||||||
if (this.filterBy && this.filterBy !== 'all') {
|
if (this.filterBy && this.filterBy !== 'all') {
|
||||||
searchParams.set('filter', this.filterBy)
|
searchParams.set('filter', this.filterBy)
|
||||||
@@ -506,35 +497,17 @@ export default {
|
|||||||
this.resetEntities()
|
this.resetEntities()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async settingsUpdated(settings) {
|
settingsUpdated(settings) {
|
||||||
await this.cardsHelpers.setCardSize()
|
|
||||||
const wasUpdated = this.checkUpdateSearchParams()
|
const wasUpdated = this.checkUpdateSearchParams()
|
||||||
if (wasUpdated) {
|
if (wasUpdated) {
|
||||||
this.resetEntities()
|
this.resetEntities()
|
||||||
} else if (settings.bookshelfCoverSize !== this.currentBookWidth) {
|
} else if (settings.bookshelfCoverSize !== this.currentBookWidth) {
|
||||||
this.rebuild()
|
this.executeRebuild()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getScrollRate() {
|
|
||||||
const currentTimestamp = Date.now()
|
|
||||||
const timeDelta = currentTimestamp - this.lastTimestamp
|
|
||||||
const scrollDelta = this.currScrollTop - this.lastScrollTop
|
|
||||||
const scrollRate = Math.abs(scrollDelta) / (timeDelta || 1)
|
|
||||||
this.lastScrollTop = this.currScrollTop
|
|
||||||
this.lastTimestamp = currentTimestamp
|
|
||||||
return scrollRate
|
|
||||||
},
|
|
||||||
scroll(e) {
|
scroll(e) {
|
||||||
if (!e || !e.target) return
|
if (!e || !e.target) return
|
||||||
clearTimeout(this.scrollTimeout)
|
var { scrollTop } = e.target
|
||||||
const { scrollTop } = e.target
|
|
||||||
const scrollRate = this.getScrollRate()
|
|
||||||
if (scrollRate > 5) {
|
|
||||||
this.scrollTimeout = setTimeout(() => {
|
|
||||||
this.handleScroll(scrollTop)
|
|
||||||
}, 25)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.handleScroll(scrollTop)
|
this.handleScroll(scrollTop)
|
||||||
},
|
},
|
||||||
libraryItemAdded(libraryItem) {
|
libraryItemAdded(libraryItem) {
|
||||||
@@ -629,73 +602,6 @@ export default {
|
|||||||
this.executeRebuild()
|
this.executeRebuild()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
authorAdded(author) {
|
|
||||||
if (this.entityName !== 'authors') return
|
|
||||||
console.log(`[LazyBookshelf] authorAdded ${author.id}`, author)
|
|
||||||
this.resetEntities()
|
|
||||||
},
|
|
||||||
authorUpdated(author) {
|
|
||||||
if (this.entityName !== 'authors') return
|
|
||||||
console.log(`[LazyBookshelf] authorUpdated ${author.id}`, author)
|
|
||||||
const indexOf = this.entities.findIndex((ent) => ent && ent.id === author.id)
|
|
||||||
if (indexOf >= 0) {
|
|
||||||
this.entities[indexOf] = author
|
|
||||||
if (this.entityComponentRefs[indexOf]) {
|
|
||||||
this.entityComponentRefs[indexOf].setEntity(author)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
authorRemoved(author) {
|
|
||||||
if (this.entityName !== 'authors') return
|
|
||||||
console.log(`[LazyBookshelf] authorRemoved ${author.id}`, author)
|
|
||||||
const indexOf = this.entities.findIndex((ent) => ent && ent.id === author.id)
|
|
||||||
if (indexOf >= 0) {
|
|
||||||
this.entities = this.entities.filter((ent) => ent.id !== author.id)
|
|
||||||
this.totalEntities--
|
|
||||||
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
|
||||||
this.executeRebuild()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
shareOpen(mediaItemShare) {
|
|
||||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
|
||||||
var indexOf = this.entities.findIndex((ent) => ent?.media?.id === mediaItemShare.mediaItemId)
|
|
||||||
if (indexOf >= 0) {
|
|
||||||
if (this.entityComponentRefs[indexOf]) {
|
|
||||||
const libraryItem = { ...this.entityComponentRefs[indexOf].libraryItem }
|
|
||||||
libraryItem.mediaItemShare = mediaItemShare
|
|
||||||
this.entityComponentRefs[indexOf].setEntity?.(libraryItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shareClosed(mediaItemShare) {
|
|
||||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
|
||||||
var indexOf = this.entities.findIndex((ent) => ent?.media?.id === mediaItemShare.mediaItemId)
|
|
||||||
if (indexOf >= 0) {
|
|
||||||
if (this.entityComponentRefs[indexOf]) {
|
|
||||||
const libraryItem = { ...this.entityComponentRefs[indexOf].libraryItem }
|
|
||||||
libraryItem.mediaItemShare = null
|
|
||||||
this.entityComponentRefs[indexOf].setEntity?.(libraryItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
updatePagesLoaded() {
|
|
||||||
let numPages = Math.ceil(this.totalEntities / this.booksPerFetch)
|
|
||||||
this.pagesLoaded = {}
|
|
||||||
for (let page = 0; page < numPages; page++) {
|
|
||||||
let numEntities = Math.min(this.totalEntities - page * this.booksPerFetch, this.booksPerFetch)
|
|
||||||
this.pagesLoaded[page] = Promise.resolve()
|
|
||||||
for (let i = 0; i < numEntities; i++) {
|
|
||||||
const index = page * this.booksPerFetch + i
|
|
||||||
if (!this.entities[index]) {
|
|
||||||
if (this.pagesLoaded[page]) delete this.pagesLoaded[page]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
initSizeData(_bookshelf) {
|
initSizeData(_bookshelf) {
|
||||||
var bookshelf = _bookshelf || document.getElementById('bookshelf')
|
var bookshelf = _bookshelf || document.getElementById('bookshelf')
|
||||||
if (!bookshelf) {
|
if (!bookshelf) {
|
||||||
@@ -705,19 +611,13 @@ export default {
|
|||||||
var entitiesPerShelfBefore = this.entitiesPerShelf
|
var entitiesPerShelfBefore = this.entitiesPerShelf
|
||||||
|
|
||||||
var { clientHeight, clientWidth } = bookshelf
|
var { clientHeight, clientWidth } = bookshelf
|
||||||
|
// console.log('Init bookshelf width', clientWidth, 'window width', window.innerWidth)
|
||||||
this.mountWindowWidth = window.innerWidth
|
this.mountWindowWidth = window.innerWidth
|
||||||
this.bookshelfHeight = clientHeight
|
this.bookshelfHeight = clientHeight
|
||||||
this.bookshelfWidth = clientWidth
|
this.bookshelfWidth = clientWidth
|
||||||
this.entitiesPerShelf = Math.max(1, Math.floor((this.bookshelfWidth - this.shelfPadding) / this.totalEntityCardWidth))
|
this.entitiesPerShelf = Math.max(1, Math.floor((this.bookshelfWidth - this.shelfPadding) / this.totalEntityCardWidth))
|
||||||
this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2
|
this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2
|
||||||
this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2
|
this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2
|
||||||
const booksPerFetch = this.entitiesPerShelf * this.shelvesPerPage
|
|
||||||
if (booksPerFetch !== this.booksPerFetch) {
|
|
||||||
this.booksPerFetch = booksPerFetch
|
|
||||||
if (this.totalEntities) {
|
|
||||||
this.updatePagesLoaded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentBookWidth = this.bookWidth
|
this.currentBookWidth = this.bookWidth
|
||||||
if (this.totalEntities) {
|
if (this.totalEntities) {
|
||||||
@@ -726,12 +626,13 @@ export default {
|
|||||||
return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed
|
return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed
|
||||||
},
|
},
|
||||||
async init(bookshelf) {
|
async init(bookshelf) {
|
||||||
this.initSizeData(bookshelf)
|
|
||||||
this.checkUpdateSearchParams()
|
this.checkUpdateSearchParams()
|
||||||
|
this.initSizeData(bookshelf)
|
||||||
|
|
||||||
await this.loadPage(0)
|
this.pagesLoaded[0] = true
|
||||||
|
await this.fetchEntites(0)
|
||||||
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
||||||
this.mountEntities(0, lastBookIndex)
|
this.mountEntites(0, lastBookIndex)
|
||||||
|
|
||||||
// Set last scroll position for this bookshelf page
|
// Set last scroll position for this bookshelf page
|
||||||
if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {
|
if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {
|
||||||
@@ -762,7 +663,7 @@ export default {
|
|||||||
var bookshelf = document.getElementById('bookshelf')
|
var bookshelf = document.getElementById('bookshelf')
|
||||||
if (bookshelf) {
|
if (bookshelf) {
|
||||||
this.init(bookshelf)
|
this.init(bookshelf)
|
||||||
bookshelf.addEventListener('scroll', this.scroll, { passive: true })
|
bookshelf.addEventListener('scroll', this.scroll)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -782,11 +683,6 @@ export default {
|
|||||||
this.$root.socket.on('playlist_added', this.playlistAdded)
|
this.$root.socket.on('playlist_added', this.playlistAdded)
|
||||||
this.$root.socket.on('playlist_updated', this.playlistUpdated)
|
this.$root.socket.on('playlist_updated', this.playlistUpdated)
|
||||||
this.$root.socket.on('playlist_removed', this.playlistRemoved)
|
this.$root.socket.on('playlist_removed', this.playlistRemoved)
|
||||||
this.$root.socket.on('author_added', this.authorAdded)
|
|
||||||
this.$root.socket.on('author_updated', this.authorUpdated)
|
|
||||||
this.$root.socket.on('author_removed', this.authorRemoved)
|
|
||||||
this.$root.socket.on('share_open', this.shareOpen)
|
|
||||||
this.$root.socket.on('share_closed', this.shareClosed)
|
|
||||||
} else {
|
} else {
|
||||||
console.error('Bookshelf - Socket not initialized')
|
console.error('Bookshelf - Socket not initialized')
|
||||||
}
|
}
|
||||||
@@ -814,49 +710,30 @@ export default {
|
|||||||
this.$root.socket.off('playlist_added', this.playlistAdded)
|
this.$root.socket.off('playlist_added', this.playlistAdded)
|
||||||
this.$root.socket.off('playlist_updated', this.playlistUpdated)
|
this.$root.socket.off('playlist_updated', this.playlistUpdated)
|
||||||
this.$root.socket.off('playlist_removed', this.playlistRemoved)
|
this.$root.socket.off('playlist_removed', this.playlistRemoved)
|
||||||
this.$root.socket.off('author_added', this.authorAdded)
|
|
||||||
this.$root.socket.off('author_updated', this.authorUpdated)
|
|
||||||
this.$root.socket.off('author_removed', this.authorRemoved)
|
|
||||||
this.$root.socket.off('share_open', this.shareOpen)
|
|
||||||
this.$root.socket.off('share_closed', this.shareClosed)
|
|
||||||
} else {
|
} else {
|
||||||
console.error('Bookshelf - Socket not initialized')
|
console.error('Bookshelf - Socket not initialized')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
destroyEntityComponents() {
|
destroyEntityComponents() {
|
||||||
for (const key in this.entityComponentRefs) {
|
for (const key in this.entityComponentRefs) {
|
||||||
const ref = this.entityComponentRefs[key]
|
if (this.entityComponentRefs[key] && this.entityComponentRefs[key].destroy) {
|
||||||
if (ref && ref.destroy) {
|
this.entityComponentRefs[key].destroy()
|
||||||
if (ref.$el) ref.$el.remove()
|
|
||||||
ref.destroy()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.entityComponentRefs = {}
|
|
||||||
this.entityIndexesMounted = []
|
|
||||||
},
|
},
|
||||||
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(this.$strings.ToastLibraryScanFailedToStart)
|
this.$toast.error('Failed to start scan')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
|
||||||
this.tempIsScanning = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
entitiesInShelf(shelf) {
|
|
||||||
return shelf == this.totalShelves ? this.totalEntities % this.entitiesPerShelf || this.entitiesPerShelf : this.entitiesPerShelf
|
|
||||||
},
|
|
||||||
entityTransform(entityIndex) {
|
|
||||||
const shelfOffsetY = this.shelfPaddingHeight * this.sizeMultiplier
|
|
||||||
const shelfOffsetX = (entityIndex - 1) * this.totalEntityCardWidth + this.bookshelfMarginLeft
|
|
||||||
return `translate3d(${shelfOffsetX}px, ${shelfOffsetY}px, 0px)`
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
mounted() {
|
||||||
await this.cardsHelpers.setCardSize()
|
|
||||||
this.initListeners()
|
this.initListeners()
|
||||||
|
|
||||||
this.routeFullPath = window.location.pathname + (window.location.search || '')
|
this.routeFullPath = window.location.pathname + (window.location.search || '')
|
||||||
@@ -891,6 +768,6 @@ export default {
|
|||||||
.bookshelfDivider {
|
.bookshelfDivider {
|
||||||
background: rgb(149, 119, 90);
|
background: rgb(149, 119, 90);
|
||||||
background: var(--bookshelf-divider-bg);
|
background: var(--bookshelf-divider-bg);
|
||||||
box-shadow: 0.125em 0.875em 0.5em #111111aa;
|
box-shadow: 2px 14px 8px #111111aa;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-2 sm:p-4 mb-8">
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||||
<div class="flex items-center mb-2">
|
<div class="flex items-center mb-2">
|
||||||
<slot name="header-prefix"></slot>
|
|
||||||
<h1 class="text-xl">{{ headerText }}</h1>
|
<h1 class="text-xl">{{ headerText }}</h1>
|
||||||
|
|
||||||
<slot name="header-items"></slot>
|
<div v-if="showAddButton" class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clicked">
|
||||||
|
<button type="button" class="material-icons" :aria-label="$strings.ButtonAdd + ': ' + headerText" style="font-size: 1.4rem">add</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="description" id="settings-description" class="mb-6 text-gray-200" v-html="description" />
|
<p v-if="description" id="settings-description" class="mb-6 text-gray-200" v-html="description" />
|
||||||
@@ -18,9 +19,14 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
headerText: String,
|
headerText: String,
|
||||||
description: String,
|
description: String,
|
||||||
note: String
|
note: String,
|
||||||
|
showAddButton: Boolean
|
||||||
},
|
},
|
||||||
methods: {}
|
methods: {
|
||||||
|
clicked() {
|
||||||
|
this.$emit('clicked')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,127 +1,117 @@
|
|||||||
<template>
|
<template>
|
||||||
<div role="toolbar" aria-orientation="vertical" aria-label="Library Sidebar" class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
|
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
|
||||||
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
|
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
|
||||||
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||||
|
|
||||||
<div id="siderail-buttons-container" role="navigation" aria-label="Library Navigation" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden">
|
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
</svg>
|
||||||
</svg>
|
|
||||||
|
|
||||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p>
|
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p>
|
||||||
|
|
||||||
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<span class="material-symbols text-2xl"></span>
|
<span class="material-icons text-2xl">format_list_bulleted</span>
|
||||||
|
|
||||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p>
|
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p>
|
||||||
|
|
||||||
<div v-show="isPodcastLatestPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isPodcastLatestPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p>
|
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p>
|
||||||
|
|
||||||
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p>
|
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p>
|
||||||
|
|
||||||
<div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<span class="material-symbols text-2xl"></span>
|
<span class="material-icons-outlined text-2xl">collections_bookmark</span>
|
||||||
|
|
||||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
|
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
|
||||||
|
|
||||||
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<span class="material-symbols text-2.5xl"></span>
|
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
|
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p>
|
||||||
|
|
||||||
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
<span class="abs-icons icon-podcast text-xl"></span>
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p>
|
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSearch }}</p>
|
||||||
|
|
||||||
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/narrators`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isNarratorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="isMusicLibrary" :to="`/library/${currentLibraryId}/bookshelf/albums`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isMusicAlbumsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<span class="material-symbols text-2xl"></span>
|
<span class="material-icons-outlined text-xl">album</span>
|
||||||
|
|
||||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p>
|
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p>
|
||||||
|
|
||||||
<div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="isBookLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/stats`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isStatsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<span class="material-symbols text-2xl"></span>
|
<span class="material-icons text-2.5xl">queue_music</span>
|
||||||
|
|
||||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonStats }}</p>
|
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
|
||||||
|
|
||||||
<div v-show="isStatsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<span class="abs-icons icon-podcast text-xl"></span>
|
<span class="material-icons text-2xl">file_download</span>
|
||||||
|
|
||||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAdd }}</p>
|
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonDownloadQueue }}</p>
|
||||||
|
|
||||||
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isPodcastDownloadQueuePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
|
||||||
<span class="material-symbols text-2xl"></span>
|
<span class="material-icons text-2xl">warning</span>
|
||||||
|
|
||||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonDownloadQueue }}</p>
|
<p class="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p>
|
||||||
|
|
||||||
<div v-show="isPodcastDownloadQueuePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="showingIssues" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
<div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center">
|
||||||
|
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
||||||
|
</div>
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : 'bg-error bg-opacity-20'">
|
<div class="w-full h-12 px-1 py-2 border-t border-black border-opacity-20 absolute left-0" :style="{ bottom: streamLibraryItem ? '240px' : '65px' }">
|
||||||
<span class="material-symbols text-2xl">warning</span>
|
|
||||||
|
|
||||||
<p class="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p>
|
|
||||||
|
|
||||||
<div v-show="showingIssues" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
|
||||||
<div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center">
|
|
||||||
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
|
||||||
</div>
|
|
||||||
</nuxt-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full h-12 px-1 py-2 border-t border-black/20 bg-bg absolute left-0" :style="{ bottom: streamLibraryItem ? '224px' : '65px' }">
|
|
||||||
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p>
|
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p>
|
||||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
|
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
|
||||||
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-changelog-view-modal v-model="showChangelogModal" :versionData="versionData" />
|
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -164,6 +154,9 @@ export default {
|
|||||||
isPodcastLibrary() {
|
isPodcastLibrary() {
|
||||||
return this.currentLibraryMediaType === 'podcast'
|
return this.currentLibraryMediaType === 'podcast'
|
||||||
},
|
},
|
||||||
|
isMusicLibrary() {
|
||||||
|
return this.currentLibraryMediaType === 'music'
|
||||||
|
},
|
||||||
isPodcastDownloadQueuePage() {
|
isPodcastDownloadQueuePage() {
|
||||||
return this.$route.name === 'library-library-podcast-download-queue'
|
return this.$route.name === 'library-library-podcast-download-queue'
|
||||||
},
|
},
|
||||||
@@ -173,6 +166,9 @@ export default {
|
|||||||
isPodcastLatestPage() {
|
isPodcastLatestPage() {
|
||||||
return this.$route.name === 'library-library-podcast-latest'
|
return this.$route.name === 'library-library-podcast-latest'
|
||||||
},
|
},
|
||||||
|
isMusicAlbumsPage() {
|
||||||
|
return this.paramId === 'albums'
|
||||||
|
},
|
||||||
homePage() {
|
homePage() {
|
||||||
return this.$route.name === 'library-library'
|
return this.$route.name === 'library-library'
|
||||||
},
|
},
|
||||||
@@ -180,17 +176,11 @@ export default {
|
|||||||
return this.$route.name === 'library-library-series-id' || this.paramId === 'series'
|
return this.$route.name === 'library-library-series-id' || this.paramId === 'series'
|
||||||
},
|
},
|
||||||
isAuthorsPage() {
|
isAuthorsPage() {
|
||||||
return this.libraryBookshelfPage && this.paramId === 'authors'
|
return this.$route.name === 'library-library-authors'
|
||||||
},
|
|
||||||
isNarratorsPage() {
|
|
||||||
return this.$route.name === 'library-library-narrators'
|
|
||||||
},
|
},
|
||||||
isPlaylistsPage() {
|
isPlaylistsPage() {
|
||||||
return this.paramId === 'playlists'
|
return this.paramId === 'playlists'
|
||||||
},
|
},
|
||||||
isStatsPage() {
|
|
||||||
return this.$route.name === 'library-library-stats'
|
|
||||||
},
|
|
||||||
libraryBookshelfPage() {
|
libraryBookshelfPage() {
|
||||||
return this.$route.name === 'library-library-bookshelf-id'
|
return this.$route.name === 'library-library-bookshelf-id'
|
||||||
},
|
},
|
||||||
@@ -216,6 +206,9 @@ export default {
|
|||||||
githubTagUrl() {
|
githubTagUrl() {
|
||||||
return this.versionData.githubTagUrl
|
return this.versionData.githubTagUrl
|
||||||
},
|
},
|
||||||
|
currentVersionChangelog() {
|
||||||
|
return this.versionData.currentVersionChangelog || 'No Changelog Available'
|
||||||
|
},
|
||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
},
|
},
|
||||||
@@ -231,12 +224,3 @@ export default {
|
|||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
#siderail-buttons-container {
|
|
||||||
max-height: calc(100vh - 64px - 48px);
|
|
||||||
}
|
|
||||||
#siderail-buttons-container.player-open {
|
|
||||||
max-height: calc(100vh - 64px - 48px - 160px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,65 +1,63 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="streamLibraryItem" id="mediaPlayerContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 lg:h-40 z-50 bg-primary px-2 lg:px-4 pb-1 lg:pb-4 pt-2">
|
<div v-if="streamLibraryItem" id="streamContainer" 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 class="absolute left-2 top-2 lg:left-4 cursor-pointer">
|
<div id="videoDock" />
|
||||||
<covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-2 top-2 md:left-4 cursor-pointer">
|
||||||
</div>
|
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||||
<div class="flex items-start mb-6 lg:mb-0" :class="isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
</nuxt-link>
|
||||||
<div class="min-w-0 w-full">
|
<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-center">
|
<div class="min-w-0">
|
||||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
|
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<widgets-explicit-indicator v-if="isExplicit" />
|
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
|
||||||
</div>
|
<span class="material-icons text-sm">person</span>
|
||||||
<div class="text-gray-400 flex items-center w-1/2 sm:w-4/5 lg:w-2/5">
|
<div class="flex items-center">
|
||||||
<span class="material-symbols text-sm">person</span>
|
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
|
||||||
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
|
<div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
|
||||||
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base truncate">
|
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
|
||||||
<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">
|
||||||
<span class="material-symbols text-xs">schedule</span>
|
<span class="material-icons text-xs">schedule</span>
|
||||||
<p class="font-mono text-xs sm:text-sm pl-1 sm:pl-1.5 pb-px">{{ totalDurationPretty }}</p>
|
<p class="font-mono text-xs sm:text-sm pl-1 sm:pl-1.5 pb-px">{{ totalDurationPretty }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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-symbols sm:px-2 py-1 lg:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</button>
|
<span class="material-icons sm:px-2 py-1 md:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<player-ui
|
<player-ui
|
||||||
ref="audioPlayer"
|
ref="audioPlayer"
|
||||||
:chapters="chapters"
|
:chapters="chapters"
|
||||||
:current-chapter="currentChapter"
|
|
||||||
:paused="!isPlaying"
|
:paused="!isPlaying"
|
||||||
:loading="playerLoading"
|
:loading="playerLoading"
|
||||||
:bookmarks="bookmarks"
|
:bookmarks="bookmarks"
|
||||||
:sleep-timer-set="sleepTimerSet"
|
:sleep-timer-set="sleepTimerSet"
|
||||||
:sleep-timer-remaining="sleepTimerRemaining"
|
:sleep-timer-remaining="sleepTimerRemaining"
|
||||||
:sleep-timer-type="sleepTimerType"
|
|
||||||
:is-podcast="isPodcast"
|
:is-podcast="isPodcast"
|
||||||
:hasNextItemInQueue="hasNextItemInQueue"
|
|
||||||
@playPause="playPause"
|
@playPause="playPause"
|
||||||
@jumpForward="jumpForward"
|
@jumpForward="jumpForward"
|
||||||
@jumpBackward="jumpBackward"
|
@jumpBackward="jumpBackward"
|
||||||
@setVolume="setVolume"
|
@setVolume="setVolume"
|
||||||
@setPlaybackRate="setPlaybackRate"
|
@setPlaybackRate="setPlaybackRate"
|
||||||
@seek="seek"
|
@seek="seek"
|
||||||
@nextItemInQueue="playNextItemInQueue"
|
|
||||||
@close="closePlayer"
|
@close="closePlayer"
|
||||||
@showBookmarks="showBookmarks"
|
@showBookmarks="showBookmarks"
|
||||||
@showSleepTimer="showSleepTimerModal = true"
|
@showSleepTimer="showSleepTimerModal = true"
|
||||||
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :playback-rate="currentPlaybackRate" :library-item-id="libraryItemId" @select="selectBookmark" />
|
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
||||||
|
|
||||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||||
|
|
||||||
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" />
|
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" :library-item-id="libraryItemId" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -79,16 +77,18 @@ export default {
|
|||||||
showSleepTimerModal: false,
|
showSleepTimerModal: false,
|
||||||
showPlayerQueueItemsModal: false,
|
showPlayerQueueItemsModal: false,
|
||||||
sleepTimerSet: false,
|
sleepTimerSet: false,
|
||||||
|
sleepTimerTime: 0,
|
||||||
sleepTimerRemaining: 0,
|
sleepTimerRemaining: 0,
|
||||||
sleepTimerType: null,
|
|
||||||
sleepTimer: null,
|
sleepTimer: null,
|
||||||
displayTitle: null,
|
displayTitle: null,
|
||||||
currentPlaybackRate: 1,
|
initialPlaybackRate: 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
|
||||||
},
|
},
|
||||||
@@ -120,33 +120,27 @@ export default {
|
|||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
},
|
},
|
||||||
streamEpisode() {
|
|
||||||
if (!this.$store.state.streamEpisodeId) return null
|
|
||||||
const episodes = this.streamLibraryItem.media.episodes || []
|
|
||||||
return episodes.find((ep) => ep.id === this.$store.state.streamEpisodeId)
|
|
||||||
},
|
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.streamLibraryItem?.id || null
|
return this.streamLibraryItem ? this.streamLibraryItem.id : null
|
||||||
},
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.streamLibraryItem?.media || {}
|
return this.streamLibraryItem ? this.streamLibraryItem.media || {} : {}
|
||||||
},
|
},
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.streamLibraryItem?.mediaType === 'podcast'
|
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false
|
||||||
|
},
|
||||||
|
isMusic() {
|
||||||
|
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'music' : false
|
||||||
},
|
},
|
||||||
isExplicit() {
|
isExplicit() {
|
||||||
return !!this.mediaMetadata.explicit
|
return this.mediaMetadata.explicit || false
|
||||||
},
|
},
|
||||||
mediaMetadata() {
|
mediaMetadata() {
|
||||||
return this.media.metadata || {}
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
chapters() {
|
chapters() {
|
||||||
if (this.streamEpisode) return this.streamEpisode.chapters || []
|
|
||||||
return this.media.chapters || []
|
return this.media.chapters || []
|
||||||
},
|
},
|
||||||
currentChapter() {
|
|
||||||
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
|
||||||
},
|
|
||||||
title() {
|
title() {
|
||||||
if (this.playerHandler.displayTitle) return this.playerHandler.displayTitle
|
if (this.playerHandler.displayTitle) return this.playerHandler.displayTitle
|
||||||
return this.mediaMetadata.title || 'No Title'
|
return this.mediaMetadata.title || 'No Title'
|
||||||
@@ -158,22 +152,15 @@ export default {
|
|||||||
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
|
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
|
||||||
},
|
},
|
||||||
totalDurationPretty() {
|
totalDurationPretty() {
|
||||||
// Adjusted by playback rate
|
return this.$secondsToTimestamp(this.totalDuration)
|
||||||
return this.$secondsToTimestamp(this.totalDuration / this.currentPlaybackRate)
|
|
||||||
},
|
},
|
||||||
podcastAuthor() {
|
podcastAuthor() {
|
||||||
if (!this.isPodcast) return null
|
if (!this.isPodcast) return null
|
||||||
return this.mediaMetadata.author || this.$strings.LabelUnknown
|
return this.mediaMetadata.author || 'Unknown'
|
||||||
},
|
},
|
||||||
hasNextItemInQueue() {
|
musicArtists() {
|
||||||
return this.currentPlayerQueueIndex < this.playerQueueItems.length - 1
|
if (!this.isMusic) return null
|
||||||
},
|
return this.mediaMetadata.artists.join(', ')
|
||||||
currentPlayerQueueIndex() {
|
|
||||||
if (!this.libraryItemId) return -1
|
|
||||||
return this.playerQueueItems.findIndex((i) => {
|
|
||||||
if (this.streamEpisode?.id) return i.episodeId === this.streamEpisode.id
|
|
||||||
return i.libraryItemId === this.libraryItemId
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
playerQueueItems() {
|
playerQueueItems() {
|
||||||
return this.$store.state.playerQueueItems || []
|
return this.$store.state.playerQueueItems || []
|
||||||
@@ -212,18 +199,14 @@ export default {
|
|||||||
this.$store.commit('setIsPlaying', isPlaying)
|
this.$store.commit('setIsPlaying', isPlaying)
|
||||||
this.updateMediaSessionPlaybackState()
|
this.updateMediaSessionPlaybackState()
|
||||||
},
|
},
|
||||||
setSleepTimer(time) {
|
setSleepTimer(seconds) {
|
||||||
this.sleepTimerSet = true
|
this.sleepTimerSet = true
|
||||||
|
this.sleepTimerTime = seconds
|
||||||
|
this.sleepTimerRemaining = seconds
|
||||||
|
this.runSleepTimer()
|
||||||
this.showSleepTimerModal = false
|
this.showSleepTimerModal = false
|
||||||
|
|
||||||
this.sleepTimerType = time.timerType
|
|
||||||
if (this.sleepTimerType === this.$constants.SleepTimerTypes.COUNTDOWN) {
|
|
||||||
this.runSleepTimer(time)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
runSleepTimer(time) {
|
runSleepTimer() {
|
||||||
this.sleepTimerRemaining = time.seconds
|
|
||||||
|
|
||||||
var lastTick = Date.now()
|
var lastTick = Date.now()
|
||||||
clearInterval(this.sleepTimer)
|
clearInterval(this.sleepTimer)
|
||||||
this.sleepTimer = setInterval(() => {
|
this.sleepTimer = setInterval(() => {
|
||||||
@@ -232,23 +215,12 @@ export default {
|
|||||||
this.sleepTimerRemaining -= elapsed / 1000
|
this.sleepTimerRemaining -= elapsed / 1000
|
||||||
|
|
||||||
if (this.sleepTimerRemaining <= 0) {
|
if (this.sleepTimerRemaining <= 0) {
|
||||||
this.sleepTimerEnd()
|
this.clearSleepTimer()
|
||||||
|
this.playerHandler.pause()
|
||||||
|
this.$toast.info('Sleep Timer Done.. zZzzZz')
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
},
|
},
|
||||||
checkChapterEnd(time) {
|
|
||||||
if (!this.currentChapter) return
|
|
||||||
const chapterEndTime = this.currentChapter.end
|
|
||||||
const tolerance = 0.75
|
|
||||||
if (time >= chapterEndTime - tolerance) {
|
|
||||||
this.sleepTimerEnd()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
sleepTimerEnd() {
|
|
||||||
this.clearSleepTimer()
|
|
||||||
this.playerHandler.pause()
|
|
||||||
this.$toast.info(this.$strings.ToastSleepTimerDone)
|
|
||||||
},
|
|
||||||
cancelSleepTimer() {
|
cancelSleepTimer() {
|
||||||
this.showSleepTimerModal = false
|
this.showSleepTimerModal = false
|
||||||
this.clearSleepTimer()
|
this.clearSleepTimer()
|
||||||
@@ -258,7 +230,6 @@ export default {
|
|||||||
this.sleepTimerRemaining = 0
|
this.sleepTimerRemaining = 0
|
||||||
this.sleepTimer = null
|
this.sleepTimer = null
|
||||||
this.sleepTimerSet = false
|
this.sleepTimerSet = false
|
||||||
this.sleepTimerType = null
|
|
||||||
},
|
},
|
||||||
incrementSleepTimer(amount) {
|
incrementSleepTimer(amount) {
|
||||||
if (!this.sleepTimerSet) return
|
if (!this.sleepTimerSet) return
|
||||||
@@ -284,25 +255,17 @@ export default {
|
|||||||
this.playerHandler.setVolume(volume)
|
this.playerHandler.setVolume(volume)
|
||||||
},
|
},
|
||||||
setPlaybackRate(playbackRate) {
|
setPlaybackRate(playbackRate) {
|
||||||
this.currentPlaybackRate = playbackRate
|
this.initialPlaybackRate = playbackRate
|
||||||
this.playerHandler.setPlaybackRate(playbackRate)
|
this.playerHandler.setPlaybackRate(playbackRate)
|
||||||
},
|
},
|
||||||
seek(time) {
|
seek(time) {
|
||||||
this.playerHandler.seek(time)
|
this.playerHandler.seek(time)
|
||||||
},
|
},
|
||||||
playbackTimeUpdate(time) {
|
|
||||||
// When updating progress from another session
|
|
||||||
this.playerHandler.seek(time, false)
|
|
||||||
},
|
|
||||||
setCurrentTime(time) {
|
setCurrentTime(time) {
|
||||||
this.currentTime = time
|
this.currentTime = time
|
||||||
if (this.$refs.audioPlayer) {
|
if (this.$refs.audioPlayer) {
|
||||||
this.$refs.audioPlayer.setCurrentTime(time)
|
this.$refs.audioPlayer.setCurrentTime(time)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.sleepTimerType === this.$constants.SleepTimerTypes.CHAPTER && this.sleepTimerSet) {
|
|
||||||
this.checkChapterEnd(time)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
setDuration(duration) {
|
setDuration(duration) {
|
||||||
this.totalDuration = duration
|
this.totalDuration = duration
|
||||||
@@ -374,28 +337,19 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API
|
|
||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
const chapterInfo = []
|
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png')
|
||||||
if (this.chapters.length) {
|
const artwork = [
|
||||||
this.chapters.forEach((chapter) => {
|
{
|
||||||
chapterInfo.push({
|
src: coverImageSrc
|
||||||
title: chapter.title,
|
}
|
||||||
startTime: chapter.start
|
]
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
title: this.title,
|
title: this.title,
|
||||||
artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown',
|
artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown',
|
||||||
album: this.mediaMetadata.seriesName || '',
|
album: this.mediaMetadata.seriesName || '',
|
||||||
artwork: [
|
artwork
|
||||||
{
|
|
||||||
src: this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true)
|
|
||||||
}
|
|
||||||
],
|
|
||||||
chapterInfo
|
|
||||||
})
|
})
|
||||||
console.log('Set media session metadata', navigator.mediaSession.metadata)
|
console.log('Set media session metadata', navigator.mediaSession.metadata)
|
||||||
|
|
||||||
@@ -405,8 +359,9 @@ export default {
|
|||||||
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
|
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
|
||||||
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
|
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
|
||||||
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
|
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
|
||||||
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
|
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionPreviousTrack)
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
|
const hasNextChapter = this.$refs.audioPlayer && this.$refs.audioPlayer.hasNextChapter
|
||||||
|
navigator.mediaSession.setActionHandler('nexttrack', hasNextChapter ? this.mediaSessionNextTrack : null)
|
||||||
} else {
|
} else {
|
||||||
console.warn('Media session not available')
|
console.warn('Media session not available')
|
||||||
}
|
}
|
||||||
@@ -415,7 +370,7 @@ export default {
|
|||||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === data.stream) {
|
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === data.stream) {
|
||||||
if (!data.numSegments) return
|
if (!data.numSegments) return
|
||||||
var chunks = data.chunks
|
var chunks = data.chunks
|
||||||
console.log(`[MediaPlayerContainer] Stream Progress ${data.percent}`)
|
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
|
||||||
if (this.$refs.audioPlayer) {
|
if (this.$refs.audioPlayer) {
|
||||||
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
||||||
} else {
|
} else {
|
||||||
@@ -429,20 +384,20 @@ export default {
|
|||||||
libraryItem: session.libraryItem,
|
libraryItem: session.libraryItem,
|
||||||
episodeId: session.episodeId
|
episodeId: session.episodeId
|
||||||
})
|
})
|
||||||
this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)
|
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate)
|
||||||
},
|
},
|
||||||
streamOpen(session) {
|
streamOpen(session) {
|
||||||
console.log(`[MediaPlayerContainer] Stream session open`, session)
|
console.log(`[StreamContainer] Stream session open`, session)
|
||||||
},
|
},
|
||||||
streamClosed(streamId) {
|
streamClosed(streamId) {
|
||||||
// Stream was closed from the server
|
// Stream was closed from the server
|
||||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
||||||
console.warn('[MediaPlayerContainer] Closing stream due to request from server')
|
console.warn('[StreamContainer] Closing stream due to request from server')
|
||||||
this.playerHandler.closePlayer()
|
this.playerHandler.closePlayer()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
streamReady() {
|
streamReady() {
|
||||||
console.log(`[MediaPlayerContainer] Stream Ready`)
|
console.log(`[StreamContainer] Stream Ready`)
|
||||||
if (this.$refs.audioPlayer) {
|
if (this.$refs.audioPlayer) {
|
||||||
this.$refs.audioPlayer.setStreamReady()
|
this.$refs.audioPlayer.setStreamReady()
|
||||||
} else {
|
} else {
|
||||||
@@ -452,7 +407,7 @@ export default {
|
|||||||
streamError(streamId) {
|
streamError(streamId) {
|
||||||
// Stream had critical error from the server
|
// Stream had critical error from the server
|
||||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
||||||
console.warn('[MediaPlayerContainer] Closing stream due to stream error from server')
|
console.warn('[StreamContainer] Closing stream due to stream error from server')
|
||||||
this.playerHandler.closePlayer()
|
this.playerHandler.closePlayer()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -468,30 +423,6 @@ export default {
|
|||||||
this.playerHandler.switchPlayer()
|
this.playerHandler.switchPlayer()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
playNextItemInQueue() {
|
|
||||||
if (this.hasNextItemInQueue) {
|
|
||||||
this.playQueueItem({ index: this.currentPlayerQueueIndex + 1 })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* @param {{ index: number }} payload
|
|
||||||
*/
|
|
||||||
playQueueItem(payload) {
|
|
||||||
if (payload?.index === undefined) {
|
|
||||||
console.error('playQueueItem: No index provided')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!this.playerQueueItems[payload.index]) {
|
|
||||||
console.error('playQueueItem: No item found at index', payload.index)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const item = this.playerQueueItems[payload.index]
|
|
||||||
this.playLibraryItem({
|
|
||||||
libraryItemId: item.libraryItemId,
|
|
||||||
episodeId: item.episodeId || null,
|
|
||||||
queueItems: this.playerQueueItems
|
|
||||||
})
|
|
||||||
},
|
|
||||||
async playLibraryItem(payload) {
|
async playLibraryItem(payload) {
|
||||||
const libraryItemId = payload.libraryItemId
|
const libraryItemId = payload.libraryItemId
|
||||||
const episodeId = payload.episodeId || null
|
const episodeId = payload.episodeId || null
|
||||||
@@ -516,43 +447,29 @@ 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()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.playerHandler.load(libraryItem, episodeId, true, this.currentPlaybackRate, payload.startTime)
|
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate, payload.startTime)
|
||||||
},
|
},
|
||||||
pauseItem() {
|
pauseItem() {
|
||||||
this.playerHandler.pause()
|
this.playerHandler.pause()
|
||||||
},
|
},
|
||||||
showFailedProgressSyncs() {
|
showFailedProgressSyncs() {
|
||||||
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
||||||
this.syncFailedToast = this.$toast(this.$strings.ToastProgressIsNotBeingSynced, { timeout: false, type: 'error' })
|
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
|
||||||
},
|
|
||||||
sessionClosedEvent(sessionId) {
|
|
||||||
if (this.playerHandler.currentSessionId === sessionId) {
|
|
||||||
console.log('sessionClosedEvent closing current session', sessionId)
|
|
||||||
this.playerHandler.resetPlayer() // Closes player without reporting to server
|
|
||||||
this.$store.commit('setMediaPlaying', null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
||||||
this.$eventBus.$on('playback-seek', this.seek)
|
this.$eventBus.$on('playback-seek', this.seek)
|
||||||
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
|
||||||
this.$eventBus.$on('play-queue-item', this.playQueueItem)
|
|
||||||
this.$eventBus.$on('play-item', this.playLibraryItem)
|
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$on('pause-item', this.pauseItem)
|
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
||||||
this.$eventBus.$off('playback-seek', this.seek)
|
this.$eventBus.$off('playback-seek', this.seek)
|
||||||
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
|
||||||
this.$eventBus.$off('play-queue-item', this.playQueueItem)
|
|
||||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$off('pause-item', this.pauseItem)
|
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||||
}
|
}
|
||||||
@@ -560,7 +477,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#mediaPlayerContainer {
|
#streamContainer {
|
||||||
box-shadow: 0px -6px 8px #1111113f;
|
box-shadow: 0px -6px 8px #1111113f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,108 +1,82 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
|
<nuxt-link :to="`/author/${author.id}`">
|
||||||
<nuxt-link :to="`/author/${author?.id}`">
|
<div @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<div cy-id="card" @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: cardHeight + '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 cy-id="textInline" v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1e bg-black bg-opacity-60 px-2e">
|
<div 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: 0.75 + 'em' }">{{ 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: 0.65 + 'em' }">{{ numBooks }} {{ $strings.LabelBooks }}</p>
|
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search icon btn -->
|
|
||||||
<div cy-id="match" v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2e cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="searchAuthor">
|
|
||||||
<ui-tooltip :text="$strings.ButtonQuickMatch" direction="bottom">
|
|
||||||
<span class="material-symbols" :style="{ fontSize: 1.125 + 'em' }">search</span>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
<div cy-id="edit" v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2e cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)">
|
|
||||||
<ui-tooltip :text="$strings.LabelEdit" direction="bottom">
|
|
||||||
<span class="material-symbols" :style="{ fontSize: 1.125 + 'em' }">edit</span>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading spinner -->
|
|
||||||
<div cy-id="spinner" v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
|
||||||
<widgets-loading-spinner size="" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div cy-id="nameBelow" v-show="nameBelow" class="w-full py-1e px-2e">
|
|
||||||
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: 0.75 + 'em' }">{{ name }}</p>
|
<!-- 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">
|
||||||
|
<ui-tooltip :text="$strings.ButtonQuickMatch" direction="bottom">
|
||||||
|
<span class="material-icons text-lg">search</span>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
<div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)">
|
||||||
|
<ui-tooltip :text="$strings.LabelEdit" direction="bottom">
|
||||||
|
<span class="material-icons text-lg">edit</span>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading spinner -->
|
||||||
|
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
||||||
|
<widgets-loading-spinner size="" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
<div v-show="nameBelow" class="w-full py-1 px-2">
|
||||||
</div>
|
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nuxt-link>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
authorMount: {
|
author: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
width: Number,
|
width: Number,
|
||||||
height: {
|
height: Number,
|
||||||
|
sizeMultiplier: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 192
|
default: 1
|
||||||
},
|
},
|
||||||
nameBelow: {
|
nameBelow: Boolean
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
searching: false,
|
searching: false,
|
||||||
isHovering: false,
|
isHovering: false
|
||||||
author: null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
cardWidth() {
|
|
||||||
return this.width || this.cardHeight * 0.8
|
|
||||||
},
|
|
||||||
cardHeight() {
|
|
||||||
return this.height * this.sizeMultiplier
|
|
||||||
},
|
|
||||||
coverHeight() {
|
|
||||||
return this.cardHeight
|
|
||||||
},
|
|
||||||
userToken() {
|
userToken() {
|
||||||
return this.store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
},
|
||||||
_author() {
|
_author() {
|
||||||
return this.author || {}
|
return this.author || {}
|
||||||
},
|
},
|
||||||
authorId() {
|
authorId() {
|
||||||
return this._author?.id || ''
|
return this._author.id
|
||||||
},
|
},
|
||||||
name() {
|
name() {
|
||||||
return this._author?.name || ''
|
return this._author.name || ''
|
||||||
},
|
},
|
||||||
asin() {
|
asin() {
|
||||||
return this._author?.asin || ''
|
return this._author.asin || ''
|
||||||
},
|
},
|
||||||
numBooks() {
|
numBooks() {
|
||||||
return this._author?.numBooks || 0
|
return this._author.numBooks || 0
|
||||||
},
|
|
||||||
store() {
|
|
||||||
return this.$store || this.$nuxt.$store
|
|
||||||
},
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
|
||||||
currentLibraryId() {
|
|
||||||
return this.store.state.libraries.currentLibraryId
|
|
||||||
},
|
|
||||||
libraryProvider() {
|
|
||||||
return this.store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
|
||||||
},
|
|
||||||
sizeMultiplier() {
|
|
||||||
return this.store.getters['user/getSizeMultiplier']
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -118,64 +92,29 @@ export default {
|
|||||||
if (this.asin) payload.asin = this.asin
|
if (this.asin) payload.asin = this.asin
|
||||||
else payload.q = this.name
|
else payload.q = this.name
|
||||||
|
|
||||||
payload.region = 'us'
|
|
||||||
if (this.libraryProvider.startsWith('audible.')) {
|
|
||||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
if (!response) {
|
if (!response) {
|
||||||
this.$toast.error(this.$getString('ToastAuthorNotFound', [this.name]))
|
this.$toast.error(`Author ${this.name} not found`)
|
||||||
} else if (response.updated) {
|
} else if (response.updated) {
|
||||||
if (response.author.imagePath) {
|
if (response.author.imagePath) this.$toast.success(`Author ${response.author.name} was updated`)
|
||||||
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
|
else this.$toast.success(`Author ${response.author.name} was updated (no image found)`)
|
||||||
} else {
|
|
||||||
this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
this.$toast.info(`No updates were made for Author ${response.author.name}`)
|
||||||
}
|
}
|
||||||
this.searching = false
|
this.searching = false
|
||||||
},
|
},
|
||||||
setSearching(isSearching) {
|
setSearching(isSearching) {
|
||||||
this.searching = isSearching
|
this.searching = isSearching
|
||||||
},
|
}
|
||||||
setEntity(author) {
|
|
||||||
this.removeListeners()
|
|
||||||
this.author = author
|
|
||||||
this.addListeners()
|
|
||||||
},
|
|
||||||
addListeners() {
|
|
||||||
if (this.author) {
|
|
||||||
this.$eventBus.$on(`searching-author-${this.authorId}`, this.setSearching)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
removeListeners() {
|
|
||||||
if (this.author) {
|
|
||||||
this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
destroy() {
|
|
||||||
// destroy the vue listeners, etc
|
|
||||||
this.$destroy()
|
|
||||||
|
|
||||||
// remove the element from the DOM
|
|
||||||
if (this.$el && this.$el.parentNode) {
|
|
||||||
this.$el.parentNode.removeChild(this.$el)
|
|
||||||
} else if (this.$el && this.$el.remove) {
|
|
||||||
this.$el.remove()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setSelectionMode(val) {}
|
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
if (this.authorMount) this.setEntity(this.authorMount)
|
this.$eventBus.$on(`searching-author-${this.authorId}`, this.setSearching)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.removeListeners()
|
this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
||||||
<p class="truncate text-sm">{{ name }}</p>
|
<p class="truncate text-sm">{{ name }}</p>
|
||||||
<p class="text-xs text-gray-400">{{ $getString('LabelXBooks', [numBooks]) }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -24,9 +23,6 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
name() {
|
name() {
|
||||||
return this.author.name
|
return this.author.name
|
||||||
},
|
|
||||||
numBooks() {
|
|
||||||
return this.author.numBooks
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
@@ -37,9 +33,9 @@ export default {
|
|||||||
<style>
|
<style>
|
||||||
.authorSearchCardContent {
|
.authorSearchCardContent {
|
||||||
width: calc(100% - 80px);
|
width: calc(100% - 80px);
|
||||||
height: 44px;
|
height: 40px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
254
client/components/cards/Book3d.vue
Normal file
254
client/components/cards/Book3d.vue
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="wrapper" class="relative pointer-events-none" :style="{ width: standardWidth * 0.8 * 1.1 * scale + 'px', height: standardHeight * 1.1 * scale + 'px', marginBottom: 20 + 'px', marginTop: 15 + 'px' }">
|
||||||
|
<div ref="card" class="wrap absolute origin-center transform duration-200" :style="{ transform: `scale(${scale * scaleMultiplier}) translateY(${hover2 ? '-40%' : '-50%'})` }">
|
||||||
|
<div class="perspective">
|
||||||
|
<div class="book-wrap transform duration-100 pointer-events-auto" :class="hover2 ? 'z-80' : 'rotate'" @mouseover="hover = true" @mouseout="hover = false">
|
||||||
|
<div class="book book-1 box-shadow-book3d" ref="front"></div>
|
||||||
|
<div class="title book-1 pointer-events-none" ref="left"></div>
|
||||||
|
<div class="bottom book-1 pointer-events-none" ref="bottom"></div>
|
||||||
|
<div class="book-back book-1 pointer-events-none">
|
||||||
|
<div class="text pointer-events-none">
|
||||||
|
<h3 class="mb-4">Book Back</h3>
|
||||||
|
<p>
|
||||||
|
<span>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Sunt earum doloremque aliquam culpa dolor nostrum consequatur quas dicta? Molestias repellendus minima pariatur libero vel, reiciendis optio magnam rerum, labore corporis.</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
src: String,
|
||||||
|
width: {
|
||||||
|
type: Number,
|
||||||
|
default: 200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
hover: false,
|
||||||
|
hover2: false,
|
||||||
|
standardWidth: 200,
|
||||||
|
standardHeight: 320,
|
||||||
|
isAttached: true,
|
||||||
|
pageX: 0,
|
||||||
|
pageY: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
src(newVal) {
|
||||||
|
this.setCover()
|
||||||
|
},
|
||||||
|
width(newVal) {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
hover(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.unattach()
|
||||||
|
} else {
|
||||||
|
this.attach()
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
this.hover2 = newVal
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
scaleMultiplier() {
|
||||||
|
return this.hover2 ? 1.25 : 1
|
||||||
|
},
|
||||||
|
scale() {
|
||||||
|
var scale = this.width / this.standardWidth
|
||||||
|
return scale
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
unattach() {
|
||||||
|
if (this.$refs.card && this.isAttached) {
|
||||||
|
var bookshelf = document.getElementById('bookshelf')
|
||||||
|
if (bookshelf) {
|
||||||
|
var pos = this.$refs.wrapper.getBoundingClientRect()
|
||||||
|
|
||||||
|
this.pageX = pos.x
|
||||||
|
this.pageY = pos.y
|
||||||
|
document.body.appendChild(this.$refs.card)
|
||||||
|
this.$refs.card.style.left = this.pageX + 'px'
|
||||||
|
this.$refs.card.style.top = this.pageY + 'px'
|
||||||
|
this.$refs.card.style.zIndex = 50
|
||||||
|
this.isAttached = false
|
||||||
|
} else if (bookshelf) {
|
||||||
|
console.log(this.pageX, this.pageY)
|
||||||
|
this.isAttached = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
attach() {
|
||||||
|
if (this.$refs.card && !this.isAttached) {
|
||||||
|
if (this.$refs.wrapper) {
|
||||||
|
this.isAttached = true
|
||||||
|
|
||||||
|
this.$refs.wrapper.appendChild(this.$refs.card)
|
||||||
|
this.$refs.card.style.left = '0px'
|
||||||
|
this.$refs.card.style.top = '0px'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Is attached already', this.isAttached)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
var standardWidth = this.standardWidth
|
||||||
|
document.documentElement.style.setProperty('--book-w', standardWidth + 'px')
|
||||||
|
document.documentElement.style.setProperty('--book-wx', standardWidth + 1 + 'px')
|
||||||
|
document.documentElement.style.setProperty('--book-h', standardWidth * 1.6 + 'px')
|
||||||
|
document.documentElement.style.setProperty('--book-d', 40 + 'px')
|
||||||
|
},
|
||||||
|
setElBg(el) {
|
||||||
|
el.style.backgroundImage = `url("${this.src}")`
|
||||||
|
el.style.backgroundSize = 'cover'
|
||||||
|
el.style.backgroundPosition = 'center center'
|
||||||
|
el.style.backgroundRepeat = 'no-repeat'
|
||||||
|
},
|
||||||
|
setCover() {
|
||||||
|
if (this.$refs.front) {
|
||||||
|
this.setElBg(this.$refs.front)
|
||||||
|
}
|
||||||
|
if (this.$refs.bottom) {
|
||||||
|
this.setElBg(this.$refs.bottom)
|
||||||
|
this.$refs.bottom.style.backgroundSize = '2000%'
|
||||||
|
this.$refs.bottom.style.filter = 'blur(1px)'
|
||||||
|
}
|
||||||
|
if (this.$refs.left) {
|
||||||
|
this.setElBg(this.$refs.left)
|
||||||
|
this.$refs.left.style.backgroundSize = '2000%'
|
||||||
|
this.$refs.left.style.filter = 'blur(1px)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.setCover()
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* :root {
|
||||||
|
--book-w: 200px;
|
||||||
|
--book-h: 320px;
|
||||||
|
--book-d: 30px;
|
||||||
|
--book-wx: 201px;
|
||||||
|
} */
|
||||||
|
/*
|
||||||
|
.wrap {
|
||||||
|
width: calc(1.1 * var(--book-w));
|
||||||
|
height: calc(1.1 * var(--book-h));
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.perspective {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
perspective: 600px;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-wrap {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: 'all ease-out 0.6s';
|
||||||
|
}
|
||||||
|
|
||||||
|
.book {
|
||||||
|
width: var(--book-w);
|
||||||
|
height: var(--book-h);
|
||||||
|
background: url(https://covers.openlibrary.org/b/id/8303020-L.jpg) no-repeat center center;
|
||||||
|
background-size: cover;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
content: '';
|
||||||
|
height: var(--book-h);
|
||||||
|
width: var(--book-d);
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: calc(var(--book-wx) * -1);
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: auto;
|
||||||
|
background: #444;
|
||||||
|
transform: rotateY(-80deg) translateX(-14px);
|
||||||
|
|
||||||
|
background: url(https://covers.openlibrary.org/b/id/8303020-L.jpg) no-repeat center center;
|
||||||
|
background-size: 5000%;
|
||||||
|
filter: blur(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom {
|
||||||
|
content: '';
|
||||||
|
height: var(--book-d);
|
||||||
|
width: var(--book-w);
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: var(--book-h);
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
margin: auto;
|
||||||
|
background: #444;
|
||||||
|
transform: rotateY(0deg) rotateX(90deg) translateY(-15px) translateX(-2.5px) skewX(10deg);
|
||||||
|
|
||||||
|
background: url(https://covers.openlibrary.org/b/id/8303020-L.jpg) no-repeat center center;
|
||||||
|
background-size: 5000%;
|
||||||
|
filter: blur(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-back {
|
||||||
|
width: var(--book-w);
|
||||||
|
height: var(--book-h);
|
||||||
|
background-color: #444;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
transform: rotate(180deg) translateZ(-30px) translateX(5px);
|
||||||
|
}
|
||||||
|
.book-back .text {
|
||||||
|
transform: rotateX(180deg);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.book-back .text h3 {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.book-back .text span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-wrap.rotate {
|
||||||
|
transform: rotateY(30deg) rotateX(0deg);
|
||||||
|
}
|
||||||
|
.book-wrap.flip {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
} */
|
||||||
|
</style>
|
||||||
@@ -13,10 +13,10 @@
|
|||||||
<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">{{ $getString('LabelByAuthor', [book.author]) }}</p>
|
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">by {{ book.author }}</p>
|
||||||
<p v-if="book.narrator" class="text-gray-400 text-xs">{{ $strings.LabelNarrators }}: {{ book.narrator }}</p>
|
<p v-if="book.narrator" class="text-gray-400 text-xs">Narrated by {{ book.narrator }}</p>
|
||||||
<p v-if="book.duration" class="text-gray-400 text-xs">{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</p>
|
<p v-if="book.duration" class="text-gray-400 text-xs">Runtime: {{ $elapsedPrettyExtended(book.duration * 60) }}</p>
|
||||||
<div v-if="book.series?.length" class="flex py-1 -mx-1">
|
<div v-if="book.series && 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">
|
||||||
{{ series.series }}<span v-if="series.sequence"> #{{ series.sequence }}</span>
|
{{ series.series }}<span v-if="series.sequence"> #{{ series.sequence }}</span>
|
||||||
@@ -24,14 +24,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full max-h-12 overflow-hidden">
|
<div class="w-full max-h-12 overflow-hidden">
|
||||||
<p class="text-gray-500 text-xs">{{ book.descriptionPlain }}</p>
|
<p class="text-gray-500 text-xs">{{ book.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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 v-if="book.explicit" /></div>
|
<div class="flex items-center">
|
||||||
|
{{ book.title }}<widgets-explicit-indicator :explicit="book.explicit" />
|
||||||
|
</div>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-base text-gray-300 whitespace-nowrap truncate">{{ $getString('LabelByAuthor', [book.author]) }}</p>
|
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ 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>
|
||||||
@@ -54,8 +56,7 @@ export default {
|
|||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
isPodcast: Boolean,
|
isPodcast: Boolean,
|
||||||
bookCoverAspectRatio: Number,
|
bookCoverAspectRatio: Number
|
||||||
currentBookDuration: Number
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -64,27 +65,12 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
bookCovers() {
|
bookCovers() {
|
||||||
return this.book.covers || []
|
return this.book.covers ? this.book.covers || [] : []
|
||||||
},
|
|
||||||
bookDuration() {
|
|
||||||
return (this.book.duration || 0) * 60
|
|
||||||
},
|
|
||||||
bookDurationComparison() {
|
|
||||||
if (!this.book.duration || !this.currentBookDuration) return ''
|
|
||||||
const currentBookDurationMinutes = Math.floor(this.currentBookDuration / 60)
|
|
||||||
let differenceInMinutes = currentBookDurationMinutes - this.book.duration
|
|
||||||
if (differenceInMinutes < 0) {
|
|
||||||
differenceInMinutes = Math.abs(differenceInMinutes)
|
|
||||||
return this.$getString('LabelDurationComparisonLonger', [this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)])
|
|
||||||
} else if (differenceInMinutes > 0) {
|
|
||||||
return this.$getString('LabelDurationComparisonShorter', [this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)])
|
|
||||||
}
|
|
||||||
return this.$strings.LabelDurationComparisonExactMatch
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
selectMatch() {
|
selectMatch() {
|
||||||
const book = { ...this.book }
|
var book = { ...this.book }
|
||||||
book.cover = this.selectedCover
|
book.cover = this.selectedCover
|
||||||
this.$emit('select', book)
|
this.$emit('select', book)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex h-full px-1 overflow-hidden">
|
|
||||||
<div class="w-10 h-10 flex items-center justify-center">
|
|
||||||
<span class="material-symbols text-2xl text-gray-200">category</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
|
||||||
<p class="truncate text-sm">{{ genre }}</p>
|
|
||||||
<p class="text-xs text-gray-400">{{ $getString('LabelXItems', [numItems]) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
genre: String,
|
|
||||||
numItems: Number
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {},
|
|
||||||
methods: {},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.tagSearchCardContent {
|
|
||||||
width: calc(100% - 40px);
|
|
||||||
height: 44px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="rounded-sm h-full relative" :style="{ width: cardWidth + 'px', height: cardHeight + 'px' }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
<div class="rounded-sm h-full relative" :style="{ width: width + 'px', height: height + 'px' }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
||||||
<nuxt-link :to="groupTo" class="cursor-pointer">
|
<nuxt-link :to="groupTo" class="cursor-pointer">
|
||||||
<div class="w-full h-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'">
|
<div class="w-full h-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'">
|
||||||
<covers-group-cover ref="groupcover" :id="groupEncode" :name="groupName" :type="groupType" :book-items="bookItems" :width="cardWidth" :height="cardHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-group-cover ref="groupcover" :id="groupEncode" :name="groupName" :type="groupType" :book-items="bookItems" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
|
||||||
<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 z-30" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
<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 z-30" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||||
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ groupName }}</p>
|
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ groupName }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute z-10 top-1.5e right-1.5e rounded-md leading-3e p-1e font-semibold text-white flex items-center justify-center" :style="{ fontSize: 0.8 + 'em' }" style="background-color: #cd9d49dd">{{ bookItems.length }}</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">{{ bookItems.length }}</div>
|
||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,10 +24,8 @@ export default {
|
|||||||
default: () => null
|
default: () => null
|
||||||
},
|
},
|
||||||
width: Number,
|
width: Number,
|
||||||
height: {
|
height: Number,
|
||||||
type: Number,
|
bookCoverAspectRatio: Number
|
||||||
default: 192
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -35,15 +33,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
bookCoverAspectRatio() {
|
|
||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
|
||||||
},
|
|
||||||
cardWidth() {
|
|
||||||
return this.width || this.cardHeight * 2
|
|
||||||
},
|
|
||||||
cardHeight() {
|
|
||||||
return this.height * this.sizeMultiplier
|
|
||||||
},
|
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
@@ -57,7 +46,8 @@ export default {
|
|||||||
return `/library/${this.currentLibraryId}/bookshelf?filter=${this.filter}`
|
return `/library/${this.currentLibraryId}/bookshelf?filter=${this.filter}`
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
return this.$store.getters['user/getSizeMultiplier']
|
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||||
|
return this.width / 240
|
||||||
},
|
},
|
||||||
bookItems() {
|
bookItems() {
|
||||||
return this._group.books || []
|
return this._group.books || []
|
||||||
@@ -88,4 +78,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -2,9 +2,15 @@
|
|||||||
<div class="flex items-center h-full px-1 overflow-hidden">
|
<div class="flex items-center h-full px-1 overflow-hidden">
|
||||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<div class="flex-grow px-2 audiobookSearchCardContent">
|
<div class="flex-grow px-2 audiobookSearchCardContent">
|
||||||
<p class="truncate text-sm">{{ title }}</p>
|
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
||||||
<p v-if="subtitle" class="truncate text-xs text-gray-300">{{ subtitle }}</p>
|
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
||||||
<p class="text-xs text-gray-200 truncate">{{ $getString('LabelByAuthor', [authorName]) }}</p>
|
|
||||||
|
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
|
||||||
|
|
||||||
|
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
|
||||||
|
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
||||||
|
|
||||||
|
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -15,7 +21,10 @@ export default {
|
|||||||
libraryItem: {
|
libraryItem: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
}
|
},
|
||||||
|
search: String,
|
||||||
|
matchKey: String,
|
||||||
|
matchText: String
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
@@ -49,6 +58,22 @@ export default {
|
|||||||
authorName() {
|
authorName() {
|
||||||
if (this.isPodcast) return this.mediaMetadata.author || 'Unknown'
|
if (this.isPodcast) return this.mediaMetadata.author || 'Unknown'
|
||||||
return this.mediaMetadata.authorName || 'Unknown'
|
return this.mediaMetadata.authorName || 'Unknown'
|
||||||
|
},
|
||||||
|
matchHtml() {
|
||||||
|
if (!this.matchText || !this.search) return ''
|
||||||
|
if (this.matchKey === 'subtitle') return ''
|
||||||
|
|
||||||
|
// This used to highlight the part of the search found
|
||||||
|
// but with removing commas periods etc this is no longer plausible
|
||||||
|
const html = this.matchText
|
||||||
|
|
||||||
|
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
|
||||||
|
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||||
|
if (this.matchKey === 'authors') return `by ${html}`
|
||||||
|
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
||||||
|
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||||
|
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
||||||
|
return `${html}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
@@ -64,4 +89,4 @@ export default {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center px-1 overflow-hidden">
|
<div class="flex items-center h-full px-1 overflow-hidden">
|
||||||
<div class="w-8 flex items-center justify-center">
|
<div class="h-5 w-5 min-w-5 text-lg mr-1.5 flex items-center justify-center">
|
||||||
<span v-if="isFinished" :class="taskIconStatus" class="material-symbols text-base">{{ actionIcon }}</span>
|
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{actionIcon}}</span>
|
||||||
<widgets-loading-spinner v-else />
|
<widgets-loading-spinner v-else />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-2 taskRunningCardContent">
|
<div class="flex-grow px-2 taskRunningCardContent">
|
||||||
<p class="truncate text-sm">{{ title }}</p>
|
<p class="truncate text-sm">{{ title }}</p>
|
||||||
|
|
||||||
<p class="truncate text-xs text-gray-300">{{ description }}</p>
|
<p class="truncate text-xs text-gray-300">{{ description }}</p>
|
||||||
<p v-if="specialMessage" class="truncate text-xs text-gray-300">{{ specialMessage }}</p>
|
|
||||||
|
|
||||||
<p v-if="isFailed && failedMessage" class="text-xs truncate text-red-500">{{ failedMessage }}</p>
|
<p v-if="isFailed && failedMessage" class="text-xs truncate text-red-500">{{ failedMessage }}</p>
|
||||||
<p v-else-if="!isFinished && cancelingScan" class="text-xs truncate">Canceling...</p>
|
|
||||||
</div>
|
</div>
|
||||||
<ui-btn v-if="userIsAdminOrUp && !isFinished && isLibraryScan && !cancelingScan" color="primary" :padding-y="1" :padding-x="1" class="text-xs w-16 max-w-16 truncate mr-1" @click.stop="cancelScan">{{ this.$strings.ButtonCancel }}</ui-btn>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -26,59 +23,31 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {}
|
||||||
cancelingScan: false,
|
|
||||||
specialMessage: ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
task: {
|
|
||||||
immediate: true,
|
|
||||||
handler() {
|
|
||||||
this.initTask()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userIsAdminOrUp() {
|
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
|
||||||
},
|
|
||||||
title() {
|
title() {
|
||||||
if (this.task.titleKey && this.$strings[this.task.titleKey]) {
|
|
||||||
return this.$getString(this.task.titleKey, this.task.titleSubs)
|
|
||||||
}
|
|
||||||
return this.task.title || 'No Title'
|
return this.task.title || 'No Title'
|
||||||
},
|
},
|
||||||
description() {
|
description() {
|
||||||
if (this.task.descriptionKey && this.$strings[this.task.descriptionKey]) {
|
|
||||||
return this.$getString(this.task.descriptionKey, this.task.descriptionSubs)
|
|
||||||
}
|
|
||||||
return this.task.description || ''
|
return this.task.description || ''
|
||||||
},
|
},
|
||||||
|
details() {
|
||||||
|
return this.task.details || 'Unknown'
|
||||||
|
},
|
||||||
isFinished() {
|
isFinished() {
|
||||||
return !!this.task.isFinished
|
return this.task.isFinished || false
|
||||||
},
|
},
|
||||||
isFailed() {
|
isFailed() {
|
||||||
return !!this.task.isFailed
|
return this.task.isFailed || false
|
||||||
},
|
|
||||||
isSuccess() {
|
|
||||||
return this.isFinished && !this.isFailed
|
|
||||||
},
|
},
|
||||||
failedMessage() {
|
failedMessage() {
|
||||||
if (this.task.errorKey && this.$strings[this.task.errorKey]) {
|
|
||||||
return this.$getString(this.task.errorKey, this.task.errorSubs)
|
|
||||||
}
|
|
||||||
return this.task.error || ''
|
return this.task.error || ''
|
||||||
},
|
},
|
||||||
action() {
|
action() {
|
||||||
return this.task.action || ''
|
return this.task.action || ''
|
||||||
},
|
},
|
||||||
actionIcon() {
|
actionIcon() {
|
||||||
if (this.isFailed) {
|
|
||||||
return 'error'
|
|
||||||
} else if (this.isSuccess) {
|
|
||||||
return 'done'
|
|
||||||
}
|
|
||||||
switch (this.action) {
|
switch (this.action) {
|
||||||
case 'download-podcast-episode':
|
case 'download-podcast-episode':
|
||||||
return 'cloud_download'
|
return 'cloud_download'
|
||||||
@@ -97,36 +66,9 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
},
|
|
||||||
isLibraryScan() {
|
|
||||||
return this.action === 'library-scan' || this.action === 'library-match-all'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
initTask() {
|
|
||||||
// special message for library scan tasks
|
|
||||||
if (this.task?.data?.scanResults) {
|
|
||||||
const scanResults = this.task.data.scanResults
|
|
||||||
const strs = []
|
|
||||||
if (scanResults.added) strs.push(this.$getString('MessageTaskScanItemsAdded', [scanResults.added]))
|
|
||||||
if (scanResults.updated) strs.push(this.$getString('MessageTaskScanItemsUpdated', [scanResults.updated]))
|
|
||||||
if (scanResults.missing) strs.push(this.$getString('MessageTaskScanItemsMissing', [scanResults.missing]))
|
|
||||||
const changesDetected = strs.length > 0 ? strs.join(', ') : this.$strings.MessageTaskScanNoChangesNeeded
|
|
||||||
const timeElapsed = scanResults.elapsed ? ` (${this.$elapsedPretty(scanResults.elapsed / 1000, false, true)})` : ''
|
|
||||||
this.specialMessage = `${changesDetected}${timeElapsed}`
|
|
||||||
} else {
|
|
||||||
this.specialMessage = ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
cancelScan() {
|
|
||||||
const libraryId = this.task?.data?.libraryId
|
|
||||||
if (!libraryId) {
|
|
||||||
console.error('No library id in library-scan task', this.task)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.cancelingScan = true
|
|
||||||
this.$root.socket.emit('cancel_scan', libraryId)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
@@ -134,8 +76,8 @@ export default {
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.taskRunningCardContent {
|
.taskRunningCardContent {
|
||||||
width: calc(100% - 84px);
|
width: calc(100% - 80px);
|
||||||
height: 60px;
|
height: 75px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!processing && !uploadFailed && !uploadSuccess" class="absolute -top-3 -right-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-error cursor-pointer" @click="$emit('remove')">
|
<div v-if="!processing && !uploadFailed && !uploadSuccess" class="absolute -top-3 -right-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-error cursor-pointer" @click="$emit('remove')">
|
||||||
<span class="text-base text-white text-opacity-80 font-mono material-symbols">close</span>
|
<span class="text-base text-white text-opacity-80 font-mono material-icons">close</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="!uploadSuccess && !uploadFailed">
|
<template v-if="!uploadSuccess && !uploadFailed">
|
||||||
@@ -15,37 +15,24 @@
|
|||||||
|
|
||||||
<div class="flex my-2 -mx-2">
|
<div class="flex my-2 -mx-2">
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-model.trim="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" />
|
<ui-text-input-with-label v-model="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<div v-if="!isPodcast" class="flex items-end">
|
<ui-text-input-with-label v-if="!isPodcast" v-model="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">
|
|
||||||
<div class="ml-2 mb-1 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer" @click="fetchMetadata">
|
|
||||||
<span class="text-base text-white text-opacity-80 font-mono material-symbols">sync</span>
|
|
||||||
</div>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
<div v-else class="w-full">
|
<div v-else class="w-full">
|
||||||
<p class="px-1 text-sm font-semibold">
|
<p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p>
|
||||||
{{ $strings.LabelDirectory }}
|
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
|
||||||
<em class="font-normal text-xs pl-2">(auto)</em>
|
|
||||||
</p>
|
|
||||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isPodcast" class="flex my-2 -mx-2">
|
<div v-if="!isPodcast" class="flex my-2 -mx-2">
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-model.trim="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" inputClass="h-10" />
|
<ui-text-input-with-label v-model="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" />
|
||||||
</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">
|
<p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p>
|
||||||
{{ $strings.LabelDirectory }}
|
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
|
||||||
<em class="font-normal text-xs pl-2">(auto)</em>
|
|
||||||
</label>
|
|
||||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs h-10" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,14 +42,14 @@
|
|||||||
<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">"{{ itemData.title }}" {{ $strings.MessageUploaderItemSuccess }}</p>
|
<p class="text-base">{{ $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">"{{ itemData.title }}" {{ $strings.MessageUploaderItemFailed }}</p>
|
<p class="text-base">{{ $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="isUploading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
|
||||||
<ui-loading-indicator :text="nonInteractionLabel" />
|
<ui-loading-indicator :text="$strings.MessageUploading" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -77,8 +64,7 @@ export default {
|
|||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
mediaType: String,
|
mediaType: String,
|
||||||
processing: Boolean,
|
processing: Boolean
|
||||||
provider: String
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -90,8 +76,7 @@ export default {
|
|||||||
error: '',
|
error: '',
|
||||||
isUploading: false,
|
isUploading: false,
|
||||||
uploadFailed: false,
|
uploadFailed: false,
|
||||||
uploadSuccess: false,
|
uploadSuccess: false
|
||||||
isFetchingMetadata: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -102,19 +87,12 @@ export default {
|
|||||||
if (!this.itemData.title) return ''
|
if (!this.itemData.title) return ''
|
||||||
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]
|
if (this.itemData.series && this.itemData.author) {
|
||||||
const cleanedOutputPathParts = outputPathParts.filter(Boolean).map((part) => this.$sanitizeFilename(part))
|
return Path.join(this.itemData.author, this.itemData.series, this.itemData.title)
|
||||||
|
} else if (this.itemData.author) {
|
||||||
return Path.join(...cleanedOutputPathParts)
|
return Path.join(this.itemData.author, this.itemData.title)
|
||||||
},
|
} else {
|
||||||
isNonInteractable() {
|
return this.itemData.title
|
||||||
return this.isUploading || this.isFetchingMetadata
|
|
||||||
},
|
|
||||||
nonInteractionLabel() {
|
|
||||||
if (this.isUploading) {
|
|
||||||
return this.$strings.MessageUploading
|
|
||||||
} else if (this.isFetchingMetadata) {
|
|
||||||
return this.$strings.LabelFetchingMetadata
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -127,49 +105,15 @@ export default {
|
|||||||
titleUpdated() {
|
titleUpdated() {
|
||||||
this.error = ''
|
this.error = ''
|
||||||
},
|
},
|
||||||
async fetchMetadata() {
|
|
||||||
if (!this.itemData.title.trim().length) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isFetchingMetadata = true
|
|
||||||
this.error = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const searchQueryString = new URLSearchParams({
|
|
||||||
title: this.itemData.title,
|
|
||||||
author: this.itemData.author,
|
|
||||||
provider: this.provider
|
|
||||||
})
|
|
||||||
const [bestCandidate, ..._rest] = await this.$axios.$get(`/api/search/books?${searchQueryString}`)
|
|
||||||
|
|
||||||
if (bestCandidate) {
|
|
||||||
this.itemData = {
|
|
||||||
...this.itemData,
|
|
||||||
title: bestCandidate.title,
|
|
||||||
author: bestCandidate.author,
|
|
||||||
series: (bestCandidate.series || [])[0]?.series
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.error = this.$strings.ErrorUploadFetchMetadataNoResults
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed', e)
|
|
||||||
this.error = this.$strings.ErrorUploadFetchMetadataAPI
|
|
||||||
} finally {
|
|
||||||
this.isFetchingMetadata = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getData() {
|
getData() {
|
||||||
if (!this.itemData.title) {
|
if (!this.itemData.title) {
|
||||||
this.error = this.$strings.ErrorUploadLacksTitle
|
this.error = 'Must have a title'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
this.error = ''
|
this.error = ''
|
||||||
var files = this.item.itemFiles.concat(this.item.otherFiles)
|
var files = this.item.itemFiles.concat(this.item.otherFiles)
|
||||||
return {
|
return {
|
||||||
index: this.item.index,
|
index: this.item.index,
|
||||||
directory: this.directory,
|
|
||||||
...this.itemData,
|
...this.itemData,
|
||||||
files
|
files
|
||||||
}
|
}
|
||||||
@@ -183,4 +127,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,23 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="card" :id="`album-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
<div ref="card" :id="`album-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||||
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
<covers-preview-cover ref="cover" :src="coverSrc" :width="width" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<covers-preview-cover ref="cover" :src="coverSrc" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative w-full">
|
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
|
||||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em ${0.5}em` }">
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8e h-8e py-1e rounded-md text-center">
|
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
|
||||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ artist || ' ' }}</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">
|
||||||
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
|
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ artist || ' ' }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -26,10 +22,8 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
index: Number,
|
index: Number,
|
||||||
width: Number,
|
width: Number,
|
||||||
height: {
|
height: Number,
|
||||||
type: Number,
|
bookCoverAspectRatio: Number,
|
||||||
default: 192
|
|
||||||
},
|
|
||||||
bookshelfView: {
|
bookshelfView: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
@@ -48,29 +42,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
bookCoverAspectRatio() {
|
|
||||||
return this.store.getters['libraries/getBookCoverAspectRatio']
|
|
||||||
},
|
|
||||||
cardWidth() {
|
|
||||||
return this.width || this.coverHeight
|
|
||||||
},
|
|
||||||
coverHeight() {
|
|
||||||
return this.height * this.sizeMultiplier
|
|
||||||
},
|
|
||||||
/*
|
|
||||||
cardHeight() {
|
|
||||||
return this.coverHeight + this.bottomTextHeight
|
|
||||||
},
|
|
||||||
bottomTextHeight() {
|
|
||||||
if (!this.isAlternativeBookshelfView) return 0
|
|
||||||
const lineHeight = 1.5
|
|
||||||
const remSize = 16
|
|
||||||
const baseHeight = this.sizeMultiplier * lineHeight * remSize
|
|
||||||
const titleHeight = this.labelFontSize * baseHeight
|
|
||||||
const paddingHeight = 4 * 2 * this.sizeMultiplier // py-1
|
|
||||||
return titleHeight + paddingHeight
|
|
||||||
},
|
|
||||||
*/
|
|
||||||
coverSrc() {
|
coverSrc() {
|
||||||
const config = this.$config || this.$nuxt.$config
|
const config = this.$config || this.$nuxt.$config
|
||||||
if (!this.album || !this.album.libraryItemId) return `${config.routerBasePath}/book_placeholder.jpg`
|
if (!this.album || !this.album.libraryItemId) return `${config.routerBasePath}/book_placeholder.jpg`
|
||||||
@@ -78,10 +49,11 @@ export default {
|
|||||||
},
|
},
|
||||||
labelFontSize() {
|
labelFontSize() {
|
||||||
if (this.width < 160) return 0.75
|
if (this.width < 160) return 0.75
|
||||||
return 0.9
|
return 0.875
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
return this.store.getters['user/getSizeMultiplier']
|
const baseSize = this.bookCoverAspectRatio === 1 ? 192 : 120
|
||||||
|
return this.width / baseSize
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.album ? this.album.title : ''
|
return this.album ? this.album.title : ''
|
||||||
@@ -139,4 +111,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,135 +1,119 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="card" :id="`book-card-${index}`" tabindex="0" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
<div 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 :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded overflow-hidden z-10 bg-primary box-shadow-book" :style="{ height: coverHeight + 'px ' }">
|
<!-- When cover image does not fill -->
|
||||||
<!-- When cover image does not fill -->
|
<div 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 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 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #78350f">
|
|
||||||
<p :style="{ fontSize: 0.8 + 'em' }">#{{ seriesSequenceList }}</p>
|
|
||||||
</div>
|
|
||||||
<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 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
|
|
||||||
<p :style="{ fontSize: 0.8 + 'em' }">{{ booksInSeries }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
|
||||||
<div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" aria-hidden="true" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }">
|
|
||||||
<p :style="{ fontSize: 0.8 + 'em' }" class="text-gray-300 text-center">{{ title }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cover Image -->
|
|
||||||
<img cy-id="coverImage" v-if="libraryItem" :alt="`${displayTitle}, ${$strings.LabelCover}`" ref="cover" aria-hidden="true" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
|
||||||
|
|
||||||
<!-- Placeholder Cover Title & Author -->
|
|
||||||
<div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }">
|
|
||||||
<div>
|
|
||||||
<p cy-id="placeholderTitleText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }">
|
|
||||||
<p cy-id="placeholderAuthorText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- No progress shown for podcasts (unless showing podcast episode) -->
|
|
||||||
<div cy-id="progressBar" v-if="!isPodcast || episodeProgress" class="absolute bottom-0 left-0 h-1e max-w-full z-20 rounded-b box-shadow-progressbar" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * userProgressPercent + 'px' }"></div>
|
|
||||||
|
|
||||||
<!-- Overlay is not shown if collapsing series in library -->
|
|
||||||
<div cy-id="overlay" v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen) && !processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded md:block" :class="overlayWrapperClasslist">
|
|
||||||
<div cy-id="playButton" v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
|
|
||||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
|
|
||||||
<span class="material-symbols fill" :style="{ fontSize: playIconFontSize + 'em' }">play_arrow</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div cy-id="readButton" v-show="showReadButton" class="h-full flex items-center justify-center pointer-events-none">
|
|
||||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="clickReadEBook">
|
|
||||||
<span class="material-symbols" :style="{ fontSize: playIconFontSize + 'em' }">auto_stories</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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 + 'em' }" @click.stop.prevent="editClick">
|
|
||||||
<span class="material-symbols" :style="{ fontSize: 1 + 'em' }">edit</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Radio button -->
|
|
||||||
<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 + 'em', left: 0.375 + 'em' }" @click.stop.prevent="selectBtnClick">
|
|
||||||
<span class="material-symbols" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 + 'em' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- More Menu Icon -->
|
|
||||||
<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 + 'em', right: 0.375 + 'em' }" @click.stop.prevent="clickShowMore">
|
|
||||||
<span class="material-symbols" :style="{ fontSize: 1.2 + 'em' }">more_vert</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div cy-id="ebookFormat" v-if="ebookFormat" class="absolute" :style="{ bottom: 0.375 + 'em', left: 0.375 + 'em' }">
|
|
||||||
<span class="text-white/80" :style="{ fontSize: 0.8 + 'em' }">{{ ebookFormat }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Processing/loading spinner overlay -->
|
|
||||||
<div cy-id="loadingSpinner" v-if="processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 rounded flex items-center justify-center">
|
|
||||||
<widgets-loading-spinner size="la-lg" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Series name overlay -->
|
|
||||||
<div 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: 1 + 'em' }">
|
|
||||||
<p v-if="seriesName" class="text-gray-200 text-center" :style="{ fontSize: 1.1 + 'em' }">{{ seriesName }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error widget -->
|
|
||||||
<ui-tooltip cy-id="ErrorTooltip" v-if="showError" :text="errorText" class="absolute bottom-4e left-0 z-10">
|
|
||||||
<div :style="{ height: 1.5 + 'em', width: 2.5 + 'em' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
|
||||||
<span class="material-symbols text-red-100 pr-1e" :style="{ fontSize: 0.875 + 'em' }">priority_high</span>
|
|
||||||
</div>
|
|
||||||
</ui-tooltip>
|
|
||||||
|
|
||||||
<!-- rss feed icon -->
|
|
||||||
<div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 + 'em' }">
|
|
||||||
<span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span>
|
|
||||||
</div>
|
|
||||||
<!-- media item shared icon -->
|
|
||||||
<div cy-id="mediaItemShare" v-if="mediaItemShare && !isSelectionMode && !isHovering" class="absolute text-success left-0 z-10" :style="{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }">
|
|
||||||
<span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">public</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Series sequence -->
|
|
||||||
<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 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }">
|
|
||||||
<p :style="{ fontSize: 0.8 + 'em' }">#{{ seriesSequence }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Podcast Episode # -->
|
|
||||||
<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 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }">
|
|
||||||
<p :style="{ fontSize: 0.8 + 'em' }">
|
|
||||||
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Podcast Num Episodes -->
|
|
||||||
<div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }">
|
|
||||||
<p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfEpisodes">{{ numEpisodes }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Podcast Num Episodes -->
|
|
||||||
<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 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }">
|
|
||||||
<p :style="{ fontSize: 0.8 + 'em' }">{{ numEpisodesIncomplete }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alternative bookshelf title/author/sort -->
|
<!-- Alternative bookshelf title/author/sort -->
|
||||||
<div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full">
|
<div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||||
<div :style="{ fontSize: 0.9 + 'em' }">
|
<div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||||
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
<div class="flex items-center">
|
||||||
<p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p>
|
<span class="truncate">{{ displayTitle }}</span>
|
||||||
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
|
<widgets-explicit-indicator :explicit="isExplicit" />
|
||||||
</ui-tooltip>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ui-tooltip v-if="showSubtitles" :text="displaySubtitle" :disabled="!displaySubtitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
||||||
<p cy-id="subtitle" class="truncate" ref="displaySubtitle" :style="{ fontSize: 0.6 + 'em' }">{{ displaySubtitle }}</p>
|
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||||
</ui-tooltip>
|
</div>
|
||||||
<p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || ' ' }}</p>
|
|
||||||
<p cy-id="line3" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
|
<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">
|
||||||
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequenceList }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="booksInSeries" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #cd9d49dd">
|
||||||
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ booksInSeries }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
||||||
|
<div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
|
||||||
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="text-gray-300 text-center">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cover Image -->
|
||||||
|
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
<p class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">
|
||||||
|
{{ titleCleaned }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
|
||||||
|
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No progress shown for collapsed series in library and podcasts (unless showing podcast episode) -->
|
||||||
|
<div v-if="!booksInSeries && (!isPodcast || episodeProgress)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||||
|
<!-- Finished progress bar for collapsed series -->
|
||||||
|
<div v-else-if="booksInSeries && seriesIsFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||||
|
|
||||||
|
<!-- Overlay is not shown if collapsing series in library -->
|
||||||
|
<div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen) && !processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
|
||||||
|
<div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
|
||||||
|
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
|
||||||
|
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="showReadButton" class="h-full flex items-center justify-center pointer-events-none">
|
||||||
|
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="clickReadEBook">
|
||||||
|
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">auto_stories</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-150 top-0 right-0" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
||||||
|
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick">
|
||||||
|
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- More Menu Icon -->
|
||||||
|
<div ref="moreIcon" v-show="!isSelectionMode && moreMenuItems.length" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
||||||
|
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Processing/loading spinner overlay -->
|
||||||
|
<div v-if="processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 rounded flex items-center justify-center">
|
||||||
|
<widgets-loading-spinner size="la-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Series name overlay -->
|
||||||
|
<div v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
|
||||||
|
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error widget -->
|
||||||
|
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0 z-10">
|
||||||
|
<div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
||||||
|
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
||||||
|
</div>
|
||||||
|
</ui-tooltip>
|
||||||
|
|
||||||
|
<div v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }">
|
||||||
|
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.5 + 'rem' }">rss_feed</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Series sequence -->
|
||||||
|
<div v-if="seriesSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||||
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Podcast Episode # -->
|
||||||
|
<div v-if="recentEpisodeNumber !== null && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||||
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
|
||||||
|
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Podcast Num Episodes -->
|
||||||
|
<div v-else-if="numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||||
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -141,11 +125,15 @@ import MoreMenu from '@/components/widgets/MoreMenu'
|
|||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
index: Number,
|
index: Number,
|
||||||
width: Number,
|
width: {
|
||||||
|
type: Number,
|
||||||
|
default: 120
|
||||||
|
},
|
||||||
height: {
|
height: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 192
|
default: 192
|
||||||
},
|
},
|
||||||
|
bookCoverAspectRatio: Number,
|
||||||
bookshelfView: Number,
|
bookshelfView: Number,
|
||||||
bookMount: {
|
bookMount: {
|
||||||
// Book can be passed as prop or set with setEntity()
|
// Book can be passed as prop or set with setEntity()
|
||||||
@@ -166,8 +154,6 @@ export default {
|
|||||||
imageReady: false,
|
imageReady: false,
|
||||||
selected: false,
|
selected: false,
|
||||||
isSelectionMode: false,
|
isSelectionMode: false,
|
||||||
displayTitleTruncated: false,
|
|
||||||
displaySubtitleTruncated: false,
|
|
||||||
showCoverBg: false
|
showCoverBg: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -181,25 +167,15 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
bookCoverAspectRatio() {
|
|
||||||
return this.store.getters['libraries/getBookCoverAspectRatio']
|
|
||||||
},
|
|
||||||
coverWidth() {
|
|
||||||
return this.width || this.coverHeight / this.bookCoverAspectRatio
|
|
||||||
},
|
|
||||||
coverHeight() {
|
|
||||||
return this.height * this.sizeMultiplier
|
|
||||||
},
|
|
||||||
cardWidth() {
|
|
||||||
// This method returns immediately without waiting for the DOM to update
|
|
||||||
return this.coverWidth
|
|
||||||
},
|
|
||||||
sizeMultiplier() {
|
|
||||||
return this.store.getters['user/getSizeMultiplier']
|
|
||||||
},
|
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.store.state.serverSettings.dateFormat
|
return this.store.state.serverSettings.dateFormat
|
||||||
},
|
},
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
|
enableEReader() {
|
||||||
|
return this.store.getters['getServerSetting']('enableEReader')
|
||||||
|
},
|
||||||
_libraryItem() {
|
_libraryItem() {
|
||||||
return this.libraryItem || {}
|
return this.libraryItem || {}
|
||||||
},
|
},
|
||||||
@@ -217,7 +193,10 @@ export default {
|
|||||||
return this._libraryItem.mediaType
|
return this._libraryItem.mediaType
|
||||||
},
|
},
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.mediaType === 'podcast' || this.store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
|
return this.mediaType === 'podcast'
|
||||||
|
},
|
||||||
|
isMusic() {
|
||||||
|
return this.mediaType === 'music'
|
||||||
},
|
},
|
||||||
isExplicit() {
|
isExplicit() {
|
||||||
return this.mediaMetadata.explicit || false
|
return this.mediaMetadata.explicit || false
|
||||||
@@ -236,17 +215,13 @@ export default {
|
|||||||
// Only included when filtering by series or collapse series or Continue Series shelf on home page
|
// Only included when filtering by series or collapse series or Continue Series shelf on home page
|
||||||
return this.mediaMetadata.series
|
return this.mediaMetadata.series
|
||||||
},
|
},
|
||||||
seriesName() {
|
|
||||||
if (this.collapsedSeries?.name) return this.collapsedSeries.name
|
|
||||||
return this.series?.name || null
|
|
||||||
},
|
|
||||||
seriesSequence() {
|
seriesSequence() {
|
||||||
return this.series?.sequence || null
|
return this.series ? this.series.sequence : null
|
||||||
},
|
},
|
||||||
libraryId() {
|
libraryId() {
|
||||||
return this._libraryItem.libraryId
|
return this._libraryItem.libraryId
|
||||||
},
|
},
|
||||||
ebookFormat() {
|
hasEbook() {
|
||||||
return this.media.ebookFormat
|
return this.media.ebookFormat
|
||||||
},
|
},
|
||||||
numTracks() {
|
numTracks() {
|
||||||
@@ -254,11 +229,9 @@ export default {
|
|||||||
return this.media.numTracks || 0 // toJSONMinified
|
return this.media.numTracks || 0 // toJSONMinified
|
||||||
},
|
},
|
||||||
numEpisodes() {
|
numEpisodes() {
|
||||||
|
if (!this.isPodcast) return 0
|
||||||
return this.media.numEpisodes || 0
|
return this.media.numEpisodes || 0
|
||||||
},
|
},
|
||||||
numEpisodesIncomplete() {
|
|
||||||
return this._libraryItem.numEpisodesIncomplete || 0
|
|
||||||
},
|
|
||||||
processingBatch() {
|
processingBatch() {
|
||||||
return this.store.state.processingBatch
|
return this.store.state.processingBatch
|
||||||
},
|
},
|
||||||
@@ -279,14 +252,14 @@ export default {
|
|||||||
},
|
},
|
||||||
booksInSeries() {
|
booksInSeries() {
|
||||||
// Only added to item object when collapseSeries is enabled
|
// Only added to item object when collapseSeries is enabled
|
||||||
return this.collapsedSeries?.numBooks || 0
|
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
||||||
},
|
},
|
||||||
seriesSequenceList() {
|
seriesSequenceList() {
|
||||||
return this.collapsedSeries?.seriesSequenceList || null
|
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null
|
||||||
},
|
},
|
||||||
libraryItemIdsInSeries() {
|
libraryItemIdsInSeries() {
|
||||||
// Only added to item object when collapseSeries is enabled
|
// Only added to item object when collapseSeries is enabled
|
||||||
return this.collapsedSeries?.libraryItemIds || []
|
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : []
|
||||||
},
|
},
|
||||||
hasCover() {
|
hasCover() {
|
||||||
return !!this.media.coverPath
|
return !!this.media.coverPath
|
||||||
@@ -294,6 +267,10 @@ export default {
|
|||||||
squareAspectRatio() {
|
squareAspectRatio() {
|
||||||
return this.bookCoverAspectRatio === 1
|
return this.bookCoverAspectRatio === 1
|
||||||
},
|
},
|
||||||
|
sizeMultiplier() {
|
||||||
|
const baseSize = this.squareAspectRatio ? 192 : 120
|
||||||
|
return this.width / baseSize
|
||||||
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.mediaMetadata.title || ''
|
return this.mediaMetadata.title || ''
|
||||||
},
|
},
|
||||||
@@ -315,18 +292,12 @@ export default {
|
|||||||
if (this.recentEpisode) return this.recentEpisode.title
|
if (this.recentEpisode) return this.recentEpisode.title
|
||||||
const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
|
const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
|
||||||
if (this.collapsedSeries) return ignorePrefix ? this.collapsedSeries.nameIgnorePrefix : this.collapsedSeries.name
|
if (this.collapsedSeries) return ignorePrefix ? this.collapsedSeries.nameIgnorePrefix : this.collapsedSeries.name
|
||||||
return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix || '\u00A0' : this.title || '\u00A0'
|
return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix : this.title
|
||||||
},
|
|
||||||
displaySubtitle() {
|
|
||||||
if (!this.libraryItem) return '\u00A0'
|
|
||||||
if (this.collapsedSeries) return `${this.collapsedSeries.numBooks} ${this.$strings.LabelBooks}`
|
|
||||||
if (this.mediaMetadata.subtitle) return this.mediaMetadata.subtitle
|
|
||||||
if (this.mediaMetadata.seriesName) return this.mediaMetadata.seriesName
|
|
||||||
return ''
|
|
||||||
},
|
},
|
||||||
displayLineTwo() {
|
displayLineTwo() {
|
||||||
if (this.recentEpisode) return this.title
|
if (this.recentEpisode) return this.title
|
||||||
if (this.isPodcast) return this.author
|
if (this.isPodcast) return this.author
|
||||||
|
if (this.isMusic) return this.artist
|
||||||
if (this.collapsedSeries) return ''
|
if (this.collapsedSeries) return ''
|
||||||
if (this.isAuthorBookshelfView) {
|
if (this.isAuthorBookshelfView) {
|
||||||
return this.mediaMetadata.publishedYear || ''
|
return this.mediaMetadata.publishedYear || ''
|
||||||
@@ -336,16 +307,12 @@ export default {
|
|||||||
},
|
},
|
||||||
displaySortLine() {
|
displaySortLine() {
|
||||||
if (this.collapsedSeries) return null
|
if (this.collapsedSeries) return null
|
||||||
if (this.orderBy === 'mtimeMs') return this.$getString('LabelFileModifiedDate', [this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat)])
|
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat)
|
||||||
if (this.orderBy === 'birthtimeMs') return this.$getString('LabelFileBornDate', [this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat)])
|
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat)
|
||||||
if (this.orderBy === 'addedAt') return this.$getString('LabelAddedDate', [this.$formatDate(this._libraryItem.addedAt, this.dateFormat)])
|
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt, this.dateFormat)
|
||||||
if (this.orderBy === 'media.duration') return this.$strings.LabelDuration + ': ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
||||||
if (this.orderBy === 'size') return this.$strings.LabelSize + ': ' + this.$bytesPretty(this._libraryItem.size)
|
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||||
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} ` + this.$strings.LabelEpisodes
|
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
|
||||||
if (this.orderBy === 'media.metadata.publishedYear') {
|
|
||||||
if (this.mediaMetadata.publishedYear) return this.$getString('LabelPublishedDate', [this.mediaMetadata.publishedYear])
|
|
||||||
return '\u00A0'
|
|
||||||
}
|
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
episodeProgress() {
|
episodeProgress() {
|
||||||
@@ -354,32 +321,14 @@ export default {
|
|||||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
|
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
|
||||||
},
|
},
|
||||||
userProgress() {
|
userProgress() {
|
||||||
|
if (this.isMusic) return null
|
||||||
if (this.episodeProgress) return this.episodeProgress
|
if (this.episodeProgress) return this.episodeProgress
|
||||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||||
},
|
},
|
||||||
isEBookOnly() {
|
|
||||||
return !this.numTracks && this.ebookFormat
|
|
||||||
},
|
|
||||||
useEBookProgress() {
|
|
||||||
if (!this.userProgress || this.userProgress.progress) return false
|
|
||||||
return this.userProgress.ebookProgress > 0
|
|
||||||
},
|
|
||||||
seriesProgressPercent() {
|
|
||||||
if (!this.libraryItemIdsInSeries.length) return 0
|
|
||||||
let progressPercent = 0
|
|
||||||
const useEBookProgress = this.useEBookProgress
|
|
||||||
this.libraryItemIdsInSeries.forEach((lid) => {
|
|
||||||
const progress = this.store.getters['user/getUserMediaProgress'](lid)
|
|
||||||
if (progress) progressPercent += progress.isFinished ? 1 : useEBookProgress ? progress.ebookProgress || 0 : progress.progress || 0
|
|
||||||
})
|
|
||||||
return progressPercent / this.libraryItemIdsInSeries.length
|
|
||||||
},
|
|
||||||
userProgressPercent() {
|
userProgressPercent() {
|
||||||
let progressPercent = this.itemIsFinished ? 1 : this.booksInSeries ? this.seriesProgressPercent : this.useEBookProgress ? this.userProgress?.ebookProgress || 0 : this.userProgress?.progress || 0
|
return this.userProgress ? this.userProgress.progress || 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() {
|
||||||
@@ -390,7 +339,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.isMissing || this.isInvalid
|
return this.numInvalidAudioFiles || this.numMissingParts || this.isMissing || this.isInvalid
|
||||||
},
|
},
|
||||||
libraryItemIdStreaming() {
|
libraryItemIdStreaming() {
|
||||||
return this.store.getters['getLibraryItemIdStreaming']
|
return this.store.getters['getLibraryItemIdStreaming']
|
||||||
@@ -406,13 +355,13 @@ export default {
|
|||||||
return this.store.getters['getIsStreamingFromDifferentLibrary']
|
return this.store.getters['getIsStreamingFromDifferentLibrary']
|
||||||
},
|
},
|
||||||
showReadButton() {
|
showReadButton() {
|
||||||
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
|
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
|
||||||
},
|
},
|
||||||
showPlayButton() {
|
showPlayButton() {
|
||||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
|
||||||
},
|
},
|
||||||
showSmallEBookIcon() {
|
showSmallEBookIcon() {
|
||||||
return !this.isSelectionMode && this.ebookFormat
|
return !this.isSelectionMode && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
|
||||||
},
|
},
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return this._libraryItem.isMissing
|
return this._libraryItem.isMissing
|
||||||
@@ -420,13 +369,29 @@ 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'
|
||||||
}
|
}
|
||||||
return 'Unknown Error'
|
let txt = ''
|
||||||
|
if (this.numMissingParts) {
|
||||||
|
txt += `${this.numMissingParts} missing parts.`
|
||||||
|
}
|
||||||
|
if (this.numInvalidAudioFiles) {
|
||||||
|
if (txt) txt += ' '
|
||||||
|
txt += `${this.numInvalidAudioFiles} invalid audio files.`
|
||||||
|
}
|
||||||
|
return txt || 'Unknown Error'
|
||||||
},
|
},
|
||||||
overlayWrapperClasslist() {
|
overlayWrapperClasslist() {
|
||||||
const classes = []
|
const classes = []
|
||||||
@@ -453,6 +418,8 @@ export default {
|
|||||||
return this.store.getters['user/getIsAdminOrUp']
|
return this.store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
moreMenuItems() {
|
moreMenuItems() {
|
||||||
|
if (this.isMusic) return []
|
||||||
|
|
||||||
if (this.recentEpisode) {
|
if (this.recentEpisode) {
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
@@ -509,24 +476,6 @@ export default {
|
|||||||
func: 'openPlaylists',
|
func: 'openPlaylists',
|
||||||
text: this.$strings.LabelAddToPlaylist
|
text: this.$strings.LabelAddToPlaylist
|
||||||
})
|
})
|
||||||
if (this.userIsAdminOrUp) {
|
|
||||||
items.push({
|
|
||||||
func: 'openShare',
|
|
||||||
text: this.$strings.LabelShare
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.ebookFormat && this.store.state.libraries.ereaderDevices?.length) {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.LabelSendEbookToDevice,
|
|
||||||
subitems: this.store.state.libraries.ereaderDevices.map((d) => {
|
|
||||||
return {
|
|
||||||
text: d.name,
|
|
||||||
func: 'sendToDevice',
|
|
||||||
data: d.name
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.userCanUpdate) {
|
if (this.userCanUpdate) {
|
||||||
@@ -554,7 +503,7 @@ export default {
|
|||||||
if (this.continueListeningShelf) {
|
if (this.continueListeningShelf) {
|
||||||
items.push({
|
items.push({
|
||||||
func: 'removeFromContinueListening',
|
func: 'removeFromContinueListening',
|
||||||
text: this.isEBookOnly ? this.$strings.ButtonRemoveFromContinueReading : this.$strings.ButtonRemoveFromContinueListening
|
text: this.$strings.ButtonRemoveFromContinueListening
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!this.isPodcast) {
|
if (!this.isPodcast) {
|
||||||
@@ -572,30 +521,22 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.userCanDelete) {
|
|
||||||
items.push({
|
|
||||||
func: 'deleteLibraryItem',
|
|
||||||
text: this.$strings.ButtonDelete
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
return items
|
||||||
},
|
},
|
||||||
_socket() {
|
_socket() {
|
||||||
return this.$root.socket || this.$nuxt.$root.socket
|
return this.$root.socket || this.$nuxt.$root.socket
|
||||||
},
|
},
|
||||||
titleFontSize() {
|
titleFontSize() {
|
||||||
return 0.75
|
return 0.75 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
authorFontSize() {
|
authorFontSize() {
|
||||||
return 0.6
|
return 0.6 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
placeholderCoverPadding() {
|
placeholderCoverPadding() {
|
||||||
return 0.8
|
return 0.8 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
authorBottom() {
|
authorBottom() {
|
||||||
return 0.75
|
return 0.75 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
titleCleaned() {
|
titleCleaned() {
|
||||||
if (!this.title) return ''
|
if (!this.title) return ''
|
||||||
@@ -619,15 +560,14 @@ export default {
|
|||||||
const constants = this.$constants || this.$nuxt.$constants
|
const constants = this.$constants || this.$nuxt.$constants
|
||||||
return this.bookshelfView === constants.BookshelfView.AUTHOR
|
return this.bookshelfView === constants.BookshelfView.AUTHOR
|
||||||
},
|
},
|
||||||
|
titleDisplayBottomOffset() {
|
||||||
|
if (!this.isAlternativeBookshelfView && !this.isAuthorBookshelfView) return 0
|
||||||
|
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
||||||
|
return 4.25 * this.sizeMultiplier
|
||||||
|
},
|
||||||
rssFeed() {
|
rssFeed() {
|
||||||
if (this.booksInSeries) return null
|
if (this.booksInSeries) return null
|
||||||
return this._libraryItem.rssFeed || null
|
return this._libraryItem.rssFeed || null
|
||||||
},
|
|
||||||
mediaItemShare() {
|
|
||||||
return this._libraryItem.mediaItemShare || null
|
|
||||||
},
|
|
||||||
showSubtitles() {
|
|
||||||
return !this.isPodcast && this.store.getters['user/getUserSetting']('showSubtitles')
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -664,15 +604,6 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.libraryItem = libraryItem
|
this.libraryItem = libraryItem
|
||||||
|
|
||||||
this.$nextTick(() => {
|
|
||||||
if (this.$refs.displayTitle) {
|
|
||||||
this.displayTitleTruncated = this.$refs.displayTitle.scrollWidth > this.$refs.displayTitle.clientWidth
|
|
||||||
}
|
|
||||||
if (this.$refs.displaySubtitle) {
|
|
||||||
this.displaySubtitleTruncated = this.$refs.displaySubtitle.scrollWidth > this.$refs.displaySubtitle.clientWidth
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
clickCard(e) {
|
clickCard(e) {
|
||||||
if (this.processing) return
|
if (this.processing) return
|
||||||
@@ -697,7 +628,7 @@ export default {
|
|||||||
toggleFinished(confirmed = false) {
|
toggleFinished(confirmed = false) {
|
||||||
if (!this.itemIsFinished && this.userProgressPercent > 0 && !confirmed) {
|
if (!this.itemIsFinished && this.userProgressPercent > 0 && !confirmed) {
|
||||||
const payload = {
|
const payload = {
|
||||||
message: this.$getString('MessageConfirmMarkItemFinished', [this.displayTitle]),
|
message: `Are you sure you want to mark "${this.displayTitle}" as finished?`,
|
||||||
callback: (confirmed) => {
|
callback: (confirmed) => {
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
this.toggleFinished(true)
|
this.toggleFinished(true)
|
||||||
@@ -723,6 +654,7 @@ export default {
|
|||||||
.$patch(apiEndpoint, updatePayload)
|
.$patch(apiEndpoint, updatePayload)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
|
toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
@@ -742,18 +674,18 @@ export default {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
var result = data.result
|
var result = data.result
|
||||||
if (!result) {
|
if (!result) {
|
||||||
this.$toast.error(this.$getString('ToastRescanFailed', [this.displayTitle]))
|
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||||
} else if (result === 'UPDATED') {
|
} else if (result === 'UPDATED') {
|
||||||
this.$toast.success(this.$strings.ToastRescanUpdated)
|
this.$toast.success(`Re-Scan complete item was updated`)
|
||||||
} else if (result === 'UPTODATE') {
|
} else if (result === 'UPTODATE') {
|
||||||
this.$toast.success(this.$strings.ToastRescanUpToDate)
|
this.$toast.success(`Re-Scan complete item was up to date`)
|
||||||
} else if (result === 'REMOVED') {
|
} else if (result === 'REMOVED') {
|
||||||
this.$toast.error(this.$strings.ToastRescanRemoved)
|
this.$toast.error(`Re-Scan complete item was removed`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to scan library item', error)
|
console.error('Failed to scan library item', error)
|
||||||
this.$toast.error(this.$strings.ToastScanFailed)
|
this.$toast.error('Failed to scan library item')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
@@ -767,40 +699,7 @@ export default {
|
|||||||
// More menu func
|
// More menu func
|
||||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
|
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
|
||||||
},
|
},
|
||||||
sendToDevice(deviceName) {
|
|
||||||
// More menu func
|
|
||||||
const payload = {
|
|
||||||
// message: `Are you sure you want to send ${this.ebookFormat} ebook "${this.title}" to device "${deviceName}"?`,
|
|
||||||
message: this.$getString('MessageConfirmSendEbookToDevice', [this.ebookFormat, this.title, deviceName]),
|
|
||||||
callback: (confirmed) => {
|
|
||||||
if (confirmed) {
|
|
||||||
const payload = {
|
|
||||||
libraryItemId: this.libraryItemId,
|
|
||||||
deviceName
|
|
||||||
}
|
|
||||||
this.processing = true
|
|
||||||
const axios = this.$axios || this.$nuxt.$axios
|
|
||||||
axios
|
|
||||||
.$post(`/api/emails/send-ebook-to-device`, payload)
|
|
||||||
.then(() => {
|
|
||||||
this.$toast.success(this.$getString('ToastSendEbookToDeviceSuccess', [deviceName]))
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to send ebook to device', error)
|
|
||||||
this.$toast.error(this.$strings.ToastSendEbookToDeviceFailed)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'yesNo'
|
|
||||||
}
|
|
||||||
this.store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
|
||||||
removeSeriesFromContinueListening() {
|
removeSeriesFromContinueListening() {
|
||||||
if (!this.series) return
|
|
||||||
|
|
||||||
const axios = this.$axios || this.$nuxt.$axios
|
const axios = this.$axios || this.$nuxt.$axios
|
||||||
this.processing = true
|
this.processing = true
|
||||||
axios
|
axios
|
||||||
@@ -810,7 +709,7 @@ export default {
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to remove series from home', error)
|
console.error('Failed to remove series from home', error)
|
||||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
this.$toast.error('Failed to update user')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
@@ -828,7 +727,7 @@ export default {
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to hide item from home', error)
|
console.error('Failed to hide item from home', error)
|
||||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
this.$toast.error('Failed to update user')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
@@ -843,7 +742,7 @@ export default {
|
|||||||
episodeId: this.recentEpisode.id,
|
episodeId: this.recentEpisode.id,
|
||||||
title: this.recentEpisode.title,
|
title: this.recentEpisode.title,
|
||||||
subtitle: this.mediaMetadata.title,
|
subtitle: this.mediaMetadata.title,
|
||||||
caption: this.recentEpisode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(this.recentEpisode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
|
caption: this.recentEpisode.publishedAt ? `Published ${this.$formatDate(this.recentEpisode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||||
duration: this.recentEpisode.audioFile.duration || null,
|
duration: this.recentEpisode.audioFile.duration || null,
|
||||||
coverPath: this.media.coverPath || null
|
coverPath: this.media.coverPath || null
|
||||||
}
|
}
|
||||||
@@ -873,41 +772,6 @@ export default {
|
|||||||
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
|
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
|
||||||
this.store.commit('globals/setShowPlaylistsModal', true)
|
this.store.commit('globals/setShowPlaylistsModal', true)
|
||||||
},
|
},
|
||||||
openShare() {
|
|
||||||
this.store.commit('setSelectedLibraryItem', this.libraryItem)
|
|
||||||
this.store.commit('globals/setShareModal', this.mediaItemShare)
|
|
||||||
},
|
|
||||||
deleteLibraryItem() {
|
|
||||||
const payload = {
|
|
||||||
message: this.$strings.MessageConfirmDeleteLibraryItem,
|
|
||||||
checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox,
|
|
||||||
yesButtonText: this.$strings.ButtonDelete,
|
|
||||||
yesButtonColor: 'error',
|
|
||||||
checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0),
|
|
||||||
callback: (confirmed, hardDelete) => {
|
|
||||||
if (confirmed) {
|
|
||||||
localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)
|
|
||||||
|
|
||||||
this.processing = true
|
|
||||||
const axios = this.$axios || this.$nuxt.$axios
|
|
||||||
axios
|
|
||||||
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
|
|
||||||
.then(() => {
|
|
||||||
this.$toast.success(this.$strings.ToastItemDeletedSuccess)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to delete item', error)
|
|
||||||
this.$toast.error(this.$strings.ToastItemDeletedFailed)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'yesNo'
|
|
||||||
}
|
|
||||||
this.store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
|
||||||
createMoreMenu() {
|
createMoreMenu() {
|
||||||
if (!this.$refs.moreIcon) return
|
if (!this.$refs.moreIcon) return
|
||||||
|
|
||||||
@@ -919,8 +783,8 @@ export default {
|
|||||||
items: this.moreMenuItems
|
items: this.moreMenuItems
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.$on('action', (action) => {
|
this.$on('action', (func) => {
|
||||||
if (action.func && _this[action.func]) _this[action.func](action.data)
|
if (_this[func]) _this[func]()
|
||||||
})
|
})
|
||||||
this.$on('close', () => {
|
this.$on('close', () => {
|
||||||
_this.isMoreMenuOpen = false
|
_this.isMoreMenuOpen = false
|
||||||
@@ -932,7 +796,7 @@ export default {
|
|||||||
var wrapperBox = this.$refs.moreIcon.getBoundingClientRect()
|
var wrapperBox = this.$refs.moreIcon.getBoundingClientRect()
|
||||||
var el = instance.$el
|
var el = instance.$el
|
||||||
|
|
||||||
var elHeight = this.moreMenuItems.length * 28 + 10
|
var elHeight = this.moreMenuItems.length * 28 + 2
|
||||||
var elWidth = 130
|
var elWidth = 130
|
||||||
|
|
||||||
var bottomOfIcon = wrapperBox.top + wrapperBox.height
|
var bottomOfIcon = wrapperBox.top + wrapperBox.height
|
||||||
@@ -959,13 +823,12 @@ export default {
|
|||||||
this.createMoreMenu()
|
this.createMoreMenu()
|
||||||
},
|
},
|
||||||
async clickReadEBook() {
|
async clickReadEBook() {
|
||||||
const axios = this.$axios || this.$nuxt.$axios
|
var libraryItem = await this.$axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
|
||||||
var libraryItem = await axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
|
|
||||||
console.error('Failed to get lirbary item', this.libraryItemId)
|
console.error('Failed to get lirbary item', this.libraryItemId)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
if (!libraryItem) return
|
if (!libraryItem) return
|
||||||
this.store.commit('showEReader', { libraryItem, keepProgress: true })
|
this.store.commit('showEReader', libraryItem)
|
||||||
},
|
},
|
||||||
selectBtnClick(evt) {
|
selectBtnClick(evt) {
|
||||||
if (this.processingBatch) return
|
if (this.processingBatch) return
|
||||||
@@ -1003,7 +866,7 @@ export default {
|
|||||||
episodeId: episode.id,
|
episodeId: episode.id,
|
||||||
title: episode.title,
|
title: episode.title,
|
||||||
subtitle: this.mediaMetadata.title,
|
subtitle: this.mediaMetadata.title,
|
||||||
caption: episode.publishedAt ? this.$getString('LabelPublishedDate', [this.$formatDate(episode.publishedAt, this.dateFormat)]) : this.$strings.LabelUnknownPublishDate,
|
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||||
duration: episode.audioFile.duration || null,
|
duration: episode.audioFile.duration || null,
|
||||||
coverPath: this.media.coverPath || null
|
coverPath: this.media.coverPath || null
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="card" :id="`collection-card-${index}`" role="button" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
<div ref="card" :id="`collection-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||||
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<covers-collection-cover ref="cover" :book-items="books" :width="cardWidth" :height="coverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
</div>
|
||||||
|
<div v-show="isHovering && userCanUpdate" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||||
|
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
||||||
|
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="isHovering && userCanUpdate" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
|
||||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 + 'em', right: 0.5 + 'em' }" @click.stop.prevent="clickEdit">
|
|
||||||
<span class="material-symbols text-white text-opacity-75 hover:text-opacity-100" :style="{ fontSize: 1.25 + 'em' }">edit</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-symbols text-success" :style="{ top: 0.5 + 'em', left: 0.5 + 'em', fontSize: 1.5 + 'em' }">rss_feed</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
|
<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>
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em ${0.5}em` }">
|
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
|
||||||
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||||
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="relative z-30 left-0 right-0 mx-auto h-8e py-1e rounded-md text-center">
|
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -30,10 +28,8 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
index: Number,
|
index: Number,
|
||||||
width: Number,
|
width: Number,
|
||||||
height: {
|
height: Number,
|
||||||
type: Number,
|
bookCoverAspectRatio: Number,
|
||||||
default: 192
|
|
||||||
},
|
|
||||||
bookshelfView: {
|
bookshelfView: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
@@ -53,21 +49,13 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
bookCoverAspectRatio() {
|
|
||||||
return this.store.getters['libraries/getBookCoverAspectRatio']
|
|
||||||
},
|
|
||||||
cardWidth() {
|
|
||||||
return this.width || (this.coverHeight / this.bookCoverAspectRatio) * 2
|
|
||||||
},
|
|
||||||
coverHeight() {
|
|
||||||
return this.height * this.sizeMultiplier
|
|
||||||
},
|
|
||||||
labelFontSize() {
|
labelFontSize() {
|
||||||
if (this.width < 160) return 0.75
|
if (this.width < 160) return 0.75
|
||||||
return 0.9
|
return 0.875
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
return this.store.getters['user/getSizeMultiplier']
|
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||||
|
return this.width / 240
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.collection ? this.collection.name : ''
|
return this.collection ? this.collection.name : ''
|
||||||
@@ -131,4 +119,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,24 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="card" :id="`playlist-card-${index}`" role="button" :style="{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
<div ref="card" :id="`playlist-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||||
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
<covers-playlist-cover ref="cover" :items="items" :width="width" :height="height" />
|
||||||
<covers-playlist-cover ref="cover" :items="items" :width="cardWidth" :height="coverHeight" />
|
</div>
|
||||||
</div>
|
<div v-show="isHovering && userCanUpdate" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||||
<div v-show="isHovering && userCanUpdate" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
||||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 + 'em', right: 0.5 + 'em' }" @click.stop.prevent="clickEdit">
|
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
||||||
<span class="material-symbols text-white text-opacity-75 hover:text-opacity-100" :style="{ fontSize: 1.25 + 'em' }">edit</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
|
||||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 -bottom-6e left-0 right-0 mx-auto h-6e 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: `0em ${0.5}em` }">
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="relative z-30 left-0 right-0 mx-auto h-8e py-1e rounded-md text-center">
|
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -28,10 +25,8 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
index: Number,
|
index: Number,
|
||||||
width: Number,
|
width: Number,
|
||||||
height: {
|
height: Number,
|
||||||
type: Number,
|
bookCoverAspectRatio: Number,
|
||||||
default: 192
|
|
||||||
},
|
|
||||||
bookshelfView: {
|
bookshelfView: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
@@ -50,21 +45,13 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
bookCoverAspectRatio() {
|
|
||||||
return this.store.getters['libraries/getBookCoverAspectRatio']
|
|
||||||
},
|
|
||||||
cardWidth() {
|
|
||||||
return this.width || this.coverHeight
|
|
||||||
},
|
|
||||||
coverHeight() {
|
|
||||||
return this.height * this.sizeMultiplier
|
|
||||||
},
|
|
||||||
labelFontSize() {
|
labelFontSize() {
|
||||||
if (this.width < 160) return 0.75
|
if (this.width < 160) return 0.75
|
||||||
return 0.9
|
return 0.875
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
return this.store.getters['user/getSizeMultiplier']
|
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6)
|
||||||
|
return this.width / 120
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.playlist ? this.playlist.name : ''
|
return this.playlist ? this.playlist.name : ''
|
||||||
@@ -125,4 +112,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,32 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<div cy-id="card" ref="card" :id="`series-card-${index}`" tabindex="0" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
<div 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="covers-area" class="relative" :style="{ height: coverHeight + 'px' }">
|
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
|
||||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
|
<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="cardWidth" :height="coverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
|
|
||||||
<p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfBooks">{{ books.length }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b w-full box-shadow-progressbar" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
|
||||||
|
|
||||||
<div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" aria-hidden="true" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: '1em' }">
|
|
||||||
<p :style="{ fontSize: 1.2 + 'em' }">{{ displayTitle }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span cy-id="rssFeedMarker" v-if="!isHovering && rssFeed" class="absolute z-10 material-symbols text-success" :style="{ top: 0.5 + 'em', left: 0.5 + 'em', fontSize: 1.5 + 'em' }">rss_feed</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div cy-id="standardBottomText" v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
|
<div class="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 class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
|
|
||||||
<p cy-id="standardBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
<div v-if="isSeriesFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success w-full" />
|
||||||
|
|
||||||
|
<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` }">
|
||||||
|
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
|
||||||
|
|
||||||
|
<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 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div cy-id="detailBottomText" v-else class="relative z-30 left-0 right-0 mx-auto py-1e rounded-md text-center">
|
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||||
<p cy-id="detailBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
||||||
<p cy-id="detailBottomSortLine" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
|
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -36,14 +32,13 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
index: Number,
|
index: Number,
|
||||||
width: Number,
|
width: Number,
|
||||||
height: {
|
height: Number,
|
||||||
type: Number,
|
bookCoverAspectRatio: Number,
|
||||||
default: 192
|
|
||||||
},
|
|
||||||
bookshelfView: {
|
bookshelfView: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
},
|
},
|
||||||
|
isCategorized: Boolean,
|
||||||
seriesMount: {
|
seriesMount: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
@@ -61,62 +56,47 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
bookCoverAspectRatio() {
|
|
||||||
return this.store.getters['libraries/getBookCoverAspectRatio']
|
|
||||||
},
|
|
||||||
cardWidth() {
|
|
||||||
return this.width || (this.coverHeight / this.bookCoverAspectRatio) * 2
|
|
||||||
},
|
|
||||||
coverHeight() {
|
|
||||||
return this.height * this.sizeMultiplier
|
|
||||||
},
|
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.store.state.serverSettings.dateFormat
|
return this.store.state.serverSettings.dateFormat
|
||||||
},
|
},
|
||||||
labelFontSize() {
|
labelFontSize() {
|
||||||
if (this.width < 160) return 0.75
|
if (this.width < 160) return 0.75
|
||||||
return 0.9
|
return 0.875
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
return this.store.getters['user/getSizeMultiplier']
|
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||||
|
return this.width / 240
|
||||||
},
|
},
|
||||||
seriesId() {
|
seriesId() {
|
||||||
return this.series?.id || ''
|
return this.series ? this.series.id : ''
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.series?.name || ''
|
return this.series ? this.series.name : ''
|
||||||
},
|
},
|
||||||
nameIgnorePrefix() {
|
nameIgnorePrefix() {
|
||||||
return this.series?.nameIgnorePrefix || ''
|
return this.series ? this.series.nameIgnorePrefix : ''
|
||||||
},
|
},
|
||||||
displayTitle() {
|
displayTitle() {
|
||||||
if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title || '\u00A0'
|
if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title
|
||||||
return this.title || '\u00A0'
|
return this.title
|
||||||
},
|
},
|
||||||
displaySortLine() {
|
displaySortLine() {
|
||||||
switch (this.orderBy) {
|
if (this.orderBy === 'addedAt') {
|
||||||
case 'addedAt':
|
// return this.addedAt
|
||||||
return this.$getString('LabelAddedDate', [this.$formatDate(this.addedAt, this.dateFormat)])
|
return 'Added ' + this.$formatDate(this.addedAt, this.dateFormat)
|
||||||
case 'totalDuration':
|
} else if (this.orderBy === 'totalDuration') {
|
||||||
return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}`
|
return 'Duration: ' + this.$elapsedPrettyExtended(this.totalDuration, false)
|
||||||
case 'lastBookUpdated':
|
|
||||||
const lastUpdated = Math.max(...this.books.map((x) => x.updatedAt), 0)
|
|
||||||
return `${this.$strings.LabelLastBookUpdated} ${this.$formatDate(lastUpdated, this.dateFormat)}`
|
|
||||||
case 'lastBookAdded':
|
|
||||||
const lastBookAdded = Math.max(...this.books.map((x) => x.addedAt), 0)
|
|
||||||
return `${this.$strings.LabelLastBookAdded} ${this.$formatDate(lastBookAdded, this.dateFormat)}`
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
},
|
},
|
||||||
books() {
|
books() {
|
||||||
return this.series?.books || []
|
return this.series ? this.series.books || [] : []
|
||||||
},
|
},
|
||||||
addedAt() {
|
addedAt() {
|
||||||
return this.series?.addedAt || 0
|
return this.series ? this.series.addedAt : 0
|
||||||
},
|
},
|
||||||
totalDuration() {
|
totalDuration() {
|
||||||
return this.series?.totalDuration || 0
|
return this.series ? this.series.totalDuration : 0
|
||||||
},
|
},
|
||||||
seriesBookProgress() {
|
seriesBookProgress() {
|
||||||
return this.books
|
return this.books
|
||||||
@@ -128,18 +108,6 @@ export default {
|
|||||||
seriesBooksFinished() {
|
seriesBooksFinished() {
|
||||||
return this.seriesBookProgress.filter((p) => p.isFinished)
|
return this.seriesBookProgress.filter((p) => p.isFinished)
|
||||||
},
|
},
|
||||||
hasSeriesBookInProgress() {
|
|
||||||
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
|
|
||||||
},
|
|
||||||
seriesPercentInProgress() {
|
|
||||||
if (!this.books.length) return 0
|
|
||||||
let progressPercent = 0
|
|
||||||
this.seriesBookProgress.forEach((progress) => {
|
|
||||||
progressPercent += progress.isFinished ? 1 : progress.progress || 0
|
|
||||||
})
|
|
||||||
progressPercent /= this.books.length
|
|
||||||
return Math.min(1, Math.max(0, progressPercent))
|
|
||||||
},
|
|
||||||
isSeriesFinished() {
|
isSeriesFinished() {
|
||||||
return this.books.length === this.seriesBooksFinished.length
|
return this.books.length === this.seriesBooksFinished.length
|
||||||
},
|
},
|
||||||
@@ -161,7 +129,7 @@ export default {
|
|||||||
return this.bookshelfView == constants.BookshelfView.DETAIL
|
return this.bookshelfView == constants.BookshelfView.DETAIL
|
||||||
},
|
},
|
||||||
rssFeed() {
|
rssFeed() {
|
||||||
return this.series?.rssFeed
|
return this.series ? this.series.rssFeed : null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(name)}`">
|
|
||||||
<div cy-id="card" :style="{ width: cardWidth + 'px', height: cardHeight + 'px', fontSize: sizeMultiplier + 'rem' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
|
||||||
<div class="absolute inset-0 w-full h-full flex items-center justify-center pointer-events-none opacity-40">
|
|
||||||
<span class="material-symbols text-[10em]"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Narrator name & num books overlay -->
|
|
||||||
<div class="absolute bottom-0 left-0 w-full py-1e bg-black bg-opacity-60 px-2e">
|
|
||||||
<p cy-id="name" class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: 0.75 + 'em' }">{{ name }}</p>
|
|
||||||
<p cy-id="numBooks" class="text-center text-gray-200" :style="{ fontSize: 0.65 + 'em' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nuxt-link>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
narrator: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
},
|
|
||||||
width: Number,
|
|
||||||
height: {
|
|
||||||
type: Number,
|
|
||||||
default: 100
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
cardWidth() {
|
|
||||||
return this.cardHeight * 1.5
|
|
||||||
},
|
|
||||||
cardHeight() {
|
|
||||||
return this.height * this.sizeMultiplier
|
|
||||||
},
|
|
||||||
name() {
|
|
||||||
return this.narrator?.name || ''
|
|
||||||
},
|
|
||||||
numBooks() {
|
|
||||||
return this.narrator?.numBooks || this.narrator?.books?.length || 0
|
|
||||||
},
|
|
||||||
userCanUpdate() {
|
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
|
||||||
},
|
|
||||||
currentLibraryId() {
|
|
||||||
return this.$store.state.libraries.currentLibraryId
|
|
||||||
},
|
|
||||||
sizeMultiplier() {
|
|
||||||
return this.$store.getters['user/getSizeMultiplier']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex h-full px-1 overflow-hidden">
|
|
||||||
<div class="w-10 h-10 flex items-center justify-center">
|
|
||||||
<span class="material-symbols text-2xl text-gray-200"></span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-2 narratorSearchCardContent h-full">
|
|
||||||
<p class="truncate text-sm">{{ narrator }}</p>
|
|
||||||
<p class="text-xs text-gray-400">{{ $getString('LabelXBooks', [numBooks]) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
narrator: String,
|
|
||||||
numBooks: Number
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {},
|
|
||||||
methods: {},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.narratorSearchCardContent {
|
|
||||||
width: calc(100% - 40px);
|
|
||||||
height: 44px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -4,11 +4,11 @@
|
|||||||
<p class="text-base md:text-lg font-semibold pr-4">{{ eventName }}</p>
|
<p class="text-base md:text-lg font-semibold pr-4">{{ eventName }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" @click.stop="fireTestEventAndSucceed">{{ this.$strings.ButtonFireOnTest }}</ui-btn>
|
<ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" @click.stop="fireTestEventAndSucceed">Fire onTest Event</ui-btn>
|
||||||
<ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" color="red-600" @click.stop="fireTestEventAndFail">{{ this.$strings.ButtonFireAndFail }}</ui-btn>
|
<ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" color="red-600" @click.stop="fireTestEventAndFail">Fire & Fail</ui-btn>
|
||||||
<!-- <ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" @click.stop="rapidFireTestEvents">Rapid Fire</ui-btn> -->
|
<!-- <ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" @click.stop="rapidFireTestEvents">Rapid Fire</ui-btn> -->
|
||||||
<ui-btn v-else-if="notification.enabled" :loading="sendingTest" small class="mr-2" @click.stop="sendTestClick">{{ this.$strings.ButtonTest }}</ui-btn>
|
<ui-btn v-else-if="notification.enabled" :loading="sendingTest" small class="mr-2" @click.stop="sendTestClick">Test</ui-btn>
|
||||||
<ui-btn v-else :loading="enabling" small color="success" class="mr-2" @click="enableNotification">{{ this.$strings.ButtonEnable }}</ui-btn>
|
<ui-btn v-else :loading="enabling" small color="success" class="mr-2" @click="enableNotification">Enable</ui-btn>
|
||||||
|
|
||||||
<ui-icon-btn :size="7" icon-font-size="1.1rem" icon="edit" class="mr-2" @click="editNotification" />
|
<ui-icon-btn :size="7" icon-font-size="1.1rem" icon="edit" class="mr-2" @click="editNotification" />
|
||||||
<ui-icon-btn bg-color="error" :size="7" icon-font-size="1.2rem" icon="delete" @click="deleteNotificationClick" />
|
<ui-icon-btn bg-color="error" :size="7" icon-font-size="1.2rem" icon="delete" @click="deleteNotificationClick" />
|
||||||
@@ -65,12 +65,12 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.$get(`/api/notifications/test?fail=${intentionallyFail ? 1 : 0}`)
|
.$get(`/api/notifications/test?fail=${intentionallyFail ? 1 : 0}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success(this.$strings.ToastNotificationTestTriggerSuccess)
|
this.$toast.success('Triggered onTest Event')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
const errorMsg = error.response ? error.response.data : null
|
const errorMsg = error.response ? error.response.data : null
|
||||||
this.$toast.error(`Failed: ${errorMsg}` || this.$strings.ToastNotificationTestTriggerFailed)
|
this.$toast.error(`Failed: ${errorMsg}` || 'Failed to trigger onTest event')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.testing = false
|
this.testing = false
|
||||||
@@ -91,7 +91,7 @@ export default {
|
|||||||
// End testing functions
|
// End testing functions
|
||||||
sendTestClick() {
|
sendTestClick() {
|
||||||
const payload = {
|
const payload = {
|
||||||
message: this.$strings.MessageConfirmNotificationTestTrigger,
|
message: `Trigger this notification with test data?`,
|
||||||
callback: (confirmed) => {
|
callback: (confirmed) => {
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
this.sendTest()
|
this.sendTest()
|
||||||
@@ -106,12 +106,12 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.$get(`/api/notifications/${this.notification.id}/test`)
|
.$get(`/api/notifications/${this.notification.id}/test`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success(this.$strings.ToastNotificationTestTriggerSuccess)
|
this.$toast.success('Triggered test notification')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
const errorMsg = error.response ? error.response.data : null
|
const errorMsg = error.response ? error.response.data : null
|
||||||
this.$toast.error(`Failed: ${errorMsg}` || this.$strings.ToastNotificationTestTriggerFailed)
|
this.$toast.error(`Failed: ${errorMsg}` || 'Failed to trigger test notification')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.sendingTest = false
|
this.sendingTest = false
|
||||||
@@ -127,10 +127,11 @@ export default {
|
|||||||
.$patch(`/api/notifications/${this.notification.id}`, payload)
|
.$patch(`/api/notifications/${this.notification.id}`, payload)
|
||||||
.then((updatedSettings) => {
|
.then((updatedSettings) => {
|
||||||
this.$emit('update', updatedSettings)
|
this.$emit('update', updatedSettings)
|
||||||
|
this.$toast.success('Notification enabled')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to update notification', error)
|
console.error('Failed to update notification', error)
|
||||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
this.$toast.error('Failed to update notification')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.enabling = false
|
this.enabling = false
|
||||||
@@ -138,7 +139,7 @@ export default {
|
|||||||
},
|
},
|
||||||
deleteNotificationClick() {
|
deleteNotificationClick() {
|
||||||
const payload = {
|
const payload = {
|
||||||
message: this.$strings.MessageConfirmDeleteNotification,
|
message: `Are you sure you want to delete this notification?`,
|
||||||
callback: (confirmed) => {
|
callback: (confirmed) => {
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
this.deleteNotification()
|
this.deleteNotification()
|
||||||
@@ -154,10 +155,11 @@ export default {
|
|||||||
.$delete(`/api/notifications/${this.notification.id}`)
|
.$delete(`/api/notifications/${this.notification.id}`)
|
||||||
.then((updatedSettings) => {
|
.then((updatedSettings) => {
|
||||||
this.$emit('update', updatedSettings)
|
this.$emit('update', updatedSettings)
|
||||||
|
this.$toast.success('Deleted notification')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
this.$toast.error(this.$strings.ToastNotificationDeleteFailed)
|
this.$toast.error('Failed to delete notification')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.deleting = false
|
this.deleting = false
|
||||||
@@ -169,4 +171,4 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full px-1 overflow-hidden">
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
<div class="w-10 h-10 flex items-center justify-center">
|
<div class="w-10 h-10 flex items-center justify-center">
|
||||||
<span class="material-symbols text-2xl text-gray-200">local_offer</span>
|
<span class="material-icons text-2xl text-gray-200">local_offer</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
||||||
<p class="truncate text-sm">{{ tag }}</p>
|
<p class="truncate text-sm">{{ tag }}</p>
|
||||||
<p class="text-xs text-gray-400">{{ $getString('LabelXItems', [numItems]) }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -13,8 +12,7 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
tag: String,
|
tag: String
|
||||||
numItems: Number
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
@@ -28,9 +26,9 @@ export default {
|
|||||||
<style>
|
<style>
|
||||||
.tagSearchCardContent {
|
.tagSearchCardContent {
|
||||||
width: calc(100% - 40px);
|
width: calc(100% - 40px);
|
||||||
height: 44px;
|
height: 40px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div v-if="narrators?.length" class="flex py-0.5 mt-4">
|
|
||||||
<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.LabelNarrators }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
|
||||||
<template v-for="(narrator, index) in narrators">
|
|
||||||
<nuxt-link :key="narrator" :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link
|
|
||||||
><span :key="index" v-if="index < narrators.length - 1">, </span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="publishedYear" 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.LabelPublishYear }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ publishedYear }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="publisher" 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.LabelPublisher }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=publishers.${$encode(publisher)}`" class="hover:underline">{{ publisher }}</nuxt-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="podcastType" class="flex py-0.5">
|
|
||||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPodcastType }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="capitalize">
|
|
||||||
{{ podcastType }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex py-0.5" v-if="genres.length">
|
|
||||||
<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.LabelGenres }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
|
||||||
<template v-for="(genre, index) in genres">
|
|
||||||
<nuxt-link :key="genre" :to="`/library/${libraryId}/bookshelf?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link
|
|
||||||
><span :key="index" v-if="index < genres.length - 1">, </span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex py-0.5" v-if="tags.length">
|
|
||||||
<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.LabelTags }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
|
||||||
<template v-for="(tag, index) in tags">
|
|
||||||
<nuxt-link :key="tag" :to="`/library/${libraryId}/bookshelf?filter=tags.${$encode(tag)}`" class="hover:underline">{{ tag }}</nuxt-link
|
|
||||||
><span :key="index" v-if="index < tags.length - 1">, </span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="language" class="flex py-0.5">
|
|
||||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelLanguage }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=languages.${$encode(language)}`" class="hover:underline">{{ language }}</nuxt-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="tracks.length || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
|
|
||||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ durationPretty }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div 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.LabelSize }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ sizePretty }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
libraryItem: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
libraryId() {
|
|
||||||
return this.libraryItem.libraryId
|
|
||||||
},
|
|
||||||
isPodcast() {
|
|
||||||
return this.libraryItem.mediaType === 'podcast'
|
|
||||||
},
|
|
||||||
media() {
|
|
||||||
return this.libraryItem.media || {}
|
|
||||||
},
|
|
||||||
tracks() {
|
|
||||||
return this.media.tracks || []
|
|
||||||
},
|
|
||||||
podcastEpisodes() {
|
|
||||||
return this.media.episodes || []
|
|
||||||
},
|
|
||||||
mediaMetadata() {
|
|
||||||
return this.media.metadata || {}
|
|
||||||
},
|
|
||||||
publishedYear() {
|
|
||||||
return this.mediaMetadata.publishedYear
|
|
||||||
},
|
|
||||||
genres() {
|
|
||||||
return this.mediaMetadata.genres || []
|
|
||||||
},
|
|
||||||
tags() {
|
|
||||||
return this.media.tags || []
|
|
||||||
},
|
|
||||||
podcastAuthor() {
|
|
||||||
return this.mediaMetadata.author || ''
|
|
||||||
},
|
|
||||||
authors() {
|
|
||||||
return this.mediaMetadata.authors || []
|
|
||||||
},
|
|
||||||
publisher() {
|
|
||||||
return this.mediaMetadata.publisher || ''
|
|
||||||
},
|
|
||||||
narrators() {
|
|
||||||
return this.mediaMetadata.narrators || []
|
|
||||||
},
|
|
||||||
language() {
|
|
||||||
return this.mediaMetadata.language || null
|
|
||||||
},
|
|
||||||
durationPretty() {
|
|
||||||
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)
|
|
||||||
|
|
||||||
if (!this.tracks.length && !this.audioFile) return 'N/A'
|
|
||||||
if (this.audioFile) return this.$elapsedPrettyExtended(this.duration)
|
|
||||||
return this.$elapsedPretty(this.duration)
|
|
||||||
},
|
|
||||||
duration() {
|
|
||||||
if (!this.tracks.length && !this.audioFile) return 0
|
|
||||||
return this.media.duration
|
|
||||||
},
|
|
||||||
totalPodcastDuration() {
|
|
||||||
if (!this.podcastEpisodes.length) return 0
|
|
||||||
let totalDuration = 0
|
|
||||||
this.podcastEpisodes.forEach((ep) => (totalDuration += ep.duration || 0))
|
|
||||||
return totalDuration
|
|
||||||
},
|
|
||||||
sizePretty() {
|
|
||||||
return this.$bytesPretty(this.media.size)
|
|
||||||
},
|
|
||||||
podcastType() {
|
|
||||||
return this.mediaMetadata.type
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||||
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||||
<span class="flex items-center justify-between">
|
<span class="flex items-center justify-between">
|
||||||
<span class="block truncate text-xs">{{ selectedText }}</span>
|
<span class="block truncate text-xs">{{ selectedText }}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -10,21 +10,16 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
||||||
<span class="material-symbols" style="font-size: 1.1rem">close</span>
|
<span class="material-icons" style="font-size: 1.1rem">close</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-sm ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
|
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in items">
|
<template v-for="item in items">
|
||||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
|
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- selected checkmark icon -->
|
|
||||||
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
|
||||||
<span class="material-symbols text-base text-yellow-400">check</span>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="">
|
<div class="sm:w-80 w-full relative">
|
||||||
<div class="w-full relative sm:w-80">
|
<form @submit.prevent="submitSearch">
|
||||||
<form role="search" @submit.prevent="submitSearch">
|
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||||
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
</form>
|
||||||
</form>
|
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
||||||
<button :aria-hidden="!search" class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
||||||
<span v-if="!search" class="material-symbols" style="font-size: 1.2rem"></span>
|
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
||||||
<span v-else class="material-symbols" style="font-size: 1.2rem">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu" @mousedown.stop.prevent>
|
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
|
||||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<li v-if="isTyping" class="py-2 px-2">
|
<li v-if="isTyping" class="py-2 px-2">
|
||||||
<p>{{ $strings.MessageThinking }}</p>
|
<p>{{ $strings.MessageThinking }}</p>
|
||||||
@@ -25,7 +23,7 @@
|
|||||||
<template v-for="item in bookResults">
|
<template v-for="item in bookResults">
|
||||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||||
<cards-item-search-card :library-item="item.libraryItem" />
|
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
@@ -34,7 +32,7 @@
|
|||||||
<template v-for="item in podcastResults">
|
<template v-for="item in podcastResults">
|
||||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||||
<cards-item-search-card :library-item="item.libraryItem" />
|
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
@@ -42,7 +40,7 @@
|
|||||||
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelAuthors }}</p>
|
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelAuthors }}</p>
|
||||||
<template v-for="item in authorResults">
|
<template v-for="item in authorResults">
|
||||||
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||||
<nuxt-link :to="`/author/${item.id}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`">
|
||||||
<cards-author-search-card :author="item" />
|
<cards-author-search-card :author="item" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
@@ -59,27 +57,9 @@
|
|||||||
|
|
||||||
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelTags }}</p>
|
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelTags }}</p>
|
||||||
<template v-for="item in tagResults">
|
<template v-for="item in tagResults">
|
||||||
<li :key="`tag.${item.name}`" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
<li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
|
||||||
<cards-tag-search-card :tag="item.name" :num-items="item.numItems" />
|
<cards-tag-search-card :tag="item.name" />
|
||||||
</nuxt-link>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<p v-if="genreResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelGenres }}</p>
|
|
||||||
<template v-for="item in genreResults">
|
|
||||||
<li :key="`genre.${item.name}`" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=genres.${$encode(item.name)}`">
|
|
||||||
<cards-genre-search-card :genre="item.name" :num-items="item.numItems" />
|
|
||||||
</nuxt-link>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<p v-if="narratorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelNarrators }}</p>
|
|
||||||
<template v-for="narrator in narratorResults">
|
|
||||||
<li :key="narrator.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
|
|
||||||
<cards-narrator-search-card :narrator="narrator.name" :num-books="narrator.numBooks" />
|
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
@@ -104,8 +84,6 @@ export default {
|
|||||||
authorResults: [],
|
authorResults: [],
|
||||||
seriesResults: [],
|
seriesResults: [],
|
||||||
tagResults: [],
|
tagResults: [],
|
||||||
genreResults: [],
|
|
||||||
narratorResults: [],
|
|
||||||
searchTimeout: null,
|
searchTimeout: null,
|
||||||
lastSearch: null
|
lastSearch: null
|
||||||
}
|
}
|
||||||
@@ -115,7 +93,7 @@ export default {
|
|||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
totalResults() {
|
totalResults() {
|
||||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.genreResults.length + this.podcastResults.length + this.narratorResults.length
|
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -126,7 +104,7 @@ export default {
|
|||||||
if (!this.search) return
|
if (!this.search) return
|
||||||
var search = this.search
|
var search = this.search
|
||||||
this.clearResults()
|
this.clearResults()
|
||||||
this.$router.push(`/library/${this.currentLibraryId}/search?q=${encodeURIComponent(search)}`)
|
this.$router.push(`/library/${this.currentLibraryId}/search?q=${search}`)
|
||||||
},
|
},
|
||||||
clearResults() {
|
clearResults() {
|
||||||
this.search = null
|
this.search = null
|
||||||
@@ -136,8 +114,6 @@ export default {
|
|||||||
this.authorResults = []
|
this.authorResults = []
|
||||||
this.seriesResults = []
|
this.seriesResults = []
|
||||||
this.tagResults = []
|
this.tagResults = []
|
||||||
this.genreResults = []
|
|
||||||
this.narratorResults = []
|
|
||||||
this.showMenu = false
|
this.showMenu = false
|
||||||
this.isFetching = false
|
this.isFetching = false
|
||||||
this.isTyping = false
|
this.isTyping = false
|
||||||
@@ -157,7 +133,7 @@ export default {
|
|||||||
clearTimeout(this.focusTimeout)
|
clearTimeout(this.focusTimeout)
|
||||||
this.focusTimeout = setTimeout(() => {
|
this.focusTimeout = setTimeout(() => {
|
||||||
this.showMenu = false
|
this.showMenu = false
|
||||||
}, 100)
|
}, 200)
|
||||||
},
|
},
|
||||||
async runSearch(value) {
|
async runSearch(value) {
|
||||||
this.lastSearch = value
|
this.lastSearch = value
|
||||||
@@ -166,7 +142,7 @@ export default {
|
|||||||
}
|
}
|
||||||
this.isFetching = true
|
this.isFetching = true
|
||||||
|
|
||||||
const searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${encodeURIComponent(value)}&limit=3`).catch((error) => {
|
var searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => {
|
||||||
console.error('Search error', error)
|
console.error('Search error', error)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
@@ -179,8 +155,6 @@ export default {
|
|||||||
this.authorResults = searchResults.authors || []
|
this.authorResults = searchResults.authors || []
|
||||||
this.seriesResults = searchResults.series || []
|
this.seriesResults = searchResults.series || []
|
||||||
this.tagResults = searchResults.tags || []
|
this.tagResults = searchResults.tags || []
|
||||||
this.genreResults = searchResults.genres || []
|
|
||||||
this.narratorResults = searchResults.narrators || []
|
|
||||||
|
|
||||||
this.isFetching = false
|
this.isFetching = false
|
||||||
if (!this.showMenu) {
|
if (!this.showMenu) {
|
||||||
@@ -211,8 +185,8 @@ export default {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style>
|
||||||
.globalSearchMenu {
|
.globalSearchMenu {
|
||||||
max-height: calc(100vh - 75px);
|
max-height: 80vh;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,61 +1,56 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||||
<div class="relative h-7">
|
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||||
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
|
<span class="flex items-center justify-between">
|
||||||
<span class="flex items-center justify-between">
|
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
</span>
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<button v-else :aria-label="$strings.ButtonClearFilter" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
||||||
<span class="material-symbols" style="font-size: 1.1rem">close</span>
|
<span class="material-icons" style="font-size: 1.1rem">close</span>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
|
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||||
<ul v-show="!sublist" class="h-full w-full" role="menu">
|
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in selectItems">
|
<template v-for="item in selectItems">
|
||||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" :aria-haspopup="item.sublist ? '' : 'menu'" @click="clickedOption(item)">
|
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
|
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
|
||||||
<span class="material-symbols text-2xl" :aria-label="$strings.LabelMore">arrow_right</span>
|
<span class="material-icons text-2xl">arrow_right</span>
|
||||||
</div>
|
|
||||||
<!-- selected checkmark icon -->
|
|
||||||
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
|
||||||
<span class="material-symbols text-base text-yellow-400">check</span>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
<ul v-show="sublist" class="h-full w-full" role="menu">
|
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="menuitem" @click="sublist = null">
|
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-black-400" role="option" @click="sublist = null">
|
||||||
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
||||||
<span class="material-symbols text-2xl">arrow_left</span>
|
<span class="material-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">{{ $strings.ButtonBack }}</span>
|
<span class="font-normal ml-3 block truncate">Back</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="menuitem">
|
<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">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span>
|
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('no-series'))">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">{{ $strings.MessageNoSeries }}</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<template v-for="item in sublistItems">
|
<template v-for="item in sublistItems">
|
||||||
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedSublistOption(item.value)">
|
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
|
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- selected checkmark icon -->
|
|
||||||
<div v-if="`${sublist}.${item.value}` === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
|
||||||
<span class="material-symbols text-base text-yellow-400">check</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -77,8 +72,9 @@ export default {
|
|||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
showMenu(newVal) {
|
showMenu(newVal) {
|
||||||
if (newVal) {
|
if (!newVal) {
|
||||||
this.sublist = this.selectedItemSublist
|
if (this.sublist && !this.selectedItemSublist) this.sublist = null
|
||||||
|
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -91,15 +87,15 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
userIsAdminOrUp() {
|
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
|
||||||
},
|
|
||||||
libraryMediaType() {
|
libraryMediaType() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
},
|
},
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.libraryMediaType === 'podcast'
|
return this.libraryMediaType === 'podcast'
|
||||||
},
|
},
|
||||||
|
isMusic() {
|
||||||
|
return this.libraryMediaType === 'music'
|
||||||
|
},
|
||||||
seriesItems() {
|
seriesItems() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -108,37 +104,26 @@ 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,
|
|
||||||
textPlural: this.$strings.LabelPublishers,
|
|
||||||
value: 'publishers',
|
|
||||||
sublist: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelLanguage,
|
text: this.$strings.LabelLanguage,
|
||||||
textPlural: this.$strings.LabelLanguages,
|
|
||||||
value: 'languages',
|
value: 'languages',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
@@ -150,56 +135,38 @@ export default {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
bookItems() {
|
bookItems() {
|
||||||
const items = [
|
return [
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelAll,
|
text: this.$strings.LabelAll,
|
||||||
value: 'all'
|
value: 'all'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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,
|
|
||||||
textPlural: this.$strings.LabelPublishers,
|
|
||||||
value: 'publishers',
|
|
||||||
sublist: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelPublishedDecade,
|
|
||||||
textPlural: this.$strings.LabelPublishedDecades,
|
|
||||||
value: 'publishedDecades',
|
|
||||||
sublist: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelLanguage,
|
text: this.$strings.LabelLanguage,
|
||||||
textPlural: this.$strings.LabelLanguages,
|
|
||||||
value: 'languages',
|
value: 'languages',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
@@ -218,16 +185,6 @@ export default {
|
|||||||
value: 'tracks',
|
value: 'tracks',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
text: this.$strings.LabelEbooks,
|
|
||||||
value: 'ebooks',
|
|
||||||
sublist: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelAbridged,
|
|
||||||
value: 'abridged',
|
|
||||||
sublist: false
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
text: this.$strings.ButtonIssues,
|
text: this.$strings.ButtonIssues,
|
||||||
value: 'issues',
|
value: 'issues',
|
||||||
@@ -239,14 +196,6 @@ export default {
|
|||||||
sublist: false
|
sublist: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
if (this.userIsAdminOrUp) {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.LabelShareOpen,
|
|
||||||
value: 'share-open',
|
|
||||||
sublist: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
},
|
},
|
||||||
podcastItems() {
|
podcastItems() {
|
||||||
return [
|
return [
|
||||||
@@ -256,20 +205,35 @@ 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,
|
text: this.$strings.ButtonIssues,
|
||||||
textPlural: this.$strings.LabelLanguages,
|
value: 'issues',
|
||||||
value: 'languages',
|
sublist: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
musicItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAll,
|
||||||
|
value: 'all'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelGenre,
|
||||||
|
value: 'genres',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelTag,
|
||||||
|
value: 'tags',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -282,35 +246,25 @@ export default {
|
|||||||
selectItems() {
|
selectItems() {
|
||||||
if (this.isSeries) return this.seriesItems
|
if (this.isSeries) return this.seriesItems
|
||||||
if (this.isPodcast) return this.podcastItems
|
if (this.isPodcast) return this.podcastItems
|
||||||
|
if (this.isMusic) return this.musicItems
|
||||||
return this.bookItems
|
return this.bookItems
|
||||||
},
|
},
|
||||||
selectedItemSublist() {
|
selectedItemSublist() {
|
||||||
return this.selected?.includes('.') ? this.selected.split('.')[0] : null
|
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
|
||||||
},
|
|
||||||
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('.')
|
var parts = this.selected.split('.')
|
||||||
const filterName = this.selectItems.find((i) => i.value === parts[0])
|
var filterName = this.selectItems.find((i) => i.value === parts[0])
|
||||||
let filterValue = null
|
var filterValue = null
|
||||||
if (parts.length > 1) {
|
if (parts.length > 1) {
|
||||||
const decoded = this.$decode(parts[1])
|
var decoded = this.$decode(parts[1])
|
||||||
if (parts[0] === 'authors') {
|
if (decoded.startsWith('aut_')) {
|
||||||
const author = this.authors.find((au) => au.id == decoded)
|
var author = this.authors.find((au) => au.id == decoded)
|
||||||
if (author) filterValue = author.name
|
if (author) filterValue = author.name
|
||||||
} else if (parts[0] === 'series') {
|
} else if (decoded.startsWith('ser_')) {
|
||||||
if (decoded === 'no-series') {
|
var series = this.series.find((se) => se.id == decoded)
|
||||||
filterValue = this.$strings.MessageNoSeries
|
if (series) filterValue = series.name
|
||||||
} else {
|
|
||||||
const series = this.series.find((se) => se.id == decoded)
|
|
||||||
if (series) filterValue = series.name
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
filterValue = decoded
|
filterValue = decoded
|
||||||
}
|
}
|
||||||
@@ -343,12 +297,6 @@ export default {
|
|||||||
languages() {
|
languages() {
|
||||||
return this.filterData.languages || []
|
return this.filterData.languages || []
|
||||||
},
|
},
|
||||||
publishers() {
|
|
||||||
return this.filterData.publishers || []
|
|
||||||
},
|
|
||||||
publishedDecades() {
|
|
||||||
return this.filterData.publishedDecades || []
|
|
||||||
},
|
|
||||||
progress() {
|
progress() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -371,10 +319,6 @@ export default {
|
|||||||
},
|
},
|
||||||
tracks() {
|
tracks() {
|
||||||
return [
|
return [
|
||||||
{
|
|
||||||
id: 'none',
|
|
||||||
name: this.$strings.LabelTracksNone
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'single',
|
id: 'single',
|
||||||
name: this.$strings.LabelTracksSingleTrack
|
name: this.$strings.LabelTracksSingleTrack
|
||||||
@@ -385,26 +329,6 @@ export default {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
ebooks() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'ebook',
|
|
||||||
name: this.$strings.LabelHasEbook
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'no-ebook',
|
|
||||||
name: this.$strings.LabelMissingEbook
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'supplementary',
|
|
||||||
name: this.$strings.LabelHasSupplementaryEbook
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'no-supplementary',
|
|
||||||
name: this.$strings.LabelMissingSupplementaryEbook
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
missing() {
|
missing() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -415,17 +339,21 @@ export default {
|
|||||||
id: 'isbn',
|
id: 'isbn',
|
||||||
name: 'ISBN'
|
name: 'ISBN'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'subtitle',
|
||||||
|
name: this.$strings.LabelSubtitle
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'authors',
|
id: 'authors',
|
||||||
name: this.$strings.LabelAuthor
|
name: this.$strings.LabelAuthor
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'chapters',
|
id: 'publishedYear',
|
||||||
name: this.$strings.LabelChapters
|
name: this.$strings.LabelPublishYear
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cover',
|
id: 'series',
|
||||||
name: this.$strings.LabelCover
|
name: this.$strings.LabelSeries
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'description',
|
id: 'description',
|
||||||
@@ -436,37 +364,29 @@ export default {
|
|||||||
name: this.$strings.LabelGenres
|
name: this.$strings.LabelGenres
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'language',
|
id: 'tags',
|
||||||
name: this.$strings.LabelLanguage
|
name: this.$strings.LabelTags
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'narrators',
|
id: 'narrators',
|
||||||
name: this.$strings.LabelNarrator
|
name: this.$strings.LabelNarrator
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'publishedYear',
|
|
||||||
name: this.$strings.LabelPublishYear
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'publisher',
|
id: 'publisher',
|
||||||
name: this.$strings.LabelPublisher
|
name: this.$strings.LabelPublisher
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'series',
|
id: 'language',
|
||||||
name: this.$strings.LabelSeries
|
name: this.$strings.LabelLanguage
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'subtitle',
|
id: 'cover',
|
||||||
name: this.$strings.LabelSubtitle
|
name: this.$strings.LabelCover
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tags',
|
|
||||||
name: this.$strings.LabelTags
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
sublistItems() {
|
sublistItems() {
|
||||||
const sublistItems = (this[this.sublist] || []).map((item) => {
|
return (this[this.sublist] || []).map((item) => {
|
||||||
if (typeof item === 'string') {
|
if (typeof item === 'string') {
|
||||||
return {
|
return {
|
||||||
text: item,
|
text: item,
|
||||||
@@ -479,13 +399,6 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (this.sublist === 'series') {
|
|
||||||
sublistItems.unshift({
|
|
||||||
text: this.$strings.MessageNoSeries,
|
|
||||||
value: this.$encode('no-series')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return sublistItems
|
|
||||||
},
|
},
|
||||||
filterData() {
|
filterData() {
|
||||||
return this.$store.state.libraries.filterData || {}
|
return this.$store.state.libraries.filterData || {}
|
||||||
@@ -510,7 +423,7 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const val = option.value
|
var val = option.value
|
||||||
if (this.selected === val) {
|
if (this.selected === val) {
|
||||||
this.showMenu = false
|
this.showMenu = false
|
||||||
return
|
return
|
||||||
@@ -521,10 +434,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.libraryFilterMenu {
|
|
||||||
max-height: calc(100vh - 125px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
|
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||||
<span class="flex items-center justify-between">
|
<span class="flex items-center justify-between">
|
||||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||||
<span class="material-symbols text-lg text-yellow-400" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="menu">
|
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 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 selectItems">
|
<template v-for="item in selectItems">
|
||||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)">
|
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||||
<span class="material-symbols text-xl" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
@@ -56,6 +56,9 @@ export default {
|
|||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.libraryMediaType === 'podcast'
|
return this.libraryMediaType === 'podcast'
|
||||||
},
|
},
|
||||||
|
isMusic() {
|
||||||
|
return this.libraryMediaType === 'music'
|
||||||
|
},
|
||||||
podcastItems() {
|
podcastItems() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -85,10 +88,6 @@ export default {
|
|||||||
{
|
{
|
||||||
text: this.$strings.LabelFileModified,
|
text: this.$strings.LabelFileModified,
|
||||||
value: 'mtimeMs'
|
value: 'mtimeMs'
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelRandomly,
|
|
||||||
value: 'random'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -129,10 +128,6 @@ export default {
|
|||||||
{
|
{
|
||||||
text: this.$strings.LabelFileModified,
|
text: this.$strings.LabelFileModified,
|
||||||
value: 'mtimeMs'
|
value: 'mtimeMs'
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelRandomly,
|
|
||||||
value: 'random'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -145,10 +140,40 @@ export default {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
musicItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelTitle,
|
||||||
|
value: 'media.metadata.title'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAddedAt,
|
||||||
|
value: 'addedAt'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelSize,
|
||||||
|
value: 'size'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelDuration,
|
||||||
|
value: 'media.duration'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelFileBirthtime,
|
||||||
|
value: 'birthtimeMs'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelFileModified,
|
||||||
|
value: 'mtimeMs'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
selectItems() {
|
selectItems() {
|
||||||
let items = null
|
let items = null
|
||||||
if (this.isPodcast) {
|
if (this.isPodcast) {
|
||||||
items = this.podcastItems
|
items = this.podcastItems
|
||||||
|
} else if (this.isMusic) {
|
||||||
|
items = this.musicItems
|
||||||
} else if (this.$store.getters['user/getUserSetting']('filterBy').startsWith('series.')) {
|
} else if (this.$store.getters['user/getUserSetting']('filterBy').startsWith('series.')) {
|
||||||
items = this.seriesItems
|
items = this.seriesItems
|
||||||
} else {
|
} else {
|
||||||
@@ -190,4 +215,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="relative ml-4 sm:ml-8" v-click-outside="clickOutside">
|
<div ref="wrapper" class="relative ml-4 sm:ml-8" v-click-outside="clickOutside">
|
||||||
<div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)">
|
<div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)">
|
||||||
<span class="text-gray-200 text-sm sm:text-base">{{ playbackRateDisplay }}<span class="text-base">x</span></span>
|
<span class="font-mono uppercase text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base">x</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="showMenu" class="absolute -top-[5.5rem] z-20 bg-bg border-black-200 border shadow-xl rounded-lg" :style="{ left: menuLeft + 'px' }">
|
<div v-show="showMenu" class="absolute -top-20 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" :style="{ left: menuLeft + 'px' }">
|
||||||
<div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }">
|
<div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }">
|
||||||
<div class="arrow-down" />
|
<div class="arrow-down" />
|
||||||
</div>
|
</div>
|
||||||
@@ -11,15 +11,15 @@
|
|||||||
<template v-for="rate in rates">
|
<template v-for="rate in rates">
|
||||||
<div :key="rate" class="h-full border-black-300 w-11 cursor-pointer border rounded-sm" :class="value === rate ? 'bg-black-100' : 'hover:bg-black hover:bg-opacity-10'" style="min-width: 44px; max-width: 44px" @click="set(rate)">
|
<div :key="rate" class="h-full border-black-300 w-11 cursor-pointer border rounded-sm" :class="value === rate ? 'bg-black-100' : 'hover:bg-black hover:bg-opacity-10'" style="min-width: 44px; max-width: 44px" @click="set(rate)">
|
||||||
<div class="w-full h-full flex justify-center items-center">
|
<div class="w-full h-full flex justify-center items-center">
|
||||||
<p class="text-xs text-center">{{ rate }}<span class="text-sm">x</span></p>
|
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm">x</span></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full py-1 px-1">
|
<div class="w-full py-1 px-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" />
|
<ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" />
|
||||||
<p class="px-2 text-2xl sm:text-3xl">{{ playbackRateDisplay }}<span class="text-2xl">x</span></p>
|
<p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl">x</span></p>
|
||||||
<ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" />
|
<ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,10 +33,6 @@ export default {
|
|||||||
value: {
|
value: {
|
||||||
type: [String, Number],
|
type: [String, Number],
|
||||||
default: 1
|
default: 1
|
||||||
},
|
|
||||||
playbackRateIncrementDecrement: {
|
|
||||||
type: Number,
|
|
||||||
default: 0.1
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -45,7 +41,7 @@ export default {
|
|||||||
currentPlaybackRate: 0,
|
currentPlaybackRate: 0,
|
||||||
MIN_SPEED: 0.5,
|
MIN_SPEED: 0.5,
|
||||||
MAX_SPEED: 10,
|
MAX_SPEED: 10,
|
||||||
menuLeft: -96,
|
menuLeft: -92,
|
||||||
arrowLeft: 0
|
arrowLeft: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -62,17 +58,10 @@ export default {
|
|||||||
return [0.5, 1, 1.2, 1.5, 2]
|
return [0.5, 1, 1.2, 1.5, 2]
|
||||||
},
|
},
|
||||||
canIncrement() {
|
canIncrement() {
|
||||||
return this.playbackRate + this.playbackRateIncrementDecrement <= this.MAX_SPEED
|
return this.playbackRate + 0.1 <= this.MAX_SPEED
|
||||||
},
|
},
|
||||||
canDecrement() {
|
canDecrement() {
|
||||||
return this.playbackRate - this.playbackRateIncrementDecrement >= this.MIN_SPEED
|
return this.playbackRate - 0.1 >= this.MIN_SPEED
|
||||||
},
|
|
||||||
playbackRateDisplay() {
|
|
||||||
if (this.playbackRateIncrementDecrement == 0.05) return this.playbackRate.toFixed(2)
|
|
||||||
// For 0.1 increment: Only show 2 decimal places if the playback rate is 2 decimals
|
|
||||||
const numDecimals = String(this.playbackRate).split('.')[1]?.length || 0
|
|
||||||
if (numDecimals <= 1) return this.playbackRate.toFixed(1)
|
|
||||||
return this.playbackRate.toFixed(2)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -84,14 +73,14 @@ export default {
|
|||||||
this.$nextTick(() => this.setShowMenu(false))
|
this.$nextTick(() => this.setShowMenu(false))
|
||||||
},
|
},
|
||||||
increment() {
|
increment() {
|
||||||
if (this.playbackRate + this.playbackRateIncrementDecrement > this.MAX_SPEED) return
|
if (this.playbackRate + 0.1 > this.MAX_SPEED) return
|
||||||
var newPlaybackRate = this.playbackRate + this.playbackRateIncrementDecrement
|
var newPlaybackRate = this.playbackRate + 0.1
|
||||||
this.playbackRate = Number(newPlaybackRate.toFixed(2))
|
this.playbackRate = Number(newPlaybackRate.toFixed(1))
|
||||||
},
|
},
|
||||||
decrement() {
|
decrement() {
|
||||||
if (this.playbackRate - this.playbackRateIncrementDecrement < this.MIN_SPEED) return
|
if (this.playbackRate - 0.1 < this.MIN_SPEED) return
|
||||||
var newPlaybackRate = this.playbackRate - this.playbackRateIncrementDecrement
|
var newPlaybackRate = this.playbackRate - 0.1
|
||||||
this.playbackRate = Number(newPlaybackRate.toFixed(2))
|
this.playbackRate = Number(newPlaybackRate.toFixed(1))
|
||||||
},
|
},
|
||||||
updateMenuPositions() {
|
updateMenuPositions() {
|
||||||
if (!this.$refs.wrapper) return
|
if (!this.$refs.wrapper) return
|
||||||
@@ -100,9 +89,9 @@ export default {
|
|||||||
if (boundingBox.left + 110 > window.innerWidth - 10) {
|
if (boundingBox.left + 110 > window.innerWidth - 10) {
|
||||||
this.menuLeft = window.innerWidth - 230 - boundingBox.left
|
this.menuLeft = window.innerWidth - 230 - boundingBox.left
|
||||||
|
|
||||||
this.arrowLeft = Math.abs(this.menuLeft) - 96
|
this.arrowLeft = Math.abs(this.menuLeft) - 92
|
||||||
} else {
|
} else {
|
||||||
this.menuLeft = -96
|
this.menuLeft = -92
|
||||||
this.arrowLeft = 0
|
this.arrowLeft = 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -120,4 +109,4 @@ export default {
|
|||||||
this.currentPlaybackRate = this.playbackRate
|
this.currentPlaybackRate = this.playbackRate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||||
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="menu" :aria-expanded="showMenu" @click.prevent="showMenu = !showMenu">
|
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||||
<span class="flex items-center justify-between">
|
<span class="flex items-center justify-between">
|
||||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||||
<span class="material-symbols text-lg text-yellow-400" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="menu">
|
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 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 items">
|
<template v-for="item in items">
|
||||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)">
|
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||||
<span class="material-symbols text-xl" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
|
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<button :aria-label="$strings.LabelVolume" class="text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
<div class="cursor-pointer text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
||||||
<span class="material-symbols text-2xl sm:text-3xl">{{ volumeIcon }}</span>
|
<span class="material-icons text-2xl sm:text-3xl">{{ volumeIcon }}</span>
|
||||||
</button>
|
</div>
|
||||||
<transition name="menux">
|
<transition name="menux">
|
||||||
<div v-show="isOpen" class="volumeMenu h-28 absolute bottom-2 w-6 py-2 bg-bg shadow-sm rounded-lg" style="top: -116px">
|
<div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px">
|
||||||
<div ref="volumeTrack" class="w-1 h-full bg-gray-500 mx-2.5 relative cursor-pointer rounded-full" @mousedown="mousedownTrack" @click="clickVolumeTrack">
|
<div ref="volumeTrack" class="h-1 w-full bg-gray-500 my-2.5 relative cursor-pointer rounded-full" @mousedown="mousedownTrack" @click="clickVolumeTrack">
|
||||||
<div class="bg-gray-100 w-full absolute left-0 bottom-0 pointer-events-none rounded-full" :style="{ height: volume * trackHeight + 'px' }" />
|
<div class="bg-gray-100 h-full absolute left-0 top-0 pointer-events-none rounded-full" :style="{ width: volume * trackWidth + 'px' }" />
|
||||||
<div class="w-2.5 h-2.5 bg-white shadow-sm rounded-full absolute pointer-events-none" :class="isDragging ? 'transform scale-125 origin-center' : ''" :style="{ bottom: cursorBottom + 'px', left: '-3px' }" />
|
<div class="w-2.5 h-2.5 bg-white shadow-sm rounded-full absolute pointer-events-none" :class="isDragging ? 'transform scale-125 origin-center' : ''" :style="{ left: cursorLeft + 'px', top: '-3px' }" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
@@ -24,10 +24,10 @@ export default {
|
|||||||
isOpen: false,
|
isOpen: false,
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
isHovering: false,
|
isHovering: false,
|
||||||
posY: 0,
|
posX: 0,
|
||||||
lastValue: 0.5,
|
lastValue: 0.5,
|
||||||
isMute: false,
|
isMute: false,
|
||||||
trackHeight: 112 - 20,
|
trackWidth: 112 - 20,
|
||||||
openTimeout: null
|
openTimeout: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -38,16 +38,16 @@ export default {
|
|||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('volume', val)
|
localStorage.setItem("volume", val);
|
||||||
} catch (error) {
|
} catch(error) {
|
||||||
console.error('Failed to store volume', err)
|
console.error('Failed to store volume', err)
|
||||||
}
|
}
|
||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
cursorBottom() {
|
cursorLeft() {
|
||||||
var bottom = this.trackHeight * this.volume
|
var left = this.trackWidth * this.volume
|
||||||
return bottom - 3
|
return left - 3
|
||||||
},
|
},
|
||||||
volumeIcon() {
|
volumeIcon() {
|
||||||
if (this.volume <= 0) return 'volume_mute'
|
if (this.volume <= 0) return 'volume_mute'
|
||||||
@@ -89,10 +89,17 @@ export default {
|
|||||||
}, 600)
|
}, 600)
|
||||||
},
|
},
|
||||||
mousemove(e) {
|
mousemove(e) {
|
||||||
var diff = this.posY - e.y
|
var diff = this.posX - e.x
|
||||||
this.posY = e.y
|
this.posX = e.x
|
||||||
var volShift = diff / this.trackHeight
|
var volShift = 0
|
||||||
var newVol = this.volume + volShift
|
if (diff < 0) {
|
||||||
|
// Volume up
|
||||||
|
volShift = diff / this.trackWidth
|
||||||
|
} else {
|
||||||
|
// volume down
|
||||||
|
volShift = diff / this.trackWidth
|
||||||
|
}
|
||||||
|
var newVol = this.volume - volShift
|
||||||
newVol = Math.min(Math.max(0, newVol), 1)
|
newVol = Math.min(Math.max(0, newVol), 1)
|
||||||
this.volume = newVol
|
this.volume = newVol
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -106,8 +113,8 @@ export default {
|
|||||||
},
|
},
|
||||||
mousedownTrack(e) {
|
mousedownTrack(e) {
|
||||||
this.isDragging = true
|
this.isDragging = true
|
||||||
this.posY = e.y
|
this.posX = e.x
|
||||||
var vol = 1 - e.offsetY / this.trackHeight
|
var vol = e.offsetX / this.trackWidth
|
||||||
vol = Math.min(Math.max(vol, 0), 1)
|
vol = Math.min(Math.max(vol, 0), 1)
|
||||||
this.volume = vol
|
this.volume = vol
|
||||||
document.body.addEventListener('mousemove', this.mousemove)
|
document.body.addEventListener('mousemove', this.mousemove)
|
||||||
@@ -130,7 +137,7 @@ export default {
|
|||||||
this.clickVolumeIcon()
|
this.clickVolumeIcon()
|
||||||
},
|
},
|
||||||
clickVolumeTrack(e) {
|
clickVolumeTrack(e) {
|
||||||
var vol = 1 - e.offsetY / this.trackHeight
|
var vol = e.offsetX / this.trackWidth
|
||||||
vol = Math.min(Math.max(vol, 0), 1)
|
vol = Math.min(Math.max(vol, 0), 1)
|
||||||
this.volume = vol
|
this.volume = vol
|
||||||
}
|
}
|
||||||
@@ -139,8 +146,8 @@ export default {
|
|||||||
if (this.value === 0) {
|
if (this.value === 0) {
|
||||||
this.isMute = true
|
this.isMute = true
|
||||||
}
|
}
|
||||||
const storageVolume = localStorage.getItem('volume')
|
const storageVolume = localStorage.getItem("volume")
|
||||||
if (storageVolume && !isNaN(storageVolume)) {
|
if (storageVolume) {
|
||||||
this.volume = parseFloat(storageVolume)
|
this.volume = parseFloat(storageVolume)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -150,4 +157,4 @@ export default {
|
|||||||
document.body.removeEventListener('mouseup', this.mouseup)
|
document.body.removeEventListener('mouseup', this.mouseup)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -56,15 +56,24 @@ export default {
|
|||||||
},
|
},
|
||||||
imgSrc() {
|
imgSrc() {
|
||||||
if (!this.imagePath) return null
|
if (!this.imagePath) return null
|
||||||
return `${this.$config.routerBasePath}/api/authors/${this.authorId}/image?ts=${this.updatedAt}`
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
// Testing
|
||||||
|
return `http://localhost:3333${this.$config.routerBasePath}/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||||
|
}
|
||||||
|
return `/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
imageLoaded() {
|
imageLoaded() {
|
||||||
|
var aspectRatio = 1.25
|
||||||
|
if (this.$refs.wrapper) {
|
||||||
|
aspectRatio = this.$refs.wrapper.clientHeight / this.$refs.wrapper.clientWidth
|
||||||
|
}
|
||||||
if (this.$refs.img) {
|
if (this.$refs.img) {
|
||||||
var { naturalWidth, naturalHeight } = this.$refs.img
|
var { naturalWidth, naturalHeight } = this.$refs.img
|
||||||
var imgAr = naturalHeight / naturalWidth
|
var imgAr = naturalHeight / naturalWidth
|
||||||
if (imgAr < 0.5 || imgAr > 2) {
|
var arDiff = Math.abs(imgAr - aspectRatio)
|
||||||
|
if (arDiff > 0.15) {
|
||||||
this.showCoverBg = true
|
this.showCoverBg = true
|
||||||
} else {
|
} else {
|
||||||
this.showCoverBg = false
|
this.showCoverBg = false
|
||||||
@@ -75,4 +84,4 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -5,8 +5,7 @@
|
|||||||
<div class="absolute cover-bg" ref="coverBg" />
|
<div class="absolute cover-bg" ref="coverBg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" draggable="false" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" @click="clickCover" />
|
<img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
||||||
|
|
||||||
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
|
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
|
||||||
<p class="text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
<p class="text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||||
<div class="absolute top-2 right-2">
|
<div class="absolute top-2 right-2">
|
||||||
@@ -44,7 +43,6 @@ export default {
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 120
|
default: 120
|
||||||
},
|
},
|
||||||
expandOnClick: Boolean,
|
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -101,14 +99,9 @@ export default {
|
|||||||
},
|
},
|
||||||
fullCoverUrl() {
|
fullCoverUrl() {
|
||||||
if (!this.libraryItem) return null
|
if (!this.libraryItem) return null
|
||||||
const store = this.$store || this.$nuxt.$store
|
var 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
|
||||||
},
|
},
|
||||||
@@ -131,16 +124,14 @@ 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`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickCover() {
|
|
||||||
if (this.expandOnClick && this.libraryItem) {
|
|
||||||
this.$store.commit('globals/setRawCoverPreviewModal', this.rawCoverUrl)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setCoverBg() {
|
setCoverBg() {
|
||||||
if (this.$refs.coverBg) {
|
if (this.$refs.coverBg) {
|
||||||
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
|
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
|
||||||
|
|||||||
@@ -121,8 +121,6 @@ export default {
|
|||||||
|
|
||||||
var img = document.createElement('img')
|
var img = document.createElement('img')
|
||||||
img.src = src
|
img.src = src
|
||||||
img.alt = `${this.name}, ${this.$strings.LabelCover}`
|
|
||||||
img.ariaHidden = true
|
|
||||||
img.className = 'absolute top-0 left-0 w-full h-full'
|
img.className = 'absolute top-0 left-0 w-full h-full'
|
||||||
img.style.objectFit = showCoverBg ? 'contain' : 'cover'
|
img.style.objectFit = showCoverBg ? 'contain' : 'cover'
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,14 @@
|
|||||||
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
||||||
|
|
||||||
<a v-if="!imageFailed && showOpenNewTab && isHovering" :href="cover" @click.stop target="_blank" class="absolute bg-primary flex items-center justify-center shadow-sm rounded-full hover:scale-110 transform duration-100" :style="{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }">
|
<a v-if="!imageFailed && showOpenNewTab && isHovering" :href="cover" @click.stop target="_blank" class="absolute bg-primary flex items-center justify-center shadow-sm rounded-full hover:scale-110 transform duration-100" :style="{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }">
|
||||||
<span class="material-symbols" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
|
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||||
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
|
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
|
||||||
<img v-if="width > 100" src="/Logo.png" class="mb-2" :style="{ height: 40 * sizeMultiplier + 'px' }" />
|
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
||||||
<p class="text-center text-error" :style="{ fontSize: invalidCoverFontSize + 'rem' }">Invalid Cover</p>
|
<p class="text-center text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -58,14 +58,11 @@ export default {
|
|||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
return this.width / 120
|
return this.width / 120
|
||||||
},
|
},
|
||||||
invalidCoverFontSize() {
|
|
||||||
return Math.max(this.sizeMultiplier * 0.8, 0.5)
|
|
||||||
},
|
|
||||||
placeholderCoverPadding() {
|
placeholderCoverPadding() {
|
||||||
return 0.8 * this.sizeMultiplier
|
return 0.8 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
resolution() {
|
resolution() {
|
||||||
return `${this.naturalWidth}×${this.naturalHeight}px`
|
return `${this.naturalWidth}x${this.naturalHeight}px`
|
||||||
},
|
},
|
||||||
placeholderUrl() {
|
placeholderUrl() {
|
||||||
const config = this.$config || this.$nuxt.$config
|
const config = this.$config || this.$nuxt.$config
|
||||||
|
|||||||
@@ -6,25 +6,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="submitForm">
|
||||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
|
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||||
<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.trim="newUser.username" :label="$strings.LabelUsername" />
|
<ui-text-input-with-label v-model="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.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">
|
|
||||||
<ui-text-input-with-label v-model.trim="newUser.email" :label="$strings.LabelEmail" />
|
|
||||||
</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" @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" />
|
||||||
@@ -69,15 +65,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center my-2 max-w-md">
|
|
||||||
<div class="w-1/2">
|
|
||||||
<p id="ereader-permissions-toggle">{{ $strings.LabelPermissionsCreateEreader }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="w-1/2">
|
|
||||||
<ui-toggle-switch labeledBy="ereader-permissions-toggle" v-model="newUser.permissions.createEreader" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center my-2 max-w-md">
|
<div class="flex items-center my-2 max-w-md">
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<p id="explicit-content-permissions-toggle">{{ $strings.LabelPermissionsAccessExplicitContent }}</p>
|
<p id="explicit-content-permissions-toggle">{{ $strings.LabelPermissionsAccessExplicitContent }}</p>
|
||||||
@@ -109,19 +96,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
||||||
<div class="flex items-center">
|
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" :label="$strings.LabelTagsAccessibleToUser" />
|
||||||
<ui-multi-select-dropdown v-model="newUser.itemTagsSelected" :items="itemTags" :label="tagsSelectionText" />
|
|
||||||
<div class="flex items-center pt-4 px-2">
|
|
||||||
<p class="px-3 font-semibold" id="selected-tags-not-accessible--permissions-toggle">{{ $strings.LabelInvert }}</p>
|
|
||||||
<ui-toggle-switch labeledBy="selected-tags-not-accessible--permissions-toggle" v-model="newUser.permissions.selectedTagsNotAccessible" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex pt-4 px-2">
|
<div class="flex pt-4 px-2">
|
||||||
<ui-btn v-if="hasOpenIDLink" small :loading="unlinkingFromOpenID" color="primary" type="button" class="mr-2" @click.stop="unlinkOpenID">{{ $strings.ButtonUnlinkOpenId }}</ui-btn>
|
<ui-btn v-if="isEditingRoot" to="/account">{{ $strings.ButtonChangeRootPassword }}</ui-btn>
|
||||||
<ui-btn v-if="isEditingRoot" small class="flex items-center" to="/account">{{ $strings.ButtonChangeRootPassword }}</ui-btn>
|
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,8 +126,7 @@ export default {
|
|||||||
newUser: {},
|
newUser: {},
|
||||||
isNew: true,
|
isNew: true,
|
||||||
tags: [],
|
tags: [],
|
||||||
loadingTags: false,
|
loadingTags: false
|
||||||
unlinkingFromOpenID: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -191,7 +170,7 @@ export default {
|
|||||||
return this.isNew ? this.$strings.HeaderNewAccount : this.$strings.HeaderUpdateAccount
|
return this.isNew ? this.$strings.HeaderNewAccount : this.$strings.HeaderUpdateAccount
|
||||||
},
|
},
|
||||||
isEditingRoot() {
|
isEditingRoot() {
|
||||||
return this.account?.type === 'root'
|
return this.account && this.account.type === 'root'
|
||||||
},
|
},
|
||||||
libraries() {
|
libraries() {
|
||||||
return this.$store.state.libraries.libraries
|
return this.$store.state.libraries.libraries
|
||||||
@@ -206,12 +185,6 @@ export default {
|
|||||||
value: t
|
value: t
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
|
||||||
tagsSelectionText() {
|
|
||||||
return this.newUser.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
|
|
||||||
},
|
|
||||||
hasOpenIDLink() {
|
|
||||||
return !!this.account?.hasOpenIDLink
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -219,37 +192,9 @@ export default {
|
|||||||
// Force close when navigating - used in UsersTable
|
// Force close when navigating - used in UsersTable
|
||||||
if (this.$refs.modal) this.$refs.modal.setHide()
|
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||||
},
|
},
|
||||||
unlinkOpenID() {
|
|
||||||
const payload = {
|
|
||||||
message: this.$strings.MessageConfirmUnlinkOpenId,
|
|
||||||
callback: (confirmed) => {
|
|
||||||
if (confirmed) {
|
|
||||||
this.unlinkingFromOpenID = true
|
|
||||||
this.$axios
|
|
||||||
.$patch(`/api/users/${this.account.id}/openid-unlink`)
|
|
||||||
.then(() => {
|
|
||||||
this.$toast.success(this.$strings.ToastUnlinkOpenIdSuccess)
|
|
||||||
this.show = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to unlink user from OpenID', error)
|
|
||||||
this.$toast.error(this.$strings.ToastUnlinkOpenIdFailed)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.unlinkingFromOpenID = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'yesNo'
|
|
||||||
}
|
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
|
||||||
accessAllTagsToggled(val) {
|
accessAllTagsToggled(val) {
|
||||||
if (val) {
|
if (val && this.newUser.itemTagsAccessible.length) {
|
||||||
if (this.newUser.itemTagsSelected?.length) {
|
this.newUser.itemTagsAccessible = []
|
||||||
this.newUser.itemTagsSelected = []
|
|
||||||
}
|
|
||||||
this.newUser.permissions.selectedTagsNotAccessible = false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fetchAllTags() {
|
fetchAllTags() {
|
||||||
@@ -274,15 +219,15 @@ export default {
|
|||||||
},
|
},
|
||||||
submitForm() {
|
submitForm() {
|
||||||
if (!this.newUser.username) {
|
if (!this.newUser.username) {
|
||||||
this.$toast.error(this.$strings.ToastNewUserUsernameError)
|
this.$toast.error('Enter a username')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!this.newUser.permissions.accessAllLibraries && !this.newUser.librariesAccessible.length) {
|
if (!this.newUser.permissions.accessAllLibraries && !this.newUser.librariesAccessible.length) {
|
||||||
this.$toast.error(this.$strings.ToastNewUserLibraryError)
|
this.$toast.error('Must select at least one library')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsSelected.length) {
|
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsAccessible.length) {
|
||||||
this.$toast.error(this.$strings.ToastNewUserTagError)
|
this.$toast.error('Must select at least one tag')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,12 +245,13 @@ export default {
|
|||||||
if (account.type === 'root' && !account.isActive) return
|
if (account.type === 'root' && !account.isActive) return
|
||||||
|
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
console.log('Calling update', account)
|
||||||
this.$axios
|
this.$axios
|
||||||
.$patch(`/api/users/${this.account.id}`, account)
|
.$patch(`/api/users/${this.account.id}`, account)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
this.$toast.error(`${this.$strings.ToastFailedToUpdate}: ${data.error}`)
|
this.$toast.error(`${this.$strings.ToastAccountUpdateFailed}: ${data.error}`)
|
||||||
} else {
|
} else {
|
||||||
console.log('Account updated', data.user)
|
console.log('Account updated', data.user)
|
||||||
|
|
||||||
@@ -322,12 +268,12 @@ export default {
|
|||||||
this.processing = false
|
this.processing = false
|
||||||
console.error('Failed to update account', error)
|
console.error('Failed to update account', error)
|
||||||
var errMsg = error.response ? error.response.data || '' : ''
|
var errMsg = error.response ? error.response.data || '' : ''
|
||||||
this.$toast.error(errMsg || this.$strings.ToastFailedToUpdate)
|
this.$toast.error(errMsg || 'Failed to update account')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
submitCreateAccount() {
|
submitCreateAccount() {
|
||||||
if (!this.newUser.password) {
|
if (!this.newUser.password) {
|
||||||
this.$toast.error(this.$strings.ToastNewUserPasswordError)
|
this.$toast.error('Must have a password, only root user can have an empty password')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,9 +284,9 @@ export default {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
this.$toast.error(this.$strings.ToastNewUserCreatedFailed + ': ' + data.error)
|
this.$toast.error(`Failed to create account: ${data.error}`)
|
||||||
} else {
|
} else {
|
||||||
this.$toast.success(this.$strings.ToastNewUserCreatedSuccess)
|
this.$toast.success('New account created')
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -360,32 +306,27 @@ export default {
|
|||||||
update: type === 'admin',
|
update: type === 'admin',
|
||||||
delete: type === 'admin',
|
delete: type === 'admin',
|
||||||
upload: type === 'admin',
|
upload: type === 'admin',
|
||||||
accessExplicitContent: type === 'admin',
|
|
||||||
accessAllLibraries: true,
|
accessAllLibraries: true,
|
||||||
accessAllTags: true,
|
accessAllTags: true
|
||||||
selectedTagsNotAccessible: false,
|
|
||||||
createEreader: type === 'admin'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.fetchAllTags()
|
this.fetchAllTags()
|
||||||
this.isNew = !this.account
|
|
||||||
|
|
||||||
|
this.isNew = !this.account
|
||||||
if (this.account) {
|
if (this.account) {
|
||||||
this.newUser = {
|
this.newUser = {
|
||||||
username: this.account.username,
|
username: this.account.username,
|
||||||
email: this.account.email,
|
|
||||||
password: this.account.password,
|
password: this.account.password,
|
||||||
type: this.account.type,
|
type: this.account.type,
|
||||||
isActive: this.account.isActive,
|
isActive: this.account.isActive,
|
||||||
permissions: { ...this.account.permissions },
|
permissions: { ...this.account.permissions },
|
||||||
librariesAccessible: [...(this.account.librariesAccessible || [])],
|
librariesAccessible: [...(this.account.librariesAccessible || [])],
|
||||||
itemTagsSelected: [...(this.account.itemTagsSelected || [])]
|
itemTagsAccessible: [...(this.account.itemTagsAccessible || [])]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.newUser = {
|
this.newUser = {
|
||||||
username: null,
|
username: null,
|
||||||
email: null,
|
|
||||||
password: null,
|
password: null,
|
||||||
type: 'user',
|
type: 'user',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -395,13 +336,9 @@ export default {
|
|||||||
delete: false,
|
delete: false,
|
||||||
upload: false,
|
upload: false,
|
||||||
accessAllLibraries: true,
|
accessAllLibraries: true,
|
||||||
accessAllTags: true,
|
accessAllTags: true
|
||||||
accessExplicitContent: false,
|
|
||||||
selectedTagsNotAccessible: false,
|
|
||||||
createEreader: false
|
|
||||||
},
|
},
|
||||||
librariesAccessible: [],
|
librariesAccessible: []
|
||||||
itemTagsSelected: []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
<template>
|
|
||||||
<modals-modal ref="modal" v-model="show" name="custom-metadata-provider" :width="600" :height="'unset'" :processing="processing">
|
|
||||||
<template #outer>
|
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
|
||||||
<p class="text-3xl text-white truncate">{{ $strings.HeaderAddCustomMetadataProvider }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<form @submit.prevent="submitForm">
|
|
||||||
<div class="px-4 w-full flex items-center text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
|
|
||||||
<div class="w-full p-8">
|
|
||||||
<div class="flex mb-2">
|
|
||||||
<div class="w-3/4 p-1">
|
|
||||||
<ui-text-input-with-label v-model="newName" :label="$strings.LabelName" />
|
|
||||||
</div>
|
|
||||||
<div class="w-1/4 p-1">
|
|
||||||
<ui-text-input-with-label value="Book" readonly :label="$strings.LabelMediaType" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-full mb-2 p-1">
|
|
||||||
<ui-text-input-with-label v-model="newUrl" label="URL" />
|
|
||||||
</div>
|
|
||||||
<div class="w-full mb-2 p-1">
|
|
||||||
<ui-text-input-with-label v-model="newAuthHeaderValue" :label="$strings.LabelProviderAuthorizationValue" type="password" />
|
|
||||||
</div>
|
|
||||||
<div class="flex px-1 pt-4">
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<ui-btn color="success" type="submit">{{ $strings.ButtonAdd }}</ui-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
value: Boolean
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
processing: false,
|
|
||||||
newName: '',
|
|
||||||
newUrl: '',
|
|
||||||
newAuthHeaderValue: ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
show: {
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.value
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('input', val)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
submitForm() {
|
|
||||||
if (!this.newName || !this.newUrl) {
|
|
||||||
this.$toast.error(this.$strings.ToastProviderNameAndUrlRequired)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.processing = true
|
|
||||||
this.$axios
|
|
||||||
.$post('/api/custom-metadata-providers', {
|
|
||||||
name: this.newName,
|
|
||||||
url: this.newUrl,
|
|
||||||
mediaType: 'book', // Currently only supporting book mediaType
|
|
||||||
authHeaderValue: this.newAuthHeaderValue
|
|
||||||
})
|
|
||||||
.then((data) => {
|
|
||||||
this.$emit('added', data.provider)
|
|
||||||
this.$toast.success(this.$strings.ToastProviderCreatedSuccess)
|
|
||||||
this.show = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
const errorMsg = error.response?.data || 'Unknown error'
|
|
||||||
console.error('Failed to add provider', error)
|
|
||||||
this.$toast.error(this.$strings.ToastProviderCreatedFailed + ': ' + errorMsg)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
init() {
|
|
||||||
this.processing = false
|
|
||||||
this.newName = ''
|
|
||||||
this.newUrl = ''
|
|
||||||
this.newAuthHeaderValue = ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
<template>
|
|
||||||
<modals-modal v-model="show" name="audiofile-data-modal" :width="700" :height="'unset'">
|
|
||||||
<div v-if="audioFile" 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 justify-between">
|
|
||||||
<p class="text-base text-gray-200 truncate">{{ metadata.filename }}</p>
|
|
||||||
<ui-btn v-if="ffprobeData" small class="ml-2" @click="ffprobeData = null">{{ $strings.ButtonReset }}</ui-btn>
|
|
||||||
<ui-btn v-else-if="userIsAdminOrUp" small :loading="probingFile" class="ml-2" @click="getFFProbeData">{{ $strings.ButtonProbeAudioFile }}</ui-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
|
||||||
|
|
||||||
<template v-if="!ffprobeData">
|
|
||||||
<ui-text-input-with-label :value="metadata.path" readonly :label="$strings.LabelPath" class="mb-4 text-sm" />
|
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row text-sm">
|
|
||||||
<div class="w-full sm:w-1/2">
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelSize }}
|
|
||||||
</p>
|
|
||||||
<p>{{ $bytesPretty(metadata.size) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelDuration }}
|
|
||||||
</p>
|
|
||||||
<p>{{ $secondsToTimestamp(audioFile.duration) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">{{ $strings.LabelFormat }}</p>
|
|
||||||
<p>{{ audioFile.format }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelChapters }}
|
|
||||||
</p>
|
|
||||||
<p>{{ audioFile.chapters?.length || 0 }}</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="audioFile.embeddedCoverArt" class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelEmbeddedCover }}
|
|
||||||
</p>
|
|
||||||
<p>{{ audioFile.embeddedCoverArt || '' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-full sm:w-1/2">
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelCodec }}
|
|
||||||
</p>
|
|
||||||
<p>{{ audioFile.codec }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelChannels }}
|
|
||||||
</p>
|
|
||||||
<p>{{ audioFile.channels }} ({{ audioFile.channelLayout }})</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelBitrate }}
|
|
||||||
</p>
|
|
||||||
<p>{{ $bytesPretty(audioFile.bitRate || 0, 0) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">{{ $strings.LabelTimeBase }}</p>
|
|
||||||
<p>{{ audioFile.timeBase }}</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="audioFile.language" class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelLanguage }}
|
|
||||||
</p>
|
|
||||||
<p>{{ audioFile.language || '' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
|
||||||
|
|
||||||
<p class="font-bold mb-2">{{ $strings.LabelMetaTags }}</p>
|
|
||||||
|
|
||||||
<div v-for="(value, key) in metaTags" :key="key" class="flex mb-1 text-sm">
|
|
||||||
<p class="w-32 min-w-32 text-black-50 mb-1">
|
|
||||||
{{ key.replace('tag', '') }}
|
|
||||||
</p>
|
|
||||||
<p>{{ value }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div v-else class="w-full">
|
|
||||||
<div class="relative">
|
|
||||||
<ui-textarea-with-label :value="prettyFfprobeData" readonly :rows="30" class="text-xs" />
|
|
||||||
|
|
||||||
<button class="absolute top-4 right-4" :class="hasCopied ? 'text-success' : 'text-gray-400 hover:text-white'" @click.stop="copyToClipboard">
|
|
||||||
<span class="material-symbols">{{ hasCopied ? 'done' : 'content_copy' }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
value: Boolean,
|
|
||||||
audioFile: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
},
|
|
||||||
libraryItemId: String
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
probingFile: false,
|
|
||||||
ffprobeData: null,
|
|
||||||
hasCopied: null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
show(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.ffprobeData = null
|
|
||||||
this.probingFile = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.value
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('input', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
metadata() {
|
|
||||||
return this.audioFile?.metadata || {}
|
|
||||||
},
|
|
||||||
metaTags() {
|
|
||||||
return this.audioFile?.metaTags || {}
|
|
||||||
},
|
|
||||||
userIsAdminOrUp() {
|
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
|
||||||
},
|
|
||||||
prettyFfprobeData() {
|
|
||||||
if (!this.ffprobeData) return ''
|
|
||||||
return JSON.stringify(this.ffprobeData, null, 2)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
getFFProbeData() {
|
|
||||||
this.probingFile = true
|
|
||||||
this.$axios
|
|
||||||
.$get(`/api/items/${this.libraryItemId}/ffprobe/${this.audioFile.ino}`)
|
|
||||||
.then((data) => {
|
|
||||||
console.log('Got ffprobe data', data)
|
|
||||||
this.ffprobeData = data
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to get ffprobe data', error)
|
|
||||||
this.$toast.error(this.$strings.ToastFailedToLoadData)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.probingFile = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
copyToClipboard() {
|
|
||||||
clearTimeout(this.hasCopied)
|
|
||||||
this.$copyToClipboard(this.prettyFfprobeData).then((success) => {
|
|
||||||
this.hasCopied = setTimeout(() => {
|
|
||||||
this.hasCopied = null
|
|
||||||
}, 2000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<widgets-cron-expression-builder ref="expressionBuilder" v-model="newCronExpression" @input="expressionUpdated" />
|
<widgets-cron-expression-builder ref="expressionBuilder" v-model="newCronExpression" @input="expressionUpdated" />
|
||||||
|
|
||||||
<div class="flex items-center justify-end">
|
<div class="flex items-center justify-end">
|
||||||
<ui-btn :disabled="!isUpdated" @click="submit">{{ isUpdated ? $strings.ButtonSave : $strings.MessageNoUpdatesWereNecessary }}</ui-btn>
|
<ui-btn :disabled="!isUpdated" @click="submit">{{ isUpdated ? $strings.ButtonSave : $strings.MessageNoUpdateNecessary }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
<div v-if="show" class="w-full h-full py-4">
|
<div v-if="show" class="w-full h-full py-4">
|
||||||
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
|
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
|
||||||
<div class="flex px-8 items-center py-2">
|
<div class="flex px-8 items-center py-2">
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<ui-tooltip :text="$strings.LabelUpdateCoverHelp">
|
<ui-tooltip :text="$strings.LabelUpdateCoverHelp">
|
||||||
<p class="pl-4">
|
<p class="pl-4">
|
||||||
{{ $strings.LabelUpdateCover }}
|
{{ $strings.LabelUpdateCover }}
|
||||||
<span class="material-symbols icon-text">info</span>
|
<span class="material-icons icon-text">info_outlined</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<ui-tooltip :text="$strings.LabelUpdateDetailsHelp">
|
<ui-tooltip :text="$strings.LabelUpdateDetailsHelp">
|
||||||
<p class="pl-4">
|
<p class="pl-4">
|
||||||
{{ $strings.LabelUpdateDetails }}
|
{{ $strings.LabelUpdateDetails }}
|
||||||
<span class="material-symbols icon-text">info</span>
|
<span class="material-icons icon-text">info_outlined</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,7 +54,8 @@ export default {
|
|||||||
options: {
|
options: {
|
||||||
provider: undefined,
|
provider: undefined,
|
||||||
overrideDetails: true,
|
overrideDetails: true,
|
||||||
overrideCover: true
|
overrideCover: true,
|
||||||
|
overrideDefaults: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -98,8 +99,8 @@ export default {
|
|||||||
init() {
|
init() {
|
||||||
// If we don't have a set provider (first open of dialog) or we've switched library, set
|
// If we don't have a set provider (first open of dialog) or we've switched library, set
|
||||||
// the selected provider to the current library default provider
|
// the selected provider to the current library default provider
|
||||||
if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) {
|
if (!this.options.provider || this.options.lastUsedLibrary != this.currentLibraryId) {
|
||||||
this.lastUsedLibrary = this.currentLibraryId
|
this.options.lastUsedLibrary = this.currentLibraryId
|
||||||
this.options.provider = this.libraryProvider
|
this.options.provider = this.libraryProvider
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -115,10 +116,10 @@ export default {
|
|||||||
libraryItemIds: this.selectedBookIds
|
libraryItemIds: this.selectedBookIds
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.info(this.$getString('ToastBatchQuickMatchStarted', [this.selectedBookIds.length]))
|
this.$toast.info('Batch quick match of ' + this.selectedBookIds.length + ' books started!')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.$toast.error(this.$strings.ToastBatchQuickMatchFailed)
|
this.$toast.error('Batch quick match failed')
|
||||||
console.error('Failed to batch quick match', error)
|
console.error('Failed to batch quick match', error)
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|||||||
@@ -5,28 +5,26 @@
|
|||||||
<p class="text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p>
|
<p class="text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="show" class="w-full rounded-lg bg-bg box-shadow-md relative" style="max-height: 80vh">
|
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
<div v-if="bookmarks.length" class="h-full max-h-[calc(80vh-60px)] w-full relative overflow-y-auto overflow-x-hidden">
|
<div v-if="show" class="w-full h-full">
|
||||||
<template v-for="bookmark in bookmarks">
|
<template v-for="bookmark in bookmarks">
|
||||||
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" :playback-rate="playbackRate" @click="clickBookmark" @delete="deleteBookmark" />
|
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @update="submitUpdateBookmark" @delete="deleteBookmark" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
|
||||||
<div v-else class="flex h-32 items-center justify-center">
|
<p class="text-xl">{{ $strings.MessageNoBookmarks }}</p>
|
||||||
<p class="text-xl">{{ $strings.MessageNoBookmarks }}</p>
|
</div>
|
||||||
</div>
|
<div v-if="!hideCreate" class="w-full h-px bg-white bg-opacity-10" />
|
||||||
|
<form v-if="!hideCreate" @submit.prevent="submitCreateBookmark">
|
||||||
<div v-if="canCreateBookmark && !hideCreate" class="w-full border-t border-white/10">
|
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
|
||||||
<form @submit.prevent="submitCreateBookmark">
|
|
||||||
<div class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
|
|
||||||
<div class="w-16 max-w-16 text-center">
|
<div class="w-16 max-w-16 text-center">
|
||||||
<p class="text-sm font-mono text-gray-400">
|
<p class="text-sm font-mono text-gray-400">
|
||||||
{{ this.$secondsToTimestamp(currentTime / playbackRate) }}
|
{{ this.$secondsToTimestamp(currentTime) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-2">
|
<div class="flex-grow px-2">
|
||||||
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full h-10" />
|
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
|
||||||
</div>
|
</div>
|
||||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-symbols text-2xl -mt-px">add</span></ui-btn>
|
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons text-2xl -mt-px">add</span></ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,7 +45,6 @@ export default {
|
|||||||
default: 0
|
default: 0
|
||||||
},
|
},
|
||||||
libraryItemId: String,
|
libraryItemId: String,
|
||||||
playbackRate: Number,
|
|
||||||
hideCreate: Boolean
|
hideCreate: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -60,7 +57,6 @@ export default {
|
|||||||
watch: {
|
watch: {
|
||||||
show(newVal) {
|
show(newVal) {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
this.selectedBookmark = null
|
|
||||||
this.showBookmarkTitleInput = false
|
this.showBookmarkTitleInput = false
|
||||||
this.newBookmarkTitle = ''
|
this.newBookmarkTitle = ''
|
||||||
}
|
}
|
||||||
@@ -76,7 +72,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
canCreateBookmark() {
|
canCreateBookmark() {
|
||||||
return !this.bookmarks.find((bm) => Math.abs(this.currentTime - bm.time) < 1)
|
return !this.bookmarks.find((bm) => bm.time === this.currentTime)
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.$store.state.serverSettings.dateFormat
|
return this.$store.state.serverSettings.dateFormat
|
||||||
@@ -98,7 +94,7 @@ export default {
|
|||||||
this.$toast.success(this.$strings.ToastBookmarkRemoveSuccess)
|
this.$toast.success(this.$strings.ToastBookmarkRemoveSuccess)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.$toast.error(this.$strings.ToastRemoveFailed)
|
this.$toast.error(this.$strings.ToastBookmarkRemoveFailed)
|
||||||
console.error(error)
|
console.error(error)
|
||||||
})
|
})
|
||||||
this.show = false
|
this.show = false
|
||||||
@@ -106,6 +102,19 @@ export default {
|
|||||||
clickBookmark(bm) {
|
clickBookmark(bm) {
|
||||||
this.$emit('select', bm)
|
this.$emit('select', bm)
|
||||||
},
|
},
|
||||||
|
submitUpdateBookmark(updatedBookmark) {
|
||||||
|
var bookmark = { ...updatedBookmark }
|
||||||
|
this.$axios
|
||||||
|
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success(this.$strings.ToastBookmarkUpdateSuccess)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.$toast.error(this.$strings.ToastBookmarkUpdateFailed)
|
||||||
|
console.error(error)
|
||||||
|
})
|
||||||
|
this.show = false
|
||||||
|
},
|
||||||
submitCreateBookmark() {
|
submitCreateBookmark() {
|
||||||
if (!this.newBookmarkTitle) {
|
if (!this.newBookmarkTitle) {
|
||||||
this.newBookmarkTitle = this.$formatDatetime(Date.now(), this.dateFormat, this.timeFormat)
|
this.newBookmarkTitle = this.$formatDatetime(Date.now(), this.dateFormat, this.timeFormat)
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
|
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
|
||||||
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
<template v-for="chap in chapters">
|
<template v-for="chap in chapters">
|
||||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer relative" :class="chap.id === currentChapterId ? 'bg-yellow-400/20 hover:bg-yellow-400/10' : chap.end / _playbackRate <= currentChapterStart ? 'bg-success/10 hover:bg-success/5' : 'hover:bg-primary/10'" @click="clickChapter(chap)">
|
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
||||||
<p class="chapter-title truncate text-sm md:text-base">
|
<p class="chapter-title truncate text-sm md:text-base">
|
||||||
{{ chap.title }}
|
{{ chap.title }}
|
||||||
</p>
|
</p>
|
||||||
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended((chap.end - chap.start) / _playbackRate) }}</span>
|
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended(chap.end - chap.start) }}</span>
|
||||||
<span class="flex-grow" />
|
<span class="flex-grow" />
|
||||||
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start / _playbackRate) }}</span>
|
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
|
||||||
|
|
||||||
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||||
</div>
|
</div>
|
||||||
@@ -28,12 +28,16 @@ export default {
|
|||||||
currentChapter: {
|
currentChapter: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
},
|
}
|
||||||
playbackRate: Number
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
value(newVal) {
|
||||||
|
this.$nextTick(this.scrollToChapter)
|
||||||
|
}
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
show: {
|
show: {
|
||||||
get() {
|
get() {
|
||||||
@@ -43,15 +47,11 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
_playbackRate() {
|
|
||||||
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
|
||||||
return this.playbackRate
|
|
||||||
},
|
|
||||||
currentChapterId() {
|
currentChapterId() {
|
||||||
return this.currentChapter?.id || null
|
return this.currentChapter ? this.currentChapter.id : null
|
||||||
},
|
},
|
||||||
currentChapterStart() {
|
currentChapterStart() {
|
||||||
return (this.currentChapter?.start || 0) / this._playbackRate
|
return this.currentChapter ? this.currentChapter.start : 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -61,19 +61,16 @@ export default {
|
|||||||
scrollToChapter() {
|
scrollToChapter() {
|
||||||
if (!this.currentChapterId) return
|
if (!this.currentChapterId) return
|
||||||
|
|
||||||
if (this.$refs.container) {
|
var container = this.$refs.container
|
||||||
const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
if (container) {
|
||||||
|
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||||
if (currChapterEl) {
|
if (currChapterEl) {
|
||||||
const containerHeight = this.$refs.container.clientHeight
|
var offsetTop = currChapterEl.offsetTop
|
||||||
this.$refs.container.scrollTo({ top: currChapterEl.offsetTop - containerHeight / 2 })
|
var containerHeight = container.clientHeight
|
||||||
|
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
updated() {
|
|
||||||
if (this.value) {
|
|
||||||
this.$nextTick(this.scrollToChapter)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -87,4 +84,4 @@ export default {
|
|||||||
max-width: calc(100% - 150px);
|
max-width: calc(100% - 150px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -48,7 +48,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickedOption(action) {
|
clickedOption(action) {
|
||||||
this.$emit('action', { action })
|
this.$emit('action', action)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" role="dialog" aria-modal="true" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 61" @click="clickClose">
|
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 61" @click="clickClose">
|
||||||
<button type="button" class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" aria-label="Close modal">
|
<div class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
|
||||||
<span class="material-symbols text-2xl md:text-4xl">close</span>
|
<span class="material-icons text-2xl md:text-4xl">close</span>
|
||||||
</button>
|
</div>
|
||||||
<div ref="content" class="text-white">
|
<div ref="content" class="text-white">
|
||||||
<form v-if="selectedSeries" @submit.prevent="submitSeriesForm">
|
<form v-if="selectedSeries" @submit.prevent="submitSeriesForm">
|
||||||
<div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
|
<div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80">
|
<div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80">
|
||||||
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!isNewSeries" :label="$strings.LabelSeriesName" @input="seriesNameInputHandler" />
|
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!isNewSeries" :label="$strings.LabelSeriesName" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24 sm:w-28 md:w-40 p-1">
|
<div class="w-24 sm:w-28 md:w-40 p-1">
|
||||||
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" :label="$strings.LabelSequence" />
|
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" :label="$strings.LabelSequence" />
|
||||||
@@ -66,11 +66,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
seriesNameInputHandler() {
|
|
||||||
if (this.$refs.sequenceInput) {
|
|
||||||
this.$refs.sequenceInput.setFocus()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setInputFocus() {
|
setInputFocus() {
|
||||||
if (this.isNewSeries) {
|
if (this.isNewSeries) {
|
||||||
// Focus on series input if new series
|
// Focus on series input if new series
|
||||||
@@ -139,4 +134,4 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -2,13 +2,13 @@
|
|||||||
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
|
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
<p class="text-lg md:text-2xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
|
<p class="text-3xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<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">{{ $getString('LabelByAuthor', [_session.displayAuthor]) }}</p>
|
<p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">by {{ _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" />
|
||||||
@@ -50,19 +50,19 @@
|
|||||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelItem }}</p>
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelItem }}</p>
|
||||||
<div v-if="_session.libraryId" class="flex items-center -mx-1 mb-1">
|
<div v-if="_session.libraryId" class="flex items-center -mx-1 mb-1">
|
||||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibrary }} Id</div>
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibrary }} Id</div>
|
||||||
<div class="px-1 text-xs">
|
<div class="px-1">
|
||||||
{{ _session.libraryId }}
|
{{ _session.libraryId }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center -mx-1 mb-1">
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibraryItem }} Id</div>
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibraryItem }} Id</div>
|
||||||
<div class="px-1 text-xs">
|
<div class="px-1">
|
||||||
{{ _session.libraryItemId }}
|
{{ _session.libraryItemId }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="_session.episodeId" class="flex items-center -mx-1 mb-1">
|
<div v-if="_session.episodeId" class="flex items-center -mx-1 mb-1">
|
||||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelEpisode }} Id</div>
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelEpisode }} Id</div>
|
||||||
<div class="px-1 text-xs">
|
<div class="px-1">
|
||||||
{{ _session.episodeId }}
|
{{ _session.episodeId }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,27 +80,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full md:w-1/3">
|
<div class="w-full md:w-1/3">
|
||||||
<p v-if="!isMediaItemShareSession" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
|
||||||
<p v-if="!isMediaItemShareSession" class="mb-1 text-xs">{{ _session.userId }}</p>
|
<p class="mb-1">{{ _session.userId }}</p>
|
||||||
|
|
||||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
|
||||||
<p class="mb-1">{{ playMethodName }}</p>
|
<p class="mb-1">{{ playMethodName }}</p>
|
||||||
<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="deviceDisplayName" class="mb-1">{{ deviceDisplayName }}</p>
|
<p v-if="clientDisplayName" class="mb-1">{{ clientDisplayName }}</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ui-btn v-if="!isOpenSession && !isMediaItemShareSession" small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
|
<ui-btn small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
|
||||||
<ui-btn v-else-if="!isMediaItemShareSession" small color="error" @click.stop="closeSessionClick">{{ $strings.ButtonCloseSession }}</ui-btn>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
@@ -142,14 +140,10 @@ 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}`
|
||||||
},
|
},
|
||||||
deviceDisplayName() {
|
clientDisplayName() {
|
||||||
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'
|
||||||
@@ -163,12 +157,6 @@ export default {
|
|||||||
},
|
},
|
||||||
timeFormat() {
|
timeFormat() {
|
||||||
return this.$store.state.serverSettings.timeFormat
|
return this.$store.state.serverSettings.timeFormat
|
||||||
},
|
|
||||||
isOpenSession() {
|
|
||||||
return !!this._session.open
|
|
||||||
},
|
|
||||||
isMediaItemShareSession() {
|
|
||||||
return this._session.mediaPlayer === 'web-share'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -200,23 +188,6 @@ export default {
|
|||||||
var errMsg = error.response ? error.response.data || '' : ''
|
var errMsg = error.response ? error.response.data || '' : ''
|
||||||
this.$toast.error(errMsg || this.$strings.ToastSessionDeleteFailed)
|
this.$toast.error(errMsg || this.$strings.ToastSessionDeleteFailed)
|
||||||
})
|
})
|
||||||
},
|
|
||||||
closeSessionClick() {
|
|
||||||
this.processing = true
|
|
||||||
this.$axios
|
|
||||||
.$post(`/api/session/${this._session.id}/close`)
|
|
||||||
.then(() => {
|
|
||||||
this.show = false
|
|
||||||
this.$emit('closedSession')
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to close session', error)
|
|
||||||
const errMsg = error.response?.data || ''
|
|
||||||
this.$toast.error(errMsg || this.$strings.ToastSessionCloseFailed)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" role="dialog" aria-modal="true" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
|
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
|
||||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||||
|
|
||||||
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
|
<div class="absolute top-3 right-3 landscape:top-2 landscape:right-2 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 h-8 w-8 landscape:h-8 landscape:w-8 md:portrait:h-12 md:portrait:w-12 lg:w-12 lg:h-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
|
||||||
<span class="material-symbols text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
<span class="material-icons text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
||||||
</button>
|
</div>
|
||||||
<slot name="outer" />
|
<slot name="outer" />
|
||||||
<div ref="content" tabindex="0" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white outline-none" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
||||||
<slot />
|
<slot />
|
||||||
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
|
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
|
||||||
<ui-loading-indicator />
|
<ui-loading-indicator />
|
||||||
@@ -126,9 +126,6 @@ export default {
|
|||||||
|
|
||||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||||
this.$store.commit('setOpenModal', this.name)
|
this.$store.commit('setOpenModal', this.name)
|
||||||
|
|
||||||
// Set focus to the modal content
|
|
||||||
this.content.focus()
|
|
||||||
},
|
},
|
||||||
setHide() {
|
setHide() {
|
||||||
if (this.content) this.content.style.transform = 'scale(0)'
|
if (this.content) this.content.style.transform = 'scale(0)'
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
<template>
|
|
||||||
<modals-modal v-model="show" name="player-settings" :width="500" :height="'unset'">
|
|
||||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-4" style="max-height: 80vh; min-height: 40vh">
|
|
||||||
<h3 class="text-xl font-semibold mb-8">{{ $strings.HeaderPlayerSettings }}</h3>
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<ui-toggle-switch v-model="useChapterTrack" @input="setUseChapterTrack" />
|
|
||||||
<div class="pl-4">
|
|
||||||
<span>{{ $strings.LabelUseChapterTrack }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<ui-select-input v-model="jumpForwardAmount" :label="$strings.LabelJumpForwardAmount" menuMaxHeight="250px" :items="jumpValues" @input="setJumpForwardAmount" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<ui-select-input v-model="jumpBackwardAmount" :label="$strings.LabelJumpBackwardAmount" menuMaxHeight="250px" :items="jumpValues" @input="setJumpBackwardAmount" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<ui-select-input v-model="playbackRateIncrementDecrement" :label="$strings.LabelPlaybackRateIncrementDecrement" menuMaxHeight="250px" :items="playbackRateIncrementDecrementValues" @input="setPlaybackRateIncrementDecrementAmount" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
value: Boolean
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
useChapterTrack: false,
|
|
||||||
jumpValues: [
|
|
||||||
{ text: this.$getString('LabelTimeDurationXSeconds', ['10']), value: 10 },
|
|
||||||
{ text: this.$getString('LabelTimeDurationXSeconds', ['15']), value: 15 },
|
|
||||||
{ text: this.$getString('LabelTimeDurationXSeconds', ['30']), value: 30 },
|
|
||||||
{ text: this.$getString('LabelTimeDurationXSeconds', ['60']), value: 60 },
|
|
||||||
{ text: this.$getString('LabelTimeDurationXMinutes', ['2']), value: 120 },
|
|
||||||
{ text: this.$getString('LabelTimeDurationXMinutes', ['5']), value: 300 }
|
|
||||||
],
|
|
||||||
jumpForwardAmount: 10,
|
|
||||||
jumpBackwardAmount: 10,
|
|
||||||
playbackRateIncrementDecrementValues: [0.1, 0.05],
|
|
||||||
playbackRateIncrementDecrement: 0.1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.value
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('input', val)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
setUseChapterTrack() {
|
|
||||||
this.$store.dispatch('user/updateUserSettings', { useChapterTrack: this.useChapterTrack })
|
|
||||||
},
|
|
||||||
setJumpForwardAmount(val) {
|
|
||||||
this.jumpForwardAmount = val
|
|
||||||
this.$store.dispatch('user/updateUserSettings', { jumpForwardAmount: val })
|
|
||||||
},
|
|
||||||
setJumpBackwardAmount(val) {
|
|
||||||
this.jumpBackwardAmount = val
|
|
||||||
this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val })
|
|
||||||
},
|
|
||||||
setPlaybackRateIncrementDecrementAmount(val) {
|
|
||||||
this.playbackRateIncrementDecrement = val
|
|
||||||
this.$store.dispatch('user/updateUserSettings', { playbackRateIncrementDecrement: val })
|
|
||||||
},
|
|
||||||
settingsUpdated() {
|
|
||||||
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
|
|
||||||
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
|
|
||||||
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
|
|
||||||
this.playbackRateIncrementDecrement = this.$store.getters['user/getUserSetting']('playbackRateIncrementDecrement')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.settingsUpdated()
|
|
||||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
|
||||||
},
|
|
||||||
beforeDestroy() {
|
|
||||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<template>
|
|
||||||
<modals-modal v-model="show" name="cover" :width="'90%'" :height="'90%'" :contentMarginTop="0">
|
|
||||||
<div class="w-full h-full" @click="show = false">
|
|
||||||
<img loading="lazy" :src="rawCoverUrl" class="w-full h-full z-10 object-scale-down" />
|
|
||||||
</div>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.$store.state.globals.showRawCoverPreviewModal
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$store.commit('globals/setShowRawCoverPreviewModal', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
rawCoverUrl() {
|
|
||||||
return this.$store.state.globals.selectedRawCoverUrl
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
<template>
|
|
||||||
<modals-modal ref="modal" v-model="show" name="share" :width="600" :height="'unset'" :processing="processing">
|
|
||||||
<template #outer>
|
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
|
||||||
<p class="text-3xl text-white truncate">{{ $strings.LabelShare }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="px-6 py-8 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
|
||||||
<div class="absolute top-0 right-0 p-4">
|
|
||||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
|
||||||
<a href="https://www.audiobookshelf.org/guides/media-item-shares" target="_blank" class="inline-flex">
|
|
||||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
|
||||||
</a>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
<template v-if="currentShare">
|
|
||||||
<div class="w-full py-2">
|
|
||||||
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelShareURL }}</label>
|
|
||||||
<ui-text-input v-model="currentShareUrl" show-copy readonly />
|
|
||||||
</div>
|
|
||||||
<div class="w-full py-2 px-1">
|
|
||||||
<p v-if="currentShare.isDownloadable" class="text-sm mb-2">{{ $strings.LabelDownloadable }}</p>
|
|
||||||
<p v-if="currentShare.expiresAt">{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}</p>
|
|
||||||
<p v-else>{{ $strings.LabelPermanent }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="flex flex-col sm:flex-row items-center justify-between space-y-4 sm:space-y-0 sm:space-x-4 mb-2">
|
|
||||||
<div class="w-full sm:w-48">
|
|
||||||
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelSlug }}</label>
|
|
||||||
<ui-text-input v-model="newShareSlug" class="text-base h-10" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<div class="w-full sm:w-80">
|
|
||||||
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelDuration }}</label>
|
|
||||||
<div class="inline-flex items-center space-x-2">
|
|
||||||
<div>
|
|
||||||
<ui-icon-btn icon="remove" :size="10" @click="clickMinus" />
|
|
||||||
</div>
|
|
||||||
<ui-text-input v-model="newShareDuration" type="number" text-center no-spinner class="text-center max-w-12 min-w-12 h-10 text-base" />
|
|
||||||
<div>
|
|
||||||
<ui-icon-btn icon="add" :size="10" @click="clickPlus" />
|
|
||||||
</div>
|
|
||||||
<div class="w-28">
|
|
||||||
<ui-dropdown v-model="shareDurationUnit" :items="durationUnits" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center w-full md:w-1/2 mb-4">
|
|
||||||
<p class="text-sm text-gray-300 py-1 px-1">{{ $strings.LabelDownloadable }}</p>
|
|
||||||
<ui-toggle-switch size="sm" v-model="isDownloadable" />
|
|
||||||
<ui-tooltip :text="$strings.LabelShareDownloadableHelp">
|
|
||||||
<p class="pl-4 text-sm">
|
|
||||||
<span class="material-symbols icon-text text-sm">info</span>
|
|
||||||
</p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareURLWillBe', [demoShareUrl])" />
|
|
||||||
<p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareExpirationWillBe', [expirationDateString])" />
|
|
||||||
</template>
|
|
||||||
<div class="flex items-center pt-6">
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<ui-btn v-if="currentShare" color="error" small @click="deleteShare">{{ $strings.ButtonDelete }}</ui-btn>
|
|
||||||
<ui-btn v-if="!currentShare" color="success" small @click="openShare">{{ $strings.ButtonShare }}</ui-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
processing: false,
|
|
||||||
newShareSlug: '',
|
|
||||||
newShareDuration: 0,
|
|
||||||
currentShare: null,
|
|
||||||
shareDurationUnit: 'minutes',
|
|
||||||
durationUnits: [
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelMinutes,
|
|
||||||
value: 'minutes'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelHours,
|
|
||||||
value: 'hours'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelDays,
|
|
||||||
value: 'days'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
isDownloadable: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
show: {
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.$store.state.globals.showShareModal
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$store.commit('globals/setShowShareModal', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mediaItemShare() {
|
|
||||||
return this.$store.state.globals.selectedMediaItemShare
|
|
||||||
},
|
|
||||||
libraryItem() {
|
|
||||||
return this.$store.state.selectedLibraryItem
|
|
||||||
},
|
|
||||||
user() {
|
|
||||||
return this.$store.state.user.user
|
|
||||||
},
|
|
||||||
demoShareUrl() {
|
|
||||||
return `${window.origin}${this.$config.routerBasePath}/share/${this.newShareSlug}`
|
|
||||||
},
|
|
||||||
currentShareUrl() {
|
|
||||||
if (!this.currentShare) return ''
|
|
||||||
return `${window.origin}${this.$config.routerBasePath}/share/${this.currentShare.slug}`
|
|
||||||
},
|
|
||||||
currentShareTimeRemaining() {
|
|
||||||
if (!this.currentShare) return 'Error'
|
|
||||||
if (!this.currentShare.expiresAt) return this.$strings.LabelPermanent
|
|
||||||
const msRemaining = new Date(this.currentShare.expiresAt).valueOf() - Date.now()
|
|
||||||
if (msRemaining <= 0) return 'Expired'
|
|
||||||
return this.$elapsedPrettyExtended(msRemaining / 1000, true, false)
|
|
||||||
},
|
|
||||||
expireDurationSeconds() {
|
|
||||||
let shareDuration = Number(this.newShareDuration)
|
|
||||||
if (!shareDuration || isNaN(shareDuration)) return 0
|
|
||||||
return this.newShareDuration * (this.shareDurationUnit === 'minutes' ? 60 : this.shareDurationUnit === 'hours' ? 3600 : 86400)
|
|
||||||
},
|
|
||||||
expirationDateString() {
|
|
||||||
if (!this.expireDurationSeconds) return this.$strings.LabelPermanent
|
|
||||||
const dateMs = Date.now() + this.expireDurationSeconds * 1000
|
|
||||||
return this.$formatDatetime(dateMs, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
clickPlus() {
|
|
||||||
this.newShareDuration++
|
|
||||||
},
|
|
||||||
clickMinus() {
|
|
||||||
if (this.newShareDuration > 0) {
|
|
||||||
this.newShareDuration--
|
|
||||||
}
|
|
||||||
},
|
|
||||||
deleteShare() {
|
|
||||||
if (!this.currentShare) return
|
|
||||||
this.processing = true
|
|
||||||
this.$axios
|
|
||||||
.$delete(`/api/share/mediaitem/${this.currentShare.id}`)
|
|
||||||
.then(() => {
|
|
||||||
this.currentShare = null
|
|
||||||
this.$emit('removed')
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('deleteShare', error)
|
|
||||||
let errorMsg = error.response?.data || 'Failed to delete share'
|
|
||||||
this.$toast.error(errorMsg)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
openShare() {
|
|
||||||
if (!this.newShareSlug) {
|
|
||||||
this.$toast.error(this.$strings.ToastSlugRequired)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const payload = {
|
|
||||||
slug: this.newShareSlug,
|
|
||||||
mediaItemType: 'book',
|
|
||||||
mediaItemId: this.libraryItem.media.id,
|
|
||||||
expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0,
|
|
||||||
isDownloadable: this.isDownloadable
|
|
||||||
}
|
|
||||||
this.processing = true
|
|
||||||
this.$axios
|
|
||||||
.$post(`/api/share/mediaitem`, payload)
|
|
||||||
.then((data) => {
|
|
||||||
this.currentShare = data
|
|
||||||
this.$emit('opened', data)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('openShare', error)
|
|
||||||
let errorMsg = error.response?.data || 'Failed to share item'
|
|
||||||
this.$toast.error(errorMsg)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
init() {
|
|
||||||
this.newShareSlug = this.$randomId(10)
|
|
||||||
if (this.mediaItemShare) {
|
|
||||||
this.currentShare = { ...this.mediaItemShare }
|
|
||||||
} else {
|
|
||||||
this.currentShare = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -6,36 +6,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
<div class="w-full">
|
<div v-if="!timerSet" class="w-full">
|
||||||
<template v-for="time in sleepTimes">
|
<template v-for="time in sleepTimes">
|
||||||
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-primary/25 relative" @click="setTime(time)">
|
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time)">
|
||||||
<p class="text-lg text-center">{{ time.text }}</p>
|
<p class="text-xl text-center">{{ time.text }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime">
|
|
||||||
<ui-text-input v-model="customTime" type="number" step="any" min="0.1" :placeholder="$strings.LabelTimeInMinutes" class="w-48" />
|
|
||||||
<ui-btn color="success" type="submit" :padding-x="0" class="h-9 w-18 flex items-center justify-center ml-1">{{ $strings.ButtonSubmit }}</ui-btn>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="timerSet" class="w-full p-4">
|
<div v-else class="w-full p-4">
|
||||||
<div class="mb-4 h-px w-full bg-white/10" />
|
<div class="mb-4 flex items-center justify-center">
|
||||||
|
<ui-btn :padding-x="2" small :disabled="remaining < 30 * 60" class="flex items-center mr-4" @click="decrement(30 * 60)">
|
||||||
<div v-if="timerType === $constants.SleepTimerTypes.COUNTDOWN" class="mb-4 flex items-center justify-center space-x-4">
|
<span class="material-icons text-lg">remove</span>
|
||||||
<ui-btn :padding-x="2" small :disabled="remaining < 30 * 60" class="flex items-center h-9" @click="decrement(30 * 60)">
|
<span class="pl-1 text-base font-mono">30m</span>
|
||||||
<span class="material-symbols text-lg">remove</span>
|
|
||||||
<span class="pl-1 text-sm">30m</span>
|
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
|
|
||||||
<ui-icon-btn icon="remove" class="min-w-9" @click="decrement(60 * 5)" />
|
<ui-icon-btn icon="remove" @click="decrement(60 * 5)" />
|
||||||
|
|
||||||
<p class="text-2xl font-mono">{{ $secondsToTimestamp(remaining) }}</p>
|
<p class="mx-6 text-2xl font-mono">{{ $secondsToTimestamp(remaining) }}</p>
|
||||||
|
|
||||||
<ui-icon-btn icon="add" class="min-w-9" @click="increment(60 * 5)" />
|
<ui-icon-btn icon="add" @click="increment(60 * 5)" />
|
||||||
|
|
||||||
<ui-btn :padding-x="2" small class="flex items-center h-9" @click="increment(30 * 60)">
|
<ui-btn :padding-x="2" small class="flex items-center ml-4" @click="increment(30 * 60)">
|
||||||
<span class="material-symbols text-lg">add</span>
|
<span class="material-icons text-lg">add</span>
|
||||||
<span class="pl-1 text-sm">30m</span>
|
<span class="pl-1 text-base font-mono">30m</span>
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
<ui-btn class="w-full" @click="$emit('cancel')">{{ $strings.ButtonCancel }}</ui-btn>
|
<ui-btn class="w-full" @click="$emit('cancel')">{{ $strings.ButtonCancel }}</ui-btn>
|
||||||
@@ -49,13 +43,47 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
value: Boolean,
|
value: Boolean,
|
||||||
timerSet: Boolean,
|
timerSet: Boolean,
|
||||||
timerType: String,
|
timerTime: Number,
|
||||||
remaining: Number,
|
remaining: Number
|
||||||
hasChapters: Boolean
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
customTime: null
|
sleepTimes: [
|
||||||
|
{
|
||||||
|
seconds: 10,
|
||||||
|
text: '10 seconds'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 5,
|
||||||
|
text: '5 minutes'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 30,
|
||||||
|
text: '30 minutes'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 60,
|
||||||
|
text: '60 minutes'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 90,
|
||||||
|
text: '90 minutes'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 120,
|
||||||
|
text: '2 hours'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 180,
|
||||||
|
text: '3 hours'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -66,72 +94,11 @@ export default {
|
|||||||
set(val) {
|
set(val) {
|
||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
sleepTimes() {
|
|
||||||
const times = [
|
|
||||||
{
|
|
||||||
seconds: 60 * 5,
|
|
||||||
text: this.$getString('LabelTimeDurationXMinutes', ['5']),
|
|
||||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
|
||||||
},
|
|
||||||
{
|
|
||||||
seconds: 60 * 15,
|
|
||||||
text: this.$getString('LabelTimeDurationXMinutes', ['15']),
|
|
||||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
|
||||||
},
|
|
||||||
{
|
|
||||||
seconds: 60 * 20,
|
|
||||||
text: this.$getString('LabelTimeDurationXMinutes', ['20']),
|
|
||||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
|
||||||
},
|
|
||||||
{
|
|
||||||
seconds: 60 * 30,
|
|
||||||
text: this.$getString('LabelTimeDurationXMinutes', ['30']),
|
|
||||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
|
||||||
},
|
|
||||||
{
|
|
||||||
seconds: 60 * 45,
|
|
||||||
text: this.$getString('LabelTimeDurationXMinutes', ['45']),
|
|
||||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
|
||||||
},
|
|
||||||
{
|
|
||||||
seconds: 60 * 60,
|
|
||||||
text: this.$getString('LabelTimeDurationXMinutes', ['60']),
|
|
||||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
|
||||||
},
|
|
||||||
{
|
|
||||||
seconds: 60 * 90,
|
|
||||||
text: this.$getString('LabelTimeDurationXMinutes', ['90']),
|
|
||||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
|
||||||
},
|
|
||||||
{
|
|
||||||
seconds: 60 * 120,
|
|
||||||
text: this.$getString('LabelTimeDurationXHours', ['2']),
|
|
||||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
|
||||||
}
|
|
||||||
]
|
|
||||||
if (this.hasChapters) {
|
|
||||||
times.push({ seconds: -1, text: this.$strings.LabelEndOfChapter, timerType: this.$constants.SleepTimerTypes.CHAPTER })
|
|
||||||
}
|
|
||||||
return times
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
submitCustomTime() {
|
|
||||||
if (!this.customTime || isNaN(this.customTime) || Number(this.customTime) <= 0) {
|
|
||||||
this.customTime = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeInSeconds = Math.round(Number(this.customTime) * 60)
|
|
||||||
const time = {
|
|
||||||
seconds: timeInSeconds,
|
|
||||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
|
||||||
}
|
|
||||||
this.setTime(time)
|
|
||||||
},
|
|
||||||
setTime(time) {
|
setTime(time) {
|
||||||
this.$emit('set', time)
|
this.$emit('set', time.seconds)
|
||||||
},
|
},
|
||||||
increment(amount) {
|
increment(amount) {
|
||||||
this.$emit('increment', amount)
|
this.$emit('increment', amount)
|
||||||
@@ -145,4 +112,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="previewUpload" class="absolute top-0 left-0 w-full h-full z-10 bg-bg p-8">
|
<div v-if="previewUpload" class="absolute top-0 left-0 w-full h-full z-10 bg-bg p-8">
|
||||||
<p class="text-lg">Preview Cover</p>
|
<p class="text-lg">Preview Cover</p>
|
||||||
<span class="absolute top-4 right-4 material-symbols text-2xl cursor-pointer" @click="resetCoverPreview">close</span>
|
<span class="absolute top-4 right-4 material-icons text-2xl cursor-pointer" @click="resetCoverPreview">close</span>
|
||||||
<div class="flex justify-center py-4">
|
<div class="flex justify-center py-4">
|
||||||
<covers-preview-cover :src="previewUpload" :width="240" />
|
<covers-preview-cover :src="previewUpload" :width="240" />
|
||||||
</div>
|
</div>
|
||||||
@@ -78,13 +78,14 @@ export default {
|
|||||||
if (data.error) {
|
if (data.error) {
|
||||||
this.$toast.error(data.error)
|
this.$toast.error(data.error)
|
||||||
} else {
|
} else {
|
||||||
|
this.$toast.success('Cover Uploaded')
|
||||||
this.resetCoverPreview()
|
this.resetCoverPreview()
|
||||||
}
|
}
|
||||||
this.processingUpload = false
|
this.processingUpload = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
var errorMsg = error.response && error.response.data ? error.response.data : this.$strings.ToastUnknownError
|
var errorMsg = error.response && error.response.data ? error.response.data : 'Unknown Error'
|
||||||
this.$toast.error(errorMsg)
|
this.$toast.error(errorMsg)
|
||||||
this.processingUpload = false
|
this.processingUpload = false
|
||||||
})
|
})
|
||||||
@@ -94,7 +95,7 @@ export default {
|
|||||||
|
|
||||||
var success = await this.$axios.$post(`/api/${this.entity}/${this.entityId}/cover`, { url: this.imageUrl }).catch((error) => {
|
var success = await this.$axios.$post(`/api/${this.entity}/${this.entityId}/cover`, { url: this.imageUrl }).catch((error) => {
|
||||||
console.error('Failed to download cover from url', error)
|
console.error('Failed to download cover from url', error)
|
||||||
var errorMsg = error.response && error.response.data ? error.response.data : this.$strings.ToastUnknownError
|
var errorMsg = error.response && error.response.data ? error.response.data : 'Unknown Error'
|
||||||
this.$toast.error(errorMsg)
|
this.$toast.error(errorMsg)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
@@ -103,4 +104,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -5,23 +5,18 @@
|
|||||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="author" class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||||
<div class="flex">
|
<form v-if="author" @submit.prevent="submitForm">
|
||||||
<div class="w-40 p-2">
|
<div class="flex">
|
||||||
<div class="w-full h-45 relative">
|
<div class="w-40 p-2">
|
||||||
<covers-author-image :author="authorCopy" />
|
<div class="w-full h-45 relative">
|
||||||
<div v-if="userCanDelete && !processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
|
<covers-author-image :author="author" />
|
||||||
<span class="absolute top-2 right-2 material-symbols text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
|
<div v-show="!processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
|
||||||
|
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="flex-grow">
|
||||||
<div class="flex-grow">
|
|
||||||
<form @submit.prevent="submitUploadCover" class="flex flex-grow mb-2 p-2">
|
|
||||||
<ui-text-input v-model="imageUrl" :placeholder="$strings.LabelImageURLFromTheWeb" class="h-9 w-full" />
|
|
||||||
<ui-btn color="success" type="submit" :padding-x="4" :disabled="!imageUrl" class="ml-2 sm:ml-3 w-24 h-9">{{ $strings.ButtonSubmit }}</ui-btn>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form v-if="author" @submit.prevent="submitForm">
|
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="w-3/4 p-2">
|
<div class="w-3/4 p-2">
|
||||||
<ui-text-input-with-label v-model="authorCopy.name" :disabled="processing" :label="$strings.LabelName" />
|
<ui-text-input-with-label v-model="authorCopy.name" :disabled="processing" :label="$strings.LabelName" />
|
||||||
@@ -30,20 +25,21 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<div class="flex pt-2 px-2">
|
<div class="flex pt-2 px-2">
|
||||||
<ui-btn v-if="userCanDelete" small color="error" type="button" @click.stop="removeClick">{{ $strings.ButtonRemove }}</ui-btn>
|
<ui-btn type="button" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn type="button" class="mx-2" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
|
||||||
|
|
||||||
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
</template>
|
</template>
|
||||||
@@ -55,9 +51,9 @@ export default {
|
|||||||
authorCopy: {
|
authorCopy: {
|
||||||
name: '',
|
name: '',
|
||||||
asin: '',
|
asin: '',
|
||||||
description: ''
|
description: '',
|
||||||
|
imagePath: ''
|
||||||
},
|
},
|
||||||
imageUrl: '',
|
|
||||||
processing: false
|
processing: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -89,51 +85,17 @@ export default {
|
|||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.$strings.HeaderUpdateAuthor
|
return this.$strings.HeaderUpdateAuthor
|
||||||
},
|
|
||||||
currentLibraryId() {
|
|
||||||
return this.$store.state.libraries.currentLibraryId
|
|
||||||
},
|
|
||||||
libraryProvider() {
|
|
||||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
|
||||||
},
|
|
||||||
userCanDelete() {
|
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
init() {
|
init() {
|
||||||
this.imageUrl = ''
|
this.authorCopy.name = this.author.name
|
||||||
this.authorCopy = {
|
this.authorCopy.asin = this.author.asin
|
||||||
...this.author
|
this.authorCopy.description = this.author.description
|
||||||
}
|
this.authorCopy.imagePath = this.author.imagePath
|
||||||
},
|
|
||||||
removeClick() {
|
|
||||||
const payload = {
|
|
||||||
message: this.$getString('MessageConfirmRemoveAuthor', [this.author.name]),
|
|
||||||
callback: (confirmed) => {
|
|
||||||
if (confirmed) {
|
|
||||||
this.processing = true
|
|
||||||
this.$axios
|
|
||||||
.$delete(`/api/authors/${this.authorId}`)
|
|
||||||
.then(() => {
|
|
||||||
this.$toast.success(this.$strings.ToastAuthorRemoveSuccess)
|
|
||||||
this.show = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to remove author', error)
|
|
||||||
this.$toast.error(this.$strings.ToastRemoveFailed)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'yesNo'
|
|
||||||
}
|
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
},
|
||||||
async submitForm() {
|
async submitForm() {
|
||||||
var keysToCheck = ['name', 'asin', 'description']
|
var keysToCheck = ['name', 'asin', 'description', 'imagePath']
|
||||||
var updatePayload = {}
|
var updatePayload = {}
|
||||||
keysToCheck.forEach((key) => {
|
keysToCheck.forEach((key) => {
|
||||||
if (this.authorCopy[key] !== this.author[key]) {
|
if (this.authorCopy[key] !== this.author[key]) {
|
||||||
@@ -141,14 +103,14 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (!Object.keys(updatePayload).length) {
|
if (!Object.keys(updatePayload).length) {
|
||||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
this.$toast.info(this.$strings.MessageNoUpdateNecessary)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.processing = true
|
this.processing = true
|
||||||
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
|
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
const errorMsg = error.response ? error.response.data : null
|
const errorMsg = error.response ? error.response.data : null
|
||||||
this.$toast.error(errorMsg || this.$strings.ToastFailedToUpdate)
|
this.$toast.error(errorMsg || this.$strings.ToastAuthorUpdateFailed)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
if (result) {
|
if (result) {
|
||||||
@@ -158,58 +120,29 @@ export default {
|
|||||||
} else if (result.merged) {
|
} else if (result.merged) {
|
||||||
this.$toast.success(this.$strings.ToastAuthorUpdateMerged)
|
this.$toast.success(this.$strings.ToastAuthorUpdateMerged)
|
||||||
this.show = false
|
this.show = false
|
||||||
} else this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
} else this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||||
}
|
}
|
||||||
this.processing = false
|
this.processing = false
|
||||||
},
|
},
|
||||||
removeCover() {
|
async removeCover() {
|
||||||
this.processing = true
|
var updatePayload = {
|
||||||
this.$axios
|
imagePath: null
|
||||||
.$delete(`/api/authors/${this.authorId}/image`)
|
|
||||||
.then((data) => {
|
|
||||||
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
|
|
||||||
|
|
||||||
this.authorCopy.updatedAt = data.author.updatedAt
|
|
||||||
this.authorCopy.imagePath = data.author.imagePath
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
this.$toast.error(this.$strings.ToastRemoveFailed)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
submitUploadCover() {
|
|
||||||
if (!this.imageUrl?.startsWith('http:') && !this.imageUrl?.startsWith('https:')) {
|
|
||||||
this.$toast.error(this.$strings.ToastInvalidImageUrl)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.processing = true
|
this.processing = true
|
||||||
const updatePayload = {
|
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
|
||||||
url: this.imageUrl
|
console.error('Failed', error)
|
||||||
|
this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (result && result.updated) {
|
||||||
|
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
|
||||||
|
this.$store.commit('globals/showEditAuthorModal', result.author)
|
||||||
}
|
}
|
||||||
this.$axios
|
this.processing = false
|
||||||
.$post(`/api/authors/${this.authorId}/image`, updatePayload)
|
|
||||||
.then((data) => {
|
|
||||||
this.imageUrl = ''
|
|
||||||
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
|
|
||||||
|
|
||||||
this.authorCopy.updatedAt = data.author.updatedAt
|
|
||||||
this.authorCopy.imagePath = data.author.imagePath
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
this.$toast.error(error.response.data || this.$strings.ToastRemoveFailed)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
async searchAuthor() {
|
async searchAuthor() {
|
||||||
if (!this.authorCopy.name && !this.authorCopy.asin) {
|
if (!this.authorCopy.name && !this.authorCopy.asin) {
|
||||||
this.$toast.error(this.$strings.ToastNameRequired)
|
this.$toast.error('Must enter an author name')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.processing = true
|
this.processing = true
|
||||||
@@ -218,29 +151,19 @@ export default {
|
|||||||
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
|
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
|
||||||
else payload.q = this.authorCopy.name
|
else payload.q = this.authorCopy.name
|
||||||
|
|
||||||
payload.region = 'us'
|
|
||||||
if (this.libraryProvider.startsWith('audible.')) {
|
|
||||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
if (!response) {
|
if (!response) {
|
||||||
this.$toast.error(this.$strings.ToastAuthorSearchNotFound)
|
this.$toast.error('Author not found')
|
||||||
} else if (response.updated) {
|
} else if (response.updated) {
|
||||||
if (response.author.imagePath) {
|
if (response.author.imagePath) {
|
||||||
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
|
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
|
||||||
} else {
|
this.$store.commit('globals/showEditAuthorModal', response.author)
|
||||||
this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
|
} else this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
|
||||||
}
|
|
||||||
|
|
||||||
this.authorCopy = {
|
|
||||||
...response.author
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
this.$toast.info('No updates were made for Author')
|
||||||
}
|
}
|
||||||
this.processing = false
|
this.processing = false
|
||||||
}
|
}
|
||||||
@@ -248,4 +171,4 @@ export default {
|
|||||||
mounted() {},
|
mounted() {},
|
||||||
beforeDestroy() {}
|
beforeDestroy() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center px-4 py-4 justify-start relative hover:bg-primary/10" :class="wrapperClass" @click.stop="click" @mouseover="mouseover" @mouseleave="mouseleave">
|
<div class="flex items-center px-4 py-4 justify-start relative bg-primary hover:bg-opacity-25" :class="wrapperClass" @click.stop="click" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<div class="w-16 max-w-16 text-center">
|
<div class="w-16 max-w-16 text-center">
|
||||||
<p class="text-sm font-mono text-gray-400">
|
<p class="text-sm font-mono text-gray-400">
|
||||||
{{ this.$secondsToTimestamp(bookmark.time / playbackRate) }}
|
{{ this.$secondsToTimestamp(bookmark.time) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow overflow-hidden px-2">
|
<div class="flex-grow overflow-hidden px-2">
|
||||||
@@ -10,11 +10,11 @@
|
|||||||
<form @submit.prevent="submitUpdate">
|
<form @submit.prevent="submitUpdate">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-grow pr-2">
|
<div class="flex-grow pr-2">
|
||||||
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full h-10" />
|
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
|
||||||
</div>
|
</div>
|
||||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-symbols text-2xl -mt-px">forward</span></ui-btn>
|
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons text-2xl -mt-px">forward</span></ui-btn>
|
||||||
<div class="pl-2 flex items-center">
|
<div class="pl-2 flex items-center">
|
||||||
<span class="material-symbols text-3xl text-white text-opacity-70 hover:text-opacity-95 cursor-pointer" @click.stop.prevent="cancelEditing">close</span>
|
<span class="material-icons text-3xl text-white text-opacity-70 hover:text-opacity-95 cursor-pointer" @click.stop.prevent="cancelEditing">close</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
<p v-else class="pl-2 pr-2 truncate">{{ bookmark.title }}</p>
|
<p v-else class="pl-2 pr-2 truncate">{{ bookmark.title }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isEditing" class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
|
<div v-if="!isEditing" class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
|
||||||
<span class="material-symbols text-xl mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
|
<span class="material-icons text-xl mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
|
||||||
<span class="material-symbols text-xl text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span>
|
<span class="material-icons text-xl text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -35,8 +35,7 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
highlight: Boolean,
|
highlight: Boolean
|
||||||
playbackRate: Number
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -84,19 +83,11 @@ export default {
|
|||||||
if (this.newBookmarkTitle === this.bookmark.title) {
|
if (this.newBookmarkTitle === this.bookmark.title) {
|
||||||
return this.cancelEditing()
|
return this.cancelEditing()
|
||||||
}
|
}
|
||||||
const bookmark = { ...this.bookmark }
|
var bookmark = { ...this.bookmark }
|
||||||
bookmark.title = this.newBookmarkTitle
|
bookmark.title = this.newBookmarkTitle
|
||||||
|
this.$emit('update', bookmark)
|
||||||
this.$axios
|
|
||||||
.$patch(`/api/me/item/${bookmark.libraryItemId}/bookmark`, bookmark)
|
|
||||||
.then(() => {
|
|
||||||
this.isEditing = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
|
||||||
console.error(error)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -2,19 +2,12 @@
|
|||||||
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
|
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
<h1 class="text-3xl text-white truncate">Changelog</h1>
|
<p class="text-3xl text-white truncate">Changelog</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
|
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
|
||||||
<template v-for="release in releasesToShow">
|
<p class="text-xl font-bold pb-4">Changelog v{{ currentVersionNumber }}</p>
|
||||||
<div :key="release.name">
|
<div class="custom-text" v-html="compiledMarkedown" />
|
||||||
<p class="text-xl font-bold pb-4">
|
|
||||||
Changelog <a :href="`https://github.com/advplyr/audiobookshelf/releases/tag/${release.name}`" target="_blank" class="hover:underline">{{ release.name }}</a> ({{ $formatDate(release.pubdate, dateFormat) }})
|
|
||||||
</p>
|
|
||||||
<div class="custom-text" v-html="getChangelog(release)" />
|
|
||||||
</div>
|
|
||||||
<div v-if="release !== releasesToShow[releasesToShow.length - 1]" :key="`${release.name}-divider`" class="border-b border-black-300 my-8" />
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
</template>
|
</template>
|
||||||
@@ -25,9 +18,17 @@ import { marked } from '@/static/libs/marked/index.js'
|
|||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
value: Boolean,
|
value: Boolean,
|
||||||
versionData: {
|
changelog: String,
|
||||||
type: Object,
|
currentVersion: String
|
||||||
default: () => {}
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -39,17 +40,15 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dateFormat() {
|
compiledMarkedown() {
|
||||||
return this.$store.state.serverSettings.dateFormat
|
return marked.parse(this.changelog, { gfm: true, breaks: true })
|
||||||
},
|
},
|
||||||
releasesToShow() {
|
currentVersionNumber() {
|
||||||
return this.versionData?.releasesToShow || []
|
return this.currentVersion
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
getChangelog(release) {
|
init() {}
|
||||||
return marked.parse(release.changelog || 'No Changelog Available', { gfm: true, breaks: true })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
@@ -58,7 +57,7 @@ export default {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
/*
|
/*
|
||||||
1. we need to manually define styles to apply to the parsed markdown elements,
|
1. we need to manually define styles to apply to the parsed markdown elements,
|
||||||
since we don't have access to the actual elements in this component
|
since we don't have access to the actual elements in this component
|
||||||
|
|
||||||
2. v-deep allows these to take effect on the content passed in to the v-html in the div above
|
2. v-deep allows these to take effect on the content passed in to the v-html in the div above
|
||||||
*/
|
*/
|
||||||
@@ -71,4 +70,4 @@ since we don't have access to the actual elements in this component
|
|||||||
.custom-text ::v-deep > ul {
|
.custom-text ::v-deep > ul {
|
||||||
@apply list-disc list-inside pb-4;
|
@apply list-disc list-inside pb-4;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
<div v-if="show" class="w-full h-full">
|
<div v-if="show" class="w-full h-full">
|
||||||
<div class="py-4 px-4">
|
<div class="py-4 px-4">
|
||||||
<h1 v-if="!showBatchCollectionModal" class="text-2xl">{{ $strings.LabelAddToCollection }}</h1>
|
<h1 v-if="!showBatchCollectionModal" class="text-2xl">{{ $strings.LabelAddToCollection }}</h1>
|
||||||
@@ -19,20 +19,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</transition-group>
|
</transition-group>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!collections.length" class="flex h-32 items-center justify-center text-center px-2">
|
<div v-if="!collections.length" class="flex h-32 items-center justify-center">
|
||||||
<div>
|
<p class="text-xl">{{ $strings.MessageNoCollections }}</p>
|
||||||
<p class="text-xl mb-2">{{ $strings.MessageNoCollections }}</p>
|
|
||||||
<div class="text-sm flex items-center justify-center text-gray-200">
|
|
||||||
<p>{{ $strings.MessageBookshelfNoCollectionsHelp }}</p>
|
|
||||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
|
||||||
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
|
|
||||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
|
||||||
</a>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full h-px bg-white bg-opacity-10" />
|
<div class="w-full h-px bg-white bg-opacity-10" />
|
||||||
<form @submit.prevent="submitCreateCollection">
|
<form @submit.prevent="submitCreateCollection">
|
||||||
<div class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
|
<div class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
|
||||||
@@ -133,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(this.$strings.ToastFailedToLoadData)
|
this.$toast.error('Failed to load collections')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
@@ -149,11 +138,12 @@ export default {
|
|||||||
.$post(`/api/collections/${collection.id}/batch/remove`, { books: this.selectedBookIds })
|
.$post(`/api/collections/${collection.id}/batch/remove`, { books: this.selectedBookIds })
|
||||||
.then((updatedCollection) => {
|
.then((updatedCollection) => {
|
||||||
console.log(`Books removed from collection`, updatedCollection)
|
console.log(`Books removed from collection`, updatedCollection)
|
||||||
|
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to remove books from collection', error)
|
console.error('Failed to remove books from collection', error)
|
||||||
this.$toast.error(this.$strings.ToastRemoveFailed)
|
this.$toast.error(this.$strings.ToastCollectionItemsRemoveFailed)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -162,11 +152,12 @@ export default {
|
|||||||
.$delete(`/api/collections/${collection.id}/book/${this.selectedLibraryItemId}`)
|
.$delete(`/api/collections/${collection.id}/book/${this.selectedLibraryItemId}`)
|
||||||
.then((updatedCollection) => {
|
.then((updatedCollection) => {
|
||||||
console.log(`Book removed from collection`, updatedCollection)
|
console.log(`Book removed from collection`, updatedCollection)
|
||||||
|
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to remove book from collection', error)
|
console.error('Failed to remove book from collection', error)
|
||||||
this.$toast.error(this.$strings.ToastRemoveFailed)
|
this.$toast.error(this.$strings.ToastCollectionItemsRemoveFailed)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -176,16 +167,17 @@ export default {
|
|||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
if (this.showBatchCollectionModal) {
|
if (this.showBatchCollectionModal) {
|
||||||
// BATCH Add books
|
// BATCH Remove books
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/collections/${collection.id}/batch/add`, { books: this.selectedBookIds })
|
.$post(`/api/collections/${collection.id}/batch/add`, { books: this.selectedBookIds })
|
||||||
.then((updatedCollection) => {
|
.then((updatedCollection) => {
|
||||||
console.log(`Books added to collection`, updatedCollection)
|
console.log(`Books added to collection`, updatedCollection)
|
||||||
|
this.$toast.success('Books added to collection')
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to add books to collection', error)
|
console.error('Failed to add books to collection', error)
|
||||||
this.$toast.error(this.$strings.ToastCollectionItemsAddFailed)
|
this.$toast.error('Failed to add books to collection')
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -195,11 +187,12 @@ export default {
|
|||||||
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId })
|
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId })
|
||||||
.then((updatedCollection) => {
|
.then((updatedCollection) => {
|
||||||
console.log(`Book added to collection`, updatedCollection)
|
console.log(`Book added to collection`, updatedCollection)
|
||||||
|
this.$toast.success('Book added to collection')
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to add book to collection', error)
|
console.error('Failed to add book to collection', error)
|
||||||
this.$toast.error(this.$strings.ToastCollectionItemsAddFailed)
|
this.$toast.error('Failed to add book to collection')
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -221,13 +214,14 @@ export default {
|
|||||||
.$post('/api/collections', newCollection)
|
.$post('/api/collections', newCollection)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
console.log('New Collection Created', data)
|
console.log('New Collection Created', data)
|
||||||
|
this.$toast.success(`Collection "${data.name}" created`)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
this.newCollectionName = ''
|
this.newCollectionName = ''
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to create collection', error)
|
console.error('Failed to create collection', error)
|
||||||
var errMsg = error.response ? error.response.data || '' : ''
|
var errMsg = error.response ? error.response.data || '' : ''
|
||||||
this.$toast.error(this.$strings.ToastCollectionCreateFailed + ': ' + errMsg)
|
this.$toast.error(`Failed to create collection: ${errMsg}`)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center px-4 py-2 justify-start relative hover:bg-black-400" @mouseover="mouseover" @mouseleave="mouseleave">
|
<div class="flex items-center px-4 py-2 justify-start relative hover:bg-bg" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<div v-if="isBookIncluded" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
|
<div v-if="isBookIncluded" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
|
||||||
<div class="w-20 max-w-20 text-center">
|
<div class="w-20 max-w-20 text-center">
|
||||||
<covers-collection-cover :book-items="books" :width="80" :height="40 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-collection-cover :book-items="books" :width="80" :height="40 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
<nuxt-link :to="`/collection/${collection.id}`" class="pl-2 pr-2 truncate hover:underline cursor-pointer" @click.native="clickNuxtLink">{{ collection.name }}</nuxt-link>
|
<nuxt-link :to="`/collection/${collection.id}`" class="pl-2 pr-2 truncate hover:underline cursor-pointer" @click.native="clickNuxtLink">{{ collection.name }}</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
|
<div class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
|
||||||
<ui-btn v-if="!isBookIncluded" color="success" :padding-x="3" small class="h-9" @click.stop="clickAdd"><span class="material-symbols text-2xl pt-px">add</span></ui-btn>
|
<ui-btn v-if="!isBookIncluded" color="success" :padding-x="3" small class="h-9" @click.stop="clickAdd"><span class="material-icons text-2xl pt-px">add</span></ui-btn>
|
||||||
<ui-btn v-else color="error" :padding-x="3" class="h-9" small @click.stop="clickRem"><span class="material-symbols text-2xl pt-px">remove</span></ui-btn>
|
<ui-btn v-else color="error" :padding-x="3" class="h-9" small @click.stop="clickRem"><span class="material-icons text-2xl pt-px">remove</span></ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -8,9 +8,10 @@
|
|||||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||||
<template v-if="!showImageUploader">
|
<template v-if="!showImageUploader">
|
||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="submitForm">
|
||||||
<div class="flex flex-wrap">
|
<div class="flex">
|
||||||
<div class="w-full flex justify-center mb-2 md:w-auto md:mb-0 md:block">
|
<div>
|
||||||
<covers-collection-cover :book-items="books" :width="200" :height="100 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-collection-cover :book-items="books" :width="200" :height="100 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
<!-- <ui-btn type="button" @click="showImageUploader = true">Upload</ui-btn> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-4">
|
<div class="flex-grow px-4">
|
||||||
<ui-text-input-with-label v-model="newCollectionName" :label="$strings.LabelName" class="mb-2" />
|
<ui-text-input-with-label v-model="newCollectionName" :label="$strings.LabelName" class="mb-2" />
|
||||||
@@ -28,7 +29,7 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="flex items-center mb-3">
|
<div class="flex items-center mb-3">
|
||||||
<div class="hover:bg-white hover:bg-opacity-10 cursor-pointer h-11 w-11 flex items-center justify-center rounded-full" @click="showImageUploader = false">
|
<div class="hover:bg-white hover:bg-opacity-10 cursor-pointer h-11 w-11 flex items-center justify-center rounded-full" @click="showImageUploader = false">
|
||||||
<span class="material-symbols text-4xl">arrow_back</span>
|
<span class="material-icons text-4xl">arrow_back</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="ml-2 text-xl mb-1">Collection Cover Image</p>
|
<p class="ml-2 text-xl mb-1">Collection Cover Image</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
<ui-btn color="success">Upload</ui-btn>
|
<ui-btn color="success">Upload</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- <modals-upload-image-modal v-model="showUploadImageModal" entity="collection" :entity-id="collection.id" /> -->
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
</template>
|
</template>
|
||||||
@@ -106,7 +108,7 @@ export default {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to remove collection', error)
|
console.error('Failed to remove collection', error)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
this.$toast.error(this.$strings.ToastRemoveFailed)
|
this.$toast.error(this.$strings.ToastCollectionRemoveFailed)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -115,7 +117,7 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!this.newCollectionName) {
|
if (!this.newCollectionName) {
|
||||||
return this.$toast.error(this.$strings.ToastNameRequired)
|
return this.$toast.error('Collection must have a name')
|
||||||
}
|
}
|
||||||
|
|
||||||
this.processing = true
|
this.processing = true
|
||||||
@@ -135,7 +137,7 @@ export default {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to update collection', error)
|
console.error('Failed to update collection', error)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
this.$toast.error(this.$strings.ToastCollectionUpdateFailed)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
<template>
|
|
||||||
<modals-modal ref="modal" v-model="show" name="ereader-device-edit" :width="800" :height="'unset'" :processing="processing">
|
|
||||||
<template #outer>
|
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
|
||||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<form @submit.prevent="submitForm">
|
|
||||||
<div class="w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300">
|
|
||||||
<div class="w-full px-3 py-5 md:p-12">
|
|
||||||
<div class="flex items-center -mx-1 mb-4">
|
|
||||||
<div class="w-full md:w-1/2 px-1">
|
|
||||||
<ui-text-input-with-label ref="ereaderNameInput" v-model="newDevice.name" :disabled="processing" :label="$strings.LabelName" />
|
|
||||||
</div>
|
|
||||||
<div class="w-full md:w-1/2 px-1">
|
|
||||||
<ui-text-input-with-label ref="ereaderEmailInput" v-model="newDevice.email" :disabled="processing" :label="$strings.LabelEmail" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center -mx-1 mb-4">
|
|
||||||
<div class="w-full md:w-1/2 px-1">
|
|
||||||
<ui-dropdown v-model="newDevice.availabilityOption" :label="$strings.LabelDeviceIsAvailableTo" :items="userAvailabilityOptions" @input="availabilityOptionChanged" />
|
|
||||||
</div>
|
|
||||||
<div class="w-full md:w-1/2 px-1">
|
|
||||||
<ui-multi-select-dropdown v-if="newDevice.availabilityOption === 'specificUsers'" v-model="newDevice.users" :label="$strings.HeaderUsers" :items="userOptions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center pt-4">
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
value: Boolean,
|
|
||||||
existingDevices: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
ereaderDevice: {
|
|
||||||
type: Object,
|
|
||||||
default: () => null
|
|
||||||
},
|
|
||||||
users: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
loadUsers: Function
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
processing: false,
|
|
||||||
newDevice: {
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
availabilityOption: 'adminAndUp',
|
|
||||||
users: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
show: {
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.value
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('input', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title() {
|
|
||||||
return !this.ereaderDevice ? 'Create Device' : 'Update Device'
|
|
||||||
},
|
|
||||||
userAvailabilityOptions() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelAdminUsersOnly,
|
|
||||||
value: 'adminOrUp'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelAllUsersExcludingGuests,
|
|
||||||
value: 'userOrUp'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelAllUsersIncludingGuests,
|
|
||||||
value: 'guestOrUp'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelSelectUsers,
|
|
||||||
value: 'specificUsers'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
userOptions() {
|
|
||||||
return this.users.map((u) => ({ text: u.username, value: u.id }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
availabilityOptionChanged(option) {
|
|
||||||
if (option === 'specificUsers' && !this.users.length) {
|
|
||||||
this.callLoadUsers()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async callLoadUsers() {
|
|
||||||
this.processing = true
|
|
||||||
await this.loadUsers()
|
|
||||||
this.processing = false
|
|
||||||
},
|
|
||||||
submitForm() {
|
|
||||||
this.$refs.ereaderNameInput.blur()
|
|
||||||
this.$refs.ereaderEmailInput.blur()
|
|
||||||
|
|
||||||
if (!this.newDevice.name?.trim() || !this.newDevice.email?.trim()) {
|
|
||||||
this.$toast.error(this.$strings.ToastNameEmailRequired)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.newDevice.availabilityOption === 'specificUsers' && !this.newDevice.users.length) {
|
|
||||||
this.$toast.error(this.$strings.ToastSelectAtLeastOneUser)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (this.newDevice.availabilityOption !== 'specificUsers') {
|
|
||||||
this.newDevice.users = []
|
|
||||||
}
|
|
||||||
|
|
||||||
this.newDevice.name = this.newDevice.name.trim()
|
|
||||||
this.newDevice.email = this.newDevice.email.trim()
|
|
||||||
|
|
||||||
if (!this.ereaderDevice) {
|
|
||||||
if (this.existingDevices.some((d) => d.name === this.newDevice.name)) {
|
|
||||||
this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.submitCreate()
|
|
||||||
} else {
|
|
||||||
if (this.ereaderDevice.name !== this.newDevice.name && this.existingDevices.some((d) => d.name === this.newDevice.name)) {
|
|
||||||
this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.submitUpdate()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
submitUpdate() {
|
|
||||||
this.processing = true
|
|
||||||
|
|
||||||
const existingDevicesWithoutThisOne = this.existingDevices.filter((d) => d.name !== this.ereaderDevice.name)
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
ereaderDevices: [
|
|
||||||
...existingDevicesWithoutThisOne,
|
|
||||||
{
|
|
||||||
...this.newDevice
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$axios
|
|
||||||
.$post(`/api/emails/ereader-devices`, payload)
|
|
||||||
.then((data) => {
|
|
||||||
this.$emit('update', data.ereaderDevices)
|
|
||||||
this.show = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to update device', error)
|
|
||||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
submitCreate() {
|
|
||||||
this.processing = true
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
ereaderDevices: [
|
|
||||||
...this.existingDevices,
|
|
||||||
{
|
|
||||||
...this.newDevice
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$axios
|
|
||||||
.$post('/api/emails/ereader-devices', payload)
|
|
||||||
.then((data) => {
|
|
||||||
this.$emit('update', data.ereaderDevices || [])
|
|
||||||
this.show = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to add device', error)
|
|
||||||
this.$toast.error(this.$strings.ToastDeviceAddFailed)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
init() {
|
|
||||||
if (this.ereaderDevice) {
|
|
||||||
this.newDevice.name = this.ereaderDevice.name
|
|
||||||
this.newDevice.email = this.ereaderDevice.email
|
|
||||||
this.newDevice.availabilityOption = this.ereaderDevice.availabilityOption || 'adminOrUp'
|
|
||||||
this.newDevice.users = this.ereaderDevice.users || []
|
|
||||||
} else {
|
|
||||||
this.newDevice.name = ''
|
|
||||||
this.newDevice.email = ''
|
|
||||||
this.newDevice.availabilityOption = 'adminOrUp'
|
|
||||||
this.newDevice.users = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
<template>
|
|
||||||
<modals-modal ref="modal" v-model="show" name="ereader-device-edit" :width="800" :height="'unset'" :processing="processing">
|
|
||||||
<template #outer>
|
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
|
||||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<form @submit.prevent="submitForm">
|
|
||||||
<div class="w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300">
|
|
||||||
<div class="w-full px-3 py-5 md:p-12">
|
|
||||||
<div class="flex items-center -mx-1 mb-4">
|
|
||||||
<div class="w-full md:w-1/2 px-1">
|
|
||||||
<ui-text-input-with-label ref="ereaderNameInput" v-model="newDevice.name" :disabled="processing" :label="$strings.LabelName" />
|
|
||||||
</div>
|
|
||||||
<div class="w-full md:w-1/2 px-1">
|
|
||||||
<ui-text-input-with-label ref="ereaderEmailInput" v-model="newDevice.email" :disabled="processing" :label="$strings.LabelEmail" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center pt-4">
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
value: Boolean,
|
|
||||||
existingDevices: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
ereaderDevice: {
|
|
||||||
type: Object,
|
|
||||||
default: () => null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
processing: false,
|
|
||||||
newDevice: {
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
availabilityOption: 'adminAndUp',
|
|
||||||
users: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
show: {
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.value
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('input', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
user() {
|
|
||||||
return this.$store.state.user.user
|
|
||||||
},
|
|
||||||
title() {
|
|
||||||
return !this.ereaderDevice ? 'Create Device' : 'Update Device'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
submitForm() {
|
|
||||||
this.$refs.ereaderNameInput.blur()
|
|
||||||
this.$refs.ereaderEmailInput.blur()
|
|
||||||
|
|
||||||
if (!this.newDevice.name?.trim() || !this.newDevice.email?.trim()) {
|
|
||||||
this.$toast.error(this.$strings.ToastNameEmailRequired)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.newDevice.name = this.newDevice.name.trim()
|
|
||||||
this.newDevice.email = this.newDevice.email.trim()
|
|
||||||
|
|
||||||
// Only catches duplicate names for the current user
|
|
||||||
// Duplicates with other users caught on server side
|
|
||||||
if (!this.ereaderDevice) {
|
|
||||||
if (this.existingDevices.some((d) => d.name === this.newDevice.name)) {
|
|
||||||
this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.submitCreate()
|
|
||||||
} else {
|
|
||||||
if (this.ereaderDevice.name !== this.newDevice.name && this.existingDevices.some((d) => d.name === this.newDevice.name)) {
|
|
||||||
this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.submitUpdate()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
submitUpdate() {
|
|
||||||
this.processing = true
|
|
||||||
|
|
||||||
const existingDevicesWithoutThisOne = this.existingDevices.filter((d) => d.name !== this.ereaderDevice.name)
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
ereaderDevices: [
|
|
||||||
...existingDevicesWithoutThisOne,
|
|
||||||
{
|
|
||||||
...this.newDevice
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$axios
|
|
||||||
.$post(`/api/me/ereader-devices`, payload)
|
|
||||||
.then((data) => {
|
|
||||||
this.$emit('update', data.ereaderDevices)
|
|
||||||
this.show = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to update device', error)
|
|
||||||
if (error.response?.data?.toLowerCase().includes('duplicate')) {
|
|
||||||
this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)
|
|
||||||
} else {
|
|
||||||
this.$toast.error(this.$strings.ToastDeviceAddFailed)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
submitCreate() {
|
|
||||||
this.processing = true
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
ereaderDevices: [
|
|
||||||
...this.existingDevices,
|
|
||||||
{
|
|
||||||
...this.newDevice
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$axios
|
|
||||||
.$post('/api/me/ereader-devices', payload)
|
|
||||||
.then((data) => {
|
|
||||||
this.$emit('update', data.ereaderDevices || [])
|
|
||||||
this.show = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to add device', error)
|
|
||||||
if (error.response?.data?.toLowerCase().includes('duplicate')) {
|
|
||||||
this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)
|
|
||||||
} else {
|
|
||||||
this.$toast.error(this.$strings.ToastDeviceAddFailed)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
init() {
|
|
||||||
if (this.ereaderDevice) {
|
|
||||||
this.newDevice.name = this.ereaderDevice.name
|
|
||||||
this.newDevice.email = this.ereaderDevice.email
|
|
||||||
this.newDevice.availabilityOption = this.ereaderDevice.availabilityOption || 'specificUsers'
|
|
||||||
this.newDevice.users = this.ereaderDevice.users || [this.user.id]
|
|
||||||
} else {
|
|
||||||
this.newDevice.name = ''
|
|
||||||
this.newDevice.email = ''
|
|
||||||
this.newDevice.availabilityOption = 'specificUsers'
|
|
||||||
this.newDevice.users = [this.user.id]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -2,24 +2,24 @@
|
|||||||
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="marginTop">
|
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="marginTop">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-4 landscape:px-4 landscape:py-2 md:portrait:p-5 lg:p-5 w-2/3 overflow-hidden pointer-events-none">
|
<div class="absolute top-0 left-0 p-4 landscape:px-4 landscape:py-2 md:portrait:p-5 lg:p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||||
<h1 class="text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</h1>
|
<p class="text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div role="tablist" class="absolute -top-10 left-0 z-10 w-full flex">
|
<div class="absolute -top-10 left-0 z-10 w-full flex">
|
||||||
<template v-for="tab in availableTabs">
|
<template v-for="tab in availableTabs">
|
||||||
<button :key="tab.id" role="tab" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</button>
|
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div role="tabpanel" class="w-full h-full max-h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
|
|
||||||
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
<div v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
||||||
<button class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" :aria-label="$strings.ButtonNext" @click.stop.prevent="goPrevBook" @mousedown.prevent>arrow_back_ios</button>
|
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goPrevBook" @mousedown.prevent>arrow_back_ios</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
<div v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
||||||
<button class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" :aria-label="$strings.ButtonPrevious" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</button>
|
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full h-full max-h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
|
||||||
|
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
</template>
|
</template>
|
||||||
@@ -74,9 +74,6 @@ export default {
|
|||||||
this.$store.commit('setEditModalTab', val)
|
this.$store.commit('setEditModalTab', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
height() {
|
|
||||||
return Math.min(this.availableHeight, 650)
|
|
||||||
},
|
|
||||||
tabs() {
|
tabs() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -127,6 +124,9 @@ export default {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
},
|
||||||
@@ -136,26 +136,14 @@ export default {
|
|||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
selectedLibraryItem() {
|
|
||||||
return this.$store.state.selectedLibraryItem || {}
|
|
||||||
},
|
|
||||||
selectedLibraryItemId() {
|
|
||||||
return this.selectedLibraryItem.id
|
|
||||||
},
|
|
||||||
media() {
|
|
||||||
return this.libraryItem?.media || {}
|
|
||||||
},
|
|
||||||
mediaMetadata() {
|
|
||||||
return this.media.metadata || {}
|
|
||||||
},
|
|
||||||
availableTabs() {
|
availableTabs() {
|
||||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||||
return this.tabs.filter((tab) => {
|
return this.tabs.filter((tab) => {
|
||||||
|
if (tab.experimental && !this.showExperimentalFeatures) return false
|
||||||
if (tab.mediaType && this.mediaType !== tab.mediaType) return false
|
if (tab.mediaType && this.mediaType !== tab.mediaType) return false
|
||||||
if (tab.admin && !this.userIsAdminOrUp) return false
|
if (tab.admin && !this.userIsAdminOrUp) return false
|
||||||
|
|
||||||
if (tab.id === 'tools' && this.isMissing) return false
|
if (tab.id === 'tools' && this.isMissing) return false
|
||||||
if (tab.id === 'chapters' && this.isEBookOnly) return false
|
|
||||||
|
|
||||||
if ((tab.id === 'tools' || tab.id === 'files') && this.userCanDownload) return true
|
if ((tab.id === 'tools' || tab.id === 'files') && this.userCanDownload) return true
|
||||||
if (tab.id !== 'tools' && tab.id !== 'files' && this.userCanUpdate) return true
|
if (tab.id !== 'tools' && tab.id !== 'files' && this.userCanUpdate) return true
|
||||||
@@ -163,6 +151,9 @@ export default {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
height() {
|
||||||
|
return Math.min(this.availableHeight, 650)
|
||||||
|
},
|
||||||
tabName() {
|
tabName() {
|
||||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||||
return _tab ? _tab.component : ''
|
return _tab ? _tab.component : ''
|
||||||
@@ -170,11 +161,20 @@ export default {
|
|||||||
isMissing() {
|
isMissing() {
|
||||||
return this.selectedLibraryItem.isMissing
|
return this.selectedLibraryItem.isMissing
|
||||||
},
|
},
|
||||||
isEBookOnly() {
|
selectedLibraryItem() {
|
||||||
return this.media.ebookFile && !this.media.tracks?.length
|
return this.$store.state.selectedLibraryItem || {}
|
||||||
|
},
|
||||||
|
selectedLibraryItemId() {
|
||||||
|
return this.selectedLibraryItem.id
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
mediaType() {
|
mediaType() {
|
||||||
return this.libraryItem?.mediaType || null
|
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.mediaMetadata.title || 'No Title'
|
return this.mediaMetadata.title || 'No Title'
|
||||||
@@ -196,9 +196,6 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
async goPrevBook() {
|
async goPrevBook() {
|
||||||
if (this.currentBookshelfIndex - 1 < 0) return
|
if (this.currentBookshelfIndex - 1 < 0) return
|
||||||
// Remove focus from active input
|
|
||||||
document.activeElement?.blur?.()
|
|
||||||
|
|
||||||
var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]
|
var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]
|
||||||
this.processing = true
|
this.processing = true
|
||||||
var prevBook = await this.$axios.$get(`/api/items/${prevBookId}?expanded=1`).catch((error) => {
|
var prevBook = await this.$axios.$get(`/api/items/${prevBookId}?expanded=1`).catch((error) => {
|
||||||
@@ -218,9 +215,6 @@ export default {
|
|||||||
},
|
},
|
||||||
async goNextBook() {
|
async goNextBook() {
|
||||||
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
|
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
|
||||||
// Remove focus from active input
|
|
||||||
document.activeElement?.blur?.()
|
|
||||||
|
|
||||||
this.processing = true
|
this.processing = true
|
||||||
var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1]
|
var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1]
|
||||||
var nextBook = await this.$axios.$get(`/api/items/${nextBookId}?expanded=1`).catch((error) => {
|
var nextBook = await this.$axios.$get(`/api/items/${nextBookId}?expanded=1`).catch((error) => {
|
||||||
@@ -306,4 +300,4 @@ export default {
|
|||||||
.tab.tab-selected {
|
.tab.tab-selected {
|
||||||
height: 41px;
|
height: 41px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -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 @close="closeModal" />
|
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" keep-open />
|
||||||
<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`" @click="clickAddChapters">{{ $strings.ButtonAddChapters }}</ui-btn>
|
<ui-btn v-if="userCanUpdate" :to="`/audiobook/${libraryItem.id}/chapters`">{{ $strings.ButtonAddChapters }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,7 +23,7 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem?.media || {}
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
},
|
},
|
||||||
chapters() {
|
chapters() {
|
||||||
return this.media.chapters || []
|
return this.media.chapters || []
|
||||||
@@ -32,15 +32,6 @@ 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,46 +1,44 @@
|
|||||||
<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-col sm:flex-row mb-4">
|
<div class="flex flex-wrap">
|
||||||
<div class="relative self-center">
|
<div class="relative">
|
||||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, libraryItemUpdatedAt, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
|
||||||
<!-- book cover overlay -->
|
<!-- book cover overlay -->
|
||||||
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
||||||
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
|
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
|
||||||
<div v-if="userCanDelete" class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
|
<div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
|
||||||
<ui-tooltip direction="top" :text="$strings.LabelRemoveCover">
|
<ui-tooltip direction="top" :text="$strings.LabelRemoveCover">
|
||||||
<span class="material-symbols text-2xl">delete</span>
|
<span class="material-icons text-2xl">delete</span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-6 md:mt-0">
|
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 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 pt-4 md:min-w-32">
|
||||||
<ui-file-input ref="fileInput" @change="fileUploadSelected">
|
<ui-file-input ref="fileInput" @change="fileUploadSelected"
|
||||||
<span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span>
|
><span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span
|
||||||
<span class="material-symbols text-2xl inline-block md:!hidden">upload</span>
|
><span class="material-icons text-2xl inline-block md:!hidden">upload</span></ui-file-input
|
||||||
</ui-file-input>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form @submit.prevent="submitForm" class="flex flex-grow">
|
<form @submit.prevent="submitForm" class="flex flex-grow">
|
||||||
<ui-text-input v-model="imageUrl" :placeholder="$strings.LabelImageURLFromTheWeb" class="h-9 w-full" />
|
<ui-text-input-with-label v-model="imageUrl" :label="$strings.LabelCoverImageURL" />
|
||||||
<ui-btn color="success" type="submit" :padding-x="4" :disabled="!imageUrl" class="ml-2 sm:ml-3 w-24 h-9">{{ $strings.ButtonSubmit }}</ui-btn>
|
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-2 sm:ml-3 w-24">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-white border-opacity-10">
|
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
|
||||||
<div class="flex items-center justify-center py-2">
|
<div class="flex items-center justify-center py-2">
|
||||||
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
|
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn>
|
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showLocalCovers" class="flex items-center justify-center pb-2">
|
<div v-if="showLocalCovers" class="flex items-center justify-center">
|
||||||
<template v-for="localCoverFile in localCovers">
|
<template v-for="cover in localCovers">
|
||||||
<div :key="localCoverFile.ino" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="localCoverFile.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(localCoverFile)">
|
<div :key="cover.path" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
||||||
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
||||||
<covers-preview-cover :src="localCoverFile.localPath" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-preview-cover :src="`${cover.localPath}?token=${userToken}`" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -49,23 +47,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="submitSearchForm">
|
<form @submit.prevent="submitSearchForm">
|
||||||
<div class="flex flex-wrap sm:flex-nowrap items-center justify-start -mx-1">
|
<div class="flex items-center justify-start -mx-1 h-20">
|
||||||
<div class="w-48 flex-grow p-1">
|
<div class="w-40 px-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 flex-grow p-1">
|
<div class="w-72 px-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 flex-grow p-1">
|
<div v-show="provider != 'itunes'" class="w-72 px-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 md:min-w-24" :padding-x="4" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
|
<ui-btn class="mt-5 ml-1" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<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">
|
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-80 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 === imageUrl ? 'border-yellow-300' : ''" @click="updateCover(cover)">
|
||||||
<covers-preview-cover :src="cover" :width="80" show-open-new-tab :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-preview-cover :src="cover" :width="80" show-open-new-tab :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -73,7 +71,7 @@
|
|||||||
|
|
||||||
<div v-if="previewUpload" class="absolute top-0 left-0 w-full h-full z-10 bg-bg p-8">
|
<div v-if="previewUpload" class="absolute top-0 left-0 w-full h-full z-10 bg-bg p-8">
|
||||||
<p class="text-lg">{{ $strings.HeaderPreviewCover }}</p>
|
<p class="text-lg">{{ $strings.HeaderPreviewCover }}</p>
|
||||||
<span class="absolute top-4 right-4 material-symbols text-2xl cursor-pointer" @click="resetCoverPreview">close</span>
|
<span class="absolute top-4 right-4 material-icons text-2xl cursor-pointer" @click="resetCoverPreview">close</span>
|
||||||
<div class="flex justify-center py-4">
|
<div class="flex justify-center py-4">
|
||||||
<covers-preview-cover :src="previewUpload" :width="240" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-preview-cover :src="previewUpload" :width="240" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
@@ -129,7 +127,7 @@ export default {
|
|||||||
},
|
},
|
||||||
providers() {
|
providers() {
|
||||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||||
return [{ text: 'All', value: 'all' }, ...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders]
|
return this.$store.state.scanners.providers
|
||||||
},
|
},
|
||||||
searchTitleLabel() {
|
searchTitleLabel() {
|
||||||
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
||||||
@@ -140,19 +138,16 @@ export default {
|
|||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem?.id || null
|
return this.libraryItem ? this.libraryItem.id : null
|
||||||
},
|
|
||||||
libraryItemUpdatedAt() {
|
|
||||||
return this.libraryItem?.updatedAt || null
|
|
||||||
},
|
},
|
||||||
mediaType() {
|
mediaType() {
|
||||||
return this.libraryItem?.mediaType || null
|
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||||
},
|
},
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.mediaType == 'podcast'
|
return this.mediaType == 'podcast'
|
||||||
},
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem?.media || {}
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
},
|
},
|
||||||
coverPath() {
|
coverPath() {
|
||||||
return this.media.coverPath
|
return this.media.coverPath
|
||||||
@@ -161,14 +156,11 @@ export default {
|
|||||||
return this.media.metadata || {}
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
libraryFiles() {
|
libraryFiles() {
|
||||||
return this.libraryItem?.libraryFiles || []
|
return this.libraryItem ? this.libraryItem.libraryFiles || [] : []
|
||||||
},
|
},
|
||||||
userCanUpload() {
|
userCanUpload() {
|
||||||
return this.$store.getters['user/getUserCanUpload']
|
return this.$store.getters['user/getUserCanUpload']
|
||||||
},
|
},
|
||||||
userCanDelete() {
|
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
|
||||||
},
|
|
||||||
userToken() {
|
userToken() {
|
||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
},
|
||||||
@@ -176,8 +168,8 @@ export default {
|
|||||||
return this.libraryFiles
|
return this.libraryFiles
|
||||||
.filter((f) => f.fileType === 'image')
|
.filter((f) => f.fileType === 'image')
|
||||||
.map((file) => {
|
.map((file) => {
|
||||||
const _file = { ...file }
|
var _file = { ...file }
|
||||||
_file.localPath = `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${file.ino}?token=${this.userToken}`
|
_file.localPath = `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}`
|
||||||
return _file
|
return _file
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -194,6 +186,7 @@ export default {
|
|||||||
if (data.error) {
|
if (data.error) {
|
||||||
this.$toast.error(data.error)
|
this.$toast.error(data.error)
|
||||||
} else {
|
} else {
|
||||||
|
this.$toast.success('Cover Uploaded')
|
||||||
this.resetCoverPreview()
|
this.resetCoverPreview()
|
||||||
}
|
}
|
||||||
this.processingUpload = false
|
this.processingUpload = false
|
||||||
@@ -203,7 +196,7 @@ export default {
|
|||||||
if (error.response && error.response.data) {
|
if (error.response && error.response.data) {
|
||||||
this.$toast.error(error.response.data)
|
this.$toast.error(error.response.data)
|
||||||
} else {
|
} else {
|
||||||
this.$toast.error(this.$strings.ToastUnknownError)
|
this.$toast.error('Oops, something went wrong...')
|
||||||
}
|
}
|
||||||
this.processingUpload = false
|
this.processingUpload = false
|
||||||
})
|
})
|
||||||
@@ -225,62 +218,82 @@ export default {
|
|||||||
this.coversFound = []
|
this.coversFound = []
|
||||||
this.hasSearched = false
|
this.hasSearched = false
|
||||||
}
|
}
|
||||||
this.imageUrl = ''
|
this.imageUrl = this.media.coverPath || ''
|
||||||
this.searchTitle = this.mediaMetadata.title || ''
|
this.searchTitle = this.mediaMetadata.title || ''
|
||||||
this.searchAuthor = this.mediaMetadata.authorName || ''
|
this.searchAuthor = this.mediaMetadata.authorName || ''
|
||||||
if (this.isPodcast) this.provider = 'itunes'
|
if (this.isPodcast) this.provider = 'itunes'
|
||||||
else this.provider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'
|
else this.provider = localStorage.getItem('book-provider') || 'google'
|
||||||
},
|
},
|
||||||
removeCover() {
|
removeCover() {
|
||||||
if (!this.coverPath) {
|
if (!this.media.coverPath) {
|
||||||
|
this.imageUrl = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.isProcessing = true
|
this.updateCover('')
|
||||||
this.$axios
|
|
||||||
.$delete(`/api/items/${this.libraryItemId}/cover`)
|
|
||||||
.then(() => {})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to remove cover', error)
|
|
||||||
if (error.response?.data) {
|
|
||||||
this.$toast.error(error.response.data)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.isProcessing = false
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
submitForm() {
|
submitForm() {
|
||||||
this.updateCover(this.imageUrl)
|
this.updateCover(this.imageUrl)
|
||||||
},
|
},
|
||||||
async updateCover(cover) {
|
async updateCover(cover) {
|
||||||
if (!cover.startsWith('http:') && !cover.startsWith('https:')) {
|
if (cover === this.coverPath) {
|
||||||
this.$toast.error(this.$strings.ToastInvalidUrl)
|
console.warn('Cover has not changed..', cover)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isProcessing = true
|
this.isProcessing = true
|
||||||
this.$axios
|
var success = false
|
||||||
.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover })
|
|
||||||
.then(() => {
|
if (!cover) {
|
||||||
this.imageUrl = ''
|
// Remove cover
|
||||||
|
success = await this.$axios
|
||||||
|
.$delete(`/api/items/${this.libraryItemId}/cover`)
|
||||||
|
.then(() => true)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to remove cover', error)
|
||||||
|
if (error.response && error.response.data) {
|
||||||
|
this.$toast.error(error.response.data)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
} else if (cover.startsWith('http:') || cover.startsWith('https:')) {
|
||||||
|
// Download cover from url and use
|
||||||
|
success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover }).catch((error) => {
|
||||||
|
console.error('Failed to download cover from url', error)
|
||||||
|
if (error.response && error.response.data) {
|
||||||
|
this.$toast.error(error.response.data)
|
||||||
|
}
|
||||||
|
return false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
} else {
|
||||||
console.error('Failed to update cover', error)
|
// Update local cover url
|
||||||
this.$toast.error(error.response?.data || this.$strings.ToastCoverUpdateFailed)
|
const updatePayload = {
|
||||||
})
|
cover
|
||||||
.finally(() => {
|
}
|
||||||
this.isProcessing = false
|
success = await this.$axios.$patch(`/api/items/${this.libraryItemId}/cover`, updatePayload).catch((error) => {
|
||||||
|
console.error('Failed to update', error)
|
||||||
|
if (error.response && error.response.data) {
|
||||||
|
this.$toast.error(error.response.data)
|
||||||
|
}
|
||||||
|
return false
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
if (success) {
|
||||||
|
this.$toast.success('Update Successful')
|
||||||
|
// this.$emit('close')
|
||||||
|
} else {
|
||||||
|
this.imageUrl = this.media.coverPath || ''
|
||||||
|
}
|
||||||
|
this.isProcessing = false
|
||||||
},
|
},
|
||||||
getSearchQuery() {
|
getSearchQuery() {
|
||||||
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
|
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
|
||||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor || ''}`
|
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
||||||
if (this.isPodcast) searchQuery += '&podcast=1'
|
if (this.isPodcast) searchQuery += '&podcast=1'
|
||||||
return searchQuery
|
return searchQuery
|
||||||
},
|
},
|
||||||
persistProvider() {
|
persistProvider() {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('book-cover-provider', this.provider)
|
localStorage.setItem('book-provider', this.provider)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('PersistProvider', error)
|
console.error('PersistProvider', error)
|
||||||
}
|
}
|
||||||
@@ -303,17 +316,8 @@ export default {
|
|||||||
this.hasSearched = true
|
this.hasSearched = true
|
||||||
},
|
},
|
||||||
setCover(coverFile) {
|
setCover(coverFile) {
|
||||||
this.isProcessing = true
|
this.updateCover(coverFile.metadata.path)
|
||||||
this.$axios
|
|
||||||
.$patch(`/api/items/${this.libraryItemId}/cover`, { cover: coverFile.metadata.path })
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to set local cover', error)
|
|
||||||
this.$toast.error(error.response?.data || this.$strings.ToastCoverUpdateFailed)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.isProcessing = false
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user