mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-04 01:40:40 +02:00
Compare commits
236 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8abda14e0f | |||
| 619e5c0895 | |||
| 3a2594cde9 | |||
| 5cca2d0155 | |||
| a467637cb5 | |||
| 1a23001955 | |||
| 8942dca31d | |||
| 2a919012b6 | |||
| 40b342498f | |||
| e220b2818a | |||
| 0df36d2609 | |||
| adfe50a841 | |||
| 35925ddc1b | |||
| 33dfb764fa | |||
| 49bef2c641 | |||
| ac58536501 | |||
| c344555be3 | |||
| 645bcc53c6 | |||
| 84dd06dfc4 | |||
| 0a73dd6437 | |||
| 2cc055a1ad | |||
| d8ec3bd218 | |||
| d189ec74c9 | |||
| 4291769b93 | |||
| 22900a3f67 | |||
| 7fa08449de | |||
| 4f7203fccb | |||
| 0eea766931 | |||
| 5c054aef90 | |||
| a1674d5da1 | |||
| 91597a5454 | |||
| 11354a3e3f | |||
| dcd4f69383 | |||
| e253939c1e | |||
| f25ce1c0e7 | |||
| 7717e57c16 | |||
| 2e28c9b06d | |||
| 4bc7cd2045 | |||
| 5389115120 | |||
| 6e99cf6570 | |||
| 21bdd9f9ec | |||
| e3ae3f7e6a | |||
| 74bf917150 | |||
| 5666b263f5 | |||
| fc8fec62a0 | |||
| 034d858f18 | |||
| ebc9e1a888 | |||
| c5a9c2bf5a | |||
| 3dbce8fd71 | |||
| b2d299dba6 | |||
| cb5d9a8287 | |||
| f9530897c0 | |||
| 7c7e8285a4 | |||
| 7b3f9a1e0c | |||
| 399e0ea0bc | |||
| a47b0bce57 | |||
| 4b60b4f73e | |||
| d88b20addd | |||
| 5d12cc3f23 | |||
| 84fb7ce8b3 | |||
| 243cc672f7 | |||
| 663546dd77 | |||
| 1b79b3f42d | |||
| d4525ad5ca | |||
| dc9c307663 | |||
| 554e9ec238 | |||
| 2276228531 | |||
| 6f7d2ef4cd | |||
| ad3fbe7abf | |||
| c58110c7b7 | |||
| f781fa9e6b | |||
| 7f3543400a | |||
| 1ff5637c1b | |||
| f2d9de5a5f | |||
| 8be3bebee8 | |||
| ef88972b25 | |||
| 35f3b5863f | |||
| ff294867f8 | |||
| 1c6cd7499b | |||
| ce35ae6b03 | |||
| 28c99cf17f | |||
| 584e754eae | |||
| 68cf748e77 | |||
| 9b8f53caf6 | |||
| fdf332937f | |||
| 182545a729 | |||
| e83df2bf4b | |||
| 10299e3037 | |||
| 6a43672973 | |||
| 02bf55b401 | |||
| f0615c2971 | |||
| 7ef44eb75b | |||
| 044804115b | |||
| 3b941d59a3 | |||
| d69f6020c6 | |||
| 2fc60e4e9c | |||
| cdcfd01da2 | |||
| d6c5b6e8c6 | |||
| 5d305c96ad | |||
| 6d823f4e42 | |||
| bd5e865a11 | |||
| cd274e0844 | |||
| e9249430c3 | |||
| cd5e5099f2 | |||
| 09dd90e3fc | |||
| a62f7a4861 | |||
| 5a26b01ffb | |||
| cbde451120 | |||
| 8bbeae4873 | |||
| 05dff2583a | |||
| 79a82df914 | |||
| 3f6ed6dbf9 | |||
| 4edba20e9e | |||
| 2c6e1cc2b5 | |||
| e1af25d9d8 | |||
| 9b30a8ff4b | |||
| b1a9de819e | |||
| 68da974c12 | |||
| 8c47ccb651 | |||
| d544ecc657 | |||
| 9f69a8ace3 | |||
| a90cfc4d04 | |||
| 88354de495 | |||
| 5b02c5185f | |||
| 1152e5513e | |||
| 8ce9b55969 | |||
| ccf08e9e80 | |||
| b0b1d2707d | |||
| 469278cd1e | |||
| 10d9e11387 | |||
| 5328f4cddb | |||
| 4154022ad1 | |||
| 642e9787c0 | |||
| da2e65c042 | |||
| ab895fa8ed | |||
| f5e892b862 | |||
| ac097862fc | |||
| 23cc6bb210 | |||
| c60807f998 | |||
| 99e2ea228d | |||
| 8df05896b5 | |||
| 174dac8fd4 | |||
| 2a386ca2a9 | |||
| fc228013d3 | |||
| 64b824ef6b | |||
| 96cd91a385 | |||
| 5c91c1e2c7 | |||
| 2df5ab0dde | |||
| baf738f5ba | |||
| 3a7cafbb95 | |||
| 3276b04256 | |||
| ac3fa31d1e | |||
| 6e5e638076 | |||
| 609bf4309f | |||
| 66b5c14c6b | |||
| e4936ed522 | |||
| c201e2aa98 | |||
| 3d3f20296c | |||
| 9ae71615bc | |||
| 292840a0e3 | |||
| 84e6e6fdbe | |||
| cfe27dff80 | |||
| c75895d711 | |||
| c0ff28ffff | |||
| 58dfa65660 | |||
| 3f8e685d64 | |||
| 08e1782253 | |||
| 0dd219f303 | |||
| d5e96a3422 | |||
| 03bfecefee | |||
| 12027b9a76 | |||
| 0e665e2091 | |||
| e32d05ea27 | |||
| 5446aea910 | |||
| 86e7c7fc33 | |||
| 173b72c3b5 | |||
| 3150822117 | |||
| 9a96d17a30 | |||
| c98409b9ae | |||
| 0e3640c246 | |||
| e030b59bae | |||
| 920ca683b9 | |||
| 28d76d21f1 | |||
| e1e6b46456 | |||
| 122f2a2556 | |||
| 27f1bd90f9 | |||
| f8d0384155 | |||
| 43bbfbfee3 | |||
| deadc63dbb | |||
| a9b9e23f46 | |||
| 6a06ba4327 | |||
| 3d2bbc7719 | |||
| c9ea5dd2d7 | |||
| eea3e2583c | |||
| 57399bb79e | |||
| 69fcb103e4 | |||
| f00b120e96 | |||
| 14a8f84446 | |||
| 099ae7c776 | |||
| 1cf9e85272 | |||
| c4eeb1cfb7 | |||
| 1dde02b170 | |||
| 08e648a3bc | |||
| 755e70b4a9 | |||
| 5ff4cd2c0b | |||
| e36c31c5e7 | |||
| d561a48229 | |||
| 5243a225e8 | |||
| 4fe60465e5 | |||
| 0af6ad63c1 | |||
| 68b13ae45f | |||
| 4c2ad3ede5 | |||
| deea6702f0 | |||
| 7348432594 | |||
| 7d66f1eec9 | |||
| be1e1e7ba0 | |||
| 4bdef893af | |||
| 6597fca576 | |||
| ea9ec13845 | |||
| 30f15d3575 | |||
| dad12537b6 | |||
| 65df377a49 | |||
| 2d19208340 | |||
| 73257188f6 | |||
| 5f4e5cd3d8 | |||
| f2be3bc95e | |||
| 2a30cc428f | |||
| b97ed953f7 | |||
| 65793f7109 | |||
| 2b7f53b0a7 | |||
| c6eb1096e8 | |||
| a907c88f66 | |||
| 43f48b65f8 | |||
| 2a4cbd48b8 | |||
| b6e4f3a8c5 | |||
| 83976b5549 |
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
# Only build when files in these directories have been changed
|
||||||
|
paths:
|
||||||
|
- client/**
|
||||||
|
- server/**
|
||||||
|
- index.js
|
||||||
|
- package.json
|
||||||
|
release:
|
||||||
|
types: [published, edited]
|
||||||
|
# Allows you to run workflow manually from Actions tab
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: "!contains(github.event.head_commit.message, 'skip ci')"
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v3
|
||||||
|
with:
|
||||||
|
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
|
||||||
|
tags: |
|
||||||
|
type=edge,branch=master
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
- name: Setup QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
|
||||||
|
- name: Cache Docker layers
|
||||||
|
uses: actions/cache@v2
|
||||||
|
with:
|
||||||
|
path: /tmp/.buildx-cache
|
||||||
|
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-buildx-
|
||||||
|
|
||||||
|
- name: Login to Dockerhub
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Login to ghcr
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GHCR_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
push: true
|
||||||
|
cache-from: type=local,src=/tmp/.buildx-cache
|
||||||
|
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||||
|
|
||||||
|
- name: Move cache
|
||||||
|
run: |
|
||||||
|
rm -rf /tmp/.buildx-cache
|
||||||
|
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||||
@@ -4,6 +4,7 @@ node_modules/
|
|||||||
/config/
|
/config/
|
||||||
/audiobooks/
|
/audiobooks/
|
||||||
/audiobooks2/
|
/audiobooks2/
|
||||||
|
/podcasts/
|
||||||
/media/
|
/media/
|
||||||
/metadata/
|
/metadata/
|
||||||
test/
|
test/
|
||||||
@@ -12,3 +13,4 @@ test/
|
|||||||
/dist/
|
/dist/
|
||||||
|
|
||||||
sw.*
|
sw.*
|
||||||
|
.DS_STORE
|
||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
### STAGE 0: Build client ###
|
### STAGE 0: Build client ###
|
||||||
FROM node:12-alpine AS build
|
FROM node:16-alpine AS build
|
||||||
WORKDIR /client
|
WORKDIR /client
|
||||||
COPY /client /client
|
COPY /client /client
|
||||||
RUN npm install
|
RUN npm install
|
||||||
RUN npm run generate
|
RUN npm run generate
|
||||||
|
|
||||||
### STAGE 1: Build server ###
|
### STAGE 1: Build server ###
|
||||||
FROM node:12-alpine
|
FROM node:16-alpine
|
||||||
RUN apk update && apk add --no-cache --update ffmpeg
|
RUN apk update && apk add --no-cache --update ffmpeg
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
COPY --from=build /client/dist /client/dist
|
COPY --from=build /client/dist /client/dist
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
|
|||||||
DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks"
|
DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks"
|
||||||
DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
|
DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
|
||||||
DEFAULT_PORT=7331
|
DEFAULT_PORT=7331
|
||||||
|
DEFAULT_HOST="0.0.0.0"
|
||||||
|
|
||||||
CONFIG_PATH="/etc/default/audiobookshelf"
|
CONFIG_PATH="/etc/default/audiobookshelf"
|
||||||
|
|
||||||
@@ -82,7 +83,8 @@ setup_config_interactive() {
|
|||||||
CONFIG_PATH=$DATA_PATH/config
|
CONFIG_PATH=$DATA_PATH/config
|
||||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
||||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
||||||
PORT=$PORT"
|
PORT=$PORT
|
||||||
|
HOST=$DEFAULT_HOST"
|
||||||
|
|
||||||
echo "$config_text"
|
echo "$config_text"
|
||||||
|
|
||||||
@@ -105,7 +107,8 @@ setup_config() {
|
|||||||
CONFIG_PATH=$DEFAULT_DATA_PATH/config
|
CONFIG_PATH=$DEFAULT_DATA_PATH/config
|
||||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
||||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
||||||
PORT=$DEFAULT_PORT"
|
PORT=$DEFAULT_PORT
|
||||||
|
HOST=$DEFAULT_HOST"
|
||||||
|
|
||||||
echo "$config_text"
|
echo "$config_text"
|
||||||
|
|
||||||
|
|||||||
@@ -187,3 +187,15 @@ Bookshelf Label
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
filter: blur(20px);
|
filter: blur(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.episode-subtitle {
|
||||||
|
word-break: break-word;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
line-height: 16px; /* fallback */
|
||||||
|
max-height: 32px; /* fallback */
|
||||||
|
-webkit-line-clamp: 2; /* number of lines to show */
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
<div v-if="!isPodcast" class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
||||||
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,7 +99,8 @@ export default {
|
|||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
sleepTimerSet: Boolean,
|
sleepTimerSet: Boolean,
|
||||||
sleepTimerRemaining: Number
|
sleepTimerRemaining: Number,
|
||||||
|
isPodcast: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -23,15 +23,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
<nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||||
<span class="material-icons">equalizer</span>
|
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
<nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||||
<span class="material-icons">upload</span>
|
<span class="material-icons" aria-label="Upload Media" role="button">upload</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||||
<span class="material-icons">settings</span>
|
<span class="material-icons" aria-label="System Settings" role="button">settings</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<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 focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-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 focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
|
||||||
@@ -44,16 +44,16 @@
|
|||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
<div v-show="numLibraryItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
||||||
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1>
|
<h1 class="text-2xl px-4">{{ numLibraryItemsSelected }} Selected</h1>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
|
<ui-tooltip v-if="!isPodcastLibrary" :text="`Mark as ${selectedIsFinished ? 'Not Finished' : 'Finished'}`" direction="bottom">
|
||||||
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" />
|
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<ui-tooltip v-if="userCanUpdate" text="Add to Collection" direction="bottom">
|
<ui-tooltip v-if="userCanUpdate && !isPodcastLibrary" text="Add to Collection" direction="bottom">
|
||||||
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
|
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<template v-if="userCanUpdate && numAudiobooksSelected < 50">
|
<template v-if="userCanUpdate && numLibraryItemsSelected < 50">
|
||||||
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
||||||
</template>
|
</template>
|
||||||
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
||||||
@@ -79,6 +79,12 @@ export default {
|
|||||||
libraryName() {
|
libraryName() {
|
||||||
return this.currentLibrary ? this.currentLibrary.name : 'unknown'
|
return this.currentLibrary ? this.currentLibrary.name : 'unknown'
|
||||||
},
|
},
|
||||||
|
libraryMediaType() {
|
||||||
|
return this.currentLibrary ? this.currentLibrary.mediaType : null
|
||||||
|
},
|
||||||
|
isPodcastLibrary() {
|
||||||
|
return this.libraryMediaType === 'podcast'
|
||||||
|
},
|
||||||
isHome() {
|
isHome() {
|
||||||
return this.$route.name === 'library-library'
|
return this.$route.name === 'library-library'
|
||||||
},
|
},
|
||||||
@@ -94,17 +100,14 @@ export default {
|
|||||||
username() {
|
username() {
|
||||||
return this.user ? this.user.username : 'err'
|
return this.user ? this.user.username : 'err'
|
||||||
},
|
},
|
||||||
numAudiobooksSelected() {
|
numLibraryItemsSelected() {
|
||||||
return this.selectedAudiobooks.length
|
return this.selectedLibraryItems.length
|
||||||
},
|
},
|
||||||
selectedAudiobooks() {
|
selectedLibraryItems() {
|
||||||
return this.$store.state.selectedAudiobooks
|
return this.$store.state.selectedLibraryItems
|
||||||
},
|
},
|
||||||
userAudiobooks() {
|
userMediaProgress() {
|
||||||
return this.$store.state.user.user.audiobooks || {}
|
return this.$store.state.user.user.mediaProgress || []
|
||||||
},
|
|
||||||
selectedSeries() {
|
|
||||||
return this.$store.state.audiobooks.selectedSeries
|
|
||||||
},
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
@@ -115,11 +118,11 @@ export default {
|
|||||||
userCanUpload() {
|
userCanUpload() {
|
||||||
return this.$store.getters['user/getUserCanUpload']
|
return this.$store.getters['user/getUserCanUpload']
|
||||||
},
|
},
|
||||||
selectedIsRead() {
|
selectedIsFinished() {
|
||||||
// Find an audiobook that is not read, if none then all audiobooks read
|
// Find an item that is not finished, if none then all items finished
|
||||||
return !this.selectedAudiobooks.find((ab) => {
|
return !this.selectedLibraryItems.find((libraryItemId) => {
|
||||||
var userAb = this.userAudiobooks[ab]
|
var itemProgress = this.userMediaProgress.find((lip) => lip.libraryItemId === libraryItemId)
|
||||||
return !userAb || !userAb.isRead
|
return !itemProgress || !itemProgress.isFinished
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
processingBatch() {
|
processingBatch() {
|
||||||
@@ -150,25 +153,26 @@ export default {
|
|||||||
},
|
},
|
||||||
cancelSelectionMode() {
|
cancelSelectionMode() {
|
||||||
if (this.processingBatchDelete) return
|
if (this.processingBatchDelete) return
|
||||||
this.$store.commit('setSelectedAudiobooks', [])
|
this.$store.commit('setSelectedLibraryItems', [])
|
||||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||||
this.isAllSelected = false
|
this.isAllSelected = false
|
||||||
},
|
},
|
||||||
toggleBatchRead() {
|
toggleBatchRead() {
|
||||||
this.$store.commit('setProcessingBatch', true)
|
this.$store.commit('setProcessingBatch', true)
|
||||||
var newIsRead = !this.selectedIsRead
|
var newIsFinished = !this.selectedIsFinished
|
||||||
var updateProgressPayloads = this.selectedAudiobooks.map((ab) => {
|
var updateProgressPayloads = this.selectedLibraryItems.map((lid) => {
|
||||||
return {
|
return {
|
||||||
audiobookId: ab,
|
id: lid,
|
||||||
isRead: newIsRead
|
isFinished: newIsFinished
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
console.log('Progress payloads', updateProgressPayloads)
|
||||||
this.$axios
|
this.$axios
|
||||||
.patch(`/api/me/audiobook/batch/update`, updateProgressPayloads)
|
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Batch update success!')
|
this.$toast.success('Batch update success!')
|
||||||
this.$store.commit('setProcessingBatch', false)
|
this.$store.commit('setProcessingBatch', false)
|
||||||
this.$store.commit('setSelectedAudiobooks', [])
|
this.$store.commit('setSelectedLibraryItems', [])
|
||||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -178,20 +182,20 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
batchDeleteClick() {
|
batchDeleteClick() {
|
||||||
var audiobookText = this.numAudiobooksSelected > 1 ? `these ${this.numAudiobooksSelected} audiobooks` : 'this audiobook'
|
var audiobookText = this.numLibraryItemsSelected > 1 ? `these ${this.numLibraryItemsSelected} items` : 'this item'
|
||||||
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the audiobooks from AudioBookshelf`
|
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf`
|
||||||
if (confirm(confirmMsg)) {
|
if (confirm(confirmMsg)) {
|
||||||
this.processingBatchDelete = true
|
this.processingBatchDelete = true
|
||||||
this.$store.commit('setProcessingBatch', true)
|
this.$store.commit('setProcessingBatch', true)
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/books/batch/delete`, {
|
.$post(`/api/items/batch/delete`, {
|
||||||
audiobookIds: this.selectedAudiobooks
|
libraryItemIds: this.selectedLibraryItems
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Batch delete success!')
|
this.$toast.success('Batch delete success!')
|
||||||
this.processingBatchDelete = false
|
this.processingBatchDelete = false
|
||||||
this.$store.commit('setProcessingBatch', false)
|
this.$store.commit('setProcessingBatch', false)
|
||||||
this.$store.commit('setSelectedAudiobooks', [])
|
this.$store.commit('setSelectedLibraryItems', [])
|
||||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="outer-container">
|
|
||||||
<!-- absolute positioned container -->
|
|
||||||
<div class="inner-container">
|
|
||||||
<div class="relative h-10">
|
|
||||||
<div class="table-header" id="headerdiv">
|
|
||||||
<table id="headertable" width="100%" cellpadding="0" cellspacing="0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="header-cell min-w-12 max-w-12"></th>
|
|
||||||
<th class="header-cell min-w-6 max-w-6"></th>
|
|
||||||
<th class="header-cell min-w-64 max-w-64 px-2">Title</th>
|
|
||||||
<th class="header-cell min-w-48 max-w-48 px-2">Author</th>
|
|
||||||
<th class="header-cell min-w-48 max-w-48 px-2">Series</th>
|
|
||||||
<th class="header-cell min-w-24 max-w-24 px-2">Year</th>
|
|
||||||
<th class="header-cell min-w-80 max-w-80 px-2">Description</th>
|
|
||||||
<th class="header-cell min-w-48 max-w-48 px-2">Narrator</th>
|
|
||||||
<th class="header-cell min-w-48 max-w-48 px-2">Genres</th>
|
|
||||||
<th class="header-cell min-w-48 max-w-48 px-2">Tags</th>
|
|
||||||
<th class="header-cell min-w-24 max-w-24 px-2"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="absolute top-0 left-0 w-full h-full pointer-events-none" :class="isScrollable ? 'header-shadow' : ''" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref="tableBody" class="table-body" onscroll="document.getElementById('headerdiv').scrollLeft = this.scrollLeft;" @scroll="tableScrolled">
|
|
||||||
<table id="bodytable" width="100%" cellpadding="0" cellspacing="0">
|
|
||||||
<tbody>
|
|
||||||
<template v-for="book in books">
|
|
||||||
<app-book-list-row :key="book.id" :book="book" @edit="editBook" />
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
books: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
isScrollable: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {},
|
|
||||||
methods: {
|
|
||||||
checkIsScrolled() {
|
|
||||||
if (!this.$refs.tableBody) return
|
|
||||||
this.isScrollable = this.$refs.tableBody.scrollTop > 0
|
|
||||||
},
|
|
||||||
tableScrolled() {
|
|
||||||
this.checkIsScrolled()
|
|
||||||
},
|
|
||||||
editBook(book) {
|
|
||||||
var bookIds = this.books.map((e) => e.id)
|
|
||||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
|
||||||
this.$store.commit('showEditModal', book)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.checkIsScrolled()
|
|
||||||
},
|
|
||||||
beforeDestroy() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.outer-container {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
overflow: visible;
|
|
||||||
height: calc(100% - 50px);
|
|
||||||
width: calc(100% - 10px);
|
|
||||||
margin: 10px;
|
|
||||||
}
|
|
||||||
.inner-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.table-header {
|
|
||||||
float: left;
|
|
||||||
overflow: hidden;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.header-shadow {
|
|
||||||
box-shadow: 3px 8px 3px #11111155;
|
|
||||||
}
|
|
||||||
.table-body {
|
|
||||||
float: left;
|
|
||||||
height: 100%;
|
|
||||||
width: inherit;
|
|
||||||
overflow-y: scroll;
|
|
||||||
padding-right: 0px;
|
|
||||||
}
|
|
||||||
.header-cell {
|
|
||||||
background-color: #22222288;
|
|
||||||
padding: 0px 4px;
|
|
||||||
text-align: left;
|
|
||||||
height: 40px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: semi-bold;
|
|
||||||
}
|
|
||||||
.body-cell {
|
|
||||||
text-align: left;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.book-row {
|
|
||||||
background-color: #22222288;
|
|
||||||
}
|
|
||||||
.book-row:nth-child(odd) {
|
|
||||||
background-color: #333;
|
|
||||||
}
|
|
||||||
.book-row.selected {
|
|
||||||
background-color: rgba(0, 255, 0, 0.05);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
<template>
|
|
||||||
<tr class="book-row" :class="selected ? 'selected' : ''">
|
|
||||||
<td class="body-cell min-w-12 max-w-12">
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<div class="bg-white border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center focus-within:border-blue-500 w-4 h-4" @click="selectBtnClick">
|
|
||||||
<svg v-if="selected" class="fill-current text-green-500 pointer-events-none w-2.5 h-2.5" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-6 max-w-6">
|
|
||||||
<covers-hover-book-cover :audiobook="book" />
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-64 max-w-64 px-2">
|
|
||||||
<nuxt-link :to="`/audiobook/${book.id}`" class="hover:underline">
|
|
||||||
<p class="truncate">
|
|
||||||
{{ book.book.title }}<span v-if="book.book.subtitle">: {{ book.book.subtitle }}</span>
|
|
||||||
</p>
|
|
||||||
</nuxt-link>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
|
||||||
<p class="truncate">{{ book.book.authorFL }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
|
||||||
<p class="truncate">{{ seriesText }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-24 max-w-24 px-2">
|
|
||||||
<p class="truncate">{{ book.book.publishYear }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-80 max-w-80 px-2">
|
|
||||||
<p class="truncate">{{ book.book.description }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
|
||||||
<p class="truncate">{{ book.book.narrator }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
|
||||||
<p class="truncate">{{ genresText }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
|
||||||
<p class="truncate">{{ tagsText }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="body-cell min-w-24 max-w-24 px-2">
|
|
||||||
<div class="flex">
|
|
||||||
<span v-if="userCanUpdate" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="editClick">edit</span>
|
|
||||||
<span v-if="showPlayButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-2xl mx-1" @click="startStream">play_arrow</span>
|
|
||||||
<span v-if="showReadButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="openEbook">auto_stories</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
book: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
},
|
|
||||||
userAudiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
isProcessingReadUpdate: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
audiobookId() {
|
|
||||||
return this.book.id
|
|
||||||
},
|
|
||||||
selected: {
|
|
||||||
get() {
|
|
||||||
return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
if (this.processingBatch) return
|
|
||||||
this.$store.commit('setAudiobookSelected', { audiobookId: this.audiobookId, selected: val })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
processingBatch() {
|
|
||||||
return this.$store.state.processingBatch
|
|
||||||
},
|
|
||||||
bookObj() {
|
|
||||||
return this.book.book || {}
|
|
||||||
},
|
|
||||||
series() {
|
|
||||||
return this.bookObj.series || null
|
|
||||||
},
|
|
||||||
volumeNumber() {
|
|
||||||
return this.bookObj.volumeNumber || null
|
|
||||||
},
|
|
||||||
seriesText() {
|
|
||||||
if (!this.series) return ''
|
|
||||||
if (!this.volumeNumber) return this.series
|
|
||||||
return `${this.series} #${this.volumeNumber}`
|
|
||||||
},
|
|
||||||
genresText() {
|
|
||||||
if (!this.bookObj.genres) return ''
|
|
||||||
return this.bookObj.genres.join(', ')
|
|
||||||
},
|
|
||||||
tagsText() {
|
|
||||||
return (this.book.tags || []).join(', ')
|
|
||||||
},
|
|
||||||
isMissing() {
|
|
||||||
return this.book.isMissing
|
|
||||||
},
|
|
||||||
isInvalid() {
|
|
||||||
return this.book.isInvalid
|
|
||||||
},
|
|
||||||
numEbooks() {
|
|
||||||
return this.book.numEbooks
|
|
||||||
},
|
|
||||||
numTracks() {
|
|
||||||
return this.book.numTracks
|
|
||||||
},
|
|
||||||
isStreaming() {
|
|
||||||
return this.$store.getters['getAudiobookIdStreaming'] === this.audiobookId
|
|
||||||
},
|
|
||||||
showReadButton() {
|
|
||||||
return this.showExperimentalFeatures && this.numEbooks
|
|
||||||
},
|
|
||||||
showPlayButton() {
|
|
||||||
return !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
|
|
||||||
},
|
|
||||||
userIsRead() {
|
|
||||||
return this.userAudiobook ? !!this.userAudiobook.isRead : false
|
|
||||||
},
|
|
||||||
userCanUpdate() {
|
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
|
||||||
},
|
|
||||||
userCanDelete() {
|
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
selectBtnClick() {
|
|
||||||
if (this.processingBatch) return
|
|
||||||
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
|
|
||||||
},
|
|
||||||
openEbook() {
|
|
||||||
this.$store.commit('showEReader', this.book)
|
|
||||||
},
|
|
||||||
downloadClick() {
|
|
||||||
this.$store.commit('showEditModalOnTab', { audiobook: this.book, tab: 'download' })
|
|
||||||
},
|
|
||||||
toggleRead() {
|
|
||||||
var updatePayload = {
|
|
||||||
isRead: !this.userIsRead
|
|
||||||
}
|
|
||||||
this.isProcessingReadUpdate = true
|
|
||||||
this.$axios
|
|
||||||
.$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload)
|
|
||||||
.then(() => {
|
|
||||||
this.isProcessingReadUpdate = false
|
|
||||||
this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
this.isProcessingReadUpdate = false
|
|
||||||
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
startStream() {
|
|
||||||
this.$eventBus.$emit('play-audiobook', this.book.id)
|
|
||||||
},
|
|
||||||
editClick() {
|
|
||||||
this.$emit('edit', this.book)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -7,13 +7,16 @@
|
|||||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
|
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loaded && !shelves.length && isRootUser" class="w-full flex flex-col items-center justify-center py-12">
|
<div v-if="loaded && !shelves.length && isRootUser && !search" class="w-full flex flex-col items-center justify-center py-12">
|
||||||
<p class="text-center text-2xl font-book mb-4 py-4">Audiobookshelf is empty!</p>
|
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
||||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
|
||||||
|
<p class="text-center text-xl font-book py-4">No results for query</p>
|
||||||
|
</div>
|
||||||
<div v-else class="w-full flex flex-col items-center">
|
<div v-else class="w-full flex flex-col items-center">
|
||||||
<template v-for="(shelf, index) in shelves">
|
<template v-for="(shelf, index) in shelves">
|
||||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
@@ -50,6 +53,9 @@ export default {
|
|||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
|
libraryName() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||||
|
},
|
||||||
bookCoverWidth() {
|
bookCoverWidth() {
|
||||||
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||||
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
||||||
@@ -85,7 +91,7 @@ export default {
|
|||||||
},
|
},
|
||||||
async fetchCategories() {
|
async fetchCategories() {
|
||||||
var categories = await this.$axios
|
var categories = await this.$axios
|
||||||
.$get(`/api/libraries/${this.currentLibraryId}/categories?minified=1`)
|
.$get(`/api/libraries/${this.currentLibraryId}/personalized`)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
return data
|
return data
|
||||||
})
|
})
|
||||||
@@ -97,53 +103,61 @@ export default {
|
|||||||
},
|
},
|
||||||
async setShelvesFromSearch() {
|
async setShelvesFromSearch() {
|
||||||
var shelves = []
|
var shelves = []
|
||||||
if (this.results.audiobooks) {
|
if (this.results.books && this.results.books.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'audiobooks',
|
id: 'books',
|
||||||
label: 'Books',
|
label: 'Books',
|
||||||
type: 'books',
|
type: 'book',
|
||||||
entities: this.results.audiobooks.map((ab) => ab.audiobook)
|
entities: this.results.books.map((res) => res.libraryItem)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.results.series) {
|
if (this.results.podcasts && this.results.podcasts.length) {
|
||||||
|
shelves.push({
|
||||||
|
id: 'podcasts',
|
||||||
|
label: 'Podcasts',
|
||||||
|
type: 'podcast',
|
||||||
|
entities: this.results.podcasts.map((res) => res.libraryItem)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.results.series && this.results.series.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'series',
|
id: 'series',
|
||||||
label: 'Series',
|
label: 'Series',
|
||||||
type: 'series',
|
type: 'series',
|
||||||
entities: this.results.series.map((seriesObj) => {
|
entities: this.results.series.map((seriesObj) => {
|
||||||
return {
|
return {
|
||||||
name: seriesObj.series,
|
name: seriesObj.series.name,
|
||||||
books: seriesObj.audiobooks,
|
series: seriesObj.series,
|
||||||
|
books: seriesObj.books,
|
||||||
type: 'series'
|
type: 'series'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.results.tags) {
|
if (this.results.tags && this.results.tags.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'tags',
|
id: 'tags',
|
||||||
label: 'Tags',
|
label: 'Tags',
|
||||||
type: 'tags',
|
type: 'tags',
|
||||||
entities: this.results.tags.map((tagObj) => {
|
entities: this.results.tags.map((tagObj) => {
|
||||||
return {
|
return {
|
||||||
name: tagObj.tag,
|
name: tagObj.name,
|
||||||
books: tagObj.audiobooks,
|
books: tagObj.books || [],
|
||||||
type: 'tags'
|
type: 'tags'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.results.authors) {
|
if (this.results.authors && this.results.authors.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'authors',
|
id: 'authors',
|
||||||
label: 'Authors',
|
label: 'Authors',
|
||||||
type: 'authors',
|
type: 'authors',
|
||||||
entities: this.results.authors.map((a) => {
|
entities: this.results.authors.map((a) => {
|
||||||
return {
|
return {
|
||||||
id: a.author,
|
...a,
|
||||||
name: a.author,
|
|
||||||
numBooks: a.numBooks,
|
|
||||||
type: 'author'
|
type: 'author'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -153,74 +167,98 @@ export default {
|
|||||||
},
|
},
|
||||||
settingsUpdated(settings) {},
|
settingsUpdated(settings) {},
|
||||||
scan() {
|
scan() {
|
||||||
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
|
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
||||||
},
|
},
|
||||||
audiobookAdded(audiobook) {
|
libraryItemAdded(libraryItem) {
|
||||||
console.log('Audiobook added', audiobook)
|
console.log('libraryItem added', libraryItem)
|
||||||
// TODO: Check if audiobook would be on this shelf
|
// TODO: Check if libraryItem would be on this shelf
|
||||||
if (!this.search) {
|
if (!this.search) {
|
||||||
this.fetchCategories()
|
this.fetchCategories()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
audiobookUpdated(audiobook) {
|
libraryItemUpdated(libraryItem) {
|
||||||
console.log('Audiobook updated', audiobook)
|
console.log('libraryItem updated', libraryItem)
|
||||||
this.shelves.forEach((shelf) => {
|
this.shelves.forEach((shelf) => {
|
||||||
if (shelf.type === 'books') {
|
if (shelf.type == 'book' || shelf.type == 'podcast') {
|
||||||
shelf.entities = shelf.entities.map((ent) => {
|
shelf.entities = shelf.entities.map((ent) => {
|
||||||
if (ent.id === audiobook.id) {
|
if (ent.id === libraryItem.id) {
|
||||||
return audiobook
|
return libraryItem
|
||||||
}
|
}
|
||||||
return ent
|
return ent
|
||||||
})
|
})
|
||||||
} else if (shelf.type === 'series') {
|
} else if (shelf.type === 'series') {
|
||||||
shelf.entities.forEach((ent) => {
|
shelf.entities.forEach((ent) => {
|
||||||
ent.books = ent.books.map((book) => {
|
ent.books = ent.books.map((book) => {
|
||||||
if (book.id === audiobook.id) return audiobook
|
if (book.id === libraryItem.id) return libraryItem
|
||||||
return book
|
return book
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
removeBookFromShelf(audiobook) {
|
removeBookFromShelf(libraryItem) {
|
||||||
this.shelves.forEach((shelf) => {
|
this.shelves.forEach((shelf) => {
|
||||||
if (shelf.type === 'books') {
|
if (shelf.type == 'book' || shelf.type == 'podcast') {
|
||||||
shelf.entities = shelf.entities.filter((ent) => {
|
shelf.entities = shelf.entities.filter((ent) => {
|
||||||
return ent.id !== audiobook.id
|
return ent.id !== libraryItem.id
|
||||||
})
|
})
|
||||||
} else if (shelf.type === 'series') {
|
} else if (shelf.type === 'series') {
|
||||||
shelf.entities.forEach((ent) => {
|
shelf.entities.forEach((ent) => {
|
||||||
ent.books = ent.books.filter((book) => {
|
ent.books = ent.books.filter((book) => {
|
||||||
return book.id !== audiobook.id
|
return book.id !== libraryItem.id
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
audiobookRemoved(audiobook) {
|
libraryItemRemoved(libraryItem) {
|
||||||
this.removeBookFromShelf(audiobook)
|
this.removeBookFromShelf(libraryItem)
|
||||||
},
|
},
|
||||||
audiobooksAdded(audiobooks) {
|
libraryItemsAdded(libraryItems) {
|
||||||
console.log('audiobooks added', audiobooks)
|
console.log('libraryItems added', libraryItems)
|
||||||
// TODO: Check if audiobook would be on this shelf
|
// TODO: Check if audiobook would be on this shelf
|
||||||
if (!this.search) {
|
if (!this.search) {
|
||||||
this.fetchCategories()
|
this.fetchCategories()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
audiobooksUpdated(audiobooks) {
|
libraryItemsUpdated(items) {
|
||||||
audiobooks.forEach((ab) => {
|
items.forEach((li) => {
|
||||||
this.audiobookUpdated(ab)
|
this.libraryItemUpdated(li)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
authorUpdated(author) {
|
||||||
|
this.shelves.forEach((shelf) => {
|
||||||
|
if (shelf.type == 'authors') {
|
||||||
|
shelf.entities = shelf.entities.map((ent) => {
|
||||||
|
if (ent.id === author.id) {
|
||||||
|
return {
|
||||||
|
...ent,
|
||||||
|
...author
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
authorRemoved(author) {
|
||||||
|
this.shelves.forEach((shelf) => {
|
||||||
|
if (shelf.type == 'authors') {
|
||||||
|
shelf.entities = shelf.entities.filter((ent) => ent.id != author.id)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
initListeners() {
|
initListeners() {
|
||||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
this.$root.socket.on('audiobook_updated', this.audiobookUpdated)
|
this.$root.socket.on('author_updated', this.authorUpdated)
|
||||||
this.$root.socket.on('audiobook_added', this.audiobookAdded)
|
this.$root.socket.on('author_removed', this.authorRemoved)
|
||||||
this.$root.socket.on('audiobook_removed', this.audiobookRemoved)
|
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||||
this.$root.socket.on('audiobooks_updated', this.audiobooksUpdated)
|
this.$root.socket.on('item_added', this.libraryItemAdded)
|
||||||
this.$root.socket.on('audiobooks_added', this.audiobooksAdded)
|
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
||||||
|
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
||||||
|
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
||||||
} else {
|
} else {
|
||||||
console.error('Error socket not initialized')
|
console.error('Error socket not initialized')
|
||||||
}
|
}
|
||||||
@@ -229,11 +267,13 @@ export default {
|
|||||||
this.$store.commit('user/removeSettingsListener', 'bookshelf')
|
this.$store.commit('user/removeSettingsListener', 'bookshelf')
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
this.$root.socket.off('audiobook_updated', this.audiobookUpdated)
|
this.$root.socket.off('author_updated', this.authorUpdated)
|
||||||
this.$root.socket.off('audiobook_added', this.audiobookAdded)
|
this.$root.socket.off('author_removed', this.authorRemoved)
|
||||||
this.$root.socket.off('audiobook_removed', this.audiobookRemoved)
|
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||||
this.$root.socket.off('audiobooks_updated', this.audiobooksUpdated)
|
this.$root.socket.off('item_added', this.libraryItemAdded)
|
||||||
this.$root.socket.off('audiobooks_added', this.audiobooksAdded)
|
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
||||||
|
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
||||||
|
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
||||||
} else {
|
} else {
|
||||||
console.error('Error socket not initialized')
|
console.error('Error socket not initialized')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,14 @@
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div ref="shelf" class="w-full max-w-full categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
|
<div ref="shelf" class="w-full max-w-full categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
|
||||||
<div class="w-full h-full pt-6">
|
<div class="w-full h-full pt-6">
|
||||||
<div v-if="shelf.type === 'books'" class="flex items-center">
|
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
|
||||||
<template v-for="(entity, index) in shelf.entities">
|
<template v-for="(entity, index) in shelf.entities">
|
||||||
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectBook" @edit="editBook" />
|
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editBook" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
||||||
|
<template v-for="(entity, index) in shelf.entities">
|
||||||
|
<cards-lazy-book-card :key="entity.recentEpisode.id" :ref="`shelf-episode-${entity.recentEpisode.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editEpisode" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'series'" class="flex items-center">
|
<div v-if="shelf.type === 'series'" class="flex items-center">
|
||||||
@@ -21,8 +26,8 @@
|
|||||||
</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">
|
||||||
<nuxt-link :key="entity.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(entity.name)}`">
|
<nuxt-link :key="entity.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(entity.id)}`">
|
||||||
<cards-author-card :width="bookCoverWidth" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" />
|
<cards-author-card :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,6 +48,7 @@
|
|||||||
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollRight">
|
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollRight">
|
||||||
<span class="material-icons text-6xl text-white">chevron_right</span>
|
<span class="material-icons text-6xl text-white">chevron_right</span>
|
||||||
</div>
|
</div>
|
||||||
|
<modals-authors-edit-modal v-model="showAuthorModal" :author="selectedAuthor" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -64,12 +70,9 @@ export default {
|
|||||||
canScrollLeft: false,
|
canScrollLeft: false,
|
||||||
isScrolling: false,
|
isScrolling: false,
|
||||||
scrollTimer: null,
|
scrollTimer: null,
|
||||||
updateTimer: null
|
updateTimer: null,
|
||||||
}
|
showAuthorModal: false,
|
||||||
},
|
selectedAuthor: null
|
||||||
watch: {
|
|
||||||
isSelectionMode(newVal) {
|
|
||||||
this.updateSelectionMode(newVal)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -79,9 +82,6 @@ export default {
|
|||||||
shelfHeight() {
|
shelfHeight() {
|
||||||
return this.bookCoverHeight + 48
|
return this.bookCoverHeight + 48
|
||||||
},
|
},
|
||||||
userAudiobooks() {
|
|
||||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
|
||||||
},
|
|
||||||
paddingLeft() {
|
paddingLeft() {
|
||||||
if (window.innerWidth < 768) return 1
|
if (window.innerWidth < 768) return 1
|
||||||
return 2.5
|
return 2.5
|
||||||
@@ -90,29 +90,55 @@ export default {
|
|||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
isSelectionMode() {
|
isSelectionMode() {
|
||||||
return this.$store.getters['getNumAudiobooksSelected'] > 0
|
return this.$store.getters['getNumLibraryItemsSelected'] > 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
clearSelectedEntities() {
|
||||||
|
this.updateSelectionMode(false)
|
||||||
|
},
|
||||||
|
editAuthor(author) {
|
||||||
|
this.selectedAuthor = author
|
||||||
|
this.showAuthorModal = true
|
||||||
|
},
|
||||||
editBook(audiobook) {
|
editBook(audiobook) {
|
||||||
var bookIds = this.shelf.entities.map((e) => e.id)
|
var bookIds = this.shelf.entities.map((e) => e.id)
|
||||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||||
this.$store.commit('showEditModal', audiobook)
|
this.$store.commit('showEditModal', audiobook)
|
||||||
},
|
},
|
||||||
|
editEpisode({ libraryItem, episode }) {
|
||||||
|
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||||
|
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||||
|
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||||
|
},
|
||||||
updateSelectionMode(val) {
|
updateSelectionMode(val) {
|
||||||
var selectedAudiobooks = this.$store.state.selectedAudiobooks
|
var selectedLibraryItems = this.$store.state.selectedLibraryItems
|
||||||
if (this.shelf.type === 'books') {
|
if (this.shelf.type === 'book' || this.shelf.type === 'podcast') {
|
||||||
this.shelf.entities.forEach((ent) => {
|
this.shelf.entities.forEach((ent) => {
|
||||||
var component = this.$refs[`shelf-book-${ent.id}`]
|
var component = this.$refs[`shelf-book-${ent.id}`]
|
||||||
if (!component || !component.length) return
|
if (!component || !component.length) return
|
||||||
component = component[0]
|
component = component[0]
|
||||||
component.setSelectionMode(val)
|
component.setSelectionMode(val)
|
||||||
component.selected = selectedAudiobooks.includes(ent.id)
|
component.selected = selectedLibraryItems.includes(ent.id)
|
||||||
|
})
|
||||||
|
} else if (this.shelf.type === 'episode') {
|
||||||
|
this.shelf.entities.forEach((ent) => {
|
||||||
|
var component = this.$refs[`shelf-episode-${ent.recentEpisode.id}`]
|
||||||
|
if (!component || !component.length) return
|
||||||
|
component = component[0]
|
||||||
|
component.setSelectionMode(val)
|
||||||
|
component.selected = selectedLibraryItems.includes(ent.id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
selectBook(audiobook) {
|
selectItem(libraryItem) {
|
||||||
this.$store.commit('toggleAudiobookSelected', audiobook.id)
|
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$eventBus.$emit('item-selected', libraryItem)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
itemSelectedEvt() {
|
||||||
|
this.updateSelectionMode(this.isSelectionMode)
|
||||||
},
|
},
|
||||||
scrolled() {
|
scrolled() {
|
||||||
clearTimeout(this.scrollTimer)
|
clearTimeout(this.scrollTimer)
|
||||||
@@ -156,6 +182,14 @@ export default {
|
|||||||
this.canScrollLeft = false
|
this.canScrollLeft = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||||
|
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||||
|
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -12,22 +12,34 @@
|
|||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 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-30 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||||
<template v-if="page !== 'search' && !isHome">
|
<template v-if="page !== 'search' && page !== 'podcast-search' && !isHome">
|
||||||
<p v-if="!selectedSeries" class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p>
|
<p v-if="!selectedSeries" class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p>
|
||||||
<div v-else class="items-center hidden md:flex">
|
<div v-else class="items-center hidden md:flex w-full">
|
||||||
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||||
<span class="material-icons text-2xl text-white">west</span>
|
<span class="material-icons text-2xl text-white">west</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="pl-4 font-book text-lg">
|
<p class="pl-4 font-book text-lg">
|
||||||
{{ selectedSeries }}
|
{{ seriesName }}
|
||||||
</p>
|
</p>
|
||||||
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
|
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
|
||||||
<span class="font-mono">{{ numShowing }}</span>
|
<span class="font-mono">{{ numShowing }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn color="primary" small :loading="processingSeries" class="flex items-center" @click="markSeriesFinished">
|
||||||
|
<div class="h-5 w-5">
|
||||||
|
<svg v-if="isSeriesFinished" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
|
||||||
|
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="pl-2"> Mark Series {{ isSeriesFinished ? 'Not Finished' : 'Finished' }}</span></ui-btn
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow hidden sm:inline-block" />
|
<div class="flex-grow hidden sm:inline-block" />
|
||||||
|
|
||||||
<ui-checkbox v-show="showSortFilters" v-model="settings.collapseSeries" label="Collapse Series" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
|
<ui-checkbox v-show="showSortFilters && !isPodcast" v-model="settings.collapseSeries" label="Collapse Series" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
|
||||||
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
|
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
|
||||||
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
|
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
|
||||||
<!-- <div v-show="showSortFilters" class="h-7 ml-4 flex border border-white border-opacity-25 rounded-md">
|
<!-- <div v-show="showSortFilters" class="h-7 ml-4 flex border border-white border-opacity-25 rounded-md">
|
||||||
@@ -38,6 +50,8 @@
|
|||||||
<span class="material-icons" style="font-size: 1.4rem">view_list</span>
|
<span class="material-icons" style="font-size: 1.4rem">view_list</span>
|
||||||
</div>
|
</div>
|
||||||
</div> -->
|
</div> -->
|
||||||
|
|
||||||
|
<ui-btn v-if="isIssuesFilter && userCanDelete" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">Remove All {{ numShowing }} {{ entityName }}</ui-btn>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="page === 'search'">
|
<template v-else-if="page === 'search'">
|
||||||
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||||
@@ -56,7 +70,10 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
page: String,
|
page: String,
|
||||||
isHome: Boolean,
|
isHome: Boolean,
|
||||||
selectedSeries: String,
|
selectedSeries: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
searchQuery: String,
|
searchQuery: String,
|
||||||
viewMode: String
|
viewMode: String
|
||||||
},
|
},
|
||||||
@@ -66,10 +83,18 @@ export default {
|
|||||||
hasInit: false,
|
hasInit: false,
|
||||||
totalEntities: 0,
|
totalEntities: 0,
|
||||||
keywordFilter: null,
|
keywordFilter: null,
|
||||||
keywordTimeout: null
|
keywordTimeout: null,
|
||||||
|
processingSeries: false,
|
||||||
|
processingIssues: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
userCanDelete() {
|
||||||
|
return this.$store.getters['user/getUserCanDelete']
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||||
|
},
|
||||||
isGridMode() {
|
isGridMode() {
|
||||||
return this.viewMode === 'grid'
|
return this.viewMode === 'grid'
|
||||||
},
|
},
|
||||||
@@ -80,6 +105,7 @@ export default {
|
|||||||
return this.totalEntities
|
return this.totalEntities
|
||||||
},
|
},
|
||||||
entityName() {
|
entityName() {
|
||||||
|
if (this.isPodcast) return 'Podcasts'
|
||||||
if (!this.page) return 'Books'
|
if (!this.page) return 'Books'
|
||||||
if (this.page === 'series') return 'Series'
|
if (this.page === 'series') return 'Series'
|
||||||
if (this.page === 'collections') return 'Collections'
|
if (this.page === 'collections') return 'Collections'
|
||||||
@@ -99,9 +125,68 @@ export default {
|
|||||||
},
|
},
|
||||||
showLibrary() {
|
showLibrary() {
|
||||||
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
||||||
|
},
|
||||||
|
seriesName() {
|
||||||
|
return this.selectedSeries ? this.selectedSeries.name : null
|
||||||
|
},
|
||||||
|
seriesProgress() {
|
||||||
|
return this.selectedSeries ? this.selectedSeries.progress : null
|
||||||
|
},
|
||||||
|
seriesLibraryItemIds() {
|
||||||
|
if (!this.seriesProgress) return []
|
||||||
|
return this.seriesProgress.libraryItemIds || []
|
||||||
|
},
|
||||||
|
isSeriesFinished() {
|
||||||
|
return this.seriesProgress && !!this.seriesProgress.isFinished
|
||||||
|
},
|
||||||
|
filterBy() {
|
||||||
|
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||||
|
},
|
||||||
|
isIssuesFilter() {
|
||||||
|
return this.filterBy === 'issues'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
removeAllIssues() {
|
||||||
|
if (confirm(`Are you sure you want to remove all library items with issues?\n\nNote: This will not delete any files`)) {
|
||||||
|
this.processingIssues = true
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/libraries/${this.currentLibraryId}/issues`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Removed library items with issues')
|
||||||
|
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
|
||||||
|
this.processingIssues = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to remove library items with issues', error)
|
||||||
|
this.$toast.error('Failed to remove library items with issues')
|
||||||
|
this.processingIssues = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
markSeriesFinished() {
|
||||||
|
var newIsFinished = !this.isSeriesFinished
|
||||||
|
this.processingSeries = true
|
||||||
|
var updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
|
||||||
|
return {
|
||||||
|
id: lid,
|
||||||
|
isFinished: newIsFinished
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log('Progress payloads', updateProgressPayloads)
|
||||||
|
this.$axios
|
||||||
|
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Series update success')
|
||||||
|
this.selectedSeries.progress.isFinished = newIsFinished
|
||||||
|
this.processingSeries = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.$toast.error('Series update failed')
|
||||||
|
console.error('Failed to batch update read/not read', error)
|
||||||
|
this.processingSeries = false
|
||||||
|
})
|
||||||
|
},
|
||||||
searchBackArrow() {
|
searchBackArrow() {
|
||||||
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
|
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<div class="w-full h-10 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamAudiobook && isMobileLandscape ? '300px' : '65px' }">
|
<div class="w-full h-10 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
|
||||||
<p class="font-mono text-sm">v{{ $config.version }}</p>
|
<p class="font-mono text-sm">v{{ $config.version }}</p>
|
||||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-sm">Update available: {{ latestVersion }}</a>
|
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-sm">Update available: {{ latestVersion }}</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,8 +109,8 @@ export default {
|
|||||||
githubTagUrl() {
|
githubTagUrl() {
|
||||||
return this.versionData.githubTagUrl
|
return this.versionData.githubTagUrl
|
||||||
},
|
},
|
||||||
streamAudiobook() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamAudiobook
|
return this.$store.state.streamLibraryItem
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -7,15 +7,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="initialized && !totalShelves && !hasFilter && isRootUser && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
|
<div v-if="initialized && !totalShelves && !hasFilter && isRootUser && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
|
||||||
<p class="text-center text-2xl font-book mb-4 py-4">Audiobookshelf is empty!</p>
|
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
||||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
<ui-btn color="success" class="w-52" @click="scan">Scan Library</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 class="flex justify-center mt-2">
|
<!-- Clear filter only available on Library bookshelf -->
|
||||||
|
<div v-if="entityName === 'books'" class="flex justify-center mt-2">
|
||||||
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">Clear Filter</ui-btn>
|
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">Clear Filter</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,7 +61,6 @@ export default {
|
|||||||
totalShelves: 0,
|
totalShelves: 0,
|
||||||
bookshelfMarginLeft: 0,
|
bookshelfMarginLeft: 0,
|
||||||
isSelectionMode: false,
|
isSelectionMode: false,
|
||||||
isSelectAll: false,
|
|
||||||
currentSFQueryString: null,
|
currentSFQueryString: null,
|
||||||
pendingReset: false,
|
pendingReset: false,
|
||||||
keywordFilter: null,
|
keywordFilter: null,
|
||||||
@@ -85,10 +85,16 @@ export default {
|
|||||||
showExperimentalFeatures() {
|
showExperimentalFeatures() {
|
||||||
return this.$store.state.showExperimentalFeatures
|
return this.$store.state.showExperimentalFeatures
|
||||||
},
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||||
|
},
|
||||||
emptyMessage() {
|
emptyMessage() {
|
||||||
if (this.page === 'series') return `You have no series`
|
if (this.page === 'series') return 'You have no series'
|
||||||
if (this.page === 'collections') return "You haven't made any collections yet"
|
if (this.page === 'collections') return "You haven't made any collections yet"
|
||||||
if (this.hasFilter) return `No Results for filter "${this.filterValue}"`
|
if (this.hasFilter) {
|
||||||
|
if (this.filterName === 'Issues') return 'No Issues'
|
||||||
|
return `No Results for filter "${this.filterName}: ${this.filterValue}"`
|
||||||
|
}
|
||||||
return 'No results'
|
return 'No results'
|
||||||
},
|
},
|
||||||
entityName() {
|
entityName() {
|
||||||
@@ -143,6 +149,9 @@ export default {
|
|||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
|
libraryName() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||||
|
},
|
||||||
isEntityBook() {
|
isEntityBook() {
|
||||||
return this.entityName === 'series-books' || this.entityName === 'books'
|
return this.entityName === 'series-books' || this.entityName === 'books'
|
||||||
},
|
},
|
||||||
@@ -183,8 +192,8 @@ export default {
|
|||||||
// Includes margin
|
// Includes margin
|
||||||
return this.entityWidth + 24
|
return this.entityWidth + 24
|
||||||
},
|
},
|
||||||
selectedAudiobooks() {
|
selectedLibraryItems() {
|
||||||
return this.$store.state.selectedAudiobooks || []
|
return this.$store.state.selectedLibraryItems || []
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||||
@@ -210,13 +219,12 @@ export default {
|
|||||||
clearSelectedEntities() {
|
clearSelectedEntities() {
|
||||||
this.updateBookSelectionMode(false)
|
this.updateBookSelectionMode(false)
|
||||||
this.isSelectionMode = false
|
this.isSelectionMode = false
|
||||||
this.isSelectAll = false
|
|
||||||
},
|
},
|
||||||
selectEntity(entity) {
|
selectEntity(entity) {
|
||||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||||
this.$store.commit('toggleAudiobookSelected', entity.id)
|
this.$store.commit('toggleLibraryItemSelected', entity.id)
|
||||||
|
|
||||||
var newIsSelectionMode = !!this.selectedAudiobooks.length
|
var newIsSelectionMode = !!this.selectedLibraryItems.length
|
||||||
if (this.isSelectionMode !== newIsSelectionMode) {
|
if (this.isSelectionMode !== newIsSelectionMode) {
|
||||||
this.isSelectionMode = newIsSelectionMode
|
this.isSelectionMode = newIsSelectionMode
|
||||||
this.updateBookSelectionMode(newIsSelectionMode)
|
this.updateBookSelectionMode(newIsSelectionMode)
|
||||||
@@ -239,7 +247,7 @@ export default {
|
|||||||
this.currentSFQueryString = this.buildSearchParams()
|
this.currentSFQueryString = this.buildSearchParams()
|
||||||
}
|
}
|
||||||
|
|
||||||
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `books/all` : this.entityName
|
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
|
||||||
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||||
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
|
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
|
||||||
|
|
||||||
@@ -300,11 +308,11 @@ export default {
|
|||||||
var firstBookPage = Math.floor(firstBookIndex / this.booksPerFetch)
|
var firstBookPage = Math.floor(firstBookIndex / this.booksPerFetch)
|
||||||
var lastBookPage = Math.floor(lastBookIndex / this.booksPerFetch)
|
var lastBookPage = Math.floor(lastBookIndex / this.booksPerFetch)
|
||||||
if (!this.pagesLoaded[firstBookPage]) {
|
if (!this.pagesLoaded[firstBookPage]) {
|
||||||
console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex)
|
// console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex)
|
||||||
this.loadPage(firstBookPage)
|
this.loadPage(firstBookPage)
|
||||||
}
|
}
|
||||||
if (!this.pagesLoaded[lastBookPage]) {
|
if (!this.pagesLoaded[lastBookPage]) {
|
||||||
console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
|
// console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
|
||||||
this.loadPage(lastBookPage)
|
this.loadPage(lastBookPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,7 +340,6 @@ export default {
|
|||||||
this.totalEntities = 0
|
this.totalEntities = 0
|
||||||
this.currentPage = 0
|
this.currentPage = 0
|
||||||
this.isSelectionMode = false
|
this.isSelectionMode = false
|
||||||
this.isSelectAll = false
|
|
||||||
this.initialized = false
|
this.initialized = false
|
||||||
|
|
||||||
this.initSizeData()
|
this.initSizeData()
|
||||||
@@ -374,9 +381,7 @@ export default {
|
|||||||
|
|
||||||
let searchParams = new URLSearchParams()
|
let searchParams = new URLSearchParams()
|
||||||
if (this.page === 'series-books') {
|
if (this.page === 'series-books') {
|
||||||
searchParams.set('filter', `series.${this.seriesId}`)
|
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
|
||||||
searchParams.set('sort', 'book.volumeNumber')
|
|
||||||
searchParams.set('desc', 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)
|
||||||
@@ -385,7 +390,7 @@ export default {
|
|||||||
searchParams.set('sort', this.orderBy)
|
searchParams.set('sort', this.orderBy)
|
||||||
searchParams.set('desc', this.orderDesc ? 1 : 0)
|
searchParams.set('desc', this.orderDesc ? 1 : 0)
|
||||||
}
|
}
|
||||||
if (this.collapseSeries) {
|
if (this.collapseSeries && !this.isPodcast) {
|
||||||
searchParams.set('collapseseries', 1)
|
searchParams.set('collapseseries', 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -425,44 +430,71 @@ export default {
|
|||||||
this.handleScroll(scrollTop)
|
this.handleScroll(scrollTop)
|
||||||
// }, 250)
|
// }, 250)
|
||||||
},
|
},
|
||||||
audiobookAdded(audiobook) {
|
libraryItemAdded(libraryItem) {
|
||||||
console.log('Audiobook added', audiobook)
|
console.log('libraryItem added', libraryItem)
|
||||||
// TODO: Check if audiobook would be on this shelf
|
// TODO: Check if audiobook would be on this shelf
|
||||||
this.resetEntities()
|
this.resetEntities()
|
||||||
},
|
},
|
||||||
audiobookUpdated(audiobook) {
|
libraryItemUpdated(libraryItem) {
|
||||||
console.log('Audiobook updated', audiobook)
|
console.log('Item updated', libraryItem)
|
||||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id)
|
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||||
if (indexOf >= 0) {
|
if (indexOf >= 0) {
|
||||||
this.entities[indexOf] = audiobook
|
this.entities[indexOf] = libraryItem
|
||||||
if (this.entityComponentRefs[indexOf]) {
|
if (this.entityComponentRefs[indexOf]) {
|
||||||
this.entityComponentRefs[indexOf].setEntity(audiobook)
|
this.entityComponentRefs[indexOf].setEntity(libraryItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
audiobookRemoved(audiobook) {
|
libraryItemRemoved(libraryItem) {
|
||||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id)
|
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||||
if (indexOf >= 0) {
|
if (indexOf >= 0) {
|
||||||
this.entities = this.entities.filter((ent) => ent.id !== audiobook.id)
|
this.entities = this.entities.filter((ent) => ent.id !== libraryItem.id)
|
||||||
this.totalEntities = this.entities.length
|
this.totalEntities = this.entities.length
|
||||||
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
||||||
this.remountEntities()
|
this.executeRebuild()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
audiobooksAdded(audiobooks) {
|
libraryItemsAdded(libraryItems) {
|
||||||
console.log('audiobooks added', audiobooks)
|
console.log('items added', libraryItems)
|
||||||
// TODO: Check if audiobook would be on this shelf
|
// TODO: Check if audiobook would be on this shelf
|
||||||
this.resetEntities()
|
this.resetEntities()
|
||||||
},
|
},
|
||||||
audiobooksUpdated(audiobooks) {
|
libraryItemsUpdated(libraryItems) {
|
||||||
audiobooks.forEach((ab) => {
|
libraryItems.forEach((ab) => {
|
||||||
this.audiobookUpdated(ab)
|
this.libraryItemUpdated(ab)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
collectionAdded(collection) {
|
||||||
|
if (this.entityName !== 'collections') return
|
||||||
|
console.log(`[LazyBookshelf] collectionAdded ${collection.id}`, collection)
|
||||||
|
this.resetEntities()
|
||||||
|
},
|
||||||
|
collectionUpdated(collection) {
|
||||||
|
if (this.entityName !== 'collections') return
|
||||||
|
console.log(`[LazyBookshelf] collectionUpdated ${collection.id}`, collection)
|
||||||
|
var indexOf = this.entities.findIndex((ent) => ent && ent.id === collection.id)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
this.entities[indexOf] = collection
|
||||||
|
if (this.entityComponentRefs[indexOf]) {
|
||||||
|
this.entityComponentRefs[indexOf].setEntity(collection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
collectionRemoved(collection) {
|
||||||
|
if (this.entityName !== 'collections') return
|
||||||
|
console.log(`[LazyBookshelf] collectionRemoved ${collection.id}`, collection)
|
||||||
|
var indexOf = this.entities.findIndex((ent) => ent && ent.id === collection.id)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
this.entities = this.entities.filter((ent) => ent.id !== collection.id)
|
||||||
|
this.totalEntities = this.entities.length
|
||||||
|
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
||||||
|
this.executeRebuild()
|
||||||
|
}
|
||||||
|
},
|
||||||
initSizeData(_bookshelf) {
|
initSizeData(_bookshelf) {
|
||||||
var bookshelf = _bookshelf || document.getElementById('bookshelf')
|
var bookshelf = _bookshelf || document.getElementById('bookshelf')
|
||||||
if (!bookshelf) {
|
if (!bookshelf) {
|
||||||
@@ -525,11 +557,14 @@ export default {
|
|||||||
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
|
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
this.$root.socket.on('audiobook_updated', this.audiobookUpdated)
|
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||||
this.$root.socket.on('audiobook_added', this.audiobookAdded)
|
this.$root.socket.on('item_added', this.libraryItemAdded)
|
||||||
this.$root.socket.on('audiobook_removed', this.audiobookRemoved)
|
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
||||||
this.$root.socket.on('audiobooks_updated', this.audiobooksUpdated)
|
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
||||||
this.$root.socket.on('audiobooks_added', this.audiobooksAdded)
|
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
||||||
|
this.$root.socket.on('collection_added', this.collectionAdded)
|
||||||
|
this.$root.socket.on('collection_updated', this.collectionUpdated)
|
||||||
|
this.$root.socket.on('collection_removed', this.collectionRemoved)
|
||||||
} else {
|
} else {
|
||||||
console.error('Bookshelf - Socket not initialized')
|
console.error('Bookshelf - Socket not initialized')
|
||||||
}
|
}
|
||||||
@@ -546,11 +581,14 @@ export default {
|
|||||||
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
|
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
this.$root.socket.off('audiobook_updated', this.audiobookUpdated)
|
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||||
this.$root.socket.off('audiobook_added', this.audiobookAdded)
|
this.$root.socket.off('item_added', this.libraryItemAdded)
|
||||||
this.$root.socket.off('audiobook_removed', this.audiobookRemoved)
|
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
||||||
this.$root.socket.off('audiobooks_updated', this.audiobooksUpdated)
|
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
||||||
this.$root.socket.off('audiobooks_added', this.audiobooksAdded)
|
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
||||||
|
this.$root.socket.off('collection_added', this.collectionAdded)
|
||||||
|
this.$root.socket.off('collection_updated', this.collectionUpdated)
|
||||||
|
this.$root.socket.off('collection_removed', this.collectionRemoved)
|
||||||
} else {
|
} else {
|
||||||
console.error('Bookshelf - Socket not initialized')
|
console.error('Bookshelf - Socket not initialized')
|
||||||
}
|
}
|
||||||
@@ -563,7 +601,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
scan() {
|
scan() {
|
||||||
this.$root.socket.emit('scan', this.currentLibraryId)
|
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<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 :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<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 :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="!isPodcastLibrary" :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-icons-outlined">collections_bookmark</span>
|
<span class="material-icons-outlined">collections_bookmark</span>
|
||||||
|
|
||||||
<p class="font-book pt-1.5" style="font-size: 0.9rem">Collections</p>
|
<p class="font-book pt-1.5" style="font-size: 0.9rem">Collections</p>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<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="showExperimentalFeatures" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="!isPodcastLibrary" :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'">
|
||||||
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
@@ -52,6 +52,14 @@
|
|||||||
<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="isPodcastLibrary" :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'">
|
||||||
|
<icons-podcast-svg class="w-6 h-6" />
|
||||||
|
|
||||||
|
<p class="font-book pt-1.5" style="font-size: 0.9rem">Search</p>
|
||||||
|
|
||||||
|
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
|
<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-icons text-2xl">warning</span>
|
<span class="material-icons text-2xl">warning</span>
|
||||||
|
|
||||||
@@ -62,36 +70,6 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<!-- <nuxt-link to="/library/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'">
|
|
||||||
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<p class="font-book pt-1.5" style="font-size: 0.8rem">Collections</p>
|
|
||||||
|
|
||||||
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
|
||||||
</nuxt-link> -->
|
|
||||||
|
|
||||||
<!-- <nuxt-link to="/library/tags" 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 === 'tags' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<p class="font-book pt-1.5" style="font-size: 0.8rem">Tags</p>
|
|
||||||
|
|
||||||
<div v-show="paramId === 'tags'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
|
||||||
</nuxt-link> -->
|
|
||||||
|
|
||||||
<!-- <nuxt-link to="/library/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="paramId === 'authors' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<p class="font-book pt-1.5" style="font-size: 0.8rem">Authors</p>
|
|
||||||
|
|
||||||
<div v-show="paramId === 'authors'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
|
||||||
</nuxt-link> -->
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -110,6 +88,15 @@ export default {
|
|||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
|
currentLibraryMediaType() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
|
},
|
||||||
|
isPodcastLibrary() {
|
||||||
|
return this.currentLibraryMediaType === 'podcast'
|
||||||
|
},
|
||||||
|
isPodcastSearchPage() {
|
||||||
|
return this.$route.name === 'library-library-podcast-search'
|
||||||
|
},
|
||||||
homePage() {
|
homePage() {
|
||||||
return this.$route.name === 'library-library'
|
return this.$route.name === 'library-library'
|
||||||
},
|
},
|
||||||
@@ -125,6 +112,9 @@ export default {
|
|||||||
showLibrary() {
|
showLibrary() {
|
||||||
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
||||||
},
|
},
|
||||||
|
filterBy() {
|
||||||
|
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||||
|
},
|
||||||
showingIssues() {
|
showingIssues() {
|
||||||
if (!this.$route.query) return false
|
if (!this.$route.query) return false
|
||||||
return this.libraryBookshelfPage && this.$route.query.filter === 'issues'
|
return this.libraryBookshelfPage && this.$route.query.filter === 'issues'
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
|
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
|
||||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||||
<covers-book-cover :audiobook="streamAudiobook" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<div class="flex items-start pl-24 mb-6 md:mb-0">
|
<div class="flex items-start pl-24 mb-6 md:mb-0">
|
||||||
<div>
|
<div>
|
||||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
|
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<div class="text-gray-400 flex items-center">
|
<div class="text-gray-400 flex items-center">
|
||||||
<span class="material-icons text-sm">person</span>
|
<span class="material-icons text-sm">person</span>
|
||||||
<p v-if="authorFL" class="pl-1.5 text-sm sm:text-base">
|
<p v-if="podcastAuthor">{{ podcastAuthor }}</p>
|
||||||
<nuxt-link v-for="(author, index) in authorsList" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}<span v-if="index < authorsList.length - 1">, </span></nuxt-link>
|
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
|
||||||
|
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p>
|
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,7 +25,6 @@
|
|||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span>
|
<span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<audio-player
|
<audio-player
|
||||||
ref="audioPlayer"
|
ref="audioPlayer"
|
||||||
:chapters="chapters"
|
:chapters="chapters"
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
:bookmarks="bookmarks"
|
:bookmarks="bookmarks"
|
||||||
:sleep-timer-set="sleepTimerSet"
|
:sleep-timer-set="sleepTimerSet"
|
||||||
:sleep-timer-remaining="sleepTimerRemaining"
|
:sleep-timer-remaining="sleepTimerRemaining"
|
||||||
|
:is-podcast="isPodcast"
|
||||||
@playPause="playPause"
|
@playPause="playPause"
|
||||||
@jumpForward="jumpForward"
|
@jumpForward="jumpForward"
|
||||||
@jumpBackward="jumpBackward"
|
@jumpBackward="jumpBackward"
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
@showSleepTimer="showSleepTimerModal = true"
|
@showSleepTimer="showSleepTimerModal = true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @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-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||||
</div>
|
</div>
|
||||||
@@ -60,7 +61,6 @@ export default {
|
|||||||
totalDuration: 0,
|
totalDuration: 0,
|
||||||
showBookmarksModal: false,
|
showBookmarksModal: false,
|
||||||
bookmarkCurrentTime: 0,
|
bookmarkCurrentTime: 0,
|
||||||
bookmarkAudiobookId: null,
|
|
||||||
playerLoading: false,
|
playerLoading: false,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
@@ -68,7 +68,9 @@ export default {
|
|||||||
sleepTimerSet: false,
|
sleepTimerSet: false,
|
||||||
sleepTimerTime: 0,
|
sleepTimerTime: 0,
|
||||||
sleepTimerRemaining: 0,
|
sleepTimerRemaining: 0,
|
||||||
sleepTimer: null
|
sleepTimer: null,
|
||||||
|
displayTitle: null,
|
||||||
|
initialPlaybackRate: 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -89,55 +91,64 @@ export default {
|
|||||||
return -64
|
return -64
|
||||||
},
|
},
|
||||||
cover() {
|
cover() {
|
||||||
if (this.streamAudiobook && this.streamAudiobook.cover) return this.streamAudiobook.cover
|
if (this.media.coverPath) return this.media.coverPath
|
||||||
return 'Logo.png'
|
return 'Logo.png'
|
||||||
},
|
},
|
||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
userAudiobook() {
|
userMediaProgress() {
|
||||||
if (!this.audiobookId) return
|
if (!this.libraryItemId) return
|
||||||
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
|
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||||
},
|
},
|
||||||
userAudiobookCurrentTime() {
|
userItemCurrentTime() {
|
||||||
return this.userAudiobook ? this.userAudiobook.currentTime || 0 : 0
|
return this.userMediaProgress ? this.userMediaProgress.currentTime || 0 : 0
|
||||||
},
|
},
|
||||||
bookmarks() {
|
bookmarks() {
|
||||||
if (!this.userAudiobook) return []
|
if (!this.libraryItemId) return []
|
||||||
return (this.userAudiobook.bookmarks || []).map((bm) => ({ ...bm })).sort((a, b) => a.time - b.time)
|
return this.$store.getters['user/getUserBookmarksForItem'](this.libraryItemId)
|
||||||
},
|
},
|
||||||
streamAudiobook() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamAudiobook
|
return this.$store.state.streamLibraryItem
|
||||||
},
|
},
|
||||||
audiobookId() {
|
libraryItemId() {
|
||||||
return this.streamAudiobook ? this.streamAudiobook.id : null
|
return this.streamLibraryItem ? this.streamLibraryItem.id : null
|
||||||
},
|
},
|
||||||
book() {
|
media() {
|
||||||
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
|
return this.streamLibraryItem ? this.streamLibraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
chapters() {
|
chapters() {
|
||||||
return this.streamAudiobook ? this.streamAudiobook.chapters || [] : []
|
return this.media.chapters || []
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.book.title || 'No Title'
|
if (this.playerHandler.displayTitle) return this.playerHandler.displayTitle
|
||||||
|
return this.mediaMetadata.title || 'No Title'
|
||||||
},
|
},
|
||||||
author() {
|
authors() {
|
||||||
return this.book.author || 'Unknown'
|
return this.mediaMetadata.authors || []
|
||||||
},
|
|
||||||
authorFL() {
|
|
||||||
return this.book.authorFL
|
|
||||||
},
|
|
||||||
authorsList() {
|
|
||||||
return this.authorFL ? this.authorFL.split(', ') : []
|
|
||||||
},
|
},
|
||||||
libraryId() {
|
libraryId() {
|
||||||
return this.streamAudiobook ? this.streamAudiobook.libraryId : null
|
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
|
||||||
},
|
},
|
||||||
totalDurationPretty() {
|
totalDurationPretty() {
|
||||||
return this.$secondsToTimestamp(this.totalDuration)
|
return this.$secondsToTimestamp(this.totalDuration)
|
||||||
|
},
|
||||||
|
podcastAuthor() {
|
||||||
|
if (!this.isPodcast) return null
|
||||||
|
return this.mediaMetadata.author || 'Unknown'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
setPlaying(isPlaying) {
|
||||||
|
this.isPlaying = isPlaying
|
||||||
|
this.$store.commit('setIsPlaying', isPlaying)
|
||||||
|
},
|
||||||
setSleepTimer(seconds) {
|
setSleepTimer(seconds) {
|
||||||
this.sleepTimerSet = true
|
this.sleepTimerSet = true
|
||||||
this.sleepTimerTime = seconds
|
this.sleepTimerTime = seconds
|
||||||
@@ -194,6 +205,7 @@ export default {
|
|||||||
this.playerHandler.setVolume(volume)
|
this.playerHandler.setVolume(volume)
|
||||||
},
|
},
|
||||||
setPlaybackRate(playbackRate) {
|
setPlaybackRate(playbackRate) {
|
||||||
|
this.initialPlaybackRate = playbackRate
|
||||||
this.playerHandler.setPlaybackRate(playbackRate)
|
this.playerHandler.setPlaybackRate(playbackRate)
|
||||||
},
|
},
|
||||||
seek(time) {
|
seek(time) {
|
||||||
@@ -217,7 +229,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
showBookmarks() {
|
showBookmarks() {
|
||||||
this.bookmarkAudiobookId = this.audiobookId
|
|
||||||
this.bookmarkCurrentTime = this.currentTime
|
this.bookmarkCurrentTime = this.currentTime
|
||||||
this.showBookmarksModal = true
|
this.showBookmarksModal = true
|
||||||
},
|
},
|
||||||
@@ -227,7 +238,7 @@ export default {
|
|||||||
},
|
},
|
||||||
closePlayer() {
|
closePlayer() {
|
||||||
this.playerHandler.closePlayer()
|
this.playerHandler.closePlayer()
|
||||||
this.$store.commit('setStreamAudiobook', null)
|
this.$store.commit('setMediaPlaying', null)
|
||||||
},
|
},
|
||||||
streamProgress(data) {
|
streamProgress(data) {
|
||||||
if (!data.numSegments) return
|
if (!data.numSegments) return
|
||||||
@@ -239,13 +250,19 @@ export default {
|
|||||||
console.error('No Audio Ref')
|
console.error('No Audio Ref')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
streamOpen(stream) {
|
sessionOpen(session) {
|
||||||
this.$store.commit('setStreamAudiobook', stream.audiobook)
|
this.$store.commit('setMediaPlaying', {
|
||||||
this.playerHandler.prepareStream(stream)
|
libraryItem: session.libraryItem,
|
||||||
|
episodeId: session.episodeId
|
||||||
|
})
|
||||||
|
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate)
|
||||||
|
},
|
||||||
|
streamOpen(session) {
|
||||||
|
console.log(`[StreamContainer] Stream session open`, session)
|
||||||
},
|
},
|
||||||
streamClosed(streamId) {
|
streamClosed(streamId) {
|
||||||
// Stream was closed from the server
|
// Stream was closed from the server
|
||||||
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
|
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
||||||
console.warn('[StreamContainer] Closing stream due to request from server')
|
console.warn('[StreamContainer] Closing stream due to request from server')
|
||||||
this.playerHandler.closePlayer()
|
this.playerHandler.closePlayer()
|
||||||
}
|
}
|
||||||
@@ -260,7 +277,7 @@ export default {
|
|||||||
},
|
},
|
||||||
streamError(streamId) {
|
streamError(streamId) {
|
||||||
// Stream had critical error from the server
|
// Stream had critical error from the server
|
||||||
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
|
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
||||||
console.warn('[StreamContainer] Closing stream due to stream error from server')
|
console.warn('[StreamContainer] Closing stream due to stream error from server')
|
||||||
this.playerHandler.closePlayer()
|
this.playerHandler.closePlayer()
|
||||||
}
|
}
|
||||||
@@ -269,32 +286,48 @@ export default {
|
|||||||
this.playerHandler.resetStream(startTime, streamId)
|
this.playerHandler.resetStream(startTime, streamId)
|
||||||
},
|
},
|
||||||
castSessionActive(isActive) {
|
castSessionActive(isActive) {
|
||||||
if (isActive && this.playerHandler.isPlayingLocalAudiobook) {
|
if (isActive && this.playerHandler.isPlayingLocalItem) {
|
||||||
// Cast session started switch to cast player
|
// Cast session started switch to cast player
|
||||||
this.playerHandler.switchPlayer()
|
this.playerHandler.switchPlayer()
|
||||||
} else if (!isActive && this.playerHandler.isPlayingCastedAudiobook) {
|
} else if (!isActive && this.playerHandler.isPlayingCastedItem) {
|
||||||
// Cast session ended switch to local player
|
// Cast session ended switch to local player
|
||||||
this.playerHandler.switchPlayer()
|
this.playerHandler.switchPlayer()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async playAudiobook(audiobookId) {
|
async playLibraryItem(payload) {
|
||||||
var audiobook = await this.$axios.$get(`/api/books/${audiobookId}`).catch((error) => {
|
var libraryItemId = payload.libraryItemId
|
||||||
console.error('Failed to fetch full audiobook', error)
|
var episodeId = payload.episodeId || null
|
||||||
|
|
||||||
|
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
|
||||||
|
this.playerHandler.play()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
|
||||||
|
console.error('Failed to fetch full item', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
if (!audiobook) return
|
if (!libraryItem) return
|
||||||
this.$store.commit('setStreamAudiobook', audiobook)
|
this.$store.commit('setMediaPlaying', {
|
||||||
|
libraryItem,
|
||||||
|
episodeId
|
||||||
|
})
|
||||||
|
|
||||||
this.playerHandler.load(audiobook, true, this.userAudiobookCurrentTime)
|
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
|
||||||
|
},
|
||||||
|
pauseItem() {
|
||||||
|
this.playerHandler.pause()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
||||||
this.$eventBus.$on('play-audiobook', this.playAudiobook)
|
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||||
|
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
||||||
this.$eventBus.$off('play-audiobook', this.playAudiobook)
|
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||||
|
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div @mouseover="mouseover" @mouseout="mouseout">
|
||||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-lg relative">
|
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||||
<div class="w-full h-full overflow-hidden max-w-full max-h-full relative">
|
<!-- Image or placeholder -->
|
||||||
<svg width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<covers-author-image :author="author" />
|
||||||
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
|
|
||||||
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
|
|
||||||
<path
|
|
||||||
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div class="absolute bottom-0 left-0 w-full py-2 bg-black bg-opacity-25 px-2">
|
<!-- Author name & num books overlay -->
|
||||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ name }}</p>
|
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
||||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.85 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||||
</div>
|
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Search icon btn -->
|
||||||
|
<div v-show="!searching && isHovering" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="searchAuthor">
|
||||||
|
<span class="material-icons text-lg">search</span>
|
||||||
|
</div>
|
||||||
|
<div v-show="isHovering && !searching" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="$emit('edit', author)">
|
||||||
|
<span class="material-icons text-lg">edit</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading spinner -->
|
||||||
|
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
||||||
|
<widgets-loading-spinner size="" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-show="nameBelow" class="w-full py-1 px-2">
|
||||||
|
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -34,11 +38,16 @@ export default {
|
|||||||
},
|
},
|
||||||
width: Number,
|
width: Number,
|
||||||
height: Number,
|
height: Number,
|
||||||
sizeMultiplier: Number
|
sizeMultiplier: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
nameBelow: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
placeholder: '/Logo.png'
|
searching: false,
|
||||||
|
isHovering: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -48,30 +57,40 @@ export default {
|
|||||||
_author() {
|
_author() {
|
||||||
return this.author || {}
|
return this.author || {}
|
||||||
},
|
},
|
||||||
|
authorId() {
|
||||||
|
return this._author.id
|
||||||
|
},
|
||||||
name() {
|
name() {
|
||||||
return this._author.name || ''
|
return this._author.name || ''
|
||||||
},
|
},
|
||||||
image() {
|
|
||||||
return this._author.image || null
|
|
||||||
},
|
|
||||||
description() {
|
|
||||||
return this._author.description
|
|
||||||
},
|
|
||||||
lastUpdate() {
|
|
||||||
return this._author.lastUpdate
|
|
||||||
},
|
|
||||||
numBooks() {
|
numBooks() {
|
||||||
return this._author.numBooks || 0
|
return this._author.numBooks || 0
|
||||||
},
|
|
||||||
imgSrc() {
|
|
||||||
if (!this.image) return this.placeholder
|
|
||||||
var encodedImg = this.image.replace(/%/g, '%25').replace(/#/g, '%23')
|
|
||||||
|
|
||||||
var url = new URL(encodedImg, document.baseURI)
|
|
||||||
return url.href + `?token=${this.userToken}&ts=${this.lastUpdate}`
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {
|
||||||
|
mouseover() {
|
||||||
|
this.isHovering = true
|
||||||
|
},
|
||||||
|
mouseout() {
|
||||||
|
this.isHovering = false
|
||||||
|
},
|
||||||
|
async searchAuthor() {
|
||||||
|
this.searching = true
|
||||||
|
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.name }).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!response) {
|
||||||
|
this.$toast.error('Author not found')
|
||||||
|
} else if (response.updated) {
|
||||||
|
if (response.author.imagePath) this.$toast.success('Author was updated')
|
||||||
|
else this.$toast.success('Author was updated (no image found)')
|
||||||
|
} else {
|
||||||
|
this.$toast.info('No updates were made for Author')
|
||||||
|
}
|
||||||
|
this.searching = false
|
||||||
|
}
|
||||||
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full px-1 overflow-hidden">
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
<img src="/icons/NoUserPhoto.png" class="w-40 h-40 max-h-40 object-contain" style="max-height: 40px; max-width: 40px" />
|
<div class="overflow-hidden bg-primary rounded" style="height: 50px; width: 40px">
|
||||||
|
<covers-author-image :author="author" />
|
||||||
|
</div>
|
||||||
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
||||||
<p class="truncate text-sm">{{ author }}</p>
|
<p class="truncate text-sm">{{ name }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -10,12 +12,19 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
author: String
|
author: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {},
|
computed: {
|
||||||
|
name() {
|
||||||
|
return this.author.name
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full border-b border-gray-700 pb-2">
|
<div class="w-full border-b border-gray-700 pb-2">
|
||||||
<div class="flex py-1 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer" @click="selectMatch">
|
<div class="flex py-1 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer" @click="selectMatch">
|
||||||
<img :src="selectedCover || '/book_placeholder.jpg'" class="h-24 object-cover" :style="{ width: 96 / bookCoverAspectRatio + 'px' }" />
|
<div class="h-24 bg-primary" :style="{ minWidth: 96 / bookCoverAspectRatio + 'px' }">
|
||||||
<div class="px-4 flex-grow">
|
<img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" />
|
||||||
|
</div>
|
||||||
|
<div v-if="!isPodcast" class="px-4 flex-grow">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<h1>{{ book.title }}</h1>
|
<h1>{{ book.title }}</h1>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p>{{ book.publishYear }}</p>
|
<p>{{ book.publishedYear }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-400">{{ book.author }}</p>
|
<p class="text-gray-400">{{ book.author }}</p>
|
||||||
<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.description }}</p>
|
<p class="text-gray-500 text-xs">{{ book.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="px-4 flex-grow">
|
||||||
|
<h1>{{ book.title }}</h1>
|
||||||
|
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p>
|
||||||
|
<p class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>
|
||||||
|
<p class="text-xs text-gray-400 leading-5">{{ book.trackCount }} Episodes</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="bookCovers.length > 1" class="flex">
|
<div v-if="bookCovers.length > 1" class="flex">
|
||||||
<template v-for="cover in bookCovers">
|
<template v-for="cover in bookCovers">
|
||||||
@@ -31,6 +39,7 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
|
isPodcast: Boolean,
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="rounded-sm h-full relative" :style="{ padding: `${paddingY}px ${paddingX}px` }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
|
||||||
<nuxt-link :to="groupTo" class="cursor-pointer">
|
|
||||||
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: coverHeight + 'px', width: coverWidth + 'px' }">
|
|
||||||
<covers-collection-cover ref="groupcover" :book-items="bookItems" :width="coverWidth" :height="coverHeight" />
|
|
||||||
|
|
||||||
<div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
|
||||||
<!-- <div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="toggleSelected">
|
|
||||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">radio_button_unchecked</span>
|
|
||||||
</div> -->
|
|
||||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
|
||||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nuxt-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto bottom-0 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, coverWidth) + 'px' }">
|
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${1 * sizeMultiplier}rem` }">
|
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ collectionName }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
collection: {
|
|
||||||
type: Object,
|
|
||||||
default: () => null
|
|
||||||
},
|
|
||||||
width: {
|
|
||||||
type: Number,
|
|
||||||
default: 120
|
|
||||||
},
|
|
||||||
paddingY: {
|
|
||||||
type: Number,
|
|
||||||
default: 24
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
isHovering: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
width(newVal) {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
if (this.$refs.groupcover && this.$refs.groupcover.init) {
|
|
||||||
this.$refs.groupcover.init()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
labelFontSize() {
|
|
||||||
if (this.coverWidth < 160) return 0.75
|
|
||||||
return 0.875
|
|
||||||
},
|
|
||||||
currentLibraryId() {
|
|
||||||
return this.$store.state.libraries.currentLibraryId
|
|
||||||
},
|
|
||||||
_collection() {
|
|
||||||
return this.collection || {}
|
|
||||||
},
|
|
||||||
groupTo() {
|
|
||||||
return `/collection/${this._collection.id}`
|
|
||||||
},
|
|
||||||
coverWidth() {
|
|
||||||
return this.width * 2
|
|
||||||
},
|
|
||||||
coverHeight() {
|
|
||||||
return this.width * 1.6
|
|
||||||
},
|
|
||||||
sizeMultiplier() {
|
|
||||||
return this.width / 120
|
|
||||||
},
|
|
||||||
paddingX() {
|
|
||||||
return 16 * this.sizeMultiplier
|
|
||||||
},
|
|
||||||
bookItems() {
|
|
||||||
return this._collection.books || []
|
|
||||||
},
|
|
||||||
collectionName() {
|
|
||||||
return this._collection.name || 'No Name'
|
|
||||||
},
|
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
toggleSelected() {
|
|
||||||
// Selected
|
|
||||||
},
|
|
||||||
clickEdit() {
|
|
||||||
this.$store.commit('globals/setEditCollection', this.collection)
|
|
||||||
},
|
|
||||||
mouseoverCard() {
|
|
||||||
this.isHovering = true
|
|
||||||
},
|
|
||||||
mouseleaveCard() {
|
|
||||||
this.isHovering = false
|
|
||||||
},
|
|
||||||
clickCard() {
|
|
||||||
this.$emit('click', this.collection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -12,9 +12,6 @@
|
|||||||
<div class="absolute top-2 right-2 w-7 h-7 rounded-lg bg-black bg-opacity-90 text-gray-300 box-shadow-book flex items-center justify-center border border-white border-opacity-25 pointer-events-none z-40">
|
<div class="absolute top-2 right-2 w-7 h-7 rounded-lg bg-black bg-opacity-90 text-gray-300 box-shadow-book flex items-center justify-center border border-white border-opacity-25 pointer-events-none z-40">
|
||||||
<p class="font-book text-xl">{{ bookItems.length }}</p>
|
<p class="font-book text-xl">{{ bookItems.length }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-0 left-0 w-full h-1 flex flex-nowrap z-40">
|
|
||||||
<div v-for="userProgress in userProgressItems" :key="userProgress.audiobookId" class="h-full w-full" :class="userProgress.isRead ? 'bg-success' : userProgress.progress > 0 ? 'bg-yellow-400' : ''" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,7 +71,7 @@ export default {
|
|||||||
},
|
},
|
||||||
groupTo() {
|
groupTo() {
|
||||||
if (this.groupType === 'series') {
|
if (this.groupType === 'series') {
|
||||||
return `/library/${this.currentLibraryId}/series/${this.groupEncode}`
|
return `/library/${this.currentLibraryId}/series/${this._group.id}`
|
||||||
} else if (this.groupType === 'collection') {
|
} else if (this.groupType === 'collection') {
|
||||||
return `/collection/${this._group.id}`
|
return `/collection/${this._group.id}`
|
||||||
} else {
|
} else {
|
||||||
@@ -100,15 +97,6 @@ export default {
|
|||||||
bookItems() {
|
bookItems() {
|
||||||
return this._group.books || []
|
return this._group.books || []
|
||||||
},
|
},
|
||||||
userAudiobooks() {
|
|
||||||
return Object.values(this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {})
|
|
||||||
},
|
|
||||||
userProgressItems() {
|
|
||||||
return this.bookItems.map((item) => {
|
|
||||||
var userAudiobook = this.userAudiobooks.find((ab) => ab.audiobookId === item.id)
|
|
||||||
return userAudiobook || {}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
groupName() {
|
groupName() {
|
||||||
return this._group.name || 'No Name'
|
return this._group.name || 'No Name'
|
||||||
},
|
},
|
||||||
@@ -119,7 +107,7 @@ export default {
|
|||||||
return `${this.groupType}.${this.$encode(this.groupName)}`
|
return `${this.groupType}.${this.$encode(this.groupName)}`
|
||||||
},
|
},
|
||||||
hasValidCovers() {
|
hasValidCovers() {
|
||||||
var validCovers = this.bookItems.map((bookItem) => bookItem.book.cover)
|
var validCovers = this.bookItems.map((bookItem) => bookItem.media.coverPath)
|
||||||
return !!validCovers.length
|
return !!validCovers.length
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
showExperimentalFeatures() {
|
||||||
|
|||||||
+20
-10
@@ -1,13 +1,13 @@
|
|||||||
<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 :audiobook="audiobook" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<div class="flex-grow px-2 audiobookSearchCardContent">
|
<div class="flex-grow px-2 audiobookSearchCardContent">
|
||||||
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
||||||
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
||||||
|
|
||||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
|
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
|
||||||
|
|
||||||
<p v-if="matchKey !== 'authorFL'" class="text-xs text-gray-200 truncate">by {{ authorFL }}</p>
|
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
|
||||||
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
||||||
|
|
||||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
audiobook: {
|
libraryItem: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
@@ -37,17 +37,27 @@ export default {
|
|||||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
||||||
return 50
|
return 50
|
||||||
},
|
},
|
||||||
book() {
|
media() {
|
||||||
return this.audiobook ? this.audiobook.book || {} : {}
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
mediaType() {
|
||||||
|
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.mediaType == 'podcast'
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.book ? this.book.title : 'No Title'
|
return this.mediaMetadata.title || 'No Title'
|
||||||
},
|
},
|
||||||
subtitle() {
|
subtitle() {
|
||||||
return this.book ? this.book.subtitle : ''
|
return this.mediaMetadata.subtitle || ''
|
||||||
},
|
},
|
||||||
authorFL() {
|
authorName() {
|
||||||
return this.book ? this.book.authorFL : 'Unknown'
|
if (this.isPodcast) return this.mediaMetadata.author || 'Unknown'
|
||||||
|
return this.mediaMetadata.authorName || 'Unknown'
|
||||||
},
|
},
|
||||||
matchHtml() {
|
matchHtml() {
|
||||||
if (!this.matchText || !this.search) return ''
|
if (!this.matchText || !this.search) return ''
|
||||||
@@ -69,7 +79,7 @@ export default {
|
|||||||
html += lastPart
|
html += lastPart
|
||||||
|
|
||||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||||
if (this.matchKey === 'authorFL') return `by ${html}`
|
if (this.matchKey === 'authors') return `by ${html}`
|
||||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
||||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||||
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
||||||
+34
-24
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative w-full py-4 px-6 border border-white border-opacity-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 border-opacity-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 text-opacity-80 font-mono">#{{ book.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 border-opacity-10 flex items-center justify-center rounded-full hover:bg-error cursor-pointer" @click="$emit('remove')">
|
<div v-if="!processing && !uploadFailed && !uploadSuccess" class="absolute -top-3 -right-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-error cursor-pointer" @click="$emit('remove')">
|
||||||
@@ -15,15 +15,19 @@
|
|||||||
|
|
||||||
<div class="flex my-2 -mx-2">
|
<div class="flex my-2 -mx-2">
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-model="bookData.title" :disabled="processing" label="Title" @input="titleUpdated" />
|
<ui-text-input-with-label v-model="itemData.title" :disabled="processing" label="Title" @input="titleUpdated" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-model="bookData.author" :disabled="processing" label="Author" />
|
<ui-text-input-with-label v-if="!isPodcast" v-model="itemData.author" :disabled="processing" label="Author" />
|
||||||
|
<div v-else class="w-full">
|
||||||
|
<p class="px-1 text-sm font-semibold">Directory <em class="font-normal text-xs pl-2">(auto)</em></p>
|
||||||
|
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex my-2 -mx-2">
|
<div v-if="!isPodcast" class="flex my-2 -mx-2">
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-model="bookData.series" :disabled="processing" label="Series" note="(optional)" />
|
<ui-text-input-with-label v-model="itemData.series" :disabled="processing" label="Series" note="(optional)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
@@ -33,9 +37,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<tables-uploaded-files-table :files="book.bookFiles" title="Book Files" class="mt-8" />
|
<tables-uploaded-files-table :files="item.itemFiles" title="Item Files" class="mt-8" />
|
||||||
<tables-uploaded-files-table v-if="book.otherFiles.length" title="Other Files" :files="book.otherFiles" />
|
<tables-uploaded-files-table v-if="item.otherFiles.length" title="Other Files" :files="item.otherFiles" />
|
||||||
<tables-uploaded-files-table v-if="book.ignoredFiles.length" title="Ignored Files" :files="book.ignoredFiles" />
|
<tables-uploaded-files-table v-if="item.ignoredFiles.length" title="Ignored Files" :files="item.ignoredFiles" />
|
||||||
</template>
|
</template>
|
||||||
<widgets-alert v-if="uploadSuccess" type="success">
|
<widgets-alert v-if="uploadSuccess" type="success">
|
||||||
<p class="text-base">Successfully Uploaded!</p>
|
<p class="text-base">Successfully Uploaded!</p>
|
||||||
@@ -55,15 +59,16 @@ import Path from 'path'
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
book: {
|
item: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
|
mediaType: String,
|
||||||
processing: Boolean
|
processing: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
bookData: {
|
itemData: {
|
||||||
title: '',
|
title: '',
|
||||||
author: '',
|
author: '',
|
||||||
series: ''
|
series: ''
|
||||||
@@ -75,14 +80,19 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
isPodcast() {
|
||||||
|
return this.mediaType === 'podcast'
|
||||||
|
},
|
||||||
directory() {
|
directory() {
|
||||||
if (!this.bookData.title) return ''
|
if (!this.itemData.title) return ''
|
||||||
if (this.bookData.series && this.bookData.author) {
|
if (this.isPodcast) return this.itemData.title
|
||||||
return Path.join(this.bookData.author, this.bookData.series, this.bookData.title)
|
|
||||||
} else if (this.bookData.author) {
|
if (this.itemData.series && this.itemData.author) {
|
||||||
return Path.join(this.bookData.author, this.bookData.title)
|
return Path.join(this.itemData.author, this.itemData.series, this.itemData.title)
|
||||||
|
} else if (this.itemData.author) {
|
||||||
|
return Path.join(this.itemData.author, this.itemData.title)
|
||||||
} else {
|
} else {
|
||||||
return this.bookData.title
|
return this.itemData.title
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -96,24 +106,24 @@ export default {
|
|||||||
this.error = ''
|
this.error = ''
|
||||||
},
|
},
|
||||||
getData() {
|
getData() {
|
||||||
if (!this.bookData.title) {
|
if (!this.itemData.title) {
|
||||||
this.error = 'Must have a title'
|
this.error = 'Must have a title'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
this.error = ''
|
this.error = ''
|
||||||
var files = this.book.bookFiles.concat(this.book.otherFiles)
|
var files = this.item.itemFiles.concat(this.item.otherFiles)
|
||||||
return {
|
return {
|
||||||
index: this.book.index,
|
index: this.item.index,
|
||||||
...this.bookData,
|
...this.itemData,
|
||||||
files
|
files
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
if (this.book) {
|
if (this.item) {
|
||||||
this.bookData.title = this.book.title
|
this.itemData.title = this.item.title
|
||||||
this.bookData.author = this.book.author
|
this.itemData.author = this.item.author
|
||||||
this.bookData.series = this.book.series
|
this.itemData.series = this.item.series
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,20 +8,21 @@
|
|||||||
<!-- Alternative bookshelf title/author/sort -->
|
<!-- Alternative bookshelf title/author/sort -->
|
||||||
<div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
<div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||||
<span v-if="volumeNumber">#{{ volumeNumber }} </span>{{ displayTitle }}
|
{{ displayTitle }}
|
||||||
</p>
|
</p>
|
||||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
|
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor || ' ' }}</p>
|
||||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="booksInSeries" class="absolute z-20 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ booksInSeries }}</div>
|
<div v-if="booksInSeries" class="absolute z-20 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ booksInSeries }}</div>
|
||||||
|
|
||||||
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
||||||
<div v-show="audiobook && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
|
<div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
|
||||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img v-show="audiobook" ref="cover" :src="bookCoverSrc" class="w-full h-full object-contain transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
<!-- Cover Image -->
|
||||||
|
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||||
|
|
||||||
<!-- Placeholder Cover Title & Author -->
|
<!-- Placeholder Cover Title & Author -->
|
||||||
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||||
@@ -34,11 +35,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No progress shown for collapsed series in library -->
|
<!-- No progress shown for collapsed series in library and podcasts (unless showing podcast episode) -->
|
||||||
<div v-if="!booksInSeries" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
<div v-if="!booksInSeries && (!isPodcast || episodeProgress)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||||
|
|
||||||
<!-- Overlay is not shown if collapsing series in library -->
|
<!-- Overlay is not shown if collapsing series in library -->
|
||||||
<div v-show="!booksInSeries && audiobook && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
|
<div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
|
||||||
<div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
|
<div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
|
||||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
|
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
|
||||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||||
@@ -51,7 +52,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50 top-0 right-0" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
||||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -59,13 +60,13 @@
|
|||||||
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref="moreIcon" v-show="!isSelectionMode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
<div ref="moreIcon" v-show="!isSelectionMode && !recentEpisode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
||||||
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Series name overlay -->
|
<!-- Series name overlay -->
|
||||||
<div v-if="booksInSeries && audiobook && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
|
<div v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
|
||||||
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
|
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -76,9 +77,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<!-- Volume number -->
|
<!-- Series sequence -->
|
||||||
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
<div v-if="seriesSequence && showSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Podcast Episode # -->
|
||||||
|
<div v-if="recentEpisodeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||||
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Podcast Num Episodes -->
|
||||||
|
<div v-else-if="numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||||
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -99,7 +110,7 @@ export default {
|
|||||||
default: 192
|
default: 192
|
||||||
},
|
},
|
||||||
bookCoverAspectRatio: Number,
|
bookCoverAspectRatio: Number,
|
||||||
showVolumeNumber: Boolean,
|
showSequence: Boolean,
|
||||||
bookshelfView: Number,
|
bookshelfView: Number,
|
||||||
bookMount: {
|
bookMount: {
|
||||||
// Book can be passed as prop or set with setEntity()
|
// Book can be passed as prop or set with setEntity()
|
||||||
@@ -115,7 +126,7 @@ export default {
|
|||||||
isHovering: false,
|
isHovering: false,
|
||||||
isMoreMenuOpen: false,
|
isMoreMenuOpen: false,
|
||||||
isProcessingReadUpdate: false,
|
isProcessingReadUpdate: false,
|
||||||
audiobook: null,
|
libraryItem: null,
|
||||||
imageReady: false,
|
imageReady: false,
|
||||||
rescanning: false,
|
rescanning: false,
|
||||||
selected: false,
|
selected: false,
|
||||||
@@ -127,7 +138,7 @@ export default {
|
|||||||
bookMount: {
|
bookMount: {
|
||||||
handler(newVal) {
|
handler(newVal) {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
this.audiobook = newVal
|
this.libraryItem = newVal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,42 +147,79 @@ export default {
|
|||||||
showExperimentalFeatures() {
|
showExperimentalFeatures() {
|
||||||
return this.store.state.showExperimentalFeatures
|
return this.store.state.showExperimentalFeatures
|
||||||
},
|
},
|
||||||
_audiobook() {
|
_libraryItem() {
|
||||||
return this.audiobook || {}
|
return this.libraryItem || {}
|
||||||
},
|
},
|
||||||
book() {
|
isFile() {
|
||||||
return this._audiobook.book || {}
|
// Library item is not in a folder
|
||||||
|
return this._libraryItem.isFile
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this._libraryItem.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
mediaType() {
|
||||||
|
return this._libraryItem.mediaType
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.mediaType === 'podcast'
|
||||||
},
|
},
|
||||||
placeholderUrl() {
|
placeholderUrl() {
|
||||||
return '/book_placeholder.jpg'
|
return '/book_placeholder.jpg'
|
||||||
},
|
},
|
||||||
bookCoverSrc() {
|
bookCoverSrc() {
|
||||||
return this.store.getters['audiobooks/getBookCoverSrc'](this._audiobook, this.placeholderUrl)
|
return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
|
||||||
},
|
},
|
||||||
audiobookId() {
|
libraryItemId() {
|
||||||
return this._audiobook.id
|
return this._libraryItem.id
|
||||||
},
|
},
|
||||||
series() {
|
series() {
|
||||||
return this.book.series
|
// Only included when filtering by series or collapse series
|
||||||
|
return this.mediaMetadata.series
|
||||||
|
},
|
||||||
|
seriesSequence() {
|
||||||
|
return this.series ? this.series.sequence : null
|
||||||
},
|
},
|
||||||
libraryId() {
|
libraryId() {
|
||||||
return this._audiobook.libraryId
|
return this._libraryItem.libraryId
|
||||||
},
|
},
|
||||||
hasEbook() {
|
hasEbook() {
|
||||||
return this._audiobook.numEbooks
|
return this.media.ebookFormat
|
||||||
},
|
},
|
||||||
hasTracks() {
|
numTracks() {
|
||||||
return this._audiobook.numTracks
|
if (this.media.tracks) return this.media.tracks.length
|
||||||
|
return this.media.numTracks || 0 // toJSONMinified
|
||||||
|
},
|
||||||
|
numEpisodes() {
|
||||||
|
if (!this.isPodcast) return 0
|
||||||
|
return this.media.numEpisodes || 0
|
||||||
},
|
},
|
||||||
processingBatch() {
|
processingBatch() {
|
||||||
return this.store.state.processingBatch
|
return this.store.state.processingBatch
|
||||||
},
|
},
|
||||||
|
recentEpisode() {
|
||||||
|
// Only added to item when getting currently listening podcasts
|
||||||
|
return this._libraryItem.recentEpisode
|
||||||
|
},
|
||||||
|
recentEpisodeNumber() {
|
||||||
|
if (!this.recentEpisode) return null
|
||||||
|
if (this.recentEpisode.episode) {
|
||||||
|
return this.recentEpisode.episode.replace(/^#/, '')
|
||||||
|
}
|
||||||
|
return this.recentEpisode.index
|
||||||
|
},
|
||||||
|
collapsedSeries() {
|
||||||
|
// Only added to item object when collapseSeries is enabled
|
||||||
|
return this._libraryItem.collapsedSeries
|
||||||
|
},
|
||||||
booksInSeries() {
|
booksInSeries() {
|
||||||
// Only added to audiobook object when collapseSeries is enabled
|
// Only added to item object when collapseSeries is enabled
|
||||||
return this._audiobook.booksInSeries
|
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
||||||
},
|
},
|
||||||
hasCover() {
|
hasCover() {
|
||||||
return !!this.book.cover
|
return !!this.media.coverPath
|
||||||
},
|
},
|
||||||
squareAspectRatio() {
|
squareAspectRatio() {
|
||||||
return this.bookCoverAspectRatio === 1
|
return this.bookCoverAspectRatio === 1
|
||||||
@@ -181,87 +229,95 @@ export default {
|
|||||||
return this.width / baseSize
|
return this.width / baseSize
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.book.title || ''
|
return this.mediaMetadata.title || ''
|
||||||
},
|
},
|
||||||
playIconFontSize() {
|
playIconFontSize() {
|
||||||
return Math.max(2, 3 * this.sizeMultiplier)
|
return Math.max(2, 3 * this.sizeMultiplier)
|
||||||
},
|
},
|
||||||
author() {
|
author() {
|
||||||
return this.book.author
|
if (this.isPodcast) return this.mediaMetadata.author
|
||||||
},
|
return this.mediaMetadata.authorName
|
||||||
authorFL() {
|
|
||||||
return this.book.authorFL || this.author
|
|
||||||
},
|
},
|
||||||
authorLF() {
|
authorLF() {
|
||||||
return this.book.authorLF || this.author
|
return this.mediaMetadata.authorNameLF
|
||||||
},
|
|
||||||
volumeNumber() {
|
|
||||||
return this.book.volumeNumber || null
|
|
||||||
},
|
},
|
||||||
displayTitle() {
|
displayTitle() {
|
||||||
if (this.orderBy === 'book.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
|
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) {
|
||||||
return this.title.substr(4) + ', The'
|
return this.mediaMetadata.titleIgnorePrefix
|
||||||
}
|
}
|
||||||
return this.title
|
return this.title
|
||||||
},
|
},
|
||||||
displayAuthor() {
|
displayAuthor() {
|
||||||
if (this.orderBy === 'book.authorLF') return this.authorLF
|
if (this.isPodcast) return this.author
|
||||||
return this.authorFL
|
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
|
||||||
|
return this.author
|
||||||
},
|
},
|
||||||
displaySortLine() {
|
displaySortLine() {
|
||||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._audiobook.mtimeMs)
|
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
||||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._audiobook.birthtimeMs)
|
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
||||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._audiobook.addedAt)
|
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
||||||
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this._audiobook.duration, false)
|
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
||||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._audiobook.size)
|
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
|
episodeProgress() {
|
||||||
|
// Only used on home page currently listening podcast shelf
|
||||||
|
if (!this.recentEpisode) return null
|
||||||
|
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
|
||||||
|
},
|
||||||
userProgress() {
|
userProgress() {
|
||||||
return this.store.getters['user/getUserAudiobook'](this.audiobookId)
|
if (this.episodeProgress) return this.episodeProgress
|
||||||
|
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||||
},
|
},
|
||||||
userProgressPercent() {
|
userProgressPercent() {
|
||||||
return this.userProgress ? this.userProgress.progress || 0 : 0
|
return this.userProgress ? this.userProgress.progress || 0 : 0
|
||||||
},
|
},
|
||||||
userIsRead() {
|
itemIsFinished() {
|
||||||
return this.userProgress ? !!this.userProgress.isRead : false
|
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||||
},
|
},
|
||||||
showError() {
|
showError() {
|
||||||
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
|
if (this.recentEpisode) return false // Dont show podcast error on episode card
|
||||||
|
return this.numInvalidAudioFiles || this.numMissingParts || this.isMissing || this.isInvalid
|
||||||
},
|
},
|
||||||
isStreaming() {
|
isStreaming() {
|
||||||
return this.store.getters['getAudiobookIdStreaming'] === this.audiobookId
|
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
||||||
},
|
},
|
||||||
showReadButton() {
|
showReadButton() {
|
||||||
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
|
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
|
||||||
},
|
},
|
||||||
showPlayButton() {
|
showPlayButton() {
|
||||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.hasTracks && !this.isStreaming
|
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
||||||
},
|
},
|
||||||
showSmallEBookIcon() {
|
showSmallEBookIcon() {
|
||||||
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
|
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
|
||||||
},
|
},
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return this._audiobook.isMissing
|
return this._libraryItem.isMissing
|
||||||
},
|
},
|
||||||
isInvalid() {
|
isInvalid() {
|
||||||
return this._audiobook.isInvalid
|
return this._libraryItem.isInvalid
|
||||||
},
|
},
|
||||||
hasMissingParts() {
|
numMissingParts() {
|
||||||
return this._audiobook.hasMissingParts
|
if (this.isPodcast) return 0
|
||||||
|
return this.media.numMissingParts
|
||||||
},
|
},
|
||||||
hasInvalidParts() {
|
numInvalidAudioFiles() {
|
||||||
return this._audiobook.hasInvalidParts
|
if (this.isPodcast) return 0
|
||||||
|
return this.media.numInvalidAudioFiles
|
||||||
},
|
},
|
||||||
errorText() {
|
errorText() {
|
||||||
if (this.isMissing) return 'Audiobook directory is missing!'
|
if (this.isMissing) return 'Item directory is missing!'
|
||||||
else if (this.isInvalid) return 'Audiobook has no audio tracks & ebook'
|
else if (this.isInvalid) {
|
||||||
var txt = ''
|
if (this.isPodcast) return 'Podcast has no episodes'
|
||||||
if (this.hasMissingParts) {
|
return 'Item has no audio tracks & ebook'
|
||||||
txt = `${this.hasMissingParts} missing parts.`
|
|
||||||
}
|
}
|
||||||
if (this.hasInvalidParts) {
|
var txt = ''
|
||||||
if (this.hasMissingParts) txt += ' '
|
if (this.numMissingParts) {
|
||||||
txt += `${this.hasInvalidParts} invalid parts.`
|
txt += `${this.numMissingParts} missing parts.`
|
||||||
|
}
|
||||||
|
if (this.numInvalidAudioFiles) {
|
||||||
|
if (txt) txt += ' '
|
||||||
|
txt += `${this.numInvalidAudioFiles} invalid audio files.`
|
||||||
}
|
}
|
||||||
return txt || 'Unknown Error'
|
return txt || 'Unknown Error'
|
||||||
},
|
},
|
||||||
@@ -290,35 +346,30 @@ export default {
|
|||||||
return this.store.getters['user/getIsRoot']
|
return this.store.getters['user/getIsRoot']
|
||||||
},
|
},
|
||||||
moreMenuItems() {
|
moreMenuItems() {
|
||||||
var items = [
|
var items = []
|
||||||
{
|
if (!this.isPodcast) {
|
||||||
func: 'toggleRead',
|
items = [
|
||||||
text: `Mark as ${this.userIsRead ? 'Not Read' : 'Read'}`
|
{
|
||||||
},
|
func: 'toggleFinished',
|
||||||
{
|
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
|
||||||
func: 'openCollections',
|
},
|
||||||
text: 'Add to Collection'
|
{
|
||||||
}
|
func: 'openCollections',
|
||||||
]
|
text: 'Add to Collection'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
if (this.userCanUpdate) {
|
if (this.userCanUpdate) {
|
||||||
if (this.hasTracks) {
|
items.push({
|
||||||
items.push({
|
func: 'showEditModalFiles',
|
||||||
func: 'showEditModalTracks',
|
text: 'Files'
|
||||||
text: 'Tracks'
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
items.push({
|
items.push({
|
||||||
func: 'showEditModalMatch',
|
func: 'showEditModalMatch',
|
||||||
text: 'Match'
|
text: 'Match'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.userCanDownload) {
|
if (this.userIsRoot && !this.isFile) {
|
||||||
items.push({
|
|
||||||
func: 'showEditModalDownload',
|
|
||||||
text: 'Download'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (this.userIsRoot) {
|
|
||||||
items.push({
|
items.push({
|
||||||
func: 'rescan',
|
func: 'rescan',
|
||||||
text: 'Re-Scan'
|
text: 'Re-Scan'
|
||||||
@@ -349,11 +400,11 @@ export default {
|
|||||||
return this.title
|
return this.title
|
||||||
},
|
},
|
||||||
authorCleaned() {
|
authorCleaned() {
|
||||||
if (!this.authorFL) return ''
|
if (!this.author) return ''
|
||||||
if (this.authorFL.length > 30) {
|
if (this.author.length > 30) {
|
||||||
return this.authorFL.slice(0, 27) + '...'
|
return this.author.slice(0, 27) + '...'
|
||||||
}
|
}
|
||||||
return this.authorFL
|
return this.author
|
||||||
},
|
},
|
||||||
isAlternativeBookshelfView() {
|
isAlternativeBookshelfView() {
|
||||||
var constants = this.$constants || this.$nuxt.$constants
|
var constants = this.$constants || this.$nuxt.$constants
|
||||||
@@ -370,8 +421,8 @@ export default {
|
|||||||
this.isSelectionMode = val
|
this.isSelectionMode = val
|
||||||
if (!val) this.selected = false
|
if (!val) this.selected = false
|
||||||
},
|
},
|
||||||
setEntity(audiobook) {
|
setEntity(libraryItem) {
|
||||||
this.audiobook = audiobook
|
this.libraryItem = libraryItem
|
||||||
},
|
},
|
||||||
clickCard(e) {
|
clickCard(e) {
|
||||||
if (this.isSelectionMode) {
|
if (this.isSelectionMode) {
|
||||||
@@ -381,66 +432,69 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
var router = this.$router || this.$nuxt.$router
|
var router = this.$router || this.$nuxt.$router
|
||||||
if (router) {
|
if (router) {
|
||||||
if (this.booksInSeries) router.push(`/library/${this.libraryId}/series/${this.$encode(this.series)}`)
|
if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`)
|
||||||
else router.push(`/audiobook/${this.audiobookId}`)
|
else router.push(`/item/${this.libraryItemId}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
editClick() {
|
editClick() {
|
||||||
this.$emit('edit', this.audiobook)
|
if (this.recentEpisode) {
|
||||||
|
return this.$emit('edit', { libraryItem: this.libraryItem, episode: this.recentEpisode })
|
||||||
|
}
|
||||||
|
this.$emit('edit', this.libraryItem)
|
||||||
},
|
},
|
||||||
toggleRead() {
|
toggleFinished() {
|
||||||
// More menu func
|
|
||||||
var updatePayload = {
|
var updatePayload = {
|
||||||
isRead: !this.userIsRead
|
isFinished: !this.itemIsFinished
|
||||||
}
|
}
|
||||||
this.isProcessingReadUpdate = true
|
this.isProcessingReadUpdate = true
|
||||||
var toast = this.$toast || this.$nuxt.$toast
|
var toast = this.$toast || this.$nuxt.$toast
|
||||||
var axios = this.$axios || this.$nuxt.$axios
|
var axios = this.$axios || this.$nuxt.$axios
|
||||||
axios
|
axios
|
||||||
.$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload)
|
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.isProcessingReadUpdate = false
|
this.isProcessingReadUpdate = false
|
||||||
toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
this.isProcessingReadUpdate = false
|
this.isProcessingReadUpdate = false
|
||||||
toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
audiobookScanComplete(result) {
|
|
||||||
this.rescanning = false
|
|
||||||
var toast = this.$toast || this.$nuxt.$toast
|
|
||||||
if (!result) {
|
|
||||||
toast.error(`Re-Scan Failed for "${this.title}"`)
|
|
||||||
} else if (result === 'UPDATED') {
|
|
||||||
toast.success(`Re-Scan complete audiobook was updated`)
|
|
||||||
} else if (result === 'UPTODATE') {
|
|
||||||
toast.success(`Re-Scan complete audiobook was up to date`)
|
|
||||||
} else if (result === 'REMOVED') {
|
|
||||||
toast.error(`Re-Scan complete audiobook was removed`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
rescan() {
|
rescan() {
|
||||||
this.rescanning = true
|
this.rescanning = true
|
||||||
this._socket.once('audiobook_scan_complete', this.audiobookScanComplete)
|
this.$axios
|
||||||
this._socket.emit('scan_audiobook', this.audiobookId)
|
.$get(`/api/items/${this.libraryItemId}/scan`)
|
||||||
|
.then((data) => {
|
||||||
|
this.rescanning = false
|
||||||
|
var result = data.result
|
||||||
|
if (!result) {
|
||||||
|
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||||
|
} else if (result === 'UPDATED') {
|
||||||
|
this.$toast.success(`Re-Scan complete item was updated`)
|
||||||
|
} else if (result === 'UPTODATE') {
|
||||||
|
this.$toast.success(`Re-Scan complete item was up to date`)
|
||||||
|
} else if (result === 'REMOVED') {
|
||||||
|
this.$toast.error(`Re-Scan complete item was removed`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to scan library item', error)
|
||||||
|
this.$toast.error('Failed to scan library item')
|
||||||
|
this.rescanning = false
|
||||||
|
})
|
||||||
},
|
},
|
||||||
showEditModalTracks() {
|
showEditModalFiles() {
|
||||||
// More menu func
|
// More menu func
|
||||||
this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'tracks' })
|
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'files' })
|
||||||
},
|
},
|
||||||
showEditModalMatch() {
|
showEditModalMatch() {
|
||||||
// More menu func
|
// More menu func
|
||||||
this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'match' })
|
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
|
||||||
},
|
|
||||||
showEditModalDownload() {
|
|
||||||
// More menu func
|
|
||||||
this.store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'download' })
|
|
||||||
},
|
},
|
||||||
openCollections() {
|
openCollections() {
|
||||||
this.store.commit('setSelectedAudiobook', this.audiobook)
|
this.store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||||
this.store.commit('globals/setShowUserCollectionsModal', true)
|
this.store.commit('globals/setShowUserCollectionsModal', true)
|
||||||
},
|
},
|
||||||
createMoreMenu() {
|
createMoreMenu() {
|
||||||
@@ -493,17 +547,26 @@ export default {
|
|||||||
clickShowMore() {
|
clickShowMore() {
|
||||||
this.createMoreMenu()
|
this.createMoreMenu()
|
||||||
},
|
},
|
||||||
clickReadEBook() {
|
async clickReadEBook() {
|
||||||
this.store.commit('showEReader', this.audiobook)
|
var libraryItem = await this.$axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
|
||||||
|
console.error('Failed to get lirbary item', this.libraryItemId)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!libraryItem) return
|
||||||
|
console.log('Got library itemn', libraryItem)
|
||||||
|
this.store.commit('showEReader', libraryItem)
|
||||||
},
|
},
|
||||||
selectBtnClick() {
|
selectBtnClick() {
|
||||||
if (this.processingBatch) return
|
if (this.processingBatch) return
|
||||||
this.selected = !this.selected
|
this.selected = !this.selected
|
||||||
this.$emit('select', this.audiobook)
|
this.$emit('select', this.libraryItem)
|
||||||
},
|
},
|
||||||
play() {
|
play() {
|
||||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||||
eventBus.$emit('play-audiobook', this.audiobookId)
|
eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: this.libraryItemId,
|
||||||
|
episodeId: this.recentEpisode ? this.recentEpisode.id : null
|
||||||
|
})
|
||||||
},
|
},
|
||||||
mouseover() {
|
mouseover() {
|
||||||
this.isHovering = true
|
this.isHovering = true
|
||||||
|
|||||||
@@ -7,11 +7,12 @@
|
|||||||
|
|
||||||
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
|
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
|
||||||
|
|
||||||
|
<div v-if="isSeriesFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success w-full" />
|
||||||
|
|
||||||
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||||
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div v-if="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
|
|
||||||
</div> -->
|
|
||||||
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
@@ -51,12 +52,31 @@ export default {
|
|||||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||||
return this.width / 240
|
return this.width / 240
|
||||||
},
|
},
|
||||||
|
seriesId() {
|
||||||
|
return this.series ? this.series.id : ''
|
||||||
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.series ? this.series.name : ''
|
return this.series ? this.series.name : ''
|
||||||
},
|
},
|
||||||
books() {
|
books() {
|
||||||
return this.series ? this.series.books || [] : []
|
return this.series ? this.series.books || [] : []
|
||||||
},
|
},
|
||||||
|
addedAt() {
|
||||||
|
return this.series ? this.series.addedAt : 0
|
||||||
|
},
|
||||||
|
seriesBookProgress() {
|
||||||
|
return this.books
|
||||||
|
.map((libraryItem) => {
|
||||||
|
return this.store.getters['user/getUserMediaProgress'](libraryItem.id)
|
||||||
|
})
|
||||||
|
.filter((p) => !!p)
|
||||||
|
},
|
||||||
|
seriesBooksFinished() {
|
||||||
|
return this.seriesBookProgress.filter((p) => p.isFinished)
|
||||||
|
},
|
||||||
|
isSeriesFinished() {
|
||||||
|
return this.books.length === this.seriesBooksFinished.length
|
||||||
|
},
|
||||||
store() {
|
store() {
|
||||||
return this.$store || this.$nuxt.$store
|
return this.$store || this.$nuxt.$store
|
||||||
},
|
},
|
||||||
@@ -64,13 +84,10 @@ export default {
|
|||||||
return this.store.state.libraries.currentLibraryId
|
return this.store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
seriesBooksRoute() {
|
seriesBooksRoute() {
|
||||||
return `/library/${this.currentLibraryId}/series/${this.$encode(this.title)}`
|
return `/library/${this.currentLibraryId}/series/${this.seriesId}`
|
||||||
},
|
|
||||||
seriesId() {
|
|
||||||
return this.series ? this.$encode(this.title) : null
|
|
||||||
},
|
},
|
||||||
hasValidCovers() {
|
hasValidCovers() {
|
||||||
var validCovers = this.books.map((bookItem) => bookItem.book.cover)
|
var validCovers = this.books.map((bookItem) => bookItem.media.coverPath)
|
||||||
return !!validCovers.length
|
return !!validCovers.length
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full px-1 overflow-hidden">
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
<covers-group-cover :name="series" :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="flex-grow px-2 seriesSearchCardContent h-full">
|
<div class="flex-grow px-2 seriesSearchCardContent h-full">
|
||||||
<p class="truncate text-sm">{{ series }}</p>
|
<p class="truncate text-sm">{{ name }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -10,7 +10,10 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
series: String,
|
series: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
bookItems: {
|
bookItems: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
@@ -22,6 +25,9 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['getBookCoverAspectRatio']
|
return this.$store.getters['getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
name() {
|
||||||
|
return this.series.name
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||||
|
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||||
|
<span class="flex items-center justify-between">
|
||||||
|
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||||
|
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||||
|
<template v-for="item in items">
|
||||||
|
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||||
|
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: String,
|
||||||
|
descending: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showMenu: false,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
text: 'Current',
|
||||||
|
value: 'index'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Title',
|
||||||
|
value: 'title'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Episode',
|
||||||
|
value: 'episode'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Pub Date',
|
||||||
|
value: 'publishedAt'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
selected: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedDesc: {
|
||||||
|
get() {
|
||||||
|
return this.descending
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('update:descending', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedText() {
|
||||||
|
var _selected = this.selected
|
||||||
|
if (!_selected) return ''
|
||||||
|
var _sel = this.items.find((i) => i.value === _selected)
|
||||||
|
if (!_sel) return ''
|
||||||
|
return _sel.text
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickOutside() {
|
||||||
|
this.showMenu = false
|
||||||
|
},
|
||||||
|
clickedOption(val) {
|
||||||
|
if (this.selected === val) {
|
||||||
|
this.selectedDesc = !this.selectedDesc
|
||||||
|
} else {
|
||||||
|
this.selected = val
|
||||||
|
}
|
||||||
|
this.showMenu = false
|
||||||
|
this.$nextTick(() => this.$emit('change', val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||||
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in items">
|
<template v-for="item in selectItems">
|
||||||
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
|
||||||
@@ -67,7 +67,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
showMenu: false,
|
showMenu: false,
|
||||||
sublist: null,
|
sublist: null,
|
||||||
items: [
|
bookItems: [
|
||||||
{
|
{
|
||||||
text: 'All',
|
text: 'All',
|
||||||
value: 'all'
|
value: 'all'
|
||||||
@@ -107,6 +107,32 @@ export default {
|
|||||||
value: 'progress',
|
value: 'progress',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'Missing',
|
||||||
|
value: 'missing',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Issues',
|
||||||
|
value: 'issues',
|
||||||
|
sublist: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
podcastItems: [
|
||||||
|
{
|
||||||
|
text: 'All',
|
||||||
|
value: 'all'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Genre',
|
||||||
|
value: 'genres',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Tag',
|
||||||
|
value: 'tags',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: 'Issues',
|
text: 'Issues',
|
||||||
value: 'issues',
|
value: 'issues',
|
||||||
@@ -132,18 +158,42 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||||
|
},
|
||||||
|
selectItems() {
|
||||||
|
if (this.isPodcast) return this.podcastItems
|
||||||
|
return this.bookItems
|
||||||
|
},
|
||||||
selectedItemSublist() {
|
selectedItemSublist() {
|
||||||
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
|
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
|
||||||
},
|
},
|
||||||
selectedText() {
|
selectedText() {
|
||||||
if (!this.selected) return ''
|
if (!this.selected) return ''
|
||||||
var parts = this.selected.split('.')
|
var parts = this.selected.split('.')
|
||||||
|
var filterName = this.selectItems.find((i) => i.value === parts[0])
|
||||||
|
var filterValue = null
|
||||||
if (parts.length > 1) {
|
if (parts.length > 1) {
|
||||||
return this.$decode(parts[1])
|
var decoded = this.$decode(parts[1])
|
||||||
|
if (decoded.startsWith('aut_')) {
|
||||||
|
var author = this.authors.find((au) => au.id == decoded)
|
||||||
|
if (author) filterValue = author.name
|
||||||
|
} else if (decoded.startsWith('ser_')) {
|
||||||
|
var series = this.series.find((se) => se.id == decoded)
|
||||||
|
if (series) filterValue = series.name
|
||||||
|
} else {
|
||||||
|
filterValue = decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filterName && filterValue) {
|
||||||
|
return `${filterName.text}: ${filterValue}`
|
||||||
|
} else if (filterName) {
|
||||||
|
return filterName.text
|
||||||
|
} else if (filterValue) {
|
||||||
|
return filterValue
|
||||||
|
} else {
|
||||||
|
return ''
|
||||||
}
|
}
|
||||||
var _sel = this.items.find((i) => i.value === this.selected)
|
|
||||||
if (!_sel) return ''
|
|
||||||
return _sel.text
|
|
||||||
},
|
},
|
||||||
genres() {
|
genres() {
|
||||||
return this.filterData.genres || []
|
return this.filterData.genres || []
|
||||||
@@ -164,13 +214,23 @@ export default {
|
|||||||
return this.filterData.languages || []
|
return this.filterData.languages || []
|
||||||
},
|
},
|
||||||
progress() {
|
progress() {
|
||||||
return ['Read', 'Unread', 'In Progress']
|
return ['Finished', 'In Progress', 'Not Started']
|
||||||
|
},
|
||||||
|
missing() {
|
||||||
|
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
|
||||||
},
|
},
|
||||||
sublistItems() {
|
sublistItems() {
|
||||||
return (this[this.sublist] || []).map((item) => {
|
return (this[this.sublist] || []).map((item) => {
|
||||||
return {
|
if (typeof item === 'string') {
|
||||||
text: item,
|
return {
|
||||||
value: this.$encode(item)
|
text: item,
|
||||||
|
value: this.$encode(item)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
text: item.name,
|
||||||
|
value: this.$encode(item.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,38 +19,47 @@
|
|||||||
<p>No Results</p>
|
<p>No Results</p>
|
||||||
</li>
|
</li>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
|
<p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
|
||||||
<template v-for="item in audiobookResults">
|
<template v-for="item in bookResults">
|
||||||
<li :key="item.audiobook.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||||
<nuxt-link :to="`/audiobook/${item.audiobook.id}`">
|
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||||
<cards-audiobook-search-card :audiobook="item.audiobook" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||||
|
</nuxt-link>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Podcasts</p>
|
||||||
|
<template v-for="item in podcastResults">
|
||||||
|
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||||
|
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||||
|
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
|
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
|
||||||
<template v-for="item in authorResults">
|
<template v-for="item in authorResults">
|
||||||
<li :key="item.author" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.author)}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`">
|
||||||
<cards-author-search-card :author="item.author" />
|
<cards-author-search-card :author="item" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
|
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
|
||||||
<template v-for="item in seriesResults">
|
<template v-for="item in seriesResults">
|
||||||
<li :key="item.series" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
<li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/series/${$encode(item.series)}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`">
|
||||||
<cards-series-search-card :series="item.series" :book-items="item.audiobooks" />
|
<cards-series-search-card :series="item.series" :book-items="item.books" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p>
|
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p>
|
||||||
<template v-for="item in tagResults">
|
<template v-for="item in tagResults">
|
||||||
<li :key="item.tag" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
<li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.tag)}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
|
||||||
<cards-tag-search-card :tag="item.tag" />
|
<cards-tag-search-card :tag="item.name" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
@@ -70,7 +79,8 @@ export default {
|
|||||||
isTyping: false,
|
isTyping: false,
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
search: null,
|
search: null,
|
||||||
audiobookResults: [],
|
podcastResults: [],
|
||||||
|
bookResults: [],
|
||||||
authorResults: [],
|
authorResults: [],
|
||||||
seriesResults: [],
|
seriesResults: [],
|
||||||
tagResults: [],
|
tagResults: [],
|
||||||
@@ -83,7 +93,7 @@ export default {
|
|||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
totalResults() {
|
totalResults() {
|
||||||
return this.audiobookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length
|
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -96,7 +106,8 @@ export default {
|
|||||||
clearResults() {
|
clearResults() {
|
||||||
this.search = null
|
this.search = null
|
||||||
this.lastSearch = null
|
this.lastSearch = null
|
||||||
this.audiobookResults = []
|
this.podcastResults = []
|
||||||
|
this.bookResults = []
|
||||||
this.authorResults = []
|
this.authorResults = []
|
||||||
this.seriesResults = []
|
this.seriesResults = []
|
||||||
this.tagResults = []
|
this.tagResults = []
|
||||||
@@ -136,7 +147,8 @@ export default {
|
|||||||
// Search was canceled
|
// Search was canceled
|
||||||
if (!this.isFetching) return
|
if (!this.isFetching) return
|
||||||
|
|
||||||
this.audiobookResults = searchResults.audiobooks || []
|
this.podcastResults = searchResults.podcast || []
|
||||||
|
this.bookResults = searchResults.book || []
|
||||||
this.authorResults = searchResults.authors || []
|
this.authorResults = searchResults.authors || []
|
||||||
this.seriesResults = searchResults.series || []
|
this.seriesResults = searchResults.series || []
|
||||||
this.tagResults = searchResults.tags || []
|
this.tagResults = searchResults.tags || []
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</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 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in items">
|
<template v-for="item in selectItems">
|
||||||
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
|
||||||
@@ -31,30 +31,48 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showMenu: false,
|
showMenu: false,
|
||||||
items: [
|
bookItems: [
|
||||||
{
|
{
|
||||||
text: 'Title',
|
text: 'Title',
|
||||||
value: 'book.title'
|
value: 'media.metadata.title'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Author (First Last)',
|
text: 'Author (First Last)',
|
||||||
value: 'book.authorFL'
|
value: 'media.metadata.authorName'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Author (Last, First)',
|
text: 'Author (Last, First)',
|
||||||
value: 'book.authorLF'
|
value: 'media.metadata.authorNameLF'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Added At',
|
text: 'Added At',
|
||||||
value: 'addedAt'
|
value: 'addedAt'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Volume #',
|
text: 'Size',
|
||||||
value: 'book.volumeNumber'
|
value: 'size'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Duration',
|
text: 'File Birthtime',
|
||||||
value: 'duration'
|
value: 'birthtimeMs'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'File Modified',
|
||||||
|
value: 'mtimeMs'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
podcastItems: [
|
||||||
|
{
|
||||||
|
text: 'Title',
|
||||||
|
value: 'media.metadata.title'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Author',
|
||||||
|
value: 'media.metadata.author'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Added At',
|
||||||
|
value: 'addedAt'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Size',
|
text: 'Size',
|
||||||
@@ -88,9 +106,18 @@ export default {
|
|||||||
this.$emit('update:descending', val)
|
this.$emit('update:descending', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||||
|
},
|
||||||
|
selectItems() {
|
||||||
|
if (this.isPodcast) return this.podcastItems
|
||||||
|
return this.bookItems
|
||||||
|
},
|
||||||
selectedText() {
|
selectedText() {
|
||||||
var _selected = this.selected === 'book.author' ? 'book.authorFL' : this.selected
|
var _selected = this.selected
|
||||||
var _sel = this.items.find((i) => i.value === _selected)
|
if (!_selected) return ''
|
||||||
|
if (this.selected.startsWith('book.')) _selected = _selected.replace('book.', 'media.metadata.')
|
||||||
|
var _sel = this.selectItems.find((i) => i.value === _selected)
|
||||||
if (!_sel) return ''
|
if (!_sel) return ''
|
||||||
return _sel.text
|
return _sel.text
|
||||||
}
|
}
|
||||||
@@ -104,6 +131,9 @@ export default {
|
|||||||
this.selectedDesc = !this.selectedDesc
|
this.selectedDesc = !this.selectedDesc
|
||||||
} else {
|
} else {
|
||||||
this.selected = val
|
this.selected = val
|
||||||
|
if (val == 'media.metadata.title' || val == 'media.metadata.author' || val == 'media.metadata.authorName' || val == 'media.metadata.authorNameLF') {
|
||||||
|
this.selectedDesc = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.showMenu = false
|
this.showMenu = false
|
||||||
this.$nextTick(() => this.$emit('change', val))
|
this.$nextTick(() => this.$emit('change', val))
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="wrapper" :class="`rounded-${rounded}`" class="w-full h-full bg-primary overflow-hidden">
|
||||||
|
<svg v-if="!imagePath" width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
|
||||||
|
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
|
||||||
|
<path
|
||||||
|
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
|
||||||
|
</svg>
|
||||||
|
<div v-else class="w-full h-full relative">
|
||||||
|
<div v-if="showCoverBg" class="cover-bg absolute" :style="{ backgroundImage: `url(${imgSrc})` }" />
|
||||||
|
<img ref="img" :src="imgSrc" @load="imageLoaded" class="absolute top-0 left-0 h-full w-full" :class="coverContain ? 'object-contain' : 'object-cover'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
author: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
rounded: {
|
||||||
|
type: String,
|
||||||
|
default: 'lg'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showCoverBg: false,
|
||||||
|
coverContain: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
_author() {
|
||||||
|
return this.author || {}
|
||||||
|
},
|
||||||
|
authorId() {
|
||||||
|
return this._author.id
|
||||||
|
},
|
||||||
|
imagePath() {
|
||||||
|
return this._author.imagePath
|
||||||
|
},
|
||||||
|
updatedAt() {
|
||||||
|
return this._author.updatedAt
|
||||||
|
},
|
||||||
|
imgSrc() {
|
||||||
|
if (!this.imagePath) return null
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
// Testing
|
||||||
|
return `http://localhost:3333/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||||
|
}
|
||||||
|
return `/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
imageLoaded() {
|
||||||
|
var aspectRatio = 1.25
|
||||||
|
if (this.$refs.wrapper) {
|
||||||
|
aspectRatio = this.$refs.wrapper.clientHeight / this.$refs.wrapper.clientWidth
|
||||||
|
}
|
||||||
|
if (this.$refs.img) {
|
||||||
|
var { naturalWidth, naturalHeight } = this.$refs.img
|
||||||
|
var imgAr = naturalHeight / naturalWidth
|
||||||
|
var arDiff = Math.abs(imgAr - aspectRatio)
|
||||||
|
if (arDiff > 0.15) {
|
||||||
|
this.showCoverBg = true
|
||||||
|
} else {
|
||||||
|
this.showCoverBg = false
|
||||||
|
this.coverContain = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -5,20 +5,11 @@
|
|||||||
<div class="absolute cover-bg" ref="coverBg" />
|
<div class="absolute cover-bg" ref="coverBg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img v-if="audiobook" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
<img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
||||||
<div v-show="loading && audiobook" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
|
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
|
||||||
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||||
<div class="absolute top-2 right-2">
|
<div class="absolute top-2 right-2">
|
||||||
<div class="la-ball-spin-clockwise la-sm">
|
<widgets-loading-spinner />
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,11 +35,10 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
audiobook: {
|
libraryItem: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
authorOverride: String,
|
|
||||||
width: {
|
width: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 120
|
default: 120
|
||||||
@@ -75,12 +65,15 @@ export default {
|
|||||||
height() {
|
height() {
|
||||||
return this.width * this.bookCoverAspectRatio
|
return this.width * this.bookCoverAspectRatio
|
||||||
},
|
},
|
||||||
book() {
|
media() {
|
||||||
if (!this.audiobook) return {}
|
if (!this.libraryItem) return {}
|
||||||
return this.audiobook.book || {}
|
return this.libraryItem.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.book.title || 'No Title'
|
return this.mediaMetadata.title || 'No Title'
|
||||||
},
|
},
|
||||||
titleCleaned() {
|
titleCleaned() {
|
||||||
if (this.title.length > 60) {
|
if (this.title.length > 60) {
|
||||||
@@ -88,9 +81,11 @@ export default {
|
|||||||
}
|
}
|
||||||
return this.title
|
return this.title
|
||||||
},
|
},
|
||||||
|
authors() {
|
||||||
|
return this.mediaMetadata.authors || []
|
||||||
|
},
|
||||||
author() {
|
author() {
|
||||||
if (this.authorOverride) return this.authorOverride
|
return this.authors.map((au) => au.name).join(', ')
|
||||||
return this.book.author || 'Unknown'
|
|
||||||
},
|
},
|
||||||
authorCleaned() {
|
authorCleaned() {
|
||||||
if (this.author.length > 30) {
|
if (this.author.length > 30) {
|
||||||
@@ -102,15 +97,15 @@ export default {
|
|||||||
return '/book_placeholder.jpg'
|
return '/book_placeholder.jpg'
|
||||||
},
|
},
|
||||||
fullCoverUrl() {
|
fullCoverUrl() {
|
||||||
if (!this.audiobook) return null
|
if (!this.libraryItem) return null
|
||||||
var store = this.$store || this.$nuxt.$store
|
var store = this.$store || this.$nuxt.$store
|
||||||
return store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
|
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl)
|
||||||
},
|
},
|
||||||
cover() {
|
cover() {
|
||||||
return this.book.cover || this.placeholderUrl
|
return this.media.coverPath || this.placeholderUrl
|
||||||
},
|
},
|
||||||
hasCover() {
|
hasCover() {
|
||||||
return !!this.book.cover
|
return !!this.media.coverPath
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
var baseSize = this.squareAspectRatio ? 192 : 120
|
var baseSize = this.squareAspectRatio ? 192 : 120
|
||||||
@@ -138,12 +133,12 @@ export default {
|
|||||||
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
|
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
hideCoverBg() {},
|
|
||||||
imageLoaded() {
|
imageLoaded() {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.imageReady = true
|
this.imageReady = true
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
|
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
|
||||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||||
var aspectRatio = naturalHeight / naturalWidth
|
var aspectRatio = naturalHeight / naturalWidth
|
||||||
@@ -168,214 +163,3 @@ export default {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
/*!
|
|
||||||
* Load Awesome v1.1.0 (http://github.danielcardoso.net/load-awesome/)
|
|
||||||
* Copyright 2015 Daniel Cardoso <@DanielCardoso>
|
|
||||||
* Licensed under MIT
|
|
||||||
*/
|
|
||||||
.la-ball-spin-clockwise,
|
|
||||||
.la-ball-spin-clockwise > div {
|
|
||||||
position: relative;
|
|
||||||
-webkit-box-sizing: border-box;
|
|
||||||
-moz-box-sizing: border-box;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise {
|
|
||||||
display: block;
|
|
||||||
font-size: 0;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-dark {
|
|
||||||
color: #262626;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div {
|
|
||||||
display: inline-block;
|
|
||||||
float: none;
|
|
||||||
background-color: currentColor;
|
|
||||||
border: 0 solid currentColor;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
margin-top: -4px;
|
|
||||||
margin-left: -4px;
|
|
||||||
border-radius: 100%;
|
|
||||||
-webkit-animation: ball-spin-clockwise 1s infinite ease-in-out;
|
|
||||||
-moz-animation: ball-spin-clockwise 1s infinite ease-in-out;
|
|
||||||
-o-animation: ball-spin-clockwise 1s infinite ease-in-out;
|
|
||||||
animation: ball-spin-clockwise 1s infinite ease-in-out;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(1) {
|
|
||||||
top: 5%;
|
|
||||||
left: 50%;
|
|
||||||
-webkit-animation-delay: -0.875s;
|
|
||||||
-moz-animation-delay: -0.875s;
|
|
||||||
-o-animation-delay: -0.875s;
|
|
||||||
animation-delay: -0.875s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(2) {
|
|
||||||
top: 18.1801948466%;
|
|
||||||
left: 81.8198051534%;
|
|
||||||
-webkit-animation-delay: -0.75s;
|
|
||||||
-moz-animation-delay: -0.75s;
|
|
||||||
-o-animation-delay: -0.75s;
|
|
||||||
animation-delay: -0.75s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(3) {
|
|
||||||
top: 50%;
|
|
||||||
left: 95%;
|
|
||||||
-webkit-animation-delay: -0.625s;
|
|
||||||
-moz-animation-delay: -0.625s;
|
|
||||||
-o-animation-delay: -0.625s;
|
|
||||||
animation-delay: -0.625s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(4) {
|
|
||||||
top: 81.8198051534%;
|
|
||||||
left: 81.8198051534%;
|
|
||||||
-webkit-animation-delay: -0.5s;
|
|
||||||
-moz-animation-delay: -0.5s;
|
|
||||||
-o-animation-delay: -0.5s;
|
|
||||||
animation-delay: -0.5s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(5) {
|
|
||||||
top: 94.9999999966%;
|
|
||||||
left: 50.0000000005%;
|
|
||||||
-webkit-animation-delay: -0.375s;
|
|
||||||
-moz-animation-delay: -0.375s;
|
|
||||||
-o-animation-delay: -0.375s;
|
|
||||||
animation-delay: -0.375s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(6) {
|
|
||||||
top: 81.8198046966%;
|
|
||||||
left: 18.1801949248%;
|
|
||||||
-webkit-animation-delay: -0.25s;
|
|
||||||
-moz-animation-delay: -0.25s;
|
|
||||||
-o-animation-delay: -0.25s;
|
|
||||||
animation-delay: -0.25s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(7) {
|
|
||||||
top: 49.9999750815%;
|
|
||||||
left: 5.0000051215%;
|
|
||||||
-webkit-animation-delay: -0.125s;
|
|
||||||
-moz-animation-delay: -0.125s;
|
|
||||||
-o-animation-delay: -0.125s;
|
|
||||||
animation-delay: -0.125s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise > div:nth-child(8) {
|
|
||||||
top: 18.179464974%;
|
|
||||||
left: 18.1803700518%;
|
|
||||||
-webkit-animation-delay: 0s;
|
|
||||||
-moz-animation-delay: 0s;
|
|
||||||
-o-animation-delay: 0s;
|
|
||||||
animation-delay: 0s;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-sm {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-sm > div {
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
margin-top: -2px;
|
|
||||||
margin-left: -2px;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-2x {
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-2x > div {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
margin-top: -8px;
|
|
||||||
margin-left: -8px;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-3x {
|
|
||||||
width: 96px;
|
|
||||||
height: 96px;
|
|
||||||
}
|
|
||||||
.la-ball-spin-clockwise.la-3x > div {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
margin-top: -12px;
|
|
||||||
margin-left: -12px;
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
* Animation
|
|
||||||
*/
|
|
||||||
@-webkit-keyframes ball-spin-clockwise {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
-webkit-transform: scale(1);
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
20% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
80% {
|
|
||||||
opacity: 0;
|
|
||||||
-webkit-transform: scale(0);
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@-moz-keyframes ball-spin-clockwise {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
-moz-transform: scale(1);
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
20% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
80% {
|
|
||||||
opacity: 0;
|
|
||||||
-moz-transform: scale(0);
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@-o-keyframes ball-spin-clockwise {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
-o-transform: scale(1);
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
20% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
80% {
|
|
||||||
opacity: 0;
|
|
||||||
-o-transform: scale(0);
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes ball-spin-clockwise {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
-webkit-transform: scale(1);
|
|
||||||
-moz-transform: scale(1);
|
|
||||||
-o-transform: scale(1);
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
20% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
80% {
|
|
||||||
opacity: 0;
|
|
||||||
-webkit-transform: scale(0);
|
|
||||||
-moz-transform: scale(0);
|
|
||||||
-o-transform: scale(0);
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
<div v-else-if="books.length" class="flex justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
|
<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 bg-opacity-5" />
|
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||||
|
|
||||||
<covers-book-cover :audiobook="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" :audiobook="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-sm">
|
<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 bg-opacity-5" />
|
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
getCoverUrl(book) {
|
getCoverUrl(book) {
|
||||||
return this.store.getters['audiobooks/getBookCoverSrc'](book, '')
|
return this.store.getters['globals/getLibraryItemCoverSrc'](book, '')
|
||||||
},
|
},
|
||||||
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
|
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
|
||||||
var src = coverData.coverUrl
|
var src = coverData.coverUrl
|
||||||
@@ -151,7 +151,6 @@ export default {
|
|||||||
.map((bookItem) => {
|
.map((bookItem) => {
|
||||||
return {
|
return {
|
||||||
id: bookItem.id,
|
id: bookItem.id,
|
||||||
volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null,
|
|
||||||
coverUrl: this.getCoverUrl(bookItem)
|
coverUrl: this.getCoverUrl(bookItem)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default {
|
|||||||
return '/book_placeholder.jpg'
|
return '/book_placeholder.jpg'
|
||||||
},
|
},
|
||||||
fullCoverUrl() {
|
fullCoverUrl() {
|
||||||
return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
|
return this.$store.getters['globals/getLibraryItemCoverSrc'](this.audiobook, this.placeholderUrl)
|
||||||
},
|
},
|
||||||
hasCover() {
|
hasCover() {
|
||||||
return !!this.audiobook.book.cover
|
return !!this.audiobook.book.cover
|
||||||
|
|||||||
@@ -77,6 +77,19 @@
|
|||||||
<div v-if="!newUser.permissions.accessAllLibraries" class="my-4">
|
<div v-if="!newUser.permissions.accessAllLibraries" class="my-4">
|
||||||
<ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" label="Libraries Accessible to User" />
|
<ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" label="Libraries Accessible to User" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-cen~ter my-2 max-w-md">
|
||||||
|
<div class="w-1/2">
|
||||||
|
<p>Can Access All Tags</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-1/2">
|
||||||
|
<ui-toggle-switch v-model="newUser.permissions.accessAllTags" @input="accessAllTagsToggled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
||||||
|
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" label="Tags Accessible to User" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex pt-4">
|
<div class="flex pt-4">
|
||||||
@@ -103,7 +116,9 @@ export default {
|
|||||||
processing: false,
|
processing: false,
|
||||||
newUser: {},
|
newUser: {},
|
||||||
isNew: true,
|
isNew: true,
|
||||||
accountTypes: ['guest', 'user', 'admin']
|
accountTypes: ['guest', 'user', 'admin'],
|
||||||
|
tags: [],
|
||||||
|
loadingTags: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -135,9 +150,37 @@ export default {
|
|||||||
},
|
},
|
||||||
libraryItems() {
|
libraryItems() {
|
||||||
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
|
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
|
||||||
|
},
|
||||||
|
itemTags() {
|
||||||
|
return this.tags.map((t) => {
|
||||||
|
return {
|
||||||
|
text: t,
|
||||||
|
value: t
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
accessAllTagsToggled(val) {
|
||||||
|
if (!val && !this.newUser.itemTagsAccessible.length) {
|
||||||
|
this.newUser.itemTagsAccessible = this.libraries.map((l) => l.id)
|
||||||
|
} else if (val && this.newUser.itemTagsAccessible.length) {
|
||||||
|
this.newUser.itemTagsAccessible = []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fetchAllTags() {
|
||||||
|
this.loadingTags = true
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/tags`)
|
||||||
|
.then((tags) => {
|
||||||
|
this.tags = tags
|
||||||
|
this.loadingTags = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load tags', error)
|
||||||
|
this.loadingTags = false
|
||||||
|
})
|
||||||
|
},
|
||||||
accessAllLibrariesToggled(val) {
|
accessAllLibrariesToggled(val) {
|
||||||
if (!val && !this.newUser.librariesAccessible.length) {
|
if (!val && !this.newUser.librariesAccessible.length) {
|
||||||
this.newUser.librariesAccessible = this.libraries.map((l) => l.id)
|
this.newUser.librariesAccessible = this.libraries.map((l) => l.id)
|
||||||
@@ -223,20 +266,25 @@ export default {
|
|||||||
download: type !== 'guest',
|
download: type !== 'guest',
|
||||||
update: type === 'admin',
|
update: type === 'admin',
|
||||||
delete: type === 'admin',
|
delete: type === 'admin',
|
||||||
upload: type === 'admin'
|
upload: type === 'admin',
|
||||||
|
accessAllLibraries: true,
|
||||||
|
accessAllTags: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
|
this.fetchAllTags()
|
||||||
|
|
||||||
this.isNew = !this.account
|
this.isNew = !this.account
|
||||||
if (this.account) {
|
if (this.account) {
|
||||||
var librariesAccessible = this.account.librariesAccessible || []
|
console.log(this.account)
|
||||||
this.newUser = {
|
this.newUser = {
|
||||||
username: this.account.username,
|
username: this.account.username,
|
||||||
password: this.account.password,
|
password: this.account.password,
|
||||||
type: this.account.type,
|
type: this.account.type,
|
||||||
isActive: this.account.isActive,
|
isActive: this.account.isActive,
|
||||||
permissions: { ...this.account.permissions },
|
permissions: { ...this.account.permissions },
|
||||||
librariesAccessible: [...librariesAccessible]
|
librariesAccessible: [...(this.account.librariesAccessible || [])],
|
||||||
|
itemTagsAccessible: [...(this.account.itemTagsAccessible || [])]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.newUser = {
|
this.newUser = {
|
||||||
@@ -249,7 +297,8 @@ export default {
|
|||||||
update: false,
|
update: false,
|
||||||
delete: false,
|
delete: false,
|
||||||
upload: false,
|
upload: false,
|
||||||
accessAllLibraries: true
|
accessAllLibraries: true,
|
||||||
|
accessAllTags: true
|
||||||
},
|
},
|
||||||
librariesAccessible: []
|
librariesAccessible: []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default {
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
},
|
},
|
||||||
audiobookId: String
|
libraryItemId: String
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -76,8 +76,15 @@ export default {
|
|||||||
this.showBookmarkTitleInput = true
|
this.showBookmarkTitleInput = true
|
||||||
},
|
},
|
||||||
deleteBookmark(bm) {
|
deleteBookmark(bm) {
|
||||||
var bookmark = { ...bm, audiobookId: this.audiobookId }
|
this.$axios
|
||||||
this.$root.socket.emit('delete_bookmark', bookmark)
|
.$delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Bookmark removed')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.$toast.error(`Failed to remove bookmark`)
|
||||||
|
console.error(error)
|
||||||
|
})
|
||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
clickBookmark(bm) {
|
clickBookmark(bm) {
|
||||||
@@ -85,9 +92,15 @@ export default {
|
|||||||
},
|
},
|
||||||
submitUpdateBookmark(updatedBookmark) {
|
submitUpdateBookmark(updatedBookmark) {
|
||||||
var bookmark = { ...updatedBookmark }
|
var bookmark = { ...updatedBookmark }
|
||||||
bookmark.audiobookId = this.audiobookId
|
this.$axios
|
||||||
|
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||||
this.$root.socket.emit('update_bookmark', bookmark)
|
.then(() => {
|
||||||
|
this.$toast.success('Bookmark updated')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.$toast.error(`Failed to update bookmark`)
|
||||||
|
console.error(error)
|
||||||
|
})
|
||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
submitCreateBookmark() {
|
submitCreateBookmark() {
|
||||||
@@ -95,11 +108,18 @@ export default {
|
|||||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||||
}
|
}
|
||||||
var bookmark = {
|
var bookmark = {
|
||||||
audiobookId: this.audiobookId,
|
|
||||||
title: this.newBookmarkTitle,
|
title: this.newBookmarkTitle,
|
||||||
time: this.currentTime
|
time: Math.floor(this.currentTime)
|
||||||
}
|
}
|
||||||
this.$root.socket.emit('create_bookmark', bookmark)
|
this.$axios
|
||||||
|
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Bookmark added')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.$toast.error(`Failed to create bookmark`)
|
||||||
|
console.error(error)
|
||||||
|
})
|
||||||
|
|
||||||
this.newBookmarkTitle = ''
|
this.newBookmarkTitle = ''
|
||||||
this.showBookmarkTitleInput = false
|
this.showBookmarkTitleInput = false
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
<template>
|
|
||||||
<modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing">
|
|
||||||
<template #outer>
|
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
|
||||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
|
||||||
<modals-libraries-edit-library v-if="show" :library="library" :processing.sync="processing" @close="show = false" />
|
|
||||||
</div>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
value: Boolean,
|
|
||||||
library: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
processing: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.value
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('input', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title() {
|
|
||||||
return this.library ? 'Update Library' : 'New Library'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {},
|
|
||||||
mounted() {},
|
|
||||||
beforeDestroy() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<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">
|
||||||
<transition-group name="list-complete" tag="div">
|
<transition-group name="list-complete" tag="div">
|
||||||
<template v-for="collection in sortedCollections">
|
<template v-for="collection in sortedCollections">
|
||||||
<modals-collections-user-collection-item :key="collection.id" :collection="collection" class="list-complete-item" @add="addToCollection" @remove="removeFromCollection" @close="show = false" />
|
<modals-collections-user-collection-item :key="collection.id" :collection="collection" :book-cover-aspect-ratio="bookCoverAspectRatio" class="list-complete-item" @add="addToCollection" @remove="removeFromCollection" @close="show = false" />
|
||||||
</template>
|
</template>
|
||||||
</transition-group>
|
</transition-group>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,7 +50,7 @@ export default {
|
|||||||
this.loadCollections()
|
this.loadCollections()
|
||||||
this.newCollectionName = ''
|
this.newCollectionName = ''
|
||||||
} else {
|
} else {
|
||||||
this.$store.commit('setSelectedAudiobook', null)
|
this.$store.commit('setSelectedLibraryItem', null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -65,15 +65,18 @@ export default {
|
|||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
if (this.showBatchUserCollectionModal) {
|
if (this.showBatchUserCollectionModal) {
|
||||||
return `${this.selectedBookIds.length} Books Selected`
|
return `${this.selectedBookIds.length} Items Selected`
|
||||||
}
|
}
|
||||||
return this.selectedAudiobook ? this.selectedAudiobook.book.title : ''
|
return this.selectedLibraryItem ? this.selectedLibraryItem.media.metadata.title : ''
|
||||||
},
|
},
|
||||||
selectedAudiobook() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.state.selectedAudiobook
|
return this.$store.getters['getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
selectedAudiobookId() {
|
selectedLibraryItem() {
|
||||||
return this.selectedAudiobook ? this.selectedAudiobook.id : null
|
return this.$store.state.selectedLibraryItem
|
||||||
|
},
|
||||||
|
selectedLibraryItemId() {
|
||||||
|
return this.selectedLibraryItem ? this.selectedLibraryItem.id : null
|
||||||
},
|
},
|
||||||
collections() {
|
collections() {
|
||||||
return this.$store.state.user.collections || []
|
return this.$store.state.user.collections || []
|
||||||
@@ -87,7 +90,7 @@ export default {
|
|||||||
var collectionBookIds = c.books.map((b) => b.id)
|
var collectionBookIds = c.books.map((b) => b.id)
|
||||||
includesBook = !this.selectedBookIds.find((id) => !collectionBookIds.includes(id))
|
includesBook = !this.selectedBookIds.find((id) => !collectionBookIds.includes(id))
|
||||||
} else {
|
} else {
|
||||||
includesBook = !!c.books.find((b) => b.id === this.selectedAudiobookId)
|
includesBook = !!c.books.find((b) => b.id === this.selectedLibraryItemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -101,7 +104,7 @@ export default {
|
|||||||
return this.$store.state.globals.showBatchUserCollectionModal
|
return this.$store.state.globals.showBatchUserCollectionModal
|
||||||
},
|
},
|
||||||
selectedBookIds() {
|
selectedBookIds() {
|
||||||
return this.$store.state.selectedAudiobooks || []
|
return this.$store.state.selectedLibraryItems || []
|
||||||
},
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
@@ -112,7 +115,7 @@ export default {
|
|||||||
this.$store.dispatch('user/loadUserCollections')
|
this.$store.dispatch('user/loadUserCollections')
|
||||||
},
|
},
|
||||||
removeFromCollection(collection) {
|
removeFromCollection(collection) {
|
||||||
if (!this.selectedAudiobookId && !this.selectedBookIds.length) return
|
if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
if (this.showBatchUserCollectionModal) {
|
if (this.showBatchUserCollectionModal) {
|
||||||
@@ -132,7 +135,7 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
// Remove single book
|
// Remove single book
|
||||||
this.$axios
|
this.$axios
|
||||||
.$delete(`/api/collections/${collection.id}/book/${this.selectedAudiobookId}`)
|
.$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('Book removed from collection')
|
this.$toast.success('Book removed from collection')
|
||||||
@@ -146,7 +149,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
addToCollection(collection) {
|
addToCollection(collection) {
|
||||||
if (!this.selectedAudiobookId && !this.selectedBookIds.length) return
|
if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
if (this.showBatchUserCollectionModal) {
|
if (this.showBatchUserCollectionModal) {
|
||||||
@@ -164,10 +167,10 @@ export default {
|
|||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
if (!this.selectedAudiobookId) return
|
if (!this.selectedLibraryItemId) return
|
||||||
|
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedAudiobookId })
|
.$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.$toast.success('Book added to collection')
|
||||||
@@ -181,12 +184,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
submitCreateCollection() {
|
submitCreateCollection() {
|
||||||
if (!this.newCollectionName || (!this.selectedAudiobookId && !this.selectedBookIds.length)) {
|
if (!this.newCollectionName || (!this.selectedLibraryItemId && !this.selectedBookIds.length)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
var books = this.showBatchUserCollectionModal ? this.selectedBookIds : [this.selectedAudiobookId]
|
var books = this.showBatchUserCollectionModal ? this.selectedBookIds : [this.selectedLibraryItemId]
|
||||||
var newCollection = {
|
var newCollection = {
|
||||||
books: books,
|
books: books,
|
||||||
libraryId: this.currentLibraryId,
|
libraryId: this.currentLibraryId,
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="edit-author" :width="800" :height="'unset'" :processing="processing">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||||
|
<form @submit.prevent="submitForm">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-40 p-2">
|
||||||
|
<div class="w-full h-45 relative">
|
||||||
|
<covers-author-image :author="author" />
|
||||||
|
<div v-show="!processing" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
|
||||||
|
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-3/4 p-2">
|
||||||
|
<ui-text-input-with-label v-model="authorCopy.name" :disabled="processing" label="Name" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow p-2">
|
||||||
|
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-2">
|
||||||
|
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" label="Description" :rows="8" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex pt-2 px-2">
|
||||||
|
<ui-btn type="button" @click="searchAuthor">Quick Match</ui-btn>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn type="submit">Submit</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
author: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
authorCopy: {
|
||||||
|
name: '',
|
||||||
|
asin: '',
|
||||||
|
description: ''
|
||||||
|
},
|
||||||
|
processing: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
author: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
authorId() {
|
||||||
|
if (!this.author) return ''
|
||||||
|
return this.author.id
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return 'Edit Author'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
init() {
|
||||||
|
this.authorCopy.name = this.author.name
|
||||||
|
this.authorCopy.asin = this.author.asin
|
||||||
|
this.authorCopy.description = this.author.description
|
||||||
|
},
|
||||||
|
async submitForm() {
|
||||||
|
var keysToCheck = ['name', 'asin', 'description']
|
||||||
|
var updatePayload = {}
|
||||||
|
keysToCheck.forEach((key) => {
|
||||||
|
if (this.authorCopy[key] !== this.author[key]) {
|
||||||
|
updatePayload[key] = this.authorCopy[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!Object.keys(updatePayload).length) {
|
||||||
|
this.$toast.info('No updates are necessary')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.processing = true
|
||||||
|
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error('Failed to update author')
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (result) {
|
||||||
|
if (result.updated) this.$toast.success('Author updated')
|
||||||
|
else this.$toast.info('No updates were needed')
|
||||||
|
}
|
||||||
|
this.processing = false
|
||||||
|
},
|
||||||
|
async removeCover() {
|
||||||
|
var updatePayload = {
|
||||||
|
imagePath: null,
|
||||||
|
relImagePath: null
|
||||||
|
}
|
||||||
|
this.processing = true
|
||||||
|
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error('Failed to remove image')
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (result && result.updated) {
|
||||||
|
this.$toast.success('Author image removed')
|
||||||
|
}
|
||||||
|
this.processing = false
|
||||||
|
},
|
||||||
|
async searchAuthor() {
|
||||||
|
if (!this.authorCopy.name) {
|
||||||
|
this.$toast.error('Must enter an author name')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.processing = true
|
||||||
|
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.authorCopy.name }).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!response) {
|
||||||
|
this.$toast.error('Author not found')
|
||||||
|
} else if (response.updated) {
|
||||||
|
if (response.author.imagePath) this.$toast.success('Author was updated')
|
||||||
|
else this.$toast.success('Author was updated (no image found)')
|
||||||
|
} else {
|
||||||
|
this.$toast.info('No updates were made for Author')
|
||||||
|
}
|
||||||
|
this.processing = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<!-- <span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-80'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> -->
|
<!-- <span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-80'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> -->
|
||||||
<div class="w-20 max-w-20 text-center">
|
<div class="w-20 max-w-20 text-center">
|
||||||
<!-- <img src="/Logo.png" /> -->
|
<!-- <img src="/Logo.png" /> -->
|
||||||
<covers-collection-cover :book-items="books" :width="80" :height="40 * 1.6" />
|
<covers-collection-cover :book-items="books" :width="80" :height="40 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow overflow-hidden px-2">
|
<div class="flex-grow overflow-hidden px-2">
|
||||||
<!-- <template v-if="isEditing">
|
<!-- <template v-if="isEditing">
|
||||||
@@ -38,7 +38,8 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
highlight: Boolean
|
highlight: Boolean,
|
||||||
|
bookCoverAspectRatio: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
|
|
||||||
<template v-for="(authorName, index) in searchAuthors">
|
|
||||||
<cards-search-author-card :key="index" :author-name="authorName" @match="setSelectedMatch" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-show="processing" class="flex h-full items-center justify-center">
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
|
||||||
<div class="flex mb-2">
|
|
||||||
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
|
|
||||||
<span class="material-icons text-3xl">arrow_back</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-xl pl-3">Update Author Details</p>
|
|
||||||
</div>
|
|
||||||
<form @submit.prevent="submitMatchUpdate">
|
|
||||||
<div v-if="selectedMatch.image" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.image" />
|
|
||||||
<img :src="selectedMatch.image" class="w-24 object-contain ml-4" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.image" :disabled="!selectedMatchUsage.image" label="Image" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.name" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.name" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.name" :disabled="!selectedMatchUsage.name" label="Name" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.description" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.description" />
|
|
||||||
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-end py-2">
|
|
||||||
<ui-btn color="success" type="submit">Update</ui-btn>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
processing: Boolean,
|
|
||||||
audiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
searchAuthors: [],
|
|
||||||
audiobookId: null,
|
|
||||||
searchAuthor: null,
|
|
||||||
lastSearch: null,
|
|
||||||
hasSearched: false,
|
|
||||||
selectedMatch: null,
|
|
||||||
|
|
||||||
selectedMatchUsage: {
|
|
||||||
image: true,
|
|
||||||
name: true,
|
|
||||||
description: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
audiobook: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
isProcessing: {
|
|
||||||
get() {
|
|
||||||
return this.processing
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('update:processing', val)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
// getSearchQuery() {
|
|
||||||
// return `q=${this.searchAuthor}`
|
|
||||||
// },
|
|
||||||
// submitSearch() {
|
|
||||||
// if (!this.searchTitle) {
|
|
||||||
// this.$toast.warning('Search title is required')
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// this.runSearch()
|
|
||||||
// },
|
|
||||||
// async runSearch() {
|
|
||||||
// var searchQuery = this.getSearchQuery()
|
|
||||||
// if (this.lastSearch === searchQuery) return
|
|
||||||
// this.selectedMatch = null
|
|
||||||
// this.isProcessing = true
|
|
||||||
// this.lastSearch = searchQuery
|
|
||||||
// var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => {
|
|
||||||
// console.error('Failed', error)
|
|
||||||
// return []
|
|
||||||
// })
|
|
||||||
// if (result) {
|
|
||||||
// this.selectedMatch = result
|
|
||||||
// }
|
|
||||||
// this.isProcessing = false
|
|
||||||
// this.hasSearched = true
|
|
||||||
// },
|
|
||||||
init() {
|
|
||||||
this.selectedMatch = null
|
|
||||||
// this.selectedMatchUsage = {
|
|
||||||
// title: true,
|
|
||||||
// subtitle: true,
|
|
||||||
// cover: true,
|
|
||||||
// author: true,
|
|
||||||
// description: true,
|
|
||||||
// isbn: true,
|
|
||||||
// publisher: true,
|
|
||||||
// publishYear: true
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (this.audiobook.id !== this.audiobookId) {
|
|
||||||
this.selectedMatch = null
|
|
||||||
this.hasSearched = false
|
|
||||||
this.audiobookId = this.audiobook.id
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.audiobook.book || !this.audiobook.book.authorFL) {
|
|
||||||
this.searchAuthors = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.searchAuthors = (this.audiobook.book.authorFL || '').split(', ')
|
|
||||||
},
|
|
||||||
selectMatch(match) {
|
|
||||||
this.selectedMatch = match
|
|
||||||
},
|
|
||||||
buildMatchUpdatePayload() {
|
|
||||||
var updatePayload = {}
|
|
||||||
for (const key in this.selectedMatchUsage) {
|
|
||||||
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
|
||||||
updatePayload[key] = this.selectedMatch[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updatePayload
|
|
||||||
},
|
|
||||||
async submitMatchUpdate() {
|
|
||||||
var updatePayload = this.buildMatchUpdatePayload()
|
|
||||||
if (!Object.keys(updatePayload).length) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.isProcessing = true
|
|
||||||
|
|
||||||
if (updatePayload.cover) {
|
|
||||||
var coverPayload = {
|
|
||||||
url: updatePayload.cover
|
|
||||||
}
|
|
||||||
var success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, coverPayload).catch((error) => {
|
|
||||||
console.error('Failed to update', error)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
if (success) {
|
|
||||||
this.$toast.success('Book Cover Updated')
|
|
||||||
} else {
|
|
||||||
this.$toast.error('Book Cover Failed to Update')
|
|
||||||
}
|
|
||||||
console.log('Updated cover')
|
|
||||||
delete updatePayload.cover
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(updatePayload).length) {
|
|
||||||
var bookUpdatePayload = {
|
|
||||||
book: updatePayload
|
|
||||||
}
|
|
||||||
var success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, bookUpdatePayload).catch((error) => {
|
|
||||||
console.error('Failed to update', error)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
if (success) {
|
|
||||||
this.$toast.success('Book Details Updated')
|
|
||||||
this.selectedMatch = null
|
|
||||||
this.$emit('selectTab', 'details')
|
|
||||||
} else {
|
|
||||||
this.$toast.error('Book Details Failed to Update')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.selectedMatch = null
|
|
||||||
}
|
|
||||||
this.isProcessing = false
|
|
||||||
},
|
|
||||||
setSelectedMatch(authorMatchObj) {
|
|
||||||
this.selectedMatch = authorMatchObj
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.matchListWrapper {
|
|
||||||
height: calc(100% - 80px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
|
||||||
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
|
|
||||||
<table v-else class="text-sm tracksTable">
|
|
||||||
<tr class="font-book">
|
|
||||||
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
|
||||||
<th class="text-left">Title</th>
|
|
||||||
<th class="text-center">Start</th>
|
|
||||||
<th class="text-center">End</th>
|
|
||||||
</tr>
|
|
||||||
<template v-for="chapter in chapters">
|
|
||||||
<tr :key="chapter.id">
|
|
||||||
<td class="text-left">
|
|
||||||
<p class="px-4">{{ chapter.id }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="font-book">
|
|
||||||
{{ chapter.title }}
|
|
||||||
</td>
|
|
||||||
<td class="font-mono text-center">
|
|
||||||
{{ $secondsToTimestamp(chapter.start) }}
|
|
||||||
</td>
|
|
||||||
<td class="font-mono text-center">
|
|
||||||
{{ $secondsToTimestamp(chapter.end) }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
audiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
chapters: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
audiobook: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {},
|
|
||||||
methods: {
|
|
||||||
init() {
|
|
||||||
this.chapters = this.audiobook.chapters || []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full h-full relative">
|
|
||||||
<form class="w-full h-full" @submit.prevent="submitForm">
|
|
||||||
<div ref="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
|
|
||||||
<div class="flex -mx-1">
|
|
||||||
<div class="w-1/2 px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.title" label="Title" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.subtitle" label="Subtitle" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
|
||||||
<div class="w-3/4 px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.author" label="Author" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
|
||||||
<div class="w-3/4 px-1">
|
|
||||||
<ui-input-dropdown ref="seriesDropdown" v-model="details.series" label="Series" :items="series" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
|
||||||
<div class="w-1/2 px-1">
|
|
||||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
|
||||||
<div class="w-1/3 px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.narrator" label="Narrator" />
|
|
||||||
</div>
|
|
||||||
<div class="w-1/3 px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.publisher" label="Publisher" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.language" label="Language" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
|
||||||
<div class="w-1/3 px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.isbn" label="ISBN" />
|
|
||||||
</div>
|
|
||||||
<div class="w-1/3 px-1">
|
|
||||||
<ui-text-input-with-label v-model="details.asin" label="ASIN" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
|
|
||||||
<div class="flex items-center px-4">
|
|
||||||
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
|
|
||||||
|
|
||||||
<div class="flex-grow" />
|
|
||||||
|
|
||||||
<ui-tooltip v-if="!isMissing" text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="mr-4 hidden sm:block">
|
|
||||||
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
|
|
||||||
</ui-tooltip>
|
|
||||||
|
|
||||||
<ui-tooltip :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
|
|
||||||
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
|
||||||
</ui-tooltip>
|
|
||||||
|
|
||||||
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
|
|
||||||
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
|
||||||
</ui-tooltip>
|
|
||||||
|
|
||||||
<ui-btn type="submit">Submit</ui-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
processing: Boolean,
|
|
||||||
audiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
details: {
|
|
||||||
title: null,
|
|
||||||
subtitle: null,
|
|
||||||
description: null,
|
|
||||||
author: null,
|
|
||||||
narrator: null,
|
|
||||||
series: null,
|
|
||||||
volumeNumber: null,
|
|
||||||
publishYear: null,
|
|
||||||
publisher: null,
|
|
||||||
language: null,
|
|
||||||
isbn: null,
|
|
||||||
asin: null,
|
|
||||||
genres: []
|
|
||||||
},
|
|
||||||
newTags: [],
|
|
||||||
resettingProgress: false,
|
|
||||||
isScrollable: false,
|
|
||||||
savingMetadata: false,
|
|
||||||
rescanning: false,
|
|
||||||
quickMatching: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
audiobook: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
isProcessing: {
|
|
||||||
get() {
|
|
||||||
return this.processing
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('update:processing', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isRootUser() {
|
|
||||||
return this.$store.getters['user/getIsRoot']
|
|
||||||
},
|
|
||||||
isMissing() {
|
|
||||||
return !!this.audiobook && !!this.audiobook.isMissing
|
|
||||||
},
|
|
||||||
audiobookId() {
|
|
||||||
return this.audiobook ? this.audiobook.id : null
|
|
||||||
},
|
|
||||||
book() {
|
|
||||||
return this.audiobook ? this.audiobook.book || {} : {}
|
|
||||||
},
|
|
||||||
userCanDelete() {
|
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
|
||||||
},
|
|
||||||
genres() {
|
|
||||||
return this.filterData.genres || []
|
|
||||||
},
|
|
||||||
tags() {
|
|
||||||
return this.filterData.tags || []
|
|
||||||
},
|
|
||||||
series() {
|
|
||||||
return this.filterData.series || []
|
|
||||||
},
|
|
||||||
filterData() {
|
|
||||||
return this.$store.state.libraries.filterData || {}
|
|
||||||
},
|
|
||||||
libraryId() {
|
|
||||||
return this.audiobook ? this.audiobook.libraryId : null
|
|
||||||
},
|
|
||||||
libraryProvider() {
|
|
||||||
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
|
|
||||||
},
|
|
||||||
libraryScan() {
|
|
||||||
if (!this.libraryId) return null
|
|
||||||
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
quickMatch() {
|
|
||||||
this.quickMatching = true
|
|
||||||
var matchOptions = {
|
|
||||||
provider: this.libraryProvider,
|
|
||||||
title: this.details.title,
|
|
||||||
author: this.details.author !== this.book.author ? this.details.author : null
|
|
||||||
}
|
|
||||||
this.$axios
|
|
||||||
.$post(`/api/books/${this.audiobookId}/match`, matchOptions)
|
|
||||||
.then((res) => {
|
|
||||||
this.quickMatching = false
|
|
||||||
if (res.warning) {
|
|
||||||
this.$toast.warning(res.warning)
|
|
||||||
} else if (res.updated) {
|
|
||||||
this.$toast.success('Audiobook details updated')
|
|
||||||
} else {
|
|
||||||
this.$toast.info('No updates were made')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
var errMsg = error.response ? error.response.data || '' : ''
|
|
||||||
console.error('Failed to match', error)
|
|
||||||
this.$toast.error(errMsg || 'Failed to match')
|
|
||||||
this.quickMatching = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
audiobookScanComplete(result) {
|
|
||||||
this.rescanning = false
|
|
||||||
if (!result) {
|
|
||||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
|
||||||
} else if (result === 'UPDATED') {
|
|
||||||
this.$toast.success(`Re-Scan complete audiobook was updated`)
|
|
||||||
} else if (result === 'UPTODATE') {
|
|
||||||
this.$toast.success(`Re-Scan complete audiobook was up to date`)
|
|
||||||
} else if (result === 'REMOVED') {
|
|
||||||
this.$toast.error(`Re-Scan complete audiobook was removed`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
rescan() {
|
|
||||||
this.rescanning = true
|
|
||||||
this.$root.socket.once('audiobook_scan_complete', this.audiobookScanComplete)
|
|
||||||
this.$root.socket.emit('scan_audiobook', this.audiobookId)
|
|
||||||
},
|
|
||||||
saveMetadataComplete(result) {
|
|
||||||
this.savingMetadata = false
|
|
||||||
if (result.error) {
|
|
||||||
this.$toast.error(result.error)
|
|
||||||
} else if (result.audiobookId) {
|
|
||||||
var { savedPath } = result
|
|
||||||
if (!savedPath) {
|
|
||||||
this.$toast.error(`Failed to save metadata file (${result.audiobookId})`)
|
|
||||||
} else {
|
|
||||||
this.$toast.success(`Metadata file saved "${result.audiobookTitle}"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
saveMetadata() {
|
|
||||||
this.savingMetadata = true
|
|
||||||
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
|
|
||||||
this.$root.socket.emit('save_metadata', this.audiobookId)
|
|
||||||
},
|
|
||||||
submitForm() {
|
|
||||||
if (this.isProcessing) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.isProcessing = true
|
|
||||||
if (this.$refs.seriesDropdown && this.$refs.seriesDropdown.isFocused) {
|
|
||||||
this.$refs.seriesDropdown.blur()
|
|
||||||
}
|
|
||||||
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
|
|
||||||
this.$refs.genresSelect.forceBlur()
|
|
||||||
}
|
|
||||||
if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {
|
|
||||||
this.$refs.tagsSelect.forceBlur()
|
|
||||||
}
|
|
||||||
this.$nextTick(this.handleForm)
|
|
||||||
},
|
|
||||||
async handleForm() {
|
|
||||||
const updatePayload = {
|
|
||||||
book: this.details,
|
|
||||||
tags: this.newTags
|
|
||||||
}
|
|
||||||
|
|
||||||
var updatedAudiobook = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, updatePayload).catch((error) => {
|
|
||||||
console.error('Failed to update', error)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
this.isProcessing = false
|
|
||||||
if (updatedAudiobook) {
|
|
||||||
this.$toast.success('Update Successful')
|
|
||||||
this.$emit('close')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
init() {
|
|
||||||
this.details.title = this.book.title
|
|
||||||
this.details.subtitle = this.book.subtitle
|
|
||||||
this.details.description = this.book.description
|
|
||||||
this.details.author = this.book.author
|
|
||||||
this.details.narrator = this.book.narrator
|
|
||||||
this.details.genres = this.book.genres || []
|
|
||||||
this.details.series = this.book.series
|
|
||||||
this.details.volumeNumber = this.book.volumeNumber
|
|
||||||
this.details.publishYear = this.book.publishYear
|
|
||||||
this.details.publisher = this.book.publisher || null
|
|
||||||
this.details.language = this.book.language || null
|
|
||||||
this.details.isbn = this.book.isbn || null
|
|
||||||
this.details.asin = this.book.asin || null
|
|
||||||
|
|
||||||
this.newTags = this.audiobook.tags || []
|
|
||||||
},
|
|
||||||
deleteAudiobook() {
|
|
||||||
if (confirm(`Are you sure you want to remove this audiobook?\n\n*Does not delete your files, only removes the audiobook from AudioBookshelf`)) {
|
|
||||||
this.isProcessing = true
|
|
||||||
this.$axios
|
|
||||||
.$delete(`/api/books/${this.audiobookId}`)
|
|
||||||
.then(() => {
|
|
||||||
console.log('Audiobook removed')
|
|
||||||
this.$toast.success('Audiobook Removed')
|
|
||||||
this.$emit('close')
|
|
||||||
this.isProcessing = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Remove Audiobook failed', error)
|
|
||||||
this.isProcessing = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
checkIsScrollable() {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
if (this.$refs.formWrapper) {
|
|
||||||
if (this.$refs.formWrapper.scrollHeight > this.$refs.formWrapper.clientHeight) {
|
|
||||||
this.isScrollable = true
|
|
||||||
} else {
|
|
||||||
this.isScrollable = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
setResizeObserver() {
|
|
||||||
try {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
this.checkIsScrollable()
|
|
||||||
})
|
|
||||||
resizeObserver.observe(this.$refs.formWrapper)
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to set resize observer')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.setResizeObserver()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.details-form-wrapper {
|
|
||||||
height: calc(100% - 70px);
|
|
||||||
max-height: calc(100% - 70px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
|
||||||
<p class="text-center text-lg mb-4 py-8">Preparing downloads can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted.<br />Download will timeout after 15 minutes.</p>
|
|
||||||
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-4">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div>
|
|
||||||
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
|
|
||||||
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<div>
|
|
||||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
|
||||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
|
||||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
|
||||||
|
|
||||||
<ui-btn v-if="singleDownloadStatus !== $constants.DownloadStatus.READY" :loading="singleDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
|
|
||||||
<div v-else>
|
|
||||||
<ui-btn @click="downloadWithProgress(singleAudioDownload)">Download</ui-btn>
|
|
||||||
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(singleAudioDownload.size) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-full border border-black-200 p-4 my-4">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div>
|
|
||||||
<p v-if="totalFiles > 1" class="text-lg">Zip {{ totalFiles }} Files</p>
|
|
||||||
<p v-else>Zip 1 File</p>
|
|
||||||
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .ZIP file from the contents of the audiobook directory.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<div>
|
|
||||||
<p v-if="zipDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
|
||||||
<p v-if="zipDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
|
||||||
<p v-if="zipDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
|
||||||
|
|
||||||
<ui-btn v-if="zipDownloadStatus !== $constants.DownloadStatus.READY" :loading="zipDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startZipDownload">Start Download</ui-btn>
|
|
||||||
<div v-else>
|
|
||||||
<ui-btn @click="downloadWithProgress(zipDownload)">Download</ui-btn>
|
|
||||||
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(zipDownload.size) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="showM4bDownload" class="w-full flex items-center justify-center absolute bottom-4 left-0 right-0 text-center">
|
|
||||||
<p class="text-error text-lg">* <strong>Experimental:</strong> Merging multiple .m4b files may have issues. <a href="https://github.com/advplyr/audiobookshelf/issues" class="underline text-blue-600" target="_blank">Report issues here.</a></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
|
||||||
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
|
|
||||||
<div class="w-full h-full flex items-center justify-center">
|
|
||||||
<p class="text-lg">Download.... {{ downloadPercent }}%</p>
|
|
||||||
<p class="w-24 font-mono pl-8 text-right">
|
|
||||||
{{ downloadAmount }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
processing: Boolean,
|
|
||||||
audiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
tempDisable: false,
|
|
||||||
isDownloading: false,
|
|
||||||
downloadPercent: '0',
|
|
||||||
downloadAmount: '0 KB'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
singleDownloadStatus(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.tempDisable = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
audiobookId() {
|
|
||||||
return this.audiobook ? this.audiobook.id : null
|
|
||||||
},
|
|
||||||
_audiobook() {
|
|
||||||
return this.audiobook || {}
|
|
||||||
},
|
|
||||||
downloads() {
|
|
||||||
return this.$store.getters['downloads/getDownloads'](this.audiobookId)
|
|
||||||
},
|
|
||||||
singleAudioDownload() {
|
|
||||||
return this.downloads.find((d) => d.type === 'singleAudio')
|
|
||||||
},
|
|
||||||
singleDownloadStatus() {
|
|
||||||
return this.singleAudioDownload ? this.singleAudioDownload.status : false
|
|
||||||
},
|
|
||||||
zipDownload() {
|
|
||||||
return this.downloads.find((d) => d.type === 'zip')
|
|
||||||
},
|
|
||||||
zipDownloadStatus() {
|
|
||||||
return this.zipDownload ? this.zipDownload.status : false
|
|
||||||
},
|
|
||||||
isSingleTrack() {
|
|
||||||
if (!this.audiobook.tracks) return false
|
|
||||||
return this.audiobook.tracks.length === 1
|
|
||||||
},
|
|
||||||
singleTrackPath() {
|
|
||||||
if (!this.isSingleTrack) return null
|
|
||||||
return this.audiobook.tracks[0].path
|
|
||||||
},
|
|
||||||
audioFiles() {
|
|
||||||
return this.audiobook ? this.audiobook.audioFiles || [] : []
|
|
||||||
},
|
|
||||||
otherFiles() {
|
|
||||||
return this.audiobook ? this.audiobook.otherFiles || [] : []
|
|
||||||
},
|
|
||||||
totalFiles() {
|
|
||||||
return this.audioFiles.length + this.otherFiles.length
|
|
||||||
},
|
|
||||||
showM4bDownload() {
|
|
||||||
return !this._audiobook.isMissing && !this._audiobook.isInvalid && this._audiobook.tracks.length
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
startZipDownload() {
|
|
||||||
// console.log('Download request received', this.audiobook)
|
|
||||||
|
|
||||||
this.tempDisable = true
|
|
||||||
setTimeout(() => {
|
|
||||||
this.tempDisable = false
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
var downloadPayload = {
|
|
||||||
audiobookId: this.audiobook.id,
|
|
||||||
type: 'zip'
|
|
||||||
}
|
|
||||||
this.$root.socket.emit('download', downloadPayload)
|
|
||||||
},
|
|
||||||
startSingleAudioDownload() {
|
|
||||||
// console.log('Download request received', this.audiobook)
|
|
||||||
|
|
||||||
this.tempDisable = true
|
|
||||||
setTimeout(() => {
|
|
||||||
this.tempDisable = false
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
var downloadPayload = {
|
|
||||||
audiobookId: this.audiobook.id,
|
|
||||||
type: 'singleAudio',
|
|
||||||
includeMetadata: true,
|
|
||||||
includeCover: true
|
|
||||||
}
|
|
||||||
this.$root.socket.emit('download', downloadPayload)
|
|
||||||
},
|
|
||||||
downloadWithProgress(download) {
|
|
||||||
var downloadId = download.id
|
|
||||||
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
|
|
||||||
var filename = download.filename
|
|
||||||
|
|
||||||
this.isDownloading = true
|
|
||||||
|
|
||||||
var request = new XMLHttpRequest()
|
|
||||||
request.responseType = 'blob'
|
|
||||||
request.open('get', downloadUrl, true)
|
|
||||||
request.setRequestHeader('Authorization', `Bearer ${this.$store.getters['user/getToken']}`)
|
|
||||||
request.send()
|
|
||||||
|
|
||||||
request.onreadystatechange = () => {
|
|
||||||
if (request.readyState === 4) {
|
|
||||||
this.isDownloading = false
|
|
||||||
}
|
|
||||||
if (request.readyState == 4 && request.status == 200) {
|
|
||||||
const url = window.URL.createObjectURL(request.response)
|
|
||||||
|
|
||||||
const anchor = document.createElement('a')
|
|
||||||
anchor.href = url
|
|
||||||
anchor.download = filename
|
|
||||||
document.body.appendChild(anchor)
|
|
||||||
anchor.click()
|
|
||||||
setTimeout(() => {
|
|
||||||
if (anchor) anchor.remove()
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onerror = (err) => {
|
|
||||||
console.error('Download error', err)
|
|
||||||
this.isDownloading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onprogress = (e) => {
|
|
||||||
const percent_complete = Math.floor((e.loaded / e.total) * 100)
|
|
||||||
this.downloadAmount = this.$bytesPretty(e.loaded)
|
|
||||||
this.downloadPercent = percent_complete
|
|
||||||
|
|
||||||
// const duration = (new Date().getTime() - startTime) / 1000
|
|
||||||
// const bps = e.loaded / duration
|
|
||||||
// const kbps = Math.floor(bps / 1024)
|
|
||||||
// const time = (e.total - e.loaded) / bps
|
|
||||||
// const seconds = Math.floor(time % 60)
|
|
||||||
// const minutes = Math.floor(time / 60)
|
|
||||||
// console.log(`${percent_complete}% - ${kbps} Kbps - ${minutes} min ${seconds} sec remaining`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
|
||||||
<div class="mb-4">
|
|
||||||
<template v-if="hasTracks">
|
|
||||||
<div class="w-full bg-primary px-4 py-2 flex items-center">
|
|
||||||
<p class="pr-4">Audio Tracks</p>
|
|
||||||
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
|
||||||
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
|
||||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`">
|
|
||||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
|
||||||
</nuxt-link>
|
|
||||||
</div>
|
|
||||||
<table class="text-sm tracksTable">
|
|
||||||
<tr class="font-book">
|
|
||||||
<th>#</th>
|
|
||||||
<th class="text-left">Filename</th>
|
|
||||||
<th class="text-left">Size</th>
|
|
||||||
<th class="text-left">Duration</th>
|
|
||||||
<th v-if="showDownload" class="text-center">Download</th>
|
|
||||||
</tr>
|
|
||||||
<template v-for="track in tracksCleaned">
|
|
||||||
<tr :key="track.index">
|
|
||||||
<td class="text-center">
|
|
||||||
<p>{{ track.index }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
|
|
||||||
<td class="font-mono">
|
|
||||||
{{ $bytesPretty(track.size) }}
|
|
||||||
</td>
|
|
||||||
<td class="font-mono">
|
|
||||||
{{ $secondsToTimestamp(track.duration) }}
|
|
||||||
</td>
|
|
||||||
<td v-if="showDownload" class="font-mono text-center">
|
|
||||||
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</table>
|
|
||||||
</template>
|
|
||||||
<div v-else class="flex my-4 text-center justify-center text-xl">No Audio Tracks</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<tables-all-files-table :audiobook="audiobook" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
audiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
tracks: null,
|
|
||||||
showFullPath: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
audiobook: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
audiobookPath() {
|
|
||||||
return this.audiobook.path
|
|
||||||
},
|
|
||||||
tracksCleaned() {
|
|
||||||
return this.tracks.map((track) => {
|
|
||||||
var trackPath = track.path.replace(/\\/g, '/')
|
|
||||||
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
|
|
||||||
|
|
||||||
return {
|
|
||||||
...track,
|
|
||||||
relativePath: trackPath
|
|
||||||
.replace(audiobookPath + '/', '')
|
|
||||||
.replace(/%/g, '%25')
|
|
||||||
.replace(/#/g, '%23')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
userCanUpdate() {
|
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
|
||||||
},
|
|
||||||
isMissing() {
|
|
||||||
return this.audiobook.isMissing
|
|
||||||
},
|
|
||||||
showDownload() {
|
|
||||||
return this.userCanDownload && !this.isMissing
|
|
||||||
},
|
|
||||||
hasTracks() {
|
|
||||||
return this.audiobook.tracks.length
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
init() {
|
|
||||||
this.tracks = this.audiobook.tracks
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
|
|
||||||
<form @submit.prevent="submitSearch">
|
|
||||||
<div class="flex items-center justify-start -mx-1 h-20">
|
|
||||||
<div class="w-40 px-1">
|
|
||||||
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
|
||||||
</div>
|
|
||||||
<div class="w-72 px-1">
|
|
||||||
<ui-text-input-with-label v-model="searchTitle" :label="provider == 'audible' ? 'Search Title or ASIN' : 'Search Title'" placeholder="Search" />
|
|
||||||
</div>
|
|
||||||
<div class="w-72 px-1">
|
|
||||||
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
|
||||||
</div>
|
|
||||||
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div v-show="processing" class="flex h-full items-center justify-center">
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
<div v-show="!processing && !searchResults.length && hasSearched" class="flex h-full items-center justify-center">
|
|
||||||
<p>No Results</p>
|
|
||||||
</div>
|
|
||||||
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper">
|
|
||||||
<template v-for="(res, index) in searchResults">
|
|
||||||
<cards-book-match-card :key="index" :book="res" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
|
||||||
<div class="flex mb-2">
|
|
||||||
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
|
|
||||||
<span class="material-icons text-3xl">arrow_back</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-xl pl-3">Update Book Details</p>
|
|
||||||
</div>
|
|
||||||
<form @submit.prevent="submitMatchUpdate">
|
|
||||||
<div v-if="selectedMatch.cover" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.cover" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" label="Cover" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.title" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.title" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" label="Title" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.subtitle" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.subtitle" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" label="Subtitle" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.author" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.author" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" label="Author" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.narrator" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.narrator" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.narrator" :disabled="!selectedMatchUsage.narrator" label="Narrator" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.description" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.description" />
|
|
||||||
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.publisher" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.publisher" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" label="Publisher" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.publishYear" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.publishYear" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.publishYear" :disabled="!selectedMatchUsage.publishYear" label="Publish Year" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="selectedMatch.series" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.series" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" label="Series" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.volumeNumber" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.volumeNumber" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.volumeNumber" :disabled="!selectedMatchUsage.volumeNumber" label="Volume Number" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.isbn" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedMatch.asin" class="flex items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.asin" />
|
|
||||||
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" class="flex-grow ml-4" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-end py-2">
|
|
||||||
<ui-btn color="success" type="submit">Update</ui-btn>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
processing: Boolean,
|
|
||||||
audiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
audiobookId: null,
|
|
||||||
searchTitle: null,
|
|
||||||
searchAuthor: null,
|
|
||||||
lastSearch: null,
|
|
||||||
provider: 'google',
|
|
||||||
searchResults: [],
|
|
||||||
hasSearched: false,
|
|
||||||
selectedMatch: null,
|
|
||||||
selectedMatchUsage: {
|
|
||||||
title: true,
|
|
||||||
subtitle: true,
|
|
||||||
cover: true,
|
|
||||||
author: true,
|
|
||||||
narrator: true,
|
|
||||||
description: true,
|
|
||||||
publisher: true,
|
|
||||||
publishYear: true,
|
|
||||||
series: true,
|
|
||||||
volumeNumber: true,
|
|
||||||
asin: true,
|
|
||||||
isbn: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
audiobook: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
isProcessing: {
|
|
||||||
get() {
|
|
||||||
return this.processing
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('update:processing', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
bookCoverAspectRatio() {
|
|
||||||
return this.$store.getters['getBookCoverAspectRatio']
|
|
||||||
},
|
|
||||||
providers() {
|
|
||||||
return this.$store.state.scanners.providers
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
persistProvider() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem('book-provider', this.provider)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('PersistProvider', error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getSearchQuery() {
|
|
||||||
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${this.searchTitle}`
|
|
||||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
|
||||||
return searchQuery
|
|
||||||
},
|
|
||||||
submitSearch() {
|
|
||||||
if (!this.searchTitle) {
|
|
||||||
this.$toast.warning('Search title is required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.persistProvider()
|
|
||||||
this.runSearch()
|
|
||||||
},
|
|
||||||
async runSearch() {
|
|
||||||
var searchQuery = this.getSearchQuery()
|
|
||||||
if (this.lastSearch === searchQuery) return
|
|
||||||
this.searchResults = []
|
|
||||||
this.isProcessing = true
|
|
||||||
this.lastSearch = searchQuery
|
|
||||||
var results = await this.$axios.$get(`/api/search/books?${searchQuery}`).catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
results = results.filter((res) => {
|
|
||||||
return !!res.title
|
|
||||||
})
|
|
||||||
this.searchResults = results
|
|
||||||
this.isProcessing = false
|
|
||||||
this.hasSearched = true
|
|
||||||
},
|
|
||||||
init() {
|
|
||||||
this.selectedMatch = null
|
|
||||||
this.selectedMatchUsage = {
|
|
||||||
title: true,
|
|
||||||
subtitle: true,
|
|
||||||
cover: true,
|
|
||||||
author: true,
|
|
||||||
narrator: true,
|
|
||||||
description: true,
|
|
||||||
publisher: true,
|
|
||||||
publishYear: true,
|
|
||||||
series: true,
|
|
||||||
volumeNumber: true,
|
|
||||||
asin: true,
|
|
||||||
isbn: true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.audiobook.id !== this.audiobookId) {
|
|
||||||
this.searchResults = []
|
|
||||||
this.hasSearched = false
|
|
||||||
this.audiobookId = this.audiobook.id
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.audiobook.book || !this.audiobook.book.title) {
|
|
||||||
this.searchTitle = null
|
|
||||||
this.searchAuthor = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.searchTitle = this.audiobook.book.title
|
|
||||||
this.searchAuthor = this.audiobook.book.authorFL || ''
|
|
||||||
this.provider = localStorage.getItem('book-provider') || 'google'
|
|
||||||
},
|
|
||||||
selectMatch(match) {
|
|
||||||
this.selectedMatch = match
|
|
||||||
},
|
|
||||||
buildMatchUpdatePayload() {
|
|
||||||
var updatePayload = {}
|
|
||||||
for (const key in this.selectedMatchUsage) {
|
|
||||||
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
|
||||||
updatePayload[key] = this.selectedMatch[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updatePayload
|
|
||||||
},
|
|
||||||
async submitMatchUpdate() {
|
|
||||||
var updatePayload = this.buildMatchUpdatePayload()
|
|
||||||
if (!Object.keys(updatePayload).length) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.isProcessing = true
|
|
||||||
|
|
||||||
if (updatePayload.cover) {
|
|
||||||
var coverPayload = {
|
|
||||||
url: updatePayload.cover
|
|
||||||
}
|
|
||||||
var success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, coverPayload).catch((error) => {
|
|
||||||
console.error('Failed to update', error)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
if (success) {
|
|
||||||
this.$toast.success('Book Cover Updated')
|
|
||||||
} else {
|
|
||||||
this.$toast.error('Book Cover Failed to Update')
|
|
||||||
}
|
|
||||||
console.log('Updated cover')
|
|
||||||
delete updatePayload.cover
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(updatePayload).length) {
|
|
||||||
var bookUpdatePayload = {
|
|
||||||
book: updatePayload
|
|
||||||
}
|
|
||||||
var success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, bookUpdatePayload).catch((error) => {
|
|
||||||
console.error('Failed to update', error)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
if (success) {
|
|
||||||
this.$toast.success('Book Details Updated')
|
|
||||||
this.selectedMatch = null
|
|
||||||
this.$emit('selectTab', 'details')
|
|
||||||
} else {
|
|
||||||
this.$toast.error('Book Details Failed to Update')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.selectedMatch = null
|
|
||||||
}
|
|
||||||
this.isProcessing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.matchListWrapper {
|
|
||||||
height: calc(100% - 80px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
|
||||||
<template v-if="hasTracks">
|
|
||||||
<div class="w-full bg-primary px-4 py-2 flex items-center">
|
|
||||||
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
|
||||||
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
|
||||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`" class="mr-4">
|
|
||||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
|
||||||
</nuxt-link>
|
|
||||||
</div>
|
|
||||||
<table class="text-sm tracksTable">
|
|
||||||
<tr class="font-book">
|
|
||||||
<th>#</th>
|
|
||||||
<th class="text-left">Filename</th>
|
|
||||||
<th class="text-left">Size</th>
|
|
||||||
<th class="text-left">Duration</th>
|
|
||||||
<th v-if="showDownload" class="text-center">Download</th>
|
|
||||||
</tr>
|
|
||||||
<template v-for="track in tracksCleaned">
|
|
||||||
<tr :key="track.index">
|
|
||||||
<td class="text-center">
|
|
||||||
<p>{{ track.index }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
|
|
||||||
<td class="font-mono">
|
|
||||||
{{ $bytesPretty(track.size) }}
|
|
||||||
</td>
|
|
||||||
<td class="font-mono">
|
|
||||||
{{ $secondsToTimestamp(track.duration) }}
|
|
||||||
</td>
|
|
||||||
<td v-if="showDownload" class="font-mono text-center">
|
|
||||||
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</table>
|
|
||||||
</template>
|
|
||||||
<div v-else class="flex my-4 text-center justify-center text-xl">No Audio Tracks</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
audiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
tracks: null,
|
|
||||||
showFullPath: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
audiobook: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
audiobookPath() {
|
|
||||||
return this.audiobook.path
|
|
||||||
},
|
|
||||||
tracksCleaned() {
|
|
||||||
return this.tracks.map((track) => {
|
|
||||||
var trackPath = track.path.replace(/\\/g, '/')
|
|
||||||
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
|
|
||||||
|
|
||||||
return {
|
|
||||||
...track,
|
|
||||||
relativePath: trackPath
|
|
||||||
.replace(audiobookPath + '/', '')
|
|
||||||
.replace(/%/g, '%25')
|
|
||||||
.replace(/#/g, '%23')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
userCanUpdate() {
|
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
|
||||||
},
|
|
||||||
isMissing() {
|
|
||||||
return this.audiobook.isMissing
|
|
||||||
},
|
|
||||||
showDownload() {
|
|
||||||
return this.userCanDownload && !this.isMissing
|
|
||||||
},
|
|
||||||
hasTracks() {
|
|
||||||
return this.audiobook.tracks.length
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
init() {
|
|
||||||
this.tracks = this.audiobook.tracks
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
+66
-62
@@ -5,7 +5,7 @@
|
|||||||
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ title }}</p>
|
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="absolute -top-10 left-0 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">
|
||||||
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book 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>
|
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book 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>
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
|
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
|
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
|
||||||
<component v-if="audiobook && show" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
<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>
|
||||||
@@ -29,49 +29,44 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
processing: false,
|
processing: false,
|
||||||
audiobook: null,
|
libraryItem: null,
|
||||||
fetchOnShow: false,
|
|
||||||
tabs: [
|
tabs: [
|
||||||
{
|
{
|
||||||
id: 'details',
|
id: 'details',
|
||||||
title: 'Details',
|
title: 'Details',
|
||||||
component: 'modals-edit-tabs-details'
|
component: 'modals-item-tabs-details'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cover',
|
id: 'cover',
|
||||||
title: 'Cover',
|
title: 'Cover',
|
||||||
component: 'modals-edit-tabs-cover'
|
component: 'modals-item-tabs-cover'
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// id: 'tracks',
|
|
||||||
// title: 'Tracks',
|
|
||||||
// component: 'modals-edit-tabs-tracks'
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
id: 'chapters',
|
id: 'chapters',
|
||||||
title: 'Chapters',
|
title: 'Chapters',
|
||||||
component: 'modals-edit-tabs-chapters'
|
component: 'modals-item-tabs-chapters'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'episodes',
|
||||||
|
title: 'Episodes',
|
||||||
|
component: 'modals-item-tabs-episodes'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'files',
|
id: 'files',
|
||||||
title: 'Files',
|
title: 'Files',
|
||||||
component: 'modals-edit-tabs-files'
|
component: 'modals-item-tabs-files'
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'download',
|
|
||||||
title: 'Download',
|
|
||||||
component: 'modals-edit-tabs-download'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'match',
|
id: 'match',
|
||||||
title: 'Match',
|
title: 'Match',
|
||||||
component: 'modals-edit-tabs-match'
|
component: 'modals-item-tabs-match'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'merge',
|
||||||
|
title: 'Merge',
|
||||||
|
component: 'modals-item-tabs-merge',
|
||||||
|
experimental: true
|
||||||
}
|
}
|
||||||
// {
|
|
||||||
// id: 'authors',
|
|
||||||
// title: 'Authors',
|
|
||||||
// component: 'modals-edit-tabs-authors'
|
|
||||||
// }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -89,12 +84,7 @@ export default {
|
|||||||
this.selectedTab = availableTabIds[0]
|
this.selectedTab = availableTabIds[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) {
|
this.libraryItem = null
|
||||||
if (this.fetchOnShow) this.fetchFull()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.fetchOnShow = false
|
|
||||||
this.audiobook = null
|
|
||||||
this.init()
|
this.init()
|
||||||
this.registerListeners()
|
this.registerListeners()
|
||||||
} else {
|
} else {
|
||||||
@@ -120,22 +110,26 @@ export default {
|
|||||||
this.$store.commit('setEditModalTab', val)
|
this.$store.commit('setEditModalTab', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
},
|
||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
availableTabs() {
|
availableTabs() {
|
||||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||||
return this.tabs.filter((tab) => {
|
return this.tabs.filter((tab) => {
|
||||||
if (tab.id === 'download' && this.isMissing) return false
|
if (tab.experimental && !this.showExperimentalFeatures) return false
|
||||||
if ((tab.id === 'download' || tab.id === 'files' || tab.id === 'authors') && this.userCanDownload) return true
|
if (tab.id === 'merge' && (this.isMissing || this.mediaType !== 'book')) return false
|
||||||
if (tab.id !== 'download' && tab.id !== 'files' && tab.id !== 'authors' && this.userCanUpdate) return true
|
if (this.mediaType == 'podcast' && tab.id == 'chapters') return false
|
||||||
if (tab.id === 'match' && this.userCanUpdate && this.showExperimentalFeatures) return true
|
if (this.mediaType == 'book' && tab.id == 'episodes') return false
|
||||||
|
|
||||||
|
if ((tab.id === 'merge' || tab.id === 'files') && this.userCanDownload) return true
|
||||||
|
if (tab.id !== 'merge' && tab.id !== 'files' && this.userCanUpdate) return true
|
||||||
|
if (tab.id === 'match' && this.userCanUpdate) return true
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -148,26 +142,32 @@ export default {
|
|||||||
return _tab ? _tab.component : ''
|
return _tab ? _tab.component : ''
|
||||||
},
|
},
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return this.selectedAudiobook.isMissing
|
return this.selectedLibraryItem.isMissing
|
||||||
},
|
},
|
||||||
selectedAudiobook() {
|
selectedLibraryItem() {
|
||||||
return this.$store.state.selectedAudiobook || {}
|
return this.$store.state.selectedLibraryItem || {}
|
||||||
},
|
},
|
||||||
selectedAudiobookId() {
|
selectedLibraryItemId() {
|
||||||
return this.selectedAudiobook.id
|
return this.selectedLibraryItem.id
|
||||||
},
|
},
|
||||||
book() {
|
media() {
|
||||||
return this.audiobook ? this.audiobook.book || {} : {}
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
mediaType() {
|
||||||
|
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.book.title || 'No Title'
|
return this.mediaMetadata.title || 'No Title'
|
||||||
},
|
},
|
||||||
bookshelfBookIds() {
|
bookshelfBookIds() {
|
||||||
return this.$store.state.bookshelfBookIds || []
|
return this.$store.state.bookshelfBookIds || []
|
||||||
},
|
},
|
||||||
currentBookshelfIndex() {
|
currentBookshelfIndex() {
|
||||||
if (!this.bookshelfBookIds.length) return 0
|
if (!this.bookshelfBookIds.length) return 0
|
||||||
return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedAudiobookId)
|
return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedLibraryItemId)
|
||||||
},
|
},
|
||||||
canGoPrev() {
|
canGoPrev() {
|
||||||
return this.bookshelfBookIds.length && this.currentBookshelfIndex > 0
|
return this.bookshelfBookIds.length && this.currentBookshelfIndex > 0
|
||||||
@@ -181,15 +181,18 @@ export default {
|
|||||||
if (this.currentBookshelfIndex - 1 < 0) return
|
if (this.currentBookshelfIndex - 1 < 0) return
|
||||||
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/books/${prevBookId}`).catch((error) => {
|
var prevBook = await this.$axios.$get(`/api/items/${prevBookId}?expanded=1`).catch((error) => {
|
||||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
|
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
|
||||||
this.$toast.error(errorMsg)
|
this.$toast.error(errorMsg)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
this.processing = false
|
this.processing = false
|
||||||
if (prevBook) {
|
if (prevBook) {
|
||||||
this.$store.commit('showEditModalOnTab', { audiobook: prevBook, tab: this.selectedTab })
|
this.unregisterListeners()
|
||||||
this.$nextTick(this.init)
|
this.libraryItem = prevBook
|
||||||
|
this.selectedTab = 'details'
|
||||||
|
this.$store.commit('setSelectedLibraryItem', prevBook)
|
||||||
|
this.$nextTick(this.registerListeners)
|
||||||
} else {
|
} else {
|
||||||
console.error('Book not found', prevBookId)
|
console.error('Book not found', prevBookId)
|
||||||
}
|
}
|
||||||
@@ -198,15 +201,18 @@ export default {
|
|||||||
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
|
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
|
||||||
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/books/${nextBookId}`).catch((error) => {
|
var nextBook = await this.$axios.$get(`/api/items/${nextBookId}?expanded=1`).catch((error) => {
|
||||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
|
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
|
||||||
this.$toast.error(errorMsg)
|
this.$toast.error(errorMsg)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
this.processing = false
|
this.processing = false
|
||||||
if (nextBook) {
|
if (nextBook) {
|
||||||
this.$store.commit('showEditModalOnTab', { audiobook: nextBook, tab: this.selectedTab })
|
this.unregisterListeners()
|
||||||
this.$nextTick(this.init)
|
this.libraryItem = nextBook
|
||||||
|
this.selectedTab = 'details'
|
||||||
|
this.$store.commit('setSelectedLibraryItem', nextBook)
|
||||||
|
this.$nextTick(this.registerListeners)
|
||||||
} else {
|
} else {
|
||||||
console.error('Book not found', nextBookId)
|
console.error('Book not found', nextBookId)
|
||||||
}
|
}
|
||||||
@@ -216,23 +222,19 @@ export default {
|
|||||||
this.selectedTab = tab
|
this.selectedTab = tab
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
audiobookUpdated() {
|
libraryItemUpdated(expandedLibraryItem) {
|
||||||
if (!this.show) this.fetchOnShow = true
|
this.libraryItem = expandedLibraryItem
|
||||||
else {
|
|
||||||
this.fetchFull()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedAudiobookId })
|
|
||||||
this.fetchFull()
|
this.fetchFull()
|
||||||
},
|
},
|
||||||
async fetchFull() {
|
async fetchFull() {
|
||||||
try {
|
try {
|
||||||
this.processing = true
|
this.processing = true
|
||||||
this.audiobook = await this.$axios.$get(`/api/books/${this.selectedAudiobookId}`)
|
this.libraryItem = await this.$axios.$get(`/api/items/${this.selectedLibraryItemId}?expanded=1`)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch audiobook', this.selectedAudiobookId, error)
|
console.error('Failed to fetch audiobook', this.selectedLibraryItemId, error)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
@@ -246,9 +248,11 @@ export default {
|
|||||||
},
|
},
|
||||||
registerListeners() {
|
registerListeners() {
|
||||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||||
|
this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
|
||||||
},
|
},
|
||||||
unregisterListeners() {
|
unregisterListeners() {
|
||||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||||
|
this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {},
|
mounted() {},
|
||||||
@@ -258,7 +262,7 @@ export default {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style scoped>
|
||||||
.tab {
|
.tab {
|
||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||||
|
<div class="w-full mb-4">
|
||||||
|
<div v-if="chapters.length" class="w-full p-4 bg-primary">
|
||||||
|
<p>Audiobook Chapters</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
|
||||||
|
<table v-else class="text-sm tracksTable">
|
||||||
|
<tr class="font-book">
|
||||||
|
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
||||||
|
<th class="text-left">Title</th>
|
||||||
|
<th class="text-center">Start</th>
|
||||||
|
<th class="text-center">End</th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="chapter in chapters" :key="chapter.id">
|
||||||
|
<td class="text-left">
|
||||||
|
<p class="px-4">{{ chapter.id }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="font-book">
|
||||||
|
{{ chapter.title }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-center">
|
||||||
|
{{ $secondsToTimestamp(chapter.start) }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-center">
|
||||||
|
{{ $secondsToTimestamp(chapter.end) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
chapters() {
|
||||||
|
return this.media.chapters || []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
+66
-67
@@ -2,9 +2,9 @@
|
|||||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative">
|
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<covers-book-cover :audiobook="audiobook" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<!-- book cover overlay -->
|
<!-- book cover overlay -->
|
||||||
<div v-if="book.cover" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
||||||
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
|
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
|
||||||
<div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
|
<div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
|
||||||
<span class="material-icons">delete</span>
|
<span class="material-icons">delete</span>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
<div v-if="showLocalCovers" class="flex items-center justify-center">
|
<div v-if="showLocalCovers" class="flex items-center justify-center">
|
||||||
<template v-for="cover in localCovers">
|
<template v-for="cover in localCovers">
|
||||||
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
||||||
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
||||||
<img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" />
|
<img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
@@ -47,9 +47,9 @@
|
|||||||
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-72 px-1">
|
<div class="w-72 px-1">
|
||||||
<ui-text-input-with-label v-model="searchTitle" :label="provider == 'audible' ? 'Search Title or ASIN' : 'Search Title'" placeholder="Search" />
|
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" placeholder="Search" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-72 px-1">
|
<div v-show="provider != 'itunes'" class="w-72 px-1">
|
||||||
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
||||||
</div>
|
</div>
|
||||||
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
processing: Boolean,
|
processing: Boolean,
|
||||||
audiobook: {
|
libraryItem: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
}
|
}
|
||||||
@@ -98,25 +98,11 @@ export default {
|
|||||||
showLocalCovers: false,
|
showLocalCovers: false,
|
||||||
previewUpload: null,
|
previewUpload: null,
|
||||||
selectedFile: null,
|
selectedFile: null,
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
text: 'Google Books',
|
|
||||||
value: 'google'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Open Library',
|
|
||||||
value: 'openlibrary'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Audible',
|
|
||||||
value: 'audible'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
provider: 'google'
|
provider: 'google'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
audiobook: {
|
libraryItem: {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
handler(newVal) {
|
handler(newVal) {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
@@ -134,23 +120,41 @@ export default {
|
|||||||
this.$emit('update:processing', val)
|
this.$emit('update:processing', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
providers() {
|
||||||
|
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||||
|
return this.$store.state.scanners.providers
|
||||||
|
},
|
||||||
|
searchTitleLabel() {
|
||||||
|
if (this.provider == 'audible') return 'Search Title or ASIN'
|
||||||
|
else if (this.provider == 'itunes') return 'Search Term'
|
||||||
|
return 'Search Title'
|
||||||
|
},
|
||||||
coverAspectRatio() {
|
coverAspectRatio() {
|
||||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||||
},
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||||
},
|
},
|
||||||
audiobookId() {
|
libraryItemId() {
|
||||||
return this.audiobook ? this.audiobook.id : null
|
return this.libraryItem ? this.libraryItem.id : null
|
||||||
},
|
},
|
||||||
book() {
|
mediaType() {
|
||||||
return this.audiobook ? this.audiobook.book || {} : {}
|
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||||
},
|
},
|
||||||
audiobookPath() {
|
isPodcast() {
|
||||||
return this.audiobook ? this.audiobook.path : null
|
return this.mediaType == 'podcast'
|
||||||
},
|
},
|
||||||
otherFiles() {
|
media() {
|
||||||
return this.audiobook ? this.audiobook.otherFiles || [] : []
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
coverPath() {
|
||||||
|
return this.media.coverPath
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
libraryFiles() {
|
||||||
|
return this.libraryItem ? this.libraryItem.libraryFiles || [] : []
|
||||||
},
|
},
|
||||||
userCanUpload() {
|
userCanUpload() {
|
||||||
return this.$store.getters['user/getUserCanUpload']
|
return this.$store.getters['user/getUserCanUpload']
|
||||||
@@ -159,12 +163,11 @@ export default {
|
|||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
},
|
||||||
localCovers() {
|
localCovers() {
|
||||||
return this.otherFiles
|
return this.libraryFiles
|
||||||
.filter((f) => f.filetype === 'image')
|
.filter((f) => f.fileType === 'image')
|
||||||
.map((file) => {
|
.map((file) => {
|
||||||
var _file = { ...file }
|
var _file = { ...file }
|
||||||
var imgRelPath = _file.path.replace(this.audiobookPath, '')
|
_file.localPath = `/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}`
|
||||||
_file.localPath = `/s/book/${this.audiobookId}/${imgRelPath}`
|
|
||||||
return _file
|
return _file
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -176,7 +179,7 @@ export default {
|
|||||||
form.set('cover', this.selectedFile)
|
form.set('cover', this.selectedFile)
|
||||||
|
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/books/${this.audiobook.id}/cover`, form)
|
.$post(`/api/items/${this.libraryItemId}/cover`, form)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
this.$toast.error(data.error)
|
this.$toast.error(data.error)
|
||||||
@@ -209,17 +212,18 @@ export default {
|
|||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.showLocalCovers = false
|
this.showLocalCovers = false
|
||||||
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.authorFL)) {
|
if (this.coversFound.length && (this.searchTitle !== this.mediaMetadata.title || this.searchAuthor !== this.mediaMetadata.authorName)) {
|
||||||
this.coversFound = []
|
this.coversFound = []
|
||||||
this.hasSearched = false
|
this.hasSearched = false
|
||||||
}
|
}
|
||||||
this.imageUrl = this.book.cover || ''
|
this.imageUrl = this.media.coverPath || ''
|
||||||
this.searchTitle = this.book.title || ''
|
this.searchTitle = this.mediaMetadata.title || ''
|
||||||
this.searchAuthor = this.book.authorFL || ''
|
this.searchAuthor = this.mediaMetadata.authorName || ''
|
||||||
this.provider = localStorage.getItem('book-provider') || 'openlibrary'
|
if (this.isPodcast) this.provider = 'itunes'
|
||||||
|
else this.provider = localStorage.getItem('book-provider') || 'google'
|
||||||
},
|
},
|
||||||
removeCover() {
|
removeCover() {
|
||||||
if (!this.book.cover) {
|
if (!this.media.coverPath) {
|
||||||
this.imageUrl = ''
|
this.imageUrl = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -229,7 +233,7 @@ export default {
|
|||||||
this.updateCover(this.imageUrl)
|
this.updateCover(this.imageUrl)
|
||||||
},
|
},
|
||||||
async updateCover(cover) {
|
async updateCover(cover) {
|
||||||
if (cover === this.book.cover) {
|
if (cover === this.coverPath) {
|
||||||
console.warn('Cover has not changed..', cover)
|
console.warn('Cover has not changed..', cover)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -237,9 +241,21 @@ export default {
|
|||||||
this.isProcessing = true
|
this.isProcessing = true
|
||||||
var success = false
|
var success = false
|
||||||
|
|
||||||
// Download cover from url and use
|
if (!cover) {
|
||||||
if (cover.startsWith('http:') || cover.startsWith('https:')) {
|
// Remove cover
|
||||||
success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, { url: cover }).catch((error) => {
|
success = await this.$axios
|
||||||
|
.$delete(`/api/items/${this.libraryItemId}/cover`)
|
||||||
|
.then(() => true)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to remove cover', error)
|
||||||
|
if (error.response && error.response.data) {
|
||||||
|
this.$toast.error(error.response.data)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
} else if (cover.startsWith('http:') || cover.startsWith('https:')) {
|
||||||
|
// Download cover from url and use
|
||||||
|
success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover }).catch((error) => {
|
||||||
console.error('Failed to download cover from url', error)
|
console.error('Failed to download cover from url', error)
|
||||||
if (error.response && error.response.data) {
|
if (error.response && error.response.data) {
|
||||||
this.$toast.error(error.response.data)
|
this.$toast.error(error.response.data)
|
||||||
@@ -249,11 +265,9 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
// Update local cover url
|
// Update local cover url
|
||||||
const updatePayload = {
|
const updatePayload = {
|
||||||
book: {
|
cover
|
||||||
cover: cover
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, updatePayload).catch((error) => {
|
success = await this.$axios.$patch(`/api/items/${this.libraryItemId}/cover`, updatePayload).catch((error) => {
|
||||||
console.error('Failed to update', error)
|
console.error('Failed to update', error)
|
||||||
if (error.response && error.response.data) {
|
if (error.response && error.response.data) {
|
||||||
this.$toast.error(error.response.data)
|
this.$toast.error(error.response.data)
|
||||||
@@ -263,15 +277,16 @@ export default {
|
|||||||
}
|
}
|
||||||
if (success) {
|
if (success) {
|
||||||
this.$toast.success('Update Successful')
|
this.$toast.success('Update Successful')
|
||||||
this.$emit('close')
|
// this.$emit('close')
|
||||||
} else {
|
} else {
|
||||||
this.imageUrl = this.book.cover || ''
|
this.imageUrl = this.media.coverPath || ''
|
||||||
}
|
}
|
||||||
this.isProcessing = false
|
this.isProcessing = false
|
||||||
},
|
},
|
||||||
getSearchQuery() {
|
getSearchQuery() {
|
||||||
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
|
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
|
||||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
||||||
|
if (this.isPodcast) searchQuery += '&podcast=1'
|
||||||
return searchQuery
|
return searchQuery
|
||||||
},
|
},
|
||||||
persistProvider() {
|
persistProvider() {
|
||||||
@@ -296,23 +311,7 @@ export default {
|
|||||||
this.hasSearched = true
|
this.hasSearched = true
|
||||||
},
|
},
|
||||||
setCover(coverFile) {
|
setCover(coverFile) {
|
||||||
this.isProcessing = true
|
this.updateCover(coverFile.metadata.path)
|
||||||
this.$axios
|
|
||||||
.$patch(`/api/books/${this.audiobook.id}/coverfile`, coverFile)
|
|
||||||
.then((data) => {
|
|
||||||
console.log('response data', data)
|
|
||||||
if (data && typeof data === 'string') {
|
|
||||||
this.$toast.success(data)
|
|
||||||
}
|
|
||||||
this.isProcessing = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to update', error)
|
|
||||||
if (error.response && error.response.data) {
|
|
||||||
this.$toast.error(error.response.data)
|
|
||||||
}
|
|
||||||
this.isProcessing = false
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full relative">
|
||||||
|
<widgets-book-details-edit v-if="mediaType == 'book'" ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" />
|
||||||
|
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" />
|
||||||
|
|
||||||
|
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
|
||||||
|
<div class="flex items-center px-4">
|
||||||
|
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
|
||||||
|
|
||||||
|
<div class="flex-grow" />
|
||||||
|
|
||||||
|
<ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
|
||||||
|
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
||||||
|
</ui-tooltip>
|
||||||
|
|
||||||
|
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
|
||||||
|
<ui-btn v-if="isRootUser && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||||
|
</ui-tooltip>
|
||||||
|
|
||||||
|
<ui-btn @click="submitForm">Submit</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
processing: Boolean,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
resettingProgress: false,
|
||||||
|
isScrollable: false,
|
||||||
|
rescanning: false,
|
||||||
|
quickMatching: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isProcessing: {
|
||||||
|
get() {
|
||||||
|
return this.processing
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('update:processing', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isFile() {
|
||||||
|
return !!this.libraryItem && this.libraryItem.isFile
|
||||||
|
},
|
||||||
|
isRootUser() {
|
||||||
|
return this.$store.getters['user/getIsRoot']
|
||||||
|
},
|
||||||
|
isMissing() {
|
||||||
|
return !!this.libraryItem && !!this.libraryItem.isMissing
|
||||||
|
},
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem ? this.libraryItem.id : null
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
mediaType() {
|
||||||
|
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
userCanDelete() {
|
||||||
|
return this.$store.getters['user/getUserCanDelete']
|
||||||
|
},
|
||||||
|
libraryId() {
|
||||||
|
return this.libraryItem ? this.libraryItem.libraryId : null
|
||||||
|
},
|
||||||
|
libraryProvider() {
|
||||||
|
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
|
||||||
|
},
|
||||||
|
libraryScan() {
|
||||||
|
if (!this.libraryId) return null
|
||||||
|
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
quickMatch() {
|
||||||
|
if (this.quickMatching) return
|
||||||
|
if (!this.$refs.itemDetailsEdit) return
|
||||||
|
|
||||||
|
var { title, author } = this.$refs.itemDetailsEdit.getTitleAndAuthorName()
|
||||||
|
if (!title) {
|
||||||
|
this.$toast.error('Must have a title for quick match')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.quickMatching = true
|
||||||
|
var matchOptions = {
|
||||||
|
provider: this.libraryProvider,
|
||||||
|
title: title || null,
|
||||||
|
author: author || null
|
||||||
|
}
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/items/${this.libraryItemId}/match`, matchOptions)
|
||||||
|
.then((res) => {
|
||||||
|
this.quickMatching = false
|
||||||
|
if (res.warning) {
|
||||||
|
this.$toast.warning(res.warning)
|
||||||
|
} else if (res.updated) {
|
||||||
|
this.$toast.success('Item details updated')
|
||||||
|
} else {
|
||||||
|
this.$toast.info('No updates were made')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
var errMsg = error.response ? error.response.data || '' : ''
|
||||||
|
console.error('Failed to match', error)
|
||||||
|
this.$toast.error(errMsg || 'Failed to match')
|
||||||
|
this.quickMatching = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
rescan() {
|
||||||
|
this.rescanning = true
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/items/${this.libraryItemId}/scan`)
|
||||||
|
.then((data) => {
|
||||||
|
this.rescanning = false
|
||||||
|
var result = data.result
|
||||||
|
if (!result) {
|
||||||
|
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||||
|
} else if (result === 'UPDATED') {
|
||||||
|
this.$toast.success(`Re-Scan complete item was updated`)
|
||||||
|
} else if (result === 'UPTODATE') {
|
||||||
|
this.$toast.success(`Re-Scan complete item was up to date`)
|
||||||
|
} else if (result === 'REMOVED') {
|
||||||
|
this.$toast.error(`Re-Scan complete item was removed`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to scan library item', error)
|
||||||
|
this.$toast.error('Failed to scan library item')
|
||||||
|
this.rescanning = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
submitForm() {
|
||||||
|
if (this.isProcessing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.$refs.itemDetailsEdit) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var updatedDetails = this.$refs.itemDetailsEdit.getDetails()
|
||||||
|
if (!updatedDetails.hasChanges) {
|
||||||
|
this.$toast.info('No changes were made')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.updateDetails(updatedDetails)
|
||||||
|
},
|
||||||
|
async updateDetails(updatedDetails) {
|
||||||
|
this.isProcessing = true
|
||||||
|
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatedDetails.updatePayload).catch((error) => {
|
||||||
|
console.error('Failed to update', error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
this.isProcessing = false
|
||||||
|
if (updateResult) {
|
||||||
|
if (updateResult.updated) {
|
||||||
|
this.$toast.success('Item details updated')
|
||||||
|
// this.$emit('close')
|
||||||
|
} else {
|
||||||
|
this.$toast.info('No updates were necessary')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeItem() {
|
||||||
|
if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
|
||||||
|
this.isProcessing = true
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/items/${this.libraryItemId}`)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Item removed')
|
||||||
|
this.$toast.success('Item Removed')
|
||||||
|
this.$emit('close')
|
||||||
|
this.isProcessing = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Remove item failed', error)
|
||||||
|
this.isProcessing = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
checkIsScrollable() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
var formWrapper = document.getElementById('formWrapper')
|
||||||
|
if (formWrapper) {
|
||||||
|
if (formWrapper.scrollHeight > formWrapper.clientHeight) {
|
||||||
|
this.isScrollable = true
|
||||||
|
} else {
|
||||||
|
this.isScrollable = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
setResizeObserver() {
|
||||||
|
try {
|
||||||
|
var formWrapper = document.getElementById('formWrapper')
|
||||||
|
if (formWrapper) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
this.checkIsScrollable()
|
||||||
|
})
|
||||||
|
resizeObserver.observe(formWrapper)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to set resize observer')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.setResizeObserver()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.details-form-wrapper {
|
||||||
|
height: calc(100% - 70px);
|
||||||
|
max-height: calc(100% - 70px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||||
|
<div class="w-full mb-4">
|
||||||
|
<!-- <div class="flex items-center mb-4">
|
||||||
|
<p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check for new episodes</ui-btn>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<div v-if="episodes.length" class="w-full p-4 bg-primary">
|
||||||
|
<p>Podcast Episodes</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="!episodes.length" class="flex my-4 text-center justify-center text-xl">No Episodes</div>
|
||||||
|
<table v-else class="text-sm tracksTable">
|
||||||
|
<tr class="font-book">
|
||||||
|
<th class="text-left">Sort #</th>
|
||||||
|
<th class="text-left whitespace-nowrap">Episode #</th>
|
||||||
|
<th class="text-left">Title</th>
|
||||||
|
<th class="text-center w-28">Duration</th>
|
||||||
|
<th class="text-center w-28">Size</th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="episode in episodes" :key="episode.id">
|
||||||
|
<td class="text-left">
|
||||||
|
<p class="px-4">{{ episode.index }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-left">
|
||||||
|
<p class="px-4">{{ episode.episode }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="font-book">
|
||||||
|
{{ episode.title }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-center">
|
||||||
|
{{ $secondsToTimestamp(episode.duration) }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-center">
|
||||||
|
{{ $bytesPretty(episode.size) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
checkingNewEpisodes: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
autoDownloadEpisodes() {
|
||||||
|
return !!this.media.autoDownloadEpisodes
|
||||||
|
},
|
||||||
|
lastEpisodeCheck() {
|
||||||
|
return this.media.lastEpisodeCheck
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem ? this.libraryItem.id : null
|
||||||
|
},
|
||||||
|
episodes() {
|
||||||
|
return this.media.episodes || []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
checkForNewEpisodes() {
|
||||||
|
this.checkingNewEpisodes = true
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/podcasts/${this.libraryItemId}/checknew`)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.episodes && response.episodes.length) {
|
||||||
|
console.log('New episodes', response.episodes.length)
|
||||||
|
this.$toast.success(`${response.episodes.length} new episodes found!`)
|
||||||
|
} else {
|
||||||
|
this.$toast.info('No new episodes found')
|
||||||
|
}
|
||||||
|
this.checkingNewEpisodes = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
var errorMsg = error.response && error.response.data ? error.response.data : 'Unknown Error'
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
this.checkingNewEpisodes = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||||
|
<tables-library-files-table expanded :files="libraryFiles" :library-item-id="libraryItem.id" :is-missing="isMissing" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tracks: [],
|
||||||
|
showFullPath: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
libraryItem: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
media() {
|
||||||
|
return this.libraryItem.media || {}
|
||||||
|
},
|
||||||
|
libraryFiles() {
|
||||||
|
return this.libraryItem.libraryFiles || []
|
||||||
|
},
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
|
userCanDownload() {
|
||||||
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
|
},
|
||||||
|
isMissing() {
|
||||||
|
return this.libraryItem.isMissing
|
||||||
|
},
|
||||||
|
showDownload() {
|
||||||
|
return this.userCanDownload && !this.isMissing
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
init() {
|
||||||
|
this.tracks = this.media.tracks || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
|
||||||
|
<form @submit.prevent="submitSearch">
|
||||||
|
<div class="flex items-center justify-start -mx-1 h-20">
|
||||||
|
<div class="w-40 px-1">
|
||||||
|
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
||||||
|
</div>
|
||||||
|
<div class="w-72 px-1">
|
||||||
|
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" placeholder="Search" />
|
||||||
|
</div>
|
||||||
|
<div v-show="provider != 'itunes'" class="w-72 px-1">
|
||||||
|
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
||||||
|
</div>
|
||||||
|
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div v-show="processing" class="flex h-full items-center justify-center">
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
<div v-show="!processing && !searchResults.length && hasSearched" class="flex h-full items-center justify-center">
|
||||||
|
<p>No Results</p>
|
||||||
|
</div>
|
||||||
|
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper">
|
||||||
|
<template v-for="(res, index) in searchResults">
|
||||||
|
<cards-book-match-card :key="index" :book="res" :is-podcast="isPodcast" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
||||||
|
<div class="flex mb-2">
|
||||||
|
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
|
||||||
|
<span class="material-icons text-3xl">arrow_back</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xl pl-3">Update Book Details</p>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="submitMatchUpdate">
|
||||||
|
<div v-if="selectedMatch.cover" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.cover" />
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" label="Cover" class="flex-grow ml-4" />
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.title" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.title" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" label="Title" />
|
||||||
|
<p v-if="mediaMetadata.title" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.title || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.subtitle" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.subtitle" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" label="Subtitle" />
|
||||||
|
<p v-if="mediaMetadata.subtitle" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.subtitle || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.author" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.author" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" label="Author" />
|
||||||
|
<p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.authorName || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.narrator" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.narrator" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.narrator" :disabled="!selectedMatchUsage.narrator" label="Narrator" />
|
||||||
|
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.narratorName || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.description" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.description" />
|
||||||
|
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.publisher" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.publisher" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" label="Publisher" />
|
||||||
|
<p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.publisher || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.publishedYear" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.publishedYear" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" label="Published Year" />
|
||||||
|
<p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.publishedYear || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedMatch.series" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.series" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" label="Series" />
|
||||||
|
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.seriesName || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.volumeNumber" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.volumeNumber" />
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.volumeNumber" :disabled="!selectedMatchUsage.volumeNumber" label="Volume Number" class="flex-grow ml-4" />
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.isbn" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" />
|
||||||
|
<p v-if="mediaMetadata.isbn" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.isbn || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.asin" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.asin" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" />
|
||||||
|
<p v-if="mediaMetadata.asin" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.asin || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedMatch.itunesId" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.itunesId" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.itunesId" type="number" :disabled="!selectedMatchUsage.itunesId" label="iTunes ID" />
|
||||||
|
<p v-if="mediaMetadata.itunesId" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.itunesId || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.feedUrl" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.feedUrl" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.feedUrl" :disabled="!selectedMatchUsage.feedUrl" label="RSS Feed URL" />
|
||||||
|
<p v-if="mediaMetadata.feedUrl" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.feedUrl || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.itunesPageUrl" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.itunesPageUrl" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.itunesPageUrl" :disabled="!selectedMatchUsage.itunesPageUrl" label="iTunes Page URL" />
|
||||||
|
<p v-if="mediaMetadata.itunesPageUrl" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.itunesPageUrl || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.releaseDate" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.releaseDate" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.releaseDate" :disabled="!selectedMatchUsage.releaseDate" label="Release Date" />
|
||||||
|
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.releaseDate || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end py-2">
|
||||||
|
<ui-btn color="success" type="submit">Update</ui-btn>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
processing: Boolean,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
libraryItemId: null,
|
||||||
|
searchTitle: null,
|
||||||
|
searchAuthor: null,
|
||||||
|
lastSearch: null,
|
||||||
|
provider: 'google',
|
||||||
|
searchResults: [],
|
||||||
|
hasSearched: false,
|
||||||
|
selectedMatch: null,
|
||||||
|
selectedMatchUsage: {
|
||||||
|
title: true,
|
||||||
|
subtitle: true,
|
||||||
|
cover: true,
|
||||||
|
author: true,
|
||||||
|
narrator: true,
|
||||||
|
description: true,
|
||||||
|
publisher: true,
|
||||||
|
publishedYear: true,
|
||||||
|
series: true,
|
||||||
|
volumeNumber: true,
|
||||||
|
asin: true,
|
||||||
|
isbn: true,
|
||||||
|
// Podcast specific
|
||||||
|
itunesPageUrl: true,
|
||||||
|
itunesId: true,
|
||||||
|
feedUrl: true,
|
||||||
|
releaseDate: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
libraryItem: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isProcessing: {
|
||||||
|
get() {
|
||||||
|
return this.processing
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('update:processing', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
return this.$store.getters['getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
providers() {
|
||||||
|
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||||
|
return this.$store.state.scanners.providers
|
||||||
|
},
|
||||||
|
searchTitleLabel() {
|
||||||
|
if (this.provider == 'audible') return 'Search Title or ASIN'
|
||||||
|
else if (this.provider == 'itunes') return 'Search Term'
|
||||||
|
return 'Search Title'
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
mediaType() {
|
||||||
|
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.mediaType == 'podcast'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
persistProvider() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('book-provider', this.provider)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PersistProvider', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getSearchQuery() {
|
||||||
|
if (this.isPodcast) return `term=${this.searchTitle}`
|
||||||
|
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${this.searchTitle}`
|
||||||
|
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
||||||
|
return searchQuery
|
||||||
|
},
|
||||||
|
submitSearch() {
|
||||||
|
if (!this.searchTitle) {
|
||||||
|
this.$toast.warning('Search title is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.persistProvider()
|
||||||
|
this.runSearch()
|
||||||
|
},
|
||||||
|
async runSearch() {
|
||||||
|
var searchQuery = this.getSearchQuery()
|
||||||
|
if (this.lastSearch === searchQuery) return
|
||||||
|
this.searchResults = []
|
||||||
|
this.isProcessing = true
|
||||||
|
this.lastSearch = searchQuery
|
||||||
|
var searchEntity = this.isPodcast ? 'podcast' : 'books'
|
||||||
|
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
// console.log('Got search results', results)
|
||||||
|
results = (results || []).filter((res) => {
|
||||||
|
return !!res.title
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.isPodcast) {
|
||||||
|
// Map to match PodcastMetadata keys
|
||||||
|
results = results.map((res) => {
|
||||||
|
res.itunesPageUrl = res.pageUrl || null
|
||||||
|
res.itunesId = res.id || null
|
||||||
|
res.author = res.artistName || null
|
||||||
|
return res
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.searchResults = results || []
|
||||||
|
this.isProcessing = false
|
||||||
|
this.hasSearched = true
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.selectedMatch = null
|
||||||
|
this.selectedMatchUsage = {
|
||||||
|
title: true,
|
||||||
|
subtitle: true,
|
||||||
|
cover: true,
|
||||||
|
author: true,
|
||||||
|
narrator: true,
|
||||||
|
description: true,
|
||||||
|
publisher: true,
|
||||||
|
publishedYear: true,
|
||||||
|
series: true,
|
||||||
|
volumeNumber: true,
|
||||||
|
asin: true,
|
||||||
|
isbn: true,
|
||||||
|
// Podcast specific
|
||||||
|
itunesPageUrl: true,
|
||||||
|
itunesId: true,
|
||||||
|
feedUrl: true,
|
||||||
|
releaseDate: true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.libraryItem.id !== this.libraryItemId) {
|
||||||
|
this.searchResults = []
|
||||||
|
this.hasSearched = false
|
||||||
|
this.libraryItemId = this.libraryItem.id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.libraryItem.media || !this.libraryItem.media.metadata.title) {
|
||||||
|
this.searchTitle = null
|
||||||
|
this.searchAuthor = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.searchTitle = this.libraryItem.media.metadata.title
|
||||||
|
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
|
||||||
|
if (this.isPodcast) this.provider = 'itunes'
|
||||||
|
else this.provider = localStorage.getItem('book-provider') || 'google'
|
||||||
|
},
|
||||||
|
selectMatch(match) {
|
||||||
|
this.selectedMatch = match
|
||||||
|
},
|
||||||
|
buildMatchUpdatePayload() {
|
||||||
|
var updatePayload = {}
|
||||||
|
|
||||||
|
var volumeNumber = this.selectedMatchUsage.volumeNumber ? this.selectedMatch.volumeNumber || null : null
|
||||||
|
for (const key in this.selectedMatchUsage) {
|
||||||
|
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
||||||
|
if (key === 'series') {
|
||||||
|
var seriesItem = {
|
||||||
|
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||||
|
name: this.selectedMatch[key],
|
||||||
|
sequence: volumeNumber
|
||||||
|
}
|
||||||
|
updatePayload.series = [seriesItem]
|
||||||
|
} else if (key === 'author' && !this.isPodcast) {
|
||||||
|
var authorItem = {
|
||||||
|
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||||
|
name: this.selectedMatch[key]
|
||||||
|
}
|
||||||
|
updatePayload.authors = [authorItem]
|
||||||
|
} else if (key === 'narrator') {
|
||||||
|
updatePayload.narrators = [this.selectedMatch[key]]
|
||||||
|
} else if (key === 'itunesId') {
|
||||||
|
updatePayload.itunesId = Number(this.selectedMatch[key])
|
||||||
|
} else if (key !== 'volumeNumber') {
|
||||||
|
updatePayload[key] = this.selectedMatch[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updatePayload
|
||||||
|
},
|
||||||
|
async submitMatchUpdate() {
|
||||||
|
var updatePayload = this.buildMatchUpdatePayload()
|
||||||
|
if (!Object.keys(updatePayload).length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.isProcessing = true
|
||||||
|
|
||||||
|
if (updatePayload.cover) {
|
||||||
|
var coverPayload = {
|
||||||
|
url: updatePayload.cover
|
||||||
|
}
|
||||||
|
var success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
|
||||||
|
console.error('Failed to update', error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (success) {
|
||||||
|
this.$toast.success('Item Cover Updated')
|
||||||
|
} else {
|
||||||
|
this.$toast.error('Item Cover Failed to Update')
|
||||||
|
}
|
||||||
|
console.log('Updated cover')
|
||||||
|
delete updatePayload.cover
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updatePayload).length) {
|
||||||
|
var mediaUpdatePayload = {
|
||||||
|
metadata: updatePayload
|
||||||
|
}
|
||||||
|
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
|
||||||
|
console.error('Failed to update', error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (updateResult) {
|
||||||
|
if (updateResult.updated) {
|
||||||
|
this.$toast.success('Item details updated')
|
||||||
|
} else {
|
||||||
|
this.$toast.info('No detail updates were necessary')
|
||||||
|
}
|
||||||
|
this.selectedMatch = null
|
||||||
|
this.$emit('selectTab', 'details')
|
||||||
|
} else {
|
||||||
|
this.$toast.error('Item Details Failed to Update')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.selectedMatch = null
|
||||||
|
}
|
||||||
|
this.isProcessing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.matchListWrapper {
|
||||||
|
height: calc(100% - 80px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||||
|
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
|
||||||
|
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<div>
|
||||||
|
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
||||||
|
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||||
|
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||||
|
|
||||||
|
<ui-btn v-if="abmergeStatus !== $constants.DownloadStatus.READY" :loading="abmergeStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startAudiobookMerge">Start Merge</ui-btn>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex">
|
||||||
|
<ui-btn @click="downloadWithProgress(abmergeDownload)">Download</ui-btn>
|
||||||
|
<ui-icon-btn small icon="delete" bg-color="error" class="ml-2" @click="removeDownload" />
|
||||||
|
</div>
|
||||||
|
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(abmergeDownload.size) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-left text-base mb-4 py-4">
|
||||||
|
<span class="text-error">* <strong>Experimental</strong></span
|
||||||
|
> - M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p>
|
||||||
|
<p v-else-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks to merge</p>
|
||||||
|
|
||||||
|
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
|
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
|
||||||
|
<div class="w-full h-full flex items-center justify-center">
|
||||||
|
<p class="text-lg">Download.... {{ downloadPercent }}%</p>
|
||||||
|
<p class="w-24 font-mono pl-8 text-right">
|
||||||
|
{{ downloadAmount }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
processing: Boolean,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tempDisable: false,
|
||||||
|
isDownloading: false,
|
||||||
|
downloadPercent: '0',
|
||||||
|
downloadAmount: '0 KB'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
abmergeStatus(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.tempDisable = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem ? this.libraryItem.id : null
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
downloads() {
|
||||||
|
return this.$store.getters['downloads/getDownloads'](this.libraryItemId)
|
||||||
|
},
|
||||||
|
abmergeDownload() {
|
||||||
|
return this.downloads.find((d) => d.type === 'abmerge')
|
||||||
|
},
|
||||||
|
abmergeStatus() {
|
||||||
|
return this.abmergeDownload ? this.abmergeDownload.status : false
|
||||||
|
},
|
||||||
|
libraryFiles() {
|
||||||
|
return this.libraryItem.libraryFiles
|
||||||
|
},
|
||||||
|
totalFiles() {
|
||||||
|
return this.libraryFiles.length
|
||||||
|
},
|
||||||
|
mediaTracks() {
|
||||||
|
return this.media.tracks || []
|
||||||
|
},
|
||||||
|
isSingleM4b() {
|
||||||
|
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
|
||||||
|
},
|
||||||
|
showM4bDownload() {
|
||||||
|
if (this.libraryItem.isMissing || !this.mediaTracks.length) return false
|
||||||
|
return !this.isSingleM4b && this.mediaTracks.length > 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
removeDownload() {
|
||||||
|
if (!this.abmergeDownload) return
|
||||||
|
if (!confirm(`Are you sure you want to remove this merge download?`)) return
|
||||||
|
|
||||||
|
var downloadId = this.abmergeDownload.id
|
||||||
|
|
||||||
|
this.tempDisable = true
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/download/${downloadId}`)
|
||||||
|
.then(() => {
|
||||||
|
this.tempDisable = false
|
||||||
|
this.$toast.success('Merge download deleted')
|
||||||
|
this.$store.commit('downloads/removeDownload', { id: downloadId })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
this.tempDisable = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
startAudiobookMerge() {
|
||||||
|
this.tempDisable = true
|
||||||
|
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/audiobook-merge/${this.libraryItemId}`)
|
||||||
|
.then(() => {
|
||||||
|
this.tempDisable = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
this.tempDisable = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
downloadWithProgress(download) {
|
||||||
|
var downloadId = download.id
|
||||||
|
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
|
||||||
|
var filename = download.filename
|
||||||
|
|
||||||
|
this.isDownloading = true
|
||||||
|
|
||||||
|
var request = new XMLHttpRequest()
|
||||||
|
request.responseType = 'blob'
|
||||||
|
request.open('get', downloadUrl, true)
|
||||||
|
request.setRequestHeader('Authorization', `Bearer ${this.$store.getters['user/getToken']}`)
|
||||||
|
request.send()
|
||||||
|
|
||||||
|
request.onreadystatechange = () => {
|
||||||
|
if (request.readyState === 4) {
|
||||||
|
this.isDownloading = false
|
||||||
|
}
|
||||||
|
if (request.readyState == 4 && request.status == 200) {
|
||||||
|
const url = window.URL.createObjectURL(request.response)
|
||||||
|
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = filename
|
||||||
|
document.body.appendChild(anchor)
|
||||||
|
anchor.click()
|
||||||
|
setTimeout(() => {
|
||||||
|
if (anchor) anchor.remove()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onerror = (err) => {
|
||||||
|
console.error('Download error', err)
|
||||||
|
this.isDownloading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onprogress = (e) => {
|
||||||
|
const percent_complete = Math.floor((e.loaded / e.total) * 100)
|
||||||
|
this.downloadAmount = this.$bytesPretty(e.loaded)
|
||||||
|
this.downloadPercent = percent_complete
|
||||||
|
|
||||||
|
// const duration = (new Date().getTime() - startTime) / 1000
|
||||||
|
// const bps = e.loaded / duration
|
||||||
|
// const kbps = Math.floor(bps / 1024)
|
||||||
|
// const time = (e.total - e.loaded) / bps
|
||||||
|
// const seconds = Math.floor(time % 60)
|
||||||
|
// const minutes = Math.floor(time / 60)
|
||||||
|
// console.log(`${percent_complete}% - ${kbps} Kbps - ${minutes} min ${seconds} sec remaining`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loadDownloads() {
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/downloads`)
|
||||||
|
.then((data) => {
|
||||||
|
var pendingDownloads = data.pendingDownloads.map((pd) => {
|
||||||
|
pd.download.status = this.$constants.DownloadStatus.PENDING
|
||||||
|
return pd.download
|
||||||
|
})
|
||||||
|
var downloads = data.downloads.map((d) => {
|
||||||
|
d.status = this.$constants.DownloadStatus.READY
|
||||||
|
return d
|
||||||
|
})
|
||||||
|
var allDownloads = downloads.concat(pendingDownloads)
|
||||||
|
this.$store.commit('downloads/setDownloads', allDownloads)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load downloads', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadDownloads()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full px-4 py-2 mb-4">
|
<div class="w-full h-full px-4 py-2 mb-4">
|
||||||
<div v-show="showDirectoryPicker" class="flex items-center py-1 mb-2">
|
|
||||||
<span class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="backArrowPress">arrow_back</span>
|
|
||||||
<p class="px-4 text-xl">{{ title }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!showDirectoryPicker" class="w-full h-full py-4">
|
<div v-if="!showDirectoryPicker" class="w-full h-full py-4">
|
||||||
<div class="flex flex-wrap md:flex-nowrap -mx-1">
|
<div class="flex flex-wrap md:flex-nowrap -mx-1">
|
||||||
|
<div class="w-2/5 md:w-72 px-1 py-1 md:py-0">
|
||||||
|
<ui-dropdown v-model="mediaType" :items="mediaTypes" label="Media Type" :disabled="!isNew" small @input="changedMediaType" />
|
||||||
|
</div>
|
||||||
<div class="w-full md:flex-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 v-model="name" label="Library Name" />
|
<ui-text-input-with-label v-model="name" label="Library Name" @blur="nameBlurred" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 md:w-72 px-1 py-1 md:py-0">
|
<div class="w-1/5 md:w-18 px-1 py-1 md:py-0">
|
||||||
<ui-media-type-picker v-model="mediaType" />
|
<ui-media-icon-picker v-model="icon" @input="iconChanged" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 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="provider" :items="providers" label="Metadata Provider" small />
|
<ui-dropdown v-model="provider" :items="providers" label="Metadata Provider" small @input="formUpdated" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -22,36 +20,26 @@
|
|||||||
<p class="px-1 text-sm font-semibold">Folders</p>
|
<p class="px-1 text-sm font-semibold">Folders</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-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||||
<ui-editable-text v-model="folder.fullPath" type="text" class="w-full" />
|
<ui-editable-text v-model="folder.fullPath" readonly type="text" class="w-full" />
|
||||||
<span v-show="folders.length > 1" class="material-icons ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
|
<span v-show="folders.length > 1" class="material-icons ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!folders.length" class="text-sm text-gray-300 px-1 py-2">No folders</p>
|
<div class="flex py-1 px-2 items-center w-full">
|
||||||
|
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||||
|
<ui-editable-text v-model="newFolderPath" placeholder="New folder path" type="text" class="w-full" @blur="newFolderInputBlurred" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<ui-btn class="w-full mt-2" color="primary" @click="showDirectoryPicker = true">Browse for Folder</ui-btn>
|
<ui-btn class="w-full mt-2" color="primary" @click="showDirectoryPicker = true">Browse for Folder</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-0 left-0 w-full py-4 px-4">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<ui-btn v-show="!disableSubmit" color="success" :disabled="disableSubmit" @click="submit">{{ library ? 'Update Library' : 'Create Library' }}</ui-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-libraries-folder-chooser v-else :paths="folderPaths" @select="selectFolder" />
|
<modals-libraries-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" />
|
||||||
|
|
||||||
<div v-if="!showDirectoryPicker">
|
|
||||||
<div class="flex items-center pt-2">
|
|
||||||
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" />
|
|
||||||
<ui-toggle-switch v-else disabled :value="false" />
|
|
||||||
<p class="pl-4 text-lg">Disable folder watcher for library</p>
|
|
||||||
</div>
|
|
||||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
isNew: Boolean,
|
||||||
library: {
|
library: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
@@ -61,40 +49,73 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
provider: '',
|
provider: 'google',
|
||||||
mediaType: '',
|
icon: '',
|
||||||
folders: [],
|
folders: [],
|
||||||
showDirectoryPicker: false,
|
showDirectoryPicker: false,
|
||||||
disableWatcher: false
|
newFolderPath: '',
|
||||||
|
mediaType: null,
|
||||||
|
mediaTypes: [
|
||||||
|
{
|
||||||
|
value: 'book',
|
||||||
|
text: 'Books'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'podcast',
|
||||||
|
text: 'Podcasts'
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
title() {
|
|
||||||
if (this.showDirectoryPicker) return 'Choose a Folder'
|
|
||||||
return ''
|
|
||||||
},
|
|
||||||
folderPaths() {
|
folderPaths() {
|
||||||
return this.folders.map((f) => f.fullPath)
|
return this.folders.map((f) => f.fullPath)
|
||||||
},
|
},
|
||||||
disableSubmit() {
|
|
||||||
if (!this.library) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var newfolderpaths = this.folderPaths.join(',')
|
|
||||||
var origfolderpaths = this.library.folders.map((f) => f.fullPath).join(',')
|
|
||||||
|
|
||||||
return newfolderpaths === origfolderpaths && this.name === this.library.name && this.provider === this.library.provider && this.disableWatcher === this.library.disableWatcher && this.mediaType === this.library.mediaType
|
|
||||||
},
|
|
||||||
providers() {
|
providers() {
|
||||||
|
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
||||||
return this.$store.state.scanners.providers
|
return this.$store.state.scanners.providers
|
||||||
},
|
|
||||||
globalWatcherDisabled() {
|
|
||||||
return this.$store.getters['getServerSetting']('scannerDisableWatcher')
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
getLibraryData() {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
provider: this.provider,
|
||||||
|
folders: this.folders,
|
||||||
|
icon: this.icon,
|
||||||
|
mediaType: this.mediaType
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formUpdated() {
|
||||||
|
this.$emit('update', this.getLibraryData())
|
||||||
|
},
|
||||||
|
newFolderInputBlurred() {
|
||||||
|
if (this.newFolderPath) {
|
||||||
|
this.folders.push({ fullPath: this.newFolderPath })
|
||||||
|
this.newFolderPath = ''
|
||||||
|
this.formUpdated()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
iconChanged() {
|
||||||
|
this.formUpdated()
|
||||||
|
},
|
||||||
|
nameBlurred() {
|
||||||
|
if (this.name !== this.library.name) {
|
||||||
|
this.formUpdated()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
changedMediaType() {
|
||||||
|
this.provider = this.providers[0].value
|
||||||
|
this.formUpdated()
|
||||||
|
},
|
||||||
|
selectFolder(fullPath) {
|
||||||
|
this.folders.push({ fullPath })
|
||||||
|
this.showDirectoryPicker = false
|
||||||
|
this.formUpdated()
|
||||||
|
},
|
||||||
removeFolder(folder) {
|
removeFolder(folder) {
|
||||||
this.folders = this.folders.filter((f) => f.fullPath !== folder.fullPath)
|
this.folders = this.folders.filter((f) => f.fullPath !== folder.fullPath)
|
||||||
|
this.formUpdated()
|
||||||
},
|
},
|
||||||
backArrowPress() {
|
backArrowPress() {
|
||||||
if (this.showDirectoryPicker) {
|
if (this.showDirectoryPicker) {
|
||||||
@@ -103,94 +124,11 @@ export default {
|
|||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.name = this.library ? this.library.name : ''
|
this.name = this.library ? this.library.name : ''
|
||||||
this.provider = this.library ? this.library.provider : ''
|
this.provider = this.library ? this.library.provider : 'google'
|
||||||
this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
|
this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
|
||||||
this.disableWatcher = this.library ? !!this.library.disableWatcher : false
|
this.icon = this.library ? this.library.icon : 'default'
|
||||||
this.mediaType = this.library ? this.library.mediaType : 'default'
|
this.mediaType = this.library ? this.library.mediaType : 'book'
|
||||||
this.showDirectoryPicker = false
|
this.showDirectoryPicker = false
|
||||||
},
|
|
||||||
selectFolder(fullPath) {
|
|
||||||
this.folders.push({ fullPath })
|
|
||||||
this.showDirectoryPicker = false
|
|
||||||
},
|
|
||||||
submit() {
|
|
||||||
if (this.library) {
|
|
||||||
this.updateLibrary()
|
|
||||||
} else {
|
|
||||||
this.createLibrary()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
updateLibrary() {
|
|
||||||
if (!this.name) {
|
|
||||||
this.$toast.error('Library must have a name')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!this.folders.length) {
|
|
||||||
this.$toast.error('Library must have at least 1 path')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var newLibraryPayload = {
|
|
||||||
name: this.name,
|
|
||||||
provider: this.provider,
|
|
||||||
folders: this.folders,
|
|
||||||
mediaType: this.mediaType,
|
|
||||||
icon: this.mediaType,
|
|
||||||
disableWatcher: this.disableWatcher
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$emit('update:processing', true)
|
|
||||||
this.$axios
|
|
||||||
.$patch(`/api/libraries/${this.library.id}`, newLibraryPayload)
|
|
||||||
.then((res) => {
|
|
||||||
this.$emit('update:processing', false)
|
|
||||||
this.$emit('close')
|
|
||||||
this.$toast.success(`Library "${res.name}" updated successfully`)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(error)
|
|
||||||
if (error.response && error.response.data) {
|
|
||||||
this.$toast.error(error.response.data)
|
|
||||||
} else {
|
|
||||||
this.$toast.error('Failed to update library')
|
|
||||||
}
|
|
||||||
this.$emit('update:processing', false)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
createLibrary() {
|
|
||||||
if (!this.name) {
|
|
||||||
this.$toast.error('Library must have a name')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!this.folders.length) {
|
|
||||||
this.$toast.error('Library must have at least 1 path')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var newLibraryPayload = {
|
|
||||||
name: this.name,
|
|
||||||
provider: this.provider,
|
|
||||||
folders: this.folders,
|
|
||||||
mediaType: this.mediaType,
|
|
||||||
icon: this.mediaType,
|
|
||||||
disableWatcher: this.disableWatcher
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$emit('update:processing', true)
|
|
||||||
this.$axios
|
|
||||||
.$post('/api/libraries', newLibraryPayload)
|
|
||||||
.then((res) => {
|
|
||||||
this.$emit('update:processing', false)
|
|
||||||
this.$emit('close')
|
|
||||||
this.$toast.success(`Library "${res.name}" created successfully`)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(error)
|
|
||||||
if (error.response && error.response.data) {
|
|
||||||
this.$toast.error(error.response.data)
|
|
||||||
} else {
|
|
||||||
this.$toast.error('Failed to create library')
|
|
||||||
}
|
|
||||||
this.$emit('update:processing', false)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="absolute -top-10 left-0 z-10 w-full flex">
|
||||||
|
<template v-for="tab in tabs">
|
||||||
|
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 w-full text-sm 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" :is="tabName" :is-new="!library" :library="libraryCopy" :processing.sync="processing" @update="updateLibrary" @close="show = false" />
|
||||||
|
|
||||||
|
<div class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-opacity-10">
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<ui-btn @click="submit">{{ buttonText }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
library: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
selectedTab: 'details',
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
id: 'details',
|
||||||
|
title: 'Details',
|
||||||
|
component: 'modals-libraries-edit-library'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
title: 'Settings',
|
||||||
|
component: 'modals-libraries-library-settings'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
libraryCopy: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.library ? 'Update Library' : 'New Library'
|
||||||
|
},
|
||||||
|
buttonText() {
|
||||||
|
return this.library ? 'Update Library' : 'Create New Library'
|
||||||
|
},
|
||||||
|
tabName() {
|
||||||
|
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||||
|
return _tab ? _tab.component : ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
selectTab(tab) {
|
||||||
|
this.selectedTab = tab
|
||||||
|
},
|
||||||
|
updateLibrary(library) {
|
||||||
|
this.mapLibraryToCopy(library)
|
||||||
|
},
|
||||||
|
getNewLibraryData() {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
provider: 'google',
|
||||||
|
folders: [],
|
||||||
|
icon: 'database',
|
||||||
|
mediaType: 'book',
|
||||||
|
settings: {
|
||||||
|
disableWatcher: false,
|
||||||
|
skipMatchingMediaWithAsin: false,
|
||||||
|
skipMatchingMediaWithIsbn: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.selectedTab = 'details'
|
||||||
|
this.libraryCopy = this.getNewLibraryData()
|
||||||
|
if (this.library) {
|
||||||
|
this.mapLibraryToCopy(this.library)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mapLibraryToCopy(library) {
|
||||||
|
for (const key in this.libraryCopy) {
|
||||||
|
if (library[key] !== undefined) {
|
||||||
|
if (key === 'folders') {
|
||||||
|
this.libraryCopy.folders = library.folders.map((f) => ({ ...f }))
|
||||||
|
} else if (key === 'settings') {
|
||||||
|
this.libraryCopy.settings = { ...library.settings }
|
||||||
|
} else {
|
||||||
|
this.libraryCopy[key] = library[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
validate() {
|
||||||
|
if (!this.libraryCopy.name) {
|
||||||
|
this.$toast.error('Library must have a name')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!this.libraryCopy.folders.length) {
|
||||||
|
this.$toast.error('Library must have at least 1 path')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
submit() {
|
||||||
|
if (!this.validate()) return
|
||||||
|
|
||||||
|
if (this.library) {
|
||||||
|
this.submitUpdateLibrary()
|
||||||
|
} else {
|
||||||
|
this.submitCreateLibrary()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getLibraryUpdatePayload() {
|
||||||
|
var updatePayload = {}
|
||||||
|
for (const key in this.libraryCopy) {
|
||||||
|
if (key === 'folders') {
|
||||||
|
if (this.libraryCopy.folders.map((f) => f.fullPath).join(',') !== this.library.folders.map((f) => f.fullPath).join(',')) {
|
||||||
|
updatePayload.folders = [...this.libraryCopy.folders]
|
||||||
|
}
|
||||||
|
} else if (key === 'settings') {
|
||||||
|
for (const settingsKey in this.libraryCopy.settings) {
|
||||||
|
if (this.libraryCopy.settings[settingsKey] !== this.library.settings[settingsKey]) {
|
||||||
|
if (!updatePayload.settings) updatePayload.settings = {}
|
||||||
|
updatePayload.settings[settingsKey] = this.libraryCopy.settings[settingsKey]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (key !== 'mediaType' && this.libraryCopy[key] !== this.library[key]) {
|
||||||
|
updatePayload[key] = this.libraryCopy[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updatePayload
|
||||||
|
},
|
||||||
|
submitUpdateLibrary() {
|
||||||
|
var newLibraryPayload = this.getLibraryUpdatePayload()
|
||||||
|
if (!Object.keys(newLibraryPayload).length) {
|
||||||
|
this.$toast.info('No updates are necessary')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$patch(`/api/libraries/${this.library.id}`, newLibraryPayload)
|
||||||
|
.then((res) => {
|
||||||
|
this.processing = false
|
||||||
|
this.show = false
|
||||||
|
this.$toast.success(`Library "${res.name}" updated successfully`)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
if (error.response && error.response.data) {
|
||||||
|
this.$toast.error(error.response.data)
|
||||||
|
} else {
|
||||||
|
this.$toast.error('Failed to update library')
|
||||||
|
}
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
submitCreateLibrary() {
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$post('/api/libraries', this.libraryCopy)
|
||||||
|
.then((res) => {
|
||||||
|
this.processing = false
|
||||||
|
this.show = false
|
||||||
|
this.$toast.success(`Library "${res.name}" created successfully`)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
if (error.response && error.response.data) {
|
||||||
|
this.$toast.error(error.response.data)
|
||||||
|
} else {
|
||||||
|
this.$toast.error('Failed to create library')
|
||||||
|
}
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tab {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.tab.tab-selected {
|
||||||
|
height: 41px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full">
|
<div class="w-full h-full bg-bg absolute top-0 left-0 px-4 py-4 z-10">
|
||||||
|
<div class="flex items-center py-1 mb-2">
|
||||||
|
<span class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="$emit('back')">arrow_back</span>
|
||||||
|
<p class="px-4 text-xl">Choose a Folder</p>
|
||||||
|
</div>
|
||||||
<div v-if="allFolders.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2">
|
<div v-if="allFolders.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="allFolders.length" class="flex bg-primary bg-opacity-50 p-4">
|
<div v-if="allFolders.length" class="flex bg-primary bg-opacity-50 p-4 folder-container">
|
||||||
<div class="w-1/2 border-r border-bg">
|
<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" @click="goBack">
|
<div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center" @click="goBack">
|
||||||
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
<span class="material-icons 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>
|
||||||
@@ -15,7 +19,7 @@
|
|||||||
<span v-if="dir.dirs && dir.dirs.length && dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span>
|
<span v-if="dir.dirs && dir.dirs.length && dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2">
|
<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" @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" @click="selectSubDir(dir)">
|
||||||
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
<span class="material-icons 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>
|
||||||
@@ -30,12 +34,8 @@
|
|||||||
<p class="text-gray-300">Note: folders already mapped will not be shown</p>
|
<p class="text-gray-300">Note: folders already mapped will not be shown</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute bottom-0 left-0 w-full py-4 px-8">
|
<div class="w-full py-2">
|
||||||
<ui-btn :disabled="!selectedPath" color="primary" class="w-full mt-2" @click="selectFolder">Select Folder Path</ui-btn>
|
<ui-btn :disabled="!selectedPath" color="primary" class="w-full mt-2" @click="selectFolder">Select Folder Path</ui-btn>
|
||||||
<!-- <div class="flex items-center">
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<ui-btn color="success" @click="selectFolder">Select</ui-btn>
|
|
||||||
</div> -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -64,7 +64,6 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
_directories() {
|
_directories() {
|
||||||
return this.directories.map((d) => {
|
return this.directories.map((d) => {
|
||||||
console.log('Directories', d)
|
|
||||||
var isUsed = !!this.paths.find((path) => path.endsWith(d.path))
|
var isUsed = !!this.paths.find((path) => path.endsWith(d.path))
|
||||||
var isSelected = d.path === this.selectedPath
|
var isSelected = d.path === this.selectedPath
|
||||||
var classes = []
|
var classes = []
|
||||||
@@ -162,4 +161,9 @@ export default {
|
|||||||
.dir-item.dir-used {
|
.dir-item.dir-used {
|
||||||
background-color: rgba(255, 25, 0, 0.1);
|
background-color: rgba(255, 25, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
.folder-container {
|
||||||
|
max-height: calc(100% - 130px);
|
||||||
|
height: calc(100% - 130px);
|
||||||
|
min-height: calc(100% - 130px);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full px-4 py-1 mb-4">
|
||||||
|
<div class="py-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" @input="formUpdated" />
|
||||||
|
<ui-toggle-switch v-else disabled :value="false" />
|
||||||
|
<p class="pl-4 text-lg">Disable folder watcher for library</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
|
||||||
|
</div>
|
||||||
|
<div class="py-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
|
||||||
|
<p class="pl-4 text-lg">Skip matching books that already have an ASIN</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="py-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
|
||||||
|
<p class="pl-4 text-lg">Skip matching books that already have an ISBN</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
library: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
processing: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
provider: null,
|
||||||
|
disableWatcher: false,
|
||||||
|
skipMatchingMediaWithAsin: false,
|
||||||
|
skipMatchingMediaWithIsbn: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
librarySettings() {
|
||||||
|
return this.library.settings || {}
|
||||||
|
},
|
||||||
|
globalWatcherDisabled() {
|
||||||
|
return this.$store.getters['getServerSetting']('scannerDisableWatcher')
|
||||||
|
},
|
||||||
|
mediaType() {
|
||||||
|
return this.library.mediaType
|
||||||
|
},
|
||||||
|
providers() {
|
||||||
|
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
||||||
|
return this.$store.state.scanners.providers
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getLibraryData() {
|
||||||
|
return {
|
||||||
|
settings: {
|
||||||
|
disableWatcher: !!this.disableWatcher,
|
||||||
|
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
||||||
|
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formUpdated() {
|
||||||
|
this.$emit('update', this.getLibraryData())
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.disableWatcher = !!this.librarySettings.disableWatcher
|
||||||
|
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
|
||||||
|
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="podcast-episode-edit-modal" :width="800" :height="'unset'" :processing="processing">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||||
|
<div class="flex flex-wrap">
|
||||||
|
<div class="w-1/3 p-1">
|
||||||
|
<ui-text-input-with-label v-model="newEpisode.episode" label="Episode" />
|
||||||
|
</div>
|
||||||
|
<div class="w-1/3 p-1">
|
||||||
|
<ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" />
|
||||||
|
</div>
|
||||||
|
<div class="w-1/3 p-1">
|
||||||
|
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" label="Pub Date" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full p-1">
|
||||||
|
<ui-text-input-with-label v-model="newEpisode.title" label="Title" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full p-1">
|
||||||
|
<ui-textarea-with-label v-model="newEpisode.subtitle" label="Subtitle" :rows="3" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full p-1">
|
||||||
|
<ui-textarea-with-label v-model="newEpisode.description" label="Description" :rows="8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end pt-4">
|
||||||
|
<ui-btn @click="submit">Submit</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
newEpisode: {
|
||||||
|
episode: null,
|
||||||
|
episodeType: null,
|
||||||
|
title: null,
|
||||||
|
subtitle: null,
|
||||||
|
description: null,
|
||||||
|
pubDate: null,
|
||||||
|
publishedAt: null
|
||||||
|
},
|
||||||
|
pubDateInput: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
episode: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.globals.showEditPodcastEpisode
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$store.commit('globals/setShowEditPodcastEpisodeModal', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
libraryItem() {
|
||||||
|
return this.$store.state.selectedLibraryItem
|
||||||
|
},
|
||||||
|
episode() {
|
||||||
|
return this.$store.state.globals.selectedEpisode
|
||||||
|
},
|
||||||
|
episodeId() {
|
||||||
|
return this.episode ? this.episode.id : null
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
if (!this.libraryItem) return ''
|
||||||
|
return this.libraryItem.media.metadata.title || 'Unknown'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updatePubDate(val) {
|
||||||
|
if (val) {
|
||||||
|
this.newEpisode.pubDate = this.$formatJsDate(new Date(val), 'E, d MMM yyyy HH:mm:ssxx')
|
||||||
|
this.newEpisode.publishedAt = new Date(val).valueOf()
|
||||||
|
} else {
|
||||||
|
this.newEpisode.pubDate = null
|
||||||
|
this.newEpisode.publishedAt = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.newEpisode.episode = this.episode.episode || ''
|
||||||
|
this.newEpisode.episodeType = this.episode.episodeType || ''
|
||||||
|
this.newEpisode.title = this.episode.title || ''
|
||||||
|
this.newEpisode.subtitle = this.episode.subtitle || ''
|
||||||
|
this.newEpisode.description = this.episode.description || ''
|
||||||
|
this.newEpisode.pubDate = this.episode.pubDate || ''
|
||||||
|
this.newEpisode.publishedAt = this.episode.publishedAt
|
||||||
|
|
||||||
|
this.pubDateInput = this.episode.pubDate ? this.$formatJsDate(new Date(this.episode.pubDate), "yyyy-MM-dd'T'HH:mm") : null
|
||||||
|
},
|
||||||
|
getUpdatePayload() {
|
||||||
|
var updatePayload = {}
|
||||||
|
for (const key in this.newEpisode) {
|
||||||
|
if (this.newEpisode[key] != this.episode[key]) {
|
||||||
|
updatePayload[key] = this.newEpisode[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updatePayload
|
||||||
|
},
|
||||||
|
submit() {
|
||||||
|
const payload = this.getUpdatePayload()
|
||||||
|
if (!Object.keys(payload).length) {
|
||||||
|
return this.$toast.info('No updates were made')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, payload)
|
||||||
|
.then(() => {
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.success('Podcast episode updated')
|
||||||
|
this.show = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed update episode'
|
||||||
|
console.error('Failed update episode', error)
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="podcast-episodes-modal" :width="1200" :height="'unset'" :processing="processing">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="wrapper" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||||
|
<div ref="episodeContainer" id="episodes-scroll" class="w-full overflow-x-hidden overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="(episode, index) in episodes"
|
||||||
|
:key="index"
|
||||||
|
class="relative"
|
||||||
|
:class="episode.enclosure && itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
|
||||||
|
@click="toggleSelectEpisode(index)"
|
||||||
|
>
|
||||||
|
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||||
|
<span v-if="episode.enclosure && itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
|
||||||
|
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
|
||||||
|
</div>
|
||||||
|
<div class="px-8 py-2">
|
||||||
|
<p v-if="episode.episode" class="font-semibold text-gray-200">#{{ episode.episode }}</p>
|
||||||
|
<p class="break-words mb-1">{{ episode.title }}</p>
|
||||||
|
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
|
||||||
|
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||||
|
<!-- <span class="material-icons cursor-pointer text-lg hover:text-success" @click="saveEpisode(episode)">save</span> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end pt-4">
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||||
|
<ui-checkbox v-model="selectAll" small checkbox-bg="primary" border-color="gray-600" :disabled="allDownloaded" />
|
||||||
|
</div>
|
||||||
|
<div class="px-8 py-2">
|
||||||
|
<p :class="!allDownloaded ? 'font-semibold text-gray-200' : 'text-gray-400'">Select all episodes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ui-btn :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
episodes: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
selectedEpisodes: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectAll: {
|
||||||
|
get() {
|
||||||
|
return this.episodesSelected.length == this.episodes.filter((_, index) => !(this.episodes[index].enclosure && this.itemEpisodeMap[this.episodes[index].enclosure.url])).length
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
for (const key in this.selectedEpisodes) {
|
||||||
|
this.selectedEpisodes[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
if (!this.libraryItem) return ''
|
||||||
|
return this.libraryItem.media.metadata.title || 'Unknown'
|
||||||
|
},
|
||||||
|
allDownloaded() {
|
||||||
|
return Object.values(this.episodes).filter((episode) => !(episode.enclosure && this.itemEpisodeMap[episode.enclosure.url])).length === 0
|
||||||
|
},
|
||||||
|
episodesSelected() {
|
||||||
|
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
||||||
|
},
|
||||||
|
buttonText() {
|
||||||
|
if (!this.episodesSelected.length) return 'No Episodes Selected'
|
||||||
|
return `Download ${this.episodesSelected.length} Episode${this.episodesSelected.length > 1 ? 's' : ''}`
|
||||||
|
},
|
||||||
|
itemEpisodes() {
|
||||||
|
if (!this.libraryItem) return []
|
||||||
|
return this.libraryItem.media.episodes || []
|
||||||
|
},
|
||||||
|
itemEpisodeMap() {
|
||||||
|
var map = {}
|
||||||
|
this.itemEpisodes.forEach((item) => {
|
||||||
|
if (item.enclosure) map[item.enclosure.url] = true
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggleSelectEpisode(index) {
|
||||||
|
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
|
||||||
|
},
|
||||||
|
submit() {
|
||||||
|
var episodesToDownload = []
|
||||||
|
if (this.episodesSelected.length) {
|
||||||
|
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
|
||||||
|
}
|
||||||
|
|
||||||
|
var payloadSize = JSON.stringify(episodesToDownload).length
|
||||||
|
var sizeInMb = payloadSize / 1024 / 1024
|
||||||
|
var sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
|
||||||
|
console.log('Request size', sizeInMb)
|
||||||
|
if (sizeInMb > 4.99) {
|
||||||
|
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/podcasts/${this.libraryItem.id}/download-episodes`, episodesToDownload)
|
||||||
|
.then(() => {
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.success('Started downloading episodes')
|
||||||
|
this.show = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to download episodes'
|
||||||
|
console.error('Failed to download episodes', error)
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
for (let i = 0; i < this.episodes.length; i++) {
|
||||||
|
var episode = this.episodes[i]
|
||||||
|
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {
|
||||||
|
// Do not include episodes already downloaded
|
||||||
|
this.$set(this.selectedEpisodes, String(i), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#podcast-wrapper {
|
||||||
|
min-height: 400px;
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
#episodes-scroll {
|
||||||
|
max-height: calc(80vh - 200px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="new-podcast-modal" :width="1000" :height="'unset'" :processing="processing">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="wrapper" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||||
|
<div class="w-full p-4">
|
||||||
|
<p class="text-lg font-semibold mb-2">Details</p>
|
||||||
|
|
||||||
|
<div v-if="podcast.imageUrl" class="p-1 w-full">
|
||||||
|
<img :src="podcast.imageUrl" class="h-16 w-16 object-contain" />
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-full md:w-1/2 p-2">
|
||||||
|
<ui-text-input-with-label v-model="podcast.title" label="Title" @input="titleUpdated" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full md:w-1/2 p-2">
|
||||||
|
<ui-text-input-with-label v-model="podcast.author" label="Author" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-full md:w-1/2 p-2">
|
||||||
|
<ui-text-input-with-label v-model="podcast.feedUrl" label="Feed URL" readonly />
|
||||||
|
</div>
|
||||||
|
<div class="w-full md:w-1/2 p-2">
|
||||||
|
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" label="Genres" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-2 w-full">
|
||||||
|
<ui-textarea-with-label v-model="podcast.description" label="Description" :rows="3" />
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-full md:w-1/2 p-2">
|
||||||
|
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" @input="folderUpdated" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full md:w-1/2 p-2">
|
||||||
|
<ui-text-input-with-label v-model="fullPath" label="Podcast Path" readonly />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center py-4">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<div class="px-4">
|
||||||
|
<ui-checkbox v-model="podcast.autoDownloadEpisodes" label="Auto Download Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||||
|
</div>
|
||||||
|
<ui-btn color="success" @click="submit">Add Podcast</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Path from 'path'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
podcastData: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
podcastFeedData: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
selectedFolderId: null,
|
||||||
|
fullPath: null,
|
||||||
|
podcast: {
|
||||||
|
title: '',
|
||||||
|
author: '',
|
||||||
|
description: '',
|
||||||
|
releaseDate: '',
|
||||||
|
genres: [],
|
||||||
|
feedUrl: '',
|
||||||
|
feedImageUrl: '',
|
||||||
|
itunesPageUrl: '',
|
||||||
|
itunesId: '',
|
||||||
|
itunesArtistId: '',
|
||||||
|
autoDownloadEpisodes: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this._podcastData.title
|
||||||
|
},
|
||||||
|
currentLibrary() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibrary']
|
||||||
|
},
|
||||||
|
folders() {
|
||||||
|
if (!this.currentLibrary) return []
|
||||||
|
return this.currentLibrary.folders || []
|
||||||
|
},
|
||||||
|
folderItems() {
|
||||||
|
return this.folders.map((fold) => {
|
||||||
|
return {
|
||||||
|
value: fold.id,
|
||||||
|
text: fold.fullPath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
_podcastData() {
|
||||||
|
return this.podcastData || {}
|
||||||
|
},
|
||||||
|
feedMetadata() {
|
||||||
|
if (!this.podcastFeedData) return {}
|
||||||
|
return this.podcastFeedData.metadata || {}
|
||||||
|
},
|
||||||
|
episodes() {
|
||||||
|
if (!this.podcastFeedData) return []
|
||||||
|
return this.podcastFeedData.episodes || []
|
||||||
|
},
|
||||||
|
selectedFolder() {
|
||||||
|
return this.folders.find((f) => f.id === this.selectedFolderId)
|
||||||
|
},
|
||||||
|
selectedFolderPath() {
|
||||||
|
if (!this.selectedFolder) return ''
|
||||||
|
return this.selectedFolder.fullPath
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
titleUpdated() {
|
||||||
|
this.folderUpdated()
|
||||||
|
},
|
||||||
|
folderUpdated() {
|
||||||
|
if (!this.selectedFolderPath || !this.podcast.title) {
|
||||||
|
this.fullPath = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.fullPath = Path.join(this.selectedFolderPath, this.podcast.title)
|
||||||
|
},
|
||||||
|
submit() {
|
||||||
|
const podcastPayload = {
|
||||||
|
path: this.fullPath,
|
||||||
|
folderId: this.selectedFolderId,
|
||||||
|
libraryId: this.currentLibrary.id,
|
||||||
|
media: {
|
||||||
|
metadata: {
|
||||||
|
title: this.podcast.title,
|
||||||
|
author: this.podcast.author,
|
||||||
|
description: this.podcast.description,
|
||||||
|
releaseDate: this.podcast.releaseDate,
|
||||||
|
genres: [...this.podcast.genres],
|
||||||
|
feedUrl: this.podcast.feedUrl,
|
||||||
|
imageUrl: this.podcast.imageUrl,
|
||||||
|
itunesPageUrl: this.podcast.itunesPageUrl,
|
||||||
|
itunesId: this.podcast.itunesId,
|
||||||
|
itunesArtistId: this.podcast.itunesArtistId,
|
||||||
|
language: this.podcast.language
|
||||||
|
},
|
||||||
|
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Podcast payload', podcastPayload)
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$post('/api/podcasts', podcastPayload)
|
||||||
|
.then((libraryItem) => {
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.success('Podcast created successfully')
|
||||||
|
this.show = false
|
||||||
|
this.$router.push(`/item/${libraryItem.id}`)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to create podcast'
|
||||||
|
console.error('Failed to create podcast', error)
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
// Prefer using itunes podcast data but not always passed in if manually entering rss feed
|
||||||
|
this.podcast.title = this._podcastData.title || this.feedMetadata.title || ''
|
||||||
|
this.podcast.author = this._podcastData.artistName || this.feedMetadata.author || ''
|
||||||
|
this.podcast.description = this._podcastData.description || this.feedMetadata.descriptionPlain || ''
|
||||||
|
this.podcast.releaseDate = this._podcastData.releaseDate || ''
|
||||||
|
this.podcast.genres = this._podcastData.genres || this.feedMetadata.categories || []
|
||||||
|
this.podcast.feedUrl = this._podcastData.feedUrl || this.feedMetadata.feedUrl || ''
|
||||||
|
this.podcast.imageUrl = this._podcastData.cover || this.feedMetadata.image || ''
|
||||||
|
this.podcast.itunesPageUrl = this._podcastData.pageUrl || ''
|
||||||
|
this.podcast.itunesId = this._podcastData.id || ''
|
||||||
|
this.podcast.itunesArtistId = this._podcastData.artistId || ''
|
||||||
|
this.podcast.language = this._podcastData.language || ''
|
||||||
|
this.podcast.autoDownloadEpisodes = false
|
||||||
|
|
||||||
|
if (this.folderItems[0]) {
|
||||||
|
this.selectedFolderId = this.folderItems[0].value
|
||||||
|
this.folderUpdated()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#podcast-wrapper {
|
||||||
|
min-height: 400px;
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
#episodes-scroll {
|
||||||
|
max-height: calc(80vh - 200px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -18,10 +18,7 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {}
|
||||||
ebookType: '',
|
|
||||||
ebookUrl: ''
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
show(newVal) {
|
show(newVal) {
|
||||||
@@ -47,46 +44,65 @@ export default {
|
|||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
abTitle() {
|
abTitle() {
|
||||||
return this.selectedAudiobook.book.title
|
return this.mediaMetadata.title
|
||||||
},
|
},
|
||||||
abAuthor() {
|
abAuthor() {
|
||||||
return this.selectedAudiobook.book.author
|
return this.mediaMetadata.authorName
|
||||||
},
|
},
|
||||||
selectedAudiobook() {
|
selectedLibraryItem() {
|
||||||
return this.$store.state.selectedAudiobook
|
return this.$store.state.selectedLibraryItem || {}
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.selectedLibraryItem.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
libraryId() {
|
libraryId() {
|
||||||
return this.selectedAudiobook.libraryId
|
return this.selectedLibraryItem.libraryId
|
||||||
},
|
},
|
||||||
folderId() {
|
folderId() {
|
||||||
return this.selectedAudiobook.folderId
|
return this.selectedLibraryItem.folderId
|
||||||
},
|
},
|
||||||
ebooks() {
|
ebookFile() {
|
||||||
return this.selectedAudiobook.ebooks || []
|
return this.media.ebookFile
|
||||||
},
|
},
|
||||||
epubEbook() {
|
ebookFormat() {
|
||||||
return this.ebooks.find((eb) => eb.ext === '.epub')
|
if (!this.ebookFile) return null
|
||||||
|
return this.ebookFile.ebookFormat
|
||||||
},
|
},
|
||||||
mobiEbook() {
|
ebookType() {
|
||||||
return this.ebooks.find((eb) => eb.ext === '.mobi' || eb.ext === '.azw3')
|
if (this.isMobi) return 'mobi'
|
||||||
|
else if (this.isEpub) return 'epub'
|
||||||
|
else if (this.isPdf) return 'pdf'
|
||||||
|
else if (this.isComic) return 'comic'
|
||||||
|
return null
|
||||||
},
|
},
|
||||||
pdfEbook() {
|
isEpub() {
|
||||||
return this.ebooks.find((eb) => eb.ext === '.pdf')
|
return this.ebookFormat == 'epub'
|
||||||
},
|
},
|
||||||
comicEbook() {
|
isMobi() {
|
||||||
return this.ebooks.find((eb) => eb.ext === '.cbz' || eb.ext === '.cbr')
|
return this.ebookFormat == 'mobi' || this.ebookFormat == 'azw3'
|
||||||
|
},
|
||||||
|
isPdf() {
|
||||||
|
return this.ebookFormat == 'pdf'
|
||||||
|
},
|
||||||
|
isComic() {
|
||||||
|
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
||||||
|
},
|
||||||
|
ebookUrl() {
|
||||||
|
if (!this.ebookFile) return null
|
||||||
|
var itemRelPath = this.selectedLibraryItem.relPath
|
||||||
|
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
|
||||||
|
var relPath = this.ebookFile.metadata.relPath
|
||||||
|
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
||||||
|
return `/ebook/${this.libraryId}/${this.folderId}/${itemRelPath}/${relPath}`
|
||||||
},
|
},
|
||||||
userToken() {
|
userToken() {
|
||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
|
||||||
selectedAudiobookFile() {
|
|
||||||
return this.$store.state.selectedAudiobookFile
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
getEbookUrl(path) {
|
|
||||||
return `/ebook/${this.libraryId}/${this.folderId}/${path}`
|
|
||||||
},
|
|
||||||
hotkey(action) {
|
hotkey(action) {
|
||||||
console.log('Reader hotkey', action)
|
console.log('Reader hotkey', action)
|
||||||
if (!this.$refs.readerComponent) return
|
if (!this.$refs.readerComponent) return
|
||||||
@@ -107,31 +123,6 @@ export default {
|
|||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.registerListeners()
|
this.registerListeners()
|
||||||
|
|
||||||
if (this.selectedAudiobookFile) {
|
|
||||||
this.ebookUrl = this.getEbookUrl(this.selectedAudiobookFile.path)
|
|
||||||
if (this.selectedAudiobookFile.ext === '.pdf') {
|
|
||||||
this.ebookType = 'pdf'
|
|
||||||
} else if (this.selectedAudiobookFile.ext === '.mobi' || this.selectedAudiobookFile.ext === '.azw3') {
|
|
||||||
this.ebookType = 'mobi'
|
|
||||||
} else if (this.selectedAudiobookFile.ext === '.epub') {
|
|
||||||
this.ebookType = 'epub'
|
|
||||||
} else if (this.selectedAudiobookFile.ext === '.cbr' || this.selectedAudiobookFile.ext === '.cbz') {
|
|
||||||
this.ebookType = 'comic'
|
|
||||||
}
|
|
||||||
} else if (this.epubEbook) {
|
|
||||||
this.ebookType = 'epub'
|
|
||||||
this.ebookUrl = this.getEbookUrl(this.epubEbook.path)
|
|
||||||
} else if (this.mobiEbook) {
|
|
||||||
this.ebookType = 'mobi'
|
|
||||||
this.ebookUrl = this.getEbookUrl(this.mobiEbook.path)
|
|
||||||
} else if (this.pdfEbook) {
|
|
||||||
this.ebookType = 'pdf'
|
|
||||||
this.ebookUrl = this.getEbookUrl(this.pdfEbook.path)
|
|
||||||
} else if (this.comicEbook) {
|
|
||||||
this.ebookType = 'comic'
|
|
||||||
this.ebookUrl = this.getEbookUrl(this.comicEbook.path)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
this.unregisterListeners()
|
this.unregisterListeners()
|
||||||
|
|||||||
@@ -5,16 +5,16 @@
|
|||||||
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
|
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div class="px-2">
|
<div class="px-2">
|
||||||
<p class="text-4xl md:text-5xl font-bold">{{ totalBooks }}</p>
|
<p class="text-4xl md:text-5xl font-bold">{{ totalItems }}</p>
|
||||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Books in Library</p>
|
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Items in Library</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex px-4">
|
<div class="flex px-4">
|
||||||
<span class="material-icons text-7xl">show_chart</span>
|
<span class="material-icons text-7xl">show_chart</span>
|
||||||
<div class="px-1">
|
<div class="px-1">
|
||||||
<p class="text-4xl md:text-5xl font-bold">{{ totalAudiobookHours }}</p>
|
<p class="text-4xl md:text-5xl font-bold">{{ totalTime }}</p>
|
||||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Overall Hours</p>
|
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Overall {{ useOverallHours ? 'Hours' : 'Days' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,8 +61,8 @@ export default {
|
|||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
totalBooks() {
|
totalItems() {
|
||||||
return this.libraryStats ? this.libraryStats.totalBooks : 0
|
return this.libraryStats ? this.libraryStats.totalItems : 0
|
||||||
},
|
},
|
||||||
totalAuthors() {
|
totalAuthors() {
|
||||||
return this.libraryStats ? this.libraryStats.totalAuthors : 0
|
return this.libraryStats ? this.libraryStats.totalAuthors : 0
|
||||||
@@ -70,12 +70,11 @@ export default {
|
|||||||
numAudioTracks() {
|
numAudioTracks() {
|
||||||
return this.libraryStats ? this.libraryStats.numAudioTracks : 0
|
return this.libraryStats ? this.libraryStats.numAudioTracks : 0
|
||||||
},
|
},
|
||||||
totalAudiobookDuration() {
|
totalDuration() {
|
||||||
return this.libraryStats ? this.libraryStats.totalDuration : 0
|
return this.libraryStats ? this.libraryStats.totalDuration : 0
|
||||||
},
|
},
|
||||||
totalAudiobookHours() {
|
totalHours() {
|
||||||
var totalHours = Math.round(this.totalAudiobookDuration / (60 * 60))
|
return Math.round(this.totalDuration / (60 * 60))
|
||||||
return totalHours
|
|
||||||
},
|
},
|
||||||
totalSizePretty() {
|
totalSizePretty() {
|
||||||
var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0
|
var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0
|
||||||
@@ -86,6 +85,13 @@ export default {
|
|||||||
},
|
},
|
||||||
totalSizeMod() {
|
totalSizeMod() {
|
||||||
return this.totalSizePretty.split(' ')[1]
|
return this.totalSizePretty.split(' ')[1]
|
||||||
|
},
|
||||||
|
useOverallHours() {
|
||||||
|
return this.totalHours < 10000
|
||||||
|
},
|
||||||
|
totalTime() {
|
||||||
|
if (this.useOverallHours) return this.totalHours
|
||||||
|
return Math.round(this.totalHours / 24)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full my-2">
|
|
||||||
<div class="w-full bg-primary px-4 py-2 flex items-center cursor-pointer">
|
|
||||||
<p class="pr-4">All Files</p>
|
|
||||||
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ allFiles.length }}</span>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
|
|
||||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
|
||||||
</div>
|
|
||||||
<div class="w-full">
|
|
||||||
<table class="text-sm tracksTable">
|
|
||||||
<tr class="font-book">
|
|
||||||
<th class="text-left px-4">Path</th>
|
|
||||||
<th class="text-left px-4 w-24">Filetype</th>
|
|
||||||
<th v-if="userCanDownload" class="text-center w-20">Download</th>
|
|
||||||
</tr>
|
|
||||||
<template v-for="file in allFiles">
|
|
||||||
<tr :key="file.path">
|
|
||||||
<td class="font-book pl-2">
|
|
||||||
{{ showFullPath ? file.fullPath : file.path }}
|
|
||||||
</td>
|
|
||||||
<td class="text-xs">
|
|
||||||
<p>{{ file.filetype }}</p>
|
|
||||||
</td>
|
|
||||||
<td v-if="userCanDownload" class="text-center">
|
|
||||||
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
audiobook: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
showFullPath: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
audiobookId() {
|
|
||||||
return this.audiobook.id
|
|
||||||
},
|
|
||||||
audiobookPath() {
|
|
||||||
return this.audiobook.path
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
|
||||||
},
|
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
isMissing() {
|
|
||||||
return this.audiobook.isMissing
|
|
||||||
},
|
|
||||||
showDownload() {
|
|
||||||
return this.userCanDownload && !this.isMissing
|
|
||||||
},
|
|
||||||
otherFiles() {
|
|
||||||
return this.audiobook.otherFiles || []
|
|
||||||
},
|
|
||||||
audioFiles() {
|
|
||||||
return this.audiobook.audioFiles || []
|
|
||||||
},
|
|
||||||
audioFilesCleaned() {
|
|
||||||
return this.audioFiles.map((af) => {
|
|
||||||
return {
|
|
||||||
path: af.path,
|
|
||||||
fullPath: af.fullPath,
|
|
||||||
relativePath: this.getRelativePath(af.path),
|
|
||||||
filetype: 'audio'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
otherFilesCleaned() {
|
|
||||||
return this.otherFiles.map((af) => {
|
|
||||||
return {
|
|
||||||
path: af.path,
|
|
||||||
fullPath: af.fullPath,
|
|
||||||
relativePath: this.getRelativePath(af.path),
|
|
||||||
filetype: af.filetype
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
allFiles() {
|
|
||||||
return this.audioFilesCleaned.concat(this.otherFilesCleaned)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
getRelativePath(path) {
|
|
||||||
var filePath = path.replace(/\\/g, '/')
|
|
||||||
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
|
|
||||||
return filePath
|
|
||||||
.replace(audiobookPath + '/', '')
|
|
||||||
.replace(/%/g, '%25')
|
|
||||||
.replace(/#/g, '%23')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -13,17 +13,20 @@
|
|||||||
<th class="hidden sm:table-cell w-20 md:w-28">Size</th>
|
<th class="hidden sm:table-cell w-20 md:w-28">Size</th>
|
||||||
<th class="w-36"></th>
|
<th class="w-36"></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="backup in backups" :key="backup.id">
|
<tr v-for="backup in backups" :key="backup.id" :class="!backup.serverVersion ? 'bg-error bg-opacity-10' : ''">
|
||||||
<td>
|
<td>
|
||||||
<p class="truncate text-xs sm:text-sm md:text-base">/{{ backup.path.replace(/\\/g, '/') }}</p>
|
<p class="truncate text-xs sm:text-sm md:text-base">/{{ backup.path.replace(/\\/g, '/') }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden sm:table-cell font-sans text-base">{{ backup.datePretty }}</td>
|
<td class="hidden sm:table-cell font-sans text-sm">{{ backup.datePretty }}</td>
|
||||||
<td class="hidden sm:table-cell font-mono md:text-base text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
|
<td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="w-full flex flex-row items-center justify-center">
|
<div class="w-full flex flex-row items-center justify-center">
|
||||||
<ui-btn small color="primary" @click="applyBackup(backup)">Apply</ui-btn>
|
<ui-btn v-if="backup.serverVersion" small color="primary" @click="applyBackup(backup)">Apply</ui-btn>
|
||||||
|
|
||||||
<a :href="`/metadata/${backup.path.replace(/%/g, '%25').replace(/#/g, '%23')}?token=${userToken}`" class="mx-1 pt-1 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
|
<a v-if="backup.serverVersion" :href="`/metadata/${$encodeUriPath(backup.path)}?token=${userToken}`" class="mx-1 pt-1 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
|
||||||
|
<ui-tooltip v-else text="This backup was created with an old version of audiobookshelf no longer supported" direction="bottom" class="mx-2 flex items-center">
|
||||||
|
<span class="material-icons-outlined text-error">error_outline</span>
|
||||||
|
</ui-tooltip>
|
||||||
|
|
||||||
<span class="material-icons text-xl hover:text-error hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="deleteBackupClick(backup)">delete</span>
|
<span class="material-icons text-xl hover:text-error hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="deleteBackupClick(backup)">delete</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,7 +45,7 @@
|
|||||||
<div v-if="selectedBackup" class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
<div v-if="selectedBackup" class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||||
<p class="text-error text-lg font-semibold">Important Notice!</p>
|
<p class="text-error text-lg font-semibold">Important Notice!</p>
|
||||||
<p class="text-base py-1">Applying a backup will overwrite users, user progress, book details, settings, and covers stored in metadata with the backed up data.</p>
|
<p class="text-base py-1">Applying a backup will overwrite users, user progress, book details, settings, and covers stored in metadata with the backed up data.</p>
|
||||||
<p class="text-base py-1">Backups <strong>do not</strong> modify any files in your library folders, only data in the audiobookshelf created <span class="font-mono">/config</span> and <span class="font-mono">/metadata</span> directories.</p>
|
<p class="text-base py-1">Backups <strong>do not</strong> modify any files in your library folders, only data in the audiobookshelf created <span class="font-mono">/config</span> and <span class="font-mono">/metadata</span> directories. If you have enabled server settings to store cover art and metadata in your library folders then those are not backup up or overwritten.</p>
|
||||||
<p class="text-base py-1">All clients using your server will be automatically refreshed.</p>
|
<p class="text-base py-1">All clients using your server will be automatically refreshed.</p>
|
||||||
|
|
||||||
<p class="text-lg text-center my-8">Are you sure you want to apply the backup created on {{ selectedBackup.datePretty }}?</p>
|
<p class="text-lg text-center my-8">Are you sure you want to apply the backup created on {{ selectedBackup.datePretty }}?</p>
|
||||||
@@ -77,14 +80,24 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
confirm() {
|
confirm() {
|
||||||
this.showConfirmApply = false
|
this.showConfirmApply = false
|
||||||
this.$root.socket.once('apply_backup_complete', this.applyBackupComplete)
|
|
||||||
this.$root.socket.emit('apply_backup', this.selectedBackup.id)
|
this.$axios
|
||||||
|
.$get(`/api/backups/${this.selectedBackup.id}/apply`)
|
||||||
|
.then(() => {
|
||||||
|
this.isBackingUp = false
|
||||||
|
location.replace('/config/backups?backup=1')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.isBackingUp = false
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error('Failed to apply backup')
|
||||||
|
})
|
||||||
},
|
},
|
||||||
deleteBackupClick(backup) {
|
deleteBackupClick(backup) {
|
||||||
if (confirm(`Are you sure you want to delete backup for ${backup.datePretty}?`)) {
|
if (confirm(`Are you sure you want to delete backup for ${backup.datePretty}?`)) {
|
||||||
this.processing = true
|
this.processing = true
|
||||||
this.$axios
|
this.$axios
|
||||||
.$delete(`/api/backup/${backup.id}`)
|
.$delete(`/api/backups/${backup.id}`)
|
||||||
.then((backups) => {
|
.then((backups) => {
|
||||||
console.log('Backup deleted', backups)
|
console.log('Backup deleted', backups)
|
||||||
this.$store.commit('setBackups', backups)
|
this.$store.commit('setBackups', backups)
|
||||||
@@ -98,29 +111,24 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
applyBackupComplete(success) {
|
|
||||||
if (success) {
|
|
||||||
// this.$toast.success('Backup Applied, refresh the page')
|
|
||||||
location.replace('/config/backups?backup=1')
|
|
||||||
} else {
|
|
||||||
this.$toast.error('Failed to apply backup')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
applyBackup(backup) {
|
applyBackup(backup) {
|
||||||
this.selectedBackup = backup
|
this.selectedBackup = backup
|
||||||
this.showConfirmApply = true
|
this.showConfirmApply = true
|
||||||
},
|
},
|
||||||
backupComplete(backups) {
|
|
||||||
this.isBackingUp = false
|
|
||||||
if (backups) {
|
|
||||||
this.$toast.success('Backup Successful')
|
|
||||||
this.$store.commit('setBackups', backups)
|
|
||||||
} else this.$toast.error('Backup Failed')
|
|
||||||
},
|
|
||||||
clickCreateBackup() {
|
clickCreateBackup() {
|
||||||
this.isBackingUp = true
|
this.isBackingUp = true
|
||||||
this.$root.socket.once('backup_complete', this.backupComplete)
|
this.$axios
|
||||||
this.$root.socket.emit('create_backup')
|
.$post('/api/backups')
|
||||||
|
.then((backups) => {
|
||||||
|
this.isBackingUp = false
|
||||||
|
this.$toast.success('Backup Successful')
|
||||||
|
this.$store.commit('setBackups', backups)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.isBackingUp = false
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error('Backup Failed')
|
||||||
|
})
|
||||||
},
|
},
|
||||||
backupUploaded(file) {
|
backupUploaded(file) {
|
||||||
var form = new FormData()
|
var form = new FormData()
|
||||||
@@ -129,7 +137,7 @@ export default {
|
|||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post('/api/backup/upload', form)
|
.$post('/api/backups/upload', form)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
console.log('Upload backup result', result)
|
console.log('Upload backup result', result)
|
||||||
this.$store.commit('setBackups', result)
|
this.$store.commit('setBackups', result)
|
||||||
@@ -171,11 +179,11 @@ export default {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#backups tr:nth-child(even) {
|
#backups tr:nth-child(even):not(.bg-error) {
|
||||||
background-color: #3a3a3a;
|
background-color: #3a3a3a;
|
||||||
}
|
}
|
||||||
|
|
||||||
#backups tr:not(.staticrow):hover {
|
#backups tr:not(.staticrow):not(.bg-error):hover {
|
||||||
background-color: #444;
|
background-color: #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<p class="font-mono text-sm">{{ books.length }}</p>
|
<p class="font-mono text-sm">{{ books.length }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p v-if="totalDuration">{{ totalDurationPretty }}</p>
|
<!-- <p v-if="totalDuration">{{ totalDurationPretty }}</p> -->
|
||||||
</div>
|
</div>
|
||||||
<draggable v-model="booksCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
<draggable v-model="booksCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
||||||
<transition-group type="transition" :name="!drag ? 'collection-book' : null">
|
<transition-group type="transition" :name="!drag ? 'collection-book' : null">
|
||||||
@@ -56,16 +56,6 @@ export default {
|
|||||||
},
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||||
},
|
|
||||||
totalDuration() {
|
|
||||||
var _total = 0
|
|
||||||
this.books.forEach((book) => {
|
|
||||||
_total += book.duration
|
|
||||||
})
|
|
||||||
return _total
|
|
||||||
},
|
|
||||||
totalDurationPretty() {
|
|
||||||
return this.$elapsedPretty(this.totalDuration)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
+16
-41
@@ -1,14 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full my-2">
|
<div class="w-full my-2">
|
||||||
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||||
<p class="pr-2 md:pr-4">Other Files</p>
|
<p class="pr-2 md:pr-4">Library Files</p>
|
||||||
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||||
<span class="text-sm font-mono">{{ files.length }}</span>
|
<span class="text-sm font-mono">{{ files.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<!-- <nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
|
||||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
|
||||||
</nuxt-link> -->
|
|
||||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||||
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
|
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
|
||||||
<span class="material-icons text-4xl">expand_more</span>
|
<span class="material-icons text-4xl">expand_more</span>
|
||||||
@@ -19,22 +16,25 @@
|
|||||||
<table class="text-sm tracksTable">
|
<table class="text-sm tracksTable">
|
||||||
<tr class="font-book">
|
<tr class="font-book">
|
||||||
<th class="text-left px-4">Path</th>
|
<th class="text-left px-4">Path</th>
|
||||||
|
<th class="text-left w-24 min-w-24">Size</th>
|
||||||
<th class="text-left px-4 w-24">Filetype</th>
|
<th class="text-left px-4 w-24">Filetype</th>
|
||||||
<th v-if="userCanDownload && !isMissing" class="text-center w-20">Download</th>
|
<th v-if="userCanDownload && !isMissing" class="text-center w-20">Download</th>
|
||||||
</tr>
|
</tr>
|
||||||
<template v-for="file in otherFilesCleaned">
|
<template v-for="file in files">
|
||||||
<tr :key="file.path">
|
<tr :key="file.path">
|
||||||
<td class="font-book pl-2">
|
<td class="font-book px-4">
|
||||||
{{ showFullPath ? file.fullPath : file.path }}
|
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono">
|
||||||
|
{{ $bytesPretty(file.metadata.size) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-xs">
|
<td class="text-xs">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span v-if="file.filetype === 'ebook'" class="material-icons text-base mr-1 cursor-pointer text-white text-opacity-60 hover:text-opacity-100" @click="readEbookClick(file)">auto_stories </span>
|
<p>{{ file.fileType }}</p>
|
||||||
<p>{{ file.filetype }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td v-if="userCanDownload && !isMissing" class="text-center">
|
<td v-if="userCanDownload && !isMissing" class="text-center">
|
||||||
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
<a :href="`/s/item/${libraryItemId}/${$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
@@ -51,10 +51,9 @@ export default {
|
|||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
audiobook: {
|
libraryItemId: String,
|
||||||
type: Object,
|
isMissing: Boolean,
|
||||||
default: () => null
|
expanded: Boolean // start expanded
|
||||||
}
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -63,44 +62,20 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
audiobookId() {
|
|
||||||
return this.audiobook.id
|
|
||||||
},
|
|
||||||
audiobookPath() {
|
|
||||||
return this.audiobook.path
|
|
||||||
},
|
|
||||||
otherFilesCleaned() {
|
|
||||||
return this.files.map((file) => {
|
|
||||||
var filePath = file.path.replace(/\\/g, '/')
|
|
||||||
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
|
|
||||||
|
|
||||||
return {
|
|
||||||
...file,
|
|
||||||
relativePath: filePath
|
|
||||||
.replace(audiobookPath + '/', '')
|
|
||||||
.replace(/%/g, '%25')
|
|
||||||
.replace(/#/g, '%23')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
userToken() {
|
userToken() {
|
||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
},
|
||||||
isMissing() {
|
|
||||||
return this.audiobook.isMissing
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
readEbookClick(file) {
|
|
||||||
this.$store.commit('showEReaderForFile', { audiobook: this.audiobook, file })
|
|
||||||
},
|
|
||||||
clickBar() {
|
clickBar() {
|
||||||
this.showFiles = !this.showFiles
|
this.showFiles = !this.showFiles
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {
|
||||||
|
this.showFiles = this.expanded
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full my-2">
|
<div class="w-full my-2">
|
||||||
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||||
<p class="pr-2 md:pr-4">Audio Tracks</p>
|
<p class="pr-2 md:pr-4">{{ title }}</p>
|
||||||
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||||
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
|
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-2 md:mr-4">
|
<nuxt-link v-if="userCanUpdate && !isFile" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
|
||||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
|
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
|
||||||
@@ -25,20 +25,20 @@
|
|||||||
<th class="text-left w-20">Duration</th>
|
<th class="text-left w-20">Duration</th>
|
||||||
<th v-if="userCanDownload" class="text-center w-20">Download</th>
|
<th v-if="userCanDownload" class="text-center w-20">Download</th>
|
||||||
</tr>
|
</tr>
|
||||||
<template v-for="track in tracksCleaned">
|
<template v-for="track in tracks">
|
||||||
<tr :key="track.index">
|
<tr :key="track.index">
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<p>{{ track.index }}</p>
|
<p>{{ track.index }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
|
<td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
|
||||||
<td class="font-mono">
|
<td class="font-mono">
|
||||||
{{ $bytesPretty(track.size) }}
|
{{ $bytesPretty(track.metadata.size) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="font-mono">
|
<td class="font-mono">
|
||||||
{{ $secondsToTimestamp(track.duration) }}
|
{{ $secondsToTimestamp(track.duration) }}
|
||||||
</td>
|
</td>
|
||||||
<td v-if="userCanDownload" class="text-center">
|
<td v-if="userCanDownload" class="text-center">
|
||||||
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
<a :href="`/s/item/${libraryItemId}/${$encodeUriPath(track.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
@@ -51,14 +51,16 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: 'Audio Tracks'
|
||||||
|
},
|
||||||
tracks: {
|
tracks: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
audiobook: {
|
libraryItemId: String,
|
||||||
type: Object,
|
isFile: Boolean
|
||||||
default: () => null
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -67,26 +69,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
audiobookId() {
|
|
||||||
return this.audiobook.id
|
|
||||||
},
|
|
||||||
audiobookPath() {
|
|
||||||
return this.audiobook.path
|
|
||||||
},
|
|
||||||
tracksCleaned() {
|
|
||||||
return this.tracks.map((track) => {
|
|
||||||
var trackPath = track.path.replace(/\\/g, '/')
|
|
||||||
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
|
|
||||||
|
|
||||||
return {
|
|
||||||
...track,
|
|
||||||
relativePath: trackPath
|
|
||||||
.replace(audiobookPath + '/', '')
|
|
||||||
.replace(/%/g, '%25')
|
|
||||||
.replace(/#/g, '%23')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
userToken() {
|
userToken() {
|
||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full my-4" @mousedown.prevent @mouseup.prevent>
|
<div class="w-full my-4">
|
||||||
<div class="w-full bg-primary px-6 py-1 flex items-center cursor-pointer" @click.stop="clickBar">
|
<div class="w-full bg-primary px-6 py-1 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||||
<p class="pr-4">{{ title }}</p>
|
<p class="pr-4">{{ title }}</p>
|
||||||
<span class="bg-black-400 rounded-xl py-0.5 px-2 text-sm font-mono">{{ files.length }}</span>
|
<span class="bg-black-400 rounded-xl py-0.5 px-2 text-sm font-mono">{{ files.length }}</span>
|
||||||
|
|||||||
@@ -26,11 +26,11 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-sm">{{ user.type }}</td>
|
<td class="text-sm">{{ user.type }}</td>
|
||||||
<td class="hidden lg:table-cell">
|
<td class="hidden lg:table-cell">
|
||||||
<div v-if="usersOnline[user.id] && usersOnline[user.id].stream && usersOnline[user.id].stream.audiobook && usersOnline[user.id].stream.audiobook.book">
|
<div v-if="usersOnline[user.id] && usersOnline[user.id].session && usersOnline[user.id].session.libraryItem && usersOnline[user.id].session.libraryItem.media">
|
||||||
<p class="truncate text-xs">Reading: {{ usersOnline[user.id].stream.audiobook.book.title || '' }}</p>
|
<p class="truncate text-xs">Listening: {{ usersOnline[user.id].session.libraryItem.media.metadata.title || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="user.audiobooks && getLastRead(user.audiobooks)">
|
<div v-else-if="user.mostRecent">
|
||||||
<p class="truncate text-xs">Last: {{ getLastRead(user.audiobooks) }}</p>
|
<p class="truncate text-xs">Last: {{ user.mostRecent.metadata.title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-xs font-mono hidden sm:table-cell">
|
<td class="text-xs font-mono hidden sm:table-cell">
|
||||||
@@ -76,28 +76,13 @@ export default {
|
|||||||
currentUserId() {
|
currentUserId() {
|
||||||
return this.$store.state.user.user.id
|
return this.$store.state.user.user.id
|
||||||
},
|
},
|
||||||
userStream() {
|
|
||||||
return this.$store.state.streamAudiobook
|
|
||||||
},
|
|
||||||
usersOnline() {
|
usersOnline() {
|
||||||
var usermap = {}
|
var usermap = {}
|
||||||
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, stream: u.stream }))
|
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, session: u.session }))
|
||||||
return usermap
|
return usermap
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
getLastRead(audiobooks) {
|
|
||||||
var abs = Object.values(audiobooks).filter((ab) => {
|
|
||||||
return ab.progress > 0
|
|
||||||
})
|
|
||||||
if (abs.length) {
|
|
||||||
abs = abs.sort((a, b) => b.lastUpdate - a.lastUpdate)
|
|
||||||
// Book object is attached on request
|
|
||||||
if (abs[0].book) return abs[0].book.title
|
|
||||||
return abs[0].audiobookTitle ? abs[0].audiobookTitle : null
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
deleteUserClick(user) {
|
deleteUserClick(user) {
|
||||||
if (this.isDeletingUser) return
|
if (this.isDeletingUser) return
|
||||||
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
|
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
|
||||||
|
|||||||
@@ -7,20 +7,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full relative" :style="{ width: coverWidth + 'px' }">
|
<div class="h-full relative" :style="{ width: coverWidth + 'px' }">
|
||||||
<covers-book-cover :audiobook="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<div class="absolute top-0 left-0 bg-black bg-opacity-50 flex items-center justify-center h-full w-full z-10" v-show="isHovering && showPlayBtn">
|
<div class="absolute top-0 left-0 bg-black bg-opacity-50 flex items-center justify-center h-full w-full z-10" v-show="isHovering && showPlayBtn">
|
||||||
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
|
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
|
||||||
<span class="material-icons">play_arrow</span>
|
<span class="material-icons">play_arrow</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-80 h-full px-2 flex items-center">
|
<div class="flex-grow max-w-md h-full px-2 flex items-center">
|
||||||
<div>
|
<div class="truncate px-1">
|
||||||
<nuxt-link :to="`/audiobook/${book.id}`" class="truncate hover:underline">{{ bookTitle }}</nuxt-link>
|
<nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline">{{ bookTitle }}</nuxt-link>
|
||||||
<nuxt-link :to="`/library/${book.libraryId}/bookshelf?filter=authors.${$encode(bookAuthor)}`" class="truncate block text-gray-400 text-sm hover:underline">{{ bookAuthor }}</nuxt-link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow flex items-center">
|
<div class="w-20 flex items-center">
|
||||||
<p class="font-mono text-sm">{{ bookDuration }}</p>
|
<p class="font-mono text-sm">{{ bookDuration }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -28,15 +27,10 @@
|
|||||||
<span class="material-icons text-lg text-white text-opacity-70 hover:text-opacity-100 cursor-pointer">radio_button_unchecked</span>
|
<span class="material-icons text-lg text-white text-opacity-70 hover:text-opacity-100 cursor-pointer">radio_button_unchecked</span>
|
||||||
</div> -->
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="absolute top-0 left-0 z-40 bg-red-500 w-full h-full">
|
|
||||||
<div class="w-24 h-full absolute top-0 -right-24 transform transition-transform" :class="isHovering ? 'translate-x-0' : '-translate-x-24'">
|
|
||||||
<span class="material-icons">edit</span>
|
|
||||||
</div>
|
|
||||||
</div> -->
|
|
||||||
<div class="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : '-translate-x-24'">
|
<div class="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : '-translate-x-24'">
|
||||||
<div class="flex h-full items-center">
|
<div class="flex h-full items-center">
|
||||||
<ui-tooltip :text="isRead ? 'Mark as Not Read' : 'Mark as Read'" direction="top">
|
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
|
||||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="isRead" borderless class="mx-1 mt-0.5" @click="toggleRead" />
|
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
<div class="mx-1" :class="isHovering ? '' : 'ml-6'">
|
<div class="mx-1" :class="isHovering ? '' : 'ml-6'">
|
||||||
<ui-icon-btn icon="edit" borderless @click="clickEdit" />
|
<ui-icon-btn icon="edit" borderless @click="clickEdit" />
|
||||||
@@ -68,12 +62,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
userIsRead: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
this.isRead = newVal
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isDragging: {
|
isDragging: {
|
||||||
handler(newVal) {
|
handler(newVal) {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
@@ -83,17 +71,23 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
_book() {
|
media() {
|
||||||
return this.book.book || {}
|
return this.book.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
tracks() {
|
||||||
|
return this.media.tracks || []
|
||||||
},
|
},
|
||||||
bookTitle() {
|
bookTitle() {
|
||||||
return this._book.title || ''
|
return this.mediaMetadata.title || ''
|
||||||
},
|
},
|
||||||
bookAuthor() {
|
bookAuthor() {
|
||||||
return this._book.authorFL || ''
|
return (this.mediaMetadata.authors || []).map((au) => au.name).join(', ')
|
||||||
},
|
},
|
||||||
bookDuration() {
|
bookDuration() {
|
||||||
return this.$secondsToTimestamp(this.book.duration)
|
return this.$secondsToTimestamp(this.media.duration)
|
||||||
},
|
},
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return this.book.isMissing
|
return this.book.isMissing
|
||||||
@@ -101,23 +95,17 @@ export default {
|
|||||||
isInvalid() {
|
isInvalid() {
|
||||||
return this.book.isInvalid
|
return this.book.isInvalid
|
||||||
},
|
},
|
||||||
numTracks() {
|
|
||||||
return this.book.numTracks
|
|
||||||
},
|
|
||||||
isStreaming() {
|
isStreaming() {
|
||||||
return this.$store.getters['getAudiobookIdStreaming'] === this.book.id
|
return this.$store.getters['getLibraryItemIdStreaming'] === this.book.id
|
||||||
},
|
},
|
||||||
showPlayBtn() {
|
showPlayBtn() {
|
||||||
return !this.isMissing && !this.isInvalid && !this.isStreaming && this.numTracks
|
return !this.isMissing && !this.isInvalid && !this.isStreaming && this.tracks.length
|
||||||
},
|
},
|
||||||
userAudiobooks() {
|
itemProgress() {
|
||||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
return this.$store.getters['user/getUserMediaProgress'](this.book.id)
|
||||||
},
|
},
|
||||||
userAudiobook() {
|
userIsFinished() {
|
||||||
return this.userAudiobooks[this.book.id] || null
|
return this.itemProgress ? !!this.itemProgress.isFinished : false
|
||||||
},
|
|
||||||
userIsRead() {
|
|
||||||
return this.userAudiobook ? !!this.userAudiobook.isRead : false
|
|
||||||
},
|
},
|
||||||
coverWidth() {
|
coverWidth() {
|
||||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.6
|
if (this.bookCoverAspectRatio === 1) return 50 * 1.6
|
||||||
@@ -133,26 +121,28 @@ export default {
|
|||||||
this.isHovering = false
|
this.isHovering = false
|
||||||
},
|
},
|
||||||
playClick() {
|
playClick() {
|
||||||
this.$eventBus.$emit('play-audiobook', this.book.id)
|
this.$eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: this.book.id
|
||||||
|
})
|
||||||
},
|
},
|
||||||
clickEdit() {
|
clickEdit() {
|
||||||
this.$emit('edit', this.book)
|
this.$emit('edit', this.book)
|
||||||
},
|
},
|
||||||
toggleRead() {
|
toggleFinished() {
|
||||||
var updatePayload = {
|
var updatePayload = {
|
||||||
isRead: !this.isRead
|
isFinished: !this.userIsFinished
|
||||||
}
|
}
|
||||||
this.isProcessingReadUpdate = true
|
this.isProcessingReadUpdate = true
|
||||||
this.$axios
|
this.$axios
|
||||||
.$patch(`/api/me/audiobook/${this.book.id}`, updatePayload)
|
.$patch(`/api/me/progress/${this.book.id}`, updatePayload)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.isProcessingReadUpdate = false
|
this.isProcessingReadUpdate = false
|
||||||
this.$toast.success(`"${this.bookTitle}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
this.isProcessingReadUpdate = false
|
this.isProcessingReadUpdate = false
|
||||||
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
removeClick() {
|
removeClick() {
|
||||||
|
|||||||
+2
-2
@@ -9,11 +9,11 @@
|
|||||||
<draggable :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
<draggable :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
||||||
<template v-for="library in libraryCopies">
|
<template v-for="library in libraryCopies">
|
||||||
<div :key="library.id" class="item">
|
<div :key="library.id" class="item">
|
||||||
<modals-libraries-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
<modals-edit-library-modal v-model="showLibraryModal" :library="selectedLibrary" />
|
<modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" />
|
||||||
|
|
||||||
<p class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
|
<p class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
|
||||||
|
|
||||||
+12
-15
@@ -7,13 +7,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
|
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="success" @click.stop="scan">Scan</ui-btn>
|
<ui-btn v-show="isHovering && !libraryScan" small color="success" @click.stop="scan">Scan</ui-btn>
|
||||||
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" class="ml-2" @click.stop="forceScan">Force Re-Scan</ui-btn>
|
<ui-btn v-show="isHovering && !libraryScan" small color="bg" class="ml-2" @click.stop="forceScan">Force Re-Scan</ui-btn>
|
||||||
|
|
||||||
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" class="ml-2" @click.stop="matchAll">Match Books</ui-btn>
|
<ui-btn v-show="isHovering && !libraryScan && isBookLibrary" small color="bg" class="ml-2" @click.stop="matchAll">Match Books</ui-btn>
|
||||||
|
|
||||||
<span v-show="isHovering && !libraryScan && showEdit && canEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
|
<span v-show="isHovering && !libraryScan && showEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
|
||||||
<span v-show="!libraryScan && isHovering && showEdit && canDelete && !isDeleting" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50 cursor-pointer'" @click.stop="deleteClick">delete</span>
|
<span v-show="!libraryScan && isHovering && showEdit && !isDeleting" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50 cursor-pointer'" @click.stop="deleteClick">delete</span>
|
||||||
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
|
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
|
||||||
<svg viewBox="0 0 24 24" class="w-6 h-6">
|
<svg viewBox="0 0 24 24" class="w-6 h-6">
|
||||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||||
@@ -49,20 +49,17 @@ export default {
|
|||||||
libraryScan() {
|
libraryScan() {
|
||||||
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
|
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
|
||||||
},
|
},
|
||||||
canEdit() {
|
mediaType() {
|
||||||
return this.$store.getters['user/getIsRoot']
|
return this.library.mediaType
|
||||||
},
|
},
|
||||||
canDelete() {
|
isBookLibrary() {
|
||||||
return this.$store.getters['user/getIsRoot']
|
return this.mediaType === 'book'
|
||||||
},
|
|
||||||
canScan() {
|
|
||||||
return this.$store.getters['user/getIsRoot']
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
matchAll() {
|
matchAll() {
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/libraries/${this.library.id}/matchbooks`)
|
.$post(`/api/libraries/${this.library.id}/matchall`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('Starting scan for matches')
|
console.log('Starting scan for matches')
|
||||||
})
|
})
|
||||||
@@ -76,10 +73,10 @@ export default {
|
|||||||
this.$emit('edit', this.library)
|
this.$emit('edit', this.library)
|
||||||
},
|
},
|
||||||
scan() {
|
scan() {
|
||||||
this.$root.socket.emit('scan', this.library.id)
|
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id })
|
||||||
},
|
},
|
||||||
forceScan() {
|
forceScan() {
|
||||||
this.$root.socket.emit('scan', this.library.id, { forceRescan: true })
|
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
|
||||||
},
|
},
|
||||||
deleteClick() {
|
deleteClick() {
|
||||||
if (this.isMain) return
|
if (this.isMain) return
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
|
<div v-if="episode" class="flex items-center h-24">
|
||||||
|
<div v-show="userCanUpdate" class="w-12 min-w-12 max-w-16 h-full">
|
||||||
|
<div class="flex h-full items-center justify-center">
|
||||||
|
<span class="material-icons drag-handle text-lg text-white text-opacity-50 hover:text-opacity-100">menu</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-2">
|
||||||
|
<p class="text-sm font-semibold">
|
||||||
|
{{ title }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ description }}</p>
|
||||||
|
<div class="flex items-center pt-2">
|
||||||
|
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick">
|
||||||
|
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||||
|
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
|
||||||
|
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||||
|
</ui-tooltip>
|
||||||
|
<p v-if="episode.episode" class="px-4 text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
||||||
|
<p v-if="publishedAt" class="px-4 text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-24 min-w-24" />
|
||||||
|
</div>
|
||||||
|
<div class="w-24 min-w-24 -right-0 absolute top-0 h-full transform transition-transform" :class="!isHovering ? 'translate-x-32' : 'translate-x-0'">
|
||||||
|
<div class="flex h-full items-center">
|
||||||
|
<div class="mx-1">
|
||||||
|
<ui-icon-btn v-if="userCanUpdate" icon="edit" borderless @click="clickEdit" />
|
||||||
|
</div>
|
||||||
|
<div class="mx-1">
|
||||||
|
<ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!userIsFinished" class="absolute bottom-0 left-0 h-0.5 bg-warning" :style="{ width: itemProgressPercent * 100 + '%' }" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItemId: String,
|
||||||
|
episode: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
isDragging: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isProcessingReadUpdate: false,
|
||||||
|
processingRemove: false,
|
||||||
|
isHovering: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
isDragging: {
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.isHovering = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
|
userCanDelete() {
|
||||||
|
return this.$store.getters['user/getUserCanDelete']
|
||||||
|
},
|
||||||
|
audioFile() {
|
||||||
|
return this.episode.audioFile
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.episode.title || ''
|
||||||
|
},
|
||||||
|
description() {
|
||||||
|
if (this.episode.subtitle) return this.episode.subtitle
|
||||||
|
var desc = this.episode.description || ''
|
||||||
|
return desc
|
||||||
|
},
|
||||||
|
duration() {
|
||||||
|
return this.$secondsToTimestamp(this.episode.duration)
|
||||||
|
},
|
||||||
|
isStreaming() {
|
||||||
|
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
|
||||||
|
},
|
||||||
|
streamIsPlaying() {
|
||||||
|
return this.$store.state.streamIsPlaying && this.isStreaming
|
||||||
|
},
|
||||||
|
itemProgress() {
|
||||||
|
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episode.id)
|
||||||
|
},
|
||||||
|
itemProgressPercent() {
|
||||||
|
return this.itemProgress ? this.itemProgress.progress : 0
|
||||||
|
},
|
||||||
|
userIsFinished() {
|
||||||
|
return this.itemProgress ? !!this.itemProgress.isFinished : false
|
||||||
|
},
|
||||||
|
timeRemaining() {
|
||||||
|
if (this.streamIsPlaying) return 'Playing'
|
||||||
|
if (!this.itemProgress) return this.$elapsedPretty(this.episode.duration)
|
||||||
|
if (this.userIsFinished) return 'Finished'
|
||||||
|
var remaining = Math.floor(this.itemProgress.duration - this.itemProgress.currentTime)
|
||||||
|
return `${this.$elapsedPretty(remaining)} left`
|
||||||
|
},
|
||||||
|
publishedAt() {
|
||||||
|
return this.episode.publishedAt
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
mouseover() {
|
||||||
|
if (this.isDragging) return
|
||||||
|
this.isHovering = true
|
||||||
|
},
|
||||||
|
mouseleave() {
|
||||||
|
this.isHovering = false
|
||||||
|
},
|
||||||
|
clickEdit() {
|
||||||
|
this.$emit('edit', this.episode)
|
||||||
|
},
|
||||||
|
playClick() {
|
||||||
|
if (this.streamIsPlaying) {
|
||||||
|
this.$eventBus.$emit('pause-item')
|
||||||
|
} else {
|
||||||
|
this.$eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: this.libraryItemId,
|
||||||
|
episodeId: this.episode.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleFinished() {
|
||||||
|
var updatePayload = {
|
||||||
|
isFinished: !this.userIsFinished
|
||||||
|
}
|
||||||
|
this.isProcessingReadUpdate = true
|
||||||
|
this.$axios
|
||||||
|
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
|
||||||
|
.then(() => {
|
||||||
|
this.isProcessingReadUpdate = false
|
||||||
|
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.isProcessingReadUpdate = false
|
||||||
|
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
removeClick() {
|
||||||
|
if (confirm(`Are you sure you want to remove episode ${this.title}?\nNote: Does not delete from file system`)) {
|
||||||
|
this.processingRemove = true
|
||||||
|
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/items/${this.libraryItemId}/episode/${this.episode.id}`)
|
||||||
|
.then((updatedPodcast) => {
|
||||||
|
console.log(`Episode removed from podcast`, updatedPodcast)
|
||||||
|
this.$toast.success('Episode removed from podcast')
|
||||||
|
this.processingRemove = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to remove episode from podcast', error)
|
||||||
|
this.$toast.error('Failed to remove episode from podcast')
|
||||||
|
this.processingRemove = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full py-6">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<p class="text-lg mb-0 font-semibold">Episodes</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<controls-episode-sort-select v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" @change="changeSort" />
|
||||||
|
<div v-if="userCanUpdate" class="w-12">
|
||||||
|
<ui-icon-btn v-if="orderChanged" :loading="savingOrder" icon="save" bg-color="primary" class="ml-auto" @click="saveOrder" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
|
||||||
|
<draggable v-model="episodesCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
||||||
|
<transition-group type="transition" :name="!drag ? 'episode' : null">
|
||||||
|
<template v-for="episode in episodesCopy">
|
||||||
|
<tables-podcast-episode-table-row :key="episode.id" :is-dragging="drag" :episode="episode" :library-item-id="libraryItem.id" class="item" :class="drag ? '' : 'episode'" @edit="editEpisode" />
|
||||||
|
</template>
|
||||||
|
</transition-group>
|
||||||
|
</draggable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
draggable
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
sortKey: 'index',
|
||||||
|
sortDesc: true,
|
||||||
|
drag: false,
|
||||||
|
episodesCopy: [],
|
||||||
|
orderChanged: false,
|
||||||
|
savingOrder: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
libraryItem: {
|
||||||
|
handler(newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
dragOptions() {
|
||||||
|
return {
|
||||||
|
animation: 200,
|
||||||
|
group: 'description',
|
||||||
|
ghostClass: 'ghost',
|
||||||
|
disabled: !this.userCanUpdate
|
||||||
|
}
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
episodes() {
|
||||||
|
return this.media.episodes || []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
changeSort() {
|
||||||
|
this.episodesCopy.sort((a, b) => {
|
||||||
|
if (this.sortDesc) {
|
||||||
|
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||||
|
}
|
||||||
|
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||||
|
})
|
||||||
|
|
||||||
|
this.orderChanged = this.checkHasOrderChanged()
|
||||||
|
},
|
||||||
|
checkHasOrderChanged() {
|
||||||
|
for (let i = 0; i < this.episodesCopy.length; i++) {
|
||||||
|
var epc = this.episodesCopy[i]
|
||||||
|
var ep = this.episodes[i]
|
||||||
|
if (epc.index != ep.index) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
editEpisode(episode) {
|
||||||
|
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||||
|
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||||
|
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||||
|
},
|
||||||
|
draggableUpdate() {
|
||||||
|
this.orderChanged = this.checkHasOrderChanged()
|
||||||
|
},
|
||||||
|
async saveOrder() {
|
||||||
|
if (!this.userCanUpdate) return
|
||||||
|
|
||||||
|
this.savingOrder = true
|
||||||
|
|
||||||
|
var episodesUpdate = {
|
||||||
|
episodes: this.episodesCopy.map((b) => b.id)
|
||||||
|
}
|
||||||
|
await this.$axios
|
||||||
|
.$patch(`/api/items/${this.libraryItem.id}/episodes`, episodesUpdate)
|
||||||
|
.then((podcast) => {
|
||||||
|
console.log('Podcast updated', podcast)
|
||||||
|
this.$toast.success('Saved episode order')
|
||||||
|
this.orderChanged = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to update podcast', error)
|
||||||
|
this.$toast.error('Failed to save podcast episode order')
|
||||||
|
})
|
||||||
|
this.savingOrder = false
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.episodesCopy = this.episodes.map((ep) => {
|
||||||
|
return {
|
||||||
|
...ep
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.episode-item {
|
||||||
|
transition: all 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-enter-from,
|
||||||
|
.episode-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-leave-active {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :class="classList">
|
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
|
||||||
<slot />
|
<slot />
|
||||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||||
<!-- <span class="material-icons animate-spin">refresh</span> -->
|
<!-- <span class="material-icons animate-spin">refresh</span> -->
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<label class="flex justify-start items-center cursor-pointer">
|
<label class="flex justify-start items-center" :class="!disabled ? 'cursor-pointer' : ''">
|
||||||
<div class="border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
|
<div class="border-2 rounded flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
|
||||||
<input v-model="selected" type="checkbox" class="opacity-0 absolute cursor-pointer" />
|
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
|
||||||
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="label" class="select-none pl-1 text-gray-100" :class="labelClass">{{ label }}</div>
|
<div v-if="label" class="select-none text-gray-100" :class="labelClassname">{{ label }}</div>
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -18,10 +18,19 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'white'
|
default: 'white'
|
||||||
},
|
},
|
||||||
|
borderColor: {
|
||||||
|
type: String,
|
||||||
|
default: 'gray-400'
|
||||||
|
},
|
||||||
checkColor: {
|
checkColor: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'green-500'
|
default: 'green-500'
|
||||||
}
|
},
|
||||||
|
labelClass: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
disabled: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
@@ -36,15 +45,17 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
wrapperClass() {
|
wrapperClass() {
|
||||||
var classes = [`bg-${this.checkboxBg}`]
|
var classes = [`bg-${this.checkboxBg} border-${this.borderColor}`]
|
||||||
if (this.small) classes.push('w-4 h-4')
|
if (this.small) classes.push('w-4 h-4')
|
||||||
else classes.push('w-6 h-6')
|
else classes.push('w-6 h-6')
|
||||||
|
|
||||||
return classes.join(' ')
|
return classes.join(' ')
|
||||||
},
|
},
|
||||||
labelClass() {
|
labelClassname() {
|
||||||
if (this.small) return 'text-xs md:text-sm'
|
if (this.labelClass) return this.labelClass
|
||||||
return ''
|
var classes = ['pl-1']
|
||||||
|
if (this.small) classes.push('text-xs md:text-sm')
|
||||||
|
return classes.join(' ')
|
||||||
},
|
},
|
||||||
svgClass() {
|
svgClass() {
|
||||||
var classes = [`text-${this.checkColor}`]
|
var classes = [`text-${this.checkColor}`]
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
||||||
<p class="text-sm font-semibold">{{ label }}</p>
|
<p class="text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||||
<button type="button" :disabled="disabled" class="relative w-full border border-gray-600 rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" :class="small ? 'h-9' : 'h-10'" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
|
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
<span class="material-icons text-gray-100">expand_more</span>
|
<span class="material-icons">expand_more</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -63,6 +63,16 @@ export default {
|
|||||||
},
|
},
|
||||||
selectedText() {
|
selectedText() {
|
||||||
return this.selectedItem ? this.selectedItem.text : ''
|
return this.selectedItem ? this.selectedItem.text : ''
|
||||||
|
},
|
||||||
|
buttonClass() {
|
||||||
|
var classes = []
|
||||||
|
if (this.small) classes.push('h-9')
|
||||||
|
else classes.push('h-10')
|
||||||
|
|
||||||
|
if (this.disabled) classes.push('cursor-not-allowed border-gray-600 bg-primary bg-opacity-70 border-opacity-70 text-gray-400')
|
||||||
|
else classes.push('cursor-pointer border-gray-600 bg-primary text-gray-100')
|
||||||
|
|
||||||
|
return classes.join(' ')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export default {
|
|||||||
var _files = Array.from(e.target.files)
|
var _files = Array.from(e.target.files)
|
||||||
if (_files && _files.length) {
|
if (_files && _files.length) {
|
||||||
var file = _files[0]
|
var file = _files[0]
|
||||||
console.log('File', file)
|
|
||||||
this.$emit('change', file)
|
this.$emit('change', file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" @mousedown.prevent :disabled="disabled" :class="className" @click="clickBtn">
|
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
|
||||||
<span :class="outlined ? 'material-icons-outlined' : 'material-icons'" :style="{ fontSize }">{{ icon }}</span>
|
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||||
|
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span v-else :class="outlined ? 'material-icons-outlined' : 'material-icons'" :style="{ fontSize }">{{ icon }}</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -14,7 +19,8 @@ export default {
|
|||||||
default: 'primary'
|
default: 'primary'
|
||||||
},
|
},
|
||||||
outlined: Boolean,
|
outlined: Boolean,
|
||||||
borderless: Boolean
|
borderless: Boolean,
|
||||||
|
loading: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
@@ -34,7 +40,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickBtn(e) {
|
clickBtn(e) {
|
||||||
if (this.disabled) {
|
if (this.disabled || this.loading) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<ul ref="menu" v-show="isFocused && items.length && (itemsToShow.length || currentSearch)" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
<ul ref="menu" v-show="isFocused && itemsToShow.length" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in itemsToShow">
|
<template v-for="item in itemsToShow">
|
||||||
<li :key="item" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
<li :key="item" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -47,7 +47,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isFocused: false,
|
isFocused: false,
|
||||||
currentSearch: null,
|
// currentSearch: null,
|
||||||
typingTimeout: null,
|
typingTimeout: null,
|
||||||
textInput: null
|
textInput: null
|
||||||
}
|
}
|
||||||
@@ -70,12 +70,13 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemsToShow() {
|
itemsToShow() {
|
||||||
if (!this.currentSearch || !this.textInput || this.textInput === this.input) {
|
if (!this.editable) return this.items
|
||||||
return this.items
|
if (!this.textInput || this.textInput === this.input) {
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
return this.items.filter((i) => {
|
return this.items.filter((i) => {
|
||||||
var iValue = String(i).toLowerCase()
|
var iValue = String(i).toLowerCase()
|
||||||
return iValue.includes(this.currentSearch.toLowerCase())
|
return iValue.includes(this.textInput.toLowerCase())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -83,7 +84,7 @@ export default {
|
|||||||
keydownInput() {
|
keydownInput() {
|
||||||
clearTimeout(this.typingTimeout)
|
clearTimeout(this.typingTimeout)
|
||||||
this.typingTimeout = setTimeout(() => {
|
this.typingTimeout = setTimeout(() => {
|
||||||
this.currentSearch = this.textInput
|
// this.currentSearch = this.textInput
|
||||||
}, 100)
|
}, 100)
|
||||||
},
|
},
|
||||||
inputFocus() {
|
inputFocus() {
|
||||||
@@ -127,11 +128,11 @@ export default {
|
|||||||
if (val && !this.items.includes(val)) {
|
if (val && !this.items.includes(val)) {
|
||||||
this.$emit('newItem', val)
|
this.$emit('newItem', val)
|
||||||
}
|
}
|
||||||
this.currentSearch = null
|
// this.currentSearch = null
|
||||||
},
|
},
|
||||||
clickedOption(e, item) {
|
clickedOption(e, item) {
|
||||||
this.textInput = null
|
this.textInput = null
|
||||||
this.currentSearch = null
|
// this.currentSearch = null
|
||||||
this.input = item
|
this.input = item
|
||||||
if (this.$refs.input) this.$refs.input.blur()
|
if (this.$refs.input) this.$refs.input.blur()
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-19
@@ -4,22 +4,15 @@
|
|||||||
|
|
||||||
<button type="button" :disabled="disabled" class="relative h-full w-full border border-gray-600 rounded shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer bg-primary text-gray-100 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
<button type="button" :disabled="disabled" class="relative h-full w-full border border-gray-600 rounded shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer bg-primary text-gray-100 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<widgets-library-icon :icon="selected" class="mr-2" />
|
<widgets-library-icon :icon="selected" />
|
||||||
<span class="block truncate text-sm">{{ selectedName }}</span>
|
|
||||||
</span>
|
|
||||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
|
||||||
<span class="material-icons text-gray-100">expand_more</span>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<transition name="menu">
|
<transition name="menu">
|
||||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
||||||
<template v-for="type in types">
|
<template v-for="type in types">
|
||||||
<li :key="type.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="select(type)">
|
<li :key="type.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400 flex justify-center" id="listbox-option-0" role="option" @click="select(type)">
|
||||||
<div class="flex items-center px-3">
|
<widgets-library-icon :icon="type.id" />
|
||||||
<widgets-library-icon :icon="type.id" class="mr-2" />
|
|
||||||
<span class="font-normal block truncate font-sans text-sm">{{ type.name }}</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -34,7 +27,7 @@ export default {
|
|||||||
disabled: Boolean,
|
disabled: Boolean,
|
||||||
label: {
|
label: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Media Type'
|
default: 'Icon'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -47,23 +40,23 @@ export default {
|
|||||||
showMenu: false,
|
showMenu: false,
|
||||||
types: [
|
types: [
|
||||||
{
|
{
|
||||||
id: 'default',
|
id: 'database',
|
||||||
name: 'Default'
|
name: 'Database'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'audiobooks',
|
id: 'audiobook',
|
||||||
name: 'Audiobooks'
|
name: 'Audiobooks'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'books',
|
id: 'book',
|
||||||
name: 'Books'
|
name: 'Books'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'podcasts',
|
id: 'podcast',
|
||||||
name: 'Podcasts'
|
name: 'Podcasts'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'comics',
|
id: 'comic',
|
||||||
name: 'Comics'
|
name: 'Comics'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -72,7 +65,7 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
selected: {
|
selected: {
|
||||||
get() {
|
get() {
|
||||||
return this.value || 'default'
|
return this.value || 'database'
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
@@ -82,7 +75,7 @@ export default {
|
|||||||
return this.types.find((t) => t.id === this.selected)
|
return this.types.find((t) => t.id === this.selected)
|
||||||
},
|
},
|
||||||
selectedName() {
|
selectedName() {
|
||||||
return this.selectedItem ? this.selectedItem.name : 'Default'
|
return this.selectedItem ? this.selectedItem.name : 'Database'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<span class="block truncate">{{ label }}</span>
|
<span class="block truncate">{{ label }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
<span class="material-icons text-gray-100">person</span>
|
<span class="material-icons text-gray-100" aria-label="User Account" role="button">person</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,15 @@
|
|||||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||||
<div ref="wrapper" class="relative">
|
<div ref="wrapper" class="relative">
|
||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="submitForm">
|
||||||
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded-md px-2 py-1 cursor-text" :class="disabled ? 'bg-black-300' : 'bg-primary'" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
|
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
|
||||||
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
|
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
|
||||||
|
<span v-if="showEdit" class="material-icons text-white hover:text-warning" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
|
||||||
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
|
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
|
||||||
</div>
|
</div>
|
||||||
{{ item }}
|
{{ item }}
|
||||||
</div>
|
</div>
|
||||||
<input ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -47,7 +48,9 @@ export default {
|
|||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
label: String,
|
label: String,
|
||||||
disabled: Boolean
|
disabled: Boolean,
|
||||||
|
readonly: Boolean,
|
||||||
|
showEdit: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -67,7 +70,7 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
selected: {
|
selected: {
|
||||||
get() {
|
get() {
|
||||||
return this.value
|
return this.value || []
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
@@ -76,6 +79,13 @@ export default {
|
|||||||
showMenu() {
|
showMenu() {
|
||||||
return this.isFocused
|
return this.isFocused
|
||||||
},
|
},
|
||||||
|
wrapperClass() {
|
||||||
|
var classes = []
|
||||||
|
if (this.disabled) classes.push('bg-black-300')
|
||||||
|
else classes.push('bg-primary')
|
||||||
|
if (!this.readonly) classes.push('cursor-text')
|
||||||
|
return classes.join(' ')
|
||||||
|
},
|
||||||
itemsToShow() {
|
itemsToShow() {
|
||||||
if (!this.currentSearch || !this.textInput) {
|
if (!this.currentSearch || !this.textInput) {
|
||||||
return this.items
|
return this.items
|
||||||
@@ -88,6 +98,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
editItem(item) {
|
||||||
|
this.$emit('edit', item)
|
||||||
|
},
|
||||||
keydownInput() {
|
keydownInput() {
|
||||||
clearTimeout(this.typingTimeout)
|
clearTimeout(this.typingTimeout)
|
||||||
this.typingTimeout = setTimeout(() => {
|
this.typingTimeout = setTimeout(() => {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default {
|
|||||||
},
|
},
|
||||||
selectedItems() {
|
selectedItems() {
|
||||||
return (this.value || []).map((v) => {
|
return (this.value || []).map((v) => {
|
||||||
return this.items.find((i) => i.value === v) || {}
|
return this.items.find((i) => i.value === v) || { text: v, value: v }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -113,6 +113,7 @@ export default {
|
|||||||
removeItem(itemValue) {
|
removeItem(itemValue) {
|
||||||
var remaining = this.selected.filter((i) => i !== itemValue)
|
var remaining = this.selected.filter((i) => i !== itemValue)
|
||||||
this.$emit('input', remaining)
|
this.$emit('input', remaining)
|
||||||
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.recalcMenuPos()
|
this.recalcMenuPos()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||||
|
<div ref="wrapper" class="relative">
|
||||||
|
<form @submit.prevent="submitForm">
|
||||||
|
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-0.5" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||||
|
<div v-for="item in selected" :key="item.id" class="rounded-full px-2 py-0.5 m-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center justify-center relative min-w-12">
|
||||||
|
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
|
||||||
|
<span v-if="showEdit" class="material-icons text-white hover:text-warning mr-1" style="font-size: 1rem" @click.stop="editItem(item)">edit</span>
|
||||||
|
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.id)">close</span>
|
||||||
|
</div>
|
||||||
|
{{ item[textKey] }}
|
||||||
|
</div>
|
||||||
|
<div v-if="showEdit" class="rounded-full cursor-pointer w-6 h-6 mx-0.5 bg-bg flex items-center justify-center">
|
||||||
|
<span class="material-icons text-white hover:text-success pt-px pr-px" style="font-size: 1.1rem" @click.stop="addItem">add</span>
|
||||||
|
</div>
|
||||||
|
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||||
|
<template v-for="item in itemsToShow">
|
||||||
|
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="font-normal ml-3 block truncate">{{ item.name }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="getIsSelected(item.id)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||||
|
<span class="material-icons text-xl">checkmark</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
<li v-if="!itemsToShow.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<span class="font-normal">No items</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
endpoint: String,
|
||||||
|
label: String,
|
||||||
|
disabled: Boolean,
|
||||||
|
readonly: Boolean,
|
||||||
|
showEdit: Boolean,
|
||||||
|
textKey: {
|
||||||
|
type: String,
|
||||||
|
default: 'name'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
textInput: null,
|
||||||
|
currentSearch: null,
|
||||||
|
searching: false,
|
||||||
|
typingTimeout: null,
|
||||||
|
isFocused: false,
|
||||||
|
menu: null,
|
||||||
|
items: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
showMenu(newVal) {
|
||||||
|
if (newVal) this.setListener()
|
||||||
|
else this.removeListener()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
selected: {
|
||||||
|
get() {
|
||||||
|
return this.value || []
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
wrapperClass() {
|
||||||
|
var classes = []
|
||||||
|
if (this.disabled) classes.push('bg-black-300')
|
||||||
|
else classes.push('bg-primary')
|
||||||
|
if (!this.readonly) classes.push('cursor-text')
|
||||||
|
return classes.join(' ')
|
||||||
|
},
|
||||||
|
showMenu() {
|
||||||
|
return this.isFocused && this.currentSearch
|
||||||
|
},
|
||||||
|
itemsToShow() {
|
||||||
|
return this.items
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
addItem() {
|
||||||
|
this.$emit('add')
|
||||||
|
},
|
||||||
|
editItem(item) {
|
||||||
|
this.$emit('edit', item)
|
||||||
|
},
|
||||||
|
getIsSelected(itemValue) {
|
||||||
|
return !!this.selected.find((i) => i.id === itemValue)
|
||||||
|
},
|
||||||
|
async search() {
|
||||||
|
if (this.searching) return
|
||||||
|
this.currentSearch = this.textInput
|
||||||
|
this.searching = true
|
||||||
|
var results = await this.$axios.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15&token=${this.userToken}`).catch((error) => {
|
||||||
|
console.error('Failed to get search results', error)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
this.items = results || []
|
||||||
|
this.searching = false
|
||||||
|
},
|
||||||
|
keydownInput() {
|
||||||
|
clearTimeout(this.typingTimeout)
|
||||||
|
this.typingTimeout = setTimeout(() => {
|
||||||
|
this.search()
|
||||||
|
}, 250)
|
||||||
|
this.setInputWidth()
|
||||||
|
},
|
||||||
|
setInputWidth() {
|
||||||
|
setTimeout(() => {
|
||||||
|
var value = this.$refs.input.value
|
||||||
|
var len = value.length * 7 + 24
|
||||||
|
this.$refs.input.style.width = len + 'px'
|
||||||
|
this.recalcMenuPos()
|
||||||
|
}, 50)
|
||||||
|
},
|
||||||
|
recalcMenuPos() {
|
||||||
|
if (!this.menu) return
|
||||||
|
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
|
||||||
|
if (boundingBox.y > window.innerHeight - 8) {
|
||||||
|
// Input is off the page
|
||||||
|
return this.forceBlur()
|
||||||
|
}
|
||||||
|
var menuHeight = this.menu.clientHeight
|
||||||
|
var top = boundingBox.y + boundingBox.height - 4
|
||||||
|
if (top + menuHeight > window.innerHeight - 20) {
|
||||||
|
// Reverse menu to open upwards
|
||||||
|
top = boundingBox.y - menuHeight - 4
|
||||||
|
}
|
||||||
|
|
||||||
|
this.menu.style.top = top + 'px'
|
||||||
|
this.menu.style.left = boundingBox.x + 'px'
|
||||||
|
this.menu.style.width = boundingBox.width + 'px'
|
||||||
|
},
|
||||||
|
unmountMountMenu() {
|
||||||
|
if (!this.$refs.menu) return
|
||||||
|
this.menu = this.$refs.menu
|
||||||
|
|
||||||
|
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
|
||||||
|
this.menu.remove()
|
||||||
|
document.body.appendChild(this.menu)
|
||||||
|
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
|
||||||
|
this.menu.style.left = boundingBox.x + 'px'
|
||||||
|
this.menu.style.width = boundingBox.width + 'px'
|
||||||
|
},
|
||||||
|
inputFocus() {
|
||||||
|
if (!this.menu) {
|
||||||
|
this.unmountMountMenu()
|
||||||
|
}
|
||||||
|
this.isFocused = true
|
||||||
|
this.$nextTick(this.recalcMenuPos)
|
||||||
|
},
|
||||||
|
inputBlur() {
|
||||||
|
if (!this.isFocused) return
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.activeElement === this.$refs.input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.isFocused = false
|
||||||
|
if (this.textInput) this.submitForm()
|
||||||
|
}, 50)
|
||||||
|
},
|
||||||
|
focus() {
|
||||||
|
if (this.$refs.input) this.$refs.input.focus()
|
||||||
|
},
|
||||||
|
blur() {
|
||||||
|
if (this.$refs.input) this.$refs.input.blur()
|
||||||
|
},
|
||||||
|
forceBlur() {
|
||||||
|
this.isFocused = false
|
||||||
|
if (this.textInput) this.submitForm()
|
||||||
|
if (this.$refs.input) this.$refs.input.blur()
|
||||||
|
},
|
||||||
|
clickedOption(e, item) {
|
||||||
|
if (e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
if (this.$refs.input) this.$refs.input.focus()
|
||||||
|
|
||||||
|
var newSelected = null
|
||||||
|
if (this.getIsSelected(item.id)) {
|
||||||
|
newSelected = this.selected.filter((s) => s.id !== item.id)
|
||||||
|
this.$emit('removedItem', item.id)
|
||||||
|
} else {
|
||||||
|
newSelected = this.selected.concat([item])
|
||||||
|
}
|
||||||
|
this.textInput = null
|
||||||
|
this.currentSearch = null
|
||||||
|
this.$emit('input', newSelected)
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.recalcMenuPos()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
clickWrapper() {
|
||||||
|
if (this.disabled) return
|
||||||
|
if (this.showMenu) {
|
||||||
|
return this.blur()
|
||||||
|
}
|
||||||
|
this.focus()
|
||||||
|
},
|
||||||
|
removeItem(itemId) {
|
||||||
|
var remaining = this.selected.filter((i) => i.id !== itemId)
|
||||||
|
this.$emit('input', remaining)
|
||||||
|
this.$emit('removedItem', itemId)
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.recalcMenuPos()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
insertNewItem(item) {
|
||||||
|
this.selected.push(item)
|
||||||
|
this.$emit('input', this.selected)
|
||||||
|
this.$emit('newItem', item)
|
||||||
|
this.textInput = null
|
||||||
|
this.currentSearch = null
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.blur()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
submitForm() {
|
||||||
|
if (!this.textInput) return
|
||||||
|
|
||||||
|
var cleaned = this.textInput.trim()
|
||||||
|
var matchesItem = this.items.find((i) => {
|
||||||
|
return i === cleaned
|
||||||
|
})
|
||||||
|
if (matchesItem) {
|
||||||
|
this.clickedOption(null, matchesItem)
|
||||||
|
} else {
|
||||||
|
this.insertNewItem({
|
||||||
|
id: `new-${Date.now()}`,
|
||||||
|
name: this.textInput
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scroll() {
|
||||||
|
this.recalcMenuPos()
|
||||||
|
},
|
||||||
|
setListener() {
|
||||||
|
document.addEventListener('scroll', this.scroll, true)
|
||||||
|
},
|
||||||
|
removeListener() {
|
||||||
|
document.removeEventListener('scroll', this.scroll, true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {},
|
||||||
|
beforeDestroy() {
|
||||||
|
if (this.menu) this.menu.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
input {
|
||||||
|
border-style: inherit !important;
|
||||||
|
}
|
||||||
|
input:read-only {
|
||||||
|
color: #aaa;
|
||||||
|
background-color: #444;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||||
|
<div ref="wrapper" class="relative">
|
||||||
|
<form @submit.prevent="submitForm">
|
||||||
|
<div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'">
|
||||||
|
<input ref="input" v-model="textInput" :disabled="disabled" class="h-full w-full bg-transparent focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ul ref="menu" v-show="isFocused && currentSearch" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||||
|
<template v-for="item in items">
|
||||||
|
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="font-normal ml-3 block truncate">{{ item.name }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="isItemSelected(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||||
|
<span class="material-icons text-xl">checkmark</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
<li v-if="!items.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<span class="font-normal">No items</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: String,
|
||||||
|
disabled: Boolean,
|
||||||
|
label: String,
|
||||||
|
endpoint: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isFocused: false,
|
||||||
|
currentSearch: null,
|
||||||
|
typingTimeout: null,
|
||||||
|
textInput: null,
|
||||||
|
searching: false,
|
||||||
|
items: [],
|
||||||
|
selectedItemObject: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
value: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
this.textInput = newVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
input: {
|
||||||
|
get() {
|
||||||
|
return this.value || ''
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
isItemSelected(item) {
|
||||||
|
return !!this.input.toLowerCase() === item.name
|
||||||
|
},
|
||||||
|
async search() {
|
||||||
|
if (this.searching) return
|
||||||
|
this.currentSearch = this.textInput
|
||||||
|
this.searching = true
|
||||||
|
var results = await this.$axios.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15`).catch((error) => {
|
||||||
|
console.error('Failed to get search results', error)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
this.items = results || []
|
||||||
|
this.searching = false
|
||||||
|
},
|
||||||
|
keydownInput() {
|
||||||
|
clearTimeout(this.typingTimeout)
|
||||||
|
this.typingTimeout = setTimeout(() => {
|
||||||
|
this.search()
|
||||||
|
}, 250)
|
||||||
|
},
|
||||||
|
inputFocus() {
|
||||||
|
this.isFocused = true
|
||||||
|
},
|
||||||
|
blur() {
|
||||||
|
// Handle blur immediately
|
||||||
|
this.isFocused = false
|
||||||
|
if (this.inputName.toLowerCase() !== this.textInput.toLowerCase()) {
|
||||||
|
var val = this.textInput ? this.textInput.trim() : null
|
||||||
|
if (val) {
|
||||||
|
this.submitForm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.$refs.input) {
|
||||||
|
this.$refs.input.blur()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
inputBlur() {
|
||||||
|
if (!this.isFocused) return
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.activeElement === this.$refs.input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.isFocused = false
|
||||||
|
if (this.input !== this.textInput) {
|
||||||
|
var val = this.textInput ? this.textInput.trim() : null
|
||||||
|
if (val) {
|
||||||
|
this.setItem(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
|
},
|
||||||
|
submitForm() {
|
||||||
|
var val = this.textInput ? this.textInput.trim() : null
|
||||||
|
if (val) {
|
||||||
|
this.setItem(val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setItem(itemText) {
|
||||||
|
if (!this.items.find((i) => i.name.toLowerCase() !== val.toLowerCase())) {
|
||||||
|
var newItem = {
|
||||||
|
id: `new-${Date.now()}`,
|
||||||
|
name: val
|
||||||
|
}
|
||||||
|
this.$emit('selected', newItem)
|
||||||
|
this.input = val
|
||||||
|
} else {
|
||||||
|
var item = this.items.find((i) => i.name.toLowerCase() !== val.toLowerCase())
|
||||||
|
this.$emit('selected', item)
|
||||||
|
this.input = item.name
|
||||||
|
}
|
||||||
|
this.currentSearch = null
|
||||||
|
},
|
||||||
|
clickedOption(e, item) {
|
||||||
|
this.textInput = item.name
|
||||||
|
this.currentSearch = null
|
||||||
|
this.input = item.name
|
||||||
|
this.selectedItemObject = item
|
||||||
|
this.$emit('selected', item)
|
||||||
|
|
||||||
|
if (this.$refs.input) this.$refs.input.blur()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -83,4 +83,7 @@ input:read-only {
|
|||||||
color: #bbb;
|
color: #bbb;
|
||||||
background-color: #444;
|
background-color: #444;
|
||||||
}
|
}
|
||||||
|
input::-webkit-calendar-picker-indicator {
|
||||||
|
filter: invert(1);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">
|
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">
|
||||||
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||||
</p>
|
</p>
|
||||||
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :type="type" class="w-full" />
|
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" @blur="inputBlurred" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'text'
|
default: 'text'
|
||||||
},
|
},
|
||||||
|
readonly: Boolean,
|
||||||
disabled: Boolean
|
disabled: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -37,6 +38,9 @@ export default {
|
|||||||
if (this.$refs.input && this.$refs.input.blur) {
|
if (this.$refs.input && this.$refs.input.blur) {
|
||||||
this.$refs.input.blur()
|
this.$refs.input.blur()
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
inputBlurred() {
|
||||||
|
this.$emit('blur')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<textarea v-model="inputValue" :rows="rows" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
|
<textarea ref="input" v-model="inputValue" :rows="rows" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -31,6 +31,11 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
change(e) {
|
change(e) {
|
||||||
this.$emit('change', e.target.value)
|
this.$emit('change', e.target.value)
|
||||||
|
},
|
||||||
|
blur() {
|
||||||
|
if (this.$refs.input && this.$refs.input.blur) {
|
||||||
|
this.$refs.input.blur()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||||
<ui-textarea-input v-model="inputValue" :disabled="disabled" :rows="rows" class="w-full" />
|
<ui-textarea-input ref="input" v-model="inputValue" :disabled="disabled" :rows="rows" class="w-full" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -29,7 +29,13 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {
|
||||||
|
blur() {
|
||||||
|
if (this.$refs.input && this.$refs.input.blur) {
|
||||||
|
this.$refs.input.blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4">
|
||||||
|
<p class="text-sm mb-2">
|
||||||
|
Missing Parts <span class="text-sm">({{ missingParts.length }})</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-sm font-mono">{{ missingPartChunks.join(', ') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="invalidParts.length" class="bg-error border-red-800 shadow-md p-4">
|
||||||
|
<p class="text-sm mb-2">
|
||||||
|
Invalid Parts <span class="text-sm">({{ invalidParts.length }})</span>
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<p v-for="part in invalidParts" :key="part.filename" class="text-sm font-mono">{{ part.filename }}: {{ part.error }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<tables-tracks-table :title="`Audiobook Tracks`" :tracks="media.tracks" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItemId: String,
|
||||||
|
media: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
isFile: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
missingPartChunks() {
|
||||||
|
if (this.missingParts === 1) return this.missingParts[0]
|
||||||
|
var chunks = []
|
||||||
|
|
||||||
|
var currentIndex = this.missingParts[0]
|
||||||
|
var currentChunk = [this.missingParts[0]]
|
||||||
|
|
||||||
|
for (let i = 1; i < this.missingParts.length; i++) {
|
||||||
|
var partIndex = this.missingParts[i]
|
||||||
|
if (currentIndex === partIndex - 1) {
|
||||||
|
currentChunk.push(partIndex)
|
||||||
|
currentIndex = partIndex
|
||||||
|
} else {
|
||||||
|
// console.log('Chunk ended', currentChunk.join(', '), currentIndex, partIndex)
|
||||||
|
if (currentChunk.length === 0) {
|
||||||
|
console.error('How is current chunk 0?', currentChunk.join(', '))
|
||||||
|
}
|
||||||
|
chunks.push(currentChunk)
|
||||||
|
currentChunk = [partIndex]
|
||||||
|
currentIndex = partIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentChunk.length) {
|
||||||
|
chunks.push(currentChunk)
|
||||||
|
}
|
||||||
|
chunks = chunks.map((chunk) => {
|
||||||
|
if (chunk.length === 1) return chunk[0]
|
||||||
|
else return `${chunk[0]}-${chunk[chunk.length - 1]}`
|
||||||
|
})
|
||||||
|
return chunks
|
||||||
|
},
|
||||||
|
missingParts() {
|
||||||
|
return this.media.missingParts || []
|
||||||
|
},
|
||||||
|
invalidParts() {
|
||||||
|
return this.media.invalidParts || []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full relative">
|
||||||
|
<form class="w-full h-full" @submit.prevent="submitForm">
|
||||||
|
<div id="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
|
||||||
|
<div class="flex -mx-1">
|
||||||
|
<div class="w-1/2 px-1">
|
||||||
|
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-1">
|
||||||
|
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex mt-2 -mx-1">
|
||||||
|
<div class="w-3/4 px-1">
|
||||||
|
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
|
||||||
|
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-1">
|
||||||
|
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex mt-2 -mx-1">
|
||||||
|
<div class="flex-grow px-1">
|
||||||
|
<ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||||
|
|
||||||
|
<div class="flex mt-2 -mx-1">
|
||||||
|
<div class="w-1/2 px-1">
|
||||||
|
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-1">
|
||||||
|
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex mt-2 -mx-1">
|
||||||
|
<div class="w-1/2 px-1">
|
||||||
|
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
|
||||||
|
</div>
|
||||||
|
<div class="w-1/4 px-1">
|
||||||
|
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
|
||||||
|
</div>
|
||||||
|
<div class="w-1/4 px-1">
|
||||||
|
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex mt-2 -mx-1">
|
||||||
|
<div class="w-1/2 px-1">
|
||||||
|
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
|
||||||
|
</div>
|
||||||
|
<div class="w-1/4 px-1">
|
||||||
|
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-1 pt-6">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div v-if="showSeriesForm" class="absolute top-0 left-0 z-20 w-full h-full bg-black bg-opacity-50 rounded-lg flex items-center justify-center" @click="cancelSeriesForm">
|
||||||
|
<div class="absolute top-0 right-0 p-4">
|
||||||
|
<span class="material-icons text-gray-200 hover:text-white text-4xl cursor-pointer">close</span>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="submitSeriesForm">
|
||||||
|
<div class="bg-bg rounded-lg p-8" @click.stop>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-grow p-1 min-w-80">
|
||||||
|
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
|
||||||
|
</div>
|
||||||
|
<div class="w-40 p-1">
|
||||||
|
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end mt-2 p-1">
|
||||||
|
<ui-btn type="submit">Save</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selectedSeries: {},
|
||||||
|
showSeriesForm: false,
|
||||||
|
details: {
|
||||||
|
title: null,
|
||||||
|
subtitle: null,
|
||||||
|
description: null,
|
||||||
|
authors: [],
|
||||||
|
narrators: [],
|
||||||
|
series: [],
|
||||||
|
publishedYear: null,
|
||||||
|
publisher: null,
|
||||||
|
language: null,
|
||||||
|
isbn: null,
|
||||||
|
asin: null,
|
||||||
|
genres: [],
|
||||||
|
explicit: false
|
||||||
|
},
|
||||||
|
newTags: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
libraryItem: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
genres() {
|
||||||
|
return this.filterData.genres || []
|
||||||
|
},
|
||||||
|
tags() {
|
||||||
|
return this.filterData.tags || []
|
||||||
|
},
|
||||||
|
series() {
|
||||||
|
return this.filterData.series || []
|
||||||
|
},
|
||||||
|
narrators() {
|
||||||
|
return this.filterData.narrators || []
|
||||||
|
},
|
||||||
|
filterData() {
|
||||||
|
return this.$store.state.libraries.filterData || {}
|
||||||
|
},
|
||||||
|
existingSeriesNames() {
|
||||||
|
// Only show series names not already selected
|
||||||
|
var alreadySelectedSeriesIds = this.details.series.map((se) => se.id)
|
||||||
|
return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name)
|
||||||
|
},
|
||||||
|
seriesItems: {
|
||||||
|
get() {
|
||||||
|
return this.details.series.map((se) => {
|
||||||
|
return {
|
||||||
|
displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name,
|
||||||
|
...se
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.details.series = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getDetails() {
|
||||||
|
this.forceBlur()
|
||||||
|
return this.checkForChanges()
|
||||||
|
},
|
||||||
|
getTitleAndAuthorName() {
|
||||||
|
this.forceBlur()
|
||||||
|
return {
|
||||||
|
title: this.details.title,
|
||||||
|
author: (this.details.authors || []).map((au) => au.name).join(', ')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mapBatchDetails(batchDetails) {
|
||||||
|
for (const key in batchDetails) {
|
||||||
|
if (key === 'tags') {
|
||||||
|
this.newTags = [...batchDetails.tags]
|
||||||
|
} else if (key === 'genres' || key === 'narrators') {
|
||||||
|
this.details[key] = [...batchDetails[key]]
|
||||||
|
} else if (key === 'authors' || key === 'series') {
|
||||||
|
this.details[key] = batchDetails[key].map((i) => ({ ...i }))
|
||||||
|
} else {
|
||||||
|
this.details[key] = batchDetails[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
forceBlur() {
|
||||||
|
if (this.$refs.titleInput) this.$refs.titleInput.blur()
|
||||||
|
if (this.$refs.subtitleInput) this.$refs.subtitleInput.blur()
|
||||||
|
if (this.$refs.publishYearInput) this.$refs.publishYearInput.blur()
|
||||||
|
if (this.$refs.descriptionInput) this.$refs.descriptionInput.blur()
|
||||||
|
if (this.$refs.isbnInput) this.$refs.isbnInput.blur()
|
||||||
|
if (this.$refs.asinInput) this.$refs.asinInput.blur()
|
||||||
|
if (this.$refs.publisherInput) this.$refs.publisherInput.blur()
|
||||||
|
if (this.$refs.languageInput) this.$refs.languageInput.blur()
|
||||||
|
|
||||||
|
if (this.$refs.authorsSelect && this.$refs.authorsSelect.isFocused) {
|
||||||
|
this.$refs.authorsSelect.forceBlur()
|
||||||
|
}
|
||||||
|
if (this.$refs.narratorsSelect && this.$refs.narratorsSelect.isFocused) {
|
||||||
|
this.$refs.narratorsSelect.forceBlur()
|
||||||
|
}
|
||||||
|
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
|
||||||
|
this.$refs.genresSelect.forceBlur()
|
||||||
|
}
|
||||||
|
if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {
|
||||||
|
this.$refs.tagsSelect.forceBlur()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancelSeriesForm() {
|
||||||
|
this.showSeriesForm = false
|
||||||
|
},
|
||||||
|
editSeriesItem(series) {
|
||||||
|
var _series = this.details.series.find((se) => se.id === series.id)
|
||||||
|
if (!_series) return
|
||||||
|
this.selectedSeries = {
|
||||||
|
..._series
|
||||||
|
}
|
||||||
|
this.showSeriesForm = true
|
||||||
|
},
|
||||||
|
addNewSeries() {
|
||||||
|
this.selectedSeries = {
|
||||||
|
id: `new-${Date.now()}`,
|
||||||
|
name: '',
|
||||||
|
sequence: ''
|
||||||
|
}
|
||||||
|
this.showSeriesForm = true
|
||||||
|
},
|
||||||
|
submitSeriesForm() {
|
||||||
|
if (!this.selectedSeries.name) {
|
||||||
|
this.$toast.error('Must enter a series')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.$refs.newSeriesSelect) {
|
||||||
|
this.$refs.newSeriesSelect.blur()
|
||||||
|
}
|
||||||
|
var existingSeriesIndex = this.details.series.findIndex((se) => se.id === this.selectedSeries.id)
|
||||||
|
|
||||||
|
var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
|
||||||
|
if (existingSeriesIndex < 0 && seriesSameName) {
|
||||||
|
this.selectedSeries.id = seriesSameName.id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingSeriesIndex >= 0) {
|
||||||
|
this.details.series.splice(existingSeriesIndex, 1, { ...this.selectedSeries })
|
||||||
|
} else {
|
||||||
|
this.details.series.push({
|
||||||
|
...this.selectedSeries
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showSeriesForm = false
|
||||||
|
},
|
||||||
|
stringArrayEqual(array1, array2) {
|
||||||
|
// return false if different
|
||||||
|
if (array1.length !== array2.length) return false
|
||||||
|
for (var item of array1) {
|
||||||
|
if (!array2.includes(item)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
objectArrayEqual(array1, array2) {
|
||||||
|
const isIterable = (value) => {
|
||||||
|
return Symbol.iterator in Object(value)
|
||||||
|
}
|
||||||
|
if (!isIterable(array1) || !isIterable(array2)) {
|
||||||
|
console.error(array1, array2)
|
||||||
|
throw new Error('Invalid arrays passed in')
|
||||||
|
}
|
||||||
|
|
||||||
|
// array of objects with id key
|
||||||
|
if (array1.length !== array2.length) return false
|
||||||
|
|
||||||
|
for (var item of array1) {
|
||||||
|
var matchingItem = array2.find((a) => a.id === item.id)
|
||||||
|
if (!matchingItem) return false
|
||||||
|
for (var key in item) {
|
||||||
|
if (item[key] !== matchingItem[key]) {
|
||||||
|
// console.log('Object array item keys changed', key, item[key], matchingItem[key])
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
checkForChanges() {
|
||||||
|
var metadata = {}
|
||||||
|
for (const key in this.details) {
|
||||||
|
var newValue = this.details[key]
|
||||||
|
var oldValue = this.mediaMetadata[key]
|
||||||
|
// Key cleared out or key first populated
|
||||||
|
if ((!newValue && oldValue) || (newValue && !oldValue)) {
|
||||||
|
metadata[key] = newValue
|
||||||
|
} else if (key === 'narrators' || key === 'genres') {
|
||||||
|
// Check array of strings
|
||||||
|
if (!this.stringArrayEqual(newValue, oldValue)) {
|
||||||
|
metadata[key] = [...newValue]
|
||||||
|
}
|
||||||
|
} else if (key === 'authors' || key === 'series') {
|
||||||
|
if (!this.objectArrayEqual(newValue, oldValue)) {
|
||||||
|
metadata[key] = newValue.map((v) => ({ ...v }))
|
||||||
|
}
|
||||||
|
} else if (newValue && newValue != oldValue) {
|
||||||
|
// Intentional !=
|
||||||
|
metadata[key] = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var updatePayload = {}
|
||||||
|
if (!!Object.keys(metadata).length) updatePayload.metadata = metadata
|
||||||
|
|
||||||
|
if (!this.stringArrayEqual(this.newTags, this.media.tags || [])) {
|
||||||
|
updatePayload.tags = [...this.newTags]
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
updatePayload,
|
||||||
|
hasChanges: !!Object.keys(updatePayload).length
|
||||||
|
}
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.details.title = this.mediaMetadata.title
|
||||||
|
this.details.subtitle = this.mediaMetadata.subtitle
|
||||||
|
this.details.description = this.mediaMetadata.description
|
||||||
|
this.details.authors = (this.mediaMetadata.authors || []).map((se) => ({ ...se }))
|
||||||
|
this.details.narrators = [...(this.mediaMetadata.narrators || [])]
|
||||||
|
this.details.genres = [...(this.mediaMetadata.genres || [])]
|
||||||
|
this.details.series = (this.mediaMetadata.series || []).map((se) => ({ ...se }))
|
||||||
|
this.details.publishedYear = this.mediaMetadata.publishedYear
|
||||||
|
this.details.publisher = this.mediaMetadata.publisher || null
|
||||||
|
this.details.language = this.mediaMetadata.language || null
|
||||||
|
this.details.isbn = this.mediaMetadata.isbn || null
|
||||||
|
this.details.asin = this.mediaMetadata.asin || null
|
||||||
|
this.details.explicit = !!this.mediaMetadata.explicit
|
||||||
|
this.newTags = [...(this.media.tags || [])]
|
||||||
|
},
|
||||||
|
submitForm() {
|
||||||
|
this.$emit('submit')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
|
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
|
||||||
<span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize">remove</span>
|
<span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize" aria-label="Decrease Cover Size" role="button">remove</span>
|
||||||
<p class="px-2 font-mono">{{ bookCoverWidth }}</p>
|
<p class="px-2 font-mono">{{ bookCoverWidth }}</p>
|
||||||
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span>
|
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize" aria-label="Increase Cover Size" role="button">add</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user