mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-11 02:19:43 +00:00
Compare commits
No commits in common. "master" and "v2.12.3" have entirely different histories.
531 changed files with 24959 additions and 55873 deletions
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.
|
|
||||||
|
|
||||||
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@v7
|
|
||||||
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"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
77
.github/workflows/codeql.yml
vendored
77
.github/workflows/codeql.yml
vendored
|
|
@ -1,25 +1,11 @@
|
||||||
name: 'CodeQL'
|
name: "CodeQL"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ['master']
|
branches: [ 'master' ]
|
||||||
# Only build when files in these directories have been changed
|
|
||||||
paths:
|
|
||||||
- client/**
|
|
||||||
- server/**
|
|
||||||
- test/**
|
|
||||||
- index.js
|
|
||||||
- package.json
|
|
||||||
pull_request:
|
pull_request:
|
||||||
# The branches below must be a subset of the branches above
|
# The branches below must be a subset of the branches above
|
||||||
branches: ['master']
|
branches: [ 'master' ]
|
||||||
# Only build when files in these directories have been changed
|
|
||||||
paths:
|
|
||||||
- client/**
|
|
||||||
- server/**
|
|
||||||
- test/**
|
|
||||||
- index.js
|
|
||||||
- package.json
|
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '16 5 * * 4'
|
- cron: '16 5 * * 4'
|
||||||
|
|
||||||
|
|
@ -35,44 +21,45 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
language: ['javascript']
|
language: [ 'javascript' ]
|
||||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||||
# Use only 'java' to analyze code written in Java, Kotlin or both
|
# Use only 'java' to analyze code written in Java, Kotlin or both
|
||||||
# Use only 'javascript' to analyze code written in JavaScript, TypeScript 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
|
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3
|
uses: github/codeql-action/init@v2
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# 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.
|
# 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.
|
# 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
|
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||||
# queries: security-extended,security-and-quality
|
# queries: security-extended,security-and-quality
|
||||||
|
|
||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
|
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
|
||||||
- name: Autobuild
|
|
||||||
uses: github/codeql-action/autobuild@v3
|
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
|
||||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@v2
|
||||||
|
|
||||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
|
|
||||||
# - run: |
|
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||||
# echo "Run, Build Application using script"
|
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||||
# ./location_of_script_within_repo/buildscript.sh
|
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
# - run: |
|
||||||
uses: github/codeql-action/analyze@v3
|
# echo "Run, Build Application using script"
|
||||||
with:
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
category: '/language:${{matrix.language}}'
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v2
|
||||||
|
with:
|
||||||
|
category: "/language:${{matrix.language}}"
|
||||||
|
|
|
||||||
48
.github/workflows/component-tests.yml
vendored
48
.github/workflows/component-tests.yml
vendored
|
|
@ -1,48 +0,0 @@
|
||||||
name: Run Component Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
ref:
|
|
||||||
description: 'Branch/Tag/SHA to test'
|
|
||||||
required: true
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'client/**'
|
|
||||||
- '.github/workflows/component-tests.yml'
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'client/**'
|
|
||||||
- '.github/workflows/component-tests.yml'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
run-component-tests:
|
|
||||||
name: Run Component 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
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
cd client
|
|
||||||
npm ci
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: |
|
|
||||||
cd client
|
|
||||||
npm test
|
|
||||||
24
.github/workflows/docker-build.yml
vendored
24
.github/workflows/docker-build.yml
vendored
|
|
@ -1,4 +1,5 @@
|
||||||
---
|
---
|
||||||
|
|
||||||
name: Build and Push Docker Image
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
|
@ -10,7 +11,7 @@ on:
|
||||||
required: true
|
required: true
|
||||||
default: 'latest'
|
default: 'latest'
|
||||||
push:
|
push:
|
||||||
branches: [main, master]
|
branches: [main,master]
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*'
|
- 'v*.*.*'
|
||||||
# Only build when files in these directories have been changed
|
# Only build when files in these directories have been changed
|
||||||
|
|
@ -22,16 +23,16 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
if: ${{ !contains(github.event.head_commit.message, 'skip ci') && github.repository == 'advplyr/audiobookshelf' }}
|
if: "!contains(github.event.head_commit.message, 'skip ci')"
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-20.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v4
|
||||||
with:
|
with:
|
||||||
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
|
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
|
||||||
tags: |
|
tags: |
|
||||||
|
|
@ -39,13 +40,13 @@ jobs:
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Setup QEMU
|
- name: Setup QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
- name: Cache Docker layers
|
- name: Cache Docker layers
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: /tmp/.buildx-cache
|
path: /tmp/.buildx-cache
|
||||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||||
|
|
@ -53,23 +54,22 @@ jobs:
|
||||||
${{ runner.os }}-buildx-
|
${{ runner.os }}-buildx-
|
||||||
|
|
||||||
- name: Login to Dockerhub
|
- name: Login to Dockerhub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
|
|
||||||
- name: Login to ghcr
|
- name: Login to ghcr
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GHCR_PASSWORD }}
|
password: ${{ secrets.GHCR_PASSWORD }}
|
||||||
|
|
||||||
- name: Build image
|
- name: Build image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
|
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
|
|
|
||||||
3
.github/workflows/i18n-integration.yml
vendored
3
.github/workflows/i18n-integration.yml
vendored
|
|
@ -20,8 +20,7 @@ jobs:
|
||||||
- name: Set up node
|
- name: Set up node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: '20'
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
# The only argument is the `directory`, which is where the i18n files are
|
# The only argument is the `directory`, which is where the i18n files are
|
||||||
# stored.
|
# stored.
|
||||||
|
|
|
||||||
16
.github/workflows/integration-test.yml
vendored
16
.github/workflows/integration-test.yml
vendored
|
|
@ -5,28 +5,20 @@ on:
|
||||||
push:
|
push:
|
||||||
branches-ignore:
|
branches-ignore:
|
||||||
- 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests
|
- 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests
|
||||||
# Only build when files in these directories have been changed
|
|
||||||
paths:
|
|
||||||
- client/**
|
|
||||||
- server/**
|
|
||||||
- test/**
|
|
||||||
- index.js
|
|
||||||
- package.json
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: build and test
|
name: build and test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: setup node
|
- name: setup nade
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: install pkg (using yao-pkg fork for targeting node20)
|
- name: install pkg (using yao-pkg fork for targetting node20)
|
||||||
run: npm install -g @yao-pkg/pkg
|
run: npm install -g @yao-pkg/pkg
|
||||||
|
|
||||||
- name: get client dependencies
|
- name: get client dependencies
|
||||||
|
|
|
||||||
19
.github/workflows/lint-openapi.yml
vendored
19
.github/workflows/lint-openapi.yml
vendored
|
|
@ -1,15 +1,13 @@
|
||||||
name: API linting
|
name: API linting
|
||||||
|
|
||||||
# Run on pull requests or pushes when there is a change to any OpenAPI files in docs/
|
# Run on pull requests or pushes when there is a change to the OpenAPI file
|
||||||
on:
|
on:
|
||||||
pull_request:
|
|
||||||
push:
|
push:
|
||||||
paths:
|
paths:
|
||||||
- 'docs/**'
|
- docs/
|
||||||
|
pull_request:
|
||||||
# This action only needs read permissions
|
paths:
|
||||||
permissions:
|
- docs/
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
@ -18,22 +16,15 @@ jobs:
|
||||||
# Check out the repository
|
# Check out the repository
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# Set up node to run the javascript
|
# Set up node to run the javascript
|
||||||
- name: Set up node
|
- name: Set up node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
# Install Redocly CLI
|
# Install Redocly CLI
|
||||||
- name: Install Redocly CLI
|
- name: Install Redocly CLI
|
||||||
run: npm install -g @redocly/cli@latest
|
run: npm install -g @redocly/cli@latest
|
||||||
|
|
||||||
# Perform linting for exploded spec
|
# Perform linting for exploded spec
|
||||||
- name: Run linting for exploded spec
|
- name: Run linting for exploded spec
|
||||||
run: redocly lint docs/root.yaml --format=github-actions
|
run: redocly lint docs/root.yaml --format=github-actions
|
||||||
|
|
||||||
# Perform linting for bundled spec
|
# Perform linting for bundled spec
|
||||||
- name: Run linting for bundled spec
|
- name: Run linting for bundled spec
|
||||||
run: redocly lint docs/openapi.json --format=github-actions
|
run: redocly lint docs/openapi.json --format=github-actions
|
||||||
|
|
|
||||||
1
.github/workflows/unit-tests.yml
vendored
1
.github/workflows/unit-tests.yml
vendored
|
|
@ -29,7 +29,6 @@ jobs:
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -7,7 +7,6 @@
|
||||||
/podcasts/
|
/podcasts/
|
||||||
/media/
|
/media/
|
||||||
/metadata/
|
/metadata/
|
||||||
/plugins/
|
|
||||||
/client/.nuxt/
|
/client/.nuxt/
|
||||||
/client/dist/
|
/client/dist/
|
||||||
/dist/
|
/dist/
|
||||||
|
|
@ -17,10 +16,8 @@
|
||||||
/ffmpeg*
|
/ffmpeg*
|
||||||
/ffprobe*
|
/ffprobe*
|
||||||
/unicode*
|
/unicode*
|
||||||
/libnusqlite3*
|
|
||||||
|
|
||||||
sw.*
|
sw.*
|
||||||
.DS_STORE
|
.DS_STORE
|
||||||
.idea/*
|
.idea/*
|
||||||
tailwind.compiled.css
|
tailwind.compiled.css
|
||||||
tailwind.config.js
|
|
||||||
|
|
|
||||||
70
Dockerfile
70
Dockerfile
|
|
@ -1,73 +1,35 @@
|
||||||
ARG NUSQLITE3_DIR="/usr/local/lib/nusqlite3"
|
|
||||||
ARG NUSQLITE3_PATH="${NUSQLITE3_DIR}/libnusqlite3.so"
|
|
||||||
|
|
||||||
### STAGE 0: Build client ###
|
### STAGE 0: Build client ###
|
||||||
FROM node:20-alpine AS build-client
|
FROM node:20-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 AS build-server
|
FROM node:20-alpine
|
||||||
|
|
||||||
ARG NUSQLITE3_DIR
|
|
||||||
ARG TARGETPLATFORM
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
RUN apk add --no-cache --update \
|
RUN apk update && \
|
||||||
curl \
|
apk add --no-cache --update \
|
||||||
make \
|
curl \
|
||||||
python3 \
|
tzdata \
|
||||||
g++ \
|
ffmpeg \
|
||||||
unzip
|
make \
|
||||||
|
gcompat \
|
||||||
|
python3 \
|
||||||
|
g++ \
|
||||||
|
tini
|
||||||
|
|
||||||
WORKDIR /server
|
COPY --from=build /client/dist /client/dist
|
||||||
COPY index.js package* /server
|
COPY index.js package* /
|
||||||
COPY /server /server/server
|
COPY server server
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
### STAGE 2: Create minimal runtime image ###
|
RUN apk del make python3 g++
|
||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
ARG NUSQLITE3_DIR
|
|
||||||
ARG NUSQLITE3_PATH
|
|
||||||
|
|
||||||
# Install only runtime dependencies
|
|
||||||
RUN apk add --no-cache --update \
|
|
||||||
tzdata \
|
|
||||||
ffmpeg \
|
|
||||||
tini
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy compiled frontend and server from build stages
|
|
||||||
COPY --from=build-client /client/dist /app/client/dist
|
|
||||||
COPY --from=build-server /server /app
|
|
||||||
COPY --from=build-server ${NUSQLITE3_PATH} ${NUSQLITE3_PATH}
|
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
ENV PORT=80
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV CONFIG_PATH="/config"
|
|
||||||
ENV METADATA_PATH="/metadata"
|
|
||||||
ENV SOURCE="docker"
|
|
||||||
ENV NUSQLITE3_DIR=${NUSQLITE3_DIR}
|
|
||||||
ENV NUSQLITE3_PATH=${NUSQLITE3_PATH}
|
|
||||||
|
|
||||||
ENTRYPOINT ["tini", "--"]
|
ENTRYPOINT ["tini", "--"]
|
||||||
CMD ["node", "index.js"]
|
CMD ["node", "index.js"]
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ add_user() {
|
||||||
declare -r descr="${4:-No description}"
|
declare -r descr="${4:-No description}"
|
||||||
declare -r shell="${5:-/bin/false}"
|
declare -r shell="${5:-/bin/false}"
|
||||||
|
|
||||||
if ! getent passwd "$user" 2>&1 >/dev/null; then
|
if ! getent passwd | grep -q "^$user:"; then
|
||||||
echo "Creating system user: $user in $group with $descr and shell $shell"
|
echo "Creating system user: $user in $group with $descr and shell $shell"
|
||||||
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
|
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
|
||||||
fi
|
fi
|
||||||
|
|
@ -39,7 +39,7 @@ add_group() {
|
||||||
declare -r gid_flags="--gid $gid"
|
declare -r gid_flags="--gid $gid"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! getent group "$group" 2>&1 >/dev/null; then
|
if ! getent group | grep -q "^$group:" ; then
|
||||||
echo "Creating system group: $group"
|
echo "Creating system group: $group"
|
||||||
groupadd $gid_flags --system $group
|
groupadd $gid_flags --system $group
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -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%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,10 +92,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%;
|
||||||
|
|
@ -176,10 +177,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 +204,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);
|
||||||
|
|
@ -249,4 +247,4 @@ Bookshelf Label
|
||||||
|
|
||||||
.abs-btn:disabled::before {
|
.abs-btn:disabled::before {
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -2,7 +2,14 @@
|
||||||
font-family: 'Material Symbols Rounded';
|
font-family: 'Material Symbols Rounded';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url(~static/fonts/MaterialSymbolsRounded.woff2) format('woff2');
|
src: url(~static/fonts/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].woff2) format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Material Symbols Outlined';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url(~static/fonts/MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].woff2) format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-symbols {
|
.material-symbols {
|
||||||
|
|
@ -25,6 +32,26 @@
|
||||||
'FILL' 1
|
'FILL' 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-family: 'Material Symbols 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;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-symbols-outlined.fill {
|
||||||
|
font-variation-settings:
|
||||||
|
'FILL' 1
|
||||||
|
}
|
||||||
|
|
||||||
/* cyrillic-ext */
|
/* cyrillic-ext */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Source Sans Pro';
|
font-family: 'Source Sans Pro';
|
||||||
|
|
|
||||||
|
|
@ -1,85 +1,3 @@
|
||||||
@import 'tailwindcss';
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
/*
|
@tailwind utilities;
|
||||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
|
||||||
so we've added these compatibility styles to make sure everything still
|
|
||||||
looks the same as it did with Tailwind CSS v3.
|
|
||||||
|
|
||||||
If we ever want to remove these styles, we need to add an explicit border
|
|
||||||
color utility to any element that depends on these defaults.
|
|
||||||
*/
|
|
||||||
@layer base {
|
|
||||||
*,
|
|
||||||
::after,
|
|
||||||
::before,
|
|
||||||
::backdrop,
|
|
||||||
::file-selector-button {
|
|
||||||
border-color: var(--color-gray-200, currentColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
[role='button'],
|
|
||||||
button {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
--spacing-0\.5e: 0.125em;
|
|
||||||
--spacing-1e: 0.25em;
|
|
||||||
--spacing-1\.5e: 0.375em;
|
|
||||||
--spacing-2e: 0.5em;
|
|
||||||
--spacing-2\.5e: 0.625em;
|
|
||||||
--spacing-3e: 0.75em;
|
|
||||||
--spacing-3\.5e: 0.875em;
|
|
||||||
--spacing-4e: 1em;
|
|
||||||
--spacing-5e: 1.25em;
|
|
||||||
--spacing-6e: 1.5em;
|
|
||||||
--spacing-7e: 1.75em;
|
|
||||||
--spacing-8e: 2em;
|
|
||||||
--spacing-9e: 2.25em;
|
|
||||||
--spacing-10e: 2.5em;
|
|
||||||
--spacing-11e: 2.75em;
|
|
||||||
--spacing-12e: 3em;
|
|
||||||
--spacing-14e: 3.5em;
|
|
||||||
--spacing-16e: 4em;
|
|
||||||
--spacing-20e: 5em;
|
|
||||||
--spacing-24e: 6em;
|
|
||||||
--spacing-28e: 7em;
|
|
||||||
--spacing-32e: 8em;
|
|
||||||
--spacing-36e: 9em;
|
|
||||||
--spacing-40e: 10em;
|
|
||||||
--spacing-44e: 11em;
|
|
||||||
--spacing-48e: 12em;
|
|
||||||
--spacing-52e: 13em;
|
|
||||||
--spacing-56e: 14em;
|
|
||||||
--spacing-60e: 15em;
|
|
||||||
--spacing-64e: 16em;
|
|
||||||
--spacing-72e: 18em;
|
|
||||||
--spacing-80e: 20em;
|
|
||||||
--spacing-96e: 24em;
|
|
||||||
|
|
||||||
--color-bg: #373838;
|
|
||||||
--color-primary: #232323;
|
|
||||||
--color-accent: #1ad691;
|
|
||||||
--color-error: #ff5252;
|
|
||||||
--color-info: #2196f3;
|
|
||||||
--color-success: #4caf50;
|
|
||||||
--color-warning: #fb8c00;
|
|
||||||
--color-darkgreen: rgb(34, 127, 35);
|
|
||||||
--color-black-50: #bbbbbb;
|
|
||||||
--color-black-100: #666666;
|
|
||||||
--color-black-200: #555555;
|
|
||||||
--color-black-300: #444444;
|
|
||||||
--color-black-400: #333333;
|
|
||||||
--color-black-500: #222222;
|
|
||||||
--color-black-600: #111111;
|
|
||||||
--color-black-700: #101010;
|
|
||||||
|
|
||||||
--font-sans: 'Source Sans Pro';
|
|
||||||
--font-mono: 'Ubuntu Mono';
|
|
||||||
|
|
||||||
--text-xxs: 0.625rem;
|
|
||||||
--text-1\.5xl: 1.375rem;
|
|
||||||
--text-2\.5xl: 1.6875rem;
|
|
||||||
--text-4\.5xl: 2.625rem;
|
|
||||||
}
|
|
||||||
|
|
@ -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,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full h-16 bg-primary relative">
|
<div class="w-full h-16 bg-primary relative">
|
||||||
<div id="appbar" 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" />
|
||||||
|
|
@ -13,10 +13,10 @@
|
||||||
<ui-libraries-dropdown class="mr-2" />
|
<ui-libraries-dropdown class="mr-2" />
|
||||||
|
|
||||||
<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="grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<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/50"> cast </span>
|
<span class="material-symbols-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>
|
||||||
|
|
@ -26,35 +26,35 @@
|
||||||
|
|
||||||
<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-symbols 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-symbols 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-symbols text-2xl" aria-label="System Settings" role="button">settings</span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded-sm shadow-xs ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left sm:text-sm cursor-pointer hover:bg-bg/40" aria-haspopup="listbox" aria-expanded="true">
|
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
|
||||||
<span class="items-center hidden md:flex">
|
<span class="items-center hidden md:flex">
|
||||||
<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-symbols text-xl text-gray-100">person</span>
|
||||||
</span>
|
</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="numMediaItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
<div v-show="numMediaItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
||||||
<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="grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn v-if="!isPodcastLibrary && selectedMediaItemsArePlayable" color="bg-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-symbols fill text-2xl -ml-2 pr-1 text-white">play_arrow</span>
|
||||||
{{ $strings.ButtonPlay }}
|
{{ $strings.ButtonPlay }}
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
|
|
@ -66,11 +66,11 @@
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<template v-if="userCanUpdate">
|
<template v-if="userCanUpdate">
|
||||||
<ui-tooltip :text="$strings.LabelEdit" direction="bottom">
|
<ui-tooltip :text="$strings.LabelEdit" direction="bottom">
|
||||||
<ui-icon-btn :disabled="processingBatch" icon="edit" bg-color="bg-warning" class="mx-1.5" @click="batchEditClick" />
|
<ui-icon-btn :disabled="processingBatch" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<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="bg-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-context-menu-dropdown v-if="contextMenuItems.length && !processingBatch" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
|
<ui-context-menu-dropdown v-if="contextMenuItems.length && !processingBatch" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
|
||||||
|
|
@ -180,15 +180,6 @@ export default {
|
||||||
action: 'rescan'
|
action: 'rescan'
|
||||||
})
|
})
|
||||||
|
|
||||||
// The limit of 50 is introduced because of the URL length. Each id has 36 chars, so 36 * 40 = 1440
|
|
||||||
// + 40 , separators = 1480 chars + base path 280 chars = 1760 chars. This keeps the URL under 2000 chars even with longer domains
|
|
||||||
if (this.selectedMediaItems.length <= 40) {
|
|
||||||
options.push({
|
|
||||||
text: this.$strings.LabelDownload,
|
|
||||||
action: 'download'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -224,8 +215,6 @@ export default {
|
||||||
this.batchAutoMatchClick()
|
this.batchAutoMatchClick()
|
||||||
} else if (action === 'rescan') {
|
} else if (action === 'rescan') {
|
||||||
this.batchRescan()
|
this.batchRescan()
|
||||||
} else if (action === 'download') {
|
|
||||||
this.batchDownload()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async batchRescan() {
|
async batchRescan() {
|
||||||
|
|
@ -252,11 +241,6 @@ export default {
|
||||||
}
|
}
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
},
|
},
|
||||||
async batchDownload() {
|
|
||||||
const libraryItemIds = this.selectedMediaItems.map((i) => i.id)
|
|
||||||
console.log('Downloading library items', libraryItemIds)
|
|
||||||
this.$downloadFile(`/api/libraries/${this.$store.state.libraries.currentLibraryId}/download?token=${this.$store.getters['user/getToken']}&ids=${libraryItemIds.join(',')}`)
|
|
||||||
},
|
|
||||||
async playSelectedItems() {
|
async playSelectedItems() {
|
||||||
this.$store.commit('setProcessingBatch', true)
|
this.$store.commit('setProcessingBatch', true)
|
||||||
|
|
||||||
|
|
@ -280,6 +264,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,
|
||||||
|
|
@ -347,13 +332,13 @@ export default {
|
||||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success(this.$strings.ToastBatchDeleteSuccess)
|
this.$toast.success('Batch delete success')
|
||||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Batch delete failed', error)
|
console.error('Batch delete failed', error)
|
||||||
this.$toast.error(this.$strings.ToastBatchDeleteFailed)
|
this.$toast.error('Batch delete failed')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.$store.commit('setProcessingBatch', false)
|
this.$store.commit('setProcessingBatch', false)
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@
|
||||||
<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">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
|
||||||
<div v-if="userIsAdminOrUp" class="flex">
|
<div v-if="userIsAdminOrUp" class="flex">
|
||||||
<ui-btn to="/config" color="bg-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="bg-success" class="w-52" :loading="isScanningLibrary || tempIsScanning" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>
|
<ui-btn color="success" class="w-52" :loading="isScanningLibrary || tempIsScanning" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="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">
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e">
|
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e">
|
||||||
<template v-for="(shelf, index) in supportedShelves">
|
<template v-for="(shelf, index) in supportedShelves">
|
||||||
<widgets-item-slider :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :type="shelf.type" class="bookshelf-row pl-8e my-6e" @selectEntity="(payload) => selectEntity(payload, index)">
|
<widgets-item-slider :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :type="shelf.type" class="bookshelf-row pl-8e my-6e" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||||
<h2 class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</h2>
|
<p class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</p>
|
||||||
</widgets-item-slider>
|
</widgets-item-slider>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -217,16 +217,6 @@ export default {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.results.episodes?.length) {
|
|
||||||
shelves.push({
|
|
||||||
id: 'episodes',
|
|
||||||
label: 'Episodes',
|
|
||||||
labelStringKey: 'LabelEpisodes',
|
|
||||||
type: 'episode',
|
|
||||||
entities: this.results.episodes.map((res) => res.libraryItem)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.results.series?.length) {
|
if (this.results.series?.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'series',
|
id: 'series',
|
||||||
|
|
@ -300,8 +290,6 @@ export default {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
userUpdated(user) {
|
userUpdated(user) {
|
||||||
if (user.id !== this.$store.state.user.user.id) return
|
|
||||||
|
|
||||||
if (user.seriesHideFromContinueListening && user.seriesHideFromContinueListening.length) {
|
if (user.seriesHideFromContinueListening && user.seriesHideFromContinueListening.length) {
|
||||||
this.removeAllSeriesFromContinueSeries(user.seriesHideFromContinueListening)
|
this.removeAllSeriesFromContinueSeries(user.seriesHideFromContinueListening)
|
||||||
}
|
}
|
||||||
|
|
@ -359,13 +347,6 @@ export default {
|
||||||
libraryItemsAdded(libraryItems) {
|
libraryItemsAdded(libraryItems) {
|
||||||
console.log('libraryItems added', libraryItems)
|
console.log('libraryItems added', libraryItems)
|
||||||
|
|
||||||
// First items added to library
|
|
||||||
const isThisLibrary = libraryItems.some((li) => li.libraryId === this.currentLibraryId)
|
|
||||||
if (!this.shelves.length && !this.search && isThisLibrary) {
|
|
||||||
this.fetchCategories()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const recentlyAddedShelf = this.shelves.find((shelf) => shelf.id === 'recently-added')
|
const recentlyAddedShelf = this.shelves.find((shelf) => shelf.id === 'recently-added')
|
||||||
if (!recentlyAddedShelf) return
|
if (!recentlyAddedShelf) return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'authors'" class="flex items-center">
|
<div v-if="shelf.type === 'authors'" class="flex items-center">
|
||||||
<template v-for="entity in shelf.entities">
|
<template v-for="entity in shelf.entities">
|
||||||
<cards-author-card :key="entity.id" :authorMount="entity" @hook:updated="updatedBookCard" class="mx-2e" @edit="editAuthor" />
|
<cards-author-card :key="entity.id" :author="entity" @hook:updated="updatedBookCard" class="mx-2e" @edit="editAuthor" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'narrators'" class="flex items-center">
|
<div v-if="shelf.type === 'narrators'" class="flex items-center">
|
||||||
|
|
@ -36,19 +36,19 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md">
|
<div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md">
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-xs border" :style="{ padding: `0em 0.5em` }">
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
|
||||||
<h2 :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</h2>
|
<p :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></div>
|
<div class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></div>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<div v-show="canScrollLeft && !isScrolling" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft">
|
||||||
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_left</span>
|
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_left</span>
|
||||||
</button>
|
</div>
|
||||||
<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">
|
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight">
|
||||||
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_right</span>
|
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_right</span>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -93,13 +93,12 @@ export default {
|
||||||
editAuthor(author) {
|
editAuthor(author) {
|
||||||
this.$store.commit('globals/showEditAuthorModal', author)
|
this.$store.commit('globals/showEditAuthorModal', author)
|
||||||
},
|
},
|
||||||
editItem(libraryItem, tab = 'details') {
|
editItem(libraryItem) {
|
||||||
var itemIds = this.shelf.entities.map((e) => e.id)
|
var itemIds = this.shelf.entities.map((e) => e.id)
|
||||||
this.$store.commit('setBookshelfBookIds', itemIds)
|
this.$store.commit('setBookshelfBookIds', itemIds)
|
||||||
this.$store.commit('showEditModalOnTab', { libraryItem, tab: tab || 'details' })
|
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)
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,71 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full h-20 md:h-10 relative">
|
<div class="w-full h-20 md:h-10 relative">
|
||||||
<div class="flex md:hidden h-10 items-center">
|
<div class="flex md:hidden h-10 items-center">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="grow h-full flex justify-center items-center" :class="isHomePage ? 'bg-primary/80' : 'bg-primary/40'">
|
<nuxt-link :to="`/library/${currentLibraryId}`" class="flex-grow h-full flex justify-center items-center" :class="isHomePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
<p v-if="isHomePage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonHome }}</p>
|
<p v-if="isHomePage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonHome }}</p>
|
||||||
<span v-else class="material-symbols text-lg">home</span>
|
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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" />
|
||||||
|
</svg>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="grow h-full flex justify-center items-center" :class="isLibraryPage ? 'bg-primary/80' : 'bg-primary/40'">
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="flex-grow h-full flex justify-center items-center" :class="isLibraryPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
<p v-if="isLibraryPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonLibrary }}</p>
|
<p v-if="isLibraryPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonLibrary }}</p>
|
||||||
<span v-else class="material-symbols text-lg">import_contacts</span>
|
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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" />
|
||||||
|
</svg>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="grow h-full flex justify-center items-center" :class="isPodcastLatestPage ? 'bg-primary/80' : 'bg-primary/40'">
|
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
<p class="text-sm">{{ $strings.ButtonLatest }}</p>
|
<p class="text-sm">{{ $strings.ButtonLatest }}</p>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="grow h-full flex justify-center items-center" :class="isSeriesPage ? 'bg-primary/80' : 'bg-primary/40'">
|
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
<p v-if="isSeriesPage" class="text-sm">{{ $strings.ButtonSeries }}</p>
|
<p v-if="isSeriesPage" class="text-sm">{{ $strings.ButtonSeries }}</p>
|
||||||
<span v-else class="material-symbols text-lg">view_column</span>
|
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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" />
|
||||||
|
</svg>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="grow h-full flex justify-center items-center" :class="isPlaylistsPage ? 'bg-primary/80' : 'bg-primary/40'">
|
<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>
|
<p v-if="isPlaylistsPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonPlaylists }}</p>
|
||||||
<span v-else class="material-symbols text-lg"></span>
|
<span v-else class="material-symbols-outlined text-lg">queue_music</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="grow h-full flex justify-center items-center" :class="isCollectionsPage ? 'bg-primary/80' : 'bg-primary/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-symbols-outlined text-lg">collections_bookmark</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/authors`" class="grow h-full flex justify-center items-center" :class="isAuthorsPage ? 'bg-primary/80' : 'bg-primary/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>
|
||||||
<span v-else class="material-symbols text-lg">groups</span>
|
<svg v-else class="w-5 h-5" 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>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary/80' : 'bg-primary/40'">
|
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
<p class="text-sm">{{ $strings.ButtonAdd }}</p>
|
<p class="text-sm">{{ $strings.ButtonAdd }}</p>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="grow h-full flex justify-center items-center" :class="isPodcastDownloadQueuePage ? 'bg-primary/80' : 'bg-primary/40'">
|
|
||||||
<p class="text-sm">{{ $strings.ButtonDownloadQueue }}</p>
|
|
||||||
</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/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="grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<!-- RSS feed -->
|
<!-- RSS feed -->
|
||||||
<ui-tooltip v-if="seriesRssFeed" :text="$strings.LabelOpenRSSFeed" direction="top">
|
<ui-tooltip v-if="seriesRssFeed" :text="$strings.LabelOpenRSSFeed" direction="top">
|
||||||
<ui-icon-btn icon="rss_feed" class="mx-0.5" :size="7" icon-font-size="1.2rem" bg-color="bg-success" outlined @click="showOpenSeriesRSSFeed" />
|
<ui-icon-btn icon="rss_feed" class="mx-0.5" :size="7" icon-font-size="1.2rem" bg-color="success" outlined @click="showOpenSeriesRSSFeed" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<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="grow hidden sm:inline-block" />
|
<div class="flex-grow hidden sm:inline-block" />
|
||||||
|
|
||||||
<!-- 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" />
|
||||||
|
|
@ -72,30 +80,28 @@
|
||||||
<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="bg-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" />
|
<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="grow" />
|
<div class="flex-grow" />
|
||||||
<p>{{ $strings.MessageSearchResultsFor }} "{{ searchQuery }}"</p>
|
<p>{{ $strings.MessageSearchResultsFor }} "{{ searchQuery }}"</p>
|
||||||
<div class="grow" />
|
<div class="flex-grow" />
|
||||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
||||||
</template>
|
</template>
|
||||||
<!-- authors page -->
|
<!-- authors page -->
|
||||||
<template v-else-if="isAuthorsPage">
|
<template v-else-if="page === 'authors'">
|
||||||
<p class="hidden md:block">{{ $formatNumber(numShowing) }} {{ entityName }}</p>
|
<div class="flex-grow" />
|
||||||
|
<ui-btn v-if="userCanUpdate && authors?.length && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
|
||||||
<div class="grow hidden sm:inline-block" />
|
|
||||||
<ui-btn v-if="userCanUpdate && !isBatchSelecting" :loading="processingAuthors" color="bg-primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
|
|
||||||
|
|
||||||
<!-- author sort select -->
|
<!-- 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" />
|
<controls-sort-select v-if="authors?.length" v-model="settings.authorSortBy" :descending.sync="settings.authorSortDesc" :items="authorSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateAuthorSort" />
|
||||||
</template>
|
</template>
|
||||||
<!-- home page -->
|
<!-- home page -->
|
||||||
<template v-else-if="isHome">
|
<template v-else-if="isHome">
|
||||||
<div class="grow" />
|
<div class="flex-grow" />
|
||||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -111,7 +117,11 @@ export default {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
},
|
},
|
||||||
searchQuery: String
|
searchQuery: String,
|
||||||
|
authors: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -149,7 +159,6 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.addSubtitlesMenuItem(items)
|
this.addSubtitlesMenuItem(items)
|
||||||
this.addCollapseSubSeriesMenuItem(items)
|
|
||||||
|
|
||||||
return items
|
return items
|
||||||
},
|
},
|
||||||
|
|
@ -236,6 +245,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 === ''
|
||||||
},
|
},
|
||||||
|
|
@ -257,22 +269,24 @@ 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() {
|
||||||
|
return this.page === 'albums'
|
||||||
},
|
},
|
||||||
numShowing() {
|
numShowing() {
|
||||||
return this.totalEntities
|
return this.totalEntities
|
||||||
},
|
},
|
||||||
entityName() {
|
entityName() {
|
||||||
|
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() {
|
||||||
|
|
@ -357,21 +371,6 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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) {
|
handleSubtitlesAction(action) {
|
||||||
if (action === 'show-subtitles') {
|
if (action === 'show-subtitles') {
|
||||||
this.settings.showSubtitles = true
|
this.settings.showSubtitles = true
|
||||||
|
|
@ -398,19 +397,6 @@ export default {
|
||||||
}
|
}
|
||||||
return false
|
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 }) {
|
contextMenuAction({ action }) {
|
||||||
if (action === 'export-opml') {
|
if (action === 'export-opml') {
|
||||||
this.exportOPML()
|
this.exportOPML()
|
||||||
|
|
@ -441,8 +427,6 @@ export default {
|
||||||
this.markSeriesFinished()
|
this.markSeriesFinished()
|
||||||
} else if (this.handleSubtitlesAction(action)) {
|
} else if (this.handleSubtitlesAction(action)) {
|
||||||
return
|
return
|
||||||
} else if (this.handleCollapseSubSeriesAction(action)) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
showOpenSeriesRSSFeed() {
|
showOpenSeriesRSSFeed() {
|
||||||
|
|
@ -458,58 +442,46 @@ 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
|
||||||
|
|
||||||
for (const author of authors) {
|
payload.region = 'us'
|
||||||
const payload = {}
|
if (this.libraryProvider.startsWith('audible.')) {
|
||||||
if (author.asin) payload.asin = author.asin
|
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
||||||
else payload.q = author.name
|
|
||||||
|
|
||||||
payload.region = 'us'
|
|
||||||
if (this.libraryProvider.startsWith('audible.')) {
|
|
||||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$eventBus.$emit(`searching-author-${author.id}`, true)
|
|
||||||
|
|
||||||
var response = await this.$axios.$post(`/api/authors/${author.id}/match`, payload).catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
if (!response) {
|
|
||||||
console.error(`Author ${author.name} not found`)
|
|
||||||
this.$toast.error(this.$getString('ToastAuthorNotFound', [author.name]))
|
|
||||||
} else if (response.updated) {
|
|
||||||
if (response.author.imagePath) console.log(`Author ${response.author.name} was updated`)
|
|
||||||
else console.log(`Author ${response.author.name} was updated (no image found)`)
|
|
||||||
} else {
|
|
||||||
console.log(`No updates were made for Author ${response.author.name}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$eventBus.$emit(`searching-author-${author.id}`, false)
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to match all authors', error)
|
this.$eventBus.$emit(`searching-author-${author.id}`, true)
|
||||||
this.$toast.error(this.$strings.ToastMatchAllAuthorsFailed)
|
|
||||||
|
var response = await this.$axios.$post(`/api/authors/${author.id}/match`, payload).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!response) {
|
||||||
|
console.error(`Author ${author.name} not found`)
|
||||||
|
this.$toast.error(`Author ${author.name} not found`)
|
||||||
|
} else if (response.updated) {
|
||||||
|
if (response.author.imagePath) console.log(`Author ${response.author.name} was updated`)
|
||||||
|
else console.log(`Author ${response.author.name} was updated (no image found)`)
|
||||||
|
} else {
|
||||||
|
console.log(`No updates were made for Author ${response.author.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$eventBus.$emit(`searching-author-${author.id}`, false)
|
||||||
}
|
}
|
||||||
this.processingAuthors = false
|
this.processingAuthors = false
|
||||||
},
|
},
|
||||||
|
|
@ -519,13 +491,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
|
||||||
|
|
@ -581,7 +553,7 @@ export default {
|
||||||
updateCollapseSeries() {
|
updateCollapseSeries() {
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
},
|
},
|
||||||
updateCollapseSubSeries() {
|
updateCollapseBookSeries() {
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
},
|
},
|
||||||
updateShowSubtitles() {
|
updateShowSubtitles() {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
<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/100 md:bg-bg/70 shadow-lg border-r border-white/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-symbols 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/30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary/70' : 'hover:bg-primary/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'">
|
||||||
<p class="leading-4">{{ route.title }}</p>
|
<p class="leading-4">{{ route.title }}</p>
|
||||||
<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>
|
||||||
|
|
@ -13,13 +13,13 @@
|
||||||
<modals-changelog-view-modal v-model="showChangelogModal" :versionData="versionData" />
|
<modals-changelog-view-modal v-model="showChangelogModal" :versionData="versionData" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-44 h-12 px-4 border-t bg-bg border-black/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 items-center justify-between">
|
||||||
<button type="button" class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</button>
|
<button type="button" class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</button>
|
||||||
|
|
||||||
<p class="text-xs text-gray-300 italic">{{ Source }}</p>
|
<p class="text-xs text-gray-300 italic">{{ Source }}</p>
|
||||||
</div>
|
</div>
|
||||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ versionData.latestVersion }}</a>
|
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ $config.version }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -70,11 +70,6 @@ export default {
|
||||||
title: this.$strings.HeaderUsers,
|
title: this.$strings.HeaderUsers,
|
||||||
path: '/config/users'
|
path: '/config/users'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'config-api-keys',
|
|
||||||
title: this.$strings.HeaderApiKeys,
|
|
||||||
path: '/config/api-keys'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'config-sessions',
|
id: 'config-sessions',
|
||||||
title: this.$strings.HeaderListeningSessions,
|
title: this.$strings.HeaderListeningSessions,
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,6 @@
|
||||||
<div id="bookshelf" ref="bookshelf" class="w-full overflow-y-auto" :style="{ fontSize: sizeMultiplier + 'rem' }">
|
<div id="bookshelf" ref="bookshelf" class="w-full overflow-y-auto" :style="{ fontSize: sizeMultiplier + 'rem' }">
|
||||||
<template v-for="shelf in totalShelves">
|
<template v-for="shelf in totalShelves">
|
||||||
<div :key="shelf" :id="`shelf-${shelf - 1}`" class="w-full px-4e sm:px-8e relative" :class="{ bookshelfRow: !isAlternativeBookshelfView }" :style="{ height: shelfHeight + 'px' }">
|
<div :key="shelf" :id="`shelf-${shelf - 1}`" class="w-full px-4e sm:px-8e relative" :class="{ bookshelfRow: !isAlternativeBookshelfView }" :style="{ height: shelfHeight + 'px' }">
|
||||||
<!-- Card skeletons -->
|
|
||||||
<template v-for="entityIndex in entitiesInShelf(shelf)">
|
|
||||||
<div :key="entityIndex" class="w-full h-full absolute rounded-sm z-5 top-0 left-0 bg-primary box-shadow-book" :style="{ transform: entityTransform(entityIndex), width: cardWidth + 'px', height: coverHeight + 'px' }" />
|
|
||||||
</template>
|
|
||||||
<div v-if="!isAlternativeBookshelfView" class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20 h-6e" />
|
<div v-if="!isAlternativeBookshelfView" class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20 h-6e" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -13,23 +9,15 @@
|
||||||
<div v-if="initialized && !totalShelves && !hasFilter && entityName === 'items'" class="w-full flex flex-col items-center justify-center py-12">
|
<div v-if="initialized && !totalShelves && !hasFilter && entityName === 'items'" 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">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
|
||||||
<div v-if="userIsAdminOrUp" class="flex">
|
<div v-if="userIsAdminOrUp" class="flex">
|
||||||
<ui-btn to="/config" color="bg-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="bg-success" class="w-52" :loading="isScanningLibrary || tempIsScanning" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>
|
<ui-btn color="success" class="w-52" :loading="isScanningLibrary || tempIsScanning" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!totalShelves && initialized" class="w-full py-16">
|
<div v-else-if="!totalShelves && initialized" class="w-full py-16">
|
||||||
<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="bg-primary" @click="clearFilter">{{ $strings.ButtonClearFilter }}</ui-btn>
|
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">{{ $strings.ButtonClearFilter }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -77,13 +65,7 @@ export default {
|
||||||
tempIsScanning: false,
|
tempIsScanning: false,
|
||||||
cardWidth: 0,
|
cardWidth: 0,
|
||||||
cardHeight: 0,
|
cardHeight: 0,
|
||||||
coverHeight: 0,
|
resizeObserver: null
|
||||||
resizeObserver: null,
|
|
||||||
lastScrollTop: 0,
|
|
||||||
lastTimestamp: 0,
|
|
||||||
postScrollTimeout: null,
|
|
||||||
currFirstEntityIndex: -1,
|
|
||||||
currLastEntityIndex: -1
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
@ -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')
|
||||||
},
|
},
|
||||||
|
|
@ -194,6 +164,9 @@ export default {
|
||||||
bookWidth() {
|
bookWidth() {
|
||||||
return this.cardWidth
|
return this.cardWidth
|
||||||
},
|
},
|
||||||
|
bookHeight() {
|
||||||
|
return this.cardHeight
|
||||||
|
},
|
||||||
shelfPadding() {
|
shelfPadding() {
|
||||||
if (this.bookshelfWidth < 640) return 32 * this.sizeMultiplier
|
if (this.bookshelfWidth < 640) return 32 * this.sizeMultiplier
|
||||||
return 64 * this.sizeMultiplier
|
return 64 * this.sizeMultiplier
|
||||||
|
|
@ -204,6 +177,9 @@ export default {
|
||||||
entityWidth() {
|
entityWidth() {
|
||||||
return this.cardWidth
|
return this.cardWidth
|
||||||
},
|
},
|
||||||
|
entityHeight() {
|
||||||
|
return this.cardHeight
|
||||||
|
},
|
||||||
shelfPaddingHeight() {
|
shelfPaddingHeight() {
|
||||||
return 16
|
return 16
|
||||||
},
|
},
|
||||||
|
|
@ -232,17 +208,15 @@ export default {
|
||||||
clearFilter() {
|
clearFilter() {
|
||||||
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
|
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
|
||||||
},
|
},
|
||||||
editEntity(entity, tab = 'details') {
|
editEntity(entity) {
|
||||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||||
const bookIds = this.entities.map((e) => e.id)
|
const bookIds = this.entities.map((e) => e.id)
|
||||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||||
this.$store.commit('showEditModalOnTab', { libraryItem: entity, tab: tab || 'details' })
|
this.$store.commit('showEditModal', entity)
|
||||||
} else if (this.entityName === 'collections') {
|
} else if (this.entityName === 'collections') {
|
||||||
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() {
|
||||||
|
|
@ -371,60 +345,59 @@ 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) {
|
async resetEntities() {
|
||||||
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(scrollPositionToRestore) {
|
|
||||||
if (this.isFetchingEntities) {
|
if (this.isFetchingEntities) {
|
||||||
this.pendingReset = true
|
this.pendingReset = true
|
||||||
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,26 +407,40 @@ 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)
|
||||||
|
},
|
||||||
if (scrollPositionToRestore) {
|
remountEntities() {
|
||||||
if (window.bookshelf) {
|
for (const key in this.entityComponentRefs) {
|
||||||
window.bookshelf.scrollTop = scrollPositionToRestore
|
if (this.entityComponentRefs[key]) {
|
||||||
|
this.entityComponentRefs[key].destroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.entityComponentRefs = {}
|
||||||
|
this.entityIndexesMounted.forEach((i) => {
|
||||||
|
this.cardsHelpers.mountEntityCard(i)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
async rebuild() {
|
rebuild() {
|
||||||
this.initSizeData()
|
this.initSizeData()
|
||||||
|
|
||||||
var lastBookIndex = Math.min(this.totalEntities, this.booksPerFetch)
|
var lastBookIndex = Math.min(this.totalEntities, this.booksPerFetch)
|
||||||
this.destroyEntityComponents()
|
this.entityIndexesMounted = []
|
||||||
await this.loadPage(0)
|
for (let i = 0; i < lastBookIndex; i++) {
|
||||||
if (window.bookshelf) {
|
this.entityIndexesMounted.push(i)
|
||||||
window.bookshelf.scrollTop = 0
|
if (!this.entities[i]) {
|
||||||
|
const page = Math.floor(i / this.booksPerFetch)
|
||||||
|
this.loadPage(page)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.mountEntities(0, lastBookIndex)
|
var bookshelfEl = document.getElementById('bookshelf')
|
||||||
|
if (bookshelfEl) {
|
||||||
|
bookshelfEl.scrollTop = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$nextTick(this.remountEntities)
|
||||||
},
|
},
|
||||||
buildSearchParams() {
|
buildSearchParams() {
|
||||||
if (this.page === 'search' || this.page === 'collections') {
|
if (this.page === 'search' || this.page === 'collections') {
|
||||||
|
|
@ -470,9 +457,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)
|
||||||
|
|
@ -517,29 +501,12 @@ export default {
|
||||||
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) {
|
||||||
|
|
@ -552,15 +519,6 @@ export default {
|
||||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||||
if (indexOf >= 0) {
|
if (indexOf >= 0) {
|
||||||
if (this.entityName === 'items' && this.orderBy === 'media.metadata.title') {
|
|
||||||
const curTitle = this.entities[indexOf].media.metadata?.title
|
|
||||||
const newTitle = libraryItem.media.metadata?.title
|
|
||||||
if (curTitle != newTitle) {
|
|
||||||
console.log('Title changed. Re-sorting...')
|
|
||||||
this.resetEntities(this.currScrollTop)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.entities[indexOf] = libraryItem
|
this.entities[indexOf] = libraryItem
|
||||||
if (this.entityComponentRefs[indexOf]) {
|
if (this.entityComponentRefs[indexOf]) {
|
||||||
this.entityComponentRefs[indexOf].setEntity(libraryItem)
|
this.entityComponentRefs[indexOf].setEntity(libraryItem)
|
||||||
|
|
@ -568,18 +526,6 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
routeToBookshelfIfLastIssueRemoved() {
|
|
||||||
if (this.totalEntities === 0) {
|
|
||||||
const currentRouteQuery = this.$route.query
|
|
||||||
if (currentRouteQuery?.filter && currentRouteQuery.filter === 'issues') {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
console.log('Last issue removed. Redirecting to library bookshelf')
|
|
||||||
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
|
|
||||||
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
libraryItemRemoved(libraryItem) {
|
libraryItemRemoved(libraryItem) {
|
||||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||||
|
|
@ -590,7 +536,6 @@ export default {
|
||||||
this.executeRebuild()
|
this.executeRebuild()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.routeToBookshelfIfLastIssueRemoved()
|
|
||||||
},
|
},
|
||||||
libraryItemsAdded(libraryItems) {
|
libraryItemsAdded(libraryItems) {
|
||||||
console.log('items added', libraryItems)
|
console.log('items added', libraryItems)
|
||||||
|
|
@ -656,34 +601,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) {
|
shareOpen(mediaItemShare) {
|
||||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||||
var indexOf = this.entities.findIndex((ent) => ent?.media?.id === mediaItemShare.mediaItemId)
|
var indexOf = this.entities.findIndex((ent) => ent?.media?.id === mediaItemShare.mediaItemId)
|
||||||
|
|
@ -710,14 +627,13 @@ export default {
|
||||||
},
|
},
|
||||||
updatePagesLoaded() {
|
updatePagesLoaded() {
|
||||||
let numPages = Math.ceil(this.totalEntities / this.booksPerFetch)
|
let numPages = Math.ceil(this.totalEntities / this.booksPerFetch)
|
||||||
this.pagesLoaded = {}
|
|
||||||
for (let page = 0; page < numPages; page++) {
|
for (let page = 0; page < numPages; page++) {
|
||||||
let numEntities = Math.min(this.totalEntities - page * this.booksPerFetch, this.booksPerFetch)
|
let numEntities = Math.min(this.totalEntities - page * this.booksPerFetch, this.booksPerFetch)
|
||||||
this.pagesLoaded[page] = Promise.resolve()
|
this.pagesLoaded[page] = true
|
||||||
for (let i = 0; i < numEntities; i++) {
|
for (let i = 0; i < numEntities; i++) {
|
||||||
const index = page * this.booksPerFetch + i
|
const index = page * this.booksPerFetch + i
|
||||||
if (!this.entities[index]) {
|
if (!this.entities[index]) {
|
||||||
if (this.pagesLoaded[page]) delete this.pagesLoaded[page]
|
this.pagesLoaded[page] = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -732,6 +648,7 @@ 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
|
||||||
|
|
@ -756,9 +673,10 @@ export default {
|
||||||
this.initSizeData(bookshelf)
|
this.initSizeData(bookshelf)
|
||||||
this.checkUpdateSearchParams()
|
this.checkUpdateSearchParams()
|
||||||
|
|
||||||
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) {
|
||||||
|
|
@ -778,6 +696,10 @@ export default {
|
||||||
windowResize() {
|
windowResize() {
|
||||||
this.executeRebuild()
|
this.executeRebuild()
|
||||||
},
|
},
|
||||||
|
socketInit() {
|
||||||
|
// Server settings are set on socket init
|
||||||
|
this.executeRebuild()
|
||||||
|
},
|
||||||
initListeners() {
|
initListeners() {
|
||||||
window.addEventListener('resize', this.windowResize)
|
window.addEventListener('resize', this.windowResize)
|
||||||
|
|
||||||
|
|
@ -785,11 +707,12 @@ 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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
|
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||||
|
this.$eventBus.$on('socket_init', this.socketInit)
|
||||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
|
|
@ -804,9 +727,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_open', this.shareOpen)
|
||||||
this.$root.socket.on('share_closed', this.shareClosed)
|
this.$root.socket.on('share_closed', this.shareClosed)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -821,6 +741,7 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
|
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||||
|
this.$eventBus.$off('socket_init', this.socketInit)
|
||||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
|
|
@ -835,9 +756,6 @@ export default {
|
||||||
this.$root.socket.off('playlist_added', this.playlistAdded)
|
this.$root.socket.off('playlist_added', this.playlistAdded)
|
||||||
this.$root.socket.off('playlist_updated', this.playlistUpdated)
|
this.$root.socket.off('playlist_updated', this.playlistUpdated)
|
||||||
this.$root.socket.off('playlist_removed', this.playlistRemoved)
|
this.$root.socket.off('playlist_removed', this.playlistRemoved)
|
||||||
this.$root.socket.off('author_added', this.authorAdded)
|
|
||||||
this.$root.socket.off('author_updated', this.authorUpdated)
|
|
||||||
this.$root.socket.off('author_removed', this.authorRemoved)
|
|
||||||
this.$root.socket.off('share_open', this.shareOpen)
|
this.$root.socket.off('share_open', this.shareOpen)
|
||||||
this.$root.socket.off('share_closed', this.shareClosed)
|
this.$root.socket.off('share_closed', this.shareClosed)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -846,14 +764,10 @@ export default {
|
||||||
},
|
},
|
||||||
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.tempIsScanning = true
|
||||||
|
|
@ -866,14 +780,6 @@ export default {
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.tempIsScanning = false
|
this.tempIsScanning = false
|
||||||
})
|
})
|
||||||
},
|
|
||||||
entitiesInShelf(shelf) {
|
|
||||||
return shelf == this.totalShelves ? this.totalEntities % this.entitiesPerShelf || this.entitiesPerShelf : this.entitiesPerShelf
|
|
||||||
},
|
|
||||||
entityTransform(entityIndex) {
|
|
||||||
const shelfOffsetY = this.shelfPaddingHeight * this.sizeMultiplier
|
|
||||||
const shelfOffsetX = (entityIndex - 1) * this.totalEntityCardWidth + this.bookshelfMarginLeft
|
|
||||||
return `translate3d(${shelfOffsetX}px, ${shelfOffsetY}px, 0px)`
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="streamLibraryItem" id="mediaPlayerContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 lg:h-40 z-50 bg-primary px-2 lg:px-4 pb-1 lg:pb-4 pt-2">
|
<div v-if="streamLibraryItem" id="mediaPlayerContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 lg:h-40 z-50 bg-primary px-2 lg:px-4 pb-1 lg:pb-4 pt-2">
|
||||||
|
<div id="videoDock" />
|
||||||
<div class="absolute left-2 top-2 lg:left-4 cursor-pointer">
|
<div class="absolute left-2 top-2 lg:left-4 cursor-pointer">
|
||||||
<covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
<covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start mb-6 lg:mb-0" :class="isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
<div class="flex items-start mb-6 lg:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
||||||
<div class="min-w-0 w-full">
|
<div class="min-w-0 w-full">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
|
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
|
||||||
|
|
@ -11,9 +12,10 @@
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<widgets-explicit-indicator v-if="isExplicit" />
|
<widgets-explicit-indicator v-if="isExplicit" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-400 flex items-center w-1/2 sm:w-4/5 lg:w-2/5">
|
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center w-1/2 sm:w-4/5 lg:w-2/5">
|
||||||
<span class="material-symbols text-sm">person</span>
|
<span class="material-symbols text-sm">person</span>
|
||||||
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base truncate">{{ podcastAuthor }}</div>
|
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
|
||||||
|
<div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
|
||||||
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base truncate">
|
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base truncate">
|
||||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </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>
|
||||||
|
|
@ -25,7 +27,7 @@
|
||||||
<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="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>
|
<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>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
@ -41,25 +43,26 @@
|
||||||
:sleep-timer-remaining="sleepTimerRemaining"
|
:sleep-timer-remaining="sleepTimerRemaining"
|
||||||
:sleep-timer-type="sleepTimerType"
|
: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"
|
||||||
|
@showPlayerSettings="showPlayerSettingsModal = 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-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||||
|
|
||||||
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" />
|
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" :library-item-id="libraryItemId" />
|
||||||
|
|
||||||
|
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -78,6 +81,7 @@ export default {
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
showSleepTimerModal: false,
|
showSleepTimerModal: false,
|
||||||
showPlayerQueueItemsModal: false,
|
showPlayerQueueItemsModal: false,
|
||||||
|
showPlayerSettingsModal: false,
|
||||||
sleepTimerSet: false,
|
sleepTimerSet: false,
|
||||||
sleepTimerRemaining: 0,
|
sleepTimerRemaining: 0,
|
||||||
sleepTimerType: null,
|
sleepTimerType: null,
|
||||||
|
|
@ -85,8 +89,7 @@ export default {
|
||||||
displayTitle: null,
|
displayTitle: null,
|
||||||
currentPlaybackRate: 1,
|
currentPlaybackRate: 1,
|
||||||
syncFailedToast: null,
|
syncFailedToast: null,
|
||||||
coverAspectRatio: 1,
|
coverAspectRatio: 1
|
||||||
lastChapterId: null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -135,6 +138,9 @@ export default {
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.streamLibraryItem?.mediaType === 'podcast'
|
return this.streamLibraryItem?.mediaType === 'podcast'
|
||||||
},
|
},
|
||||||
|
isMusic() {
|
||||||
|
return this.streamLibraryItem?.mediaType === 'music'
|
||||||
|
},
|
||||||
isExplicit() {
|
isExplicit() {
|
||||||
return !!this.mediaMetadata.explicit
|
return !!this.mediaMetadata.explicit
|
||||||
},
|
},
|
||||||
|
|
@ -156,7 +162,7 @@ export default {
|
||||||
return this.mediaMetadata.authors || []
|
return this.mediaMetadata.authors || []
|
||||||
},
|
},
|
||||||
libraryId() {
|
libraryId() {
|
||||||
return this.streamLibraryItem?.libraryId || null
|
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
|
||||||
},
|
},
|
||||||
totalDurationPretty() {
|
totalDurationPretty() {
|
||||||
// Adjusted by playback rate
|
// Adjusted by playback rate
|
||||||
|
|
@ -164,17 +170,11 @@ export default {
|
||||||
},
|
},
|
||||||
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 || []
|
||||||
|
|
@ -237,22 +237,18 @@ export default {
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
},
|
},
|
||||||
checkChapterEnd() {
|
checkChapterEnd(time) {
|
||||||
if (!this.currentChapter) return
|
if (!this.currentChapter) return
|
||||||
|
const chapterEndTime = this.currentChapter.end
|
||||||
// Track chapter transitions by comparing current chapter with last chapter
|
const tolerance = 0.75
|
||||||
if (this.lastChapterId !== this.currentChapter.id) {
|
if (time >= chapterEndTime - tolerance) {
|
||||||
// Chapter changed - if we had a previous chapter, this means we crossed a boundary
|
this.sleepTimerEnd()
|
||||||
if (this.lastChapterId) {
|
|
||||||
this.sleepTimerEnd()
|
|
||||||
}
|
|
||||||
this.lastChapterId = this.currentChapter.id
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
sleepTimerEnd() {
|
sleepTimerEnd() {
|
||||||
this.clearSleepTimer()
|
this.clearSleepTimer()
|
||||||
this.playerHandler.pause()
|
this.playerHandler.pause()
|
||||||
this.$toast.info(this.$strings.ToastSleepTimerDone)
|
this.$toast.info('Sleep Timer Done.. zZzzZz')
|
||||||
},
|
},
|
||||||
cancelSleepTimer() {
|
cancelSleepTimer() {
|
||||||
this.showSleepTimerModal = false
|
this.showSleepTimerModal = false
|
||||||
|
|
@ -306,7 +302,7 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.sleepTimerType === this.$constants.SleepTimerTypes.CHAPTER && this.sleepTimerSet) {
|
if (this.sleepTimerType === this.$constants.SleepTimerTypes.CHAPTER && this.sleepTimerSet) {
|
||||||
this.checkChapterEnd()
|
this.checkChapterEnd(time)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setDuration(duration) {
|
setDuration(duration) {
|
||||||
|
|
@ -379,28 +375,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', true)
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -473,30 +460,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
|
||||||
|
|
@ -535,7 +498,7 @@ export default {
|
||||||
},
|
},
|
||||||
showFailedProgressSyncs() {
|
showFailedProgressSyncs() {
|
||||||
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
||||||
this.syncFailedToast = this.$toast(this.$strings.ToastProgressIsNotBeingSynced, { timeout: false, type: 'error' })
|
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
|
||||||
},
|
},
|
||||||
sessionClosedEvent(sessionId) {
|
sessionClosedEvent(sessionId) {
|
||||||
if (this.playerHandler.currentSessionId === sessionId) {
|
if (this.playerHandler.currentSessionId === sessionId) {
|
||||||
|
|
@ -549,7 +512,6 @@ export default {
|
||||||
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('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)
|
||||||
},
|
},
|
||||||
|
|
@ -557,7 +519,6 @@ export default {
|
||||||
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('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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="bg-bg rounded-md shadow-lg border border-white/5 p-2 sm:p-4 mb-8">
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-2 sm:p-4 mb-8">
|
||||||
<div class="flex items-center mb-2">
|
<div class="flex items-center mb-2">
|
||||||
<slot name="header-prefix"></slot>
|
<slot name="header-prefix"></slot>
|
||||||
<h1 class="text-xl">{{ headerText }}</h1>
|
<h1 class="text-xl">{{ headerText }}</h1>
|
||||||
|
|
@ -39,4 +39,4 @@ export default {
|
||||||
color: white;
|
color: white;
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,82 +1,93 @@
|
||||||
<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">
|
<div id="siderail-buttons-container" :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/70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary/80' : 'bg-bg/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'">
|
||||||
<span class="material-symbols text-2xl">home</span>
|
<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" />
|
||||||
|
</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/70 hover:bg-primary cursor-pointer relative" :class="isPodcastLatestPage ? 'bg-primary/80' : 'bg-bg/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-symbols 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/70 hover:bg-primary cursor-pointer relative" :class="showLibrary ? 'bg-primary/80' : 'bg-bg/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'">
|
||||||
<span class="material-symbols text-2xl">import_contacts</span>
|
<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" />
|
||||||
|
</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/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary/80' : 'bg-bg/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'">
|
||||||
<span class="material-symbols text-2xl">view_column</span>
|
<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" />
|
||||||
|
</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/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary/80' : 'bg-bg/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-symbols-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/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary/80' : 'bg-bg/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-2.5xl"></span>
|
<span class="material-symbols text-2.5xl">queue_music</span>
|
||||||
|
|
||||||
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
|
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
|
||||||
|
|
||||||
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary/80' : 'bg-bg/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-2xl">groups</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-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p>
|
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p>
|
||||||
|
|
||||||
<div v-show="isAuthorsPage" 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}/narrators`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isNarratorsPage ? 'bg-primary/80' : 'bg-bg/60'">
|
<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'">
|
||||||
<span class="material-symbols text-2xl"></span>
|
<span class="material-symbols text-2xl">record_voice_over</span>
|
||||||
|
|
||||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p>
|
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p>
|
||||||
|
|
||||||
<div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isNarratorsPage" 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/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isStatsPage ? 'bg-primary/80' : 'bg-bg/60'">
|
<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'">
|
||||||
<span class="material-symbols text-2xl"></span>
|
<span class="material-symbols text-2xl">monitoring</span>
|
||||||
|
|
||||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonStats }}</p>
|
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonStats }}</p>
|
||||||
|
|
||||||
<div v-show="isStatsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isStatsPage" 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/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary/80' : 'bg-bg/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'">
|
||||||
<span class="abs-icons icon-podcast text-xl"></span>
|
<span class="abs-icons icon-podcast text-xl"></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.ButtonAdd }}</p>
|
||||||
|
|
@ -84,28 +95,36 @@
|
||||||
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary/80' : 'bg-bg/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-symbols-outlined text-xl">album</span>
|
||||||
|
|
||||||
|
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p>
|
||||||
|
|
||||||
|
<div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
|
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
|
<span class="material-symbols text-2xl">file_download</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: 0.9rem">{{ $strings.ButtonDownloadQueue }}</p>
|
||||||
|
|
||||||
<div v-show="isPodcastDownloadQueuePage" 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="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-error/40 cursor-pointer relative" :class="showingIssues ? 'bg-error/40' : 'bg-error/20'">
|
<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">warning</span>
|
<span class="material-symbols text-2xl">warning</span>
|
||||||
|
|
||||||
<p class="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p>
|
<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 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/30 flex items-center justify-center">
|
<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>
|
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</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' }">
|
<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 cursor-pointer" @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>
|
||||||
|
|
@ -153,6 +172,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'
|
||||||
},
|
},
|
||||||
|
|
@ -162,6 +184,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'
|
||||||
},
|
},
|
||||||
|
|
@ -169,7 +194,7 @@ export default {
|
||||||
return this.$route.name === 'library-library-series-id' || this.paramId === 'series'
|
return this.$route.name === 'library-library-series-id' || this.paramId === 'series'
|
||||||
},
|
},
|
||||||
isAuthorsPage() {
|
isAuthorsPage() {
|
||||||
return this.libraryBookshelfPage && this.paramId === 'authors'
|
return this.$route.name === 'library-library-authors'
|
||||||
},
|
},
|
||||||
isNarratorsPage() {
|
isNarratorsPage() {
|
||||||
return this.$route.name === 'library-library-narrators'
|
return this.$route.name === 'library-library-narrators'
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
|
<div :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
|
||||||
<nuxt-link :to="`/author/${author?.id}`">
|
<nuxt-link :to="`/author/${author.id}`">
|
||||||
<div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave">
|
<div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<div cy-id="imageArea" :style="{ height: cardHeight + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
<div cy-id="imageArea" :style="{ height: cardHeight + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||||
<!-- Image or placeholder -->
|
<!-- Image or placeholder -->
|
||||||
<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/60 px-2e">
|
<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">
|
||||||
<p class="text-center font-semibold truncate" :style="{ fontSize: 0.75 + 'em' }">{{ name }}</p>
|
<p class="text-center font-semibold truncate" :style="{ fontSize: 0.75 + 'em' }">{{ 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: 0.65 + 'em' }">{{ numBooks }} {{ $strings.LabelBooks }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading spinner -->
|
<!-- Loading spinner -->
|
||||||
<div cy-id="spinner" v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black/50 flex items-center justify-center">
|
<div cy-id="spinner" v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
||||||
<widgets-loading-spinner size="" />
|
<widgets-loading-spinner size="" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
authorMount: {
|
author: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
|
|
@ -57,8 +57,7 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
searching: false,
|
searching: false,
|
||||||
isHovering: false,
|
isHovering: false
|
||||||
author: null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -68,38 +67,35 @@ export default {
|
||||||
cardHeight() {
|
cardHeight() {
|
||||||
return this.height * this.sizeMultiplier
|
return this.height * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
coverHeight() {
|
userToken() {
|
||||||
return this.cardHeight
|
return this.$store.getters['user/getToken']
|
||||||
},
|
},
|
||||||
_author() {
|
_author() {
|
||||||
return this.author || {}
|
return this.author || {}
|
||||||
},
|
},
|
||||||
authorId() {
|
authorId() {
|
||||||
return this._author?.id || ''
|
return this._author.id
|
||||||
},
|
},
|
||||||
name() {
|
name() {
|
||||||
return this._author?.name || ''
|
return this._author.name || ''
|
||||||
},
|
},
|
||||||
asin() {
|
asin() {
|
||||||
return this._author?.asin || ''
|
return this._author.asin || ''
|
||||||
},
|
},
|
||||||
numBooks() {
|
numBooks() {
|
||||||
return this._author?.numBooks || 0
|
return this._author.numBooks || 0
|
||||||
},
|
|
||||||
store() {
|
|
||||||
return this.$store || this.$nuxt.$store
|
|
||||||
},
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
libraryProvider() {
|
libraryProvider() {
|
||||||
return this.store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
return this.store.getters['user/getSizeMultiplier']
|
return this.$store.getters['user/getSizeMultiplier']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
@ -125,54 +121,24 @@ export default {
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full px-1 overflow-hidden">
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
<div class="overflow-hidden bg-primary rounded-sm" style="height: 50px; width: 40px">
|
<div class="overflow-hidden bg-primary rounded" style="height: 50px; width: 40px">
|
||||||
<covers-author-image :author="author" />
|
<covers-author-image :author="author" />
|
||||||
</div>
|
</div>
|
||||||
<div class="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>
|
<p class="text-xs text-gray-400">{{ $getString('LabelXBooks', [numBooks]) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,33 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="book" class="w-full border-b border-gray-700 pb-2">
|
<div v-if="book" class="w-full border-b border-gray-700 pb-2">
|
||||||
<div class="flex py-1 hover:bg-gray-300/10 cursor-pointer" @click="selectMatch">
|
<div class="flex py-1 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer" @click="selectMatch">
|
||||||
<div class="min-w-12 max-w-12 md:min-w-20 md:max-w-20">
|
<div class="min-w-12 max-w-12 md:min-w-20 md:max-w-20">
|
||||||
<div class="w-full bg-primary">
|
<div class="w-full bg-primary">
|
||||||
<img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" />
|
<img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" />
|
||||||
<div v-else class="w-12 h-12 md:w-20 md:h-20 bg-primary" />
|
<div v-else class="w-12 h-12 md:w-20 md:h-20 bg-primary" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isPodcast" class="px-2 md:px-4 grow">
|
<div v-if="!isPodcast" class="px-2 md:px-4 flex-grow">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<h1 class="text-sm md:text-base">{{ book.title }}</h1>
|
<h1 class="text-sm md:text-base">{{ book.title }}</h1>
|
||||||
<div class="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>
|
||||||
<div class="flex items-center">
|
<p v-if="book.narrator" class="text-gray-400 text-xs">{{ $strings.LabelNarrators }}: {{ book.narrator }}</p>
|
||||||
<div>
|
<p v-if="book.duration" class="text-gray-400 text-xs">{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</p>
|
||||||
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">{{ $getString('LabelByAuthor', [book.author]) }}</p>
|
|
||||||
<p v-if="book.narrator" class="text-gray-400 text-xs">{{ $strings.LabelNarrators }}: {{ book.narrator }}</p>
|
|
||||||
<p v-if="book.duration" class="text-gray-400 text-xs">{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="grow" />
|
|
||||||
<div v-if="book.matchConfidence" class="rounded-full px-2 py-1 text-xs whitespace-nowrap text-white" :class="book.matchConfidence > 0.95 ? 'bg-success/80' : 'bg-info/80'">{{ $strings.LabelMatchConfidence }}: {{ (book.matchConfidence * 100).toFixed(0) }}%</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="book.series?.length" class="flex py-1 -mx-1">
|
<div v-if="book.series?.length" class="flex py-1 -mx-1">
|
||||||
<div v-for="(series, index) in book.series" :key="index" class="bg-white/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>
|
||||||
</p>
|
</p>
|
||||||
</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 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 v-if="book.explicit" /></div>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="flex items-center h-full px-1 overflow-hidden">
|
|
||||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
|
||||||
<div class="grow px-2 episodeSearchCardContent">
|
|
||||||
<p class="truncate text-sm">{{ episodeTitle }}</p>
|
|
||||||
<p class="text-xs text-gray-200 truncate">{{ podcastTitle }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
libraryItem: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
},
|
|
||||||
episode: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
bookCoverAspectRatio() {
|
|
||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
|
||||||
},
|
|
||||||
coverWidth() {
|
|
||||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
|
||||||
return 50
|
|
||||||
},
|
|
||||||
media() {
|
|
||||||
return this.libraryItem?.media || {}
|
|
||||||
},
|
|
||||||
mediaMetadata() {
|
|
||||||
return this.media.metadata || {}
|
|
||||||
},
|
|
||||||
episodeTitle() {
|
|
||||||
return this.episode.title || 'No Title'
|
|
||||||
},
|
|
||||||
podcastTitle() {
|
|
||||||
return this.mediaMetadata.title || 'No Title'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.episodeSearchCardContent {
|
|
||||||
width: calc(100% - 80px);
|
|
||||||
height: 75px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<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">category</span>
|
<span class="material-symbols text-2xl text-gray-200">category</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="grow px-2 tagSearchCardContent h-full">
|
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
||||||
<p class="truncate text-sm">{{ genre }}</p>
|
<p class="truncate text-sm">{{ genre }}</p>
|
||||||
<p class="text-xs text-gray-400">{{ $getString('LabelXItems', [numItems]) }}</p>
|
<p class="text-xs text-gray-400">{{ $getString('LabelXItems', [numItems]) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="rounded-xs 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: cardWidth + 'px', height: cardHeight + '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="cardWidth" :height="cardHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
|
||||||
<div v-if="hasValidCovers" class="bg-black/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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<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="grow px-2 audiobookSearchCardContent">
|
<div class="flex-grow px-2 audiobookSearchCardContent">
|
||||||
<p class="truncate text-sm">{{ title }}</p>
|
<p class="truncate text-sm">{{ title }}</p>
|
||||||
<p v-if="subtitle" class="truncate text-xs text-gray-300">{{ subtitle }}</p>
|
<p v-if="subtitle" class="truncate text-xs text-gray-300">{{ subtitle }}</p>
|
||||||
<p class="text-xs text-gray-200 truncate">{{ $getString('LabelByAuthor', [authorName]) }}</p>
|
<p class="text-xs text-gray-200 truncate">{{ $getString('LabelByAuthor', [authorName]) }}</p>
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,15 @@
|
||||||
<span v-if="isFinished" :class="taskIconStatus" class="material-symbols text-base">{{ actionIcon }}</span>
|
<span v-if="isFinished" :class="taskIconStatus" class="material-symbols text-base">{{ actionIcon }}</span>
|
||||||
<widgets-loading-spinner v-else />
|
<widgets-loading-spinner v-else />
|
||||||
</div>
|
</div>
|
||||||
<div class="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>
|
<p v-else-if="!isFinished && cancelingScan" class="text-xs truncate">Canceling...</p>
|
||||||
</div>
|
</div>
|
||||||
<ui-btn v-if="userIsAdminOrUp && !isFinished && isLibraryScan && !cancelingScan" color="bg-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>
|
<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>
|
||||||
|
|
||||||
|
|
@ -27,16 +26,7 @@ export default {
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
cancelingScan: false,
|
cancelingScan: false
|
||||||
specialMessage: ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
task: {
|
|
||||||
immediate: true,
|
|
||||||
handler() {
|
|
||||||
this.initTask()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -44,17 +34,14 @@ export default {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
if (this.task.titleKey && this.$strings[this.task.titleKey]) {
|
|
||||||
return this.$getString(this.task.titleKey, this.task.titleSubs)
|
|
||||||
}
|
|
||||||
return this.task.title || 'No Title'
|
return this.task.title || 'No Title'
|
||||||
},
|
},
|
||||||
description() {
|
description() {
|
||||||
if (this.task.descriptionKey && this.$strings[this.task.descriptionKey]) {
|
|
||||||
return this.$getString(this.task.descriptionKey, this.task.descriptionSubs)
|
|
||||||
}
|
|
||||||
return this.task.description || ''
|
return this.task.description || ''
|
||||||
},
|
},
|
||||||
|
details() {
|
||||||
|
return this.task.details || 'Unknown'
|
||||||
|
},
|
||||||
isFinished() {
|
isFinished() {
|
||||||
return !!this.task.isFinished
|
return !!this.task.isFinished
|
||||||
},
|
},
|
||||||
|
|
@ -65,9 +52,6 @@ export default {
|
||||||
return this.isFinished && !this.isFailed
|
return this.isFinished && !this.isFailed
|
||||||
},
|
},
|
||||||
failedMessage() {
|
failedMessage() {
|
||||||
if (this.task.errorKey && this.$strings[this.task.errorKey]) {
|
|
||||||
return this.$getString(this.task.errorKey, this.task.errorSubs)
|
|
||||||
}
|
|
||||||
return this.task.error || ''
|
return this.task.error || ''
|
||||||
},
|
},
|
||||||
action() {
|
action() {
|
||||||
|
|
@ -103,21 +87,6 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
initTask() {
|
|
||||||
// special message for library scan tasks
|
|
||||||
if (this.task?.data?.scanResults) {
|
|
||||||
const scanResults = this.task.data.scanResults
|
|
||||||
const strs = []
|
|
||||||
if (scanResults.added) strs.push(this.$getString('MessageTaskScanItemsAdded', [scanResults.added]))
|
|
||||||
if (scanResults.updated) strs.push(this.$getString('MessageTaskScanItemsUpdated', [scanResults.updated]))
|
|
||||||
if (scanResults.missing) strs.push(this.$getString('MessageTaskScanItemsMissing', [scanResults.missing]))
|
|
||||||
const changesDetected = strs.length > 0 ? strs.join(', ') : this.$strings.MessageTaskScanNoChangesNeeded
|
|
||||||
const timeElapsed = scanResults.elapsed ? ` (${this.$elapsedPretty(scanResults.elapsed / 1000, false, true)})` : ''
|
|
||||||
this.specialMessage = `${changesDetected}${timeElapsed}`
|
|
||||||
} else {
|
|
||||||
this.specialMessage = ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
cancelScan() {
|
cancelScan() {
|
||||||
const libraryId = this.task?.data?.libraryId
|
const libraryId = this.task?.data?.libraryId
|
||||||
if (!libraryId) {
|
if (!libraryId) {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="relative w-full py-4 px-6 border border-white/10 shadow-lg rounded-md my-6">
|
<div class="relative w-full py-4 px-6 border border-white border-opacity-10 shadow-lg rounded-md my-6">
|
||||||
<div class="absolute -top-3 -left-3 w-8 h-8 bg-bg border border-white/10 flex items-center justify-center rounded-full">
|
<div class="absolute -top-3 -left-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full">
|
||||||
<p class="text-base text-white/80 font-mono">#{{ item.index }}</p>
|
<p class="text-base text-white text-opacity-80 font-mono">#{{ item.index }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!processing && !uploadFailed && !uploadSuccess" class="absolute -top-3 -right-3 w-8 h-8 bg-bg border border-white/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/80 font-mono material-symbols">close</span>
|
<span class="text-base text-white text-opacity-80 font-mono material-symbols">close</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="!uploadSuccess && !uploadFailed">
|
<template v-if="!uploadSuccess && !uploadFailed">
|
||||||
|
|
@ -20,10 +20,10 @@
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<div v-if="!isPodcast" class="flex items-end">
|
<div v-if="!isPodcast" class="flex items-end">
|
||||||
<ui-text-input-with-label v-model.trim="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
|
<ui-text-input-with-label v-model.trim="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
|
||||||
<ui-tooltip direction="top" :text="$strings.LabelUploaderItemFetchMetadataHelp">
|
<ui-tooltip :text="$strings.LabelUploaderItemFetchMetadataHelp">
|
||||||
<button type="button" class="ml-2 mb-1 w-8 h-8 bg-bg border border-white/10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer" @click="fetchMetadata">
|
<div class="ml-2 mb-1 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer" @click="fetchMetadata">
|
||||||
<span class="text-base text-white/80 font-mono material-symbols">sync</span>
|
<span class="text-base text-white text-opacity-80 font-mono material-symbols">sync</span>
|
||||||
</button>
|
</div>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="w-full">
|
<div v-else class="w-full">
|
||||||
|
|
@ -61,25 +61,8 @@
|
||||||
<p class="text-base">"{{ itemData.title }}" {{ $strings.MessageUploaderItemFailed }}</p>
|
<p class="text-base">"{{ itemData.title }}" {{ $strings.MessageUploaderItemFailed }}</p>
|
||||||
</widgets-alert>
|
</widgets-alert>
|
||||||
|
|
||||||
<div v-if="isNonInteractable" class="absolute top-0 left-0 w-full h-full bg-black/50 flex items-center justify-center z-20">
|
<div v-if="isNonInteractable" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
|
||||||
<ui-loading-indicator>
|
<ui-loading-indicator :text="nonInteractionLabel" />
|
||||||
<div class="mb-4">
|
|
||||||
<span class="text-lg font-medium text-white">
|
|
||||||
{{ nonInteractionLabel }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isUploading" class="w-64 mx-auto mb-2">
|
|
||||||
<div class="flex items-center justify-center mb-2">
|
|
||||||
<span class="text-sm font-medium text-white/60 text-center w-full">
|
|
||||||
{{ uploadProgressText }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-primary/20 rounded-full h-2.5">
|
|
||||||
<div class="bg-green-500 h-2.5 rounded-full transition-all duration-300 ease-out" :style="{ width: uploadProgressPercent + '%' }"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ui-loading-indicator>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -108,11 +91,7 @@ export default {
|
||||||
isUploading: false,
|
isUploading: false,
|
||||||
uploadFailed: false,
|
uploadFailed: false,
|
||||||
uploadSuccess: false,
|
uploadSuccess: false,
|
||||||
isFetchingMetadata: false,
|
isFetchingMetadata: false
|
||||||
uploadProgress: {
|
|
||||||
loaded: 0,
|
|
||||||
total: 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -137,15 +116,6 @@ export default {
|
||||||
} else if (this.isFetchingMetadata) {
|
} else if (this.isFetchingMetadata) {
|
||||||
return this.$strings.LabelFetchingMetadata
|
return this.$strings.LabelFetchingMetadata
|
||||||
}
|
}
|
||||||
},
|
|
||||||
uploadProgressPercent() {
|
|
||||||
if (this.uploadProgress.total === 0) return 0
|
|
||||||
return Math.min(100, Math.round((this.uploadProgress.loaded / this.uploadProgress.total) * 100))
|
|
||||||
},
|
|
||||||
uploadProgressText() {
|
|
||||||
const loaded = this.$bytesPretty(this.uploadProgress.loaded)
|
|
||||||
const total = this.$bytesPretty(this.uploadProgress.total)
|
|
||||||
return `${this.uploadProgressPercent}% (${loaded} / ${total})`
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
@ -153,21 +123,6 @@ export default {
|
||||||
this.isUploading = status === 'uploading'
|
this.isUploading = status === 'uploading'
|
||||||
this.uploadFailed = status === 'failed'
|
this.uploadFailed = status === 'failed'
|
||||||
this.uploadSuccess = status === 'success'
|
this.uploadSuccess = status === 'success'
|
||||||
|
|
||||||
if (status !== 'uploading') {
|
|
||||||
this.uploadProgress = {
|
|
||||||
loaded: 0,
|
|
||||||
total: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setUploadProgress(progress) {
|
|
||||||
if (this.isUploading && progress) {
|
|
||||||
this.uploadProgress = {
|
|
||||||
loaded: progress.loaded || 0,
|
|
||||||
total: progress.total || 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
titleUpdated() {
|
titleUpdated() {
|
||||||
this.error = ''
|
this.error = ''
|
||||||
|
|
|
||||||
142
client/components/cards/LazyAlbumCard.vue
Normal file
142
client/components/cards/LazyAlbumCard.vue
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
<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 class="relative" :style="{ height: coverHeight + 'px' }">
|
||||||
|
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||||
|
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||||
|
<covers-preview-cover ref="cover" :src="coverSrc" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative w-full">
|
||||||
|
<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: `0em ${0.5}em` }">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
index: Number,
|
||||||
|
width: Number,
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 192
|
||||||
|
},
|
||||||
|
bookshelfView: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
albumMount: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
album: null,
|
||||||
|
isSelectionMode: false,
|
||||||
|
selected: false,
|
||||||
|
isHovering: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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() {
|
||||||
|
const config = this.$config || this.$nuxt.$config
|
||||||
|
if (!this.album || !this.album.libraryItemId) return `${config.routerBasePath}/book_placeholder.jpg`
|
||||||
|
return this.store.getters['globals/getLibraryItemCoverSrcById'](this.album.libraryItemId)
|
||||||
|
},
|
||||||
|
labelFontSize() {
|
||||||
|
if (this.width < 160) return 0.75
|
||||||
|
return 0.9
|
||||||
|
},
|
||||||
|
sizeMultiplier() {
|
||||||
|
return this.store.getters['user/getSizeMultiplier']
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.album ? this.album.title : ''
|
||||||
|
},
|
||||||
|
artist() {
|
||||||
|
return this.album ? this.album.artist : ''
|
||||||
|
},
|
||||||
|
store() {
|
||||||
|
return this.$store || this.$nuxt.$store
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
isAlternativeBookshelfView() {
|
||||||
|
const constants = this.$constants || this.$nuxt.$constants
|
||||||
|
return this.bookshelfView == constants.BookshelfView.DETAIL
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setEntity(album) {
|
||||||
|
this.album = album
|
||||||
|
},
|
||||||
|
setSelectionMode(val) {
|
||||||
|
this.isSelectionMode = val
|
||||||
|
},
|
||||||
|
mouseover() {
|
||||||
|
this.isHovering = true
|
||||||
|
},
|
||||||
|
mouseleave() {
|
||||||
|
this.isHovering = false
|
||||||
|
},
|
||||||
|
clickCard() {
|
||||||
|
if (!this.album) return
|
||||||
|
// const router = this.$router || this.$nuxt.$router
|
||||||
|
// router.push(`/album/${this.$encode(this.title)}`)
|
||||||
|
},
|
||||||
|
clickEdit() {
|
||||||
|
this.$emit('edit', this.album)
|
||||||
|
},
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (this.albumMount) {
|
||||||
|
this.setEntity(this.albumMount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -1,41 +1,48 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="card" :id="`book-card-${index}`" tabindex="0" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-xs 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: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||||
<div :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded-sm overflow-hidden z-10 bg-primary box-shadow-book" :style="{ height: coverHeight + 'px ' }">
|
<div :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded overflow-hidden z-10 bg-primary box-shadow-book" :style="{ height: coverHeight + 'px ' }">
|
||||||
<!-- When cover image does not fill -->
|
<!-- When cover image does not fill -->
|
||||||
<div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-xs 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>
|
||||||
|
|
||||||
<div cy-id="seriesSequenceList" v-if="seriesSequenceList" class="absolute rounded-lg bg-black/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">
|
<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>
|
<p :style="{ fontSize: 0.8 + 'em' }">#{{ seriesSequenceList }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div cy-id="booksInSeries" v-else-if="booksInSeries" class="absolute rounded-lg bg-black/90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
|
<div cy-id="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>
|
<p :style="{ fontSize: 0.8 + 'em' }">{{ booksInSeries }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full h-full absolute top-0 left-0 rounded-sm overflow-hidden z-10">
|
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
||||||
<div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" aria-hidden="true" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }">
|
<div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }">
|
||||||
<p :style="{ fontSize: 0.8 + 'em' }" class="text-gray-300 text-center">{{ title }}</p>
|
<p :style="{ fontSize: 0.8 + 'em' }" class="text-gray-300 text-center">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cover Image -->
|
<!-- Cover Image -->
|
||||||
<img cy-id="coverImage" v-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 }" />
|
<img cy-id="coverImage" v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||||
|
|
||||||
<!-- Placeholder Cover Title & Author -->
|
<!-- Placeholder Cover Title & Author -->
|
||||||
<div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }">
|
<div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }">
|
||||||
<div>
|
<div>
|
||||||
<p cy-id="placeholderTitleText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
|
<p cy-id="placeholderTitleText" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }">
|
<div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }">
|
||||||
<p cy-id="placeholderAuthorText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
|
<p cy-id="placeholderAuthorText" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #78350f">
|
||||||
|
<p :style="{ fontSize: 0.8 + 'em' }">#{{ 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 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #cd9d49dd">
|
||||||
|
<p :style="{ fontSize: 0.8 + 'em' }">{{ booksInSeries }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No progress shown for podcasts (unless showing podcast episode) -->
|
<!-- 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>
|
<div cy-id="progressBar" v-if="!isPodcast || episodeProgress" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * userProgressPercent + 'px' }"></div>
|
||||||
|
|
||||||
<!-- Overlay is not shown if collapsing series in library -->
|
<!-- 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-sm md:block" :class="overlayWrapperClasslist">
|
<div cy-id="overlay" v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen) && !processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded md:block" :class="overlayWrapperClasslist">
|
||||||
<div cy-id="playButton" v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
|
<div cy-id="playButton" v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
|
||||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
|
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
|
||||||
<span class="material-symbols fill" :style="{ fontSize: playIconFontSize + 'em' }">play_arrow</span>
|
<span class="material-symbols fill" :style="{ fontSize: playIconFontSize + 'em' }">play_arrow</span>
|
||||||
|
|
@ -68,17 +75,17 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Processing/loading spinner overlay -->
|
<!-- 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/40 rounded-sm flex items-center justify-center">
|
<div cy-id="loadingSpinner" v-if="processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 rounded flex items-center justify-center">
|
||||||
<widgets-loading-spinner size="la-lg" />
|
<widgets-loading-spinner size="la-lg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Series name overlay -->
|
<!-- Series name overlay -->
|
||||||
<div cy-id="seriesNameOverlay" v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black/60 rounded-sm flex items-center justify-center" :style="{ padding: 1 + 'em' }">
|
<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>
|
<p v-if="seriesName" class="text-gray-200 text-center" :style="{ fontSize: 1.1 + 'em' }">{{ seriesName }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error widget -->
|
<!-- Error widget -->
|
||||||
<ui-tooltip cy-id="ErrorTooltip" v-if="showError" :text="errorText" plaintext class="absolute bottom-4e left-0 z-10">
|
<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">
|
<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>
|
<span class="material-symbols text-red-100 pr-1e" :style="{ fontSize: 0.875 + 'em' }">priority_high</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -86,29 +93,28 @@
|
||||||
|
|
||||||
<!-- rss feed icon -->
|
<!-- rss feed icon -->
|
||||||
<div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 + 'em' }">
|
<div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 + 'em' }">
|
||||||
<span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span>
|
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- media item shared icon -->
|
<!-- media item shared icon -->
|
||||||
<div cy-id="mediaItemShare" v-if="mediaItemShare && !isSelectionMode && !isHovering" class="absolute text-success left-0 z-10" :style="{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }">
|
<div cy-id="mediaItemShare" v-if="mediaItemShare && !isSelectionMode && !isHovering" class="absolute text-success left-0 z-10" :style="{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }">
|
||||||
<span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">public</span>
|
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">public</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Series sequence -->
|
<!-- Series sequence -->
|
||||||
<div cy-id="seriesSequence" v-if="seriesSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black/90 box-shadow-md z-10" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }">
|
<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>
|
<p :style="{ fontSize: 0.8 + 'em' }">#{{ seriesSequence }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast Episode # -->
|
<!-- Podcast Episode # -->
|
||||||
<div cy-id="podcastEpisodeNumber" v-if="recentEpisodeNumber !== null && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black/90 box-shadow-md z-10" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }">
|
<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' }">
|
<p :style="{ fontSize: 0.8 + 'em' }">
|
||||||
Episode
|
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||||
<span v-if="recentEpisodeNumber">#{{ recentEpisodeNumber }}</span>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast Num Episodes -->
|
<!-- Podcast Num Episodes -->
|
||||||
<div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black/90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }">
|
<div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }">
|
||||||
<p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfEpisodes">{{ numEpisodes }}</p>
|
<p :style="{ fontSize: 0.8 + 'em' }">{{ numEpisodes }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast Num Episodes -->
|
<!-- Podcast Num Episodes -->
|
||||||
|
|
@ -121,12 +127,12 @@
|
||||||
<!-- 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 cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full">
|
||||||
<div :style="{ fontSize: 0.9 + 'em' }">
|
<div :style="{ fontSize: 0.9 + 'em' }">
|
||||||
<ui-tooltip v-if="displayTitle" :text="displayTitle" plaintext :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||||
<p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p>
|
<p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p>
|
||||||
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
|
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<ui-tooltip v-if="showSubtitles" :text="displaySubtitle" plaintext :disabled="!displaySubtitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
<ui-tooltip v-if="showSubtitles" :text="displaySubtitle" :disabled="!displaySubtitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||||
<p cy-id="subtitle" class="truncate" ref="displaySubtitle" :style="{ fontSize: 0.6 + 'em' }">{{ displaySubtitle }}</p>
|
<p cy-id="subtitle" class="truncate" ref="displaySubtitle" :style="{ fontSize: 0.6 + 'em' }">{{ displaySubtitle }}</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || ' ' }}</p>
|
<p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || ' ' }}</p>
|
||||||
|
|
@ -195,14 +201,28 @@ export default {
|
||||||
// This method returns immediately without waiting for the DOM to update
|
// This method returns immediately without waiting for the DOM to update
|
||||||
return this.coverWidth
|
return this.coverWidth
|
||||||
},
|
},
|
||||||
|
/*
|
||||||
|
cardHeight() {
|
||||||
|
// This method returns immediately without waiting for the DOM to update
|
||||||
|
return this.coverHeight + this.detailsHeight
|
||||||
|
},
|
||||||
|
detailsHeight() {
|
||||||
|
if (!this.isAlternativeBookshelfView) return 0
|
||||||
|
const lineHeight = 1.5
|
||||||
|
const remSize = 16
|
||||||
|
const baseHeight = this.sizeMultiplier * lineHeight * remSize
|
||||||
|
const titleHeight = 0.9 * baseHeight
|
||||||
|
const line2Height = 0.8 * baseHeight
|
||||||
|
const line3Height = this.displaySortLine ? 0.8 * baseHeight : 0
|
||||||
|
const marginHeight = 8 * 2 * this.sizeMultiplier // py-2
|
||||||
|
return titleHeight + line2Height + line3Height + marginHeight
|
||||||
|
},
|
||||||
|
*/
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
return this.store.getters['user/getSizeMultiplier']
|
return this.store.getters['user/getSizeMultiplier']
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.store.getters['getServerSetting']('dateFormat')
|
return this.store.state.serverSettings.dateFormat
|
||||||
},
|
|
||||||
timeFormat() {
|
|
||||||
return this.store.getters['getServerSetting']('timeFormat')
|
|
||||||
},
|
},
|
||||||
_libraryItem() {
|
_libraryItem() {
|
||||||
return this.libraryItem || {}
|
return this.libraryItem || {}
|
||||||
|
|
@ -223,11 +243,15 @@ export default {
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.mediaType === 'podcast' || this.store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
|
return this.mediaType === 'podcast' || this.store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
|
||||||
},
|
},
|
||||||
|
isMusic() {
|
||||||
|
return this.mediaType === 'music'
|
||||||
|
},
|
||||||
isExplicit() {
|
isExplicit() {
|
||||||
return this.mediaMetadata.explicit || false
|
return this.mediaMetadata.explicit || false
|
||||||
},
|
},
|
||||||
placeholderUrl() {
|
placeholderUrl() {
|
||||||
return this.store.getters['globals/getPlaceholderCoverSrc']
|
const config = this.$config || this.$nuxt.$config
|
||||||
|
return `${config.routerBasePath}/book_placeholder.jpg`
|
||||||
},
|
},
|
||||||
bookCoverSrc() {
|
bookCoverSrc() {
|
||||||
return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
|
return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
|
||||||
|
|
@ -240,7 +264,6 @@ export default {
|
||||||
return this.mediaMetadata.series
|
return this.mediaMetadata.series
|
||||||
},
|
},
|
||||||
seriesName() {
|
seriesName() {
|
||||||
if (this.collapsedSeries?.name) return this.collapsedSeries.name
|
|
||||||
return this.series?.name || null
|
return this.series?.name || null
|
||||||
},
|
},
|
||||||
seriesSequence() {
|
seriesSequence() {
|
||||||
|
|
@ -322,7 +345,7 @@ export default {
|
||||||
},
|
},
|
||||||
displaySubtitle() {
|
displaySubtitle() {
|
||||||
if (!this.libraryItem) return '\u00A0'
|
if (!this.libraryItem) return '\u00A0'
|
||||||
if (this.collapsedSeries) return `${this.collapsedSeries.numBooks} ${this.$strings.LabelBooks}`
|
if (this.collapsedSeries) return this.collapsedSeries.numBooks === 1 ? '1 book' : `${this.collapsedSeries.numBooks} books`
|
||||||
if (this.mediaMetadata.subtitle) return this.mediaMetadata.subtitle
|
if (this.mediaMetadata.subtitle) return this.mediaMetadata.subtitle
|
||||||
if (this.mediaMetadata.seriesName) return this.mediaMetadata.seriesName
|
if (this.mediaMetadata.seriesName) return this.mediaMetadata.seriesName
|
||||||
return ''
|
return ''
|
||||||
|
|
@ -330,6 +353,7 @@ export default {
|
||||||
displayLineTwo() {
|
displayLineTwo() {
|
||||||
if (this.recentEpisode) return this.title
|
if (this.recentEpisode) return this.title
|
||||||
if (this.isPodcast) return this.author
|
if (this.isPodcast) return this.author
|
||||||
|
if (this.isMusic) return this.artist
|
||||||
if (this.collapsedSeries) return ''
|
if (this.collapsedSeries) return ''
|
||||||
if (this.isAuthorBookshelfView) {
|
if (this.isAuthorBookshelfView) {
|
||||||
return this.mediaMetadata.publishedYear || ''
|
return this.mediaMetadata.publishedYear || ''
|
||||||
|
|
@ -339,28 +363,16 @@ 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.orderBy === 'media.metadata.publishedYear') {
|
||||||
if (this.mediaMetadata.publishedYear) return this.$getString('LabelPublishedDate', [this.mediaMetadata.publishedYear])
|
if (this.mediaMetadata.publishedYear) return 'Published ' + this.mediaMetadata.publishedYear
|
||||||
return '\u00A0'
|
return '\u00A0'
|
||||||
}
|
}
|
||||||
if (this.orderBy === 'progress') {
|
|
||||||
if (!this.userProgressLastUpdated) return '\u00A0'
|
|
||||||
return this.$getString('LabelLastProgressDate', [this.$formatDatetime(this.userProgressLastUpdated, this.dateFormat, this.timeFormat)])
|
|
||||||
}
|
|
||||||
if (this.orderBy === 'progress.createdAt') {
|
|
||||||
if (!this.userProgressStartedDate) return '\u00A0'
|
|
||||||
return this.$getString('LabelStartedDate', [this.$formatDatetime(this.userProgressStartedDate, this.dateFormat, this.timeFormat)])
|
|
||||||
}
|
|
||||||
if (this.orderBy === 'progress.finishedAt') {
|
|
||||||
if (!this.userProgressFinishedDate) return '\u00A0'
|
|
||||||
return this.$getString('LabelFinishedDate', [this.$formatDatetime(this.userProgressFinishedDate, this.dateFormat, this.timeFormat)])
|
|
||||||
}
|
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
episodeProgress() {
|
episodeProgress() {
|
||||||
|
|
@ -369,6 +381,7 @@ 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)
|
||||||
},
|
},
|
||||||
|
|
@ -393,18 +406,6 @@ export default {
|
||||||
let progressPercent = this.itemIsFinished ? 1 : this.booksInSeries ? this.seriesProgressPercent : this.useEBookProgress ? this.userProgress?.ebookProgress || 0 : this.userProgress?.progress || 0
|
let progressPercent = this.itemIsFinished ? 1 : this.booksInSeries ? this.seriesProgressPercent : this.useEBookProgress ? this.userProgress?.ebookProgress || 0 : this.userProgress?.progress || 0
|
||||||
return Math.max(Math.min(1, progressPercent), 0)
|
return Math.max(Math.min(1, progressPercent), 0)
|
||||||
},
|
},
|
||||||
userProgressLastUpdated() {
|
|
||||||
if (!this.userProgress) return null
|
|
||||||
return this.userProgress.lastUpdate
|
|
||||||
},
|
|
||||||
userProgressStartedDate() {
|
|
||||||
if (!this.userProgress) return null
|
|
||||||
return this.userProgress.startedAt
|
|
||||||
},
|
|
||||||
userProgressFinishedDate() {
|
|
||||||
if (!this.userProgress) return null
|
|
||||||
return this.userProgress.finishedAt
|
|
||||||
},
|
|
||||||
itemIsFinished() {
|
itemIsFinished() {
|
||||||
if (this.booksInSeries) return this.seriesIsFinished
|
if (this.booksInSeries) return this.seriesIsFinished
|
||||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||||
|
|
@ -436,7 +437,7 @@ export default {
|
||||||
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
|
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
|
||||||
},
|
},
|
||||||
showPlayButton() {
|
showPlayButton() {
|
||||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
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.ebookFormat
|
||||||
|
|
@ -457,8 +458,8 @@ export default {
|
||||||
},
|
},
|
||||||
overlayWrapperClasslist() {
|
overlayWrapperClasslist() {
|
||||||
const classes = []
|
const classes = []
|
||||||
if (this.isSelectionMode) classes.push('bg-black/60')
|
if (this.isSelectionMode) classes.push('bg-opacity-60')
|
||||||
else classes.push('bg-black/40')
|
else classes.push('bg-opacity-40')
|
||||||
if (this.selected) {
|
if (this.selected) {
|
||||||
classes.push('border-2 border-yellow-400')
|
classes.push('border-2 border-yellow-400')
|
||||||
}
|
}
|
||||||
|
|
@ -480,6 +481,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 = [
|
||||||
{
|
{
|
||||||
|
|
@ -724,7 +727,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)
|
||||||
|
|
@ -769,18 +772,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
|
||||||
|
|
@ -788,11 +791,11 @@ export default {
|
||||||
},
|
},
|
||||||
showEditModalFiles() {
|
showEditModalFiles() {
|
||||||
// More menu func
|
// More menu func
|
||||||
this.$emit('edit', this.libraryItem, 'files')
|
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'files' })
|
||||||
},
|
},
|
||||||
showEditModalMatch() {
|
showEditModalMatch() {
|
||||||
// More menu func
|
// More menu func
|
||||||
this.$emit('edit', this.libraryItem, 'match')
|
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
|
||||||
},
|
},
|
||||||
sendToDevice(deviceName) {
|
sendToDevice(deviceName) {
|
||||||
// More menu func
|
// More menu func
|
||||||
|
|
@ -837,7 +840,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
|
||||||
|
|
@ -855,7 +858,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
|
||||||
|
|
@ -870,7 +873,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
|
||||||
}
|
}
|
||||||
|
|
@ -920,11 +923,11 @@ export default {
|
||||||
axios
|
axios
|
||||||
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
|
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success(this.$strings.ToastItemDeletedSuccess)
|
this.$toast.success('Item deleted')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to delete item', error)
|
console.error('Failed to delete item', error)
|
||||||
this.$toast.error(this.$strings.ToastItemDeletedFailed)
|
this.$toast.error('Failed to delete item')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
|
|
@ -1030,7 +1033,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,13 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="card" :id="`collection-card-${index}`" role="button" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-xs 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: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||||
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
||||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||||
<div class="w-full h-full bg-primary relative rounded-sm overflow-hidden">
|
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||||
<covers-collection-cover ref="cover" :book-items="books" :width="cardWidth" :height="coverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-collection-cover ref="cover" :book-items="books" :width="cardWidth" :height="coverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
<div v-show="isHovering && userCanUpdate" class="w-full h-full absolute top-0 left-0 z-10 bg-black/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 + 'em', right: 0.5 + 'em' }" @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-symbols text-white/75 hover:text-white/100" :style="{ fontSize: 1.25 + 'em' }">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>
|
||||||
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
</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' }">
|
<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-xs border" :style="{ padding: `0em ${0.5}em` }">
|
<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>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -57,11 +57,23 @@ export default {
|
||||||
return this.store.getters['libraries/getBookCoverAspectRatio']
|
return this.store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
cardWidth() {
|
cardWidth() {
|
||||||
return this.width || (this.coverHeight / this.bookCoverAspectRatio) * 2
|
return this.width || this.coverHeight * 2
|
||||||
},
|
},
|
||||||
coverHeight() {
|
coverHeight() {
|
||||||
return this.height * this.sizeMultiplier
|
return this.height * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
|
cardHeight() {
|
||||||
|
return this.coverHeight + this.bottomTextHeight
|
||||||
|
},
|
||||||
|
bottomTextHeight() {
|
||||||
|
if (!this.isAlternativeBookshelfView) return 0 // bottom text appears on top of the divider
|
||||||
|
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
|
||||||
|
},
|
||||||
labelFontSize() {
|
labelFontSize() {
|
||||||
if (this.width < 160) return 0.75
|
if (this.width < 160) return 0.75
|
||||||
return 0.9
|
return 0.9
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
<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-xs 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: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||||
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
||||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||||
<div class="w-full h-full bg-primary relative rounded-sm overflow-hidden">
|
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||||
<covers-playlist-cover ref="cover" :items="items" :width="cardWidth" :height="coverHeight" />
|
<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/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 + 'em', right: 0.5 + 'em' }" @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-symbols text-white/75 hover:text-white/100" :style="{ fontSize: 1.25 + 'em' }">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>
|
||||||
|
|
||||||
<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 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-xs border" :style="{ padding: `0em ${0.5}em` }">
|
<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>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
<template>
|
<template>
|
||||||
<div cy-id="card" ref="card" :id="`series-card-${index}`" tabindex="0" :style="{ width: cardWidth + 'px' }" class="absolute rounded-xs z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
<div cy-id="card" ref="card" :id="`series-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||||
<div cy-id="covers-area" class="relative" :style="{ height: coverHeight + 'px' }">
|
<div cy-id="covers-area" class="relative" :style="{ height: coverHeight + 'px' }">
|
||||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||||
<div class="w-full h-full bg-primary relative rounded-sm 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="cardWidth" :height="coverHeight" :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>
|
||||||
|
|
||||||
<div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black/90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
|
<div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
|
||||||
<p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfBooks">{{ books.length }}</p>
|
<p :style="{ fontSize: 0.8 + 'em' }">{{ books.length }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-xs 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="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||||
|
|
||||||
<div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" aria-hidden="true" class="bg-black/60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: '1em' }">
|
<div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: '1em' }">
|
||||||
<p :style="{ fontSize: 1.2 + 'em' }">{{ displayTitle }}</p>
|
<p :style="{ fontSize: 1.2 + 'em' }">{{ displayTitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
</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 cy-id="standardBottomText" v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-xs border" :style="{ padding: `0em 0.5em` }">
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
|
||||||
<p cy-id="standardBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
<p cy-id="standardBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -65,13 +65,13 @@ export default {
|
||||||
return this.store.getters['libraries/getBookCoverAspectRatio']
|
return this.store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
cardWidth() {
|
cardWidth() {
|
||||||
return this.width || (this.coverHeight / this.bookCoverAspectRatio) * 2
|
return this.width || this.coverHeight * 2
|
||||||
},
|
},
|
||||||
coverHeight() {
|
coverHeight() {
|
||||||
return this.height * this.sizeMultiplier
|
return this.height * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.store.getters['getServerSetting']('dateFormat')
|
return this.store.state.serverSettings.dateFormat
|
||||||
},
|
},
|
||||||
labelFontSize() {
|
labelFontSize() {
|
||||||
if (this.width < 160) return 0.75
|
if (this.width < 160) return 0.75
|
||||||
|
|
@ -96,7 +96,7 @@ export default {
|
||||||
displaySortLine() {
|
displaySortLine() {
|
||||||
switch (this.orderBy) {
|
switch (this.orderBy) {
|
||||||
case 'addedAt':
|
case 'addedAt':
|
||||||
return this.$getString('LabelAddedDate', [this.$formatDate(this.addedAt, this.dateFormat)])
|
return `${this.$strings.LabelAdded} ${this.$formatDate(this.addedAt, this.dateFormat)}`
|
||||||
case 'totalDuration':
|
case 'totalDuration':
|
||||||
return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}`
|
return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}`
|
||||||
case 'lastBookUpdated':
|
case 'lastBookUpdated':
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(name)}`">
|
<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 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">
|
<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>
|
<span class="material-symbols-outlined text-[10em]">record_voice_over</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Narrator name & num books overlay -->
|
<!-- Narrator name & num books overlay -->
|
||||||
<div class="absolute bottom-0 left-0 w-full py-1e bg-black/60 px-2e">
|
<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="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>
|
<p cy-id="numBooks" class="text-center text-gray-200" :style="{ fontSize: 0.65 + 'em' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
<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"></span>
|
<span class="material-symbols text-2xl text-gray-200">record_voice_over</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="grow px-2 narratorSearchCardContent h-full">
|
<div class="flex-grow px-2 narratorSearchCardContent h-full">
|
||||||
<p class="truncate text-sm">{{ narrator }}</p>
|
<p class="truncate text-sm">{{ narrator }}</p>
|
||||||
<p class="text-xs text-gray-400">{{ $getString('LabelXBooks', [numBooks]) }}</p>
|
<p class="text-xs text-gray-400">{{ $getString('LabelXBooks', [numBooks]) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full border border-white/10 rounded-xl p-4 my-2" :class="notification.enabled ? 'bg-primary/25' : 'bg-error/5'">
|
<div class="w-full border border-white border-opacity-10 rounded-xl p-4 my-2" :class="notification.enabled ? 'bg-primary bg-opacity-25' : 'bg-error bg-opacity-5'">
|
||||||
<div class="flex flex-wrap items-center">
|
<div class="flex flex-wrap items-center">
|
||||||
<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="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="bg-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="bg-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="bg-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" />
|
||||||
</div>
|
</div>
|
||||||
<div class="pt-4">
|
<div class="pt-4">
|
||||||
<p class="text-gray-300 text-xs md:text-sm mb-2">{{ notification.urls.join(', ') }}</p>
|
<p class="text-gray-300 text-xs md:text-sm mb-2">{{ notification.urls.join(', ') }}</p>
|
||||||
|
|
@ -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,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="w-full p-2 border border-white/10 rounded-sm">
|
<div ref="wrapper" class="w-full p-2 border border-white border-opacity-10 rounded">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="w-16 min-w-16">
|
<div class="w-16 min-w-16">
|
||||||
<div class="w-full h-16 bg-primary">
|
<div class="w-full h-16 bg-primary">
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-400 text-xxs pt-1 text-center">{{ numEpisodes }} {{ $strings.HeaderEpisodes }}</p>
|
<p class="text-gray-400 text-xxs pt-1 text-center">{{ numEpisodes }} {{ $strings.HeaderEpisodes }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grow pl-2" :style="{ maxWidth: detailsWidth + 'px' }">
|
<div class="flex-grow pl-2" :style="{ maxWidth: detailsWidth + 'px' }">
|
||||||
<p class="mb-1">{{ title }}</p>
|
<p class="mb-1">{{ title }}</p>
|
||||||
<p class="text-xs mb-1 text-gray-300">{{ author }}</p>
|
<p class="text-xs mb-1 text-gray-300">{{ author }}</p>
|
||||||
<p class="text-xs mb-2 text-gray-200">{{ description }}</p>
|
<p class="text-xs mb-2 text-gray-200">{{ description }}</p>
|
||||||
|
|
@ -68,4 +68,4 @@ export default {
|
||||||
this.width = this.$refs.wrapper.clientWidth
|
this.width = this.$refs.wrapper.clientWidth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full px-1 overflow-hidden">
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
<covers-group-cover :name="name" :book-items="bookItems" :width="60" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-group-cover :name="name" :book-items="bookItems" :width="60" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<div class="grow px-2 seriesSearchCardContent h-full">
|
<div class="flex-grow px-2 seriesSearchCardContent h-full">
|
||||||
<p class="truncate text-sm">{{ name }}</p>
|
<p class="truncate text-sm">{{ name }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<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-symbols text-2xl text-gray-200">local_offer</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="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>
|
<p class="text-xs text-gray-400">{{ $getString('LabelXItems', [numItems]) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,77 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="narrators?.length" class="flex py-0.5 mt-4">
|
<div v-if="narrators?.length" class="flex py-0.5 mt-4">
|
||||||
<div class="w-34 min-w-34 sm:w-34 sm:min-w-34 break-words">
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
<span class="text-white/60 uppercase text-sm">{{ $strings.LabelNarrators }}</span>
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNarrators }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden text-ellipsis">
|
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
||||||
<template v-for="(narrator, index) in narrators">
|
<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
|
<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>
|
><span :key="index" v-if="index < narrators.length - 1">, </span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="publishedYear" role="paragraph" class="flex py-0.5">
|
<div v-if="publishedYear" class="flex py-0.5">
|
||||||
<div class="w-34 min-w-34 sm:w-34 sm:min-w-34 break-words">
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
<span class="text-white/60 uppercase text-sm">{{ $strings.LabelPublishYear }}</span>
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublishYear }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{{ publishedYear }}
|
{{ publishedYear }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="publisher" role="paragraph" class="flex py-0.5">
|
<div v-if="publisher" class="flex py-0.5">
|
||||||
<div class="w-34 min-w-34 sm:w-34 sm:min-w-34 break-words">
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
<span class="text-white/60 uppercase text-sm">{{ $strings.LabelPublisher }}</span>
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublisher }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=publishers.${$encode(publisher)}`" class="hover:underline">{{ publisher }}</nuxt-link>
|
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=publishers.${$encode(publisher)}`" class="hover:underline">{{ publisher }}</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="podcastType" role="paragraph" class="flex py-0.5">
|
<div v-if="musicAlbum" class="flex py-0.5">
|
||||||
<div class="w-34 min-w-34 sm:w-34 sm:min-w-34 break-words">
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
<span class="text-white/60 uppercase text-sm">{{ $strings.LabelPodcastType }}</span>
|
<span class="text-white text-opacity-60 uppercase text-sm">Album</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ musicAlbum }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="musicAlbumArtist" class="flex py-0.5">
|
||||||
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ musicAlbumArtist }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="musicTrackPretty" class="flex py-0.5">
|
||||||
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Track</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ musicTrackPretty }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="musicDiscPretty" class="flex py-0.5">
|
||||||
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Disc</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ musicDiscPretty }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="podcastType" class="flex py-0.5">
|
||||||
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPodcastType }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="capitalize">
|
<div class="capitalize">
|
||||||
{{ podcastType }}
|
{{ podcastType }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex py-0.5" v-if="genres.length">
|
<div class="flex py-0.5" v-if="genres.length">
|
||||||
<div class="w-34 min-w-34 sm:w-34 sm:min-w-34 break-words">
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
<span class="text-white/60 uppercase text-sm">{{ $strings.LabelGenres }}</span>
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelGenres }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden text-ellipsis">
|
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
||||||
<template v-for="(genre, index) in genres">
|
<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
|
<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>
|
><span :key="index" v-if="index < genres.length - 1">, </span>
|
||||||
|
|
@ -47,10 +79,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex py-0.5" v-if="tags.length">
|
<div class="flex py-0.5" v-if="tags.length">
|
||||||
<div class="w-34 min-w-34 sm:w-34 sm:min-w-34 break-words">
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
<span class="text-white/60 uppercase text-sm">{{ $strings.LabelTags }}</span>
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelTags }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden text-ellipsis">
|
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
||||||
<template v-for="(tag, index) in tags">
|
<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
|
<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>
|
><span :key="index" v-if="index < tags.length - 1">, </span>
|
||||||
|
|
@ -58,24 +90,24 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="language" class="flex py-0.5">
|
<div v-if="language" class="flex py-0.5">
|
||||||
<div class="w-34 min-w-34 sm:w-34 sm:min-w-34 break-words">
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
<span class="text-white/60 uppercase text-sm">{{ $strings.LabelLanguage }}</span>
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelLanguage }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=languages.${$encode(language)}`" class="hover:underline">{{ language }}</nuxt-link>
|
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=languages.${$encode(language)}`" class="hover:underline">{{ language }}</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="tracks.length || (isPodcast && totalPodcastDuration)" role="paragraph" class="flex py-0.5">
|
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
|
||||||
<div class="w-34 min-w-34 sm:w-34 sm:min-w-34 break-words">
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
<span class="text-white/60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{{ durationPretty }}
|
{{ durationPretty }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div role="paragraph" class="flex py-0.5">
|
<div class="flex py-0.5">
|
||||||
<div class="w-34 min-w-34 sm:w-34 sm:min-w-34 break-words">
|
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
<span class="text-white/60 uppercase text-sm">{{ $strings.LabelSize }}</span>
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelSize }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{{ sizePretty }}
|
{{ sizePretty }}
|
||||||
|
|
@ -102,6 +134,10 @@ export default {
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.libraryItem.mediaType === 'podcast'
|
return this.libraryItem.mediaType === 'podcast'
|
||||||
},
|
},
|
||||||
|
audioFile() {
|
||||||
|
// Music track
|
||||||
|
return this.media.audioFile
|
||||||
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem.media || {}
|
return this.libraryItem.media || {}
|
||||||
},
|
},
|
||||||
|
|
@ -132,6 +168,25 @@ export default {
|
||||||
publisher() {
|
publisher() {
|
||||||
return this.mediaMetadata.publisher || ''
|
return this.mediaMetadata.publisher || ''
|
||||||
},
|
},
|
||||||
|
musicArtists() {
|
||||||
|
return this.mediaMetadata.artists || []
|
||||||
|
},
|
||||||
|
musicAlbum() {
|
||||||
|
return this.mediaMetadata.album || ''
|
||||||
|
},
|
||||||
|
musicAlbumArtist() {
|
||||||
|
return this.mediaMetadata.albumArtist || ''
|
||||||
|
},
|
||||||
|
musicTrackPretty() {
|
||||||
|
if (!this.mediaMetadata.trackNumber) return null
|
||||||
|
if (!this.mediaMetadata.trackTotal) return this.mediaMetadata.trackNumber
|
||||||
|
return `${this.mediaMetadata.trackNumber} / ${this.mediaMetadata.trackTotal}`
|
||||||
|
},
|
||||||
|
musicDiscPretty() {
|
||||||
|
if (!this.mediaMetadata.discNumber) return null
|
||||||
|
if (!this.mediaMetadata.discTotal) return this.mediaMetadata.discNumber
|
||||||
|
return `${this.mediaMetadata.discNumber} / ${this.mediaMetadata.discTotal}`
|
||||||
|
},
|
||||||
narrators() {
|
narrators() {
|
||||||
return this.mediaMetadata.narrators || []
|
return this.mediaMetadata.narrators || []
|
||||||
},
|
},
|
||||||
|
|
@ -165,4 +220,4 @@ export default {
|
||||||
methods: {},
|
methods: {},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,25 +1,23 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||||
<div class="relative h-9">
|
<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-sm shadow-xs pl-3 pr-3 py-0 text-left focus:outline-hidden 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">{{ selectedText }}</span>
|
||||||
<span class="block truncate text-xs">{{ 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 type="button" :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-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-symbols" 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 max-h-96 rounded-md py-1 text-sm ring-1 ring-black/5 overflow-auto focus:outline-hidden">
|
<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">
|
||||||
<ul class="h-full w-full" role="menu">
|
<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="menuitem" @click="clickedOption(item)">
|
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="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">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -88,4 +86,4 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="w-full relative sm:w-80">
|
<div class="w-full relative sm:w-80">
|
||||||
<form role="search" @submit.prevent="submitSearch">
|
<form @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>
|
||||||
<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">
|
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
||||||
<span v-if="!search" class="material-symbols" style="font-size: 1.2rem"></span>
|
<span v-if="!search" class="material-symbols" style="font-size: 1.2rem">search</span>
|
||||||
<span v-else class="material-symbols" style="font-size: 1.2rem">close</span>
|
<span v-else class="material-symbols" style="font-size: 1.2rem">close</span>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black/5 overflow-auto focus:outline-hidden sm:text-sm globalSearchMenu" @mousedown.stop.prevent>
|
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
|
||||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<li v-if="isTyping" class="py-2 px-2">
|
<li v-if="isTyping" class="py-2 px-2">
|
||||||
<p>{{ $strings.MessageThinking }}</p>
|
<p>{{ $strings.MessageThinking }}</p>
|
||||||
|
|
@ -39,19 +39,10 @@
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p v-if="episodeResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelEpisodes }}</p>
|
|
||||||
<template v-for="item in episodeResults">
|
|
||||||
<li :key="item.libraryItem.recentEpisode.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}`">
|
|
||||||
<cards-episode-search-card :episode="item.libraryItem.recentEpisode" :library-item="item.libraryItem" />
|
|
||||||
</nuxt-link>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
@ -109,7 +100,6 @@ export default {
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
search: null,
|
search: null,
|
||||||
podcastResults: [],
|
podcastResults: [],
|
||||||
episodeResults: [],
|
|
||||||
bookResults: [],
|
bookResults: [],
|
||||||
authorResults: [],
|
authorResults: [],
|
||||||
seriesResults: [],
|
seriesResults: [],
|
||||||
|
|
@ -125,7 +115,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 + this.episodeResults.length
|
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.genreResults.length + this.podcastResults.length + this.narratorResults.length
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
@ -142,7 +132,6 @@ export default {
|
||||||
this.search = null
|
this.search = null
|
||||||
this.lastSearch = null
|
this.lastSearch = null
|
||||||
this.podcastResults = []
|
this.podcastResults = []
|
||||||
this.episodeResults = []
|
|
||||||
this.bookResults = []
|
this.bookResults = []
|
||||||
this.authorResults = []
|
this.authorResults = []
|
||||||
this.seriesResults = []
|
this.seriesResults = []
|
||||||
|
|
@ -168,7 +157,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
|
||||||
|
|
@ -186,7 +175,6 @@ export default {
|
||||||
if (!this.isFetching) return
|
if (!this.isFetching) return
|
||||||
|
|
||||||
this.podcastResults = searchResults.podcast || []
|
this.podcastResults = searchResults.podcast || []
|
||||||
this.episodeResults = searchResults.episodes || []
|
|
||||||
this.bookResults = searchResults.book || []
|
this.bookResults = searchResults.book || []
|
||||||
this.authorResults = searchResults.authors || []
|
this.authorResults = searchResults.authors || []
|
||||||
this.seriesResults = searchResults.series || []
|
this.seriesResults = searchResults.series || []
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,28 @@
|
||||||
<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-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||||
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded-sm shadow-xs pl-3 pr-3 py-0 text-left focus:outline-hidden 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-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
||||||
<span class="material-symbols" style="font-size: 1.1rem">close</span>
|
<span class="material-symbols" style="font-size: 1.1rem">close</span>
|
||||||
</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/5 overflow-auto focus:outline-hidden text-sm libraryFilterMenu">
|
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
|
||||||
<ul v-show="!sublist" class="h-full w-full" role="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="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)">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
|
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
|
||||||
<span class="material-symbols text-2xl" :aria-label="$strings.LabelMore">arrow_right</span>
|
<span class="material-symbols text-2xl">arrow_right</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- selected checkmark icon -->
|
<!-- selected checkmark icon -->
|
||||||
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
||||||
|
|
@ -33,8 +31,8 @@
|
||||||
</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-white/5" 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-symbols text-2xl">arrow_left</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -42,13 +40,13 @@
|
||||||
<span class="font-normal block truncate">{{ $strings.ButtonBack }}</span>
|
<span class="font-normal block truncate">{{ $strings.ButtonBack }}</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="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">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<template v-for="item in sublistItems">
|
<template v-for="item in sublistItems">
|
||||||
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedSublistOption(item.value)">
|
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="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>
|
||||||
|
|
@ -94,15 +92,15 @@ export default {
|
||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
userCanAccessExplicitContent() {
|
|
||||||
return this.$store.getters['user/getUserCanAccessExplicitContent']
|
|
||||||
},
|
|
||||||
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 [
|
||||||
{
|
{
|
||||||
|
|
@ -194,12 +192,6 @@ export default {
|
||||||
value: 'publishers',
|
value: 'publishers',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
text: this.$strings.LabelPublishedDecade,
|
|
||||||
textPlural: this.$strings.LabelPublishedDecades,
|
|
||||||
value: 'publishedDecades',
|
|
||||||
sublist: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelLanguage,
|
text: this.$strings.LabelLanguage,
|
||||||
textPlural: this.$strings.LabelLanguages,
|
textPlural: this.$strings.LabelLanguages,
|
||||||
|
|
@ -242,15 +234,6 @@ export default {
|
||||||
sublist: false
|
sublist: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
if (this.userCanAccessExplicitContent) {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.LabelExplicit,
|
|
||||||
value: 'explicit',
|
|
||||||
sublist: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.userIsAdminOrUp) {
|
if (this.userIsAdminOrUp) {
|
||||||
items.push({
|
items.push({
|
||||||
text: this.$strings.LabelShareOpen,
|
text: this.$strings.LabelShareOpen,
|
||||||
|
|
@ -261,7 +244,7 @@ export default {
|
||||||
return items
|
return items
|
||||||
},
|
},
|
||||||
podcastItems() {
|
podcastItems() {
|
||||||
const items = [
|
return [
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelAll,
|
text: this.$strings.LabelAll,
|
||||||
value: 'all'
|
value: 'all'
|
||||||
|
|
@ -288,27 +271,38 @@ export default {
|
||||||
text: this.$strings.ButtonIssues,
|
text: this.$strings.ButtonIssues,
|
||||||
value: 'issues',
|
value: 'issues',
|
||||||
sublist: false
|
sublist: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
musicItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelAll,
|
||||||
|
value: 'all'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelRSSFeedOpen,
|
text: this.$strings.LabelGenre,
|
||||||
value: 'feed-open',
|
textPlural: this.$strings.LabelGenres,
|
||||||
|
value: 'genres',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelTag,
|
||||||
|
textPlural: this.$strings.LabelTags,
|
||||||
|
value: 'tags',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.ButtonIssues,
|
||||||
|
value: 'issues',
|
||||||
sublist: false
|
sublist: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
if (this.userCanAccessExplicitContent) {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.LabelExplicit,
|
|
||||||
value: 'explicit',
|
|
||||||
sublist: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
},
|
},
|
||||||
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() {
|
||||||
|
|
@ -338,18 +332,6 @@ export default {
|
||||||
const series = this.series.find((se) => se.id == decoded)
|
const series = this.series.find((se) => se.id == decoded)
|
||||||
if (series) filterValue = series.name
|
if (series) filterValue = series.name
|
||||||
}
|
}
|
||||||
} else if (parts[0] === 'progress') {
|
|
||||||
const item = this.progress.find((p) => p.id == decoded)
|
|
||||||
if (item) filterValue = item.name
|
|
||||||
} else if (parts[0] === 'tracks') {
|
|
||||||
const item = this.tracks.find((t) => t.id == decoded)
|
|
||||||
if (item) filterValue = item.name
|
|
||||||
} else if (parts[0] === 'ebooks') {
|
|
||||||
const item = this.ebooks.find((e) => e.id == decoded)
|
|
||||||
if (item) filterValue = item.name
|
|
||||||
} else if (parts[0] === 'missing') {
|
|
||||||
const item = this.missing.find((m) => m.id == decoded)
|
|
||||||
if (item) filterValue = item.name
|
|
||||||
} else {
|
} else {
|
||||||
filterValue = decoded
|
filterValue = decoded
|
||||||
}
|
}
|
||||||
|
|
@ -385,9 +367,6 @@ export default {
|
||||||
publishers() {
|
publishers() {
|
||||||
return this.filterData.publishers || []
|
return this.filterData.publishers || []
|
||||||
},
|
},
|
||||||
publishedDecades() {
|
|
||||||
return this.filterData.publishedDecades || []
|
|
||||||
},
|
|
||||||
progress() {
|
progress() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
@ -454,17 +433,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',
|
||||||
|
|
@ -475,32 +458,24 @@ 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
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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-sm shadow-xs pl-3 pr-3 py-0 text-left focus:outline-hidden 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-symbols text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ul v-show="showMenu" class="librarySortMenu 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/5 overflow-auto focus:outline-hidden 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 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none 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="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||||
<span class="material-symbols text-xl" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
<span class="material-symbols 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 [
|
||||||
{
|
{
|
||||||
|
|
@ -130,18 +133,6 @@ export default {
|
||||||
text: this.$strings.LabelFileModified,
|
text: this.$strings.LabelFileModified,
|
||||||
value: 'mtimeMs'
|
value: 'mtimeMs'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
text: this.$strings.LabelLibrarySortByProgress,
|
|
||||||
value: 'progress'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelLibrarySortByProgressStarted,
|
|
||||||
value: 'progress.createdAt'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelLibrarySortByProgressFinished,
|
|
||||||
value: 'progress.finishedAt'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelRandomly,
|
text: this.$strings.LabelRandomly,
|
||||||
value: 'random'
|
value: 'random'
|
||||||
|
|
@ -157,10 +148,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 {
|
||||||
|
|
@ -203,9 +224,3 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.librarySortMenu {
|
|
||||||
max-height: calc(100vh - 125px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
<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>
|
||||||
<div class="flex items-center h-9 relative overflow-hidden rounded-lg" style="width: 220px">
|
<div class="flex items-center h-9 relative overflow-hidden rounded-lg" style="width: 220px">
|
||||||
<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-xs" :class="value === rate ? 'bg-black-100' : 'hover:bg-black/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-sm shadow-xs pl-3 pr-3 py-0 text-left focus:outline-hidden 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 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-symbols 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/5 overflow-auto focus:outline-hidden 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 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none 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="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||||
<span class="material-symbols text-xl" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
<span class="material-symbols text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -77,4 +77,4 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -4,10 +4,10 @@
|
||||||
<span class="material-symbols text-2xl sm:text-3xl">{{ volumeIcon }}</span>
|
<span class="material-symbols text-2xl sm:text-3xl">{{ volumeIcon }}</span>
|
||||||
</button>
|
</button>
|
||||||
<transition name="menux">
|
<transition name="menux">
|
||||||
<div v-show="isOpen" class="volumeMenu h-28 absolute bottom-2 w-6 py-2 bg-bg shadow-xs 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-xs 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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -45,9 +45,9 @@ export default {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
@ -140,7 +147,7 @@ export default {
|
||||||
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>
|
||||||
|
|
@ -39,6 +39,9 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
_author() {
|
_author() {
|
||||||
return this.author || {}
|
return this.author || {}
|
||||||
},
|
},
|
||||||
|
|
@ -53,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
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="relative rounded-xs overflow-hidden" :style="{ height: height + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
|
<div class="relative rounded-sm overflow-hidden" :style="{ height: height + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
|
||||||
<div class="w-full h-full relative bg-bg">
|
<div class="w-full h-full relative bg-bg">
|
||||||
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-xs bg-primary">
|
<div 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>
|
||||||
|
|
||||||
|
|
@ -96,8 +96,8 @@ export default {
|
||||||
return this.author
|
return this.author
|
||||||
},
|
},
|
||||||
placeholderUrl() {
|
placeholderUrl() {
|
||||||
const store = this.$store || this.$nuxt.$store
|
const config = this.$config || this.$nuxt.$config
|
||||||
return store.getters['globals/getPlaceholderCoverSrc']
|
return `${config.routerBasePath}/book_placeholder.jpg`
|
||||||
},
|
},
|
||||||
fullCoverUrl() {
|
fullCoverUrl() {
|
||||||
if (!this.libraryItem) return null
|
if (!this.libraryItem) return null
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="relative rounded-xs overflow-hidden" :style="{ width: width + 'px', height: height + 'px' }">
|
<div class="relative rounded-sm overflow-hidden" :style="{ width: width + 'px', height: height + 'px' }">
|
||||||
<!-- <div class="absolute top-0 left-0 w-full h-full rounded-xs overflow-hidden z-10">
|
<!-- <div class="absolute top-0 left-0 w-full h-full rounded-sm overflow-hidden z-10">
|
||||||
<div class="w-full h-full border border-white/10" />
|
<div class="w-full h-full border border-white border-opacity-10" />
|
||||||
</div> -->
|
</div> -->
|
||||||
|
|
||||||
<div v-if="hasOwnCover" class="w-full h-full relative rounded-xs">
|
<div v-if="hasOwnCover" class="w-full h-full relative rounded-sm">
|
||||||
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
|
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
|
||||||
<div class="w-full h-full z-0" ref="coverBg" />
|
<div class="w-full h-full z-0" ref="coverBg" />
|
||||||
</div>
|
</div>
|
||||||
<img ref="cover" :src="fullCoverUrl" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
|
<img ref="cover" :src="fullCoverUrl" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="books.length" class="flex justify-center h-full relative bg-primary/95 rounded-xs">
|
<div v-else-if="books.length" class="flex justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
|
||||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400/5" />
|
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||||
|
|
||||||
<covers-book-cover :library-item="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<covers-book-cover v-if="books.length > 1" :library-item="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover v-if="books.length > 1" :library-item="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-xs">
|
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
|
||||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400/5" />
|
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||||
|
|
||||||
<p class="text-white/60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
|
<p class="text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -62,4 +62,4 @@ export default {
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -109,7 +109,7 @@ export default {
|
||||||
|
|
||||||
if (showCoverBg) {
|
if (showCoverBg) {
|
||||||
var coverbgwrapper = document.createElement('div')
|
var coverbgwrapper = document.createElement('div')
|
||||||
coverbgwrapper.className = 'absolute top-0 left-0 w-full h-full overflow-hidden rounded-xs bg-primary'
|
coverbgwrapper.className = 'absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary'
|
||||||
|
|
||||||
var coverbg = document.createElement('div')
|
var coverbg = document.createElement('div')
|
||||||
coverbg.className = 'absolute cover-bg'
|
coverbg.className = 'absolute cover-bg'
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="relative rounded-xs overflow-hidden" :style="{ width: width + 'px', height: height + 'px' }">
|
<div class="relative rounded-sm overflow-hidden" :style="{ width: width + 'px', height: height + 'px' }">
|
||||||
<div v-if="items.length" class="flex flex-wrap justify-center h-full relative bg-primary/95 rounded-xs">
|
<div v-if="items.length" class="flex flex-wrap justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
|
||||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400/5" />
|
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||||
<covers-book-cover v-for="(li, index) in libraryItemCovers" :key="index" :library-item="li" :width="itemCoverWidth" :book-cover-aspect-ratio="1" />
|
<covers-book-cover v-for="(li, index) in libraryItemCovers" :key="index" :library-item="li" :width="itemCoverWidth" :book-cover-aspect-ratio="1" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-xs">
|
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
|
||||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400/5" />
|
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -48,4 +48,4 @@ export default {
|
||||||
methods: {},
|
methods: {},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="relative rounded-xs" :style="{ height: width * bookCoverAspectRatio + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
<div class="relative rounded-sm" :style="{ height: width * bookCoverAspectRatio + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||||
<div class="w-full h-full relative overflow-hidden">
|
<div class="w-full h-full relative overflow-hidden">
|
||||||
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-xs bg-primary">
|
<div 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>
|
||||||
<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-xs 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-symbols" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="!imageFailed && showResolution && resolution" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
|
<p v-if="!imageFailed && showResolution" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -65,12 +65,11 @@ export default {
|
||||||
return 0.8 * this.sizeMultiplier
|
return 0.8 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
resolution() {
|
resolution() {
|
||||||
if (!this.naturalWidth || !this.naturalHeight) return null
|
|
||||||
return `${this.naturalWidth}×${this.naturalHeight}px`
|
return `${this.naturalWidth}×${this.naturalHeight}px`
|
||||||
},
|
},
|
||||||
placeholderUrl() {
|
placeholderUrl() {
|
||||||
const store = this.$store || this.$nuxt.$store
|
const config = this.$config || this.$nuxt.$config
|
||||||
return store.getters['globals/getPlaceholderCoverSrc']
|
return `${config.routerBasePath}/book_placeholder.jpg`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -69,15 +69,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>
|
||||||
|
|
@ -120,10 +111,10 @@
|
||||||
</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="bg-primary" type="button" class="mr-2" @click.stop="unlinkOpenID">{{ $strings.ButtonUnlinkOpenId }}</ui-btn>
|
<ui-btn v-if="hasOpenIDLink" small :loading="unlinkingFromOpenID" color="primary" type="button" class="mr-2" @click.stop="unlinkOpenID">Unlink OpenID</ui-btn>
|
||||||
<ui-btn v-if="isEditingRoot" small class="flex items-center" to="/account">{{ $strings.ButtonChangeRootPassword }}</ui-btn>
|
<ui-btn v-if="isEditingRoot" small class="flex items-center" to="/account">{{ $strings.ButtonChangeRootPassword }}</ui-btn>
|
||||||
<div class="grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn color="bg-success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -221,19 +212,19 @@ export default {
|
||||||
},
|
},
|
||||||
unlinkOpenID() {
|
unlinkOpenID() {
|
||||||
const payload = {
|
const payload = {
|
||||||
message: this.$strings.MessageConfirmUnlinkOpenId,
|
message: 'Are you sure you want to unlink this user from OpenID?',
|
||||||
callback: (confirmed) => {
|
callback: (confirmed) => {
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
this.unlinkingFromOpenID = true
|
this.unlinkingFromOpenID = true
|
||||||
this.$axios
|
this.$axios
|
||||||
.$patch(`/api/users/${this.account.id}/openid-unlink`)
|
.$patch(`/api/users/${this.account.id}/openid-unlink`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success(this.$strings.ToastUnlinkOpenIdSuccess)
|
this.$toast.success('User unlinked from OpenID')
|
||||||
this.show = false
|
this.show = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to unlink user from OpenID', error)
|
console.error('Failed to unlink user from OpenID', error)
|
||||||
this.$toast.error(this.$strings.ToastUnlinkOpenIdFailed)
|
this.$toast.error('Failed to unlink user from OpenID')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.unlinkingFromOpenID = false
|
this.unlinkingFromOpenID = false
|
||||||
|
|
@ -274,15 +265,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.itemTagsSelected.length) {
|
||||||
this.$toast.error(this.$strings.ToastNewUserTagError)
|
this.$toast.error('Must select at least one tag')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -305,13 +296,13 @@ export default {
|
||||||
.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)
|
||||||
|
|
||||||
if (data.user.id === this.user.id && data.user.accessToken !== this.user.accessToken) {
|
if (data.user.id === this.user.id && data.user.token !== this.user.token) {
|
||||||
console.log('Current user access token was updated')
|
console.log('Current user token was updated')
|
||||||
this.$store.commit('user/setAccessToken', data.user.accessToken)
|
this.$store.commit('user/setUserToken', data.user.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$toast.success(this.$strings.ToastAccountUpdateSuccess)
|
this.$toast.success(this.$strings.ToastAccountUpdateSuccess)
|
||||||
|
|
@ -322,12 +313,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 +329,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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -351,17 +342,18 @@ export default {
|
||||||
this.$toast.error(errMsg || 'Failed to create account')
|
this.$toast.error(errMsg || 'Failed to create account')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
toggleActive() {
|
||||||
|
this.newUser.isActive = !this.newUser.isActive
|
||||||
|
},
|
||||||
userTypeUpdated(type) {
|
userTypeUpdated(type) {
|
||||||
this.newUser.permissions = {
|
this.newUser.permissions = {
|
||||||
download: type !== 'guest',
|
download: type !== 'guest',
|
||||||
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,
|
selectedTagsNotAccessible: false
|
||||||
createEreader: type === 'admin'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
|
|
@ -393,9 +385,7 @@ export default {
|
||||||
upload: false,
|
upload: false,
|
||||||
accessAllLibraries: true,
|
accessAllLibraries: true,
|
||||||
accessAllTags: true,
|
accessAllTags: true,
|
||||||
accessExplicitContent: false,
|
selectedTagsNotAccessible: false
|
||||||
selectedTagsNotAccessible: false,
|
|
||||||
createEreader: false
|
|
||||||
},
|
},
|
||||||
librariesAccessible: [],
|
librariesAccessible: [],
|
||||||
itemTagsSelected: []
|
itemTagsSelected: []
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<modals-modal ref="modal" v-model="show" name="custom-metadata-provider" :width="600" :height="'unset'" :processing="processing">
|
<modals-modal ref="modal" v-model="show" name="custom-metadata-provider" :width="600" :height="'unset'" :processing="processing">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
<p class="text-3xl text-white truncate">{{ $strings.HeaderAddCustomMetadataProvider }}</p>
|
<p class="text-3xl text-white truncate">Add custom metadata provider</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="submitForm">
|
||||||
|
|
@ -10,21 +10,21 @@
|
||||||
<div class="w-full p-8">
|
<div class="w-full p-8">
|
||||||
<div class="flex mb-2">
|
<div class="flex mb-2">
|
||||||
<div class="w-3/4 p-1">
|
<div class="w-3/4 p-1">
|
||||||
<ui-text-input-with-label v-model="newName" :label="$strings.LabelName" trim-whitespace />
|
<ui-text-input-with-label v-model="newName" :label="$strings.LabelName" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/4 p-1">
|
<div class="w-1/4 p-1">
|
||||||
<ui-text-input-with-label value="Book" readonly :label="$strings.LabelMediaType" />
|
<ui-text-input-with-label value="Book" readonly :label="$strings.LabelMediaType" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full mb-2 p-1">
|
<div class="w-full mb-2 p-1">
|
||||||
<ui-text-input-with-label v-model="newUrl" label="URL" trim-whitespace />
|
<ui-text-input-with-label v-model="newUrl" label="URL" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full mb-2 p-1">
|
<div class="w-full mb-2 p-1">
|
||||||
<ui-text-input-with-label v-model="newAuthHeaderValue" :label="$strings.LabelProviderAuthorizationValue" type="password" />
|
<ui-text-input-with-label v-model="newAuthHeaderValue" :label="'Authorization Header Value'" type="password" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex px-1 pt-4">
|
<div class="flex px-1 pt-4">
|
||||||
<div class="grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn color="bg-success" type="submit">{{ $strings.ButtonAdd }}</ui-btn>
|
<ui-btn color="success" type="submit">{{ $strings.ButtonAdd }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -65,13 +65,9 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async submitForm() {
|
submitForm() {
|
||||||
// Remove focus from active input
|
|
||||||
document.activeElement?.blur?.()
|
|
||||||
await this.$nextTick()
|
|
||||||
|
|
||||||
if (!this.newName || !this.newUrl) {
|
if (!this.newName || !this.newUrl) {
|
||||||
this.$toast.error(this.$strings.ToastProviderNameAndUrlRequired)
|
this.$toast.error('Must add name and url')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,13 +81,13 @@ export default {
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.$emit('added', data.provider)
|
this.$emit('added', data.provider)
|
||||||
this.$toast.success(this.$strings.ToastProviderCreatedSuccess)
|
this.$toast.success('New provider added')
|
||||||
this.show = false
|
this.show = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
const errorMsg = error.response?.data || 'Unknown error'
|
const errorMsg = error.response?.data || 'Unknown error'
|
||||||
console.error('Failed to add provider', error)
|
console.error('Failed to add provider', error)
|
||||||
this.$toast.error(this.$strings.ToastProviderCreatedFailed + ': ' + errorMsg)
|
this.$toast.error('Failed to add provider: ' + errorMsg)
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
<template>
|
|
||||||
<modals-modal ref="modal" v-model="show" name="api-key-created" :width="800" :height="'unset'" persistent>
|
|
||||||
<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="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: 200px; max-height: 80vh">
|
|
||||||
<div class="w-full p-8">
|
|
||||||
<p class="text-lg text-white mb-4">{{ $getString('LabelApiKeyCreated', [apiKeyName]) }}</p>
|
|
||||||
|
|
||||||
<p class="text-lg text-white mb-4">{{ $strings.LabelApiKeyCreatedDescription }}</p>
|
|
||||||
|
|
||||||
<ui-text-input label="API Key" :value="apiKeyKey" readonly show-copy />
|
|
||||||
|
|
||||||
<div class="flex justify-end mt-4">
|
|
||||||
<ui-btn color="bg-primary" @click="show = false">{{ $strings.ButtonClose }}</ui-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
value: Boolean,
|
|
||||||
apiKey: {
|
|
||||||
type: Object,
|
|
||||||
default: () => null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.value
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('input', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title() {
|
|
||||||
return this.$strings.HeaderNewApiKey
|
|
||||||
},
|
|
||||||
apiKeyName() {
|
|
||||||
return this.apiKey?.name || ''
|
|
||||||
},
|
|
||||||
apiKeyKey() {
|
|
||||||
return this.apiKey?.apiKey || ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
<template>
|
|
||||||
<modals-modal ref="modal" v-model="show" name="api-key" :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="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="w-full p-8">
|
|
||||||
<div class="flex py-2">
|
|
||||||
<div class="w-1/2 px-2">
|
|
||||||
<ui-text-input-with-label v-model.trim="newApiKey.name" :readonly="!isNew" :label="$strings.LabelName" />
|
|
||||||
</div>
|
|
||||||
<div v-if="isNew" class="w-1/2 px-2">
|
|
||||||
<ui-text-input-with-label v-model.trim="newApiKey.expiresIn" :label="$strings.LabelExpiresInSeconds" type="number" :min="0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center pt-4 pb-2 gap-2">
|
|
||||||
<div class="flex items-center px-2">
|
|
||||||
<p class="px-3 font-semibold" id="user-enabled-toggle">{{ $strings.LabelEnable }}</p>
|
|
||||||
<ui-toggle-switch :disabled="isExpired && !apiKey.isActive" labeledBy="user-enabled-toggle" v-model="newApiKey.isActive" />
|
|
||||||
</div>
|
|
||||||
<div v-if="isExpired" class="px-2">
|
|
||||||
<p class="text-sm text-error">{{ $strings.LabelExpired }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full border-t border-b border-black-200 py-4 px-3 mt-4">
|
|
||||||
<p class="text-lg mb-2 font-semibold">{{ $strings.LabelApiKeyUser }}</p>
|
|
||||||
<p class="text-sm mb-2 text-gray-400">{{ $strings.LabelApiKeyUserDescription }}</p>
|
|
||||||
<ui-select-input v-model="newApiKey.userId" :disabled="isExpired && !apiKey.isActive" :items="userItems" :placeholder="$strings.LabelSelectUser" :label="$strings.LabelApiKeyUser" label-hidden />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex pt-4 px-2">
|
|
||||||
<div class="grow" />
|
|
||||||
<ui-btn color="bg-success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
value: Boolean,
|
|
||||||
apiKey: {
|
|
||||||
type: Object,
|
|
||||||
default: () => null
|
|
||||||
},
|
|
||||||
users: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
processing: false,
|
|
||||||
newApiKey: {},
|
|
||||||
isNew: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
show: {
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.value
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('input', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title() {
|
|
||||||
return this.isNew ? this.$strings.HeaderNewApiKey : this.$strings.HeaderUpdateApiKey
|
|
||||||
},
|
|
||||||
userItems() {
|
|
||||||
return this.users
|
|
||||||
.filter((u) => {
|
|
||||||
// Only show root user if the current user is root
|
|
||||||
return u.type !== 'root' || this.$store.getters['user/getIsRoot']
|
|
||||||
})
|
|
||||||
.map((u) => ({ text: u.username, value: u.id, subtext: u.type }))
|
|
||||||
},
|
|
||||||
isExpired() {
|
|
||||||
if (!this.apiKey || !this.apiKey.expiresAt) return false
|
|
||||||
|
|
||||||
return new Date(this.apiKey.expiresAt).getTime() < Date.now()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
submitForm() {
|
|
||||||
if (!this.newApiKey.name) {
|
|
||||||
this.$toast.error(this.$strings.ToastNameRequired)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.newApiKey.userId) {
|
|
||||||
this.$toast.error(this.$strings.ToastNewApiKeyUserError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isNew) {
|
|
||||||
this.submitCreateApiKey()
|
|
||||||
} else {
|
|
||||||
this.submitUpdateApiKey()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
submitUpdateApiKey() {
|
|
||||||
if (this.newApiKey.isActive === this.apiKey.isActive && this.newApiKey.userId === this.apiKey.userId) {
|
|
||||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
|
||||||
this.show = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = {
|
|
||||||
isActive: this.newApiKey.isActive,
|
|
||||||
userId: this.newApiKey.userId
|
|
||||||
}
|
|
||||||
|
|
||||||
this.processing = true
|
|
||||||
this.$axios
|
|
||||||
.$patch(`/api/api-keys/${this.apiKey.id}`, apiKey)
|
|
||||||
.then((data) => {
|
|
||||||
this.processing = false
|
|
||||||
if (data.error) {
|
|
||||||
this.$toast.error(`${this.$strings.ToastFailedToUpdate}: ${data.error}`)
|
|
||||||
} else {
|
|
||||||
this.show = false
|
|
||||||
this.$emit('updated', data.apiKey)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.processing = false
|
|
||||||
console.error('Failed to update apiKey', error)
|
|
||||||
var errMsg = error.response ? error.response.data || '' : ''
|
|
||||||
this.$toast.error(errMsg || this.$strings.ToastFailedToUpdate)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
submitCreateApiKey() {
|
|
||||||
const apiKey = { ...this.newApiKey }
|
|
||||||
|
|
||||||
if (this.newApiKey.expiresIn) {
|
|
||||||
apiKey.expiresIn = parseInt(this.newApiKey.expiresIn)
|
|
||||||
} else {
|
|
||||||
delete apiKey.expiresIn
|
|
||||||
}
|
|
||||||
|
|
||||||
this.processing = true
|
|
||||||
this.$axios
|
|
||||||
.$post('/api/api-keys', apiKey)
|
|
||||||
.then((data) => {
|
|
||||||
this.processing = false
|
|
||||||
if (data.error) {
|
|
||||||
this.$toast.error(this.$strings.ToastFailedToCreate + ': ' + data.error)
|
|
||||||
} else {
|
|
||||||
this.show = false
|
|
||||||
this.$emit('created', data.apiKey)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.processing = false
|
|
||||||
console.error('Failed to create apiKey', error)
|
|
||||||
var errMsg = error.response ? error.response.data || '' : ''
|
|
||||||
this.$toast.error(errMsg || this.$strings.ToastFailedToCreate)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
init() {
|
|
||||||
this.isNew = !this.apiKey
|
|
||||||
|
|
||||||
if (this.apiKey) {
|
|
||||||
this.newApiKey = {
|
|
||||||
name: this.apiKey.name,
|
|
||||||
isActive: this.apiKey.isActive,
|
|
||||||
userId: this.apiKey.userId
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.newApiKey = {
|
|
||||||
name: null,
|
|
||||||
expiresIn: null,
|
|
||||||
isActive: true,
|
|
||||||
userId: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
@ -4,10 +4,10 @@
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-base text-gray-200 truncate">{{ metadata.filename }}</p>
|
<p class="text-base text-gray-200 truncate">{{ metadata.filename }}</p>
|
||||||
<ui-btn v-if="ffprobeData" small class="ml-2" @click="ffprobeData = null">{{ $strings.ButtonReset }}</ui-btn>
|
<ui-btn v-if="ffprobeData" small class="ml-2" @click="ffprobeData = null">{{ $strings.ButtonReset }}</ui-btn>
|
||||||
<ui-btn v-else-if="userIsAdminOrUp" small :loading="probingFile" class="ml-2" @click="getFFProbeData">{{ $strings.ButtonProbeAudioFile }}</ui-btn>
|
<ui-btn v-else-if="userIsAdminOrUp" small :loading="probingFile" class="ml-2" @click="getFFProbeData">Probe Audio File</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full h-px bg-white/10 my-4" />
|
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||||
|
|
||||||
<template v-if="!ffprobeData">
|
<template v-if="!ffprobeData">
|
||||||
<ui-text-input-with-label :value="metadata.path" readonly :label="$strings.LabelPath" class="mb-4 text-sm" />
|
<ui-text-input-with-label :value="metadata.path" readonly :label="$strings.LabelPath" class="mb-4 text-sm" />
|
||||||
|
|
@ -75,7 +75,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full h-px bg-white/10 my-4" />
|
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||||
|
|
||||||
<p class="font-bold mb-2">{{ $strings.LabelMetaTags }}</p>
|
<p class="font-bold mb-2">{{ $strings.LabelMetaTags }}</p>
|
||||||
|
|
||||||
|
|
@ -90,8 +90,8 @@
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<ui-textarea-with-label :value="prettyFfprobeData" readonly :rows="30" class="text-xs" />
|
<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">
|
<button class="absolute top-4 right-4" :class="copiedToClipboard ? 'text-success' : 'text-white/50 hover:text-white/80'" @click.stop="copyFfprobeData">
|
||||||
<span class="material-symbols">{{ hasCopied ? 'done' : 'content_copy' }}</span>
|
<span class="material-symbols">{{ copiedToClipboard ? 'check' : 'content_copy' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -113,13 +113,14 @@ export default {
|
||||||
return {
|
return {
|
||||||
probingFile: false,
|
probingFile: false,
|
||||||
ffprobeData: null,
|
ffprobeData: null,
|
||||||
hasCopied: null
|
copiedToClipboard: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
show(newVal) {
|
show(newVal) {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
this.ffprobeData = null
|
this.ffprobeData = null
|
||||||
|
this.copiedToClipboard = false
|
||||||
this.probingFile = false
|
this.probingFile = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -158,19 +159,14 @@ export default {
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to get ffprobe data', error)
|
console.error('Failed to get ffprobe data', error)
|
||||||
this.$toast.error(this.$strings.ToastFailedToLoadData)
|
this.$toast.error('FFProbe failed')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.probingFile = false
|
this.probingFile = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
copyToClipboard() {
|
async copyFfprobeData() {
|
||||||
clearTimeout(this.hasCopied)
|
this.copiedToClipboard = await this.$copyToClipboard(this.prettyFfprobeData)
|
||||||
this.$copyToClipboard(this.prettyFfprobeData).then((success) => {
|
|
||||||
this.hasCopied = setTimeout(() => {
|
|
||||||
this.hasCopied = null
|
|
||||||
}, 2000)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
@ -32,11 +32,11 @@
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 pt-4 text-white/80 border-t border-white/5">
|
<div class="mt-4 pt-4 text-white text-opacity-80 border-t border-white border-opacity-5">
|
||||||
<div class="flex items-center px-4">
|
<div class="flex items-center px-4">
|
||||||
<ui-btn type="button" @click="show = false">{{ $strings.ButtonCancel }}</ui-btn>
|
<ui-btn type="button" @click="show = false">{{ $strings.ButtonCancel }}</ui-btn>
|
||||||
<div class="grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn color="bg-success" @click="doBatchQuickMatch">{{ $strings.ButtonSubmit }}</ui-btn>
|
<ui-btn color="success" @click="doBatchQuickMatch">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -54,7 +54,8 @@ export default {
|
||||||
options: {
|
options: {
|
||||||
provider: undefined,
|
provider: undefined,
|
||||||
overrideDetails: true,
|
overrideDetails: true,
|
||||||
overrideCover: true
|
overrideCover: true,
|
||||||
|
overrideDefaults: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -88,7 +89,7 @@ export default {
|
||||||
},
|
},
|
||||||
providers() {
|
providers() {
|
||||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||||
return this.$store.state.scanners.bookProviders
|
return this.$store.state.scanners.providers
|
||||||
},
|
},
|
||||||
libraryProvider() {
|
libraryProvider() {
|
||||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||||
|
|
@ -96,13 +97,10 @@ export default {
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
init() {
|
init() {
|
||||||
// Fetch providers when modal is shown
|
|
||||||
this.$store.dispatch('scanners/fetchProviders')
|
|
||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -118,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(() => {
|
||||||
|
|
@ -130,7 +128,8 @@ export default {
|
||||||
this.show = false
|
this.show = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/10 text-white/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="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="bg-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-symbols 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,13 +72,13 @@ 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.getters['getServerSetting']('dateFormat')
|
return this.$store.state.serverSettings.dateFormat
|
||||||
},
|
},
|
||||||
timeFormat() {
|
timeFormat() {
|
||||||
return this.$store.getters['getServerSetting']('timeFormat')
|
return this.$store.state.serverSettings.timeFormat
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
{{ 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) / _playbackRate) }}</span>
|
||||||
<span class="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 / _playbackRate) }}</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" />
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,12 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white/20" style="max-height: 75%" @click.stop>
|
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||||
<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="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success/10' : ''" role="option" @click="clickedOption(item.value)">
|
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
|
||||||
<div class="relative flex items-center px-3">
|
<div class="relative flex items-center px-3">
|
||||||
<p class="font-normal block truncate text-base text-white/80">{{ item.text }}</p>
|
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,19 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" role="dialog" aria-modal="true" class="hidden absolute top-0 left-0 w-full h-full bg-black/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-symbols 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="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" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="error" class="text-error text-sm mt-2 p-1">{{ error }}</div>
|
|
||||||
<div class="flex justify-end mt-2 p-1">
|
<div class="flex justify-end mt-2 p-1">
|
||||||
<ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
<ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -35,17 +34,12 @@ export default {
|
||||||
existingSeriesNames: {
|
existingSeriesNames: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
|
||||||
originalSeriesSequence: {
|
|
||||||
type: String,
|
|
||||||
default: null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
el: null,
|
el: null,
|
||||||
content: null,
|
content: null
|
||||||
error: null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
@ -72,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
|
||||||
|
|
@ -91,17 +80,10 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
submitSeriesForm() {
|
submitSeriesForm() {
|
||||||
this.error = null
|
|
||||||
|
|
||||||
if (this.$refs.newSeriesSelect) {
|
if (this.$refs.newSeriesSelect) {
|
||||||
this.$refs.newSeriesSelect.blur()
|
this.$refs.newSeriesSelect.blur()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.selectedSeries.sequence !== this.originalSeriesSequence && this.selectedSeries.sequence.includes(' ')) {
|
|
||||||
this.error = this.$strings.MessageSeriesSequenceCannotContainSpaces
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$emit('submit')
|
this.$emit('submit')
|
||||||
},
|
},
|
||||||
clickClose() {
|
clickClose() {
|
||||||
|
|
@ -113,7 +95,6 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setShow() {
|
setShow() {
|
||||||
this.error = null
|
|
||||||
if (!this.el || !this.content) {
|
if (!this.el || !this.content) {
|
||||||
this.init()
|
this.init()
|
||||||
}
|
}
|
||||||
|
|
@ -153,4 +134,4 @@ export default {
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
<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">{{ $getString('LabelByAuthor', [_session.displayAuthor]) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full h-px bg-white/10 my-4" />
|
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||||
|
|
||||||
<div class="flex flex-wrap mb-4">
|
<div class="flex flex-wrap mb-4">
|
||||||
<div class="w-full md:w-2/3">
|
<div class="w-full md:w-2/3">
|
||||||
|
|
@ -81,7 +81,7 @@
|
||||||
</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 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 v-if="!isMediaItemShareSession" class="mb-1 text-xs">{{ username }}</p>
|
<p v-if="!isMediaItemShareSession" class="mb-1 text-xs">{{ _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>
|
||||||
|
|
@ -99,8 +99,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ui-btn v-if="!isOpenSession && !isMediaItemShareSession" small color="bg-error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
|
<ui-btn v-if="!isOpenSession && !isMediaItemShareSession" small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
|
||||||
<ui-btn v-else-if="!isMediaItemShareSession" small color="bg-error" @click.stop="closeSessionClick">{{ $strings.ButtonCloseSession }}</ui-btn>
|
<ui-btn v-else-if="!isMediaItemShareSession" small color="error" @click.stop="closeSessionClick">Close Open Session</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
|
|
@ -132,9 +132,6 @@ export default {
|
||||||
_session() {
|
_session() {
|
||||||
return this.session || {}
|
return this.session || {}
|
||||||
},
|
},
|
||||||
username() {
|
|
||||||
return this._session.user?.username || this._session.userId || ''
|
|
||||||
},
|
|
||||||
deviceInfo() {
|
deviceInfo() {
|
||||||
return this._session.deviceInfo || {}
|
return this._session.deviceInfo || {}
|
||||||
},
|
},
|
||||||
|
|
@ -162,10 +159,10 @@ export default {
|
||||||
return 'Unknown'
|
return 'Unknown'
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.$store.getters['getServerSetting']('dateFormat')
|
return this.$store.state.serverSettings.dateFormat
|
||||||
},
|
},
|
||||||
timeFormat() {
|
timeFormat() {
|
||||||
return this.$store.getters['getServerSetting']('timeFormat')
|
return this.$store.state.serverSettings.timeFormat
|
||||||
},
|
},
|
||||||
isOpenSession() {
|
isOpenSession() {
|
||||||
return !!this._session.open
|
return !!this._session.open
|
||||||
|
|
@ -209,13 +206,14 @@ export default {
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/session/${this._session.id}/close`)
|
.$post(`/api/session/${this._session.id}/close`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
this.$toast.success('Session closed')
|
||||||
this.show = false
|
this.show = false
|
||||||
this.$emit('closedSession')
|
this.$emit('closedSession')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to close session', error)
|
console.error('Failed to close session', error)
|
||||||
const errMsg = error.response?.data || ''
|
const errMsg = error.response?.data || ''
|
||||||
this.$toast.error(errMsg || this.$strings.ToastSessionCloseFailed)
|
this.$toast.error(errMsg || 'Failed to close open session')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
<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-primary/${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-linear-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||||
|
|
||||||
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
|
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
|
||||||
<span class="material-symbols text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
<span class="material-symbols text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
||||||
</button>
|
</button>
|
||||||
<slot name="outer" />
|
<slot name="outer" />
|
||||||
<div ref="content" tabindex="0" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white outline-hidden" :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" aria-modal="true" :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/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 />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -23,7 +23,7 @@ export default {
|
||||||
processing: Boolean,
|
processing: Boolean,
|
||||||
persistent: {
|
persistent: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: true
|
||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
type: [String, Number],
|
type: [String, Number],
|
||||||
|
|
@ -99,7 +99,7 @@ export default {
|
||||||
this.preventClickoutside = false
|
this.preventClickoutside = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.processing || this.persistent) return
|
if (this.processing && this.persistent) return
|
||||||
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
|
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
|
|
@ -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)'
|
||||||
|
|
@ -163,4 +160,4 @@ export default {
|
||||||
this.$eventBus.$off('showing-prompt', this.showingPrompt)
|
this.$eventBus.$off('showing-prompt', this.showingPrompt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -11,12 +11,9 @@
|
||||||
<div class="flex items-center mb-4">
|
<div class="flex items-center mb-4">
|
||||||
<ui-select-input v-model="jumpForwardAmount" :label="$strings.LabelJumpForwardAmount" menuMaxHeight="250px" :items="jumpValues" @input="setJumpForwardAmount" />
|
<ui-select-input v-model="jumpForwardAmount" :label="$strings.LabelJumpForwardAmount" menuMaxHeight="250px" :items="jumpValues" @input="setJumpForwardAmount" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center mb-4">
|
<div class="flex items-center">
|
||||||
<ui-select-input v-model="jumpBackwardAmount" :label="$strings.LabelJumpBackwardAmount" menuMaxHeight="250px" :items="jumpValues" @input="setJumpBackwardAmount" />
|
<ui-select-input v-model="jumpBackwardAmount" :label="$strings.LabelJumpBackwardAmount" menuMaxHeight="250px" :items="jumpValues" @input="setJumpBackwardAmount" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -38,9 +35,7 @@ export default {
|
||||||
{ text: this.$getString('LabelTimeDurationXMinutes', ['5']), value: 300 }
|
{ text: this.$getString('LabelTimeDurationXMinutes', ['5']), value: 300 }
|
||||||
],
|
],
|
||||||
jumpForwardAmount: 10,
|
jumpForwardAmount: 10,
|
||||||
jumpBackwardAmount: 10,
|
jumpBackwardAmount: 10
|
||||||
playbackRateIncrementDecrementValues: [0.1, 0.05],
|
|
||||||
playbackRateIncrementDecrement: 0.1
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -64,24 +59,12 @@ export default {
|
||||||
setJumpBackwardAmount(val) {
|
setJumpBackwardAmount(val) {
|
||||||
this.jumpBackwardAmount = val
|
this.jumpBackwardAmount = val
|
||||||
this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val })
|
this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val })
|
||||||
},
|
|
||||||
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() {
|
mounted() {
|
||||||
this.settingsUpdated()
|
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
|
||||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
|
||||||
},
|
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
|
||||||
beforeDestroy() {
|
|
||||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -16,21 +16,20 @@
|
||||||
<template v-if="currentShare">
|
<template v-if="currentShare">
|
||||||
<div class="w-full py-2">
|
<div class="w-full py-2">
|
||||||
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelShareURL }}</label>
|
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelShareURL }}</label>
|
||||||
<ui-text-input v-model="currentShareUrl" show-copy readonly />
|
<ui-text-input v-model="currentShareUrl" show-copy readonly class="text-base h-10" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full py-2 px-1">
|
<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" class="text-base">{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}</p>
|
||||||
<p v-if="currentShare.expiresAt">{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}</p>
|
|
||||||
<p v-else>{{ $strings.LabelPermanent }}</p>
|
<p v-else>{{ $strings.LabelPermanent }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<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="flex flex-col sm:flex-row items-center justify-between space-y-4 sm:space-y-0 sm:space-x-4 mb-4">
|
||||||
<div class="w-full sm:w-48">
|
<div class="w-full sm:w-48">
|
||||||
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelSlug }}</label>
|
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelSlug }}</label>
|
||||||
<ui-text-input v-model="newShareSlug" class="text-base h-10" />
|
<ui-text-input v-model="newShareSlug" class="text-base h-10" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grow" />
|
<div class="flex-grow" />
|
||||||
<div class="w-full sm:w-80">
|
<div class="w-full sm:w-80">
|
||||||
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelDuration }}</label>
|
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelDuration }}</label>
|
||||||
<div class="inline-flex items-center space-x-2">
|
<div class="inline-flex items-center space-x-2">
|
||||||
|
|
@ -47,22 +46,13 @@
|
||||||
</div>
|
</div>
|
||||||
</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('MessageShareURLWillBe', [demoShareUrl])" />
|
||||||
<p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareExpirationWillBe', [expirationDateString])" />
|
<p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareExpirationWillBe', [expirationDateString])" />
|
||||||
</template>
|
</template>
|
||||||
<div class="flex items-center pt-6">
|
<div class="flex items-center pt-6">
|
||||||
<div class="grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn v-if="currentShare" color="bg-error" small @click="deleteShare">{{ $strings.ButtonDelete }}</ui-btn>
|
<ui-btn v-if="currentShare" color="error" small @click="deleteShare">{{ $strings.ButtonDelete }}</ui-btn>
|
||||||
<ui-btn v-if="!currentShare" color="bg-success" small @click="openShare">{{ $strings.ButtonShare }}</ui-btn>
|
<ui-btn v-if="!currentShare" color="success" small @click="openShare">{{ $strings.ButtonShare }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
|
|
@ -91,8 +81,7 @@ export default {
|
||||||
text: this.$strings.LabelDays,
|
text: this.$strings.LabelDays,
|
||||||
value: 'days'
|
value: 'days'
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
isDownloadable: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
@ -123,11 +112,11 @@ export default {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
demoShareUrl() {
|
demoShareUrl() {
|
||||||
return `${window.origin}${this.$config.routerBasePath}/share/${this.newShareSlug}`
|
return `${window.origin}/share/${this.newShareSlug}`
|
||||||
},
|
},
|
||||||
currentShareUrl() {
|
currentShareUrl() {
|
||||||
if (!this.currentShare) return ''
|
if (!this.currentShare) return ''
|
||||||
return `${window.origin}${this.$config.routerBasePath}/share/${this.currentShare.slug}`
|
return `${window.origin}/share/${this.currentShare.slug}`
|
||||||
},
|
},
|
||||||
currentShareTimeRemaining() {
|
currentShareTimeRemaining() {
|
||||||
if (!this.currentShare) return 'Error'
|
if (!this.currentShare) return 'Error'
|
||||||
|
|
@ -144,7 +133,7 @@ export default {
|
||||||
expirationDateString() {
|
expirationDateString() {
|
||||||
if (!this.expireDurationSeconds) return this.$strings.LabelPermanent
|
if (!this.expireDurationSeconds) return this.$strings.LabelPermanent
|
||||||
const dateMs = Date.now() + this.expireDurationSeconds * 1000
|
const dateMs = Date.now() + this.expireDurationSeconds * 1000
|
||||||
return this.$formatDatetime(dateMs, this.$store.getters['getServerSetting']('dateFormat'), this.$store.getters['getServerSetting']('timeFormat'))
|
return this.$formatDatetime(dateMs, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
@ -176,15 +165,14 @@ export default {
|
||||||
},
|
},
|
||||||
openShare() {
|
openShare() {
|
||||||
if (!this.newShareSlug) {
|
if (!this.newShareSlug) {
|
||||||
this.$toast.error(this.$strings.ToastSlugRequired)
|
this.$toast.error('Slug is required')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
slug: this.newShareSlug,
|
slug: this.newShareSlug,
|
||||||
mediaItemType: 'book',
|
mediaItemType: 'book',
|
||||||
mediaItemId: this.libraryItem.media.id,
|
mediaItemId: this.libraryItem.media.id,
|
||||||
expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0,
|
expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0
|
||||||
isDownloadable: this.isDownloadable
|
|
||||||
}
|
}
|
||||||
this.processing = true
|
this.processing = true
|
||||||
this.$axios
|
this.$axios
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
</template>
|
</template>
|
||||||
<form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime">
|
<form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime">
|
||||||
<ui-text-input v-model="customTime" type="number" step="any" min="0.1" :placeholder="$strings.LabelTimeInMinutes" class="w-48" />
|
<ui-text-input v-model="customTime" type="number" step="any" min="0.1" :placeholder="$strings.LabelTimeInMinutes" class="w-48" />
|
||||||
<ui-btn color="bg-success" type="submit" :padding-x="0" class="h-9 w-18 flex items-center justify-center ml-1">{{ $strings.ButtonSubmit }}</ui-btn>
|
<ui-btn color="success" type="submit" :padding-x="0" class="h-9 w-12 flex items-center justify-center ml-1">Set</ui-btn>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="timerSet" class="w-full p-4">
|
<div v-if="timerSet" class="w-full p-4">
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@
|
||||||
<div class="w-40 pr-2 pt-4" style="min-width: 160px">
|
<div class="w-40 pr-2 pt-4" style="min-width: 160px">
|
||||||
<ui-file-input ref="fileInput" @change="fileUploadSelected">Upload Cover</ui-file-input>
|
<ui-file-input ref="fileInput" @change="fileUploadSelected">Upload Cover</ui-file-input>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="submitForm" class="flex grow">
|
<form @submit.prevent="submitForm" class="flex flex-grow">
|
||||||
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
|
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
|
||||||
<ui-btn color="bg-success" type="submit" :padding-x="4" class="mt-5 ml-3 w-24">Update</ui-btn>
|
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-3 w-24">Update</ui-btn>
|
||||||
</form>
|
</form>
|
||||||
</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">
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-0 right-0 flex py-4 px-5">
|
<div class="absolute bottom-0 right-0 flex py-4 px-5">
|
||||||
<ui-btn :disabled="processingUpload" class="mx-2" @click="resetCoverPreview">Clear</ui-btn>
|
<ui-btn :disabled="processingUpload" class="mx-2" @click="resetCoverPreview">Clear</ui-btn>
|
||||||
<ui-btn :loading="processingUpload" color="bg-success" @click="submitCoverUpload">Upload</ui-btn>
|
<ui-btn :loading="processingUpload" color="success" @click="submitCoverUpload">Upload</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
@ -15,10 +15,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grow">
|
<div class="flex-grow">
|
||||||
<form @submit.prevent="submitUploadCover" class="flex grow mb-2 p-2">
|
<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-text-input v-model="imageUrl" :placeholder="$strings.LabelImageURLFromTheWeb" class="h-9 w-full" />
|
||||||
<ui-btn color="bg-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" :disabled="!imageUrl" class="ml-2 sm:ml-3 w-24 h-9">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form v-if="author" @submit.prevent="submitForm">
|
<form v-if="author" @submit.prevent="submitForm">
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
<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" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grow p-2">
|
<div class="flex-grow p-2">
|
||||||
<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>
|
||||||
|
|
@ -35,8 +35,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex pt-2 px-2">
|
<div class="flex pt-2 px-2">
|
||||||
<ui-btn v-if="userCanDelete" small color="bg-error" type="button" @click.stop="removeClick">{{ $strings.ButtonRemove }}</ui-btn>
|
<ui-btn v-if="userCanDelete" small color="error" type="button" @click.stop="removeClick">{{ $strings.ButtonRemove }}</ui-btn>
|
||||||
<div class="grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn type="button" class="mx-2" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
<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>
|
||||||
|
|
@ -116,12 +116,12 @@ export default {
|
||||||
this.$axios
|
this.$axios
|
||||||
.$delete(`/api/authors/${this.authorId}`)
|
.$delete(`/api/authors/${this.authorId}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success(this.$strings.ToastAuthorRemoveSuccess)
|
this.$toast.success('Author removed')
|
||||||
this.show = false
|
this.show = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to remove author', error)
|
console.error('Failed to remove author', error)
|
||||||
this.$toast.error(this.$strings.ToastRemoveFailed)
|
this.$toast.error('Failed to remove author')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
|
|
@ -141,14 +141,14 @@ export default {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (!Object.keys(updatePayload).length) {
|
if (!Object.keys(updatePayload).length) {
|
||||||
this.$toast.info(this.$strings.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,7 +158,7 @@ export default {
|
||||||
} else if (result.merged) {
|
} else if (result.merged) {
|
||||||
this.$toast.success(this.$strings.ToastAuthorUpdateMerged)
|
this.$toast.success(this.$strings.ToastAuthorUpdateMerged)
|
||||||
this.show = false
|
this.show = false
|
||||||
} else this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
} else this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||||
}
|
}
|
||||||
this.processing = false
|
this.processing = false
|
||||||
},
|
},
|
||||||
|
|
@ -174,7 +174,7 @@ export default {
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
this.$toast.error(this.$strings.ToastRemoveFailed)
|
this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed)
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
|
|
@ -182,7 +182,7 @@ export default {
|
||||||
},
|
},
|
||||||
submitUploadCover() {
|
submitUploadCover() {
|
||||||
if (!this.imageUrl?.startsWith('http:') && !this.imageUrl?.startsWith('https:')) {
|
if (!this.imageUrl?.startsWith('http:') && !this.imageUrl?.startsWith('https:')) {
|
||||||
this.$toast.error(this.$strings.ToastInvalidImageUrl)
|
this.$toast.error('Invalid image url')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,14 +194,14 @@ export default {
|
||||||
.$post(`/api/authors/${this.authorId}/image`, updatePayload)
|
.$post(`/api/authors/${this.authorId}/image`, updatePayload)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.imageUrl = ''
|
this.imageUrl = ''
|
||||||
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
|
this.$toast.success('Author image updated')
|
||||||
|
|
||||||
this.authorCopy.updatedAt = data.author.updatedAt
|
this.authorCopy.updatedAt = data.author.updatedAt
|
||||||
this.authorCopy.imagePath = data.author.imagePath
|
this.authorCopy.imagePath = data.author.imagePath
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
this.$toast.error(error.response.data || this.$strings.ToastRemoveFailed)
|
this.$toast.error(error.response.data || 'Failed to remove author image')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
|
|
@ -209,7 +209,7 @@ export default {
|
||||||
},
|
},
|
||||||
async searchAuthor() {
|
async searchAuthor() {
|
||||||
if (!this.authorCopy.name && !this.authorCopy.asin) {
|
if (!this.authorCopy.name && !this.authorCopy.asin) {
|
||||||
this.$toast.error(this.$strings.ToastNameRequired)
|
this.$toast.error('Must enter an author name')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
@ -228,19 +228,17 @@ export default {
|
||||||
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 {
|
} else this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
|
||||||
this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.authorCopy = {
|
this.authorCopy = {
|
||||||
...response.author
|
...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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
<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="grow overflow-hidden px-2">
|
<div class="flex-grow overflow-hidden px-2">
|
||||||
<template v-if="isEditing">
|
<template v-if="isEditing">
|
||||||
<form @submit.prevent="submitUpdate">
|
<form @submit.prevent="submitUpdate">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="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="bg-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-symbols 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/70 hover:text-white/95 cursor-pointer" @click.stop.prevent="cancelEditing">close</span>
|
<span class="material-symbols 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>
|
||||||
|
|
@ -35,8 +35,7 @@ export default {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
highlight: Boolean,
|
highlight: Boolean
|
||||||
playbackRate: Number
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -48,7 +47,7 @@ export default {
|
||||||
computed: {
|
computed: {
|
||||||
wrapperClass() {
|
wrapperClass() {
|
||||||
var classes = []
|
var classes = []
|
||||||
if (this.highlight) classes.push('bg-bg/60')
|
if (this.highlight) classes.push('bg-bg bg-opacity-60')
|
||||||
if (!this.isEditing) classes.push('cursor-pointer')
|
if (!this.isEditing) classes.push('cursor-pointer')
|
||||||
return classes.join(' ')
|
return classes.join(' ')
|
||||||
}
|
}
|
||||||
|
|
@ -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,7 +2,7 @@
|
||||||
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
|
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
<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">
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
</p>
|
</p>
|
||||||
<div class="custom-text" v-html="getChangelog(release)" />
|
<div class="custom-text" v-html="getChangelog(release)" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="release !== releasesToShow[releasesToShow.length - 1]" :key="`${release.name}-divider`" class="border-b border-black-300 my-8" />
|
<div v-if="release !== releasesToShow[releasesToShow.length - 1]" class="border-b border-black-300 my-8" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
|
|
@ -40,7 +40,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.$store.getters['getServerSetting']('dateFormat')
|
return this.$store.state.serverSettings.dateFormat
|
||||||
},
|
},
|
||||||
releasesToShow() {
|
releasesToShow() {
|
||||||
return this.versionData?.releasesToShow || []
|
return this.versionData?.releasesToShow || []
|
||||||
|
|
@ -62,8 +62,6 @@ 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
|
||||||
*/
|
*/
|
||||||
@reference "tailwindcss";
|
|
||||||
|
|
||||||
.custom-text ::v-deep > h2 {
|
.custom-text ::v-deep > h2 {
|
||||||
@apply text-lg font-bold;
|
@apply text-lg font-bold;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,27 +19,16 @@
|
||||||
</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/10" />
|
|
||||||
<form @submit.prevent="submitCreateCollection">
|
<form @submit.prevent="submitCreateCollection">
|
||||||
<div class="flex px-4 py-2 items-center text-center border-b border-white/10 text-white/80">
|
<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="grow px-2">
|
<div class="flex-grow px-2">
|
||||||
<ui-text-input v-model="newCollectionName" :placeholder="$strings.PlaceholderNewCollection" class="w-full" />
|
<ui-text-input v-model="newCollectionName" :placeholder="$strings.PlaceholderNewCollection" class="w-full" />
|
||||||
</div>
|
</div>
|
||||||
<ui-btn type="submit" color="bg-success" :padding-x="4" class="h-10">{{ $strings.ButtonCreate }}</ui-btn>
|
<ui-btn type="submit" color="success" :padding-x="4" class="h-10">{{ $strings.ButtonCreate }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -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,15 +1,15 @@
|
||||||
<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" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grow overflow-hidden px-2">
|
<div class="flex-grow overflow-hidden px-2">
|
||||||
<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="bg-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-symbols text-2xl pt-px">add</span></ui-btn>
|
||||||
<ui-btn v-else color="bg-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-symbols text-2xl pt-px">remove</span></ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -55,4 +55,4 @@ export default {
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -12,32 +12,32 @@
|
||||||
<div class="w-full flex justify-center mb-2 md:w-auto md:mb-0 md:block">
|
<div class="w-full flex justify-center mb-2 md:w-auto md:mb-0 md:block">
|
||||||
<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" />
|
||||||
</div>
|
</div>
|
||||||
<div class="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" />
|
||||||
|
|
||||||
<ui-textarea-with-label v-model="newCollectionDescription" :label="$strings.LabelDescription" />
|
<ui-textarea-with-label v-model="newCollectionDescription" :label="$strings.LabelDescription" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-0 left-0 right-0 w-full py-4 px-4 flex">
|
<div class="absolute bottom-0 left-0 right-0 w-full py-2 px-4 flex">
|
||||||
<ui-btn v-if="userCanDelete" small color="bg-error" type="button" @click.stop="removeClick">{{ $strings.ButtonRemove }}</ui-btn>
|
<ui-btn v-if="userCanDelete" small color="error" type="button" @click.stop="removeClick">{{ $strings.ButtonRemove }}</ui-btn>
|
||||||
<div class="grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn color="bg-success" type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
<ui-btn color="success" type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
<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/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-symbols 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>
|
||||||
<div class="flex mb-4">
|
<div class="flex mb-4">
|
||||||
<ui-btn small class="mr-2">Upload</ui-btn>
|
<ui-btn small class="mr-2">Upload</ui-btn>
|
||||||
<ui-text-input v-model="newCoverImage" class="grow" placeholder="Collection Cover Image" />
|
<ui-text-input v-model="newCoverImage" class="flex-grow" placeholder="Collection Cover Image" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<ui-btn color="bg-success">Upload</ui-btn>
|
<ui-btn color="success">Upload</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -94,39 +94,28 @@ export default {
|
||||||
this.newCollectionDescription = this.collection.description || ''
|
this.newCollectionDescription = this.collection.description || ''
|
||||||
},
|
},
|
||||||
removeClick() {
|
removeClick() {
|
||||||
const payload = {
|
if (confirm(this.$getString('MessageConfirmRemoveCollection', [this.collectionName]))) {
|
||||||
message: this.$getString('MessageConfirmRemoveCollection', [this.collectionName]),
|
this.processing = true
|
||||||
callback: (confirmed) => {
|
this.$axios
|
||||||
if (confirmed) {
|
.$delete(`/api/collections/${this.collection.id}`)
|
||||||
this.deleteCollection()
|
.then(() => {
|
||||||
}
|
this.processing = false
|
||||||
},
|
this.show = false
|
||||||
type: 'yesNo'
|
this.$toast.success(this.$strings.ToastCollectionRemoveSuccess)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to remove collection', error)
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.error(this.$strings.ToastCollectionRemoveFailed)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
|
||||||
deleteCollection() {
|
|
||||||
this.processing = true
|
|
||||||
this.$axios
|
|
||||||
.$delete(`/api/collections/${this.collection.id}`)
|
|
||||||
.then(() => {
|
|
||||||
this.show = false
|
|
||||||
this.$toast.success(this.$strings.ToastCollectionRemoveSuccess)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to remove collection', error)
|
|
||||||
this.$toast.error(this.$strings.ToastRemoveFailed)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
submitForm() {
|
submitForm() {
|
||||||
if (this.newCollectionName === this.collectionName && this.newCollectionDescription === this.collection.description) {
|
if (this.newCollectionName === this.collectionName && this.newCollectionDescription === this.collection.description) {
|
||||||
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
|
||||||
|
|
@ -146,7 +135,7 @@ export default {
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to update collection', error)
|
console.error('Failed to update collection', error)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
this.$toast.error(this.$strings.ToastCollectionUpdateFailed)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center pt-4">
|
<div class="flex items-center pt-4">
|
||||||
<div class="grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn color="bg-success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -125,12 +125,12 @@ export default {
|
||||||
this.$refs.ereaderEmailInput.blur()
|
this.$refs.ereaderEmailInput.blur()
|
||||||
|
|
||||||
if (!this.newDevice.name?.trim() || !this.newDevice.email?.trim()) {
|
if (!this.newDevice.name?.trim() || !this.newDevice.email?.trim()) {
|
||||||
this.$toast.error(this.$strings.ToastNameEmailRequired)
|
this.$toast.error('Name and email required')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.newDevice.availabilityOption === 'specificUsers' && !this.newDevice.users.length) {
|
if (this.newDevice.availabilityOption === 'specificUsers' && !this.newDevice.users.length) {
|
||||||
this.$toast.error(this.$strings.ToastSelectAtLeastOneUser)
|
this.$toast.error('Must select at least one user')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.newDevice.availabilityOption !== 'specificUsers') {
|
if (this.newDevice.availabilityOption !== 'specificUsers') {
|
||||||
|
|
@ -142,14 +142,14 @@ export default {
|
||||||
|
|
||||||
if (!this.ereaderDevice) {
|
if (!this.ereaderDevice) {
|
||||||
if (this.existingDevices.some((d) => d.name === this.newDevice.name)) {
|
if (this.existingDevices.some((d) => d.name === this.newDevice.name)) {
|
||||||
this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)
|
this.$toast.error('Ereader device with that name already exists')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.submitCreate()
|
this.submitCreate()
|
||||||
} else {
|
} else {
|
||||||
if (this.ereaderDevice.name !== this.newDevice.name && this.existingDevices.some((d) => d.name === this.newDevice.name)) {
|
if (this.ereaderDevice.name !== this.newDevice.name && this.existingDevices.some((d) => d.name === this.newDevice.name)) {
|
||||||
this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)
|
this.$toast.error('Ereader device with that name already exists')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,11 +174,12 @@ export default {
|
||||||
.$post(`/api/emails/ereader-devices`, payload)
|
.$post(`/api/emails/ereader-devices`, payload)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.$emit('update', data.ereaderDevices)
|
this.$emit('update', data.ereaderDevices)
|
||||||
|
this.$toast.success('Device updated')
|
||||||
this.show = false
|
this.show = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to update device', error)
|
console.error('Failed to update device', error)
|
||||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
this.$toast.error('Failed to update device')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
|
|
@ -200,11 +201,12 @@ export default {
|
||||||
.$post('/api/emails/ereader-devices', payload)
|
.$post('/api/emails/ereader-devices', payload)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.$emit('update', data.ereaderDevices || [])
|
this.$emit('update', data.ereaderDevices || [])
|
||||||
|
this.$toast.success('Device added')
|
||||||
this.show = false
|
this.show = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to add device', error)
|
console.error('Failed to add device', error)
|
||||||
this.$toast.error(this.$strings.ToastDeviceAddFailed)
|
this.$toast.error('Failed to add device')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
|
|
|
||||||
|
|
@ -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="grow" />
|
|
||||||
<ui-btn color="bg-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/50 hover:text-white/90 cursor-pointer pointer-events-auto" :aria-label="$strings.ButtonNext" @click.stop.prevent="goPrevBook" @mousedown.prevent>arrow_back_ios</button>
|
<div class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goPrevBook" @mousedown.prevent>arrow_back_ios</div>
|
||||||
</div>
|
</div>
|
||||||
<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/50 hover:text-white/90 cursor-pointer pointer-events-auto" :aria-label="$strings.ButtonPrevious" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</button>
|
<div class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
@ -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,42 +1,42 @@
|
||||||
<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-col sm:flex-row mb-4">
|
||||||
<div class="relative self-center md:self-start">
|
<div class="relative self-center">
|
||||||
<covers-preview-cover :src="coverUrl" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, libraryItemUpdatedAt, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
|
||||||
<!-- book cover overlay -->
|
<!-- book cover overlay -->
|
||||||
<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-linear-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-xs" @click="removeCover">
|
<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">
|
||||||
<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-symbols text-2xl">delete</span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="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-6 md:mt-0">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 md:min-w-32">
|
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 md:min-w-32">
|
||||||
<ui-file-input ref="fileInput" @change="fileUploadSelected">
|
<ui-file-input ref="fileInput" @change="fileUploadSelected">
|
||||||
<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-symbols text-2xl inline-block md:!hidden">upload</span>
|
||||||
</ui-file-input>
|
</ui-file-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form @submit.prevent="submitForm" class="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 v-model="imageUrl" :placeholder="$strings.LabelImageURLFromTheWeb" class="h-9 w-full" />
|
||||||
<ui-btn color="bg-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" :disabled="!imageUrl" class="ml-2 sm:ml-3 w-24 h-9">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-white/10">
|
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-white border-opacity-10">
|
||||||
<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="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 flex-wrap pb-2">
|
<div v-if="showLocalCovers" class="flex items-center justify-center pb-2">
|
||||||
<template v-for="localCoverFile in localCovers">
|
<template v-for="localCoverFile 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="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 class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
||||||
|
|
@ -50,22 +50,20 @@
|
||||||
</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 flex-wrap sm:flex-nowrap items-center justify-start -mx-1">
|
||||||
<div class="w-48 grow p-1">
|
<div class="w-48 flex-grow p-1">
|
||||||
<ui-dropdown v-model="provider" :items="providers" :disabled="searchInProgress" :label="$strings.LabelProvider" small />
|
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-72 grow p-1">
|
<div class="w-72 flex-grow p-1">
|
||||||
<ui-text-input-with-label v-model="searchTitle" :disabled="searchInProgress" :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 grow p-1">
|
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 flex-grow p-1">
|
||||||
<ui-text-input-with-label v-model="searchAuthor" :disabled="searchInProgress" :label="$strings.LabelAuthor" />
|
<ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
|
||||||
</div>
|
</div>
|
||||||
<ui-btn v-if="!searchInProgress" 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 md:min-w-24" :padding-x="4" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
|
||||||
<ui-btn v-else class="mt-5 ml-1 md:min-w-24" :padding-x="4" type="button" color="bg-error" @click.prevent="cancelCurrentSearch">{{ $strings.ButtonCancel }}</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 sm:max-h-80 sm:overflow-y-scroll mt-2 max-w-full">
|
||||||
<p v-if="searchInProgress && !coversFound.length" class="text-gray-300 py-4">{{ $strings.MessageLoading }}</p>
|
<p v-if="!coversFound.length">{{ $strings.MessageNoCoversFound }}</p>
|
||||||
<p v-else-if="!searchInProgress && !coversFound.length" class="text-gray-300 py-4">{{ $strings.MessageNoCoversFound }}</p>
|
|
||||||
<template v-for="cover in coversFound">
|
<template v-for="cover in coversFound">
|
||||||
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === coverPath ? 'border-yellow-300' : ''" @click="updateCover(cover)">
|
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === coverPath ? 'border-yellow-300' : ''" @click="updateCover(cover)">
|
||||||
<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" />
|
||||||
|
|
@ -81,7 +79,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-0 right-0 flex py-4 px-5">
|
<div class="absolute bottom-0 right-0 flex py-4 px-5">
|
||||||
<ui-btn :disabled="processingUpload" class="mx-2" @click="resetCoverPreview">{{ $strings.ButtonReset }}</ui-btn>
|
<ui-btn :disabled="processingUpload" class="mx-2" @click="resetCoverPreview">{{ $strings.ButtonReset }}</ui-btn>
|
||||||
<ui-btn :loading="processingUpload" color="bg-success" @click="submitCoverUpload">{{ $strings.ButtonUpload }}</ui-btn>
|
<ui-btn :loading="processingUpload" color="success" @click="submitCoverUpload">{{ $strings.ButtonUpload }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -107,10 +105,7 @@ export default {
|
||||||
showLocalCovers: false,
|
showLocalCovers: false,
|
||||||
previewUpload: null,
|
previewUpload: null,
|
||||||
selectedFile: null,
|
selectedFile: null,
|
||||||
provider: 'google',
|
provider: 'google'
|
||||||
currentSearchRequestId: null,
|
|
||||||
searchInProgress: false,
|
|
||||||
socketListenersActive: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
@ -133,8 +128,8 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
providers() {
|
providers() {
|
||||||
if (this.isPodcast) return this.$store.state.scanners.podcastCoverProviders
|
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||||
return this.$store.state.scanners.bookCoverProviders
|
return [{ text: 'All', value: 'all' }, ...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders]
|
||||||
},
|
},
|
||||||
searchTitleLabel() {
|
searchTitleLabel() {
|
||||||
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
||||||
|
|
@ -162,12 +157,6 @@ export default {
|
||||||
coverPath() {
|
coverPath() {
|
||||||
return this.media.coverPath
|
return this.media.coverPath
|
||||||
},
|
},
|
||||||
coverUrl() {
|
|
||||||
if (!this.coverPath) {
|
|
||||||
return this.$store.getters['globals/getPlaceholderCoverSrc']
|
|
||||||
}
|
|
||||||
return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.libraryItemId, this.libraryItemUpdatedAt, true)
|
|
||||||
},
|
|
||||||
mediaMetadata() {
|
mediaMetadata() {
|
||||||
return this.media.metadata || {}
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
|
|
@ -191,9 +180,6 @@ export default {
|
||||||
_file.localPath = `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${file.ino}?token=${this.userToken}`
|
_file.localPath = `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${file.ino}?token=${this.userToken}`
|
||||||
return _file
|
return _file
|
||||||
})
|
})
|
||||||
},
|
|
||||||
socket() {
|
|
||||||
return this.$root.socket
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
@ -208,6 +194,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
|
||||||
|
|
@ -217,7 +204,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
|
||||||
})
|
})
|
||||||
|
|
@ -243,19 +230,7 @@ export default {
|
||||||
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 {
|
else this.provider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'
|
||||||
// Migrate from 'all' to 'best' (only once)
|
|
||||||
const migrationKey = 'book-cover-provider-migrated'
|
|
||||||
const currentProvider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'
|
|
||||||
|
|
||||||
if (!localStorage.getItem(migrationKey) && currentProvider === 'all') {
|
|
||||||
localStorage.setItem('book-cover-provider', 'best')
|
|
||||||
localStorage.setItem(migrationKey, 'true')
|
|
||||||
this.provider = 'best'
|
|
||||||
} else {
|
|
||||||
this.provider = currentProvider
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
removeCover() {
|
removeCover() {
|
||||||
if (!this.coverPath) {
|
if (!this.coverPath) {
|
||||||
|
|
@ -280,7 +255,7 @@ export default {
|
||||||
},
|
},
|
||||||
async updateCover(cover) {
|
async updateCover(cover) {
|
||||||
if (!cover.startsWith('http:') && !cover.startsWith('https:')) {
|
if (!cover.startsWith('http:') && !cover.startsWith('https:')) {
|
||||||
this.$toast.error(this.$strings.ToastInvalidUrl)
|
this.$toast.error('Invalid URL')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,10 +264,11 @@ export default {
|
||||||
.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover })
|
.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.imageUrl = ''
|
this.imageUrl = ''
|
||||||
|
this.$toast.success('Update Successful')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to update cover', error)
|
console.error('Failed to update cover', error)
|
||||||
this.$toast.error(error.response?.data || this.$strings.ToastCoverUpdateFailed)
|
this.$toast.error(error.response?.data || 'Failed to update cover')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.isProcessing = false
|
this.isProcessing = false
|
||||||
|
|
@ -311,143 +287,38 @@ export default {
|
||||||
console.error('PersistProvider', error)
|
console.error('PersistProvider', error)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
generateRequestId() {
|
|
||||||
return `cover-search-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
|
||||||
},
|
|
||||||
addSocketListeners() {
|
|
||||||
if (!this.socket || this.socketListenersActive) return
|
|
||||||
|
|
||||||
this.socket.on('cover_search_result', this.handleSearchResult)
|
|
||||||
this.socket.on('cover_search_complete', this.handleSearchComplete)
|
|
||||||
this.socket.on('cover_search_error', this.handleSearchError)
|
|
||||||
this.socket.on('cover_search_provider_error', this.handleProviderError)
|
|
||||||
this.socket.on('cover_search_cancelled', this.handleSearchCancelled)
|
|
||||||
this.socket.on('disconnect', this.handleSocketDisconnect)
|
|
||||||
this.socketListenersActive = true
|
|
||||||
},
|
|
||||||
removeSocketListeners() {
|
|
||||||
if (!this.socket || !this.socketListenersActive) return
|
|
||||||
|
|
||||||
this.socket.off('cover_search_result', this.handleSearchResult)
|
|
||||||
this.socket.off('cover_search_complete', this.handleSearchComplete)
|
|
||||||
this.socket.off('cover_search_error', this.handleSearchError)
|
|
||||||
this.socket.off('cover_search_provider_error', this.handleProviderError)
|
|
||||||
this.socket.off('cover_search_cancelled', this.handleSearchCancelled)
|
|
||||||
this.socket.off('disconnect', this.handleSocketDisconnect)
|
|
||||||
this.socketListenersActive = false
|
|
||||||
},
|
|
||||||
handleSearchResult(data) {
|
|
||||||
if (data.requestId !== this.currentSearchRequestId) return
|
|
||||||
|
|
||||||
// Add new covers to the list (avoiding duplicates)
|
|
||||||
const newCovers = data.covers.filter((cover) => !this.coversFound.includes(cover))
|
|
||||||
this.coversFound.push(...newCovers)
|
|
||||||
},
|
|
||||||
handleSearchComplete(data) {
|
|
||||||
if (data.requestId !== this.currentSearchRequestId) return
|
|
||||||
|
|
||||||
this.searchInProgress = false
|
|
||||||
this.currentSearchRequestId = null
|
|
||||||
},
|
|
||||||
handleSearchError(data) {
|
|
||||||
if (data.requestId !== this.currentSearchRequestId) return
|
|
||||||
|
|
||||||
console.error('[Cover Search] Search error:', data.error)
|
|
||||||
this.$toast.error(this.$strings.ToastCoverSearchFailed)
|
|
||||||
this.searchInProgress = false
|
|
||||||
this.currentSearchRequestId = null
|
|
||||||
},
|
|
||||||
handleProviderError(data) {
|
|
||||||
if (data.requestId !== this.currentSearchRequestId) return
|
|
||||||
|
|
||||||
console.warn(`[Cover Search] Provider ${data.provider} failed:`, data.error)
|
|
||||||
},
|
|
||||||
handleSearchCancelled(data) {
|
|
||||||
if (data.requestId !== this.currentSearchRequestId) return
|
|
||||||
|
|
||||||
this.searchInProgress = false
|
|
||||||
this.currentSearchRequestId = null
|
|
||||||
},
|
|
||||||
handleSocketDisconnect() {
|
|
||||||
// If we were in the middle of a search, cancel it (server can't send results anymore)
|
|
||||||
if (this.searchInProgress && this.currentSearchRequestId) {
|
|
||||||
this.searchInProgress = false
|
|
||||||
this.currentSearchRequestId = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
cancelCurrentSearch() {
|
|
||||||
if (!this.currentSearchRequestId || !this.socket?.connected) {
|
|
||||||
console.error('[Cover Search] Socket not connected')
|
|
||||||
this.$toast.error(this.$strings.ToastConnectionNotAvailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.socket.emit('cancel_cover_search', this.currentSearchRequestId)
|
|
||||||
this.currentSearchRequestId = null
|
|
||||||
this.searchInProgress = false
|
|
||||||
},
|
|
||||||
async submitSearchForm() {
|
async submitSearchForm() {
|
||||||
if (!this.socket?.connected) {
|
|
||||||
console.error('[Cover Search] Socket not connected')
|
|
||||||
this.$toast.error(this.$strings.ToastConnectionNotAvailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel any existing search
|
|
||||||
if (this.searchInProgress) {
|
|
||||||
this.cancelCurrentSearch()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store provider in local storage
|
// Store provider in local storage
|
||||||
this.persistProvider()
|
this.persistProvider()
|
||||||
|
|
||||||
// Setup socket listeners if not already done
|
this.isProcessing = true
|
||||||
this.addSocketListeners()
|
const searchQuery = this.getSearchQuery()
|
||||||
|
const results = await this.$axios
|
||||||
// Clear previous results
|
.$get(`/api/search/covers?${searchQuery}`)
|
||||||
this.coversFound = []
|
.then((res) => res.results)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
this.coversFound = results
|
||||||
|
this.isProcessing = false
|
||||||
this.hasSearched = true
|
this.hasSearched = true
|
||||||
this.searchInProgress = true
|
|
||||||
|
|
||||||
// Generate unique request ID
|
|
||||||
const requestId = this.generateRequestId()
|
|
||||||
this.currentSearchRequestId = requestId
|
|
||||||
|
|
||||||
// Emit search request via WebSocket
|
|
||||||
this.socket.emit('search_covers', {
|
|
||||||
requestId,
|
|
||||||
title: this.searchTitle,
|
|
||||||
author: this.searchAuthor || '',
|
|
||||||
provider: this.provider,
|
|
||||||
podcast: this.isPodcast
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
setCover(coverFile) {
|
setCover(coverFile) {
|
||||||
this.isProcessing = true
|
this.isProcessing = true
|
||||||
this.$axios
|
this.$axios
|
||||||
.$patch(`/api/items/${this.libraryItemId}/cover`, { cover: coverFile.metadata.path })
|
.$patch(`/api/items/${this.libraryItemId}/cover`, { cover: coverFile.metadata.path })
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Update Successful')
|
||||||
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to set local cover', error)
|
console.error('Failed to set local cover', error)
|
||||||
this.$toast.error(error.response?.data || this.$strings.ToastCoverUpdateFailed)
|
this.$toast.error(error.response?.data || 'Failed to set cover')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.isProcessing = false
|
this.isProcessing = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
// Setup socket listeners when component is mounted
|
|
||||||
this.addSocketListeners()
|
|
||||||
// Fetch providers if not already loaded
|
|
||||||
this.$store.dispatch('scanners/fetchProviders')
|
|
||||||
},
|
|
||||||
beforeDestroy() {
|
|
||||||
// Cancel any ongoing search when component is destroyed
|
|
||||||
if (this.searchInProgress) {
|
|
||||||
this.cancelCurrentSearch()
|
|
||||||
}
|
|
||||||
// Remove socket listeners
|
|
||||||
this.removeSocketListeners()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -5,15 +5,17 @@
|
||||||
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
|
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white/5'">
|
<div class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
|
||||||
<div class="flex items-center px-4">
|
<div class="flex items-center px-4">
|
||||||
<ui-tooltip :disabled="!!quickMatching" :text="$getString('MessageQuickMatchDescription', [libraryProvider])" direction="bottom" class="mr-2 md:mr-4">
|
<ui-tooltip :disabled="!!quickMatching" :text="$getString('MessageQuickMatchDescription', [libraryProvider])" direction="bottom" class="mr-2 md:mr-4">
|
||||||
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg-bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="isLibraryScanning" color="bg-bg" type="button" class="h-full" small @click.stop.prevent="rescan">{{ $strings.ButtonReScan }}</ui-btn>
|
<ui-tooltip :disabled="isLibraryScanning" text="Rescan library item including metadata" direction="bottom" class="mr-2 md:mr-4">
|
||||||
|
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="isLibraryScanning" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">{{ $strings.ButtonReScan }}</ui-btn>
|
||||||
|
</ui-tooltip>
|
||||||
|
|
||||||
<div class="grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<!-- desktop -->
|
<!-- desktop -->
|
||||||
<ui-btn @click="save" class="mx-2 hidden md:block">{{ $strings.ButtonSave }}</ui-btn>
|
<ui-btn @click="save" class="mx-2 hidden md:block">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
|
|
@ -90,7 +92,7 @@ export default {
|
||||||
|
|
||||||
var { title, author } = this.$refs.itemDetailsEdit.getTitleAndAuthorName()
|
var { title, author } = this.$refs.itemDetailsEdit.getTitleAndAuthorName()
|
||||||
if (!title) {
|
if (!title) {
|
||||||
this.$toast.error(this.$strings.ToastTitleRequired)
|
this.$toast.error('Must have a title for quick match')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.quickMatching = true
|
this.quickMatching = true
|
||||||
|
|
@ -106,9 +108,9 @@ export default {
|
||||||
if (res.warning) {
|
if (res.warning) {
|
||||||
this.$toast.warning(res.warning)
|
this.$toast.warning(res.warning)
|
||||||
} else if (res.updated) {
|
} else if (res.updated) {
|
||||||
this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)
|
this.$toast.success('Item details updated')
|
||||||
} else {
|
} else {
|
||||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
this.$toast.info('No updates were made')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|
@ -126,18 +128,18 @@ export default {
|
||||||
this.rescanning = false
|
this.rescanning = false
|
||||||
var result = data.result
|
var result = data.result
|
||||||
if (!result) {
|
if (!result) {
|
||||||
this.$toast.error(this.$getString('ToastRescanFailed', [this.title]))
|
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')
|
||||||
this.rescanning = false
|
this.rescanning = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -154,7 +156,7 @@ export default {
|
||||||
}
|
}
|
||||||
var updatedDetails = this.$refs.itemDetailsEdit.getDetails()
|
var updatedDetails = this.$refs.itemDetailsEdit.getDetails()
|
||||||
if (!updatedDetails.hasChanges) {
|
if (!updatedDetails.hasChanges) {
|
||||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
this.$toast.info('No changes were made')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return this.updateDetails(updatedDetails)
|
return this.updateDetails(updatedDetails)
|
||||||
|
|
@ -168,7 +170,7 @@ export default {
|
||||||
this.isProcessing = false
|
this.isProcessing = false
|
||||||
if (updateResult) {
|
if (updateResult) {
|
||||||
if (updateResult.updated) {
|
if (updateResult.updated) {
|
||||||
this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)
|
this.$toast.success('Item details updated')
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||||
|
|
@ -215,4 +217,4 @@ export default {
|
||||||
height: calc(100% - 80px);
|
height: calc(100% - 80px);
|
||||||
max-height: calc(100% - 80px);
|
max-height: calc(100% - 80px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<ui-text-input-with-label ref="maxEpisodesInput" v-model="maxEpisodesToDownload" :disabled="checkingNewEpisodes" type="number" :label="$strings.LabelLimit" class="w-16 mr-2" input-class="h-10">
|
<ui-text-input-with-label ref="maxEpisodesInput" v-model="maxEpisodesToDownload" :disabled="checkingNewEpisodes" type="number" :label="$strings.LabelLimit" class="w-16 mr-2" input-class="h-10">
|
||||||
<div class="flex -mb-0.5">
|
<div class="flex -mb-0.5">
|
||||||
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': checkingNewEpisodes }">{{ $strings.LabelLimit }}</p>
|
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': checkingNewEpisodes }">{{ $strings.LabelLimit }}</p>
|
||||||
<ui-tooltip direction="top" :text="$strings.LabelMaxEpisodesToDownload">
|
<ui-tooltip direction="top" text="Max # of episodes to download. Use 0 for unlimited.">
|
||||||
<span class="material-symbols text-base">info</span>
|
<span class="material-symbols text-base">info</span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -99,7 +99,7 @@ export default {
|
||||||
|
|
||||||
if (this.maxEpisodesToDownload < 0) {
|
if (this.maxEpisodesToDownload < 0) {
|
||||||
this.maxEpisodesToDownload = 3
|
this.maxEpisodesToDownload = 3
|
||||||
this.$toast.error(this.$strings.ToastInvalidMaxEpisodesToDownload)
|
this.$toast.error('Invalid max episodes to download')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,10 +113,6 @@ export default {
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
console.log('updateResult', updateResult)
|
console.log('updateResult', updateResult)
|
||||||
} else if (!lastEpisodeCheck) {
|
|
||||||
this.$toast.error(this.$strings.ToastDateTimeInvalidOrIncomplete)
|
|
||||||
this.checkingNewEpisodes = false
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$axios
|
this.$axios
|
||||||
|
|
@ -124,9 +120,9 @@ export default {
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.episodes && response.episodes.length) {
|
if (response.episodes && response.episodes.length) {
|
||||||
console.log('New episodes', response.episodes.length)
|
console.log('New episodes', response.episodes.length)
|
||||||
this.$toast.success(this.$getString('ToastNewEpisodesFound', [response.episodes.length]))
|
this.$toast.success(`${response.episodes.length} new episodes found!`)
|
||||||
} else {
|
} else {
|
||||||
this.$toast.info(this.$strings.ToastNoNewEpisodesFound)
|
this.$toast.info('No new episodes found')
|
||||||
}
|
}
|
||||||
this.checkingNewEpisodes = false
|
this.checkingNewEpisodes = false
|
||||||
})
|
})
|
||||||
|
|
@ -145,4 +141,4 @@ export default {
|
||||||
this.setLastEpisodeCheckInput()
|
this.setLastEpisodeCheckInput()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -29,6 +29,9 @@ export default {
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem.media || {}
|
return this.libraryItem.media || {}
|
||||||
},
|
},
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
<div id="match-wrapper" class="w-full h-full overflow-hidden px-2 md:px-4 py-4 md:py-6 relative">
|
<div id="match-wrapper" class="w-full h-full overflow-hidden px-2 md:px-4 py-4 md:py-6 relative">
|
||||||
<form @submit.prevent="submitSearch">
|
<form @submit.prevent="submitSearch">
|
||||||
<div class="flex flex-wrap md:flex-nowrap items-center justify-start -mx-1">
|
<div class="flex flex-wrap md:flex-nowrap items-center justify-start -mx-1">
|
||||||
<div v-if="providersLoaded" class="w-36 px-1">
|
<div class="w-36 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="grow md:w-72 px-1">
|
<div class="flex-grow md: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'" class="w-60 md:w-72 px-1">
|
<div v-show="provider != 'itunes'" class="w-60 md:w-72 px-1">
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig" class="absolute top-0 left-0 w-full bg-bg h-full px-2 py-6 md:p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
<div v-if="selectedMatchOrig" class="absolute top-0 left-0 w-full bg-bg h-full px-2 py-6 md:p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
||||||
<div class="flex mb-4">
|
<div class="flex mb-4">
|
||||||
<div class="w-8 h-8 rounded-full hover:bg-white/10 flex items-center justify-center cursor-pointer" @click="clearSelectedMatch">
|
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="clearSelectedMatch">
|
||||||
<span class="material-symbols text-3xl">arrow_back</span>
|
<span class="material-symbols text-3xl">arrow_back</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xl pl-3">{{ $strings.HeaderUpdateDetails }}</p>
|
<p class="text-xl pl-3">{{ $strings.HeaderUpdateDetails }}</p>
|
||||||
|
|
@ -35,9 +35,9 @@
|
||||||
<ui-checkbox v-model="selectAll" :label="$strings.LabelSelectAll" checkbox-bg="bg" @input="selectAllToggled" />
|
<ui-checkbox v-model="selectAll" :label="$strings.LabelSelectAll" checkbox-bg="bg" @input="selectAllToggled" />
|
||||||
<form @submit.prevent="submitMatchUpdate">
|
<form @submit.prevent="submitMatchUpdate">
|
||||||
<div v-if="selectedMatchOrig.cover" class="flex flex-wrap md:flex-nowrap items-center justify-center">
|
<div v-if="selectedMatchOrig.cover" class="flex flex-wrap md:flex-nowrap items-center justify-center">
|
||||||
<div class="flex grow items-center py-2">
|
<div class="flex flex-grow items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="grow mx-4" />
|
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex py-2">
|
<div class="flex py-2">
|
||||||
|
|
@ -57,176 +57,176 @@
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.title" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.title" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.title" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.title" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" :label="$strings.LabelTitle" />
|
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" :label="$strings.LabelTitle" />
|
||||||
<p v-if="mediaMetadata.title" class="text-xs ml-1 text-white/60">
|
<p v-if="mediaMetadata.title" class="text-xs ml-1 text-white text-opacity-60">
|
||||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('title', mediaMetadata.title)">{{ mediaMetadata.title || '' }}</a>
|
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('title', mediaMetadata.title)">{{ mediaMetadata.title || '' }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.subtitle" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.subtitle" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.subtitle" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.subtitle" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" :label="$strings.LabelSubtitle" />
|
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" :label="$strings.LabelSubtitle" />
|
||||||
<p v-if="mediaMetadata.subtitle" class="text-xs ml-1 text-white/60">
|
<p v-if="mediaMetadata.subtitle" class="text-xs ml-1 text-white text-opacity-60">
|
||||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('subtitle', mediaMetadata.subtitle)">{{ mediaMetadata.subtitle }}</a>
|
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('subtitle', mediaMetadata.subtitle)">{{ mediaMetadata.subtitle }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.author" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.author" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.author" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.author" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" :label="$strings.LabelAuthor" />
|
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" :label="$strings.LabelAuthor" />
|
||||||
<p v-if="mediaMetadata.authorName || (isPodcast && mediaMetadata.author)" class="text-xs ml-1 text-white/60">
|
<p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white text-opacity-60">
|
||||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('author', isPodcast ? mediaMetadata.author : mediaMetadata.authorName)">{{ isPodcast ? mediaMetadata.author : mediaMetadata.authorName }}</a>
|
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('author', mediaMetadata.authorName)">{{ mediaMetadata.authorName }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.narrator" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.narrator" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.narrator" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.narrator" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-multi-select v-model="selectedMatch.narrator" :items="narrators" :disabled="!selectedMatchUsage.narrator" :label="$strings.LabelNarrators" />
|
<ui-multi-select v-model="selectedMatch.narrator" :items="narrators" :disabled="!selectedMatchUsage.narrator" :label="$strings.LabelNarrators" />
|
||||||
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white/60">
|
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">
|
||||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('narrator', mediaMetadata.narrators)">{{ mediaMetadata.narratorName }}</a>
|
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('narrator', mediaMetadata.narrators)">{{ mediaMetadata.narratorName }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.description" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.description" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.description" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.description" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-rich-text-editor v-model="selectedMatch.description" :disabled="!selectedMatchUsage.description" :label="$strings.LabelDescription" />
|
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" :label="$strings.LabelDescription" />
|
||||||
<p v-if="mediaMetadata.description" class="text-xs ml-1 text-white/60">
|
<p v-if="mediaMetadata.description" class="text-xs ml-1 text-white text-opacity-60">
|
||||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('description', mediaMetadata.description)">{{ mediaMetadata.descriptionPlain.substr(0, 100) + (mediaMetadata.descriptionPlain.length > 100 ? '...' : '') }}</a>
|
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('description', mediaMetadata.description)">{{ mediaMetadata.description.substr(0, 100) + (mediaMetadata.description.length > 100 ? '...' : '') }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.publisher" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.publisher" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.publisher" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.publisher" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" :label="$strings.LabelPublisher" />
|
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" :label="$strings.LabelPublisher" />
|
||||||
<p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white/60">
|
<p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white text-opacity-60">
|
||||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publisher', mediaMetadata.publisher)">{{ mediaMetadata.publisher }}</a>
|
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publisher', mediaMetadata.publisher)">{{ mediaMetadata.publisher }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.publishedYear" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.publishedYear" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.publishedYear" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.publishedYear" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" :label="$strings.LabelPublishYear" />
|
<ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" :label="$strings.LabelPublishYear" />
|
||||||
<p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white/60">
|
<p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white text-opacity-60">
|
||||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publishedYear', mediaMetadata.publishedYear)">{{ mediaMetadata.publishedYear }}</a>
|
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publishedYear', mediaMetadata.publishedYear)">{{ mediaMetadata.publishedYear }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedMatchOrig.series" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.series" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.series" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.series" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<widgets-series-input-widget v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" />
|
<widgets-series-input-widget v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" />
|
||||||
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white/60">
|
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">
|
||||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('series', mediaMetadata.series)">{{ mediaMetadata.seriesName }}</a>
|
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('series', mediaMetadata.series)">{{ mediaMetadata.seriesName }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.genres?.length" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.genres?.length" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-multi-select v-model="selectedMatch.genres" :items="genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
|
<ui-multi-select v-model="selectedMatch.genres" :items="genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
|
||||||
<p v-if="mediaMetadata.genres?.length" class="text-xs ml-1 text-white/60">
|
<p v-if="mediaMetadata.genres?.length" class="text-xs ml-1 text-white text-opacity-60">
|
||||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('genres', mediaMetadata.genres)">{{ mediaMetadata.genres.join(', ') }}</a>
|
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('genres', mediaMetadata.genres)">{{ mediaMetadata.genres.join(', ') }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.tags" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.tags" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.tags" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.tags" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-multi-select v-model="selectedMatch.tags" :items="tags" :disabled="!selectedMatchUsage.tags" :label="$strings.LabelTags" />
|
<ui-multi-select v-model="selectedMatch.tags" :items="tags" :disabled="!selectedMatchUsage.tags" :label="$strings.LabelTags" />
|
||||||
<p v-if="media.tags?.length" class="text-xs ml-1 text-white/60">
|
<p v-if="media.tags?.length" class="text-xs ml-1 text-white text-opacity-60">
|
||||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('tags', media.tags)">{{ media.tags.join(', ') }}</a>
|
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('tags', media.tags)">{{ media.tags.join(', ') }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.language" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.language" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.language" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.language" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.language" :disabled="!selectedMatchUsage.language" :label="$strings.LabelLanguage" />
|
<ui-text-input-with-label v-model="selectedMatch.language" :disabled="!selectedMatchUsage.language" :label="$strings.LabelLanguage" />
|
||||||
<p v-if="mediaMetadata.language" class="text-xs ml-1 text-white/60">
|
<p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">
|
||||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('language', mediaMetadata.language)">{{ mediaMetadata.language }}</a>
|
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('language', mediaMetadata.language)">{{ mediaMetadata.language }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.isbn" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.isbn" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.isbn" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.isbn" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" />
|
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" />
|
||||||
<p v-if="mediaMetadata.isbn" class="text-xs ml-1 text-white/60">
|
<p v-if="mediaMetadata.isbn" class="text-xs ml-1 text-white text-opacity-60">
|
||||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('isbn', mediaMetadata.isbn)">{{ mediaMetadata.isbn }}</a>
|
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('isbn', mediaMetadata.isbn)">{{ mediaMetadata.isbn }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.asin" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.asin" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.asin" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.asin" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" />
|
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" />
|
||||||
<p v-if="mediaMetadata.asin" class="text-xs ml-1 text-white/60">
|
<p v-if="mediaMetadata.asin" class="text-xs ml-1 text-white text-opacity-60">
|
||||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('asin', mediaMetadata.asin)">{{ mediaMetadata.asin }}</a>
|
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('asin', mediaMetadata.asin)">{{ mediaMetadata.asin }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedMatchOrig.itunesId" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.itunesId" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.itunesId" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.itunesId" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.itunesId" type="number" :disabled="!selectedMatchUsage.itunesId" label="iTunes ID" />
|
<ui-text-input-with-label v-model="selectedMatch.itunesId" type="number" :disabled="!selectedMatchUsage.itunesId" label="iTunes ID" />
|
||||||
<p v-if="mediaMetadata.itunesId" class="text-xs ml-1 text-white/60">
|
<p v-if="mediaMetadata.itunesId" class="text-xs ml-1 text-white text-opacity-60">
|
||||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('itunesId', mediaMetadata.itunesId)">{{ mediaMetadata.itunesId }}</a>
|
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('itunesId', mediaMetadata.itunesId)">{{ mediaMetadata.itunesId }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.feedUrl" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.feedUrl" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.feedUrl" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.feedUrl" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.feedUrl" :disabled="!selectedMatchUsage.feedUrl" label="RSS Feed URL" />
|
<ui-text-input-with-label v-model="selectedMatch.feedUrl" :disabled="!selectedMatchUsage.feedUrl" label="RSS Feed URL" />
|
||||||
<p v-if="mediaMetadata.feedUrl" class="text-xs ml-1 text-white/60">
|
<p v-if="mediaMetadata.feedUrl" class="text-xs ml-1 text-white text-opacity-60">
|
||||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('feedUrl', mediaMetadata.feedUrl)">{{ mediaMetadata.feedUrl }}</a>
|
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('feedUrl', mediaMetadata.feedUrl)">{{ mediaMetadata.feedUrl }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.itunesPageUrl" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.itunesPageUrl" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.itunesPageUrl" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.itunesPageUrl" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.itunesPageUrl" :disabled="!selectedMatchUsage.itunesPageUrl" label="iTunes Page URL" />
|
<ui-text-input-with-label v-model="selectedMatch.itunesPageUrl" :disabled="!selectedMatchUsage.itunesPageUrl" label="iTunes Page URL" />
|
||||||
<p v-if="mediaMetadata.itunesPageUrl" class="text-xs ml-1 text-white/60">
|
<p v-if="mediaMetadata.itunesPageUrl" class="text-xs ml-1 text-white text-opacity-60">
|
||||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('itunesPageUrl', mediaMetadata.itunesPageUrl)">{{ mediaMetadata.itunesPageUrl }}</a>
|
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('itunesPageUrl', mediaMetadata.itunesPageUrl)">{{ mediaMetadata.itunesPageUrl }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.releaseDate" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.releaseDate" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.releaseDate" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.releaseDate" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.releaseDate" :disabled="!selectedMatchUsage.releaseDate" :label="$strings.LabelReleaseDate" />
|
<ui-text-input-with-label v-model="selectedMatch.releaseDate" :disabled="!selectedMatchUsage.releaseDate" :label="$strings.LabelReleaseDate" />
|
||||||
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white/60">
|
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">
|
||||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('releaseDate', mediaMetadata.releaseDate)">{{ mediaMetadata.releaseDate }}</a>
|
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('releaseDate', mediaMetadata.releaseDate)">{{ mediaMetadata.releaseDate }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.explicit != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.explicit == null }">
|
<div v-if="selectedMatchOrig.explicit != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.explicit == null }">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.explicit" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.explicit" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="grow ml-4" :class="{ 'pt-4': mediaMetadata.explicit != null }">
|
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.explicit != null }">
|
||||||
<ui-checkbox v-model="selectedMatch.explicit" :label="$strings.LabelExplicit" :disabled="!selectedMatchUsage.explicit" :checkbox-bg="!selectedMatchUsage.explicit ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
<ui-checkbox v-model="selectedMatch.explicit" :label="$strings.LabelExplicit" :disabled="!selectedMatchUsage.explicit" :checkbox-bg="!selectedMatchUsage.explicit ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||||
<p v-if="mediaMetadata.explicit != null" class="text-xs ml-1 text-white/60">{{ $strings.LabelCurrently }} {{ mediaMetadata.explicit ? $strings.LabelExplicitChecked : $strings.LabelExplicitUnchecked }}</p>
|
<p v-if="mediaMetadata.explicit != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.explicit ? $strings.LabelExplicitChecked : $strings.LabelExplicitUnchecked }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.abridged != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.abridged == null }">
|
<div v-if="selectedMatchOrig.abridged != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.abridged == null }">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.abridged" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.abridged" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="grow ml-4" :class="{ 'pt-4': mediaMetadata.abridged != null }">
|
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.abridged != null }">
|
||||||
<ui-checkbox v-model="selectedMatch.abridged" :label="$strings.LabelAbridged" :disabled="!selectedMatchUsage.abridged" :checkbox-bg="!selectedMatchUsage.abridged ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
<ui-checkbox v-model="selectedMatch.abridged" :label="$strings.LabelAbridged" :disabled="!selectedMatchUsage.abridged" :checkbox-bg="!selectedMatchUsage.abridged ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||||
<p v-if="mediaMetadata.abridged != null" class="text-xs ml-1 text-white/60">{{ $strings.LabelCurrently }} {{ mediaMetadata.abridged ? $strings.LabelAbridgedChecked : $strings.LabelAbridgedUnchecked }}</p>
|
<p v-if="mediaMetadata.abridged != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.abridged ? $strings.LabelAbridgedChecked : $strings.LabelAbridgedUnchecked }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end py-2">
|
<div class="flex items-center justify-end py-2">
|
||||||
<ui-btn color="bg-success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -253,7 +253,6 @@ export default {
|
||||||
hasSearched: false,
|
hasSearched: false,
|
||||||
selectedMatch: null,
|
selectedMatch: null,
|
||||||
selectedMatchOrig: null,
|
selectedMatchOrig: null,
|
||||||
waitingForProviders: false,
|
|
||||||
selectedMatchUsage: {
|
selectedMatchUsage: {
|
||||||
title: true,
|
title: true,
|
||||||
subtitle: true,
|
subtitle: true,
|
||||||
|
|
@ -286,19 +285,9 @@ export default {
|
||||||
handler(newVal) {
|
handler(newVal) {
|
||||||
if (newVal) this.init()
|
if (newVal) this.init()
|
||||||
}
|
}
|
||||||
},
|
|
||||||
providersLoaded(isLoaded) {
|
|
||||||
// Complete initialization once providers are loaded
|
|
||||||
if (isLoaded && this.waitingForProviders) {
|
|
||||||
this.waitingForProviders = false
|
|
||||||
this.initProviderAndSearch()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
providersLoaded() {
|
|
||||||
return this.$store.getters['scanners/areProvidersLoaded']
|
|
||||||
},
|
|
||||||
isProcessing: {
|
isProcessing: {
|
||||||
get() {
|
get() {
|
||||||
return this.processing
|
return this.processing
|
||||||
|
|
@ -330,7 +319,7 @@ export default {
|
||||||
},
|
},
|
||||||
providers() {
|
providers() {
|
||||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||||
return this.$store.state.scanners.bookProviders
|
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
|
||||||
|
|
@ -408,12 +397,10 @@ export default {
|
||||||
},
|
},
|
||||||
submitSearch() {
|
submitSearch() {
|
||||||
if (!this.searchTitle) {
|
if (!this.searchTitle) {
|
||||||
this.$toast.warning(this.$strings.ToastTitleRequired)
|
this.$toast.warning('Search title is required')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!this.isPodcast) {
|
this.persistProvider()
|
||||||
this.persistProvider()
|
|
||||||
}
|
|
||||||
this.runSearch()
|
this.runSearch()
|
||||||
},
|
},
|
||||||
async runSearch() {
|
async runSearch() {
|
||||||
|
|
@ -489,24 +476,6 @@ export default {
|
||||||
|
|
||||||
this.checkboxToggled()
|
this.checkboxToggled()
|
||||||
},
|
},
|
||||||
initProviderAndSearch() {
|
|
||||||
// Set provider based on media type
|
|
||||||
if (this.isPodcast) {
|
|
||||||
this.provider = 'itunes'
|
|
||||||
} else {
|
|
||||||
this.provider = this.getDefaultBookProvider()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer using ASIN if set and using audible provider
|
|
||||||
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
|
|
||||||
this.searchTitle = this.libraryItem.media.metadata.asin
|
|
||||||
this.searchAuthor = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.searchTitle) {
|
|
||||||
this.submitSearch()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
init() {
|
init() {
|
||||||
this.clearSelectedMatch()
|
this.clearSelectedMatch()
|
||||||
this.initSelectedMatchUsage()
|
this.initSelectedMatchUsage()
|
||||||
|
|
@ -524,13 +493,19 @@ export default {
|
||||||
}
|
}
|
||||||
this.searchTitle = this.libraryItem.media.metadata.title
|
this.searchTitle = this.libraryItem.media.metadata.title
|
||||||
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
|
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
|
||||||
|
if (this.isPodcast) this.provider = 'itunes'
|
||||||
|
else {
|
||||||
|
this.provider = this.getDefaultBookProvider()
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for providers to be loaded before setting provider and searching
|
// Prefer using ASIN if set and using audible provider
|
||||||
if (this.providersLoaded || this.isPodcast) {
|
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
|
||||||
this.waitingForProviders = false
|
this.searchTitle = this.libraryItem.media.metadata.asin
|
||||||
this.initProviderAndSearch()
|
this.searchAuthor = ''
|
||||||
} else {
|
}
|
||||||
this.waitingForProviders = true
|
|
||||||
|
if (this.searchTitle) {
|
||||||
|
this.submitSearch()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
selectMatch(match) {
|
selectMatch(match) {
|
||||||
|
|
@ -643,12 +618,12 @@ export default {
|
||||||
if (updateResult.updated) {
|
if (updateResult.updated) {
|
||||||
this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)
|
this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)
|
||||||
} else {
|
} else {
|
||||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
this.$toast.info(this.$strings.ToastItemDetailsUpdateUnneeded)
|
||||||
}
|
}
|
||||||
this.clearSelectedMatch()
|
this.clearSelectedMatch()
|
||||||
this.$emit('selectTab', 'details')
|
this.$emit('selectTab', 'details')
|
||||||
} else {
|
} else {
|
||||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
this.$toast.error(this.$strings.ToastItemDetailsUpdateFailed)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.clearSelectedMatch()
|
this.clearSelectedMatch()
|
||||||
|
|
@ -660,10 +635,6 @@ export default {
|
||||||
this.selectedMatch = null
|
this.selectedMatch = null
|
||||||
this.selectedMatchOrig = null
|
this.selectedMatchOrig = null
|
||||||
}
|
}
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
// Fetch providers if not already loaded
|
|
||||||
this.$store.dispatch('scanners/fetchProviders')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -2,28 +2,28 @@
|
||||||
<div class="w-full h-full relative">
|
<div class="w-full h-full relative">
|
||||||
<div id="scheduleWrapper" class="w-full overflow-y-auto px-2 py-4 md:px-6 md:py-6">
|
<div id="scheduleWrapper" class="w-full overflow-y-auto px-2 py-4 md:px-6 md:py-6">
|
||||||
<template v-if="!feedUrl">
|
<template v-if="!feedUrl">
|
||||||
<widgets-alert type="warning" class="text-base mb-4">{{ $strings.ToastPodcastNoRssFeed }}</widgets-alert>
|
<widgets-alert type="warning" class="text-base mb-4">No RSS feed URL is set for this podcast</widgets-alert>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="feedUrl || autoDownloadEpisodes">
|
<template v-if="feedUrl || autoDownloadEpisodes">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<p class="text-base md:text-xl font-semibold">{{ $strings.HeaderScheduleEpisodeDownloads }}</p>
|
<p class="text-base md:text-xl font-semibold">Schedule Automatic Episode Downloads</p>
|
||||||
<ui-checkbox v-model="enableAutoDownloadEpisodes" :label="$strings.LabelEnable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
|
<ui-checkbox v-model="enableAutoDownloadEpisodes" label="Enable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
|
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
|
||||||
<ui-text-input ref="maxEpisodesInput" type="number" v-model="newMaxEpisodesToKeep" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updatedMaxEpisodesToKeep" />
|
<ui-text-input ref="maxEpisodesInput" type="number" v-model="newMaxEpisodesToKeep" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updatedMaxEpisodesToKeep" />
|
||||||
<ui-tooltip :text="$strings.LabelMaxEpisodesToKeepHelp">
|
<ui-tooltip text="Value of 0 sets no max limit. After a new episode is auto-downloaded this will delete the oldest episode if you have more than X episodes. <br>This will only delete 1 episode per new download.">
|
||||||
<p class="pl-4 text-base">
|
<p class="pl-4 text-base">
|
||||||
{{ $strings.LabelMaxEpisodesToKeep }}
|
Max episodes to keep
|
||||||
<span class="material-symbols icon-text">info</span>
|
<span class="material-symbols icon-text">info</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
|
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
|
||||||
<ui-text-input ref="maxEpisodesToDownloadInput" type="number" v-model="newMaxNewEpisodesToDownload" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updateMaxNewEpisodesToDownload" />
|
<ui-text-input ref="maxEpisodesToDownloadInput" type="number" v-model="newMaxNewEpisodesToDownload" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updateMaxNewEpisodesToDownload" />
|
||||||
<ui-tooltip :text="$strings.LabelUseZeroForUnlimited">
|
<ui-tooltip text="Value of 0 sets no max limit. When checking for new episodes this is the max number of episodes that will be downloaded.">
|
||||||
<p class="pl-4 text-base">
|
<p class="pl-4 text-base">
|
||||||
{{ $strings.LabelMaxEpisodesToDownloadPerCheck }}
|
Max new episodes to download per check
|
||||||
<span class="material-symbols icon-text">info</span>
|
<span class="material-symbols icon-text">info</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
@ -33,10 +33,10 @@
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="feedUrl || autoDownloadEpisodes" class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg border-t border-white/5">
|
<div v-if="feedUrl || autoDownloadEpisodes" class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg border-t border-white border-opacity-5">
|
||||||
<div class="flex items-center px-2 md:px-4">
|
<div class="flex items-center px-2 md:px-4">
|
||||||
<div class="grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn @click="save" :disabled="!isUpdated" :color="isUpdated ? 'bg-success' : 'bg-primary'" class="mx-2">{{ isUpdated ? $strings.ButtonSave : $strings.MessageNoUpdatesWereNecessary }}</ui-btn>
|
<ui-btn @click="save" :disabled="!isUpdated" :color="isUpdated ? 'success' : 'primary'" class="mx-2">{{ isUpdated ? 'Save' : 'No update necessary' }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -163,7 +163,7 @@ export default {
|
||||||
this.isProcessing = false
|
this.isProcessing = false
|
||||||
if (updateResult) {
|
if (updateResult) {
|
||||||
if (updateResult.updated) {
|
if (updateResult.updated) {
|
||||||
this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)
|
this.$toast.success('Item details updated')
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
<p class="text-lg">{{ $strings.LabelToolsMakeM4b }}</p>
|
<p class="text-lg">{{ $strings.LabelToolsMakeM4b }}</p>
|
||||||
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $strings.LabelToolsMakeM4bDescription }}</p>
|
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $strings.LabelToolsMakeM4bDescription }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grow" />
|
<div class="flex-grow" />
|
||||||
<div>
|
<div>
|
||||||
<ui-btn :to="`/audiobook/${libraryItemId}/manage?tool=m4b`" class="flex items-center"
|
<ui-btn :to="`/audiobook/${libraryItemId}/manage?tool=m4b`" class="flex items-center"
|
||||||
>{{ $strings.ButtonOpenManager }}
|
>{{ $strings.ButtonOpenManager }}
|
||||||
|
|
@ -26,25 +26,25 @@
|
||||||
<p class="text-lg">{{ $strings.LabelToolsEmbedMetadata }}</p>
|
<p class="text-lg">{{ $strings.LabelToolsEmbedMetadata }}</p>
|
||||||
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $strings.LabelToolsEmbedMetadataDescription }}</p>
|
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $strings.LabelToolsEmbedMetadataDescription }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grow" />
|
<div class="flex-grow" />
|
||||||
<div>
|
<div>
|
||||||
<ui-btn :to="`/audiobook/${libraryItemId}/manage?tool=embed`" class="flex items-center"
|
<ui-btn :to="`/audiobook/${libraryItemId}/manage?tool=embed`" class="flex items-center"
|
||||||
>{{ $strings.ButtonOpenManager }}
|
>{{ $strings.ButtonOpenManager }}
|
||||||
<span class="material-symbols text-lg ml-2">launch</span>
|
<span class="material-symbols text-lg ml-2">launch</span>
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
|
|
||||||
<ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">{{ $strings.ButtonQuickEmbed }}</ui-btn>
|
<ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">Quick Embed</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- queued alert -->
|
<!-- queued alert -->
|
||||||
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mt-4">
|
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mt-4">
|
||||||
<p class="text-lg">{{ $getString('MessageQuickEmbedQueue', [queuedEmbedLIds.length]) }}</p>
|
<p class="text-lg">Queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
|
||||||
</widgets-alert>
|
</widgets-alert>
|
||||||
|
|
||||||
<!-- processing alert -->
|
<!-- processing alert -->
|
||||||
<widgets-alert v-if="isEmbedTaskRunning" type="warning" class="mt-4">
|
<widgets-alert v-if="isEmbedTaskRunning" type="warning" class="mt-4">
|
||||||
<p class="text-lg">{{ $strings.MessageQuickEmbedInProgress }}</p>
|
<p class="text-lg">Currently embedding metadata</p>
|
||||||
</widgets-alert>
|
</widgets-alert>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -74,12 +74,19 @@ export default {
|
||||||
mediaTracks() {
|
mediaTracks() {
|
||||||
return this.media.tracks || []
|
return this.media.tracks || []
|
||||||
},
|
},
|
||||||
|
isSingleM4b() {
|
||||||
|
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
|
||||||
|
},
|
||||||
chapters() {
|
chapters() {
|
||||||
return this.media.chapters || []
|
return this.media.chapters || []
|
||||||
},
|
},
|
||||||
showM4bDownload() {
|
showM4bDownload() {
|
||||||
if (!this.mediaTracks.length) return false
|
if (!this.mediaTracks.length) return false
|
||||||
return true
|
return !this.isSingleM4b
|
||||||
|
},
|
||||||
|
showMp3Split() {
|
||||||
|
if (!this.mediaTracks.length) return false
|
||||||
|
return this.isSingleM4b && this.chapters.length
|
||||||
},
|
},
|
||||||
queuedEmbedLIds() {
|
queuedEmbedLIds() {
|
||||||
return this.$store.state.tasks.queuedEmbedLIds || []
|
return this.$store.state.tasks.queuedEmbedLIds || []
|
||||||
|
|
@ -106,7 +113,7 @@ export default {
|
||||||
methods: {
|
methods: {
|
||||||
quickEmbed() {
|
quickEmbed() {
|
||||||
const payload = {
|
const payload = {
|
||||||
message: this.$strings.MessageConfirmQuickEmbed,
|
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
|
||||||
callback: (confirmed) => {
|
callback: (confirmed) => {
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
this.$axios
|
this.$axios
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<div class="w-2/5 md:w-72 px-1 py-1 md:py-0">
|
<div class="w-2/5 md:w-72 px-1 py-1 md:py-0">
|
||||||
<ui-dropdown v-model="mediaType" :items="mediaTypes" :label="$strings.LabelMediaType" :disabled="!isNew" small @input="changedMediaType" />
|
<ui-dropdown v-model="mediaType" :items="mediaTypes" :label="$strings.LabelMediaType" :disabled="!isNew" small @input="changedMediaType" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full md:grow px-1 py-1 md:py-0">
|
<div class="w-full md:flex-grow px-1 py-1 md:py-0">
|
||||||
<ui-text-input-with-label ref="nameInput" v-model="name" :label="$strings.LabelLibraryName" @blur="nameBlurred" />
|
<ui-text-input-with-label ref="nameInput" v-model="name" :label="$strings.LabelLibraryName" @blur="nameBlurred" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/5 md:w-18 px-1 py-1 md:py-0">
|
<div class="w-1/5 md:w-18 px-1 py-1 md:py-0">
|
||||||
|
|
@ -19,16 +19,16 @@
|
||||||
<div class="folders-container overflow-y-auto w-full py-2 mb-2">
|
<div class="folders-container overflow-y-auto w-full py-2 mb-2">
|
||||||
<p class="px-1 text-sm font-semibold">{{ $strings.LabelFolders }}</p>
|
<p class="px-1 text-sm font-semibold">{{ $strings.LabelFolders }}</p>
|
||||||
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2">
|
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2">
|
||||||
<span class="material-symbols fill mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
<span class="material-symbols fill bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||||
<ui-editable-text ref="folderInput" v-model="folder.fullPath" :readonly="!!folder.id" type="text" class="w-full" @blur="existingFolderInputBlurred(folder)" />
|
<ui-editable-text ref="folderInput" v-model="folder.fullPath" :readonly="!!folder.id" type="text" class="w-full" @blur="existingFolderInputBlurred(folder)" />
|
||||||
<span v-show="folders.length > 1" class="material-symbols text-2xl ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
|
<span v-show="folders.length > 1" class="material-symbols text-2xl ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex py-1 px-2 items-center w-full">
|
<div class="flex py-1 px-2 items-center w-full">
|
||||||
<span class="material-symbols fill mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
<span class="material-symbols fill bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||||
<ui-editable-text ref="newFolderInput" v-model="newFolderPath" :placeholder="$strings.PlaceholderNewFolderPath" type="text" class="w-full" @blur="newFolderInputBlurred" />
|
<ui-editable-text ref="newFolderInput" v-model="newFolderPath" :placeholder="$strings.PlaceholderNewFolderPath" type="text" class="w-full" @blur="newFolderInputBlurred" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ui-btn class="w-full mt-2" color="bg-primary" @click="browseForFolder">{{ $strings.ButtonBrowseForFolder }}</ui-btn>
|
<ui-btn class="w-full mt-2" color="primary" @click="browseForFolder">{{ $strings.ButtonBrowseForFolder }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<modals-libraries-lazy-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" />
|
<modals-libraries-lazy-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" />
|
||||||
|
|
@ -74,7 +74,7 @@ export default {
|
||||||
},
|
},
|
||||||
providers() {
|
providers() {
|
||||||
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
||||||
return this.$store.state.scanners.bookProviders
|
return this.$store.state.scanners.providers
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
@ -156,8 +156,6 @@ export default {
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.init()
|
this.init()
|
||||||
// Fetch providers if not already loaded
|
|
||||||
this.$store.dispatch('scanners/fetchProviders')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
<div class="px-2 md:px-4 w-full text-sm pt-2 md:pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
<div class="px-2 md:px-4 w-full text-sm pt-2 md:pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||||
<component v-if="libraryCopy && show" ref="tabComponent" :is="tabName" :is-new="!library" :library="libraryCopy" :library-id="libraryId" :processing.sync="processing" @update="updateLibrary" @close="show = false" />
|
<component v-if="libraryCopy && show" ref="tabComponent" :is="tabName" :is-new="!library" :library="libraryCopy" :library-id="libraryId" :processing.sync="processing" @update="updateLibrary" @close="show = false" />
|
||||||
|
|
||||||
<div v-show="selectedTab !== 'tools'" class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white/10">
|
<div v-show="selectedTab !== 'tools'" class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10">
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<ui-btn @click="submit">{{ buttonText }}</ui-btn>
|
<ui-btn @click="submit">{{ buttonText }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -111,6 +111,7 @@ export default {
|
||||||
},
|
},
|
||||||
updateLibrary(library) {
|
updateLibrary(library) {
|
||||||
this.mapLibraryToCopy(library)
|
this.mapLibraryToCopy(library)
|
||||||
|
console.log('Updated library', this.libraryCopy)
|
||||||
},
|
},
|
||||||
getNewLibraryData() {
|
getNewLibraryData() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -127,9 +128,7 @@ export default {
|
||||||
autoScanCronExpression: null,
|
autoScanCronExpression: null,
|
||||||
hideSingleBookSeries: false,
|
hideSingleBookSeries: false,
|
||||||
onlyShowLaterBooksInContinueSeries: false,
|
onlyShowLaterBooksInContinueSeries: false,
|
||||||
metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'],
|
metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
|
||||||
markAsFinishedPercentComplete: null,
|
|
||||||
markAsFinishedTimeRemaining: 10
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -157,11 +156,11 @@ export default {
|
||||||
},
|
},
|
||||||
validate() {
|
validate() {
|
||||||
if (!this.libraryCopy.name) {
|
if (!this.libraryCopy.name) {
|
||||||
this.$toast.error(this.$strings.ToastNameRequired)
|
this.$toast.error('Library must have a name')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!this.libraryCopy.folders.length) {
|
if (!this.libraryCopy.folders.length) {
|
||||||
this.$toast.error(this.$strings.ToastMustHaveAtLeastOnePath)
|
this.$toast.error('Library must have at least 1 path')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,7 +205,7 @@ export default {
|
||||||
submitUpdateLibrary() {
|
submitUpdateLibrary() {
|
||||||
var newLibraryPayload = this.getLibraryUpdatePayload()
|
var newLibraryPayload = this.getLibraryUpdatePayload()
|
||||||
if (!Object.keys(newLibraryPayload).length) {
|
if (!Object.keys(newLibraryPayload).length) {
|
||||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
this.$toast.info('No updates are necessary')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,7 +222,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.ToastFailedToUpdate)
|
this.$toast.error(this.$strings.ToastLibraryUpdateFailed)
|
||||||
}
|
}
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
|
|
@ -237,6 +236,7 @@ export default {
|
||||||
this.show = false
|
this.show = false
|
||||||
this.$toast.success(this.$getString('ToastLibraryCreateSuccess', [res.name]))
|
this.$toast.success(this.$getString('ToastLibraryCreateSuccess', [res.name]))
|
||||||
if (!this.$store.state.libraries.currentLibraryId) {
|
if (!this.$store.state.libraries.currentLibraryId) {
|
||||||
|
console.log('Setting initially library id', res.id)
|
||||||
// First library added
|
// First library added
|
||||||
this.$store.dispatch('libraries/fetch', res.id)
|
this.$store.dispatch('libraries/fetch', res.id)
|
||||||
}
|
}
|
||||||
|
|
@ -264,4 +264,4 @@ export default {
|
||||||
.tab.tab-selected {
|
.tab.tab-selected {
|
||||||
height: 41px;
|
height: 41px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -4,24 +4,24 @@
|
||||||
<span class="material-symbols text-3xl cursor-pointer hover:text-gray-300" @click="$emit('back')">arrow_back</span>
|
<span class="material-symbols text-3xl cursor-pointer hover:text-gray-300" @click="$emit('back')">arrow_back</span>
|
||||||
<p class="px-4 text-xl">{{ $strings.HeaderChooseAFolder }}</p>
|
<p class="px-4 text-xl">{{ $strings.HeaderChooseAFolder }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="rootDirs.length" class="w-full bg-primary/70 py-1 px-4 mb-2">
|
<div v-if="rootDirs.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2">
|
||||||
<p class="font-mono truncate">{{ selectedPath || '/' }}</p>
|
<p class="font-mono truncate">{{ selectedPath || '/' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="rootDirs.length" class="relative flex bg-primary/50 p-4 folder-container">
|
<div v-if="rootDirs.length" class="relative flex bg-primary bg-opacity-50 p-4 folder-container">
|
||||||
<div class="w-1/2 border-r border-bg h-full overflow-y-auto">
|
<div class="w-1/2 border-r border-bg h-full overflow-y-auto">
|
||||||
<div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center hover:bg-white/10" @click="goBack">
|
<div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center hover:bg-white/10" @click="goBack">
|
||||||
<span class="material-symbols fill text-yellow-200" style="font-size: 1.2rem">folder</span>
|
<span class="material-symbols fill bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||||
<p class="text-base font-mono px-2">..</p>
|
<p class="text-base font-mono px-2">..</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="dir in _directories" :key="dir.path" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200 hover:bg-white/10" :class="dir.className" @click="selectDir(dir)">
|
<div v-for="dir in _directories" :key="dir.path" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200 hover:bg-white/10" :class="dir.className" @click="selectDir(dir)">
|
||||||
<span class="material-symbols fill text-yellow-200" style="font-size: 1.2rem">folder</span>
|
<span class="material-symbols fill bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||||
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
|
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
|
||||||
<span v-if="dir.path === selectedPath" class="material-symbols" style="font-size: 1.1rem">arrow_right</span>
|
<span v-if="dir.path === selectedPath" class="material-symbols" style="font-size: 1.1rem">arrow_right</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 h-full overflow-y-auto">
|
<div class="w-1/2 h-full overflow-y-auto">
|
||||||
<div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200 hover:bg-white/10" @click="selectSubDir(dir)">
|
<div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200 hover:bg-white/10" @click="selectSubDir(dir)">
|
||||||
<span class="material-symbols fill text-yellow-200" style="font-size: 1.2rem">folder</span>
|
<span class="material-symbols fill bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||||
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
|
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full py-2">
|
<div class="w-full py-2">
|
||||||
<ui-btn :disabled="!selectedPath" color="bg-primary" class="w-full mt-2" @click="selectFolder">{{ $strings.ButtonSelectFolderPath }}</ui-btn>
|
<ui-btn :disabled="!selectedPath" color="primary" class="w-full mt-2" @click="selectFolder">{{ $strings.ButtonSelectFolderPath }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -162,7 +162,7 @@ export default {
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to get filesystem paths', error)
|
console.error('Failed to get filesystem paths', error)
|
||||||
this.$toast.error(this.$strings.ToastFailedToLoadData)
|
this.$toast.error('Failed to get filesystem paths')
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
<div class="text-center py-1 w-8 min-w-8">
|
<div class="text-center py-1 w-8 min-w-8">
|
||||||
{{ source.include ? getSourceIndex(source.id) : '' }}
|
{{ source.include ? getSourceIndex(source.id) : '' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grow inline-flex justify-between px-4 py-3">
|
<div class="flex-grow inline-flex justify-between px-4 py-3">
|
||||||
{{ source.name }} <span v-if="source.include && (index === firstActiveSourceIndex || index === lastActiveSourceIndex)" class="px-2 italic font-semibold text-xs text-gray-400">{{ index === firstActiveSourceIndex ? $strings.LabelHighestPriority : $strings.LabelLowestPriority }}</span>
|
{{ source.name }} <span v-if="source.include && (index === firstActiveSourceIndex || index === lastActiveSourceIndex)" class="px-2 italic font-semibold text-xs text-gray-400">{{ index === firstActiveSourceIndex ? $strings.LabelHighestPriority : $strings.LabelLowestPriority }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-2 opacity-100">
|
<div class="px-2 opacity-100">
|
||||||
|
|
|
||||||
|
|
@ -1,95 +1,79 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
|
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
|
||||||
<div class="flex flex-wrap">
|
<div class="flex items-center py-3">
|
||||||
<div class="flex items-center p-2 w-full md:w-1/2">
|
<ui-toggle-switch v-model="useSquareBookCovers" @input="formUpdated" />
|
||||||
<ui-toggle-switch v-model="useSquareBookCovers" size="sm" @input="formUpdated" />
|
<ui-tooltip :text="$strings.LabelSettingsSquareBookCoversHelp">
|
||||||
<ui-tooltip :text="$strings.LabelSettingsSquareBookCoversHelp">
|
<p class="pl-4 text-base">
|
||||||
<p class="pl-4 text-sm">
|
{{ $strings.LabelSettingsSquareBookCovers }}
|
||||||
{{ $strings.LabelSettingsSquareBookCovers }}
|
<span class="material-symbols icon-text text-sm">info</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="py-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="enableWatcher" @input="formUpdated" />
|
||||||
|
<ui-toggle-switch v-else disabled :value="false" />
|
||||||
|
<p class="pl-4 text-base">{{ $strings.LabelSettingsEnableWatcherForLibrary }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="isBookLibrary" class="flex items-center py-3">
|
||||||
|
<ui-toggle-switch v-model="audiobooksOnly" @input="formUpdated" />
|
||||||
|
<ui-tooltip :text="$strings.LabelSettingsAudiobooksOnlyHelp">
|
||||||
|
<p class="pl-4 text-base">
|
||||||
|
{{ $strings.LabelSettingsAudiobooksOnly }}
|
||||||
|
<span class="material-symbols icon-text text-sm">info</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
<div v-if="isBookLibrary" class="py-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
|
||||||
|
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithASIN }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isBookLibrary" class="py-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
|
||||||
|
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithISBN }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isBookLibrary" class="py-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-toggle-switch v-model="hideSingleBookSeries" @input="formUpdated" />
|
||||||
|
<ui-tooltip :text="$strings.LabelSettingsHideSingleBookSeriesHelp">
|
||||||
|
<p class="pl-4 text-base">
|
||||||
|
{{ $strings.LabelSettingsHideSingleBookSeries }}
|
||||||
<span class="material-symbols icon-text text-sm">info</span>
|
<span class="material-symbols icon-text text-sm">info</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-2 w-full md:w-1/2">
|
</div>
|
||||||
<div class="flex items-center">
|
<div v-if="isBookLibrary" class="py-3">
|
||||||
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="enableWatcher" size="sm" @input="formUpdated" />
|
<div class="flex items-center">
|
||||||
<ui-toggle-switch v-else disabled size="sm" :value="false" />
|
<ui-toggle-switch v-model="onlyShowLaterBooksInContinueSeries" @input="formUpdated" />
|
||||||
<p class="pl-4 text-sm">{{ $strings.LabelSettingsEnableWatcherForLibrary }}</p>
|
<ui-tooltip :text="$strings.LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp">
|
||||||
</div>
|
<p class="pl-4 text-base">
|
||||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p>
|
{{ $strings.LabelSettingsOnlyShowLaterBooksInContinueSeries }}
|
||||||
</div>
|
|
||||||
<div v-if="isBookLibrary" class="flex items-center p-2 w-full md:w-1/2">
|
|
||||||
<ui-toggle-switch v-model="audiobooksOnly" size="sm" @input="formUpdated" />
|
|
||||||
<ui-tooltip :text="$strings.LabelSettingsAudiobooksOnlyHelp">
|
|
||||||
<p class="pl-4 text-sm">
|
|
||||||
{{ $strings.LabelSettingsAudiobooksOnly }}
|
|
||||||
<span class="material-symbols icon-text text-sm">info</span>
|
<span class="material-symbols icon-text text-sm">info</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isBookLibrary" class="p-2 w-full md:w-1/2">
|
</div>
|
||||||
<div class="flex items-center">
|
<div v-if="isBookLibrary" class="py-3">
|
||||||
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" size="sm" @input="formUpdated" />
|
<div class="flex items-center">
|
||||||
<p class="pl-4 text-sm">{{ $strings.LabelSettingsSkipMatchingBooksWithASIN }}</p>
|
<ui-toggle-switch v-model="epubsAllowScriptedContent" @input="formUpdated" />
|
||||||
</div>
|
<ui-tooltip :text="$strings.LabelSettingsEpubsAllowScriptedContentHelp">
|
||||||
</div>
|
<p class="pl-4 text-base">
|
||||||
<div v-if="isBookLibrary" class="p-2 w-full md:w-1/2">
|
{{ $strings.LabelSettingsEpubsAllowScriptedContent }}
|
||||||
<div class="flex items-center">
|
<span class="material-symbols icon-text text-sm">info</span>
|
||||||
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" size="sm" @input="formUpdated" />
|
</p>
|
||||||
<p class="pl-4 text-sm">{{ $strings.LabelSettingsSkipMatchingBooksWithISBN }}</p>
|
</ui-tooltip>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="isBookLibrary" class="p-2 w-full md:w-1/2">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<ui-toggle-switch v-model="hideSingleBookSeries" size="sm" @input="formUpdated" />
|
|
||||||
<ui-tooltip :text="$strings.LabelSettingsHideSingleBookSeriesHelp">
|
|
||||||
<p class="pl-4 text-sm">
|
|
||||||
{{ $strings.LabelSettingsHideSingleBookSeries }}
|
|
||||||
<span class="material-symbols icon-text text-sm">info</span>
|
|
||||||
</p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="isBookLibrary" class="p-2 w-full md:w-1/2">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<ui-toggle-switch v-model="onlyShowLaterBooksInContinueSeries" size="sm" @input="formUpdated" />
|
|
||||||
<ui-tooltip :text="$strings.LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp">
|
|
||||||
<p class="pl-4 text-sm">
|
|
||||||
{{ $strings.LabelSettingsOnlyShowLaterBooksInContinueSeries }}
|
|
||||||
<span class="material-symbols icon-text text-sm">info</span>
|
|
||||||
</p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="isBookLibrary" class="p-2 w-full md:w-1/2">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<ui-toggle-switch v-model="epubsAllowScriptedContent" size="sm" @input="formUpdated" />
|
|
||||||
<ui-tooltip :text="$strings.LabelSettingsEpubsAllowScriptedContentHelp">
|
|
||||||
<p class="pl-4 text-sm">
|
|
||||||
{{ $strings.LabelSettingsEpubsAllowScriptedContent }}
|
|
||||||
<span class="material-symbols icon-text text-sm">info</span>
|
|
||||||
</p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="isPodcastLibrary" class="p-2 w-full md:w-1/2">
|
|
||||||
<ui-dropdown :label="$strings.LabelPodcastSearchRegion" v-model="podcastSearchRegion" :items="$podcastSearchRegionOptions" small class="max-w-72" menu-max-height="200px" @input="formUpdated" />
|
|
||||||
</div>
|
|
||||||
<div class="p-2 w-full flex items-center space-x-2 flex-wrap">
|
|
||||||
<div>
|
|
||||||
<ui-dropdown v-model="markAsFinishedWhen" :items="maskAsFinishedWhenItems" :label="$strings.LabelSettingsLibraryMarkAsFinishedWhen" small class="w-72 min-w-72 text-sm" menu-max-height="200px" @input="markAsFinishedWhenChanged" />
|
|
||||||
</div>
|
|
||||||
<div class="w-16">
|
|
||||||
<div>
|
|
||||||
<label class="px-1 text-sm font-semibold"></label>
|
|
||||||
<div class="relative">
|
|
||||||
<ui-text-input v-model="markAsFinishedValue" type="number" label="" no-spinner custom-input-class="pr-5" @input="markAsFinishedChanged" />
|
|
||||||
<div class="absolute top-0 bottom-0 right-4 flex items-center">{{ markAsFinishedWhen === 'timeRemaining' ? '' : '%' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isPodcastLibrary" class="py-3">
|
||||||
|
<ui-dropdown :label="$strings.LabelPodcastSearchRegion" v-model="podcastSearchRegion" :items="$podcastSearchRegionOptions" small class="max-w-72" menu-max-height="200px" @input="formUpdated" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -104,6 +88,7 @@ export default {
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
provider: null,
|
||||||
useSquareBookCovers: false,
|
useSquareBookCovers: false,
|
||||||
enableWatcher: false,
|
enableWatcher: false,
|
||||||
skipMatchingMediaWithAsin: false,
|
skipMatchingMediaWithAsin: false,
|
||||||
|
|
@ -112,9 +97,7 @@ export default {
|
||||||
epubsAllowScriptedContent: false,
|
epubsAllowScriptedContent: false,
|
||||||
hideSingleBookSeries: false,
|
hideSingleBookSeries: false,
|
||||||
onlyShowLaterBooksInContinueSeries: false,
|
onlyShowLaterBooksInContinueSeries: false,
|
||||||
podcastSearchRegion: 'us',
|
podcastSearchRegion: 'us'
|
||||||
markAsFinishedWhen: 'timeRemaining',
|
|
||||||
markAsFinishedValue: 10
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -133,33 +116,13 @@ export default {
|
||||||
isPodcastLibrary() {
|
isPodcastLibrary() {
|
||||||
return this.mediaType === 'podcast'
|
return this.mediaType === 'podcast'
|
||||||
},
|
},
|
||||||
maskAsFinishedWhenItems() {
|
providers() {
|
||||||
return [
|
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
||||||
{
|
return this.$store.state.scanners.providers
|
||||||
text: this.$strings.LabelSettingsLibraryMarkAsFinishedTimeRemaining,
|
|
||||||
value: 'timeRemaining'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelSettingsLibraryMarkAsFinishedPercentComplete,
|
|
||||||
value: 'percentComplete'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
markAsFinishedWhenChanged(val) {
|
|
||||||
if (val === 'percentComplete' && this.markAsFinishedValue > 100) {
|
|
||||||
this.markAsFinishedValue = 100
|
|
||||||
}
|
|
||||||
this.formUpdated()
|
|
||||||
},
|
|
||||||
markAsFinishedChanged(val) {
|
|
||||||
this.formUpdated()
|
|
||||||
},
|
|
||||||
getLibraryData() {
|
getLibraryData() {
|
||||||
let markAsFinishedTimeRemaining = this.markAsFinishedWhen === 'timeRemaining' ? Number(this.markAsFinishedValue) : null
|
|
||||||
let markAsFinishedPercentComplete = this.markAsFinishedWhen === 'percentComplete' ? Number(this.markAsFinishedValue) : null
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
settings: {
|
settings: {
|
||||||
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
|
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
|
||||||
|
|
@ -170,9 +133,7 @@ export default {
|
||||||
epubsAllowScriptedContent: !!this.epubsAllowScriptedContent,
|
epubsAllowScriptedContent: !!this.epubsAllowScriptedContent,
|
||||||
hideSingleBookSeries: !!this.hideSingleBookSeries,
|
hideSingleBookSeries: !!this.hideSingleBookSeries,
|
||||||
onlyShowLaterBooksInContinueSeries: !!this.onlyShowLaterBooksInContinueSeries,
|
onlyShowLaterBooksInContinueSeries: !!this.onlyShowLaterBooksInContinueSeries,
|
||||||
podcastSearchRegion: this.podcastSearchRegion,
|
podcastSearchRegion: this.podcastSearchRegion
|
||||||
markAsFinishedTimeRemaining: markAsFinishedTimeRemaining,
|
|
||||||
markAsFinishedPercentComplete: markAsFinishedPercentComplete
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -189,11 +150,6 @@ export default {
|
||||||
this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries
|
this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries
|
||||||
this.onlyShowLaterBooksInContinueSeries = !!this.librarySettings.onlyShowLaterBooksInContinueSeries
|
this.onlyShowLaterBooksInContinueSeries = !!this.librarySettings.onlyShowLaterBooksInContinueSeries
|
||||||
this.podcastSearchRegion = this.librarySettings.podcastSearchRegion || 'us'
|
this.podcastSearchRegion = this.librarySettings.podcastSearchRegion || 'us'
|
||||||
this.markAsFinishedWhen = this.librarySettings.markAsFinishedTimeRemaining ? 'timeRemaining' : 'percentComplete'
|
|
||||||
if (!this.librarySettings.markAsFinishedTimeRemaining && !this.librarySettings.markAsFinishedPercentComplete) {
|
|
||||||
this.markAsFinishedWhen = 'timeRemaining'
|
|
||||||
}
|
|
||||||
this.markAsFinishedValue = this.librarySettings.markAsFinishedTimeRemaining || this.librarySettings.markAsFinishedPercentComplete || 10
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,13 @@
|
||||||
<div class="w-full border border-black-200 p-4 my-8">
|
<div class="w-full border border-black-200 p-4 my-8">
|
||||||
<div class="flex flex-wrap items-center">
|
<div class="flex flex-wrap items-center">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-lg">{{ $strings.LabelRemoveMetadataFile }}</p>
|
<p class="text-lg">Remove metadata files in library item folders</p>
|
||||||
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $getString('LabelRemoveMetadataFileHelp', [mediaType]) }}</p>
|
<p class="max-w-sm text-sm pt-2 text-gray-300">Remove all metadata.json or metadata.abs files in your {{ mediaType }} folders</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grow" />
|
<div class="flex-grow" />
|
||||||
<div>
|
<div>
|
||||||
<ui-btn class="mb-4 block" @click.stop="removeAllMetadataClick('json')">{{ $strings.LabelRemoveAllMetadataJson }}</ui-btn>
|
<ui-btn class="mb-4 block" @click.stop="removeAllMetadataClick('json')">Remove all metadata.json</ui-btn>
|
||||||
<ui-btn @click.stop="removeAllMetadataClick('abs')">{{ $strings.LabelRemoveAllMetadataAbs }}</ui-btn>
|
<ui-btn @click.stop="removeAllMetadataClick('abs')">Remove all metadata.abs</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -43,7 +43,7 @@ export default {
|
||||||
methods: {
|
methods: {
|
||||||
removeAllMetadataClick(ext) {
|
removeAllMetadataClick(ext) {
|
||||||
const payload = {
|
const payload = {
|
||||||
message: this.$getString('MessageConfirmRemoveMetadataFiles', [ext]),
|
message: `Are you sure you want to remove all metadata.${ext} files in your library item folders?`,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
callback: (confirmed) => {
|
callback: (confirmed) => {
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
|
|
@ -60,16 +60,16 @@ export default {
|
||||||
.$post(`/api/libraries/${this.libraryId}/remove-metadata?ext=${ext}`)
|
.$post(`/api/libraries/${this.libraryId}/remove-metadata?ext=${ext}`)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!data.found) {
|
if (!data.found) {
|
||||||
this.$toast.info(this.$getString('ToastMetadataFilesRemovedNoneFound', [ext]))
|
this.$toast.info(`No metadata.${ext} files were found in library`)
|
||||||
} else if (!data.removed) {
|
} else if (!data.removed) {
|
||||||
this.$toast.success(this.$getString('ToastMetadataFilesRemovedNoneRemoved', [ext]))
|
this.$toast.success(`No metadata.${ext} files removed`)
|
||||||
} else {
|
} else {
|
||||||
this.$toast.success(this.$getString('ToastMetadataFilesRemovedSuccess', [data.removed, ext]))
|
this.$toast.success(`Successfully removed ${data.removed} metadata.${ext} files`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to remove metadata files', error)
|
console.error('Failed to remove metadata files', error)
|
||||||
this.$toast.error(this.$getString('ToastMetadataFilesRemovedError', [ext]))
|
this.$toast.error('Failed to remove metadata files')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.$emit('update:processing', false)
|
this.$emit('update:processing', false)
|
||||||
|
|
@ -78,4 +78,4 @@ export default {
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue