mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-05 10:12:44 +02:00
Compare commits
198 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d05e9ebfdd | |||
| 92c2c53c09 | |||
| 80f8a784c8 | |||
| de9c0ef034 | |||
| b32b99418a | |||
| 98c1ee01fd | |||
| 97a065030e | |||
| ee1dc92898 | |||
| 74d2987310 | |||
| c20aaf3cb2 | |||
| 994eb2862e | |||
| ff1eeda468 | |||
| 7d9ed75a28 | |||
| 09aed354b3 | |||
| e3425acd75 | |||
| 03e39640be | |||
| 48f0e039e5 | |||
| aa872948d5 | |||
| 88e2bac3f5 | |||
| 18c1d8f1a3 | |||
| b9dee8704f | |||
| 03963aa9a1 | |||
| 315592efe5 | |||
| cd6e99b4c3 | |||
| 9715c53332 | |||
| 120c70622a | |||
| 04f92c33c2 | |||
| 0c168b3da4 | |||
| 2ed0468b33 | |||
| d9c8aa287d | |||
| 1d118d1364 | |||
| c94d9e620c | |||
| ff68440d26 | |||
| 125a8a8e32 | |||
| 2d507a455e | |||
| 32bc9d5282 | |||
| 59d12ef5de | |||
| e80ec10e8a | |||
| f752c19418 | |||
| 75aede914f | |||
| 19dcb6173e | |||
| d9d34e87e0 | |||
| a65f7e6fad | |||
| 577f3bead9 | |||
| 49072769a5 | |||
| a590e795e3 | |||
| b20cbb020a | |||
| 1637b6c7f0 | |||
| 78449d918b | |||
| bd336345ee | |||
| 326663d9d9 | |||
| ef2b9a0415 | |||
| 509d9e5e03 | |||
| 3dd8dc6dd4 | |||
| a4bf3a759f | |||
| 8d9d5a8d1b | |||
| 28cbe0a95c | |||
| 0db34dcab5 | |||
| 4e45ff83c6 | |||
| 47c6c1aaad | |||
| d753683090 | |||
| d6cab8e591 | |||
| de5baba930 | |||
| dc18eb408e | |||
| b26c1ba886 | |||
| 6f891208d0 | |||
| b74b12301c | |||
| 643040e635 | |||
| bb7e580ee3 | |||
| c82d888b5a | |||
| 07207b091d | |||
| 8dc0acb6b7 | |||
| 8389a31775 | |||
| 893032bcc0 | |||
| 26b37def46 | |||
| 29ba62357d | |||
| 8ddfc6e7ac | |||
| ef8ec3d7ab | |||
| 68f534a97e | |||
| 0d556c3f76 | |||
| 8c91ef85c9 | |||
| ac950f2173 | |||
| bf1e6051be | |||
| 0eb00c8820 | |||
| 6067aecc4f | |||
| f0b070ca1b | |||
| 34f0815319 | |||
| 2ed2790550 | |||
| e2a6bf6c1a | |||
| cce860ff74 | |||
| 73ef4cd4b5 | |||
| d95689a078 | |||
| 784ffbc568 | |||
| 77eee0c85a | |||
| 0d33ed98d9 | |||
| 4de3ffac63 | |||
| be6da8dad9 | |||
| ec23250273 | |||
| 8fdbe58da8 | |||
| e1fd74caaf | |||
| 37c38e69df | |||
| 31e109d0f0 | |||
| b23f9362ef | |||
| ae1b94e991 | |||
| 03feaf6e5a | |||
| c821ef424f | |||
| 9e668bbf35 | |||
| 9e8ea43d5b | |||
| c7b01165fd | |||
| a63d76985c | |||
| ea61400329 | |||
| 3ba3069894 | |||
| 6738c51f6e | |||
| ef587a5f58 | |||
| 540f3e4196 | |||
| 043b353333 | |||
| 737f8342f4 | |||
| c884fd6880 | |||
| 07994d38d5 | |||
| 80e2e35dcd | |||
| bb7e79004d | |||
| 62d0a82dde | |||
| 5f69339a27 | |||
| bd0e8518be | |||
| a56b3a8096 | |||
| a7c538193c | |||
| 5dd1542af0 | |||
| 9107620b3c | |||
| c30955f909 | |||
| beaa1e14bb | |||
| 394d312282 | |||
| 299fc95c78 | |||
| 07a2a0aefd | |||
| 2e82370408 | |||
| c8d857edb9 | |||
| 11d7330c5d | |||
| 4f094df0b2 | |||
| 838d188504 | |||
| 6f6a3f71b3 | |||
| a66a84bd2d | |||
| af0365c81f | |||
| 45ee42bddd | |||
| 1aff255170 | |||
| 27956bd67a | |||
| 6a2d7af720 | |||
| 3c854ca32b | |||
| c5bc865e6c | |||
| 7a35342577 | |||
| 1b63d90cd7 | |||
| 7ddb4f0539 | |||
| 28966e191b | |||
| 35808abdf6 | |||
| db0ecfb51b | |||
| 64cc7efa6a | |||
| b4a62dbf4b | |||
| 091aa6ef82 | |||
| c208e876d2 | |||
| 3141e74513 | |||
| 1dc01615dd | |||
| cb40e063da | |||
| 0c1a29adbf | |||
| 6e8270c5ea | |||
| 51357195e2 | |||
| 759be593b6 | |||
| 82241fd2f2 | |||
| d63e582e56 | |||
| c59cc52667 | |||
| db2f2d6660 | |||
| 7ef977b783 | |||
| 40502aab1e | |||
| 33bbf1a49b | |||
| a93f409dcd | |||
| 5ecfaa88c2 | |||
| e7898377ed | |||
| c3fd9045a8 | |||
| f70e1beca1 | |||
| fb0a6f4ec2 | |||
| 696766945c | |||
| 5e9d40b663 | |||
| 1c453a3560 | |||
| 343657fe97 | |||
| e04b5d3152 | |||
| 30ce8fee76 | |||
| f30fa2fb0c | |||
| 7d4e2e3d97 | |||
| 744aacbb4b | |||
| 506f16c431 | |||
| 08c3f84c06 | |||
| 7df0708f38 | |||
| d59aefd8c7 | |||
| 2c5b5dbeae | |||
| 1c88c0a796 | |||
| 7e482352b1 | |||
| 6e8fe32bf5 | |||
| 9a74825bad | |||
| dee7bcb2c2 | |||
| 8fbdc1523d | |||
| 6930e69b55 |
@@ -6,6 +6,7 @@ npm-debug.log
|
|||||||
/config
|
/config
|
||||||
/audiobooks
|
/audiobooks
|
||||||
/audiobooks2
|
/audiobooks2
|
||||||
|
/media/
|
||||||
/metadata
|
/metadata
|
||||||
dev.js
|
dev.js
|
||||||
test/
|
test/
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ node_modules/
|
|||||||
/config/
|
/config/
|
||||||
/audiobooks/
|
/audiobooks/
|
||||||
/audiobooks2/
|
/audiobooks2/
|
||||||
|
/media/
|
||||||
/metadata/
|
/metadata/
|
||||||
test/
|
test/
|
||||||
/client/.nuxt/
|
/client/.nuxt/
|
||||||
|
|||||||
+3
-7
@@ -1,19 +1,15 @@
|
|||||||
### STAGE 0: FFMPEG ###
|
### STAGE 0: Build client ###
|
||||||
FROM jrottenberg/ffmpeg:4.1-alpine AS ffmpeg
|
|
||||||
|
|
||||||
### STAGE 1: Build client ###
|
|
||||||
FROM node:12-alpine AS build
|
FROM node:12-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 2: Build server ###
|
### STAGE 1: Build server ###
|
||||||
FROM node:12-alpine
|
FROM node:12-alpine
|
||||||
|
RUN apk update && apk add --no-cache --update ffmpeg
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV LOG_LEVEL=INFO
|
|
||||||
COPY --from=build /client/dist /client/dist
|
COPY --from=build /client/dist /client/dist
|
||||||
COPY --from=ffmpeg / /
|
|
||||||
COPY index.js index.js
|
COPY index.js index.js
|
||||||
COPY package.json package.json
|
COPY package.json package.json
|
||||||
COPY server server
|
COPY server server
|
||||||
|
|||||||
+18
-3
@@ -1,4 +1,6 @@
|
|||||||
@import url('./transitions.css');
|
@import './fonts.css';
|
||||||
|
@import './transitions.css';
|
||||||
|
@import './draggable.css';
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -51,6 +53,19 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Chrome, Safari, Edge, Opera */
|
||||||
|
.no-spinner::-webkit-outer-spin-button,
|
||||||
|
.no-spinner::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
input[type=number] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.tracksTable {
|
.tracksTable {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -66,10 +81,10 @@
|
|||||||
background-color: #474747;
|
background-color: #474747;
|
||||||
}
|
}
|
||||||
.tracksTable td {
|
.tracksTable td {
|
||||||
padding: 4px;
|
padding: 4px 8px;
|
||||||
}
|
}
|
||||||
.tracksTable th {
|
.tracksTable th {
|
||||||
padding: 4px;
|
padding: 4px 8px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
.flip-list-move {
|
||||||
|
transition: transform 0.5s;
|
||||||
|
}
|
||||||
|
.no-move {
|
||||||
|
transition: transform 0s;
|
||||||
|
}
|
||||||
|
.ghost {
|
||||||
|
opacity: 0.5;
|
||||||
|
background-color: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
.list-group {
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
#librariesTable .item {
|
||||||
|
cursor: n-resize;
|
||||||
|
}
|
||||||
|
.list-group-item:not(.exclude) {
|
||||||
|
cursor: n-resize;
|
||||||
|
}
|
||||||
|
.list-group-item.exclude {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.list-group-item:not(.ghost):not(.exclude):hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.list-group-item:nth-child(even):not(.ghost):not(.exclude) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group-item.exclude:not(.ghost) {
|
||||||
|
background-color: rgba(255, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.list-group-item.exclude:not(.ghost):hover {
|
||||||
|
background-color: rgba(223, 0, 0, 0.25);
|
||||||
|
}
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
/*
|
||||||
|
Calibres stylesheet
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default `
|
||||||
|
@charset "UTF-8";
|
||||||
|
|
||||||
|
/*
|
||||||
|
Calibre styles
|
||||||
|
*/
|
||||||
|
.arabic {
|
||||||
|
display: block;
|
||||||
|
list-style-type: decimal;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
margin-right: 0;
|
||||||
|
margin-top: 1em;
|
||||||
|
text-align: justify
|
||||||
|
}
|
||||||
|
.attribution {
|
||||||
|
display: block;
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: left;
|
||||||
|
margin: 0.3em 0
|
||||||
|
}
|
||||||
|
.big {
|
||||||
|
font-size: 1.375em;
|
||||||
|
line-height: 1.2
|
||||||
|
}
|
||||||
|
.big1 {
|
||||||
|
font-size: 1em
|
||||||
|
}
|
||||||
|
.block {
|
||||||
|
display: block;
|
||||||
|
text-align: justify;
|
||||||
|
margin: 1em 1em 2em
|
||||||
|
}
|
||||||
|
.block1 {
|
||||||
|
display: block;
|
||||||
|
text-align: justify;
|
||||||
|
margin: 1em 4em
|
||||||
|
}
|
||||||
|
.block2 {
|
||||||
|
display: block;
|
||||||
|
text-align: justify;
|
||||||
|
margin: 1em 1em 1em 2em
|
||||||
|
}
|
||||||
|
.bullet {
|
||||||
|
display: block;
|
||||||
|
list-style-type: disc;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
margin-right: 0;
|
||||||
|
margin-top: 1em;
|
||||||
|
text-align: disc
|
||||||
|
}
|
||||||
|
.calibre {
|
||||||
|
background-color: #000007;
|
||||||
|
display: block;
|
||||||
|
font-family: Charis, "Times New Roman", Verdana, Arial;
|
||||||
|
font-size: 1.125em;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 5pt
|
||||||
|
}
|
||||||
|
.calibre1 {
|
||||||
|
display: block
|
||||||
|
}
|
||||||
|
.calibre2 {
|
||||||
|
height: auto;
|
||||||
|
width: auto
|
||||||
|
}
|
||||||
|
.calibre3:not(strong) {
|
||||||
|
display: block;
|
||||||
|
font-family: Charis, "Times New Roman", Verdana, Arial;
|
||||||
|
font-size: 1.125em;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
margin: 0 5pt
|
||||||
|
}
|
||||||
|
.calibre4 {
|
||||||
|
font-weight: bold
|
||||||
|
}
|
||||||
|
.calibre5 {
|
||||||
|
font-style: italic
|
||||||
|
}
|
||||||
|
.calibre6 {
|
||||||
|
background-color: #FFF;
|
||||||
|
display: block;
|
||||||
|
font-family: Charis, "Times New Roman", Verdana, Arial;
|
||||||
|
font-size: 1.125em;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 5pt
|
||||||
|
}
|
||||||
|
.calibre7 {
|
||||||
|
display: list-item
|
||||||
|
}
|
||||||
|
.calibre8 {
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1.2;
|
||||||
|
vertical-align: super
|
||||||
|
}
|
||||||
|
.calibre9 {
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 2px;
|
||||||
|
display: table;
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
text-indent: 0
|
||||||
|
}
|
||||||
|
.calibre10 {
|
||||||
|
display: table-row;
|
||||||
|
vertical-align: middle
|
||||||
|
}
|
||||||
|
.calibre11 {
|
||||||
|
display: table-cell;
|
||||||
|
text-align: right;
|
||||||
|
vertical-align: inherit;
|
||||||
|
padding: 1px
|
||||||
|
}
|
||||||
|
.calibre12 {
|
||||||
|
display: table-cell;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: inherit;
|
||||||
|
padding: 1px
|
||||||
|
}
|
||||||
|
.calibre13 {
|
||||||
|
height: 1em;
|
||||||
|
width: auto
|
||||||
|
}
|
||||||
|
.calibre14 {
|
||||||
|
font-size: 0.88889em;
|
||||||
|
line-height: 1.2;
|
||||||
|
vertical-align: super
|
||||||
|
}
|
||||||
|
.calibre15 {
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1.2;
|
||||||
|
vertical-align: sub
|
||||||
|
}
|
||||||
|
.calibre16 {
|
||||||
|
display: block;
|
||||||
|
list-style-type: decimal;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
margin-right: 0;
|
||||||
|
margin-top: 1em
|
||||||
|
}
|
||||||
|
.calibre17 {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.125em;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0.83em 0
|
||||||
|
}
|
||||||
|
.center {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
margin: 1em 0
|
||||||
|
}
|
||||||
|
.center1 {
|
||||||
|
display: block;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
margin: -2em 0 3em
|
||||||
|
}
|
||||||
|
.center2 {
|
||||||
|
display: block;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
margin: 2em 0 1em
|
||||||
|
}
|
||||||
|
.center3 {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
margin: -1em 0 1em
|
||||||
|
}
|
||||||
|
.center4 {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
text-indent: 3%;
|
||||||
|
margin: 1em 0
|
||||||
|
}
|
||||||
|
.chapter {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.125em;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 2em;
|
||||||
|
text-align: center;
|
||||||
|
margin: 2em 0 1em
|
||||||
|
}
|
||||||
|
.chapter1 {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.88889em;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-left: 0.5em;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
margin-top: 2em
|
||||||
|
}
|
||||||
|
.chapter2 {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.125em;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 2em;
|
||||||
|
text-align: center;
|
||||||
|
margin: 2em 0 3em
|
||||||
|
}
|
||||||
|
.copyright {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.88889em;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-top: 4em;
|
||||||
|
text-align: center
|
||||||
|
}
|
||||||
|
.dedication {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.88889em;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-top: 4em
|
||||||
|
}
|
||||||
|
.dropcaps {
|
||||||
|
float: left;
|
||||||
|
font-size: 3.4375rem;
|
||||||
|
line-height: 50px;
|
||||||
|
margin-right: 0.09em;
|
||||||
|
margin-top: -0.05em;
|
||||||
|
padding-top: 1px
|
||||||
|
}
|
||||||
|
.dropcaps1 {
|
||||||
|
float: left;
|
||||||
|
font-size: 3.4375rem;
|
||||||
|
line-height: 50px;
|
||||||
|
margin-right: 0.09em;
|
||||||
|
margin-top: 0.15em;
|
||||||
|
padding-top: 1px
|
||||||
|
}
|
||||||
|
.extract {
|
||||||
|
display: block;
|
||||||
|
text-align: justify;
|
||||||
|
margin: 2em 0 0.3em
|
||||||
|
}
|
||||||
|
.extract1 {
|
||||||
|
display: block;
|
||||||
|
text-align: justify;
|
||||||
|
text-indent: 3%;
|
||||||
|
margin: 2em 0 0.3em
|
||||||
|
}
|
||||||
|
.extract2 {
|
||||||
|
display: block;
|
||||||
|
text-align: justify;
|
||||||
|
margin: 1em 0 0.3em
|
||||||
|
}
|
||||||
|
.footnote {
|
||||||
|
border-bottom-style: solid;
|
||||||
|
border-bottom-width: 0;
|
||||||
|
border-left-style: solid;
|
||||||
|
border-left-width: 0;
|
||||||
|
border-right-style: solid;
|
||||||
|
border-right-width: 0;
|
||||||
|
border-top-style: solid;
|
||||||
|
border-top-width: 1px;
|
||||||
|
display: block;
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-top: 2 em
|
||||||
|
}
|
||||||
|
.footnote1 {
|
||||||
|
display: block;
|
||||||
|
text-align: justify;
|
||||||
|
margin: 0.3em 0 0.3em 2
|
||||||
|
}
|
||||||
|
.footnote2 {
|
||||||
|
border-bottom-style: solid;
|
||||||
|
border-bottom-width: 0;
|
||||||
|
border-left-style: solid;
|
||||||
|
border-left-width: 0;
|
||||||
|
border-right-style: solid;
|
||||||
|
border-right-width: 0;
|
||||||
|
border-top-style: solid;
|
||||||
|
border-top-width: 1px;
|
||||||
|
display: block;
|
||||||
|
font-size: 0.88889em;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-top: 2 em
|
||||||
|
}
|
||||||
|
.hanging {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.88889em;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: left;
|
||||||
|
text-indent: -1em;
|
||||||
|
margin: 0.5em 0 0.3em 1em
|
||||||
|
}
|
||||||
|
.hanging1 {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.88889em;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: left;
|
||||||
|
text-indent: -1em;
|
||||||
|
margin: 0.5em 0 0.3em 1.5em
|
||||||
|
}
|
||||||
|
.hanging2 {
|
||||||
|
display: block;
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-indent: -1em;
|
||||||
|
margin: 0.5em 0 0.3em 1em
|
||||||
|
}
|
||||||
|
.hanging3 {
|
||||||
|
display: block;
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: left;
|
||||||
|
text-indent: 1em;
|
||||||
|
margin: 0.1em 0 0.3em 1em
|
||||||
|
}
|
||||||
|
.hanging4 {
|
||||||
|
display: block;
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: left;
|
||||||
|
text-indent: 0.1em;
|
||||||
|
margin: 0.1em 0 0.3em 1em
|
||||||
|
}
|
||||||
|
a.hlink {
|
||||||
|
text-decoration: none
|
||||||
|
}
|
||||||
|
.indent {
|
||||||
|
display: block;
|
||||||
|
text-align: justify;
|
||||||
|
text-indent: 1em;
|
||||||
|
margin: 0.3em 0
|
||||||
|
}
|
||||||
|
.line {
|
||||||
|
border-top: currentColor solid 1px;
|
||||||
|
border-bottom: currentColor solid 1px
|
||||||
|
}
|
||||||
|
.loweralpha {
|
||||||
|
display: block;
|
||||||
|
list-style-type: lower-alpha;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
margin-right: 0;
|
||||||
|
margin-top: 1em;
|
||||||
|
text-align: justify
|
||||||
|
}
|
||||||
|
.none {
|
||||||
|
display: block;
|
||||||
|
list-style-type: none;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
margin-right: 0;
|
||||||
|
margin-top: 1em;
|
||||||
|
text-align: justify
|
||||||
|
}
|
||||||
|
.none1 {
|
||||||
|
display: block;
|
||||||
|
list-style-type: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
text-align: justify
|
||||||
|
}
|
||||||
|
.nonindent {
|
||||||
|
display: block;
|
||||||
|
text-align: justify;
|
||||||
|
margin: 0.3em 0
|
||||||
|
}
|
||||||
|
.nonindent1 {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.125em;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-indent: -1em;
|
||||||
|
margin: 0.5em 0 0.3em 0.1em
|
||||||
|
}
|
||||||
|
.nonindent2 {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.125em;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-indent: -1em;
|
||||||
|
margin: 0.5em 0 0.3em -0.5em
|
||||||
|
}
|
||||||
|
.nonindent3 {
|
||||||
|
display: block;
|
||||||
|
text-align: justify;
|
||||||
|
text-indent: 3%;
|
||||||
|
margin: 0.3em 0
|
||||||
|
}
|
||||||
|
.part {
|
||||||
|
display: block;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 2em;
|
||||||
|
text-align: center;
|
||||||
|
margin: 4em 0 1em
|
||||||
|
}
|
||||||
|
.preface {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.88889em;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-left: 2em;
|
||||||
|
margin-right: 2em;
|
||||||
|
text-align: justify
|
||||||
|
}
|
||||||
|
.pubhlink {
|
||||||
|
color: green;
|
||||||
|
text-decoration: none
|
||||||
|
}
|
||||||
|
.right {
|
||||||
|
display: block;
|
||||||
|
text-align: right;
|
||||||
|
margin: 0.3em 0
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.125em;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
margin: 2em 0 0.5em
|
||||||
|
}
|
||||||
|
.section1 {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.125em;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: left;
|
||||||
|
margin: 2em 0 0.3em
|
||||||
|
}
|
||||||
|
.section2 {
|
||||||
|
display: block;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: left;
|
||||||
|
margin: 2em 0 0.3em 1em
|
||||||
|
}
|
||||||
|
.small {
|
||||||
|
font-size: 0.66667em
|
||||||
|
}
|
||||||
|
.small1 {
|
||||||
|
font-size: 0.75em
|
||||||
|
}
|
||||||
|
.subchapter {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.125em;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 1em 0
|
||||||
|
}
|
||||||
|
.textbox {
|
||||||
|
background-color: #E4E4E4;
|
||||||
|
display: block;
|
||||||
|
line-height: 1.5em;
|
||||||
|
margin-bottom: 2em;
|
||||||
|
margin-top: 2em;
|
||||||
|
text-align: justify;
|
||||||
|
border-top: currentColor double 2px;
|
||||||
|
border-bottom: currentColor double 2px
|
||||||
|
}
|
||||||
|
.textbox1 {
|
||||||
|
display: block;
|
||||||
|
text-align: justify;
|
||||||
|
margin: 0.3em 0.5em 0.3em 0.8em
|
||||||
|
}
|
||||||
|
.textbox2 {
|
||||||
|
display: block;
|
||||||
|
text-align: justify;
|
||||||
|
text-indent: 1em;
|
||||||
|
margin: 0.3em 0.5em
|
||||||
|
}
|
||||||
|
.textbox3 {
|
||||||
|
display: block;
|
||||||
|
text-align: justify;
|
||||||
|
text-indent: 3%;
|
||||||
|
margin: 0.3em 0.5em 0.3em 0.8em
|
||||||
|
}
|
||||||
|
.titlepage {
|
||||||
|
display: block;
|
||||||
|
margin-left: -0.4em;
|
||||||
|
margin-top: 1.2em
|
||||||
|
}
|
||||||
|
.toc {
|
||||||
|
display: block;
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center
|
||||||
|
}
|
||||||
|
.toc1 {
|
||||||
|
display: block;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0.67em 0 3em
|
||||||
|
}
|
||||||
|
.underline {
|
||||||
|
text-decoration: underline
|
||||||
|
}
|
||||||
|
`
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
/*
|
||||||
|
This is borrowed from koodo-reader https://github.com/troyeguo/koodo-reader/tree/master/src
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const isTitle = (
|
||||||
|
line,
|
||||||
|
isContainDI = false,
|
||||||
|
isContainChapter = false,
|
||||||
|
isContainCHAPTER = false
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
line.length < 30 &&
|
||||||
|
line.indexOf("[") === -1 &&
|
||||||
|
line.indexOf("(") === -1 &&
|
||||||
|
(line.startsWith("CHAPTER") ||
|
||||||
|
line.startsWith("Chapter") ||
|
||||||
|
line.startsWith("序章") ||
|
||||||
|
line.startsWith("前言") ||
|
||||||
|
line.startsWith("声明") ||
|
||||||
|
line.startsWith("聲明") ||
|
||||||
|
line.startsWith("写在前面的话") ||
|
||||||
|
line.startsWith("后记") ||
|
||||||
|
line.startsWith("楔子") ||
|
||||||
|
line.startsWith("后序") ||
|
||||||
|
line.startsWith("寫在前面的話") ||
|
||||||
|
line.startsWith("後記") ||
|
||||||
|
line.startsWith("後序") ||
|
||||||
|
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
|
||||||
|
line
|
||||||
|
) ||
|
||||||
|
(line.startsWith("第") && startWithDI(line)) ||
|
||||||
|
(line.startsWith("卷") && startWithJUAN(line)) ||
|
||||||
|
startWithRomanNum(line) ||
|
||||||
|
(!isContainDI &&
|
||||||
|
!isContainChapter &&
|
||||||
|
!isContainCHAPTER &&
|
||||||
|
line.indexOf("第") > -1 &&
|
||||||
|
(line[line.indexOf("第") - 1] === " " ||
|
||||||
|
line[line.indexOf("第") - 1] === " " ||
|
||||||
|
line[line.indexOf("第") - 1] === "、" ||
|
||||||
|
line[line.indexOf("第") - 1] === ":" ||
|
||||||
|
line[line.indexOf("第") - 1] === ":") &&
|
||||||
|
startWithDI(line.substr(line.indexOf("第")))) ||
|
||||||
|
(!isContainDI &&
|
||||||
|
!isContainChapter &&
|
||||||
|
!isContainCHAPTER &&
|
||||||
|
line.indexOf(" ") &&
|
||||||
|
startWithNumAndSpace(line)) ||
|
||||||
|
(!isContainDI &&
|
||||||
|
!isContainChapter &&
|
||||||
|
!isContainCHAPTER &&
|
||||||
|
line.indexOf(" ") &&
|
||||||
|
startWithNumAndSpace(line)) ||
|
||||||
|
(!isContainDI &&
|
||||||
|
!isContainChapter &&
|
||||||
|
!isContainCHAPTER &&
|
||||||
|
line.indexOf("、") &&
|
||||||
|
startWithNumAndPause(line)) ||
|
||||||
|
(!isContainDI &&
|
||||||
|
!isContainChapter &&
|
||||||
|
!isContainCHAPTER &&
|
||||||
|
line.indexOf(":") &&
|
||||||
|
startWithNumAndColon(line)) ||
|
||||||
|
(!isContainDI &&
|
||||||
|
!isContainChapter &&
|
||||||
|
!isContainCHAPTER &&
|
||||||
|
line.indexOf(":") &&
|
||||||
|
startWithNumAndColon(line)))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const startWithDI = (line) => {
|
||||||
|
let keywords = [
|
||||||
|
"章",
|
||||||
|
"节",
|
||||||
|
"回",
|
||||||
|
"節",
|
||||||
|
"卷",
|
||||||
|
"部",
|
||||||
|
"輯",
|
||||||
|
"辑",
|
||||||
|
"話",
|
||||||
|
"集",
|
||||||
|
"话",
|
||||||
|
"篇",
|
||||||
|
];
|
||||||
|
let flag = false;
|
||||||
|
for (let i = 0; i < keywords.length; i++) {
|
||||||
|
if (
|
||||||
|
(line.indexOf(keywords[i]) > -1 &&
|
||||||
|
(line[line.indexOf(keywords[i]) + 1] === " " ||
|
||||||
|
line[line.indexOf(keywords[i]) + 1] === " " ||
|
||||||
|
line[line.indexOf(keywords[i]) + 1] === "、" ||
|
||||||
|
line[line.indexOf(keywords[i]) + 1] === ":" ||
|
||||||
|
line[line.indexOf(keywords[i]) + 1] === ":")) ||
|
||||||
|
!line[line.indexOf(keywords[i]) + 1]
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
|
||||||
|
line.substring(1, line.indexOf(keywords[i])).trim()
|
||||||
|
) ||
|
||||||
|
/^\d+$/.test(line.substring(1, line.indexOf(keywords[i])).trim())
|
||||||
|
) {
|
||||||
|
flag = true;
|
||||||
|
}
|
||||||
|
if (flag) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flag;
|
||||||
|
};
|
||||||
|
const startWithJUAN = (line) => {
|
||||||
|
if (
|
||||||
|
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
|
||||||
|
line.substring(1, line.indexOf(" "))
|
||||||
|
) ||
|
||||||
|
/^\d+$/.test(line.substring(1, line.indexOf(" ")))
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
if (
|
||||||
|
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
|
||||||
|
line.substring(1, line.indexOf(" "))
|
||||||
|
) ||
|
||||||
|
/^\d+$/.test(line.substring(1, line.indexOf(" ")))
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
if (
|
||||||
|
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
|
||||||
|
line.substring(1)
|
||||||
|
) ||
|
||||||
|
/^\d+$/.test(line.substring(1))
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const startWithRomanNum = (line) => {
|
||||||
|
if (
|
||||||
|
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
|
||||||
|
line.substring(0, line.indexOf(" "))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
if (
|
||||||
|
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
|
||||||
|
line.substring(0, line.indexOf("."))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
if (
|
||||||
|
/(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test(
|
||||||
|
line.trim()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const startWithNumAndSpace = (line) => {
|
||||||
|
if (
|
||||||
|
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
|
||||||
|
line.substring(0, line.indexOf(" "))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
if (
|
||||||
|
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
|
||||||
|
line.substring(0, line.indexOf(" "))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (/^\d+$/.test(line.substring(0, line.indexOf(" ")))) return true;
|
||||||
|
if (/^\d+$/.test(line.substring(0, line.indexOf(" ")))) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const startWithNumAndColon = (line) => {
|
||||||
|
if (
|
||||||
|
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
|
||||||
|
line.substring(0, line.indexOf(":"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
if (
|
||||||
|
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
|
||||||
|
line.substring(0, line.indexOf(":"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (/^\d+$/.test(line.substring(0, line.indexOf(":")))) return true;
|
||||||
|
if (/^\d+$/.test(line.substring(0, line.indexOf(":")))) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const startWithNumAndPause = (line) => {
|
||||||
|
if (
|
||||||
|
/^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test(
|
||||||
|
line.substring(0, line.indexOf("、"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (/^\d+$/.test(line.substring(0, line.indexOf("、")))) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
class HtmlParser {
|
||||||
|
bookDoc;
|
||||||
|
contentList;
|
||||||
|
contentTitleList;
|
||||||
|
constructor(bookDoc) {
|
||||||
|
this.bookDoc = bookDoc;
|
||||||
|
this.contentList = [];
|
||||||
|
this.contentTitleList = [];
|
||||||
|
this.getContent(bookDoc);
|
||||||
|
}
|
||||||
|
getContent(bookDoc) {
|
||||||
|
this.contentList = Array.from(
|
||||||
|
bookDoc.querySelectorAll("h1,h2,h3,h4,h5,b,font")
|
||||||
|
).filter((item, index) => {
|
||||||
|
return isTitle(item.innerText.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < this.contentList.length; i++) {
|
||||||
|
let random = Math.floor(Math.random() * 900000) + 100000;
|
||||||
|
this.contentTitleList.push({
|
||||||
|
label: this.contentList[i].innerText,
|
||||||
|
id: "title" + random,
|
||||||
|
href: "#title" + random,
|
||||||
|
subitems: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < this.contentList.length; i++) {
|
||||||
|
this.contentList[i].id = this.contentTitleList[i].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getAnchoredDoc() {
|
||||||
|
return this.bookDoc;
|
||||||
|
}
|
||||||
|
getContentList() {
|
||||||
|
return this.contentTitleList.filter((item, index) => {
|
||||||
|
if (index > 0) {
|
||||||
|
return item.label !== this.contentTitleList[index - 1].label;
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HtmlParser;
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
/*
|
||||||
|
This is borrowed from koodo-reader https://github.com/troyeguo/koodo-reader/tree/master/src
|
||||||
|
*/
|
||||||
|
|
||||||
|
function ab2str(buf) {
|
||||||
|
if (buf instanceof ArrayBuffer) {
|
||||||
|
buf = new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
return new TextDecoder("utf-8").decode(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
var domParser = new DOMParser();
|
||||||
|
|
||||||
|
class Buffer {
|
||||||
|
capacity;
|
||||||
|
fragment_list;
|
||||||
|
imageArray;
|
||||||
|
cur_fragment;
|
||||||
|
constructor(capacity) {
|
||||||
|
this.capacity = capacity;
|
||||||
|
this.fragment_list = [];
|
||||||
|
this.imageArray = [];
|
||||||
|
this.cur_fragment = new Fragment(capacity);
|
||||||
|
this.fragment_list.push(this.cur_fragment);
|
||||||
|
}
|
||||||
|
write(byte) {
|
||||||
|
var result = this.cur_fragment.write(byte);
|
||||||
|
if (!result) {
|
||||||
|
this.cur_fragment = new Fragment(this.capacity);
|
||||||
|
this.fragment_list.push(this.cur_fragment);
|
||||||
|
this.cur_fragment.write(byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
get(idx) {
|
||||||
|
var fi = 0;
|
||||||
|
while (fi < this.fragment_list.length) {
|
||||||
|
var frag = this.fragment_list[fi];
|
||||||
|
if (idx < frag.size) {
|
||||||
|
return frag.get(idx);
|
||||||
|
}
|
||||||
|
idx -= frag.size;
|
||||||
|
fi += 1;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
size() {
|
||||||
|
var s = 0;
|
||||||
|
for (var i = 0; i < this.fragment_list.length; i++) {
|
||||||
|
s += this.fragment_list[i].size;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
shrink() {
|
||||||
|
var total_buffer = new Uint8Array(this.size());
|
||||||
|
var offset = 0;
|
||||||
|
for (var i = 0; i < this.fragment_list.length; i++) {
|
||||||
|
var frag = this.fragment_list[i];
|
||||||
|
if (frag.full()) {
|
||||||
|
total_buffer.set(frag.buffer, offset);
|
||||||
|
} else {
|
||||||
|
total_buffer.set(frag.buffer.slice(0, frag.size), offset);
|
||||||
|
}
|
||||||
|
offset += frag.size;
|
||||||
|
}
|
||||||
|
return total_buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var copagesne_uint8array = function (buffers) {
|
||||||
|
var total_size = 0;
|
||||||
|
for (let i = 0; i < buffers.length; i++) {
|
||||||
|
var buffer = buffers[i];
|
||||||
|
total_size += buffer.length;
|
||||||
|
}
|
||||||
|
var total_buffer = new Uint8Array(total_size);
|
||||||
|
var offset = 0;
|
||||||
|
for (let i = 0; i < buffers.length; i++) {
|
||||||
|
buffer = buffers[i];
|
||||||
|
total_buffer.set(buffer, offset);
|
||||||
|
offset += buffer.length;
|
||||||
|
}
|
||||||
|
return total_buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
class Fragment {
|
||||||
|
buffer;
|
||||||
|
capacity;
|
||||||
|
size;
|
||||||
|
constructor(capacity) {
|
||||||
|
this.buffer = new Uint8Array(capacity);
|
||||||
|
this.capacity = capacity;
|
||||||
|
this.size = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
write(byte) {
|
||||||
|
if (this.size >= this.capacity) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.buffer[this.size] = byte;
|
||||||
|
this.size += 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
full() {
|
||||||
|
return this.size === this.capacity;
|
||||||
|
}
|
||||||
|
get(idx) {
|
||||||
|
return this.buffer[idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var uncompression_lz77 = function (data) {
|
||||||
|
var length = data.length;
|
||||||
|
var offset = 0; // Current offset into data
|
||||||
|
var buffer = new Buffer(data.length);
|
||||||
|
|
||||||
|
while (offset < length) {
|
||||||
|
var char = data[offset];
|
||||||
|
offset += 1;
|
||||||
|
|
||||||
|
if (char === 0) {
|
||||||
|
buffer.write(char);
|
||||||
|
} else if (char <= 8) {
|
||||||
|
for (var i = offset; i < offset + char; i++) {
|
||||||
|
buffer.write(data[i]);
|
||||||
|
}
|
||||||
|
offset += char;
|
||||||
|
} else if (char <= 0x7f) {
|
||||||
|
buffer.write(char);
|
||||||
|
} else if (char <= 0xbf) {
|
||||||
|
var next = data[offset];
|
||||||
|
offset += 1;
|
||||||
|
var distance = (((char << 8) | next) >> 3) & 0x7ff;
|
||||||
|
var lz_length = (next & 0x7) + 3;
|
||||||
|
|
||||||
|
var buffer_size = buffer.size();
|
||||||
|
for (let i = 0; i < lz_length; i++) {
|
||||||
|
buffer.write(buffer.get(buffer_size - distance));
|
||||||
|
buffer_size += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
buffer.write(32);
|
||||||
|
buffer.write(char ^ 0x80);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
class MobiFile {
|
||||||
|
view;
|
||||||
|
buffer;
|
||||||
|
offset;
|
||||||
|
header;
|
||||||
|
palm_header;
|
||||||
|
mobi_header;
|
||||||
|
reclist;
|
||||||
|
constructor(data) {
|
||||||
|
this.view = new DataView(data);
|
||||||
|
this.buffer = this.view.buffer;
|
||||||
|
this.offset = 0;
|
||||||
|
this.header = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
parse() { }
|
||||||
|
|
||||||
|
getUint8() {
|
||||||
|
var v = this.view.getUint8(this.offset);
|
||||||
|
this.offset += 1;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
getUint16() {
|
||||||
|
var v = this.view.getUint16(this.offset);
|
||||||
|
this.offset += 2;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
getUint32() {
|
||||||
|
var v = this.view.getUint32(this.offset);
|
||||||
|
this.offset += 4;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStr(size) {
|
||||||
|
var v = ab2str(this.buffer.slice(this.offset, this.offset + size));
|
||||||
|
this.offset += size;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
skip(size) {
|
||||||
|
this.offset += size;
|
||||||
|
}
|
||||||
|
|
||||||
|
setoffset(_of) {
|
||||||
|
this.offset = _of;
|
||||||
|
}
|
||||||
|
|
||||||
|
get_record_extrasize(data, flags) {
|
||||||
|
var pos = data.length - 1;
|
||||||
|
var extra = 0;
|
||||||
|
for (var i = 15; i > 0; i--) {
|
||||||
|
if (flags & (1 << i)) {
|
||||||
|
var res = this.buffer_get_varlen(data, pos);
|
||||||
|
var size = res[0];
|
||||||
|
var l = res[1];
|
||||||
|
pos = res[2];
|
||||||
|
pos -= size - l;
|
||||||
|
extra += size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (flags & 1) {
|
||||||
|
var a = data[pos];
|
||||||
|
extra += (a & 0x3) + 1;
|
||||||
|
}
|
||||||
|
return extra;
|
||||||
|
}
|
||||||
|
|
||||||
|
// data should be uint8array
|
||||||
|
buffer_get_varlen(data, pos) {
|
||||||
|
var l = 0;
|
||||||
|
var size = 0;
|
||||||
|
var byte_count = 0;
|
||||||
|
var mask = 0x7f;
|
||||||
|
var stop_flag = 0x80;
|
||||||
|
var shift = 0;
|
||||||
|
for (var i = 0; ; i++) {
|
||||||
|
var byte = data[pos];
|
||||||
|
size |= (byte & mask) << shift;
|
||||||
|
shift += 7;
|
||||||
|
l += 1;
|
||||||
|
byte_count += 1;
|
||||||
|
pos -= 1;
|
||||||
|
|
||||||
|
var to_stop = byte & stop_flag;
|
||||||
|
if (byte_count >= 4 || to_stop > 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [size, l, pos];
|
||||||
|
}
|
||||||
|
// 读出文本内容
|
||||||
|
read_text() {
|
||||||
|
var text_end = this.palm_header.record_count;
|
||||||
|
var buffers = [];
|
||||||
|
for (var i = 1; i <= text_end; i++) {
|
||||||
|
buffers.push(this.read_text_record(i));
|
||||||
|
}
|
||||||
|
var all = copagesne_uint8array(buffers);
|
||||||
|
return ab2str(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
read_text_record(i) {
|
||||||
|
var flags = this.mobi_header.extra_flags;
|
||||||
|
var begin = this.reclist[i].offset;
|
||||||
|
var end = this.reclist[i + 1].offset;
|
||||||
|
|
||||||
|
var data = new Uint8Array(this.buffer.slice(begin, end));
|
||||||
|
var ex = this.get_record_extrasize(data, flags);
|
||||||
|
|
||||||
|
data = new Uint8Array(this.buffer.slice(begin, end - ex));
|
||||||
|
if (this.palm_header.compression === 2) {
|
||||||
|
var buffer = uncompression_lz77(data);
|
||||||
|
return buffer.shrink();
|
||||||
|
} else {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 从buffer中读出image
|
||||||
|
read_image(idx) {
|
||||||
|
var first_image_idx = this.mobi_header.first_image_idx;
|
||||||
|
var begin = this.reclist[first_image_idx + idx].offset;
|
||||||
|
var end = this.reclist[first_image_idx + idx + 1].offset;
|
||||||
|
var data = new Uint8Array(this.buffer.slice(begin, end));
|
||||||
|
return new Blob([data.buffer]);
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
this.header = this.load_pdbheader();
|
||||||
|
this.reclist = this.load_reclist();
|
||||||
|
this.load_record0();
|
||||||
|
}
|
||||||
|
|
||||||
|
load_pdbheader() {
|
||||||
|
var header = {};
|
||||||
|
header.name = this.getStr(32);
|
||||||
|
header.attr = this.getUint16();
|
||||||
|
header.version = this.getUint16();
|
||||||
|
header.ctime = this.getUint32();
|
||||||
|
header.mtime = this.getUint32();
|
||||||
|
header.btime = this.getUint32();
|
||||||
|
header.mod_num = this.getUint32();
|
||||||
|
header.appinfo_offset = this.getUint32();
|
||||||
|
header.sortinfo_offset = this.getUint32();
|
||||||
|
header.type = this.getStr(4);
|
||||||
|
header.creator = this.getStr(4);
|
||||||
|
header.uid = this.getUint32();
|
||||||
|
header.next_rec = this.getUint32();
|
||||||
|
header.record_num = this.getUint16();
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
load_reclist() {
|
||||||
|
var reclist = [];
|
||||||
|
for (var i = 0; i < this.header.record_num; i++) {
|
||||||
|
var record = {};
|
||||||
|
record.offset = this.getUint32();
|
||||||
|
// TODO(zz) change
|
||||||
|
record.attr = this.getUint32();
|
||||||
|
reclist.push(record);
|
||||||
|
}
|
||||||
|
return reclist;
|
||||||
|
}
|
||||||
|
load_record0() {
|
||||||
|
this.palm_header = this.load_record0_header();
|
||||||
|
this.mobi_header = this.load_mobi_header();
|
||||||
|
}
|
||||||
|
|
||||||
|
load_record0_header() {
|
||||||
|
var p_header = {};
|
||||||
|
var first_record = this.reclist[0];
|
||||||
|
this.setoffset(first_record.offset);
|
||||||
|
|
||||||
|
p_header.compression = this.getUint16();
|
||||||
|
this.skip(2);
|
||||||
|
p_header.text_length = this.getUint32();
|
||||||
|
p_header.record_count = this.getUint16();
|
||||||
|
p_header.record_size = this.getUint16();
|
||||||
|
p_header.encryption_type = this.getUint16();
|
||||||
|
this.skip(2);
|
||||||
|
|
||||||
|
return p_header;
|
||||||
|
}
|
||||||
|
|
||||||
|
load_mobi_header() {
|
||||||
|
var mobi_header = {};
|
||||||
|
|
||||||
|
var start_offset = this.offset;
|
||||||
|
|
||||||
|
mobi_header.identifier = this.getUint32();
|
||||||
|
mobi_header.header_length = this.getUint32();
|
||||||
|
mobi_header.mobi_type = this.getUint32();
|
||||||
|
mobi_header.text_encoding = this.getUint32();
|
||||||
|
mobi_header.uid = this.getUint32();
|
||||||
|
mobi_header.generator_version = this.getUint32();
|
||||||
|
|
||||||
|
this.skip(40);
|
||||||
|
|
||||||
|
mobi_header.first_nonbook_index = this.getUint32();
|
||||||
|
mobi_header.full_name_offset = this.getUint32();
|
||||||
|
mobi_header.full_name_length = this.getUint32();
|
||||||
|
|
||||||
|
mobi_header.language = this.getUint32();
|
||||||
|
mobi_header.input_language = this.getUint32();
|
||||||
|
mobi_header.output_language = this.getUint32();
|
||||||
|
mobi_header.min_version = this.getUint32();
|
||||||
|
mobi_header.first_image_idx = this.getUint32();
|
||||||
|
|
||||||
|
mobi_header.huff_rec_index = this.getUint32();
|
||||||
|
mobi_header.huff_rec_count = this.getUint32();
|
||||||
|
mobi_header.datp_rec_index = this.getUint32();
|
||||||
|
mobi_header.datp_rec_count = this.getUint32();
|
||||||
|
|
||||||
|
mobi_header.exth_flags = this.getUint32();
|
||||||
|
|
||||||
|
this.skip(36);
|
||||||
|
|
||||||
|
mobi_header.drm_offset = this.getUint32();
|
||||||
|
mobi_header.drm_count = this.getUint32();
|
||||||
|
mobi_header.drm_size = this.getUint32();
|
||||||
|
mobi_header.drm_flags = this.getUint32();
|
||||||
|
|
||||||
|
this.skip(8);
|
||||||
|
|
||||||
|
// TODO (zz) fdst_index
|
||||||
|
this.skip(4);
|
||||||
|
|
||||||
|
this.skip(46);
|
||||||
|
|
||||||
|
mobi_header.extra_flags = this.getUint16();
|
||||||
|
|
||||||
|
this.setoffset(start_offset + mobi_header.header_length);
|
||||||
|
|
||||||
|
return mobi_header;
|
||||||
|
}
|
||||||
|
load_exth_header() {
|
||||||
|
// TODO
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
extractContent(s) {
|
||||||
|
var span = document.createElement("span");
|
||||||
|
span.innerHTML = s;
|
||||||
|
return span.textContent || span.innerText;
|
||||||
|
}
|
||||||
|
render(isElectron = false) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.load();
|
||||||
|
var content = this.read_text();
|
||||||
|
var bookDoc = domParser.parseFromString(content, "text/html")
|
||||||
|
.documentElement;
|
||||||
|
let lines = Array.from(
|
||||||
|
bookDoc.querySelectorAll("p,b,font,h3,h2,h1")
|
||||||
|
);
|
||||||
|
let parseContent = [];
|
||||||
|
for (let i = 0, len = lines.length; i < len - 1; i++) {
|
||||||
|
lines[i].innerText &&
|
||||||
|
lines[i].innerText !== parseContent[parseContent.length - 1] &&
|
||||||
|
parseContent.push(lines[i].innerText);
|
||||||
|
let imgDoms = lines[i].getElementsByTagName("img");
|
||||||
|
if (imgDoms.length > 0) {
|
||||||
|
for (let i = 0; i < imgDoms.length; i++) {
|
||||||
|
parseContent.push("#image");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleImage = async () => {
|
||||||
|
var imgDoms = bookDoc.getElementsByTagName("img");
|
||||||
|
parseContent.push("~image");
|
||||||
|
for (let i = 0; i < imgDoms.length; i++) {
|
||||||
|
const src = await this.render_image(imgDoms, i);
|
||||||
|
parseContent.push(
|
||||||
|
src + " " + imgDoms[i].width + " " + imgDoms[i].height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (imgDoms.length > 200 || !isElectron) {
|
||||||
|
resolve(bookDoc);
|
||||||
|
} else {
|
||||||
|
resolve(parseContent.join("\n \n"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handleImage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
render_image = (imgDoms, i) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
var imgDom = imgDoms[i];
|
||||||
|
var idx = +imgDom.getAttribute("recindex");
|
||||||
|
var blob = this.read_image(idx - 1);
|
||||||
|
var imgReader = new FileReader();
|
||||||
|
imgReader.onload = (e) => {
|
||||||
|
imgDom.src = e.target?.result;
|
||||||
|
resolve(e.target?.result);
|
||||||
|
};
|
||||||
|
imgReader.onerror = function (err) {
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
imgReader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobiFile;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/* fallback */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Material Icons';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url(/fonts/material-icons.woff2) format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons {
|
||||||
|
font-family: 'Material Icons';
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-transform: none;
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
word-wrap: normal;
|
||||||
|
direction: ltr;
|
||||||
|
-webkit-font-feature-settings: 'liga';
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
.material-icons:not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl) {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Gentium Book Basic';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Gentium Book Basic';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute right-32 top-0 bottom-0">
|
<div class="absolute right-32 top-0 bottom-0">
|
||||||
<controls-volume-control v-model="volume" @input="updateVolume" />
|
<controls-volume-control ref="volumeControl" v-model="volume" @input="updateVolume" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex my-2">
|
<div class="flex my-2">
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
@@ -91,7 +91,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
hlsInstance: null,
|
hlsInstance: null,
|
||||||
staleHlsInstance: null,
|
staleHlsInstance: null,
|
||||||
volume: 0.5,
|
volume: 1,
|
||||||
playbackRate: 1,
|
playbackRate: 1,
|
||||||
trackWidth: 0,
|
trackWidth: 0,
|
||||||
isPaused: true,
|
isPaused: true,
|
||||||
@@ -440,7 +440,7 @@ export default {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
|
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
|
||||||
console.error('[HLS] Error', data.type, data.details)
|
console.error('[HLS] Error', data.type, data.details, data)
|
||||||
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
|
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
|
||||||
console.error('[HLS] BUFFER STALLED ERROR')
|
console.error('[HLS] BUFFER STALLED ERROR')
|
||||||
}
|
}
|
||||||
@@ -451,7 +451,8 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
showChapters() {
|
showChapters() {
|
||||||
this.showChaptersModal = true
|
if (!this.chapters.length) return
|
||||||
|
this.showChaptersModal = !this.showChaptersModal
|
||||||
},
|
},
|
||||||
play() {
|
play() {
|
||||||
if (!this.$refs.audio) {
|
if (!this.$refs.audio) {
|
||||||
@@ -486,6 +487,9 @@ export default {
|
|||||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||||
|
|
||||||
this.audioEl = this.$refs.audio
|
this.audioEl = this.$refs.audio
|
||||||
|
this.setTrackWidth()
|
||||||
|
},
|
||||||
|
setTrackWidth() {
|
||||||
if (this.$refs.track) {
|
if (this.$refs.track) {
|
||||||
this.trackWidth = this.$refs.track.clientWidth
|
this.trackWidth = this.$refs.track.clientWidth
|
||||||
} else {
|
} else {
|
||||||
@@ -496,14 +500,66 @@ export default {
|
|||||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
||||||
this.updatePlaybackRate(settings.playbackRate)
|
this.updatePlaybackRate(settings.playbackRate)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
volumeUp() {
|
||||||
|
if (this.volume >= 1) return
|
||||||
|
this.volume = Math.min(1, this.volume + 0.1)
|
||||||
|
this.updateVolume(this.volume)
|
||||||
|
},
|
||||||
|
volumeDown() {
|
||||||
|
if (this.volume <= 0) return
|
||||||
|
this.volume = Math.max(0, this.volume - 0.1)
|
||||||
|
this.updateVolume(this.volume)
|
||||||
|
},
|
||||||
|
toggleMute() {
|
||||||
|
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
|
||||||
|
this.$refs.volumeControl.toggleMute()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
increasePlaybackRate() {
|
||||||
|
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||||
|
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||||
|
if (currentRateIndex >= rates.length - 1) return
|
||||||
|
this.playbackRate = rates[currentRateIndex + 1] || 1
|
||||||
|
this.playbackRateChanged(this.playbackRate)
|
||||||
|
},
|
||||||
|
decreasePlaybackRate() {
|
||||||
|
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||||
|
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||||
|
if (currentRateIndex <= 0) return
|
||||||
|
this.playbackRate = rates[currentRateIndex - 1] || 1
|
||||||
|
this.playbackRateChanged(this.playbackRate)
|
||||||
|
},
|
||||||
|
closePlayer() {
|
||||||
|
if (this.loading) return
|
||||||
|
this.$emit('close')
|
||||||
|
},
|
||||||
|
hotkey(action) {
|
||||||
|
if (action === 'Space') this.playPauseClick()
|
||||||
|
else if (action === 'ArrowRight') this.forward10()
|
||||||
|
else if (action === 'ArrowLeft') this.backward10()
|
||||||
|
else if (action === 'ArrowUp') this.volumeUp()
|
||||||
|
else if (action === 'ArrowDown') this.volumeDown()
|
||||||
|
else if (action === 'KeyM') this.toggleMute()
|
||||||
|
else if (action === 'KeyL') this.showChapters()
|
||||||
|
else if (action === 'Shift-ArrowUp') this.increasePlaybackRate()
|
||||||
|
else if (action === 'Shift-ArrowDown') this.decreasePlaybackRate()
|
||||||
|
else if (action === 'Escape') this.closePlayer()
|
||||||
|
},
|
||||||
|
windowResize() {
|
||||||
|
this.setTrackWidth()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
window.addEventListener('resize', this.windowResize)
|
||||||
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
|
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
|
||||||
this.init()
|
this.init()
|
||||||
|
this.$eventBus.$on('player-hotkey', this.hotkey)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
|
window.removeEventListener('resize', this.windowResize)
|
||||||
this.$store.commit('user/removeSettingsListener', 'audioplayer')
|
this.$store.commit('user/removeSettingsListener', 'audioplayer')
|
||||||
|
this.$eventBus.$off('player-hotkey', this.hotkey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,16 +7,14 @@
|
|||||||
<span class="material-icons text-4xl text-white">arrow_back</span>
|
<span class="material-icons text-4xl text-white">arrow_back</span>
|
||||||
</a>
|
</a>
|
||||||
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1>
|
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1>
|
||||||
<!-- <div class="-mb-2">
|
|
||||||
<h1 class="text-lg font-book leading-3 mr-6 px-1">AudioBookshelf</h1>
|
<ui-libraries-dropdown />
|
||||||
<div class="bg-black bg-opacity-20 rounded-sm py-1.5 px-2 mt-1.5 flex items-center justify-between border border-bg">
|
|
||||||
<p class="text-sm text-gray-400 leading-3">My Library</p>
|
|
||||||
<span class="material-icons text-sm leading-3 text-gray-400">expand_more</span>
|
|
||||||
</div>
|
|
||||||
</div> -->
|
|
||||||
<controls-global-search />
|
<controls-global-search />
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
|
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-4">logo_dev</span>
|
||||||
|
|
||||||
<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">
|
<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">
|
||||||
<span class="material-icons">upload</span>
|
<span class="material-icons">upload</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -64,11 +62,17 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
currentLibrary() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibrary']
|
||||||
|
},
|
||||||
|
libraryName() {
|
||||||
|
return this.currentLibrary ? this.currentLibrary.name : 'unknown'
|
||||||
|
},
|
||||||
isHome() {
|
isHome() {
|
||||||
return this.$route.name === 'index'
|
return this.$route.name === 'library-library'
|
||||||
},
|
},
|
||||||
showBack() {
|
showBack() {
|
||||||
return this.$route.name !== 'library-id' && !this.isHome
|
return this.$route.name !== 'library-library-bookshelf-id' && !this.isHome
|
||||||
},
|
},
|
||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
@@ -76,7 +80,6 @@ export default {
|
|||||||
isRootUser() {
|
isRootUser() {
|
||||||
return this.$store.getters['user/getIsRoot']
|
return this.$store.getters['user/getIsRoot']
|
||||||
},
|
},
|
||||||
|
|
||||||
username() {
|
username() {
|
||||||
return this.user ? this.user.username : 'err'
|
return this.user ? this.user.username : 'err'
|
||||||
},
|
},
|
||||||
@@ -117,6 +120,9 @@ export default {
|
|||||||
},
|
},
|
||||||
processingBatch() {
|
processingBatch() {
|
||||||
return this.$store.state.processingBatch
|
return this.$store.state.processingBatch
|
||||||
|
},
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
|
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
|
||||||
<!-- Cover size widget -->
|
<!-- Cover size widget -->
|
||||||
<div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-20">
|
<div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-30">
|
||||||
<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">remove</span>
|
||||||
<p class="px-2 font-mono">{{ bookCoverWidth }}</p>
|
<p class="px-2 font-mono">{{ bookCoverWidth }}</p>
|
||||||
@@ -16,6 +16,14 @@
|
|||||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="page === 'search'" id="bookshelf-categorized" class="w-full flex flex-col items-center">
|
||||||
|
<template v-for="(shelf, index) in categorizedShelves">
|
||||||
|
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" />
|
||||||
|
</template>
|
||||||
|
<div v-show="!categorizedShelves.length" class="w-full py-16 text-center text-xl">
|
||||||
|
<div class="py-4 mb-6"><p class="text-2xl">No Results</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-else id="bookshelf" class="w-full flex flex-col items-center">
|
<div v-else id="bookshelf" class="w-full flex flex-col items-center">
|
||||||
<template v-for="(shelf, index) in shelves">
|
<template v-for="(shelf, index) in shelves">
|
||||||
<div :key="index" class="w-full bookshelfRow relative">
|
<div :key="index" class="w-full bookshelfRow relative">
|
||||||
@@ -23,7 +31,7 @@
|
|||||||
<template v-for="entity in shelf">
|
<template v-for="entity in shelf">
|
||||||
<cards-group-card v-if="showGroups" :key="entity.id" :width="bookCoverWidth" :group="entity" @click="clickGroup" />
|
<cards-group-card v-if="showGroups" :key="entity.id" :width="bookCoverWidth" :group="entity" @click="clickGroup" />
|
||||||
<!-- <cards-book-3d :key="entity.id" v-else :width="100" :src="$store.getters['audiobooks/getBookCoverSrc'](entity.book)" /> -->
|
<!-- <cards-book-3d :key="entity.id" v-else :width="100" :src="$store.getters['audiobooks/getBookCoverSrc'](entity.book)" /> -->
|
||||||
<cards-book-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" />
|
<cards-book-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @edit="editBook" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
|
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
|
||||||
@@ -45,15 +53,15 @@ export default {
|
|||||||
page: String,
|
page: String,
|
||||||
selectedSeries: String,
|
selectedSeries: String,
|
||||||
searchResults: {
|
searchResults: {
|
||||||
type: Array,
|
type: Object,
|
||||||
default: () => []
|
default: () => {}
|
||||||
},
|
},
|
||||||
searchQuery: String
|
searchQuery: String
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
shelves: [],
|
shelves: [],
|
||||||
currFilterOrderKey: null,
|
currSearchParams: null,
|
||||||
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
|
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
|
||||||
selectedSizeIndex: 3,
|
selectedSizeIndex: 3,
|
||||||
rowPaddingX: 40,
|
rowPaddingX: 40,
|
||||||
@@ -74,9 +82,14 @@ export default {
|
|||||||
},
|
},
|
||||||
searchResults() {
|
searchResults() {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.$store.commit('audiobooks/setSearchResults', this.searchResults)
|
// this.$store.commit('audiobooks/setSearchResults', this.searchResults)
|
||||||
this.setBookshelfEntities()
|
this.setBookshelfEntities()
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
'$route.query.filter'() {
|
||||||
|
if (this.$route.query.filter && this.$route.query.filter !== this.filterBy) {
|
||||||
|
this.$store.dispatch('user/updateUserSettings', { filterBy: this.$route.query.filter })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -89,8 +102,8 @@ export default {
|
|||||||
audiobooks() {
|
audiobooks() {
|
||||||
return this.$store.state.audiobooks.audiobooks
|
return this.$store.state.audiobooks.audiobooks
|
||||||
},
|
},
|
||||||
filterOrderKey() {
|
sizeMultiplier() {
|
||||||
return this.$store.getters['user/getFilterOrderKey']
|
return this.bookCoverWidth / 120
|
||||||
},
|
},
|
||||||
bookCoverWidth() {
|
bookCoverWidth() {
|
||||||
return this.availableSizes[this.selectedSizeIndex]
|
return this.availableSizes[this.selectedSizeIndex]
|
||||||
@@ -102,7 +115,8 @@ export default {
|
|||||||
return 16 * this.sizeMultiplier
|
return 16 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
bookWidth() {
|
bookWidth() {
|
||||||
return this.bookCoverWidth + this.paddingX * 2
|
var _width = this.bookCoverWidth + this.paddingX * 2
|
||||||
|
return this.showGroups ? _width * 1.6 : _width
|
||||||
},
|
},
|
||||||
isSelectionMode() {
|
isSelectionMode() {
|
||||||
return this.$store.getters['getNumAudiobooksSelected']
|
return this.$store.getters['getNumAudiobooksSelected']
|
||||||
@@ -110,14 +124,63 @@ export default {
|
|||||||
filterBy() {
|
filterBy() {
|
||||||
return this.$store.getters['user/getUserSetting']('filterBy')
|
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||||
},
|
},
|
||||||
|
orderBy() {
|
||||||
|
return this.$store.getters['user/getUserSetting']('orderBy')
|
||||||
|
},
|
||||||
|
orderDesc() {
|
||||||
|
return this.$store.getters['user/getUserSetting']('orderDesc')
|
||||||
|
},
|
||||||
showGroups() {
|
showGroups() {
|
||||||
return this.page !== '' && this.page !== 'search' && !this.selectedSeries
|
return this.page !== '' && this.page !== 'search' && !this.selectedSeries
|
||||||
},
|
},
|
||||||
|
categorizedShelves() {
|
||||||
|
if (this.page !== 'search') return []
|
||||||
|
var audiobookSearchResults = this.searchResults ? this.searchResults.audiobooks || [] : []
|
||||||
|
const shelves = []
|
||||||
|
|
||||||
|
if (audiobookSearchResults.length) {
|
||||||
|
shelves.push({
|
||||||
|
label: 'Books',
|
||||||
|
books: audiobookSearchResults.map((absr) => absr.audiobook)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.searchResults.series && this.searchResults.series.length) {
|
||||||
|
var seriesGroups = this.searchResults.series.map((seriesResult) => {
|
||||||
|
return {
|
||||||
|
type: 'series',
|
||||||
|
name: seriesResult.series || '',
|
||||||
|
books: seriesResult.audiobooks || []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
shelves.push({
|
||||||
|
label: 'Series',
|
||||||
|
series: seriesGroups
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.searchResults.tags && this.searchResults.tags.length) {
|
||||||
|
var tagGroups = this.searchResults.tags.map((tagResult) => {
|
||||||
|
return {
|
||||||
|
type: 'tags',
|
||||||
|
name: tagResult.tag || '',
|
||||||
|
books: tagResult.audiobooks || []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
shelves.push({
|
||||||
|
label: 'Tags',
|
||||||
|
series: tagGroups
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return shelves
|
||||||
|
},
|
||||||
entities() {
|
entities() {
|
||||||
if (this.page === '') {
|
if (this.page === '') {
|
||||||
return this.$store.getters['audiobooks/getFilteredAndSorted']()
|
return this.$store.getters['audiobooks/getFilteredAndSorted']()
|
||||||
} else if (this.page === 'search') {
|
} else if (this.page === 'search') {
|
||||||
return this.searchResults || []
|
var audiobookSearchResults = this.searchResults ? this.searchResults.audiobooks || [] : []
|
||||||
|
return audiobookSearchResults.map((absr) => absr.audiobook)
|
||||||
} else {
|
} else {
|
||||||
var seriesGroups = this.$store.getters['audiobooks/getSeriesGroups']()
|
var seriesGroups = this.$store.getters['audiobooks/getSeriesGroups']()
|
||||||
if (this.selectedSeries) {
|
if (this.selectedSeries) {
|
||||||
@@ -129,6 +192,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
editBook(audiobook) {
|
||||||
|
var bookIds = this.entities.map((e) => e.id)
|
||||||
|
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||||
|
this.$store.commit('showEditModal', audiobook)
|
||||||
|
},
|
||||||
clickGroup(group) {
|
clickGroup(group) {
|
||||||
this.$emit('update:selectedSeries', group.name)
|
this.$emit('update:selectedSeries', group.name)
|
||||||
},
|
},
|
||||||
@@ -161,9 +229,13 @@ export default {
|
|||||||
setBookshelfEntities() {
|
setBookshelfEntities() {
|
||||||
this.wrapperClientWidth = this.$refs.wrapper.clientWidth
|
this.wrapperClientWidth = this.$refs.wrapper.clientWidth
|
||||||
var width = Math.max(0, this.wrapperClientWidth - this.rowPaddingX * 2)
|
var width = Math.max(0, this.wrapperClientWidth - this.rowPaddingX * 2)
|
||||||
|
|
||||||
var booksPerRow = Math.floor(width / this.bookWidth)
|
var booksPerRow = Math.floor(width / this.bookWidth)
|
||||||
|
|
||||||
|
this.currSearchParams = this.buildSearchParams()
|
||||||
|
|
||||||
var entities = this.entities
|
var entities = this.entities
|
||||||
|
|
||||||
var groups = []
|
var groups = []
|
||||||
var currentRow = 0
|
var currentRow = 0
|
||||||
var currentGroup = []
|
var currentGroup = []
|
||||||
@@ -183,6 +255,8 @@ export default {
|
|||||||
this.shelves = groups
|
this.shelves = groups
|
||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
|
this.checkUpdateSearchParams()
|
||||||
|
|
||||||
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
||||||
|
|
||||||
var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||||
@@ -201,10 +275,41 @@ export default {
|
|||||||
console.log('[AudioBookshelf] Audiobooks Updated')
|
console.log('[AudioBookshelf] Audiobooks Updated')
|
||||||
this.setBookshelfEntities()
|
this.setBookshelfEntities()
|
||||||
},
|
},
|
||||||
settingsUpdated(settings) {
|
buildSearchParams() {
|
||||||
if (this.currFilterOrderKey !== this.filterOrderKey) {
|
if (this.page === 'search' || this.page === 'series') {
|
||||||
this.setBookshelfEntities()
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let searchParams = new URLSearchParams()
|
||||||
|
if (this.filterBy && this.filterBy !== 'all') {
|
||||||
|
searchParams.set('filter', this.filterBy)
|
||||||
|
}
|
||||||
|
if (this.orderBy) {
|
||||||
|
searchParams.set('order', this.orderBy)
|
||||||
|
searchParams.set('orderdesc', this.orderDesc ? 1 : 0)
|
||||||
|
}
|
||||||
|
return searchParams.toString()
|
||||||
|
},
|
||||||
|
checkUpdateSearchParams() {
|
||||||
|
var newSearchParams = this.buildSearchParams()
|
||||||
|
var currentQueryString = window.location.search
|
||||||
|
|
||||||
|
if (newSearchParams === '') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newSearchParams !== this.currSearchParams || newSearchParams !== currentQueryString) {
|
||||||
|
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
|
||||||
|
window.history.replaceState({ path: newurl }, '', newurl)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
settingsUpdated(settings) {
|
||||||
|
var wasUpdated = this.checkUpdateSearchParams()
|
||||||
|
if (wasUpdated) this.setBookshelfEntities()
|
||||||
|
|
||||||
if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) {
|
if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) {
|
||||||
var index = this.availableSizes.indexOf(settings.bookshelfCoverSize)
|
var index = this.availableSizes.indexOf(settings.bookshelfCoverSize)
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
@@ -214,7 +319,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
scan() {
|
scan() {
|
||||||
this.$root.socket.emit('scan')
|
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updated() {
|
updated() {
|
||||||
|
|||||||
@@ -51,9 +51,6 @@ export default {
|
|||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
return this.bookCoverWidth / 120
|
return this.bookCoverWidth / 120
|
||||||
},
|
},
|
||||||
signSizeMultiplier() {
|
|
||||||
return (1 - this.sizeMultiplier) / 2 + this.sizeMultiplier
|
|
||||||
},
|
|
||||||
paddingX() {
|
paddingX() {
|
||||||
return 16 * this.sizeMultiplier
|
return 16 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
@@ -94,13 +91,17 @@ export default {
|
|||||||
return audiobooks.slice(0, 10)
|
return audiobooks.slice(0, 10)
|
||||||
},
|
},
|
||||||
shelves() {
|
shelves() {
|
||||||
var shelves = [
|
var shelves = []
|
||||||
{ books: this.mostRecentPlayed, label: 'Continue Reading' },
|
if (this.mostRecentPlayed.length) {
|
||||||
{ books: this.mostRecentAdded, label: 'Recently Added' }
|
shelves.push({ books: this.mostRecentPlayed, label: 'Continue Reading' })
|
||||||
]
|
}
|
||||||
|
|
||||||
|
shelves.push({ books: this.mostRecentAdded, label: 'Recently Added' })
|
||||||
|
|
||||||
if (this.recentlyUpdatedSeries) {
|
if (this.recentlyUpdatedSeries) {
|
||||||
shelves.push({ books: this.recentlyUpdatedSeries, label: 'Newest Series' })
|
shelves.push({ books: this.recentlyUpdatedSeries, label: 'Newest Series' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.booksRecentlyRead.length) {
|
if (this.booksRecentlyRead.length) {
|
||||||
shelves.push({ books: this.booksRecentlyRead, label: 'Read Again' })
|
shelves.push({ books: this.booksRecentlyRead, label: 'Read Again' })
|
||||||
}
|
}
|
||||||
@@ -139,7 +140,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
scan() {
|
scan() {
|
||||||
this.$root.socket.emit('scan')
|
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -2,9 +2,21 @@
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div ref="shelf" class="w-full max-w-full bookshelfRowCategorized relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: 2.5 * sizeMultiplier + 'rem' }" @scroll="scrolled">
|
<div ref="shelf" class="w-full max-w-full bookshelfRowCategorized relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: 2.5 * sizeMultiplier + 'rem' }" @scroll="scrolled">
|
||||||
<div class="w-full h-full" :style="{ marginTop: sizeMultiplier + 'rem' }">
|
<div class="w-full h-full" :style="{ marginTop: sizeMultiplier + 'rem' }">
|
||||||
<div class="flex items-center -mb-2">
|
<div v-if="shelf.books" class="flex items-center -mb-2">
|
||||||
<template v-for="entity in shelf.books">
|
<template v-for="entity in shelf.books">
|
||||||
<cards-book-card :key="entity.id" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @hook:updated="updatedBookCard" />
|
<cards-book-card :key="entity.id" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @hook:updated="updatedBookCard" @edit="editBook" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="shelf.series" class="flex items-center -mb-2">
|
||||||
|
<template v-for="entity in shelf.series">
|
||||||
|
<cards-group-card :key="entity.name" :width="bookCoverWidth" :group="entity" @click="$emit('clickSeries', entity)" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="shelf.tags" class="flex items-center -mb-2">
|
||||||
|
<template v-for="entity in shelf.tags">
|
||||||
|
<nuxt-link :key="entity.name" :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(entity.name)}`">
|
||||||
|
<cards-group-card :width="bookCoverWidth" :group="entity" />
|
||||||
|
</nuxt-link>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,6 +65,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
editBook(audiobook) {
|
||||||
|
var bookIds = this.shelf.books.map((e) => e.id)
|
||||||
|
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||||
|
this.$store.commit('showEditModal', audiobook)
|
||||||
|
},
|
||||||
scrolled() {
|
scrolled() {
|
||||||
clearTimeout(this.scrollTimer)
|
clearTimeout(this.scrollTimer)
|
||||||
this.scrollTimer = setTimeout(() => {
|
this.scrollTimer = setTimeout(() => {
|
||||||
@@ -62,7 +79,6 @@ export default {
|
|||||||
},
|
},
|
||||||
scrollLeft() {
|
scrollLeft() {
|
||||||
if (!this.$refs.shelf) {
|
if (!this.$refs.shelf) {
|
||||||
console.error('No Shelf', this.index)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.isScrolling = true
|
this.isScrolling = true
|
||||||
@@ -70,7 +86,6 @@ export default {
|
|||||||
},
|
},
|
||||||
scrollRight() {
|
scrollRight() {
|
||||||
if (!this.$refs.shelf) {
|
if (!this.$refs.shelf) {
|
||||||
console.error('No Shelf', this.index)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.isScrolling = true
|
this.isScrolling = true
|
||||||
@@ -84,7 +99,6 @@ export default {
|
|||||||
},
|
},
|
||||||
checkCanScroll() {
|
checkCanScroll() {
|
||||||
if (!this.$refs.shelf) {
|
if (!this.$refs.shelf) {
|
||||||
console.error('No Shelf', this.index)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var clientWidth = this.$refs.shelf.clientWidth
|
var clientWidth = this.$refs.shelf.clientWidth
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-10 relative">
|
<div class="w-full h-10 relative">
|
||||||
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-40 flex items-center px-8">
|
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-8">
|
||||||
<template v-if="page !== 'search' && !isHome">
|
<template v-if="page !== 'search' && !isHome">
|
||||||
<p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
|
<p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
|
||||||
<div v-else class="flex items-center">
|
<div v-else class="flex items-center">
|
||||||
<div @click="seriesBackArrow" class="rounded-full h-10 w-10 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-3xl text-white">west</span>
|
<span class="material-icons text-2xl text-white">west</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- <span class="material-icons text-2xl cursor-pointer" @click="seriesBackArrow">west</span> -->
|
<!-- <span class="material-icons text-2xl cursor-pointer" @click="seriesBackArrow">west</span> -->
|
||||||
<p class="pl-4 font-book text-lg">
|
<p class="pl-4 font-book text-lg">
|
||||||
{{ selectedSeries }} <span class="ml-3 font-mono text-lg bg-black bg-opacity-30 rounded-lg px-1 py-0.5">{{ numShowing }}</span>
|
{{ selectedSeries }}
|
||||||
</p>
|
</p>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<ui-text-input v-show="!selectedSeries" v-model="_keywordFilter" placeholder="Keyword Filter" :padding-y="1.5" class="text-xs w-40" />
|
<ui-text-input v-show="!selectedSeries" v-model="_keywordFilter" placeholder="Keyword Filter" :padding-y="1.5" clearable class="text-xs w-40" />
|
||||||
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-48 h-7.5 ml-4" @change="updateFilter" />
|
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-48 h-7.5 ml-4" @change="updateFilter" />
|
||||||
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5 ml-4" @change="updateOrder" />
|
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5 ml-4" @change="updateOrder" />
|
||||||
</template>
|
</template>
|
||||||
@@ -38,8 +41,8 @@ export default {
|
|||||||
isHome: Boolean,
|
isHome: Boolean,
|
||||||
selectedSeries: String,
|
selectedSeries: String,
|
||||||
searchResults: {
|
searchResults: {
|
||||||
type: Array,
|
type: Object,
|
||||||
default: () => []
|
default: () => {}
|
||||||
},
|
},
|
||||||
searchQuery: String
|
searchQuery: String
|
||||||
},
|
},
|
||||||
@@ -57,7 +60,8 @@ export default {
|
|||||||
if (this.page === '') {
|
if (this.page === '') {
|
||||||
return this.$store.getters['audiobooks/getFiltered']().length
|
return this.$store.getters['audiobooks/getFiltered']().length
|
||||||
} else if (this.page === 'search') {
|
} else if (this.page === 'search') {
|
||||||
return (this.searchResults || []).length
|
var audiobookSearchResults = this.searchResults ? this.searchResults.audiobooks || [] : []
|
||||||
|
return audiobookSearchResults.length
|
||||||
} else {
|
} else {
|
||||||
var groups = this.$store.getters['audiobooks/getSeriesGroups']()
|
var groups = this.$store.getters['audiobooks/getSeriesGroups']()
|
||||||
if (this.selectedSeries) {
|
if (this.selectedSeries) {
|
||||||
@@ -81,14 +85,17 @@ export default {
|
|||||||
set(val) {
|
set(val) {
|
||||||
this.$store.commit('audiobooks/setKeywordFilter', val)
|
this.$store.commit('audiobooks/setKeywordFilter', val)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
searchBackArrow() {
|
searchBackArrow() {
|
||||||
this.$router.replace('/library')
|
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
|
||||||
},
|
},
|
||||||
seriesBackArrow() {
|
seriesBackArrow() {
|
||||||
this.$router.replace('/library/series')
|
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/series`)
|
||||||
this.$emit('update:selectedSeries', null)
|
this.$emit('update:selectedSeries', null)
|
||||||
},
|
},
|
||||||
updateOrder() {
|
updateOrder() {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-44 fixed left-0 top-16 z-40 h-full bg-bg bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-4">
|
||||||
|
<nuxt-link to="/config" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||||
|
<p>Settings</p>
|
||||||
|
<div v-show="routeName === 'config'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
<nuxt-link to="/config/libraries" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-libraries' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||||
|
<p>Libraries</p>
|
||||||
|
<div v-show="routeName === 'config-libraries'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
<nuxt-link to="/config/users" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-users' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||||
|
<p>Users</p>
|
||||||
|
<div v-show="routeName === 'config-users'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
<nuxt-link to="/config/backups" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-backups' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||||
|
<p>Backups</p>
|
||||||
|
<div v-show="routeName === 'config-backups'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
<nuxt-link to="/config/log" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-log' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||||
|
<p>Log</p>
|
||||||
|
<div v-show="routeName === 'config-log'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
|
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute bottom-20 left-0 flex flex-col justify-center">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
routeName() {
|
||||||
|
return this.$route.name
|
||||||
|
},
|
||||||
|
versionData() {
|
||||||
|
return this.$store.state.versionData || {}
|
||||||
|
},
|
||||||
|
hasUpdate() {
|
||||||
|
return !!this.versionData.hasUpdate
|
||||||
|
},
|
||||||
|
latestVersion() {
|
||||||
|
return this.versionData.latestVersion
|
||||||
|
},
|
||||||
|
githubTagUrl() {
|
||||||
|
return this.versionData.githubTagUrl
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-20 bg-bg h-full relative box-shadow-side z-30" style="min-width: 80px">
|
<div class="w-20 bg-bg h-full relative box-shadow-side z-30" style="min-width: 80px">
|
||||||
<div class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
<div class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||||
<nuxt-link to="/" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link to="/library" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === '' && !homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === '' && !homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<div v-show="paramId === '' && !homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="paramId === '' && !homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link to="/library/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="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<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="paramId === 'series' ? '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>
|
||||||
@@ -72,11 +72,14 @@ export default {
|
|||||||
paramId() {
|
paramId() {
|
||||||
return this.$route.params ? this.$route.params.id || '' : ''
|
return this.$route.params ? this.$route.params.id || '' : ''
|
||||||
},
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
selectedClassName() {
|
selectedClassName() {
|
||||||
return ''
|
return ''
|
||||||
},
|
},
|
||||||
homePage() {
|
homePage() {
|
||||||
return this.$route.name === 'index'
|
return this.$route.name === 'library-library'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span>
|
<span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<audio-player ref="audioPlayer" :chapters="chapters" :loading="isLoading" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
|
<audio-player ref="audioPlayer" :chapters="chapters" :loading="isLoading" @close="cancelStream" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -63,12 +63,15 @@ export default {
|
|||||||
},
|
},
|
||||||
playlistUrl() {
|
playlistUrl() {
|
||||||
return this.stream ? this.stream.clientPlaylistUri : null
|
return this.stream ? this.stream.clientPlaylistUri : null
|
||||||
|
},
|
||||||
|
libraryId() {
|
||||||
|
return this.streamAudiobook ? this.streamAudiobook.libraryId : null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
filterByAuthor() {
|
filterByAuthor() {
|
||||||
if (this.$route.name !== 'index') {
|
if (this.$route.name !== 'index') {
|
||||||
this.$router.push('/library')
|
this.$router.push(`/library/${this.libraryId || this.$store.state.libraries.currentLibraryId}/bookshelf`)
|
||||||
}
|
}
|
||||||
var settingsUpdate = {
|
var settingsUpdate = {
|
||||||
filterBy: `authors.${this.$encode(this.author)}`
|
filterBy: `authors.${this.$encode(this.author)}`
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full px-1 overflow-hidden">
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
<cards-book-cover :audiobook="audiobook" :width="40" />
|
<cards-book-cover :audiobook="audiobook" :width="50" />
|
||||||
<div class="flex-grow px-2 searchCardContent h-full">
|
<div class="flex-grow px-2 audiobookSearchCardContent">
|
||||||
<p class="truncate text-sm">{{ title }}</p>
|
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
||||||
<p class="text-xs text-gray-200 truncate">by {{ author }}</p>
|
<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 !== 'authorFL'" class="text-xs text-gray-200 truncate">by {{ authorFL }}</p>
|
||||||
|
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
||||||
|
|
||||||
|
<div v-if="matchKey === 'series' || matchKey === 'tags'" class="m-0 p-0 truncate" v-html="matchHtml" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -14,7 +21,10 @@ export default {
|
|||||||
audiobook: {
|
audiobook: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
}
|
},
|
||||||
|
search: String,
|
||||||
|
matchKey: String,
|
||||||
|
matchText: String
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
@@ -26,8 +36,35 @@ export default {
|
|||||||
title() {
|
title() {
|
||||||
return this.book ? this.book.title : 'No Title'
|
return this.book ? this.book.title : 'No Title'
|
||||||
},
|
},
|
||||||
author() {
|
subtitle() {
|
||||||
return this.book ? this.book.author : 'Unknown'
|
return this.book ? this.book.subtitle : ''
|
||||||
|
},
|
||||||
|
authorFL() {
|
||||||
|
return this.book ? this.book.authorFL : 'Unknown'
|
||||||
|
},
|
||||||
|
matchHtml() {
|
||||||
|
if (!this.matchText || !this.search) return ''
|
||||||
|
if (this.matchKey === 'subtitle') return ''
|
||||||
|
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
|
||||||
|
if (matchSplit.length < 2) return ''
|
||||||
|
|
||||||
|
var html = ''
|
||||||
|
var totalLenSoFar = 0
|
||||||
|
for (let i = 0; i < matchSplit.length - 1; i++) {
|
||||||
|
var indexOf = matchSplit[i].length
|
||||||
|
var firstPart = this.matchText.substr(totalLenSoFar, indexOf)
|
||||||
|
var actualWasThere = this.matchText.substr(totalLenSoFar + indexOf, this.search.length)
|
||||||
|
totalLenSoFar += indexOf + this.search.length
|
||||||
|
|
||||||
|
html += `${firstPart}<strong class="text-warning">${actualWasThere}</strong>`
|
||||||
|
}
|
||||||
|
var lastPart = this.matchText.substr(totalLenSoFar)
|
||||||
|
html += lastPart
|
||||||
|
|
||||||
|
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||||
|
if (this.matchKey === 'authorFL') return `by ${html}`
|
||||||
|
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
||||||
|
return `${html}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
@@ -36,9 +73,9 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.searchCardContent {
|
.audiobookSearchCardContent {
|
||||||
width: calc(100% - 80px);
|
width: calc(100% - 80px);
|
||||||
height: calc(40px * 1.5);
|
height: 75px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<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="flex-grow px-2 authorSearchCardContent h-full">
|
||||||
|
<p class="truncate text-sm">{{ author }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
author: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.authorSearchCardContent {
|
||||||
|
width: calc(100% - 80px);
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -14,11 +14,16 @@
|
|||||||
<cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" />
|
<cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" />
|
||||||
|
|
||||||
<div v-show="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black rounded" :class="overlayWrapperClasslist">
|
<div v-show="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black rounded" :class="overlayWrapperClasslist">
|
||||||
<div v-show="!isSelectionMode && !isMissing" class="h-full flex items-center justify-center">
|
<div v-show="showPlayButton" class="h-full flex items-center justify-center">
|
||||||
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play">
|
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play">
|
||||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-show="showReadButton" class="h-full flex items-center justify-center">
|
||||||
|
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="clickReadEBook">
|
||||||
|
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">auto_stories</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-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" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 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>
|
||||||
@@ -33,9 +38,16 @@
|
|||||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <div v-if="true && hasEbook" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
<!-- EBook Icon -->
|
||||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">EBook</p>
|
<div
|
||||||
</div> -->
|
v-if="showSmallEBookIcon"
|
||||||
|
class="absolute rounded-full bg-blue-500 flex items-center justify-center bg-opacity-90 hover:scale-125 transform duration-200"
|
||||||
|
:style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem`, width: 1.5 * sizeMultiplier + 'rem', height: 1.5 * sizeMultiplier + 'rem' }"
|
||||||
|
@click.stop.prevent="clickReadEBook"
|
||||||
|
>
|
||||||
|
<!-- <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">EBook</p> -->
|
||||||
|
<span class="material-icons text-white" :style="{ fontSize: sizeMultiplier * 1 + 'rem' }">auto_stories</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||||
|
|
||||||
@@ -73,6 +85,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
isNew() {
|
isNew() {
|
||||||
return this.tags.includes('New')
|
return this.tags.includes('New')
|
||||||
},
|
},
|
||||||
@@ -85,8 +100,10 @@ export default {
|
|||||||
hasEbook() {
|
hasEbook() {
|
||||||
return this.audiobook.numEbooks
|
return this.audiobook.numEbooks
|
||||||
},
|
},
|
||||||
|
hasTracks() {
|
||||||
|
return this.audiobook.numTracks
|
||||||
|
},
|
||||||
isSelectionMode() {
|
isSelectionMode() {
|
||||||
// return this.$store.getters['getNumAudiobooksSelected']
|
|
||||||
return !!this.selectedAudiobooks.length
|
return !!this.selectedAudiobooks.length
|
||||||
},
|
},
|
||||||
selectedAudiobooks() {
|
selectedAudiobooks() {
|
||||||
@@ -145,11 +162,26 @@ export default {
|
|||||||
return this.userProgress ? !!this.userProgress.isRead : false
|
return this.userProgress ? !!this.userProgress.isRead : false
|
||||||
},
|
},
|
||||||
showError() {
|
showError() {
|
||||||
return this.hasMissingParts || this.hasInvalidParts || this.isMissing
|
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isIncomplete
|
||||||
|
},
|
||||||
|
isStreaming() {
|
||||||
|
return this.$store.getters['getAudiobookIdStreaming'] === this.audiobookId
|
||||||
|
},
|
||||||
|
showReadButton() {
|
||||||
|
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
|
||||||
|
},
|
||||||
|
showPlayButton() {
|
||||||
|
return !this.isSelectionMode && !this.isMissing && !this.isIncomplete && this.hasTracks && !this.isStreaming
|
||||||
|
},
|
||||||
|
showSmallEBookIcon() {
|
||||||
|
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
|
||||||
},
|
},
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return this.audiobook.isMissing
|
return this.audiobook.isMissing
|
||||||
},
|
},
|
||||||
|
isIncomplete() {
|
||||||
|
return this.audiobook.isIncomplete
|
||||||
|
},
|
||||||
hasMissingParts() {
|
hasMissingParts() {
|
||||||
return this.audiobook.hasMissingParts
|
return this.audiobook.hasMissingParts
|
||||||
},
|
},
|
||||||
@@ -158,6 +190,7 @@ export default {
|
|||||||
},
|
},
|
||||||
errorText() {
|
errorText() {
|
||||||
if (this.isMissing) return 'Audiobook directory is missing!'
|
if (this.isMissing) return 'Audiobook directory is missing!'
|
||||||
|
else if (this.isIncomplete) return 'Audiobook has no audio tracks & ebook'
|
||||||
var txt = ''
|
var txt = ''
|
||||||
if (this.hasMissingParts) {
|
if (this.hasMissingParts) {
|
||||||
txt = `${this.hasMissingParts} missing parts.`
|
txt = `${this.hasMissingParts} missing parts.`
|
||||||
@@ -198,7 +231,8 @@ export default {
|
|||||||
this.$root.socket.emit('open_stream', this.audiobookId)
|
this.$root.socket.emit('open_stream', this.audiobookId)
|
||||||
},
|
},
|
||||||
editClick() {
|
editClick() {
|
||||||
this.$store.commit('showEditModal', this.audiobook)
|
// this.$store.commit('showEditModal', this.audiobook)
|
||||||
|
this.$emit('edit', this.audiobook)
|
||||||
},
|
},
|
||||||
clickCard(e) {
|
clickCard(e) {
|
||||||
if (this.isSelectionMode) {
|
if (this.isSelectionMode) {
|
||||||
@@ -206,6 +240,9 @@ export default {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
this.selectBtnClick()
|
this.selectBtnClick()
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
clickReadEBook() {
|
||||||
|
this.$store.commit('showEReader', this.audiobook)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,9 +53,6 @@ export default {
|
|||||||
book() {
|
book() {
|
||||||
return this.audiobook.book || {}
|
return this.audiobook.book || {}
|
||||||
},
|
},
|
||||||
bookLastUpdate() {
|
|
||||||
return this.book.lastUpdate || Date.now()
|
|
||||||
},
|
|
||||||
title() {
|
title() {
|
||||||
return this.book.title || 'No Title'
|
return this.book.title || 'No Title'
|
||||||
},
|
},
|
||||||
@@ -79,7 +76,7 @@ export default {
|
|||||||
return '/book_placeholder.jpg'
|
return '/book_placeholder.jpg'
|
||||||
},
|
},
|
||||||
fullCoverUrl() {
|
fullCoverUrl() {
|
||||||
return this.$store.getters['audiobooks/getBookCoverSrc'](this.book, this.placeholderUrl)
|
return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
|
||||||
},
|
},
|
||||||
cover() {
|
cover() {
|
||||||
return this.book.cover || this.placeholderUrl
|
return this.book.cover || this.placeholderUrl
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard">
|
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard">
|
||||||
<nuxt-link :to="`/library/series?${groupType}=${groupEncode}`" class="cursor-pointer">
|
<nuxt-link :to="groupTo" class="cursor-pointer">
|
||||||
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: height + 'px', width: height + 'px' }">
|
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: height + 'px', width: height + 'px' }">
|
||||||
<cards-group-cover ref="groupcover" :name="groupName" :book-items="bookItems" :width="height" :height="height" />
|
<cards-group-cover ref="groupcover" :name="groupName" :book-items="bookItems" :width="height" :height="height" />
|
||||||
|
|
||||||
@@ -48,9 +48,22 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
_group() {
|
_group() {
|
||||||
return this.group || {}
|
return this.group || {}
|
||||||
},
|
},
|
||||||
|
groupType() {
|
||||||
|
return this._group.type
|
||||||
|
},
|
||||||
|
groupTo() {
|
||||||
|
if (this.groupType === 'series') {
|
||||||
|
return `/library/${this.currentLibraryId}/bookshelf/series?series=${this.groupEncode}`
|
||||||
|
} else {
|
||||||
|
return `/library/${this.currentLibraryId}/bookshelf?filter=tags.${this.groupEncode}`
|
||||||
|
}
|
||||||
|
},
|
||||||
height() {
|
height() {
|
||||||
return this.width * 1.6
|
return this.width * 1.6
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export default {
|
|||||||
this.coverDiv.remove()
|
this.coverDiv.remove()
|
||||||
this.coverDiv = null
|
this.coverDiv = null
|
||||||
}
|
}
|
||||||
var validCovers = this.bookItems.map((bookItem) => this.getCoverUrl(bookItem.book)).filter((b) => b !== '')
|
var validCovers = this.bookItems.map((bookItem) => this.getCoverUrl(bookItem)).filter((b) => b !== '')
|
||||||
if (!validCovers.length) {
|
if (!validCovers.length) {
|
||||||
this.noValidCovers = true
|
this.noValidCovers = true
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
|
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||||
<div class="w-full h-full relative">
|
<div class="w-full h-full relative">
|
||||||
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
|
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
|
||||||
<div class="w-full h-full z-0" ref="coverBg" />
|
<div class="w-full h-full z-0" ref="coverBg" />
|
||||||
</div>
|
</div>
|
||||||
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
|
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
|
||||||
|
|
||||||
|
<a v-if="!imageFailed && showOpenNewTab && isHovering" :href="cover" @click.stop target="_blank" class="absolute bg-primary flex items-center justify-center shadow-sm rounded-full hover:scale-110 transform duration-100" :style="{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }">
|
||||||
|
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||||
@@ -23,12 +27,16 @@ export default {
|
|||||||
width: {
|
width: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 120
|
default: 120
|
||||||
}
|
},
|
||||||
|
showOpenNewTab: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
imageFailed: false,
|
imageFailed: false,
|
||||||
showCoverBg: false
|
showCoverBg: false,
|
||||||
|
isHovering: false,
|
||||||
|
naturalHeight: 0,
|
||||||
|
naturalWidth: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -60,6 +68,9 @@ export default {
|
|||||||
imageLoaded() {
|
imageLoaded() {
|
||||||
if (this.$refs.cover) {
|
if (this.$refs.cover) {
|
||||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||||
|
this.naturalHeight = naturalHeight
|
||||||
|
this.naturalWidth = naturalWidth
|
||||||
|
|
||||||
var aspectRatio = naturalHeight / naturalWidth
|
var aspectRatio = naturalHeight / naturalWidth
|
||||||
var arDiff = Math.abs(aspectRatio - 1.6)
|
var arDiff = Math.abs(aspectRatio - 1.6)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
|
<cards-group-cover :name="series" :book-items="bookItems" :width="60" :height="60" />
|
||||||
|
<div class="flex-grow px-2 seriesSearchCardContent h-full">
|
||||||
|
<p class="truncate text-sm">{{ series }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
series: String,
|
||||||
|
bookItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.seriesSearchCardContent {
|
||||||
|
width: calc(100% - 80px);
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
|
<div class="w-10 h-10 flex items-center justify-center">
|
||||||
|
<span class="material-icons text-2xl text-gray-200">local_offer</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
||||||
|
<p class="truncate text-sm">{{ tag }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
tag: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tagSearchCardContent {
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -41,6 +41,11 @@
|
|||||||
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
|
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('No Series'))">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">No Series</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
<template v-for="item in sublistItems">
|
<template v-for="item in sublistItems">
|
||||||
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
|
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -87,6 +92,11 @@ export default {
|
|||||||
value: 'authors',
|
value: 'authors',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'Narrator',
|
||||||
|
value: 'narrators',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: 'Progress',
|
text: 'Progress',
|
||||||
value: 'progress',
|
value: 'progress',
|
||||||
@@ -137,6 +147,9 @@ export default {
|
|||||||
authors() {
|
authors() {
|
||||||
return this.$store.getters['audiobooks/getUniqueAuthors']
|
return this.$store.getters['audiobooks/getUniqueAuthors']
|
||||||
},
|
},
|
||||||
|
narrators() {
|
||||||
|
return this.$store.getters['audiobooks/getUniqueNarrators']
|
||||||
|
},
|
||||||
progress() {
|
progress() {
|
||||||
return ['Read', 'Unread', 'In Progress']
|
return ['Read', 'Unread', 'In Progress']
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-64 ml-8 relative">
|
<div class="w-80 ml-6 relative">
|
||||||
<form @submit.prevent="submitSearch">
|
<form @submit.prevent="submitSearch">
|
||||||
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||||
</form>
|
</form>
|
||||||
@@ -7,23 +7,51 @@
|
|||||||
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
||||||
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-10 -mt-px 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">
|
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
|
||||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<li v-if="isTyping" class="py-2 px-2">
|
<li v-if="isTyping" class="py-2 px-2">
|
||||||
<p>Typing...</p>
|
<p>Thinking...</p>
|
||||||
</li>
|
</li>
|
||||||
<li v-else-if="isFetching" class="py-2 px-2">
|
<li v-else-if="isFetching" class="py-2 px-2">
|
||||||
<p>Fetching...</p>
|
<p>Fetching...</p>
|
||||||
</li>
|
</li>
|
||||||
<li v-else-if="!items.length" class="py-2 px-2">
|
<li v-else-if="!totalResults" class="py-2 px-2">
|
||||||
<p>No Results</p>
|
<p>No Results</p>
|
||||||
</li>
|
</li>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<template v-for="item in items">
|
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
|
||||||
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickedOption(item)">
|
<template v-for="item in audiobookResults">
|
||||||
<template v-if="item.type === 'audiobook'">
|
<li :key="item.audiobook.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||||
<cards-audiobook-search-card :audiobook="item.data" />
|
<nuxt-link :to="`/audiobook/${item.audiobook.id}`">
|
||||||
</template>
|
<cards-audiobook-search-card :audiobook="item.audiobook" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||||
|
</nuxt-link>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<li :key="item.author" 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)}`">
|
||||||
|
<cards-author-search-card :author="item.author" />
|
||||||
|
</nuxt-link>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<li :key="item.series" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||||
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series?series=${$encode(item.series)}`">
|
||||||
|
<cards-series-search-card :series="item.series" :book-items="item.audiobooks" />
|
||||||
|
</nuxt-link>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<li :key="item.tag" 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)}`">
|
||||||
|
<cards-tag-search-card :tag="item.tag" />
|
||||||
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@@ -42,7 +70,10 @@ export default {
|
|||||||
isTyping: false,
|
isTyping: false,
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
search: null,
|
search: null,
|
||||||
items: [],
|
audiobookResults: [],
|
||||||
|
authorResults: [],
|
||||||
|
seriesResults: [],
|
||||||
|
tagResults: [],
|
||||||
searchTimeout: null,
|
searchTimeout: null,
|
||||||
lastSearch: null
|
lastSearch: null
|
||||||
}
|
}
|
||||||
@@ -50,16 +81,32 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
audiobooks() {
|
audiobooks() {
|
||||||
return this.$store.state.audiobooks.audiobooks
|
return this.$store.state.audiobooks.audiobooks
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
totalResults() {
|
||||||
|
return this.audiobookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
submitSearch() {
|
submitSearch() {
|
||||||
if (!this.search) return
|
if (!this.search) return
|
||||||
this.$router.push(`/library/search?query=${this.search}`)
|
var search = this.search
|
||||||
|
this.clearResults()
|
||||||
|
this.$router.push(`/library/${this.currentLibraryId}/bookshelf/search?query=${search}`)
|
||||||
|
},
|
||||||
|
clearResults() {
|
||||||
this.search = null
|
this.search = null
|
||||||
this.items = []
|
this.lastSearch = null
|
||||||
|
this.audiobookResults = []
|
||||||
|
this.authorResults = []
|
||||||
|
this.seriesResults = []
|
||||||
|
this.tagResults = []
|
||||||
this.showMenu = false
|
this.showMenu = false
|
||||||
|
this.isFetching = false
|
||||||
|
this.isTyping = false
|
||||||
|
clearTimeout(this.searchTimeout)
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
if (this.$refs.input) {
|
if (this.$refs.input) {
|
||||||
this.$refs.input.blur()
|
this.$refs.input.blur()
|
||||||
@@ -83,22 +130,24 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.isFetching = true
|
this.isFetching = true
|
||||||
var results = await this.$axios.$get(`/api/audiobooks?q=${value}`).catch((error) => {
|
|
||||||
|
var searchResults = await this.$axios.$get(`/api/library/${this.currentLibraryId}/search?q=${value}`).catch((error) => {
|
||||||
console.error('Search error', error)
|
console.error('Search error', error)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Search was canceled
|
||||||
|
if (!this.isFetching) return
|
||||||
|
|
||||||
|
this.audiobookResults = searchResults.audiobooks || []
|
||||||
|
this.authorResults = searchResults.authors || []
|
||||||
|
this.seriesResults = searchResults.series || []
|
||||||
|
this.tagResults = searchResults.tags || []
|
||||||
|
|
||||||
this.isFetching = false
|
this.isFetching = false
|
||||||
if (!this.showMenu) {
|
if (!this.showMenu) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.items = results.map((res) => {
|
|
||||||
return {
|
|
||||||
id: res.id,
|
|
||||||
data: res,
|
|
||||||
type: 'audiobook'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
inputUpdate(val) {
|
inputUpdate(val) {
|
||||||
clearTimeout(this.searchTimeout)
|
clearTimeout(this.searchTimeout)
|
||||||
@@ -109,23 +158,23 @@ export default {
|
|||||||
}
|
}
|
||||||
this.isTyping = true
|
this.isTyping = true
|
||||||
this.searchTimeout = setTimeout(() => {
|
this.searchTimeout = setTimeout(() => {
|
||||||
|
// Canceled search
|
||||||
|
if (!this.isTyping) return
|
||||||
|
|
||||||
this.isTyping = false
|
this.isTyping = false
|
||||||
this.runSearch(val)
|
this.runSearch(val)
|
||||||
}, 1000)
|
}, 750)
|
||||||
},
|
|
||||||
clickedOption(option) {
|
|
||||||
if (option.type === 'audiobook') {
|
|
||||||
this.$router.push(`/audiobook/${option.data.id}`)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
clickClear() {
|
clickClear() {
|
||||||
if (this.search) {
|
this.clearResults()
|
||||||
this.search = null
|
|
||||||
this.items = []
|
|
||||||
this.showMenu = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.globalSearchMenu {
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -114,6 +114,9 @@ export default {
|
|||||||
this.volume = this.lastValue || 0.5
|
this.volume = this.lastValue || 0.5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
toggleMute() {
|
||||||
|
this.clickVolumeIcon()
|
||||||
|
},
|
||||||
clickVolumeTrack(e) {
|
clickVolumeTrack(e) {
|
||||||
var vol = e.offsetX / this.trackWidth
|
var vol = e.offsetX / this.trackWidth
|
||||||
vol = Math.min(Math.max(vol, 0), 1)
|
vol = Math.min(Math.max(vol, 0), 1)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="800" :height="'unset'" :processing="processing">
|
<modals-modal v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
@@ -64,6 +64,19 @@
|
|||||||
<ui-toggle-switch v-model="newUser.permissions.upload" />
|
<ui-toggle-switch v-model="newUser.permissions.upload" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center my-2 max-w-md">
|
||||||
|
<div class="w-1/2">
|
||||||
|
<p>Can Access All Libraries</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-1/2">
|
||||||
|
<ui-toggle-switch v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!newUser.permissions.accessAllLibraries" class="my-4">
|
||||||
|
<ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" label="Libraries Accessible to User" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex pt-4">
|
<div class="flex pt-4">
|
||||||
@@ -116,14 +129,31 @@ export default {
|
|||||||
},
|
},
|
||||||
isEditingRoot() {
|
isEditingRoot() {
|
||||||
return this.account && this.account.type === 'root'
|
return this.account && this.account.type === 'root'
|
||||||
|
},
|
||||||
|
libraries() {
|
||||||
|
return this.$store.state.libraries.libraries
|
||||||
|
},
|
||||||
|
libraryItems() {
|
||||||
|
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
accessAllLibrariesToggled(val) {
|
||||||
|
if (!val && !this.newUser.librariesAccessible.length) {
|
||||||
|
this.newUser.librariesAccessible = this.libraries.map((l) => l.id)
|
||||||
|
} else if (val && this.newUser.librariesAccessible.length) {
|
||||||
|
this.newUser.librariesAccessible = []
|
||||||
|
}
|
||||||
|
},
|
||||||
submitForm() {
|
submitForm() {
|
||||||
if (!this.newUser.username) {
|
if (!this.newUser.username) {
|
||||||
this.$toast.error('Enter a username')
|
this.$toast.error('Enter a username')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!this.newUser.permissions.accessAllLibraries && !this.newUser.librariesAccessible.length) {
|
||||||
|
this.$toast.error('Must select at least one library')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isNew) {
|
if (this.isNew) {
|
||||||
this.submitCreateAccount()
|
this.submitCreateAccount()
|
||||||
@@ -139,6 +169,7 @@ export default {
|
|||||||
if (account.type === 'root' && !account.isActive) return
|
if (account.type === 'root' && !account.isActive) return
|
||||||
|
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
console.log('Calling update', account)
|
||||||
this.$axios
|
this.$axios
|
||||||
.$patch(`/api/user/${this.account.id}`, account)
|
.$patch(`/api/user/${this.account.id}`, account)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -146,14 +177,16 @@ export default {
|
|||||||
if (data.error) {
|
if (data.error) {
|
||||||
this.$toast.error(`Failed to update account: ${data.error}`)
|
this.$toast.error(`Failed to update account: ${data.error}`)
|
||||||
} else {
|
} else {
|
||||||
|
console.log('Account updated', data.user)
|
||||||
this.$toast.success('Account updated')
|
this.$toast.success('Account updated')
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to update account', error)
|
|
||||||
this.processing = false
|
this.processing = false
|
||||||
this.$toast.error('Failed to update account')
|
console.error('Failed to update account', error)
|
||||||
|
var errMsg = error.response ? error.response.data || '' : ''
|
||||||
|
this.$toast.error(errMsg || 'Failed to update account')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
submitCreateAccount() {
|
submitCreateAccount() {
|
||||||
@@ -176,9 +209,10 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to create account', error)
|
|
||||||
this.processing = false
|
this.processing = false
|
||||||
this.$toast.error('Failed to create account')
|
console.error('Failed to create account', error)
|
||||||
|
var errMsg = error.response ? error.response.data || '' : ''
|
||||||
|
this.$toast.error(errMsg || 'Failed to create account')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
toggleActive() {
|
toggleActive() {
|
||||||
@@ -195,12 +229,14 @@ export default {
|
|||||||
init() {
|
init() {
|
||||||
this.isNew = !this.account
|
this.isNew = !this.account
|
||||||
if (this.account) {
|
if (this.account) {
|
||||||
|
var librariesAccessible = this.account.librariesAccessible || []
|
||||||
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]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.newUser = {
|
this.newUser = {
|
||||||
@@ -212,8 +248,10 @@ export default {
|
|||||||
download: true,
|
download: true,
|
||||||
update: false,
|
update: false,
|
||||||
delete: false,
|
delete: false,
|
||||||
upload: false
|
upload: false,
|
||||||
}
|
accessAllLibraries: true
|
||||||
|
},
|
||||||
|
librariesAccessible: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="500" :height="'unset'">
|
<modals-modal v-model="show" name="chapters" :width="500" :height="'unset'">
|
||||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
<template v-for="chap in chapters">
|
<template v-for="chap in chapters">
|
||||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-bg bg-opacity-80' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-bg bg-opacity-80' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="800" :height="height" :processing="processing" :content-margin-top="75">
|
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="75">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
@@ -10,6 +10,14 @@
|
|||||||
<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" :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" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
||||||
|
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goPrevBook" @mousedown.prevent>arrow_back_ios</div>
|
||||||
|
</div>
|
||||||
|
<div v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
||||||
|
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="w-full h-full 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">
|
||||||
<keep-alive>
|
<keep-alive>
|
||||||
<component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" />
|
<component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" />
|
||||||
@@ -36,11 +44,11 @@ export default {
|
|||||||
title: 'Cover',
|
title: 'Cover',
|
||||||
component: 'modals-edit-tabs-cover'
|
component: 'modals-edit-tabs-cover'
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
id: 'match',
|
// id: 'match',
|
||||||
title: 'Match',
|
// title: 'Match',
|
||||||
component: 'modals-edit-tabs-match'
|
// component: 'modals-edit-tabs-match'
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
id: 'tracks',
|
id: 'tracks',
|
||||||
title: 'Tracks',
|
title: 'Tracks',
|
||||||
@@ -51,6 +59,11 @@ export default {
|
|||||||
title: 'Chapters',
|
title: 'Chapters',
|
||||||
component: 'modals-edit-tabs-chapters'
|
component: 'modals-edit-tabs-chapters'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'files',
|
||||||
|
title: 'Files',
|
||||||
|
component: 'modals-edit-tabs-files'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'download',
|
id: 'download',
|
||||||
title: 'Download',
|
title: 'Download',
|
||||||
@@ -68,6 +81,7 @@ export default {
|
|||||||
this.show = false
|
this.show = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!availableTabIds.includes(this.selectedTab)) {
|
if (!availableTabIds.includes(this.selectedTab)) {
|
||||||
this.selectedTab = availableTabIds[0]
|
this.selectedTab = availableTabIds[0]
|
||||||
}
|
}
|
||||||
@@ -79,6 +93,9 @@ export default {
|
|||||||
this.fetchOnShow = false
|
this.fetchOnShow = false
|
||||||
this.audiobook = null
|
this.audiobook = null
|
||||||
this.init()
|
this.init()
|
||||||
|
this.registerListeners()
|
||||||
|
} else {
|
||||||
|
this.unregisterListeners()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,9 +154,45 @@ export default {
|
|||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.book.title || 'No Title'
|
return this.book.title || 'No Title'
|
||||||
|
},
|
||||||
|
bookshelfBookIds() {
|
||||||
|
return this.$store.state.bookshelfBookIds || []
|
||||||
|
},
|
||||||
|
currentBookshelfIndex() {
|
||||||
|
if (!this.bookshelfBookIds.length) return 0
|
||||||
|
return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedAudiobookId)
|
||||||
|
},
|
||||||
|
canGoPrev() {
|
||||||
|
return this.bookshelfBookIds.length && this.currentBookshelfIndex > 0
|
||||||
|
},
|
||||||
|
canGoNext() {
|
||||||
|
return this.bookshelfBookIds.length && this.currentBookshelfIndex < this.bookshelfBookIds.length - 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
goPrevBook() {
|
||||||
|
if (this.currentBookshelfIndex - 1 < 0) return
|
||||||
|
var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]
|
||||||
|
var prevBook = this.$store.getters['audiobooks/getAudiobook'](prevBookId)
|
||||||
|
if (prevBook) {
|
||||||
|
this.$store.commit('showEditModalOnTab', { audiobook: prevBook, tab: this.selectedTab })
|
||||||
|
this.$nextTick(this.init)
|
||||||
|
} else {
|
||||||
|
console.error('Book not found', prevBookId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
goNextBook() {
|
||||||
|
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
|
||||||
|
|
||||||
|
var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1]
|
||||||
|
var nextBook = this.$store.getters['audiobooks/getAudiobook'](nextBookId)
|
||||||
|
if (nextBook) {
|
||||||
|
this.$store.commit('showEditModalOnTab', { audiobook: nextBook, tab: this.selectedTab })
|
||||||
|
this.$nextTick(this.init)
|
||||||
|
} else {
|
||||||
|
console.error('Book not found', nextBookId)
|
||||||
|
}
|
||||||
|
},
|
||||||
selectTab(tab) {
|
selectTab(tab) {
|
||||||
this.selectedTab = tab
|
this.selectedTab = tab
|
||||||
},
|
},
|
||||||
@@ -155,14 +208,33 @@ export default {
|
|||||||
},
|
},
|
||||||
async fetchFull() {
|
async fetchFull() {
|
||||||
try {
|
try {
|
||||||
|
this.processing = true
|
||||||
this.audiobook = await this.$axios.$get(`/api/audiobook/${this.selectedAudiobookId}`)
|
this.audiobook = await this.$axios.$get(`/api/audiobook/${this.selectedAudiobookId}`)
|
||||||
|
this.processing = false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch audiobook', this.selectedAudiobookId, error)
|
console.error('Failed to fetch audiobook', this.selectedAudiobookId, error)
|
||||||
|
this.processing = false
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
hotkey(action) {
|
||||||
|
if (action === 'ArrowRight') {
|
||||||
|
this.goNextBook()
|
||||||
|
} else if (action === 'ArrowLeft') {
|
||||||
|
this.goPrevBook()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
registerListeners() {
|
||||||
|
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||||
|
},
|
||||||
|
unregisterListeners() {
|
||||||
|
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.unregisterListeners()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-40 opacity-0">
|
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-40 opacity-0">
|
||||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||||
|
|
||||||
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
|
||||||
<span class="material-icons text-4xl">close</span>
|
<span class="material-icons text-4xl">close</span>
|
||||||
</div>
|
</div>
|
||||||
<slot name="outer" />
|
<slot name="outer" />
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
name: String,
|
||||||
value: Boolean,
|
value: Boolean,
|
||||||
processing: Boolean,
|
processing: Boolean,
|
||||||
persistent: {
|
persistent: {
|
||||||
@@ -73,23 +74,37 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
clickClose() {
|
||||||
|
this.show = false
|
||||||
|
},
|
||||||
clickBg(vm, ev) {
|
clickBg(vm, ev) {
|
||||||
if (this.processing && this.persistent) return
|
if (this.processing && this.persistent) return
|
||||||
if (vm.srcElement.classList.contains('modal-bg')) {
|
if (vm.srcElement.classList.contains('modal-bg')) {
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
hotkey(action) {
|
||||||
|
if (action === 'Escape') {
|
||||||
|
this.show = false
|
||||||
|
}
|
||||||
|
},
|
||||||
setShow() {
|
setShow() {
|
||||||
document.body.appendChild(this.el)
|
document.body.appendChild(this.el)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.content.style.transform = 'scale(1)'
|
this.content.style.transform = 'scale(1)'
|
||||||
}, 10)
|
}, 10)
|
||||||
document.documentElement.classList.add('modal-open')
|
document.documentElement.classList.add('modal-open')
|
||||||
|
|
||||||
|
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||||
|
this.$store.commit('setOpenModal', this.name)
|
||||||
},
|
},
|
||||||
setHide() {
|
setHide() {
|
||||||
this.content.style.transform = 'scale(0)'
|
this.content.style.transform = 'scale(0)'
|
||||||
this.el.remove()
|
this.el.remove()
|
||||||
document.documentElement.classList.remove('modal-open')
|
document.documentElement.classList.remove('modal-open')
|
||||||
|
|
||||||
|
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||||
|
this.$store.commit('setOpenModal', null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-grow pl-6 pr-2">
|
<div class="flex-grow pl-6 pr-2">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div v-if="userCanUpload" class="w-40 pr-2" style="min-width: 160px">
|
<div v-if="userCanUpload" class="w-40 pr-2 pt-4" style="min-width: 160px">
|
||||||
<ui-file-input ref="fileInput" @change="fileUploadSelected" />
|
<ui-file-input ref="fileInput" @change="fileUploadSelected">Upload Cover</ui-file-input>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="submitForm" class="flex flex-grow">
|
<form @submit.prevent="submitForm" class="flex flex-grow">
|
||||||
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
|
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
|
||||||
@@ -24,16 +24,16 @@
|
|||||||
|
|
||||||
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
|
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
|
||||||
<div class="flex items-center justify-center py-2">
|
<div class="flex items-center justify-center py-2">
|
||||||
<p>{{ localCovers.length }} local image(s)</p>
|
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? 'Hide' : 'Show' }}</ui-btn>
|
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? 'Hide' : 'Show' }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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.localPath)">
|
<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 class="h-24 bg-primary" style="width: 60px">
|
<div class="h-24 bg-primary" style="width: 60px">
|
||||||
<img :src="cover.localPath" class="h-full w-full object-contain" />
|
<img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -53,14 +53,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-60 overflow-y-scroll mt-2 max-w-full">
|
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-80 overflow-y-scroll mt-2 max-w-full">
|
||||||
<p v-if="!coversFound.length">No Covers Found</p>
|
<p v-if="!coversFound.length">No Covers Found</p>
|
||||||
<template v-for="cover in coversFound">
|
<template v-for="cover in coversFound">
|
||||||
<div :key="cover" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
<div :key="cover" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="updateCover(cover)">
|
||||||
<div class="h-24 bg-primary" style="width: 60px">
|
<cards-preview-cover :src="cover" :width="80" show-open-new-tab />
|
||||||
<img :src="cover" class="h-full w-full object-contain" />
|
|
||||||
</div>
|
|
||||||
<!-- <img :src="cover" class="h-24 object-cover" style="width: 60px" /> -->
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,8 +79,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Path from 'path'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
processing: Boolean,
|
processing: Boolean,
|
||||||
@@ -124,21 +119,31 @@ export default {
|
|||||||
this.$emit('update:processing', val)
|
this.$emit('update:processing', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
audiobookId() {
|
||||||
|
return this.audiobook ? this.audiobook.id : null
|
||||||
|
},
|
||||||
book() {
|
book() {
|
||||||
return this.audiobook ? this.audiobook.book || {} : {}
|
return this.audiobook ? this.audiobook.book || {} : {}
|
||||||
},
|
},
|
||||||
|
audiobookPath() {
|
||||||
|
return this.audiobook ? this.audiobook.path : null
|
||||||
|
},
|
||||||
otherFiles() {
|
otherFiles() {
|
||||||
return this.audiobook ? this.audiobook.otherFiles || [] : []
|
return this.audiobook ? this.audiobook.otherFiles || [] : []
|
||||||
},
|
},
|
||||||
userCanUpload() {
|
userCanUpload() {
|
||||||
return this.$store.getters['user/getUserCanUpload']
|
return this.$store.getters['user/getUserCanUpload']
|
||||||
},
|
},
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
localCovers() {
|
localCovers() {
|
||||||
return this.otherFiles
|
return this.otherFiles
|
||||||
.filter((f) => f.filetype === 'image')
|
.filter((f) => f.filetype === 'image')
|
||||||
.map((file) => {
|
.map((file) => {
|
||||||
var _file = { ...file }
|
var _file = { ...file }
|
||||||
_file.localPath = Path.join('local', _file.path)
|
var imgRelPath = _file.path.replace(this.audiobookPath, '')
|
||||||
|
_file.localPath = `/s/book/${this.audiobookId}${imgRelPath}`
|
||||||
return _file
|
return _file
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -162,7 +167,11 @@ export default {
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
this.$toast.error('Oops, something went wrong...')
|
if (error.response && error.response.data) {
|
||||||
|
this.$toast.error(error.response.data)
|
||||||
|
} else {
|
||||||
|
this.$toast.error('Oops, something went wrong...')
|
||||||
|
}
|
||||||
this.processingUpload = false
|
this.processingUpload = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -179,13 +188,13 @@ export default {
|
|||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.showLocalCovers = false
|
this.showLocalCovers = false
|
||||||
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.author)) {
|
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.authorFL)) {
|
||||||
this.coversFound = []
|
this.coversFound = []
|
||||||
this.hasSearched = false
|
this.hasSearched = false
|
||||||
}
|
}
|
||||||
this.imageUrl = this.book.cover || ''
|
this.imageUrl = this.book.cover || ''
|
||||||
this.searchTitle = this.book.title || ''
|
this.searchTitle = this.book.title || ''
|
||||||
this.searchAuthor = this.book.author || ''
|
this.searchAuthor = this.book.authorFL || ''
|
||||||
},
|
},
|
||||||
removeCover() {
|
removeCover() {
|
||||||
if (!this.book.cover) {
|
if (!this.book.cover) {
|
||||||
@@ -204,20 +213,39 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.isProcessing = true
|
this.isProcessing = true
|
||||||
const updatePayload = {
|
var success = false
|
||||||
book: {
|
|
||||||
cover: cover
|
// Download cover from url and use
|
||||||
|
if (cover.startsWith('http:') || cover.startsWith('https:')) {
|
||||||
|
success = await this.$axios.$post(`/api/audiobook/${this.audiobook.id}/cover`, { url: cover }).catch((error) => {
|
||||||
|
console.error('Failed to download cover from url', error)
|
||||||
|
if (error.response && error.response.data) {
|
||||||
|
this.$toast.error(error.response.data)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Update local cover url
|
||||||
|
const updatePayload = {
|
||||||
|
book: {
|
||||||
|
cover: cover
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
success = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
|
||||||
|
console.error('Failed to update', error)
|
||||||
|
if (error.response && error.response.data) {
|
||||||
|
this.$toast.error(error.response.data)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
|
if (success) {
|
||||||
console.error('Failed to update', error)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
this.isProcessing = false
|
|
||||||
if (updatedAudiobook) {
|
|
||||||
this.$toast.success('Update Successful')
|
this.$toast.success('Update Successful')
|
||||||
this.$emit('close')
|
this.$emit('close')
|
||||||
|
} else {
|
||||||
|
this.imageUrl = this.book.cover || ''
|
||||||
}
|
}
|
||||||
|
this.isProcessing = false
|
||||||
},
|
},
|
||||||
getSearchQuery() {
|
getSearchQuery() {
|
||||||
var searchQuery = `provider=openlibrary&title=${this.searchTitle}`
|
var searchQuery = `provider=openlibrary&title=${this.searchTitle}`
|
||||||
@@ -235,8 +263,24 @@ export default {
|
|||||||
this.isProcessing = false
|
this.isProcessing = false
|
||||||
this.hasSearched = true
|
this.hasSearched = true
|
||||||
},
|
},
|
||||||
setCover(cover) {
|
setCover(coverFile) {
|
||||||
this.updateCover(cover)
|
this.isProcessing = true
|
||||||
|
this.$axios
|
||||||
|
.$patch(`/api/audiobook/${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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="flex mt-2 -mx-1">
|
||||||
<div class="w-1/2 px-1">
|
<div class="w-1/2 px-1">
|
||||||
<ui-text-input-with-label v-model="details.narrarator" label="Narrarator" />
|
<ui-text-input-with-label v-model="details.narrator" label="Narrator" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,10 +56,14 @@
|
|||||||
<div class="flex px-4">
|
<div class="flex px-4">
|
||||||
<ui-btn v-if="userCanDelete" color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
|
<ui-btn v-if="userCanDelete" color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
|
||||||
|
|
||||||
<ui-tooltip text="(Root User Only) Save a NFO metadata file in your audiobooks directory" class="mx-4">
|
<ui-tooltip text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="ml-4">
|
||||||
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
|
<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>
|
||||||
|
|
||||||
|
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="ml-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>
|
||||||
|
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn type="submit">Submit</ui-btn>
|
<ui-btn type="submit">Submit</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,7 +88,7 @@ export default {
|
|||||||
subtitle: null,
|
subtitle: null,
|
||||||
description: null,
|
description: null,
|
||||||
author: null,
|
author: null,
|
||||||
narrarator: null,
|
narrator: null,
|
||||||
series: null,
|
series: null,
|
||||||
volumeNumber: null,
|
volumeNumber: null,
|
||||||
publishYear: null,
|
publishYear: null,
|
||||||
@@ -93,7 +97,8 @@ export default {
|
|||||||
newTags: [],
|
newTags: [],
|
||||||
resettingProgress: false,
|
resettingProgress: false,
|
||||||
isScrollable: false,
|
isScrollable: false,
|
||||||
savingMetadata: false
|
savingMetadata: false,
|
||||||
|
rescanning: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -133,9 +138,33 @@ export default {
|
|||||||
},
|
},
|
||||||
series() {
|
series() {
|
||||||
return this.$store.state.audiobooks.series
|
return this.$store.state.audiobooks.series
|
||||||
|
},
|
||||||
|
libraryId() {
|
||||||
|
return this.audiobook ? this.audiobook.libraryId : null
|
||||||
|
},
|
||||||
|
libraryScan() {
|
||||||
|
if (!this.libraryId) return null
|
||||||
|
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
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) {
|
saveMetadataComplete(result) {
|
||||||
this.savingMetadata = false
|
this.savingMetadata = false
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
@@ -179,7 +208,7 @@ export default {
|
|||||||
this.details.subtitle = this.book.subtitle
|
this.details.subtitle = this.book.subtitle
|
||||||
this.details.description = this.book.description
|
this.details.description = this.book.description
|
||||||
this.details.author = this.book.author
|
this.details.author = this.book.author
|
||||||
this.details.narrarator = this.book.narrarator
|
this.details.narrator = this.book.narrator
|
||||||
this.details.genres = this.book.genres || []
|
this.details.genres = this.book.genres || []
|
||||||
this.details.series = this.book.series
|
this.details.series = this.book.series
|
||||||
this.details.volumeNumber = this.book.volumeNumber
|
this.details.volumeNumber = this.book.volumeNumber
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
<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>
|
<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 class="w-full border border-black-200 p-4 my-4">
|
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div>
|
<div>
|
||||||
<!-- <p class="text-lg">{{ isSingleTrack ? 'Single Track' : 'M4B Audiobook File' }}</p> -->
|
|
||||||
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
|
<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>
|
<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>
|
||||||
@@ -14,7 +13,6 @@
|
|||||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</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>
|
<p v-if="singleDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||||
|
|
||||||
<!-- <a v-if="isSingleTrack" :href="`/local/${singleTrackPath}`" class="btn outline-none rounded-md shadow-md relative border border-gray-600 px-4 py-2 bg-primary">Download Track</a> -->
|
|
||||||
<ui-btn v-if="singleDownloadStatus !== $constants.DownloadStatus.READY" :loading="singleDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
|
<ui-btn v-if="singleDownloadStatus !== $constants.DownloadStatus.READY" :loading="singleDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<ui-btn @click="downloadWithProgress(singleAudioDownload)">Download</ui-btn>
|
<ui-btn @click="downloadWithProgress(singleAudioDownload)">Download</ui-btn>
|
||||||
@@ -46,7 +44,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full flex items-center justify-center absolute bottom-4 left-0 right-0 text-center">
|
<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>
|
<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>
|
||||||
|
|
||||||
@@ -91,6 +89,9 @@ export default {
|
|||||||
audiobookId() {
|
audiobookId() {
|
||||||
return this.audiobook ? this.audiobook.id : null
|
return this.audiobook ? this.audiobook.id : null
|
||||||
},
|
},
|
||||||
|
_audiobook() {
|
||||||
|
return this.audiobook || {}
|
||||||
|
},
|
||||||
downloads() {
|
downloads() {
|
||||||
return this.$store.getters['downloads/getDownloads'](this.audiobookId)
|
return this.$store.getters['downloads/getDownloads'](this.audiobookId)
|
||||||
},
|
},
|
||||||
@@ -122,6 +123,9 @@ export default {
|
|||||||
},
|
},
|
||||||
totalFiles() {
|
totalFiles() {
|
||||||
return this.audioFiles.length + this.otherFiles.length
|
return this.audioFiles.length + this.otherFiles.length
|
||||||
|
},
|
||||||
|
showM4bDownload() {
|
||||||
|
return !this._audiobook.isMissing && !this._audiobook.isIncomplete && this._audiobook.tracks.length
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||||
|
<tables-all-files-table :audiobook="audiobook" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
audiobook: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,38 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||||
<div class="flex mb-4">
|
<template v-if="hasTracks">
|
||||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`">
|
<div class="w-full bg-primary px-4 py-2 flex items-center">
|
||||||
<ui-btn color="primary">Edit Track Order</ui-btn>
|
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||||
</nuxt-link>
|
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<table class="text-sm tracksTable">
|
<div class="flex-grow" />
|
||||||
<tr class="font-book">
|
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||||
<th>#</th>
|
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`" class="mr-4">
|
||||||
<th class="text-left">Filename</th>
|
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
||||||
<th class="text-left">Size</th>
|
</nuxt-link>
|
||||||
<th class="text-left">Duration</th>
|
</div>
|
||||||
<th v-if="showDownload" class="text-center">Download</th>
|
<table class="text-sm tracksTable">
|
||||||
</tr>
|
<tr class="font-book">
|
||||||
<template v-for="track in tracks">
|
<th>#</th>
|
||||||
<tr :key="track.index">
|
<th class="text-left">Filename</th>
|
||||||
<td class="text-center">
|
<th class="text-left">Size</th>
|
||||||
<p>{{ track.index }}</p>
|
<th class="text-left">Duration</th>
|
||||||
</td>
|
<th v-if="showDownload" class="text-center">Download</th>
|
||||||
<td class="font-book">
|
|
||||||
{{ 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="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
<template v-for="track in tracksCleaned">
|
||||||
</table>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -47,7 +53,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
tracks: null,
|
tracks: null,
|
||||||
audioFiles: null
|
showFullPath: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -59,6 +65,26 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
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() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
},
|
||||||
@@ -70,11 +96,13 @@ export default {
|
|||||||
},
|
},
|
||||||
showDownload() {
|
showDownload() {
|
||||||
return this.userCanDownload && !this.isMissing
|
return this.userCanDownload && !this.isMissing
|
||||||
|
},
|
||||||
|
hasTracks() {
|
||||||
|
return this.audiobook.tracks.length
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
init() {
|
init() {
|
||||||
this.audioFiles = this.audiobook.audioFiles
|
|
||||||
this.tracks = this.audiobook.tracks
|
this.tracks = this.audiobook.tracks
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full px-4 py-2 mb-12">
|
||||||
|
<div class="flex items-center py-1 mb-2">
|
||||||
|
<span v-show="showDirectoryPicker" 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">
|
||||||
|
<ui-text-input-with-label v-model="name" label="Library Name" />
|
||||||
|
|
||||||
|
<div class="w-full py-4">
|
||||||
|
<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">
|
||||||
|
<!-- <ui-text-input :value="folder.fullPath" type="text" class="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="folder.fullPath" 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>
|
||||||
|
</div>
|
||||||
|
<p v-if="!folders.length" class="text-sm text-gray-300 px-1 py-2">No folders</p>
|
||||||
|
<ui-btn class="w-full mt-2" color="primary" @click="showDirectoryPicker = true">Browse for Folder</ui-btn>
|
||||||
|
</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>
|
||||||
|
<modals-libraries-folder-chooser v-else :paths="folderPaths" @select="selectFolder" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
library: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
processing: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
folders: [],
|
||||||
|
showDirectoryPicker: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
title() {
|
||||||
|
if (this.showDirectoryPicker) return 'Choose a Folder'
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
folderPaths() {
|
||||||
|
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(',')
|
||||||
|
console.log(newfolderpaths)
|
||||||
|
console.log(origfolderpaths)
|
||||||
|
console.log(newfolderpaths === origfolderpaths)
|
||||||
|
|
||||||
|
return newfolderpaths === origfolderpaths && this.name === this.library.name
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
removeFolder(folder) {
|
||||||
|
this.folders = this.folders.filter((f) => f.fullPath !== folder.fullPath)
|
||||||
|
},
|
||||||
|
backArrowPress() {
|
||||||
|
if (this.showDirectoryPicker) {
|
||||||
|
this.showDirectoryPicker = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.name = this.library ? this.library.name : ''
|
||||||
|
this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
|
||||||
|
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,
|
||||||
|
folders: this.folders
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit('update:processing', true)
|
||||||
|
this.$axios
|
||||||
|
.$patch(`/api/library/${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,
|
||||||
|
folders: this.folders
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit('update:processing', true)
|
||||||
|
this.$axios
|
||||||
|
.$post('/api/library', 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() {
|
||||||
|
console.log('Mounted edit library')
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div v-if="allFolders.length" class="flex bg-primary bg-opacity-50 p-4">
|
||||||
|
<div class="w-1/2 border-r border-bg">
|
||||||
|
<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>
|
||||||
|
<p class="text-base font-mono px-2">..</p>
|
||||||
|
</div>
|
||||||
|
<div v-for="dir in _directories" :key="dir.path" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" :class="dir.className" @click="selectDir(dir)">
|
||||||
|
<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>
|
||||||
|
<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 class="w-1/2">
|
||||||
|
<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>
|
||||||
|
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="loadingFolders" class="py-12 text-center">
|
||||||
|
<p>Loading folders...</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="py-12 text-center">
|
||||||
|
<p class="text-lg mb-2">No Folders Available</p>
|
||||||
|
<p class="text-gray-300">Note: folders already mapped will not be shown</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute bottom-0 left-0 w-full py-4 px-8">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
paths: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loadingFolders: false,
|
||||||
|
allFolders: [],
|
||||||
|
directories: [],
|
||||||
|
selectedPath: '',
|
||||||
|
selectedFullPath: '',
|
||||||
|
subdirs: [],
|
||||||
|
level: 0,
|
||||||
|
currentDir: null,
|
||||||
|
previousDir: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
_directories() {
|
||||||
|
return this.directories.map((d) => {
|
||||||
|
console.log('Directories', d)
|
||||||
|
var isUsed = !!this.paths.find((path) => path.endsWith(d.path))
|
||||||
|
var isSelected = d.path === this.selectedPath
|
||||||
|
var classes = []
|
||||||
|
if (isSelected) classes.push('dir-selected')
|
||||||
|
if (isUsed) classes.push('dir-used')
|
||||||
|
return {
|
||||||
|
isUsed,
|
||||||
|
isSelected,
|
||||||
|
className: classes.join(' '),
|
||||||
|
...d
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
_subdirs() {
|
||||||
|
return this.subdirs.map((d) => {
|
||||||
|
var isUsed = !!this.paths.find((path) => path.endsWith(d.path))
|
||||||
|
var classes = []
|
||||||
|
if (isUsed) classes.push('dir-used')
|
||||||
|
return {
|
||||||
|
isUsed,
|
||||||
|
className: classes.join(' '),
|
||||||
|
...d
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
goBack() {
|
||||||
|
var splitPaths = this.selectedPath.split('\\').slice(1)
|
||||||
|
var prev = splitPaths.slice(0, -1).join('\\')
|
||||||
|
|
||||||
|
var currDirs = this.allFolders
|
||||||
|
for (let i = 0; i < splitPaths.length; i++) {
|
||||||
|
var _dir = currDirs.find((dir) => dir.dirname === splitPaths[i])
|
||||||
|
if (_dir && _dir.path.slice(1) === prev) {
|
||||||
|
this.directories = currDirs
|
||||||
|
this.selectDir(_dir)
|
||||||
|
return
|
||||||
|
} else if (_dir) {
|
||||||
|
currDirs = _dir.dirs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectDir(dir) {
|
||||||
|
if (dir.isUsed) return
|
||||||
|
this.selectedPath = dir.path
|
||||||
|
this.selectedFullPath = dir.fullPath
|
||||||
|
this.level = dir.level
|
||||||
|
this.subdirs = dir.dirs
|
||||||
|
},
|
||||||
|
selectSubDir(dir) {
|
||||||
|
if (dir.isUsed) return
|
||||||
|
this.selectedPath = dir.path
|
||||||
|
this.selectedFullPath = dir.fullPath
|
||||||
|
this.level = dir.level
|
||||||
|
this.directories = this.subdirs
|
||||||
|
this.subdirs = dir.dirs
|
||||||
|
},
|
||||||
|
selectFolder() {
|
||||||
|
if (!this.selectedPath) {
|
||||||
|
console.error('No Selected path')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.paths.find((p) => p.startsWith(this.selectedFullPath))) {
|
||||||
|
this.$toast.error(`Oops, you cannot add a parent directory of a folder already added`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.$emit('select', this.selectedFullPath)
|
||||||
|
this.selectedPath = ''
|
||||||
|
this.selectedFullPath = ''
|
||||||
|
},
|
||||||
|
async init() {
|
||||||
|
this.loadingFolders = true
|
||||||
|
this.allFolders = await this.$store.dispatch('libraries/loadFolders')
|
||||||
|
this.loadingFolders = false
|
||||||
|
|
||||||
|
this.directories = this.allFolders
|
||||||
|
this.subdirs = []
|
||||||
|
this.selectedPath = ''
|
||||||
|
this.selectedFullPath = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
console.log('folder chooser mounted')
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dir-item.dir-selected {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.dir-item.dir-used {
|
||||||
|
background-color: rgba(255, 25, 0, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full px-4 h-12 border border-white border-opacity-10 flex items-center relative -mt-px" :class="selected ? 'bg-primary bg-opacity-50' : 'hover:bg-primary hover:bg-opacity-25'" @mouseover="mouseover = true" @mouseleave="mouseover = false">
|
||||||
|
<div v-show="selected" class="absolute top-0 left-0 h-full w-0.5 bg-warning z-10" />
|
||||||
|
<svg v-if="!libraryScan" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else viewBox="0 0 24 24" class="h-6 w-6 text-white text-opacity-50 animate-spin">
|
||||||
|
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" @click.stop="scan">Scan</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="!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>
|
||||||
|
<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">
|
||||||
|
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
library: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
selected: Boolean,
|
||||||
|
showEdit: Boolean,
|
||||||
|
dragging: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
mouseover: false,
|
||||||
|
isDeleting: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isHovering() {
|
||||||
|
return this.mouseover && !this.dragging
|
||||||
|
},
|
||||||
|
isMain() {
|
||||||
|
return this.library.id === 'main'
|
||||||
|
},
|
||||||
|
libraryScan() {
|
||||||
|
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
|
||||||
|
},
|
||||||
|
canEdit() {
|
||||||
|
return this.$store.getters['user/getIsRoot']
|
||||||
|
},
|
||||||
|
canDelete() {
|
||||||
|
return this.$store.getters['user/getIsRoot']
|
||||||
|
},
|
||||||
|
canScan() {
|
||||||
|
return this.$store.getters['user/getIsRoot']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
itemClicked() {
|
||||||
|
// this.$emit('click', this.library)
|
||||||
|
},
|
||||||
|
editClick() {
|
||||||
|
this.$emit('edit', this.library)
|
||||||
|
},
|
||||||
|
scan() {
|
||||||
|
this.$root.socket.emit('scan', this.library.id)
|
||||||
|
},
|
||||||
|
deleteClick() {
|
||||||
|
if (this.isMain) return
|
||||||
|
if (confirm(`Are you sure you want to permanently delete library "${this.library.name}"?`)) {
|
||||||
|
this.isDeleting = true
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/library/${this.library.id}`)
|
||||||
|
.then((data) => {
|
||||||
|
this.isDeleting = false
|
||||||
|
if (data.error) {
|
||||||
|
this.$toast.error(data.error)
|
||||||
|
} else {
|
||||||
|
this.$toast.success('Library deleted')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to delete library', error)
|
||||||
|
this.$toast.error('Failed to delete library')
|
||||||
|
this.isDeleting = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-40 opacity-0">
|
||||||
|
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||||
|
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
persistent: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: 500
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: 'unset'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
el: null,
|
||||||
|
content: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.setShow()
|
||||||
|
} else {
|
||||||
|
this.setHide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modalHeight() {
|
||||||
|
if (typeof this.height === 'string') {
|
||||||
|
return this.height
|
||||||
|
} else {
|
||||||
|
return this.height + 'px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modalWidth() {
|
||||||
|
return typeof this.width === 'string' ? this.width : this.width + 'px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setShow() {
|
||||||
|
document.body.appendChild(this.el)
|
||||||
|
setTimeout(() => {
|
||||||
|
this.content.style.transform = 'scale(1)'
|
||||||
|
}, 10)
|
||||||
|
document.documentElement.classList.add('modal-open')
|
||||||
|
},
|
||||||
|
setHide() {
|
||||||
|
this.content.style.transform = 'scale(0)'
|
||||||
|
this.el.remove()
|
||||||
|
document.documentElement.classList.remove('modal-open')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.el = this.$refs.wrapper
|
||||||
|
this.content = this.$refs.content
|
||||||
|
this.content.style.transform = 'scale(0)'
|
||||||
|
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
|
||||||
|
this.el.style.opacity = 1
|
||||||
|
this.el.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full">
|
||||||
|
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52">
|
||||||
|
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index)">
|
||||||
|
<p class="text-sm truncate">{{ file }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-10 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
|
||||||
|
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
|
||||||
|
<p class="text-xs">
|
||||||
|
<strong>{{ key }}</strong
|
||||||
|
>: {{ comicMetadata[key] }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="comicMetadata" class="absolute top-0 right-52 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showInfoMenu = !showInfoMenu">
|
||||||
|
<span class="material-icons text-xl">more</span>
|
||||||
|
</div>
|
||||||
|
<div class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" style="right: 156px" @mousedown.prevent @click.stop.prevent="showPageMenu = !showPageMenu">
|
||||||
|
<span class="material-icons text-xl">menu</span>
|
||||||
|
</div>
|
||||||
|
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
|
||||||
|
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-hidden m-auto comicwrapper relative">
|
||||||
|
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
|
||||||
|
<div class="flex items-center justify-center h-full w-1/2">
|
||||||
|
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
|
||||||
|
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
|
||||||
|
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-full flex justify-center">
|
||||||
|
<img v-if="mainImg" :src="mainImg" class="object-contain comicimg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
|
||||||
|
<ui-loading-indicator />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <div v-show="loading" class="w-screen h-screen absolute top-0 left-0 bg-black bg-opacity-20 flex items-center justify-center">
|
||||||
|
<ui-loading-indicator />
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Path from 'path'
|
||||||
|
import { Archive } from 'libarchive.js/main.js'
|
||||||
|
|
||||||
|
Archive.init({
|
||||||
|
workerUrl: '/libarchive/worker-bundle.js'
|
||||||
|
})
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
url: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
pages: null,
|
||||||
|
filesObject: null,
|
||||||
|
mainImg: null,
|
||||||
|
page: 0,
|
||||||
|
numPages: 0,
|
||||||
|
showPageMenu: false,
|
||||||
|
showInfoMenu: false,
|
||||||
|
loadTimeout: null,
|
||||||
|
loadedFirstPage: false,
|
||||||
|
comicMetadata: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
url: {
|
||||||
|
immediate: true,
|
||||||
|
handler() {
|
||||||
|
this.extract()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
comicMetadataKeys() {
|
||||||
|
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
|
||||||
|
},
|
||||||
|
canGoNext() {
|
||||||
|
return this.page < this.numPages - 1
|
||||||
|
},
|
||||||
|
canGoPrev() {
|
||||||
|
return this.page > 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickOutside() {
|
||||||
|
if (this.showPageMenu) this.showPageMenu = false
|
||||||
|
if (this.showInfoMenu) this.showInfoMenu = false
|
||||||
|
},
|
||||||
|
next() {
|
||||||
|
if (!this.canGoNext) return
|
||||||
|
this.setPage(this.page + 1)
|
||||||
|
},
|
||||||
|
prev() {
|
||||||
|
if (!this.canGoPrev) return
|
||||||
|
this.setPage(this.page - 1)
|
||||||
|
},
|
||||||
|
setPage(index) {
|
||||||
|
if (index < 0 || index > this.numPages - 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var filename = this.pages[index]
|
||||||
|
this.page = index
|
||||||
|
return this.extractFile(filename)
|
||||||
|
},
|
||||||
|
setLoadTimeout() {
|
||||||
|
this.loadTimeout = setTimeout(() => {
|
||||||
|
this.loading = true
|
||||||
|
}, 150)
|
||||||
|
},
|
||||||
|
extractFile(filename) {
|
||||||
|
return new Promise(async (resolve) => {
|
||||||
|
this.setLoadTimeout()
|
||||||
|
var file = await this.filesObject[filename].extract()
|
||||||
|
var reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
this.mainImg = e.target.result
|
||||||
|
this.loading = false
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
reader.onerror = (e) => {
|
||||||
|
console.error(e)
|
||||||
|
this.$toast.error('Read page file failed')
|
||||||
|
this.loading = false
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
clearTimeout(this.loadTimeout)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async extract() {
|
||||||
|
this.loading = true
|
||||||
|
console.log('Extracting', this.url)
|
||||||
|
|
||||||
|
var buff = await this.$axios.$get(this.url, {
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
const archive = await Archive.open(buff)
|
||||||
|
this.filesObject = await archive.getFilesObject()
|
||||||
|
var filenames = Object.keys(this.filesObject)
|
||||||
|
this.parseFilenames(filenames)
|
||||||
|
|
||||||
|
var xmlFile = filenames.find((f) => (Path.extname(f) || '').toLowerCase() === '.xml')
|
||||||
|
if (xmlFile) await this.extractXmlFile(xmlFile)
|
||||||
|
|
||||||
|
this.numPages = this.pages.length
|
||||||
|
|
||||||
|
if (this.pages.length) {
|
||||||
|
this.loading = false
|
||||||
|
await this.setPage(0)
|
||||||
|
this.loadedFirstPage = true
|
||||||
|
} else {
|
||||||
|
this.$toast.error('Unable to extract pages')
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async extractXmlFile(filename) {
|
||||||
|
console.log('extracting xml filename', filename)
|
||||||
|
try {
|
||||||
|
var file = await this.filesObject[filename].extract()
|
||||||
|
var reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
this.comicMetadata = this.$xmlToJson(e.target.result)
|
||||||
|
console.log('Metadata', this.comicMetadata)
|
||||||
|
}
|
||||||
|
reader.onerror = (e) => {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parseImageFilename(filename) {
|
||||||
|
var basename = Path.basename(filename, Path.extname(filename))
|
||||||
|
var numbersinpath = basename.match(/\d{1,4}/g)
|
||||||
|
if (!numbersinpath || !numbersinpath.length) {
|
||||||
|
return {
|
||||||
|
index: -1,
|
||||||
|
filename
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
index: Number(numbersinpath[numbersinpath.length - 1]),
|
||||||
|
filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
parseFilenames(filenames) {
|
||||||
|
const acceptableImages = ['.jpeg', '.jpg', '.png']
|
||||||
|
var imageFiles = filenames.filter((f) => {
|
||||||
|
return acceptableImages.includes((Path.extname(f) || '').toLowerCase())
|
||||||
|
})
|
||||||
|
var imageFileObjs = imageFiles.map((img) => {
|
||||||
|
return this.parseImageFilename(img)
|
||||||
|
})
|
||||||
|
|
||||||
|
var imagesWithNum = imageFileObjs.filter((i) => i.index >= 0)
|
||||||
|
var orderedImages = imagesWithNum.sort((a, b) => a.index - b.index).map((i) => i.filename)
|
||||||
|
var noNumImages = imageFileObjs.filter((i) => i.index < 0)
|
||||||
|
orderedImages = orderedImages.concat(noNumImages.map((i) => i.filename))
|
||||||
|
|
||||||
|
this.pages = orderedImages
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pagemenu {
|
||||||
|
max-height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
.comicimg {
|
||||||
|
height: calc(100vh - 40px);
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
.comicwrapper {
|
||||||
|
width: 100vw;
|
||||||
|
height: calc(100vh - 40px);
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-full w-full">
|
||||||
|
<div class="h-full flex items-center">
|
||||||
|
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden justify-center">
|
||||||
|
<span v-show="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
|
||||||
|
</div>
|
||||||
|
<div id="frame" class="w-full" style="height: 650px">
|
||||||
|
<div id="viewer" class="border border-gray-100 bg-white shadow-md"></div>
|
||||||
|
|
||||||
|
<div class="py-4 flex justify-center" style="height: 50px">
|
||||||
|
<p>{{ progress }}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="width: 100px; max-width: 100px" class="h-full flex items-center justify-center overflow-x-hidden">
|
||||||
|
<span v-show="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import ePub from 'epubjs'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
url: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
book: null,
|
||||||
|
rendition: null,
|
||||||
|
chapters: [],
|
||||||
|
title: '',
|
||||||
|
author: '',
|
||||||
|
progress: 0,
|
||||||
|
hasNext: true,
|
||||||
|
hasPrev: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {
|
||||||
|
changedChapter() {
|
||||||
|
if (this.rendition) {
|
||||||
|
this.rendition.display(this.selectedChapter)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
prev() {
|
||||||
|
if (this.rendition) {
|
||||||
|
this.rendition.prev()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
next() {
|
||||||
|
if (this.rendition) {
|
||||||
|
this.rendition.next()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyUp() {
|
||||||
|
if ((e.keyCode || e.which) == 37) {
|
||||||
|
this.prev()
|
||||||
|
} else if ((e.keyCode || e.which) == 39) {
|
||||||
|
this.next()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initEpub() {
|
||||||
|
// var book = ePub(this.url, {
|
||||||
|
// requestHeaders: {
|
||||||
|
// Authorization: `Bearer ${this.userToken}`
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
var book = ePub(this.url)
|
||||||
|
this.book = book
|
||||||
|
|
||||||
|
this.rendition = book.renderTo('viewer', {
|
||||||
|
width: window.innerWidth - 200,
|
||||||
|
height: 600,
|
||||||
|
ignoreClass: 'annotator-hl',
|
||||||
|
manager: 'continuous',
|
||||||
|
spread: 'always'
|
||||||
|
})
|
||||||
|
var displayed = this.rendition.display()
|
||||||
|
|
||||||
|
book.ready
|
||||||
|
.then(() => {
|
||||||
|
console.log('Book ready')
|
||||||
|
return book.locations.generate(1600)
|
||||||
|
})
|
||||||
|
.then((locations) => {
|
||||||
|
// console.log('Loaded locations', locations)
|
||||||
|
// Wait for book to be rendered to get current page
|
||||||
|
displayed.then(() => {
|
||||||
|
// Get the current CFI
|
||||||
|
var currentLocation = this.rendition.currentLocation()
|
||||||
|
if (!currentLocation.start) {
|
||||||
|
console.error('No Start', currentLocation)
|
||||||
|
} else {
|
||||||
|
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi)
|
||||||
|
// console.log('current page', currentPage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
book.loaded.navigation.then((toc) => {
|
||||||
|
var _chapters = []
|
||||||
|
toc.forEach((chapter) => {
|
||||||
|
_chapters.push(chapter)
|
||||||
|
})
|
||||||
|
this.chapters = _chapters
|
||||||
|
})
|
||||||
|
book.loaded.metadata.then((metadata) => {
|
||||||
|
this.author = metadata.creator
|
||||||
|
this.title = metadata.title
|
||||||
|
})
|
||||||
|
|
||||||
|
this.rendition.on('keyup', this.keyUp)
|
||||||
|
|
||||||
|
this.rendition.on('relocated', (location) => {
|
||||||
|
var percent = book.locations.percentageFromCfi(location.start.cfi)
|
||||||
|
this.progress = Math.floor(percent * 100)
|
||||||
|
|
||||||
|
this.hasNext = !location.atEnd
|
||||||
|
this.hasPrev = !location.atStart
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.initEpub()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full">
|
||||||
|
<div class="h-full max-h-full w-full">
|
||||||
|
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-12 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
|
||||||
|
<iframe title="html-viewer" width="100%"> Loading </iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import MobiParser from '@/assets/ebooks/mobi.js'
|
||||||
|
import HtmlParser from '@/assets/ebooks/htmlParser.js'
|
||||||
|
import defaultCss from '@/assets/ebooks/basic.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
url: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {
|
||||||
|
addHtmlCss() {
|
||||||
|
let iframe = document.getElementsByTagName('iframe')[0]
|
||||||
|
if (!iframe) return
|
||||||
|
let doc = iframe.contentDocument
|
||||||
|
if (!doc) return
|
||||||
|
let style = doc.createElement('style')
|
||||||
|
style.id = 'default-style'
|
||||||
|
style.textContent = defaultCss
|
||||||
|
doc.head.appendChild(style)
|
||||||
|
},
|
||||||
|
handleIFrameHeight(iFrame) {
|
||||||
|
const isElement = (obj) => !!(obj && obj.nodeType === 1)
|
||||||
|
|
||||||
|
var body = iFrame.contentWindow.document.body,
|
||||||
|
html = iFrame.contentWindow.document.documentElement
|
||||||
|
iFrame.height = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight) * 2
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
let lastchild = body.lastElementChild
|
||||||
|
let lastEle = body.lastChild
|
||||||
|
|
||||||
|
let itemAs = body.querySelectorAll('a')
|
||||||
|
let itemPs = body.querySelectorAll('p')
|
||||||
|
let lastItemA = itemAs[itemAs.length - 1]
|
||||||
|
let lastItemP = itemPs[itemPs.length - 1]
|
||||||
|
let lastItem
|
||||||
|
if (isElement(lastItemA) && isElement(lastItemP)) {
|
||||||
|
if (lastItemA.clientHeight + lastItemA.offsetTop > lastItemP.clientHeight + lastItemP.offsetTop) {
|
||||||
|
lastItem = lastItemA
|
||||||
|
} else {
|
||||||
|
lastItem = lastItemP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastchild && !lastItem && !lastEle) return
|
||||||
|
if (lastEle.nodeType === 3 && !lastchild && !lastItem) return
|
||||||
|
|
||||||
|
let nodeHeight = 0
|
||||||
|
if (lastEle.nodeType === 3 && document.createRange) {
|
||||||
|
let range = document.createRange()
|
||||||
|
range.selectNodeContents(lastEle)
|
||||||
|
if (range.getBoundingClientRect) {
|
||||||
|
let rect = range.getBoundingClientRect()
|
||||||
|
if (rect) {
|
||||||
|
nodeHeight = rect.bottom - rect.top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var lastChildHeight = isElement(lastchild) ? lastchild.clientHeight + lastchild.offsetTop : 0
|
||||||
|
var lastEleHeight = isElement(lastEle) ? lastEle.clientHeight + lastEle.offsetTop : 0
|
||||||
|
var lastItemHeight = isElement(lastItem) ? lastItem.clientHeight + lastItem.offsetTop : 0
|
||||||
|
iFrame.height = Math.max(lastChildHeight, lastEleHeight, lastItemHeight) + 100 + nodeHeight
|
||||||
|
}, 500)
|
||||||
|
},
|
||||||
|
async initMobi() {
|
||||||
|
// Fetch mobi file as blob
|
||||||
|
var buff = await this.$axios.$get(this.url, {
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
var reader = new FileReader()
|
||||||
|
reader.onload = async (event) => {
|
||||||
|
var file_content = event.target.result
|
||||||
|
|
||||||
|
let mobiFile = new MobiParser(file_content)
|
||||||
|
|
||||||
|
let content = await mobiFile.render()
|
||||||
|
let htmlParser = new HtmlParser(new DOMParser().parseFromString(content.outerHTML, 'text/html'))
|
||||||
|
var anchoredDoc = htmlParser.getAnchoredDoc()
|
||||||
|
|
||||||
|
let iFrame = document.getElementsByTagName('iframe')[0]
|
||||||
|
iFrame.contentDocument.body.innerHTML = anchoredDoc.documentElement.outerHTML
|
||||||
|
|
||||||
|
// Add css
|
||||||
|
let style = iFrame.contentDocument.createElement('style')
|
||||||
|
style.id = 'default-style'
|
||||||
|
style.textContent = defaultCss
|
||||||
|
iFrame.contentDocument.head.appendChild(style)
|
||||||
|
|
||||||
|
this.handleIFrameHeight(iFrame)
|
||||||
|
}
|
||||||
|
reader.readAsArrayBuffer(buff)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.initMobi()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ebook-viewer {
|
||||||
|
height: calc(100% - 96px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full pt-20 relative">
|
||||||
|
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
|
||||||
|
<div class="flex items-center justify-center h-full w-1/2">
|
||||||
|
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
|
||||||
|
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
|
||||||
|
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center">
|
||||||
|
<p class="font-mono">{{ page }} / {{ numPages }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
|
||||||
|
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
|
||||||
|
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="url" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- <div class="text-center py-2 text-lg">
|
||||||
|
<p>{{ page }} / {{ numPages }}</p>
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import pdf from 'vue-pdf'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
pdf
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
url: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
rotate: 0,
|
||||||
|
loadedRatio: 0,
|
||||||
|
page: 1,
|
||||||
|
numPages: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
pdfWidth() {
|
||||||
|
return this.pdfHeight * 0.6667
|
||||||
|
},
|
||||||
|
pdfHeight() {
|
||||||
|
return window.innerHeight - 120
|
||||||
|
},
|
||||||
|
canGoNext() {
|
||||||
|
return this.page < this.numPages
|
||||||
|
},
|
||||||
|
canGoPrev() {
|
||||||
|
return this.page > 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
numPagesLoaded(e) {
|
||||||
|
this.numPages = e
|
||||||
|
},
|
||||||
|
prev() {
|
||||||
|
if (this.page <= 1) return
|
||||||
|
this.page--
|
||||||
|
},
|
||||||
|
next() {
|
||||||
|
if (this.page >= this.numPages) return
|
||||||
|
this.page++
|
||||||
|
},
|
||||||
|
error(err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-50 bg-primary text-white">
|
||||||
|
<div class="absolute top-4 right-4 z-20">
|
||||||
|
<span class="material-icons cursor-pointer text-4xl" @click="close">close</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute top-4 left-4 font-book">
|
||||||
|
<h1 class="text-2xl mb-1">{{ abTitle }}</h1>
|
||||||
|
<p v-if="abAuthor">by {{ abAuthor }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" />
|
||||||
|
|
||||||
|
<div class="absolute bottom-2 left-2">{{ ebookType }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
ebookType: '',
|
||||||
|
ebookUrl: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.showEReader
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$store.commit('setShowEReader', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
componentName() {
|
||||||
|
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
||||||
|
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
|
||||||
|
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
|
||||||
|
else if (this.ebookType === 'comic') return 'readers-comic-reader'
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
abTitle() {
|
||||||
|
return this.selectedAudiobook.book.title
|
||||||
|
},
|
||||||
|
abAuthor() {
|
||||||
|
return this.selectedAudiobook.book.author
|
||||||
|
},
|
||||||
|
selectedAudiobook() {
|
||||||
|
return this.$store.state.selectedAudiobook
|
||||||
|
},
|
||||||
|
libraryId() {
|
||||||
|
return this.selectedAudiobook.libraryId
|
||||||
|
},
|
||||||
|
folderId() {
|
||||||
|
return this.selectedAudiobook.folderId
|
||||||
|
},
|
||||||
|
ebooks() {
|
||||||
|
return this.selectedAudiobook.ebooks || []
|
||||||
|
},
|
||||||
|
epubEbook() {
|
||||||
|
return this.ebooks.find((eb) => eb.ext === '.epub')
|
||||||
|
},
|
||||||
|
mobiEbook() {
|
||||||
|
return this.ebooks.find((eb) => eb.ext === '.mobi' || eb.ext === '.azw3')
|
||||||
|
},
|
||||||
|
pdfEbook() {
|
||||||
|
return this.ebooks.find((eb) => eb.ext === '.pdf')
|
||||||
|
},
|
||||||
|
comicEbook() {
|
||||||
|
return this.ebooks.find((eb) => eb.ext === '.cbz' || eb.ext === '.cbr')
|
||||||
|
},
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
selectedAudiobookFile() {
|
||||||
|
return this.$store.state.selectedAudiobookFile
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getEbookUrl(path) {
|
||||||
|
return `/ebook/${this.libraryId}/${this.folderId}/${path}`
|
||||||
|
},
|
||||||
|
hotkey(action) {
|
||||||
|
console.log('Reader hotkey', action)
|
||||||
|
if (!this.$refs.readerComponent) return
|
||||||
|
|
||||||
|
if (action === 'ArrowRight') {
|
||||||
|
if (this.$refs.readerComponent.next) this.$refs.readerComponent.next()
|
||||||
|
} else if (action === 'ArrowLeft') {
|
||||||
|
if (this.$refs.readerComponent.prev) this.$refs.readerComponent.prev()
|
||||||
|
} else if (action === 'Escape') {
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
registerListeners() {
|
||||||
|
this.$eventBus.$on('reader-hotkey', this.hotkey)
|
||||||
|
},
|
||||||
|
unregisterListeners() {
|
||||||
|
this.$eventBus.$off('reader-hotkey', this.hotkey)
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
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'
|
||||||
|
// this.initMobi()
|
||||||
|
} else if (this.selectedAudiobookFile.ext === '.epub') {
|
||||||
|
this.ebookType = 'epub'
|
||||||
|
// this.initEpub()
|
||||||
|
} 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)
|
||||||
|
// this.initEpub()
|
||||||
|
} else if (this.mobiEbook) {
|
||||||
|
this.ebookType = 'mobi'
|
||||||
|
this.ebookUrl = this.getEbookUrl(this.mobiEbook.path)
|
||||||
|
// this.initMobi()
|
||||||
|
} 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() {
|
||||||
|
this.unregisterListeners()
|
||||||
|
this.show = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (this.show) this.init()
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.unregisterListeners()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* @import url(@/assets/calibre/basic.css); */
|
||||||
|
.ebook-viewer {
|
||||||
|
height: calc(100% - 96px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full my-2">
|
||||||
|
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer">
|
||||||
|
<p class="pr-4">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>
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<div class="flex py-4">
|
||||||
|
<ui-file-input ref="fileInput" class="mr-2" accept=".audiobookshelf" @change="backupUploaded">Upload Backup</ui-file-input>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn :loading="isBackingUp" @click="clickCreateBackup">Create Backup</ui-btn>
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<table id="backups">
|
||||||
|
<tr>
|
||||||
|
<th>File</th>
|
||||||
|
<th class="w-56">Datetime</th>
|
||||||
|
<th class="w-28">Size</th>
|
||||||
|
<th class="w-36"></th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="backup in backups" :key="backup.id">
|
||||||
|
<td>
|
||||||
|
<p class="truncate">/{{ backup.path.replace(/\\/g, '/') }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="font-sans">{{ backup.datePretty }}</td>
|
||||||
|
<td class="font-mono">{{ $bytesPretty(backup.fileSize) }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="w-full flex items-center justify-center">
|
||||||
|
<ui-btn 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-0.5 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
|
||||||
|
<!-- <span class="material-icons text-xl hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="downloadBackup">download</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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!backups.length" class="staticrow">
|
||||||
|
<td colspan="4" class="text-lg">No Backups</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div v-show="processing" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-25 flex items-center justify-center">
|
||||||
|
<ui-loading-indicator />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<prompt-dialog v-model="showConfirmApply" :width="675">
|
||||||
|
<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-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">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>
|
||||||
|
<div class="flex px-1 items-center">
|
||||||
|
<ui-btn color="primary" @click="showConfirmApply = false">Nevermind</ui-btn>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn color="success" @click="confirm">Apply Backup</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</prompt-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showConfirmApply: false,
|
||||||
|
selectedBackup: null,
|
||||||
|
isBackingUp: false,
|
||||||
|
processing: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
backups() {
|
||||||
|
return this.$store.state.backups || []
|
||||||
|
},
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
confirm() {
|
||||||
|
this.showConfirmApply = false
|
||||||
|
this.$root.socket.once('apply_backup_complete', this.applyBackupComplete)
|
||||||
|
this.$root.socket.emit('apply_backup', this.selectedBackup.id)
|
||||||
|
},
|
||||||
|
deleteBackupClick(backup) {
|
||||||
|
if (confirm(`Are you sure you want to delete backup for ${backup.datePretty}?`)) {
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/backup/${backup.id}`)
|
||||||
|
.then((backups) => {
|
||||||
|
console.log('Backup deleted', backups)
|
||||||
|
this.$store.commit('setBackups', backups)
|
||||||
|
this.$toast.success(`Backup deleted`)
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
this.$toast.error('Failed to delete backup')
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
applyBackupComplete(success) {
|
||||||
|
if (success) {
|
||||||
|
// this.$toast.success('Backup Applied, refresh the page')
|
||||||
|
location.replace('/config?backup=1')
|
||||||
|
} else {
|
||||||
|
this.$toast.error('Failed to apply backup')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
applyBackup(backup) {
|
||||||
|
this.selectedBackup = backup
|
||||||
|
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() {
|
||||||
|
this.isBackingUp = true
|
||||||
|
this.$root.socket.once('backup_complete', this.backupComplete)
|
||||||
|
this.$root.socket.emit('create_backup')
|
||||||
|
},
|
||||||
|
backupUploaded(file) {
|
||||||
|
var form = new FormData()
|
||||||
|
form.set('file', file)
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
|
||||||
|
this.$axios
|
||||||
|
.$post('/api/backup/upload', form)
|
||||||
|
.then((result) => {
|
||||||
|
console.log('Upload backup result', result)
|
||||||
|
this.$store.commit('setBackups', result)
|
||||||
|
this.$toast.success('Backup upload success')
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
var errorMessage = error.response && error.response.data ? error.response.data : 'Failed to upload backup'
|
||||||
|
this.$toast.error(errorMessage)
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (this.$route.query.backup) {
|
||||||
|
this.$toast.success('Backup applied successfully')
|
||||||
|
this.$router.replace('/config')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#backups {
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#backups td,
|
||||||
|
#backups th {
|
||||||
|
border: 1px solid #2e2e2e;
|
||||||
|
padding: 8px 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
#backups tr.staticrow td {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#backups tr:nth-child(even) {
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#backups tr:not(.staticrow):hover {
|
||||||
|
background-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
#backups th {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-top: 5px;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<div id="librariesTable" class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<h1 class="text-xl">Libraries</h1>
|
||||||
|
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddLibrary">
|
||||||
|
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<draggable v-model="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
||||||
|
<!-- <transition-group type="transition" :name="!drag ? 'flip-list' : null"> -->
|
||||||
|
<template v-for="library in libraryCopies">
|
||||||
|
<modals-libraries-library-item :key="library.id" :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" class="item" />
|
||||||
|
</template>
|
||||||
|
<!-- </transition-group> -->
|
||||||
|
</draggable>
|
||||||
|
<modals-edit-library-modal v-model="showLibraryModal" :library="selectedLibrary" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
draggable
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
libraryCopies: [],
|
||||||
|
currentOrder: [],
|
||||||
|
showLibraryModal: false,
|
||||||
|
selectedLibrary: null,
|
||||||
|
drag: false,
|
||||||
|
dragOptions: {
|
||||||
|
animation: 200,
|
||||||
|
group: 'description',
|
||||||
|
ghostClass: 'ghost'
|
||||||
|
},
|
||||||
|
orderTimeout: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
currentLibrary() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibrary']
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.currentLibrary ? this.currentLibrary.id : null
|
||||||
|
},
|
||||||
|
libraries() {
|
||||||
|
return this.$store.getters['libraries/getSortedLibraries']()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
startDrag() {
|
||||||
|
this.drag = true
|
||||||
|
clearTimeout(this.orderTimeout)
|
||||||
|
},
|
||||||
|
endDrag() {
|
||||||
|
this.drag = false
|
||||||
|
this.checkOrder()
|
||||||
|
console.log('DRAG END')
|
||||||
|
},
|
||||||
|
checkOrder() {
|
||||||
|
clearTimeout(this.orderTimeout)
|
||||||
|
this.orderTimeout = setTimeout(() => {
|
||||||
|
this.saveOrder()
|
||||||
|
}, 500)
|
||||||
|
},
|
||||||
|
saveOrder() {
|
||||||
|
var _newOrder = 1
|
||||||
|
var currOrder = this.libraries.map((lib) => lib.id).join(',')
|
||||||
|
var libraryOrderData = this.libraryCopies.map((library) => {
|
||||||
|
return {
|
||||||
|
newOrder: _newOrder++,
|
||||||
|
oldOrder: library.displayOrder,
|
||||||
|
id: library.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
var newOrder = libraryOrderData.map((lib) => lib.id).join(',')
|
||||||
|
if (currOrder !== newOrder) {
|
||||||
|
this.$axios.$patch('/api/libraries/order', libraryOrderData).then((libraries) => {
|
||||||
|
if (libraries && libraries.length) {
|
||||||
|
this.$toast.success('Library order saved', { timeout: 1500 })
|
||||||
|
this.$store.commit('libraries/set', libraries)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async setLibrary(library) {
|
||||||
|
await this.$store.dispatch('libraries/fetch', library.id)
|
||||||
|
this.$router.push(`/library/${library.id}`)
|
||||||
|
},
|
||||||
|
clickAddLibrary() {
|
||||||
|
this.selectedLibrary = null
|
||||||
|
this.showLibraryModal = true
|
||||||
|
},
|
||||||
|
editLibrary(library) {
|
||||||
|
this.selectedLibrary = library
|
||||||
|
this.showLibraryModal = true
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.libraryCopies = this.libraries.map((lib) => {
|
||||||
|
return { ...lib }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
librariesUpdated() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$store.commit('libraries/addListener', { id: 'libraries-table', meth: this.librariesUpdated })
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$store.commit('libraries/removeListener', 'libraries-table')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -2,11 +2,14 @@
|
|||||||
<div class="w-full my-2">
|
<div class="w-full my-2">
|
||||||
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||||
<p class="pr-4">Other Files</p>
|
<p class="pr-4">Other Files</p>
|
||||||
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ files.length }}</span>
|
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||||
|
<span class="text-sm font-mono">{{ files.length }}</span>
|
||||||
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<!-- <nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
<!-- <nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
||||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
||||||
</nuxt-link> -->
|
</nuxt-link> -->
|
||||||
|
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2" @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>
|
||||||
</div>
|
</div>
|
||||||
@@ -15,16 +18,23 @@
|
|||||||
<div class="w-full" v-show="showFiles">
|
<div class="w-full" v-show="showFiles">
|
||||||
<table class="text-sm tracksTable">
|
<table class="text-sm tracksTable">
|
||||||
<tr class="font-book">
|
<tr class="font-book">
|
||||||
<th class="text-left">Path</th>
|
<th class="text-left px-4">Path</th>
|
||||||
<th class="text-left">Filetype</th>
|
<th class="text-left px-4 w-24">Filetype</th>
|
||||||
|
<th v-if="userCanDownload && !isMissing" class="text-center w-20">Download</th>
|
||||||
</tr>
|
</tr>
|
||||||
<template v-for="file in files">
|
<template v-for="file in otherFilesCleaned">
|
||||||
<tr :key="file.path">
|
<tr :key="file.path">
|
||||||
<td class="font-book pl-2">
|
<td class="font-book pl-2">
|
||||||
{{ file.path }}
|
{{ showFullPath ? file.fullPath : file.path }}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-xs">
|
<td class="text-xs">
|
||||||
<p>{{ file.filetype }}</p>
|
<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>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<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>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
@@ -41,15 +51,52 @@ export default {
|
|||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
audiobookId: String
|
audiobook: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showFiles: false
|
showFiles: false,
|
||||||
|
showFullPath: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
isMissing() {
|
||||||
|
return this.audiobook.isMissing
|
||||||
|
},
|
||||||
|
userCanDownload() {
|
||||||
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {},
|
|
||||||
methods: {
|
methods: {
|
||||||
|
readEbookClick(file) {
|
||||||
|
this.$store.commit('showEReaderForFile', { audiobook: this.audiobook, file })
|
||||||
|
},
|
||||||
clickBar() {
|
clickBar() {
|
||||||
this.showFiles = !this.showFiles
|
this.showFiles = !this.showFiles
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
<div class="w-full my-2">
|
<div class="w-full my-2">
|
||||||
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||||
<p class="pr-4">Audio Tracks</p>
|
<p class="pr-4">Audio Tracks</p>
|
||||||
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span>
|
<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>
|
||||||
|
<!-- <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" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
||||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -15,20 +19,18 @@
|
|||||||
<div class="w-full" v-show="showTracks">
|
<div class="w-full" v-show="showTracks">
|
||||||
<table class="text-sm tracksTable">
|
<table class="text-sm tracksTable">
|
||||||
<tr class="font-book">
|
<tr class="font-book">
|
||||||
<th>#</th>
|
<th class="w-10">#</th>
|
||||||
<th class="text-left">Filename</th>
|
<th class="text-left">Filename</th>
|
||||||
<th class="text-left">Size</th>
|
<th class="text-left w-20">Size</th>
|
||||||
<th class="text-left">Duration</th>
|
<th class="text-left w-20">Duration</th>
|
||||||
<th v-if="userCanDownload" class="text-center">Download</th>
|
<th v-if="userCanDownload" class="text-center w-20">Download</th>
|
||||||
</tr>
|
</tr>
|
||||||
<template v-for="track in tracks">
|
<template v-for="track in tracksCleaned">
|
||||||
<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-book">
|
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
|
||||||
{{ track.filename }}
|
|
||||||
</td>
|
|
||||||
<td class="font-mono">
|
<td class="font-mono">
|
||||||
{{ $bytesPretty(track.size) }}
|
{{ $bytesPretty(track.size) }}
|
||||||
</td>
|
</td>
|
||||||
@@ -36,7 +38,7 @@
|
|||||||
{{ $secondsToTimestamp(track.duration) }}
|
{{ $secondsToTimestamp(track.duration) }}
|
||||||
</td>
|
</td>
|
||||||
<td v-if="userCanDownload" class="text-center">
|
<td v-if="userCanDownload" class="text-center">
|
||||||
<a :href="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a>
|
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
@@ -53,14 +55,41 @@ export default {
|
|||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
audiobookId: String
|
audiobook: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showTracks: false
|
showTracks: false,
|
||||||
|
showFullPath: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<h1 class="text-xl">Users</h1>
|
||||||
|
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddUser">
|
||||||
|
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
|
||||||
|
<div class="text-center">
|
||||||
|
<table id="accounts">
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th class="w-20">Type</th>
|
||||||
|
<th>Activity</th>
|
||||||
|
<th class="w-32">Last Seen</th>
|
||||||
|
<th class="w-32">Created</th>
|
||||||
|
<th class="w-32"></th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="user in users" :key="user.id" class="cursor-pointer" :class="user.isActive ? '' : 'bg-error bg-opacity-20'" @click="$router.push(`/config/users/${user.id}`)">
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<widgets-online-indicator :value="!!usersOnline[user.id]" />
|
||||||
|
<span class="pl-2">{{ user.username }}</span> <span v-show="$isDev" class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-sm">{{ user.type }}</td>
|
||||||
|
<td>
|
||||||
|
<div v-if="usersOnline[user.id] && usersOnline[user.id].stream && usersOnline[user.id].stream.audiobook && usersOnline[user.id].stream.audiobook.book">
|
||||||
|
<p class="truncate text-xs">Reading: {{ usersOnline[user.id].stream.audiobook.book.title || '' }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="user.audiobooks && getLastRead(user.audiobooks)">
|
||||||
|
<p class="truncate text-xs">Last: {{ getLastRead(user.audiobooks) }}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-xs font-mono">
|
||||||
|
<ui-tooltip v-if="user.lastSeen" direction="top" :text="$formatDate(user.lastSeen, 'MMMM do, yyyy HH:mm')">
|
||||||
|
{{ $dateDistanceFromNow(user.lastSeen) }}
|
||||||
|
</ui-tooltip>
|
||||||
|
</td>
|
||||||
|
<td class="text-xs font-mono">
|
||||||
|
<ui-tooltip direction="top" :text="$formatDate(user.createdAt, 'MMMM do, yyyy HH:mm')">
|
||||||
|
{{ $formatDate(user.createdAt, 'MMM d, yyyy') }}
|
||||||
|
</ui-tooltip>
|
||||||
|
</td>
|
||||||
|
<td class="py-0">
|
||||||
|
<div class="w-full flex justify-center">
|
||||||
|
<!-- <span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click.stop="editUser(user)">edit</span> -->
|
||||||
|
<div class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)">
|
||||||
|
<span class="material-icons text-base">edit</span>
|
||||||
|
</div>
|
||||||
|
<div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)">
|
||||||
|
<span class="material-icons text-base">delete</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
users: [],
|
||||||
|
selectedAccount: null,
|
||||||
|
showAccountModal: false,
|
||||||
|
isDeletingUser: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
currentUserId() {
|
||||||
|
return this.$store.state.user.user.id
|
||||||
|
},
|
||||||
|
userStream() {
|
||||||
|
return this.$store.state.streamAudiobook
|
||||||
|
},
|
||||||
|
usersOnline() {
|
||||||
|
var usermap = {}
|
||||||
|
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, stream: u.stream }))
|
||||||
|
return usermap
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getLastRead(audiobooks) {
|
||||||
|
var abs = Object.values(audiobooks)
|
||||||
|
if (abs.length) {
|
||||||
|
abs = abs.sort((a, b) => a.lastUpdate - b.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) {
|
||||||
|
if (this.isDeletingUser) return
|
||||||
|
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
|
||||||
|
this.isDeletingUser = true
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/user/${user.id}`)
|
||||||
|
.then((data) => {
|
||||||
|
this.isDeletingUser = false
|
||||||
|
if (data.error) {
|
||||||
|
this.$toast.error(data.error)
|
||||||
|
} else {
|
||||||
|
this.$toast.success('User deleted')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to delete user', error)
|
||||||
|
this.$toast.error('Failed to delete user')
|
||||||
|
this.isDeletingUser = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clickAddUser() {
|
||||||
|
this.selectedAccount = null
|
||||||
|
this.showAccountModal = true
|
||||||
|
},
|
||||||
|
editUser(user) {
|
||||||
|
this.selectedAccount = user
|
||||||
|
this.showAccountModal = true
|
||||||
|
},
|
||||||
|
loadUsers() {
|
||||||
|
this.$axios
|
||||||
|
.$get('/api/users')
|
||||||
|
.then((users) => {
|
||||||
|
this.users = users.sort((a, b) => {
|
||||||
|
return a.createdAt - b.createdAt
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
addUpdateUser(user) {
|
||||||
|
if (!this.users) return
|
||||||
|
var index = this.users.findIndex((u) => u.id === user.id)
|
||||||
|
if (index >= 0) {
|
||||||
|
this.users.splice(index, 1, user)
|
||||||
|
} else {
|
||||||
|
this.users.push(user)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
userRemoved(user) {
|
||||||
|
this.users = this.users.filter((u) => u.id !== user.id)
|
||||||
|
},
|
||||||
|
init(attempts = 0) {
|
||||||
|
if (!this.$root.socket) {
|
||||||
|
if (attempts > 10) {
|
||||||
|
return console.error('Failed to setup socket listeners')
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
this.init(++attempts)
|
||||||
|
}, 250)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.$root.socket.on('user_added', this.addUpdateUser)
|
||||||
|
this.$root.socket.on('user_updated', this.addUpdateUser)
|
||||||
|
this.$root.socket.on('user_removed', this.userRemoved)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadUsers()
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
if (this.$root.socket) {
|
||||||
|
this.$root.socket.off('user_added', this.newUserAdded)
|
||||||
|
this.$root.socket.off('user_updated', this.userUpdated)
|
||||||
|
this.$root.socket.off('user_removed', this.userRemoved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#accounts {
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border: 1px solid #474747;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#accounts td,
|
||||||
|
#accounts th {
|
||||||
|
/* border: 1px solid #2e2e2e; */
|
||||||
|
padding: 8px 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
#accounts td.py-0 {
|
||||||
|
padding: 0px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#accounts tr:nth-child(even) {
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#accounts tr:hover {
|
||||||
|
background-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
#accounts th {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-top: 5px;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -56,6 +56,9 @@ export default {
|
|||||||
if (this.paddingX !== undefined) {
|
if (this.paddingX !== undefined) {
|
||||||
list.push(`px-${this.paddingX}`)
|
list.push(`px-${this.paddingX}`)
|
||||||
}
|
}
|
||||||
|
if (this.disabled) {
|
||||||
|
list.push('cursor-not-allowed')
|
||||||
|
}
|
||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative w-full" v-click-outside="clickOutside">
|
||||||
|
<p class="text-sm text-opacity-75 mb-1">{{ label }}</p>
|
||||||
|
<button type="button" :disabled="disabled" class="relative h-10 w-full border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span class="block truncate">{{ selectedText }}</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>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<transition name="menu">
|
||||||
|
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-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="item in items">
|
||||||
|
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="font-normal ml-3 block truncate font-sans text-sm">{{ item.text }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: [String, Number],
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
disabled: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showMenu: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
selected: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedItem() {
|
||||||
|
return this.items.find((i) => i.value === this.selected)
|
||||||
|
},
|
||||||
|
selectedText() {
|
||||||
|
return this.selectedItem ? this.selectedItem.text : ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickShowMenu() {
|
||||||
|
if (this.disabled) return
|
||||||
|
this.showMenu = !this.showMenu
|
||||||
|
},
|
||||||
|
clickOutside() {
|
||||||
|
this.showMenu = false
|
||||||
|
},
|
||||||
|
clickedOption(itemValue) {
|
||||||
|
this.selected = itemValue
|
||||||
|
this.showMenu = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-1 bg-transparent border-b border-opacity-0 border-gray-400 focus:border-opacity-100 focus:outline-none" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: [String, Number],
|
||||||
|
placeholder: String,
|
||||||
|
readonly: Boolean,
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'text'
|
||||||
|
},
|
||||||
|
disabled: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
inputValue: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
focused() {
|
||||||
|
this.$emit('focus')
|
||||||
|
},
|
||||||
|
blurred() {
|
||||||
|
this.$emit('blur')
|
||||||
|
},
|
||||||
|
change(e) {
|
||||||
|
this.$emit('change', e.target.value)
|
||||||
|
},
|
||||||
|
keyup(e) {
|
||||||
|
this.$emit('keyup', e)
|
||||||
|
},
|
||||||
|
blur() {
|
||||||
|
if (this.$refs.input) this.$refs.input.blur()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
input {
|
||||||
|
border-style: inherit !important;
|
||||||
|
}
|
||||||
|
input:read-only {
|
||||||
|
background-color: #444;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<input ref="fileInput" id="hidden-input" type="file" :accept="inputAccept" class="hidden" @change="inputChanged" />
|
<input ref="fileInput" id="hidden-input" type="file" :accept="accept" class="hidden" @change="inputChanged" />
|
||||||
<ui-btn @click="clickUpload" color="primary" type="text">Upload Cover</ui-btn>
|
<ui-btn @click="clickUpload" color="primary" type="text"><slot /></ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
data() {
|
props: {
|
||||||
return {
|
accept: {
|
||||||
inputAccept: 'image/*'
|
type: String,
|
||||||
|
default: '.png, .jpg, .jpeg, .webp'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
computed: {},
|
computed: {},
|
||||||
methods: {
|
methods: {
|
||||||
reset() {
|
reset() {
|
||||||
|
|||||||
@@ -116,9 +116,6 @@ export default {
|
|||||||
this.textInput = null
|
this.textInput = null
|
||||||
this.currentSearch = null
|
this.currentSearch = null
|
||||||
this.input = item
|
this.input = item
|
||||||
|
|
||||||
// this.input = this.textInput ? this.textInput.trim() : null
|
|
||||||
console.log('Clicked option', item)
|
|
||||||
if (this.$refs.input) this.$refs.input.blur()
|
if (this.$refs.input) this.$refs.input.blur()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="currentLibrary" class="relative w-36 h-8" v-click-outside="clickOutside">
|
||||||
|
<button type="button" :disabled="disabled" class="relative h-full w-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
|
||||||
|
<span class="block truncate text-sm">{{ currentLibrary.name }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<transition name="menu">
|
||||||
|
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-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="library in librariesFiltered">
|
||||||
|
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
|
||||||
|
<div class="flex items-center px-3">
|
||||||
|
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
|
||||||
|
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showMenu: false,
|
||||||
|
disabled: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
currentLibrary() {
|
||||||
|
return this.libraries.find((lib) => lib.id === this.currentLibraryId)
|
||||||
|
},
|
||||||
|
currentLibraryIcon() {
|
||||||
|
return this.currentLibrary ? this.currentLibrary.icon || 'database' : 'database'
|
||||||
|
},
|
||||||
|
libraries() {
|
||||||
|
return this.$store.getters['libraries/getSortedLibraries']()
|
||||||
|
},
|
||||||
|
canUserAccessAllLibraries() {
|
||||||
|
return this.$store.getters['user/getUserCanAccessAllLibraries']
|
||||||
|
},
|
||||||
|
userLibrariesAccessible() {
|
||||||
|
return this.$store.getters['user/getLibrariesAccessible']
|
||||||
|
},
|
||||||
|
librariesFiltered() {
|
||||||
|
if (this.canUserAccessAllLibraries) return this.libraries
|
||||||
|
return this.libraries.filter((lib) => {
|
||||||
|
return this.userLibrariesAccessible.includes(lib.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickShowMenu() {
|
||||||
|
if (this.disabled) return
|
||||||
|
this.showMenu = !this.showMenu
|
||||||
|
},
|
||||||
|
clickOutside() {
|
||||||
|
this.showMenu = false
|
||||||
|
},
|
||||||
|
selectLibrary(library) {
|
||||||
|
this.updateLibrary(library)
|
||||||
|
this.showMenu = false
|
||||||
|
},
|
||||||
|
async updateLibrary(library) {
|
||||||
|
this.disabled = true
|
||||||
|
await this.$store.dispatch('libraries/fetch', library.id)
|
||||||
|
this.$router.push(`/library/${library.id}`)
|
||||||
|
this.disabled = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -127,6 +127,7 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.isFocused = false
|
this.isFocused = false
|
||||||
|
if (this.textInput) this.submitForm()
|
||||||
}, 50)
|
}, 50)
|
||||||
},
|
},
|
||||||
focus() {
|
focus() {
|
||||||
@@ -145,6 +146,7 @@ export default {
|
|||||||
var newSelected = null
|
var newSelected = null
|
||||||
if (this.selected.includes(itemValue)) {
|
if (this.selected.includes(itemValue)) {
|
||||||
newSelected = this.selected.filter((s) => s !== itemValue)
|
newSelected = this.selected.filter((s) => s !== itemValue)
|
||||||
|
this.$emit('removedItem', itemValue)
|
||||||
} else {
|
} else {
|
||||||
newSelected = this.selected.concat([itemValue])
|
newSelected = this.selected.concat([itemValue])
|
||||||
}
|
}
|
||||||
@@ -164,6 +166,7 @@ export default {
|
|||||||
removeItem(item) {
|
removeItem(item) {
|
||||||
var remaining = this.selected.filter((i) => i !== item)
|
var remaining = this.selected.filter((i) => i !== item)
|
||||||
this.$emit('input', remaining)
|
this.$emit('input', remaining)
|
||||||
|
this.$emit('removedItem', item)
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.recalcMenuPos()
|
this.recalcMenuPos()
|
||||||
})
|
})
|
||||||
@@ -171,6 +174,7 @@ export default {
|
|||||||
insertNewItem(item) {
|
insertNewItem(item) {
|
||||||
this.selected.push(item)
|
this.selected.push(item)
|
||||||
this.$emit('input', this.selected)
|
this.$emit('input', this.selected)
|
||||||
|
this.$emit('newItem', item)
|
||||||
this.textInput = null
|
this.textInput = null
|
||||||
this.currentSearch = null
|
this.currentSearch = null
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full" v-click-outside="closeMenu">
|
||||||
|
<p class="px-1 text-sm font-semibold">{{ label }}</p>
|
||||||
|
<div ref="wrapper" class="relative">
|
||||||
|
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-pointer" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||||
|
<div v-for="item in selectedItems" :key="item.value" class="rounded-full px-2 py-1 ma-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">
|
||||||
|
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.value)">close</span>
|
||||||
|
</div>
|
||||||
|
{{ item.text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 items">
|
||||||
|
<li :key="item.value" 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.text }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="selected.includes(item.value)" 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: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
label: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showMenu: false,
|
||||||
|
menu: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
selected: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedItems() {
|
||||||
|
return (this.value || []).map((v) => {
|
||||||
|
return this.items.find((i) => i.value === v) || {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
recalcMenuPos() {
|
||||||
|
if (!this.menu) return
|
||||||
|
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
|
||||||
|
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + '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'
|
||||||
|
},
|
||||||
|
clickedOption(e, item) {
|
||||||
|
if (e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
var newSelected = null
|
||||||
|
if (this.selected.includes(item.value)) {
|
||||||
|
newSelected = this.selected.filter((s) => s !== item.value)
|
||||||
|
} else {
|
||||||
|
newSelected = this.selected.concat([item.value])
|
||||||
|
}
|
||||||
|
this.$emit('input', newSelected)
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.recalcMenuPos()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
closeMenu() {
|
||||||
|
this.showMenu = false
|
||||||
|
},
|
||||||
|
clickWrapper() {
|
||||||
|
this.showMenu = !this.showMenu
|
||||||
|
},
|
||||||
|
removeItem(itemValue) {
|
||||||
|
var remaining = this.selected.filter((i) => i !== itemValue)
|
||||||
|
this.$emit('input', remaining)
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.recalcMenuPos()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<button class="icon-btn rounded-md bg-primary border border-gray-600 flex items-center justify-center h-9 w-9 relative" @click="clickBtn">
|
<button class="icon-btn rounded-md bg-primary border border-gray-600 flex items-center justify-center h-9 w-9 relative" @click="clickBtn">
|
||||||
<div class="w-5 h-5 text-white relative">
|
<div class="w-5 h-5 text-white relative">
|
||||||
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
<svg v-if="isRead" 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" />
|
<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>
|
||||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none border border-gray-600" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
<div class="relative">
|
||||||
|
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||||
|
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
||||||
|
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -20,7 +25,10 @@ export default {
|
|||||||
paddingX: {
|
paddingX: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 3
|
default: 3
|
||||||
}
|
},
|
||||||
|
noSpinner: Boolean,
|
||||||
|
textCenter: Boolean,
|
||||||
|
clearable: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
@@ -38,10 +46,15 @@ export default {
|
|||||||
var _list = []
|
var _list = []
|
||||||
_list.push(`px-${this.paddingX}`)
|
_list.push(`px-${this.paddingX}`)
|
||||||
_list.push(`py-${this.paddingY}`)
|
_list.push(`py-${this.paddingY}`)
|
||||||
|
if (this.noSpinner) _list.push('no-spinner')
|
||||||
|
if (this.textCenter) _list.push('text-center')
|
||||||
return _list.join(' ')
|
return _list.join(' ')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
clear() {
|
||||||
|
this.inputValue = ''
|
||||||
|
},
|
||||||
focused() {
|
focused() {
|
||||||
this.$emit('focus')
|
this.$emit('focus')
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="border rounded-full border-black-100 flex items-center cursor-pointer w-12 justify-start" :class="className" @click="clickToggle">
|
<div class="border rounded-full border-black-100 flex items-center cursor-pointer w-10 justify-start" :class="className" @click="clickToggle">
|
||||||
<span class="rounded-full border w-6 h-6 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span>
|
<span class="rounded-full border w-5 h-5 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -30,12 +30,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
className() {
|
className() {
|
||||||
if (this.disabled) return 'bg-bg cursor-not-allowed'
|
if (this.disabled) return this.toggleValue ? `bg-${this.onColor} cursor-not-allowed` : `bg-${this.offColor} cursor-not-allowed`
|
||||||
return this.toggleValue ? `bg-${this.onColor}` : `bg-${this.offColor}`
|
return this.toggleValue ? `bg-${this.onColor}` : `bg-${this.offColor}`
|
||||||
},
|
},
|
||||||
switchClassName() {
|
switchClassName() {
|
||||||
var bgColor = this.disabled ? 'bg-gray-300' : 'bg-white'
|
var bgColor = this.disabled ? 'bg-gray-300' : 'bg-white'
|
||||||
return this.toggleValue ? 'translate-x-6 ' + bgColor : bgColor
|
return this.toggleValue ? 'translate-x-5 ' + bgColor : bgColor
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
tooltip: null,
|
tooltip: null,
|
||||||
|
tooltipId: null,
|
||||||
isShowing: false
|
isShowing: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -45,13 +46,14 @@ export default {
|
|||||||
'font-size': '0.75rem'
|
'font-size': '0.75rem'
|
||||||
}
|
}
|
||||||
var size = this.$calculateTextSize(this.text, styles)
|
var size = this.$calculateTextSize(this.text, styles)
|
||||||
console.log('Text Size', size.width, size.height)
|
|
||||||
return size.width
|
return size.width
|
||||||
},
|
},
|
||||||
createTooltip() {
|
createTooltip() {
|
||||||
if (!this.$refs.box) return
|
if (!this.$refs.box) return
|
||||||
var tooltip = document.createElement('div')
|
var tooltip = document.createElement('div')
|
||||||
tooltip.className = 'absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs'
|
this.tooltipId = String(Math.floor(Math.random() * 10000))
|
||||||
|
tooltip.id = this.tooltipId
|
||||||
|
tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs'
|
||||||
tooltip.style.zIndex = 100
|
tooltip.style.zIndex = 100
|
||||||
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
|
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
|
||||||
tooltip.innerHTML = this.text
|
tooltip.innerHTML = this.text
|
||||||
@@ -91,8 +93,15 @@ export default {
|
|||||||
if (this.disabled) return
|
if (this.disabled) return
|
||||||
if (!this.tooltip) {
|
if (!this.tooltip) {
|
||||||
this.createTooltip()
|
this.createTooltip()
|
||||||
|
if (!this.tooltip) return
|
||||||
|
}
|
||||||
|
if (!this.$refs.box) return // Ensure element is not destroyed
|
||||||
|
try {
|
||||||
|
document.body.appendChild(this.tooltip)
|
||||||
|
this.setTooltipPosition(this.tooltip)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
}
|
}
|
||||||
document.body.appendChild(this.tooltip)
|
|
||||||
this.isShowing = true
|
this.isShowing = true
|
||||||
},
|
},
|
||||||
hideTooltip() {
|
hideTooltip() {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<button class="bg-error text-white px-2 py-1 shadow-md" @click="$emit('click', $event)">Cancel</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.Vue-Toastification__close-button.cancel-scan-btn {
|
||||||
|
background-color: rgb(255, 82, 82);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 1;
|
||||||
|
padding: 0px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: normal;
|
||||||
|
font-family: 'Open Sans';
|
||||||
|
margin-left: 10px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
.Vue-Toastification__close-button.cancel-scan-btn:hover {
|
||||||
|
background-color: rgb(235, 65, 65);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-4 w-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
icon: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-3 h-3">
|
||||||
|
<div v-if="value" class="w-full h-full text-sm mr-2 text-success animate-pulse">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<svg v-else class="w-full h-full mr-2 text-white text-opacity-20" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-show="isScanning" class="fixed bottom-4 left-0 right-0 mx-auto z-20 max-w-lg">
|
|
||||||
<div class="w-full my-1 rounded-lg drop-shadow-lg px-4 py-2 flex items-center justify-center text-center transition-all border border-white border-opacity-40 shadow-md bg-warning">
|
|
||||||
<p class="text-lg font-sans" v-html="text" />
|
|
||||||
</div>
|
|
||||||
<div v-show="!hasCanceled" class="absolute right-0 top-3 bottom-0 px-2">
|
|
||||||
<ui-btn color="red-600" small :padding-x="1" @click="cancelScan">Cancel</ui-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
hasCanceled: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
isScanning(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.hasCanceled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
text() {
|
|
||||||
var scanText = this.isScanningFiles ? 'Scanning...' : 'Scanning Covers...'
|
|
||||||
return `${scanText} <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>`
|
|
||||||
},
|
|
||||||
isScanning() {
|
|
||||||
return this.isScanningFiles || this.isScanningCovers
|
|
||||||
},
|
|
||||||
isScanningFiles() {
|
|
||||||
return this.$store.state.isScanning
|
|
||||||
},
|
|
||||||
isScanningCovers() {
|
|
||||||
return this.$store.state.isScanningCovers
|
|
||||||
},
|
|
||||||
scanProgressKey() {
|
|
||||||
return this.isScanningFiles ? 'scanProgress' : 'coverScanProgress'
|
|
||||||
},
|
|
||||||
scanProgress() {
|
|
||||||
return this.$store.state[this.scanProgressKey]
|
|
||||||
},
|
|
||||||
scanPercent() {
|
|
||||||
return this.scanProgress ? this.scanProgress.progress + '%' : '0%'
|
|
||||||
},
|
|
||||||
scanNum() {
|
|
||||||
return this.scanProgress ? this.scanProgress.done : 0
|
|
||||||
},
|
|
||||||
scanTotal() {
|
|
||||||
return this.scanProgress ? this.scanProgress.total : 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
cancelScan() {
|
|
||||||
this.hasCanceled = true
|
|
||||||
this.$root.socket.emit('cancel_scan')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
+157
-45
@@ -5,12 +5,15 @@
|
|||||||
<Nuxt />
|
<Nuxt />
|
||||||
|
|
||||||
<app-stream-container ref="streamContainer" />
|
<app-stream-container ref="streamContainer" />
|
||||||
|
|
||||||
<modals-edit-modal />
|
<modals-edit-modal />
|
||||||
<widgets-scan-alert />
|
<readers-reader />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import CloseButton from '@/components/widgets/CloseButton'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
middleware: 'authenticated',
|
middleware: 'authenticated',
|
||||||
data() {
|
data() {
|
||||||
@@ -64,6 +67,20 @@ export default {
|
|||||||
if (payload.serverSettings) {
|
if (payload.serverSettings) {
|
||||||
this.$store.commit('setServerSettings', payload.serverSettings)
|
this.$store.commit('setServerSettings', payload.serverSettings)
|
||||||
}
|
}
|
||||||
|
if (payload.librariesScanning) {
|
||||||
|
payload.librariesScanning.forEach((libraryScan) => {
|
||||||
|
this.scanStart(libraryScan)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (payload.backups && payload.backups.length) {
|
||||||
|
this.$store.commit('setBackups', payload.backups)
|
||||||
|
}
|
||||||
|
if (payload.usersOnline) {
|
||||||
|
this.$store.commit('users/resetUsers')
|
||||||
|
payload.usersOnline.forEach((user) => {
|
||||||
|
this.$store.commit('users/updateUser', user)
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
streamOpen(stream) {
|
streamOpen(stream) {
|
||||||
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
|
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
|
||||||
@@ -89,58 +106,61 @@ export default {
|
|||||||
audiobookRemoved(audiobook) {
|
audiobookRemoved(audiobook) {
|
||||||
if (this.$route.name.startsWith('audiobook')) {
|
if (this.$route.name.startsWith('audiobook')) {
|
||||||
if (this.$route.params.id === audiobook.id) {
|
if (this.$route.params.id === audiobook.id) {
|
||||||
this.$router.replace('/library')
|
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.$store.commit('audiobooks/remove', audiobook)
|
this.$store.commit('audiobooks/remove', audiobook)
|
||||||
},
|
},
|
||||||
scanComplete({ scanType, results }) {
|
libraryAdded(library) {
|
||||||
if (scanType === 'covers') {
|
this.$store.commit('libraries/addUpdate', library)
|
||||||
this.$store.commit('setIsScanningCovers', false)
|
|
||||||
if (results) {
|
|
||||||
this.$toast.success(`Scan Finished\nUpdated ${results.found} covers`)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.$store.commit('setIsScanning', false)
|
|
||||||
if (results) {
|
|
||||||
var scanResultMsgs = []
|
|
||||||
if (results.added) scanResultMsgs.push(`${results.added} added`)
|
|
||||||
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
|
|
||||||
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
|
|
||||||
if (results.missing) scanResultMsgs.push(`${results.missing} missing`)
|
|
||||||
if (!scanResultMsgs.length) this.$toast.success('Scan Finished\nEverything was up to date')
|
|
||||||
else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
scanStart(scanType) {
|
libraryUpdated(library) {
|
||||||
if (scanType === 'covers') {
|
this.$store.commit('libraries/addUpdate', library)
|
||||||
this.$store.commit('setIsScanningCovers', true)
|
|
||||||
} else {
|
|
||||||
this.$store.commit('setIsScanning', true)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
scanProgress({ scanType, progress }) {
|
libraryRemoved(library) {
|
||||||
if (scanType === 'covers') {
|
this.$store.commit('libraries/remove', library)
|
||||||
this.$store.commit('setCoverScanProgress', progress)
|
|
||||||
} else {
|
|
||||||
this.$store.commit('setScanProgress', progress)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
saveMetadataComplete(result) {
|
scanComplete(data) {
|
||||||
if (result.error) {
|
var message = `Scan "${data.name}" complete!`
|
||||||
this.$toast.error(result.error)
|
if (data.results) {
|
||||||
} else if (result.audiobookId) {
|
var scanResultMsgs = []
|
||||||
var { savedPath } = result
|
var results = data.results
|
||||||
if (!savedPath) {
|
if (results.added) scanResultMsgs.push(`${results.added} added`)
|
||||||
this.$toast.error(`Failed to save metadata file (${result.audiobookId})`)
|
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
|
||||||
} else {
|
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
|
||||||
this.$toast.success(`Metadata file saved (${result.audiobookId})`)
|
if (results.missing) scanResultMsgs.push(`${results.missing} missing`)
|
||||||
}
|
if (!scanResultMsgs.length) message += '\nEverything was up to date'
|
||||||
|
else message += '\n' + scanResultMsgs.join('\n')
|
||||||
} else {
|
} else {
|
||||||
var { success, failed } = result
|
message = `Scan "${data.name}" was canceled`
|
||||||
this.$toast.success(`Metadata save complete\n${success} Succeeded\n${failed} Failed`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
|
||||||
|
if (existingScan && !isNaN(existingScan.toastId)) {
|
||||||
|
this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: 'success', closeButton: false, position: 'bottom-center', onClose: () => null } }, true)
|
||||||
|
} else {
|
||||||
|
this.$toast.success(message, { timeout: 5000, position: 'bottom-center' })
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$store.commit('scanners/remove', data)
|
||||||
|
},
|
||||||
|
onScanToastCancel(id) {
|
||||||
|
this.$root.socket.emit('cancel_scan', id)
|
||||||
|
},
|
||||||
|
scanStart(data) {
|
||||||
|
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) })
|
||||||
|
this.$store.commit('scanners/addUpdate', data)
|
||||||
|
},
|
||||||
|
scanProgress(data) {
|
||||||
|
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
|
||||||
|
if (existingScan && !isNaN(existingScan.toastId)) {
|
||||||
|
data.toastId = existingScan.toastId
|
||||||
|
this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true)
|
||||||
|
} else {
|
||||||
|
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$store.commit('scanners/addUpdate', data)
|
||||||
},
|
},
|
||||||
userUpdated(user) {
|
userUpdated(user) {
|
||||||
if (this.$store.state.user.user.id === user.id) {
|
if (this.$store.state.user.user.id === user.id) {
|
||||||
@@ -148,10 +168,20 @@ export default {
|
|||||||
this.$store.commit('user/setSettings', user.settings)
|
this.$store.commit('user/setSettings', user.settings)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
userOnline(user) {
|
||||||
|
this.$store.commit('users/updateUser', user)
|
||||||
|
},
|
||||||
|
userOffline(user) {
|
||||||
|
this.$store.commit('users/removeUser', user)
|
||||||
|
},
|
||||||
|
userStreamUpdate(user) {
|
||||||
|
this.$store.commit('users/updateUser', user)
|
||||||
|
},
|
||||||
downloadToastClick(download) {
|
downloadToastClick(download) {
|
||||||
if (!download || !download.audiobookId) {
|
if (!download || !download.audiobookId) {
|
||||||
return console.error('Invalid download object', download)
|
return console.error('Invalid download object', download)
|
||||||
}
|
}
|
||||||
|
|
||||||
var audiobook = this.$store.getters['audiobooks/getAudiobook'](download.audiobookId)
|
var audiobook = this.$store.getters['audiobooks/getAudiobook'](download.audiobookId)
|
||||||
if (!audiobook) {
|
if (!audiobook) {
|
||||||
return console.error('Audiobook not found for download', download)
|
return console.error('Audiobook not found for download', download)
|
||||||
@@ -205,6 +235,13 @@ export default {
|
|||||||
download.status = this.$constants.DownloadStatus.EXPIRED
|
download.status = this.$constants.DownloadStatus.EXPIRED
|
||||||
this.$store.commit('downloads/addUpdateDownload', download)
|
this.$store.commit('downloads/addUpdateDownload', download)
|
||||||
},
|
},
|
||||||
|
logEvtReceived(payload) {
|
||||||
|
this.$store.commit('logs/logEvt', payload)
|
||||||
|
},
|
||||||
|
backupApplied() {
|
||||||
|
// Force refresh
|
||||||
|
location.reload()
|
||||||
|
},
|
||||||
initializeSocket() {
|
initializeSocket() {
|
||||||
this.socket = this.$nuxtSocket({
|
this.socket = this.$nuxtSocket({
|
||||||
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
||||||
@@ -238,14 +275,21 @@ export default {
|
|||||||
this.socket.on('audiobook_added', this.audiobookAdded)
|
this.socket.on('audiobook_added', this.audiobookAdded)
|
||||||
this.socket.on('audiobook_removed', this.audiobookRemoved)
|
this.socket.on('audiobook_removed', this.audiobookRemoved)
|
||||||
|
|
||||||
|
// Library Listeners
|
||||||
|
this.socket.on('library_updated', this.libraryUpdated)
|
||||||
|
this.socket.on('library_added', this.libraryAdded)
|
||||||
|
this.socket.on('library_removed', this.libraryRemoved)
|
||||||
|
|
||||||
// User Listeners
|
// User Listeners
|
||||||
this.socket.on('user_updated', this.userUpdated)
|
this.socket.on('user_updated', this.userUpdated)
|
||||||
|
this.socket.on('user_online', this.userOnline)
|
||||||
|
this.socket.on('user_offline', this.userOffline)
|
||||||
|
this.socket.on('user_stream_update', this.userStreamUpdate)
|
||||||
|
|
||||||
// Scan Listeners
|
// Scan Listeners
|
||||||
this.socket.on('scan_start', this.scanStart)
|
this.socket.on('scan_start', this.scanStart)
|
||||||
this.socket.on('scan_complete', this.scanComplete)
|
this.socket.on('scan_complete', this.scanComplete)
|
||||||
this.socket.on('scan_progress', this.scanProgress)
|
this.socket.on('scan_progress', this.scanProgress)
|
||||||
// this.socket.on('save_metadata_complete', this.saveMetadataComplete)
|
|
||||||
|
|
||||||
// Download Listeners
|
// Download Listeners
|
||||||
this.socket.on('download_started', this.downloadStarted)
|
this.socket.on('download_started', this.downloadStarted)
|
||||||
@@ -253,6 +297,10 @@ export default {
|
|||||||
this.socket.on('download_failed', this.downloadFailed)
|
this.socket.on('download_failed', this.downloadFailed)
|
||||||
this.socket.on('download_killed', this.downloadKilled)
|
this.socket.on('download_killed', this.downloadKilled)
|
||||||
this.socket.on('download_expired', this.downloadExpired)
|
this.socket.on('download_expired', this.downloadExpired)
|
||||||
|
|
||||||
|
this.socket.on('log', this.logEvtReceived)
|
||||||
|
|
||||||
|
this.socket.on('backup_applied', this.backupApplied)
|
||||||
},
|
},
|
||||||
showUpdateToast(versionData) {
|
showUpdateToast(versionData) {
|
||||||
var ignoreVersion = localStorage.getItem('ignoreVersion')
|
var ignoreVersion = localStorage.getItem('ignoreVersion')
|
||||||
@@ -277,10 +325,71 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
console.warn(`Update is available but user chose to dismiss it! v${versionData.latestVersion}`)
|
console.warn(`Update is available but user chose to dismiss it! v${versionData.latestVersion}`)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
checkActiveElementIsInput() {
|
||||||
|
var activeElement = document.activeElement
|
||||||
|
var inputs = ['input', 'select', 'button', 'textarea']
|
||||||
|
return activeElement && inputs.indexOf(activeElement.tagName.toLowerCase()) !== -1
|
||||||
|
},
|
||||||
|
keyUp(e) {
|
||||||
|
var keyCode = e.keyCode || e.which
|
||||||
|
if (!this.$keynames[keyCode]) {
|
||||||
|
// Unused hotkey
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyName = this.$keynames[keyCode]
|
||||||
|
var name = keyName
|
||||||
|
if (e.shiftKey) name = 'Shift-' + keyName
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
console.log('Hotkey command', name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input is focused then ignore key press
|
||||||
|
if (this.checkActiveElementIsInput()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal is open
|
||||||
|
if (this.$store.state.openModal) {
|
||||||
|
this.$eventBus.$emit('modal-hotkey', name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// EReader is open
|
||||||
|
if (this.$store.state.showEReader) {
|
||||||
|
this.$eventBus.$emit('reader-hotkey', name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch selecting
|
||||||
|
if (this.$store.getters['getNumAudiobooksSelected']) {
|
||||||
|
// ESCAPE key cancels batch selection
|
||||||
|
if (name === 'Escape') {
|
||||||
|
this.$store.commit('setSelectedAudiobooks', [])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playing audiobook
|
||||||
|
if (this.$store.state.streamAudiobook) {
|
||||||
|
this.$eventBus.$emit('player-hotkey', name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
document.addEventListener('keyup', this.keyUp)
|
||||||
|
|
||||||
this.initializeSocket()
|
this.initializeSocket()
|
||||||
|
this.$store.dispatch('libraries/load')
|
||||||
|
|
||||||
|
// If experimental features set in local storage
|
||||||
|
var experimentalFeaturesSaved = localStorage.getItem('experimental')
|
||||||
|
if (experimentalFeaturesSaved === '1') {
|
||||||
|
this.$store.commit('setExperimentalFeatures', true)
|
||||||
|
}
|
||||||
|
|
||||||
this.$store
|
this.$store
|
||||||
.dispatch('checkForUpdate')
|
.dispatch('checkForUpdate')
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
@@ -292,6 +401,9 @@ export default {
|
|||||||
this.$toast.error(this.$route.query.error)
|
this.$toast.error(this.$route.query.error)
|
||||||
this.$router.replace(this.$route.path)
|
this.$router.replace(this.$route.path)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
document.removeEventListener('keyup', this.keyUp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ export default function (context) {
|
|||||||
|
|
||||||
if (route.name === 'login' || from.name === 'login') return
|
if (route.name === 'login' || from.name === 'login') return
|
||||||
|
|
||||||
if (route.name === 'config' || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id')) {
|
if (!route.name) {
|
||||||
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && from.name !== 'config' && from.name !== 'upload' && from.name !== 'account') {
|
console.warn('No Route name', route)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.name.startsWith('config') || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id')) {
|
||||||
|
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && !from.name.startsWith('config') && from.name !== 'upload' && from.name !== 'account') {
|
||||||
var _history = [...store.state.routeHistory]
|
var _history = [...store.state.routeHistory]
|
||||||
if (!_history.length || _history[_history.length - 1] !== from.fullPath) {
|
if (!_history.length || _history[_history.length - 1] !== from.fullPath) {
|
||||||
_history.push(from.fullPath)
|
_history.push(from.fullPath)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ module.exports = {
|
|||||||
|
|
||||||
// Global page headers: https://go.nuxtjs.dev/config-head
|
// Global page headers: https://go.nuxtjs.dev/config-head
|
||||||
head: {
|
head: {
|
||||||
title: 'AudioBookshelf',
|
title: 'Audiobookshelf',
|
||||||
htmlAttrs: {
|
htmlAttrs: {
|
||||||
lang: 'en'
|
lang: 'en'
|
||||||
},
|
},
|
||||||
@@ -35,8 +35,8 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
link: [
|
link: [
|
||||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
||||||
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Fira+Mono&family=Ubuntu+Mono&family=Open+Sans:wght@400;600&family=Gentium+Book+Basic' },
|
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Ubuntu+Mono&family=Source+Sans+Pro:wght@300;400;600' },
|
||||||
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/icon?family=Material+Icons' }
|
// { rel: 'stylesheet', href: 'https://fonts.googleapis.com/icon?family=Material+Icons' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -75,7 +75,10 @@ module.exports = {
|
|||||||
|
|
||||||
proxy: {
|
proxy: {
|
||||||
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
|
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
|
||||||
'/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/', pathRewrite: { '^/local/': '' } },
|
'/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
||||||
|
'/lib/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
||||||
|
'/ebook/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
||||||
|
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
||||||
'/metadata/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }
|
'/metadata/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -95,8 +98,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Build Configuration: https://go.nuxtjs.dev/config-build
|
// Build Configuration: https://go.nuxtjs.dev/config-build
|
||||||
build: {
|
build: {},
|
||||||
},
|
|
||||||
watchers: {
|
watchers: {
|
||||||
webpack: {
|
webpack: {
|
||||||
aggregateTimeout: 300,
|
aggregateTimeout: 300,
|
||||||
|
|||||||
Generated
+248
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "1.2.4",
|
"version": "1.4.10",
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2532,6 +2532,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/less/-/less-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/less/-/less-3.0.2.tgz",
|
||||||
"integrity": "sha512-62vfe65cMSzYaWmpmhqCMMNl0khen89w57mByPi1OseGfcV/LV03fO8YVrNj7rFQsRWNJo650WWyh6m7p8vZmA=="
|
"integrity": "sha512-62vfe65cMSzYaWmpmhqCMMNl0khen89w57mByPi1OseGfcV/LV03fO8YVrNj7rFQsRWNJo650WWyh6m7p8vZmA=="
|
||||||
},
|
},
|
||||||
|
"@types/localforage": {
|
||||||
|
"version": "0.0.34",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/localforage/-/localforage-0.0.34.tgz",
|
||||||
|
"integrity": "sha1-XjHDLdh5HsS5/z70fJy1Wy0NlDg=",
|
||||||
|
"requires": {
|
||||||
|
"localforage": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@types/mime": {
|
"@types/mime": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
|
||||||
@@ -3407,6 +3415,11 @@
|
|||||||
"@babel/helper-define-polyfill-provider": "^0.2.2"
|
"@babel/helper-define-polyfill-provider": "^0.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"babel-plugin-syntax-dynamic-import": {
|
||||||
|
"version": "6.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-dynamic-import/-/babel-plugin-syntax-dynamic-import-6.18.0.tgz",
|
||||||
|
"integrity": "sha1-jWomIpyDdFqZgqRBBRVyyqF5sdo="
|
||||||
|
},
|
||||||
"backo2": {
|
"backo2": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
|
||||||
@@ -4978,6 +4991,20 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz",
|
||||||
"integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk="
|
"integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk="
|
||||||
},
|
},
|
||||||
|
"d": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==",
|
||||||
|
"requires": {
|
||||||
|
"es5-ext": "^0.10.50",
|
||||||
|
"type": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"date-fns": {
|
||||||
|
"version": "2.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.25.0.tgz",
|
||||||
|
"integrity": "sha512-ovYRFnTrbGPD4nqaEqescPEv1mNwvt+UTqI3Ay9SzNtey9NZnYu6E2qCcBBgJ6/2VF1zGGygpyTDITqpQQ5e+w=="
|
||||||
|
},
|
||||||
"de-indent": {
|
"de-indent": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||||
@@ -5373,6 +5400,22 @@
|
|||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
|
||||||
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="
|
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="
|
||||||
},
|
},
|
||||||
|
"epubjs": {
|
||||||
|
"version": "0.3.88",
|
||||||
|
"resolved": "https://registry.npmjs.org/epubjs/-/epubjs-0.3.88.tgz",
|
||||||
|
"integrity": "sha512-VRumULpUELYmYwzypyfbDwoSIqDp2LXOXCtY3o55o3YDW5Zm32UjtZuX/xaWFGqyZORNNMWWQ8VlMaY1djnDYg==",
|
||||||
|
"requires": {
|
||||||
|
"@types/localforage": "0.0.34",
|
||||||
|
"core-js": "^3.6.5",
|
||||||
|
"event-emitter": "^0.3.5",
|
||||||
|
"jszip": "^3.4.0",
|
||||||
|
"localforage": "^1.7.3",
|
||||||
|
"lodash": "^4.17.15",
|
||||||
|
"marks-pane": "^1.0.9",
|
||||||
|
"path-webpack": "0.0.3",
|
||||||
|
"xmldom": "^0.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"errno": {
|
"errno": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz",
|
||||||
@@ -5431,6 +5474,35 @@
|
|||||||
"is-symbol": "^1.0.2"
|
"is-symbol": "^1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"es5-ext": {
|
||||||
|
"version": "0.10.53",
|
||||||
|
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.53.tgz",
|
||||||
|
"integrity": "sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==",
|
||||||
|
"requires": {
|
||||||
|
"es6-iterator": "~2.0.3",
|
||||||
|
"es6-symbol": "~3.1.3",
|
||||||
|
"next-tick": "~1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es6-iterator": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
|
||||||
|
"integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=",
|
||||||
|
"requires": {
|
||||||
|
"d": "1",
|
||||||
|
"es5-ext": "^0.10.35",
|
||||||
|
"es6-symbol": "^3.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es6-symbol": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==",
|
||||||
|
"requires": {
|
||||||
|
"d": "^1.0.1",
|
||||||
|
"ext": "^1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"escalade": {
|
"escalade": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
|
||||||
@@ -5490,6 +5562,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
|
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
|
||||||
},
|
},
|
||||||
|
"event-emitter": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
|
||||||
|
"integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=",
|
||||||
|
"requires": {
|
||||||
|
"d": "1",
|
||||||
|
"es5-ext": "~0.10.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"eventemitter3": {
|
"eventemitter3": {
|
||||||
"version": "4.0.7",
|
"version": "4.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||||
@@ -5580,6 +5661,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ext": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ext/-/ext-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-sdBImtzkq2HpkdRLtlLWDa6w4DX22ijZLKx8BMPUuKe1c5lbN6xwQDQCxSfxBQnHZ13ls/FH0MQZx/q/gr6FQg==",
|
||||||
|
"requires": {
|
||||||
|
"type": "^2.5.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"type": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type/-/type-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-180WMDQaIMm3+7hGXWf12GtdniDEy7nYcyFMKJn/eZz/6tSLXrUN9V0wKSbMjej0I1WHWbpREDEKHtqPQa9NNw=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"extend-shallow": {
|
"extend-shallow": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz",
|
||||||
@@ -6583,6 +6679,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz",
|
||||||
"integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw=="
|
"integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw=="
|
||||||
},
|
},
|
||||||
|
"immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps="
|
||||||
|
},
|
||||||
"import-cwd": {
|
"import-cwd": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz",
|
||||||
@@ -7105,6 +7206,27 @@
|
|||||||
"universalify": "^2.0.0"
|
"universalify": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"jszip": {
|
||||||
|
"version": "3.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.7.1.tgz",
|
||||||
|
"integrity": "sha512-ghL0tz1XG9ZEmRMcEN2vt7xabrDdqHHeykgARpmZ0BiIctWxM47Vt63ZO2dnp4QYt/xJVLLy5Zv1l/xRdh2byg==",
|
||||||
|
"requires": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"set-immediate-shim": "~1.0.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"requires": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"keygrip": {
|
"keygrip": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
|
||||||
@@ -7263,6 +7385,19 @@
|
|||||||
"launch-editor": "^2.2.1"
|
"launch-editor": "^2.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"libarchive.js": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/libarchive.js/-/libarchive.js-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-EkQfRXt9DhWwj6BnEA2TNpOf4jTnzSTUPGgE+iFxcdNqjktY8GitbDeHnx8qZA0/IukNyyBUR3oQKRdYkO+HFg=="
|
||||||
|
},
|
||||||
|
"lie": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
|
||||||
|
"integrity": "sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=",
|
||||||
|
"requires": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"lilconfig": {
|
"lilconfig": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.3.tgz",
|
||||||
@@ -7300,6 +7435,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"localforage": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
|
||||||
|
"requires": {
|
||||||
|
"lie": "3.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"locate-path": {
|
"locate-path": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
@@ -7412,6 +7555,11 @@
|
|||||||
"object-visit": "^1.0.0"
|
"object-visit": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"marks-pane": {
|
||||||
|
"version": "1.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/marks-pane/-/marks-pane-1.0.9.tgz",
|
||||||
|
"integrity": "sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg=="
|
||||||
|
},
|
||||||
"md5.js": {
|
"md5.js": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
|
||||||
@@ -7713,6 +7861,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
|
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
|
||||||
},
|
},
|
||||||
|
"next-tick": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
|
||||||
|
"integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw="
|
||||||
|
},
|
||||||
"no-case": {
|
"no-case": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
|
||||||
@@ -8334,6 +8487,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||||
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="
|
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="
|
||||||
},
|
},
|
||||||
|
"path-webpack": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-webpack/-/path-webpack-0.0.3.tgz",
|
||||||
|
"integrity": "sha1-/23sdJ7sWpRgXATV9j/FVgegOhY="
|
||||||
|
},
|
||||||
"pbkdf2": {
|
"pbkdf2": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz",
|
||||||
@@ -8346,6 +8504,11 @@
|
|||||||
"sha.js": "^2.4.8"
|
"sha.js": "^2.4.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"pdfjs-dist": {
|
||||||
|
"version": "2.6.347",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.6.347.tgz",
|
||||||
|
"integrity": "sha512-QC+h7hG2su9v/nU1wEI3SnpPIrqJODL7GTDFvR74ANKGq1AFJW16PH8VWnhpiTi9YcLSFV9xLeWSgq+ckHLdVQ=="
|
||||||
|
},
|
||||||
"picomatch": {
|
"picomatch": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz",
|
||||||
@@ -11091,6 +11254,37 @@
|
|||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
|
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
|
||||||
},
|
},
|
||||||
|
"raw-loader": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==",
|
||||||
|
"requires": {
|
||||||
|
"loader-utils": "^2.0.0",
|
||||||
|
"schema-utils": "^3.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"loader-utils": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
|
||||||
|
"requires": {
|
||||||
|
"big.js": "^5.2.2",
|
||||||
|
"emojis-list": "^3.0.0",
|
||||||
|
"json5": "^2.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schema-utils": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
|
||||||
|
"requires": {
|
||||||
|
"@types/json-schema": "^7.0.8",
|
||||||
|
"ajv": "^6.12.5",
|
||||||
|
"ajv-keywords": "^3.5.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"rc9": {
|
"rc9": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/rc9/-/rc9-1.2.0.tgz",
|
||||||
@@ -11708,6 +11902,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz",
|
||||||
"integrity": "sha1-8Tv5KOQrnD55OD5hzDmYtdFObN0="
|
"integrity": "sha1-8Tv5KOQrnD55OD5hzDmYtdFObN0="
|
||||||
},
|
},
|
||||||
|
"set-immediate-shim": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz",
|
||||||
|
"integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E="
|
||||||
|
},
|
||||||
"set-value": {
|
"set-value": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
|
||||||
@@ -12814,6 +13013,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
|
||||||
"integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY="
|
"integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY="
|
||||||
},
|
},
|
||||||
|
"type": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg=="
|
||||||
|
},
|
||||||
"type-fest": {
|
"type-fest": {
|
||||||
"version": "0.21.3",
|
"version": "0.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
|
||||||
@@ -13156,6 +13360,24 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
|
||||||
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g=="
|
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g=="
|
||||||
},
|
},
|
||||||
|
"vue-pdf": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-pdf/-/vue-pdf-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zd3lJj6CbtrawgaaDDciTDjkJMUKiLWtbEmBg5CvFn9Noe9oAO/GNy/fc5c59qGuFCJ14ibIV1baw4S07e5bSQ==",
|
||||||
|
"requires": {
|
||||||
|
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
||||||
|
"loader-utils": "^1.4.0",
|
||||||
|
"pdfjs-dist": "2.6.347",
|
||||||
|
"raw-loader": "^4.0.2",
|
||||||
|
"vue-resize-sensor": "^2.0.0",
|
||||||
|
"worker-loader": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vue-resize-sensor": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-W+y2EAI/BxS4Vlcca9scQv8ifeBFck56DRtSwWJ2H4Cw1GLNUYxiZxUHHkuzuI5JPW/cYtL1bPO5xPyEXx4LmQ=="
|
||||||
|
},
|
||||||
"vue-router": {
|
"vue-router": {
|
||||||
"version": "3.5.2",
|
"version": "3.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.2.tgz",
|
||||||
@@ -14023,6 +14245,26 @@
|
|||||||
"errno": "~0.1.7"
|
"errno": "~0.1.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"worker-loader": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-tnvNp4K3KQOpfRnD20m8xltE3eWh89Ye+5oj7wXEEHKac1P4oZ6p9oTj8/8ExqoSBnk9nu5Pr4nKfQ1hn2APJw==",
|
||||||
|
"requires": {
|
||||||
|
"loader-utils": "^1.0.0",
|
||||||
|
"schema-utils": "^0.4.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"schema-utils": {
|
||||||
|
"version": "0.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.7.tgz",
|
||||||
|
"integrity": "sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ==",
|
||||||
|
"requires": {
|
||||||
|
"ajv": "^6.1.0",
|
||||||
|
"ajv-keywords": "^3.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"wrap-ansi": {
|
"wrap-ansi": {
|
||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
@@ -14145,6 +14387,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.3.tgz",
|
||||||
"integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg=="
|
"integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg=="
|
||||||
},
|
},
|
||||||
|
"xmldom": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-z9s6k3wxE+aZHgXYxSTpGDo7BYOUfJsIRyoZiX6HTjwpwfS2wpQBQKa2fD+ShLyPkqDYo5ud7KitmLZ2Cd6r0g=="
|
||||||
|
},
|
||||||
"xtend": {
|
"xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
+6
-1
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "1.2.8",
|
"version": "1.4.16",
|
||||||
"description": "Audiobook manager and player",
|
"description": "Audiobook manager and player",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nuxt",
|
"dev": "nuxt",
|
||||||
|
"dev2": "nuxt --hostname localhost --port 1337",
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
"start": "nuxt start",
|
"start": "nuxt start",
|
||||||
"generate": "nuxt generate"
|
"generate": "nuxt generate"
|
||||||
@@ -15,9 +16,13 @@
|
|||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
"@nuxtjs/proxy": "^2.1.0",
|
"@nuxtjs/proxy": "^2.1.0",
|
||||||
"core-js": "^3.16.0",
|
"core-js": "^3.16.0",
|
||||||
|
"date-fns": "^2.25.0",
|
||||||
|
"epubjs": "^0.3.88",
|
||||||
"hls.js": "^1.0.7",
|
"hls.js": "^1.0.7",
|
||||||
|
"libarchive.js": "^1.3.0",
|
||||||
"nuxt": "^2.15.7",
|
"nuxt": "^2.15.7",
|
||||||
"nuxt-socket-io": "^1.1.18",
|
"nuxt-socket-io": "^1.1.18",
|
||||||
|
"vue-pdf": "^4.3.0",
|
||||||
"vue-toastification": "^1.7.11",
|
"vue-toastification": "^1.7.11",
|
||||||
"vuedraggable": "^2.24.3"
|
"vuedraggable": "^2.24.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<p v-if="isRoot" class="text-error py-2 text-xs">* Root user is the only user that can have an empty password</p>
|
<p v-if="isRoot" class="text-error py-2 text-xs">* Root user is the only user that can have an empty password</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn type="submit" :loading="changingPassword" color="success">Submit</ui-btn>
|
<ui-btn v-show="(password && newPassword && confirmPassword) || isRoot" type="submit" :loading="changingPassword" color="success">Submit</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,7 +61,11 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
logout() {
|
logout() {
|
||||||
this.$axios.$post('/logout').catch((error) => {
|
var rootSocket = this.$root.socket || {}
|
||||||
|
const logoutPayload = {
|
||||||
|
socketId: rootSocket.id
|
||||||
|
}
|
||||||
|
this.$axios.$post('/logout', logoutPayload).catch((error) => {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
})
|
})
|
||||||
if (localStorage.getItem('token')) {
|
if (localStorage.getItem('token')) {
|
||||||
|
|||||||
@@ -10,10 +10,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-full flex items-center text-sm py-4 bg-primary border-l border-r border-t border-gray-600">
|
<div class="w-full flex items-center text-sm py-4 bg-primary border-l border-r border-t border-gray-600">
|
||||||
<div class="font-book text-center px-4 w-12">New</div>
|
<div class="font-book text-center px-4 w-12">New</div>
|
||||||
<div class="font-book text-center px-4 w-12">Old</div>
|
<div class="font-book text-center px-4 w-24 flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByCurrent" @mousedown.prevent>
|
||||||
<div class="font-book text-center px-4 w-32">Track Parsed from Filename</div>
|
<span class="text-white">Current</span>
|
||||||
<div class="font-book text-center px-4 w-32">Track From Metadata</div>
|
<span class="material-icons ml-1" :class="currentSort === 'current' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'current' ? 'expand_more' : 'unfold_more' }}</span>
|
||||||
<div class="font-book truncate px-4 flex-grow">Filename</div>
|
</div>
|
||||||
|
<div class="font-book text-center px-4 w-32 flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByFilenameTrack" @mousedown.prevent>
|
||||||
|
<span class="text-white">Track From Filename</span>
|
||||||
|
<span class="material-icons ml-1" :class="currentSort === 'track-filename' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'track-filename' ? 'expand_more' : 'unfold_more' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="font-book text-center px-4 w-32 flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByMetadataTrack" @mousedown.prevent>
|
||||||
|
<span class="text-white">Track From Metadata</span>
|
||||||
|
<span class="material-icons ml-1" :class="currentSort === 'metadata' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'metadata' ? 'expand_more' : 'unfold_more' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="font-mono w-20 text-center">CD From Filename</div>
|
||||||
|
<div class="font-book text-center px-4 flex-grow flex items-center cursor-pointer text-white text-opacity-40 hover:text-opacity-100" @click="sortByFilename" @mousedown.prevent>
|
||||||
|
<span class="text-white">Filename</span>
|
||||||
|
<span class="material-icons ml-1" :class="currentSort === 'filename' ? 'text-white text-opacity-100 text-lg' : 'text-sm'">{{ currentSort === 'filename' ? 'expand_more' : 'unfold_more' }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- <div class="font-book truncate px-4 flex-grow">Filename</div> -->
|
||||||
|
|
||||||
<div class="font-mono w-20 text-center">Size</div>
|
<div class="font-mono w-20 text-center">Size</div>
|
||||||
<div class="font-mono w-20 text-center">Duration</div>
|
<div class="font-mono w-20 text-center">Duration</div>
|
||||||
@@ -21,19 +35,22 @@
|
|||||||
<div class="font-mono w-56">Notes</div>
|
<div class="font-mono w-56">Notes</div>
|
||||||
<div class="font-book w-40">Include in Tracklist</div>
|
<div class="font-book w-40">Include in Tracklist</div>
|
||||||
</div>
|
</div>
|
||||||
<draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false">
|
<draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
||||||
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
|
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
|
||||||
<li v-for="(audio, index) in files" :key="audio.path" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center">
|
<li v-for="(audio, index) in files" :key="audio.path" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center">
|
||||||
<div class="font-book text-center px-4 py-1 w-12">
|
<div class="font-book text-center px-4 py-1 w-12">
|
||||||
{{ audio.include ? index - numExcluded + 1 : -1 }}
|
{{ audio.include ? index - numExcluded + 1 : -1 }}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-book text-center px-4 w-12">{{ audio.index }}</div>
|
<div class="font-book text-center px-4 w-24">{{ audio.index }}</div>
|
||||||
<div class="font-book text-center px-2 w-32">
|
<div class="font-book text-center px-2 w-32">
|
||||||
{{ audio.trackNumFromFilename }}
|
{{ audio.trackNumFromFilename }}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-book text-center w-32">
|
<div class="font-book text-center w-32">
|
||||||
{{ audio.trackNumFromMeta }}
|
{{ audio.trackNumFromMeta }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="font-book truncate px-4 w-20">
|
||||||
|
{{ audio.cdNumFromFilename }}
|
||||||
|
</div>
|
||||||
<div class="font-book truncate px-4 flex-grow">
|
<div class="font-book truncate px-4 flex-grow">
|
||||||
{{ audio.filename }}
|
{{ audio.filename }}
|
||||||
</div>
|
</div>
|
||||||
@@ -56,6 +73,30 @@
|
|||||||
</li>
|
</li>
|
||||||
</transition-group>
|
</transition-group>
|
||||||
</draggable>
|
</draggable>
|
||||||
|
|
||||||
|
<div v-if="showExperimentalFeatures" class="p-4">
|
||||||
|
<ui-btn :loading="checkingTrackNumbers" small @click="checkTrackNumbers">Check Track Numbers</ui-btn>
|
||||||
|
<div v-if="trackNumData && trackNumData.length" class="w-full max-w-4xl py-2">
|
||||||
|
<table class="tracksTable">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left">Filename</th>
|
||||||
|
<th class="w-32">Index</th>
|
||||||
|
<th class="w-32"># From Metadata</th>
|
||||||
|
<th class="w-32"># From Filename</th>
|
||||||
|
<th class="w-32"># From Probe</th>
|
||||||
|
<th class="w-32">Raw Tags</th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="trackData in trackNumData" :key="trackData.filename">
|
||||||
|
<td class="text-xs">{{ trackData.filename }}</td>
|
||||||
|
<td class="text-center">{{ trackData.currentTrackNum }}</td>
|
||||||
|
<td class="text-center">{{ trackData.trackNumFromMeta }}</td>
|
||||||
|
<td class="text-center">{{ trackData.trackNumFromFilename }}</td>
|
||||||
|
<td class="text-center">{{ trackData.scanDataTrackNum }}</td>
|
||||||
|
<td class="text-left text-xs">{{ JSON.stringify(trackData.rawTags || '') }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -95,7 +136,10 @@ export default {
|
|||||||
group: 'description',
|
group: 'description',
|
||||||
ghostClass: 'ghost'
|
ghostClass: 'ghost'
|
||||||
},
|
},
|
||||||
saving: false
|
saving: false,
|
||||||
|
checkingTrackNumbers: false,
|
||||||
|
trackNumData: [],
|
||||||
|
currentSort: 'current'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -172,9 +216,55 @@ export default {
|
|||||||
},
|
},
|
||||||
streamAudiobook() {
|
streamAudiobook() {
|
||||||
return this.$store.state.streamAudiobook
|
return this.$store.state.streamAudiobook
|
||||||
|
},
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
draggableUpdate(e) {
|
||||||
|
this.currentSort = ''
|
||||||
|
},
|
||||||
|
sortByCurrent() {
|
||||||
|
this.files.sort((a, b) => {
|
||||||
|
if (a.index === null) return 1
|
||||||
|
return a.index - b.index
|
||||||
|
})
|
||||||
|
this.currentSort = 'current'
|
||||||
|
},
|
||||||
|
sortByMetadataTrack() {
|
||||||
|
this.files.sort((a, b) => {
|
||||||
|
if (a.trackNumFromMeta === null) return 1
|
||||||
|
return a.trackNumFromMeta - b.trackNumFromMeta
|
||||||
|
})
|
||||||
|
this.currentSort = 'metadata'
|
||||||
|
},
|
||||||
|
sortByFilenameTrack() {
|
||||||
|
this.files.sort((a, b) => {
|
||||||
|
if (a.trackNumFromFilename === null) return 1
|
||||||
|
return a.trackNumFromFilename - b.trackNumFromFilename
|
||||||
|
})
|
||||||
|
this.currentSort = 'track-filename'
|
||||||
|
},
|
||||||
|
sortByFilename() {
|
||||||
|
this.files.sort((a, b) => {
|
||||||
|
return (a.filename || '').toLowerCase().localeCompare((b.filename || '').toLowerCase())
|
||||||
|
})
|
||||||
|
this.currentSort = 'filename'
|
||||||
|
},
|
||||||
|
checkTrackNumbers() {
|
||||||
|
this.checkingTrackNumbers = true
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/scantracks/${this.audiobookId}`)
|
||||||
|
.then((res) => {
|
||||||
|
this.trackNumData = res
|
||||||
|
this.checkingTrackNumbers = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.checkingTrackNumbers = false
|
||||||
|
})
|
||||||
|
},
|
||||||
includeToggled(audio) {
|
includeToggled(audio) {
|
||||||
var new_index = 0
|
var new_index = 0
|
||||||
if (audio.include) {
|
if (audio.include) {
|
||||||
@@ -224,41 +314,3 @@ export default {
|
|||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
.flip-list-move {
|
|
||||||
transition: transform 0.5s;
|
|
||||||
}
|
|
||||||
.no-move {
|
|
||||||
transition: transform 0s;
|
|
||||||
}
|
|
||||||
.ghost {
|
|
||||||
opacity: 0.5;
|
|
||||||
background-color: rgba(255, 255, 255, 0.25);
|
|
||||||
}
|
|
||||||
.list-group {
|
|
||||||
min-height: 30px;
|
|
||||||
}
|
|
||||||
.list-group-item:not(.exclude) {
|
|
||||||
cursor: n-resize;
|
|
||||||
}
|
|
||||||
.list-group-item.exclude {
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.list-group-item:not(.ghost):not(.exclude):hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude) {
|
|
||||||
background-color: rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-group-item.exclude:not(.ghost) {
|
|
||||||
background-color: rgba(255, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
.list-group-item.exclude:not(.ghost):hover {
|
|
||||||
background-color: rgba(223, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -10,20 +10,73 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-10">
|
<div class="flex-grow px-10">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="mb-2">
|
<div class="mb-4">
|
||||||
<h1 class="text-2xl font-book leading-7">{{ title }}</h1>
|
<div class="flex items-end">
|
||||||
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
|
<h1 class="text-3xl font-sans">
|
||||||
<div class="w-min">
|
{{ title }}<span v-if="isDeveloperMode"> ({{ audiobook.ino }})</span>
|
||||||
<ui-tooltip :text="authorTooltipText" direction="bottom">
|
</h1>
|
||||||
<span class="text-sm text-gray-100 leading-7 whitespace-nowrap">by {{ author }}</span>
|
<p v-if="subtitle" class="ml-4 text-gray-400 text-2xl">{{ subtitle }}</p>
|
||||||
</ui-tooltip>
|
</div>
|
||||||
|
|
||||||
|
<p class="mb-2 mt-0.5 text-gray-100 text-xl">
|
||||||
|
by <nuxt-link v-if="authorFL" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(authorFL)}`" class="hover:underline">{{ authorFL }}</nuxt-link
|
||||||
|
><span v-else>Unknown</span>
|
||||||
|
</p>
|
||||||
|
<nuxt-link v-if="series" :to="`/library/${libraryId}/bookshelf/series?series=${$encode(series)}`" class="hover:underline font-sans text-gray-300 text-lg leading-7 mb-4"> {{ seriesText }}</nuxt-link>
|
||||||
|
|
||||||
|
<div v-if="narrator" class="flex py-0.5">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Narrated By</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="publishYear" class="flex py-0.5">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Publish Year</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ publishYear }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex py-0.5" v-if="genres.length">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Genres</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<template v-for="(genre, index) in genres">
|
||||||
|
<nuxt-link :key="genre" :to="`/library/${libraryId}/bookshelf?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link
|
||||||
|
><span :key="index" v-if="index < genres.length - 1">, </span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="tracks.length" class="flex py-0.5">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Duration</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ durationPretty }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="tracks.length" class="flex py-0.5">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Size</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ sizePretty }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-300 text-sm my-1">
|
|
||||||
{{ durationPretty }}<span class="px-4">{{ sizePretty }}</span>
|
<!-- Alerts -->
|
||||||
</p>
|
<div v-show="showExperimentalReadAlert" class="bg-error p-4 rounded-xl flex items-center">
|
||||||
|
<span class="material-icons text-2xl">warning_amber</span>
|
||||||
|
<p class="ml-4">Book has no audio tracks but has valid ebook files. The e-reader is experimental and can be turned on in config.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="progressPercent > 0 && progressPercent < 1" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-200 relative max-w-max" :class="resettingProgress ? 'opacity-25' : ''">
|
<div v-if="progressPercent > 0 && progressPercent < 1" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-200 relative max-w-max" :class="resettingProgress ? 'opacity-25' : ''">
|
||||||
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||||
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||||
@@ -33,19 +86,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center pt-4">
|
<div class="flex items-center pt-4">
|
||||||
<ui-btn v-if="!isMissing" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
|
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
|
||||||
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
||||||
{{ streaming ? 'Streaming' : 'Play' }}
|
{{ streaming ? 'Streaming' : 'Play' }}
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
<ui-btn v-else color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
|
<ui-btn v-else-if="isMissing || isIncomplete" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
|
||||||
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">error</span>
|
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">error</span>
|
||||||
Missing
|
{{ isMissing ? 'Missing' : 'Incomplete' }}
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
|
|
||||||
<!-- <ui-btn v-if="ebooks.length" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
|
<ui-btn v-if="showExperimentalFeatures && numEbooks" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
|
||||||
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span>
|
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span>
|
||||||
Read
|
Read
|
||||||
</ui-btn> -->
|
</ui-btn>
|
||||||
|
|
||||||
<ui-tooltip v-if="userCanUpdate" text="Edit" direction="top">
|
<ui-tooltip v-if="userCanUpdate" text="Edit" direction="top">
|
||||||
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
|
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
|
||||||
@@ -60,12 +113,10 @@
|
|||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-btn v-if="isDeveloperMode" class="mx-2" @click="openRssFeed">Open RSS Feed</ui-btn>
|
<ui-btn v-if="isDeveloperMode" class="mx-2" @click="openRssFeed">Open RSS Feed</ui-btn>
|
||||||
|
|
||||||
<div class="flex-grow" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-4">
|
<div class="my-4 max-w-2xl">
|
||||||
<p class="text-sm text-gray-100">{{ description }}</p>
|
<p class="text-base text-gray-100">{{ description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4">
|
<div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4">
|
||||||
@@ -84,15 +135,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<tables-tracks-table :tracks="tracks" :audiobook-id="audiobook.id" class="mt-6" />
|
<tables-tracks-table v-if="tracks.length" :tracks="tracks" :audiobook="audiobook" class="mt-6" />
|
||||||
|
|
||||||
<tables-audio-files-table v-if="otherAudioFiles.length" :audiobook-id="audiobook.id" :files="otherAudioFiles" class="mt-6" />
|
<tables-audio-files-table v-if="otherAudioFiles.length" :audiobook-id="audiobook.id" :files="otherAudioFiles" class="mt-6" />
|
||||||
|
|
||||||
<tables-other-files-table v-if="otherFiles.length" :audiobook-id="audiobook.id" :files="otherFiles" class="mt-6" />
|
<tables-other-files-table v-if="otherFiles.length" :audiobook="audiobook" :files="otherFiles" class="mt-6" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="area"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -111,6 +160,7 @@ export default {
|
|||||||
console.error('No audiobook...', params.id)
|
console.error('No audiobook...', params.id)
|
||||||
return redirect('/')
|
return redirect('/')
|
||||||
}
|
}
|
||||||
|
store.commit('audiobooks/addUpdate', audiobook)
|
||||||
return {
|
return {
|
||||||
audiobook
|
audiobook
|
||||||
}
|
}
|
||||||
@@ -134,6 +184,9 @@ export default {
|
|||||||
isDeveloperMode() {
|
isDeveloperMode() {
|
||||||
return this.$store.state.developerMode
|
return this.$store.state.developerMode
|
||||||
},
|
},
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
missingPartChunks() {
|
missingPartChunks() {
|
||||||
if (this.missingParts === 1) return this.missingParts[0]
|
if (this.missingParts === 1) return this.missingParts[0]
|
||||||
var chunks = []
|
var chunks = []
|
||||||
@@ -168,18 +221,42 @@ export default {
|
|||||||
isMissing() {
|
isMissing() {
|
||||||
return this.audiobook.isMissing
|
return this.audiobook.isMissing
|
||||||
},
|
},
|
||||||
|
isIncomplete() {
|
||||||
|
return this.audiobook.isIncomplete
|
||||||
|
},
|
||||||
|
showPlayButton() {
|
||||||
|
return !this.isMissing && !this.isIncomplete && this.tracks.length
|
||||||
|
},
|
||||||
missingParts() {
|
missingParts() {
|
||||||
return this.audiobook.missingParts || []
|
return this.audiobook.missingParts || []
|
||||||
},
|
},
|
||||||
invalidParts() {
|
invalidParts() {
|
||||||
return this.audiobook.invalidParts || []
|
return this.audiobook.invalidParts || []
|
||||||
},
|
},
|
||||||
|
libraryId() {
|
||||||
|
return this.audiobook.libraryId
|
||||||
|
},
|
||||||
|
folderId() {
|
||||||
|
return this.audiobook.folderId
|
||||||
|
},
|
||||||
audiobookId() {
|
audiobookId() {
|
||||||
return this.audiobook.id
|
return this.audiobook.id
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.book.title || 'No Title'
|
return this.book.title || 'No Title'
|
||||||
},
|
},
|
||||||
|
publishYear() {
|
||||||
|
return this.book.publishYear
|
||||||
|
},
|
||||||
|
narrator() {
|
||||||
|
return this.book.narrator
|
||||||
|
},
|
||||||
|
subtitle() {
|
||||||
|
return this.book.subtitle
|
||||||
|
},
|
||||||
|
genres() {
|
||||||
|
return this.book.genres || []
|
||||||
|
},
|
||||||
author() {
|
author() {
|
||||||
return this.book.author || 'Unknown'
|
return this.book.author || 'Unknown'
|
||||||
},
|
},
|
||||||
@@ -233,6 +310,15 @@ export default {
|
|||||||
ebooks() {
|
ebooks() {
|
||||||
return this.audiobook.ebooks
|
return this.audiobook.ebooks
|
||||||
},
|
},
|
||||||
|
showExperimentalReadAlert() {
|
||||||
|
return !this.tracks.length && this.ebooks.length && !this.showExperimentalFeatures
|
||||||
|
},
|
||||||
|
numEbooks() {
|
||||||
|
return this.audiobook.numEbooks
|
||||||
|
},
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
description() {
|
description() {
|
||||||
return this.book.description || ''
|
return this.book.description || ''
|
||||||
},
|
},
|
||||||
@@ -272,16 +358,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
openEbook() {
|
openEbook() {
|
||||||
var ebook = this.ebooks[0]
|
this.$store.commit('showEReader', this.audiobook)
|
||||||
console.log('Ebook', ebook)
|
|
||||||
this.$axios
|
|
||||||
.$get(`/ebook/open/${this.audiobookId}/${ebook.ino}`)
|
|
||||||
.then(() => {
|
|
||||||
console.log('opened')
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('failed', error)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
toggleRead() {
|
toggleRead() {
|
||||||
var updatePayload = {
|
var updatePayload = {
|
||||||
@@ -317,6 +394,7 @@ export default {
|
|||||||
this.$root.socket.emit('open_stream', this.audiobook.id)
|
this.$root.socket.emit('open_stream', this.audiobook.id)
|
||||||
},
|
},
|
||||||
editClick() {
|
editClick() {
|
||||||
|
this.$store.commit('setBookshelfBookIds', [])
|
||||||
this.$store.commit('showEditModal', this.audiobook)
|
this.$store.commit('showEditModal', this.audiobook)
|
||||||
},
|
},
|
||||||
lookupMetadata(index) {
|
lookupMetadata(index) {
|
||||||
@@ -334,6 +412,7 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.$get(`/api/audiobook/${this.audiobookId}`)
|
.$get(`/api/audiobook/${this.audiobookId}`)
|
||||||
.then((audiobook) => {
|
.then((audiobook) => {
|
||||||
|
console.log('Updated audiobook', audiobook)
|
||||||
this.audiobook = audiobook
|
this.audiobook = audiobook
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -362,6 +441,11 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$store.commit('audiobooks/addListener', { id: 'audiobook', audiobookId: this.audiobookId, meth: this.audiobookUpdated })
|
this.$store.commit('audiobooks/addListener', { id: 'audiobook', audiobookId: this.audiobookId, meth: this.audiobookUpdated })
|
||||||
|
|
||||||
|
// If a library has not yet been loaded, use this audiobooks library id as the current
|
||||||
|
if (!this.$store.state.audiobooks.loadedLibraryId && this.libraryId) {
|
||||||
|
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.$store.commit('audiobooks/removeListener', 'audiobook')
|
this.$store.commit('audiobooks/removeListener', 'audiobook')
|
||||||
|
|||||||
@@ -33,16 +33,16 @@
|
|||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="flex mt-2 -mx-1">
|
||||||
<div class="w-1/2 px-1">
|
<div class="w-1/2 px-1">
|
||||||
<ui-multi-select v-model="audiobook.book.genres" label="Genres" :items="genres" />
|
<ui-multi-select v-model="audiobook.book.genres" label="Genres" :items="genreItems" @newItem="newGenreItem" @removedItem="removedGenreItem" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-1">
|
<div class="flex-grow px-1">
|
||||||
<ui-multi-select v-model="audiobook.tags" label="Tags" :items="tags" />
|
<ui-multi-select v-model="audiobook.tags" label="Tags" :items="tagItems" @newItem="newTagItem" @removedItem="removedTagItem" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="flex mt-2 -mx-1">
|
||||||
<div class="w-1/2 px-1">
|
<div class="w-1/2 px-1">
|
||||||
<ui-text-input-with-label v-model="audiobook.book.narrarator" label="Narrarator" />
|
<ui-text-input-with-label v-model="audiobook.book.narrator" label="Narrator" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,7 +76,9 @@ export default {
|
|||||||
isProcessing: false,
|
isProcessing: false,
|
||||||
audiobookCopies: [],
|
audiobookCopies: [],
|
||||||
isScrollable: false,
|
isScrollable: false,
|
||||||
newSeriesItems: []
|
newSeriesItems: [],
|
||||||
|
newTagItems: [],
|
||||||
|
newGenreItems: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -86,20 +88,62 @@ export default {
|
|||||||
genres() {
|
genres() {
|
||||||
return this.$store.state.audiobooks.genres
|
return this.$store.state.audiobooks.genres
|
||||||
},
|
},
|
||||||
|
genreItems() {
|
||||||
|
return this.genres.concat(this.newGenreItems)
|
||||||
|
},
|
||||||
tags() {
|
tags() {
|
||||||
return this.$store.state.audiobooks.tags
|
return this.$store.state.audiobooks.tags
|
||||||
},
|
},
|
||||||
|
tagItems() {
|
||||||
|
return this.tags.concat(this.newTagItems)
|
||||||
|
},
|
||||||
series() {
|
series() {
|
||||||
return this.$store.state.audiobooks.series
|
return this.$store.state.audiobooks.series
|
||||||
},
|
},
|
||||||
seriesItems() {
|
seriesItems() {
|
||||||
return [...this.series, ...this.newSeriesItems]
|
return [...this.series, ...this.newSeriesItems]
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
newTagItem(item) {
|
||||||
|
if (item && !this.newTagItems.includes(item)) {
|
||||||
|
this.newTagItems.push(item)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removedTagItem(item) {
|
||||||
|
// If newly added, remove if not used on any other audiobooks
|
||||||
|
if (item && this.newTagItems.includes(item)) {
|
||||||
|
var usedByOtherAb = this.audiobookCopies.find((ab) => {
|
||||||
|
return ab.tags && ab.tags.includes(item)
|
||||||
|
})
|
||||||
|
if (!usedByOtherAb) {
|
||||||
|
this.newTagItems = this.newTagItems.filter((t) => t !== item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
newGenreItem(item) {
|
||||||
|
if (item && !this.newGenreItems.includes(item)) {
|
||||||
|
this.newGenreItems.push(item)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removedGenreItem(item) {
|
||||||
|
// If newly added, remove if not used on any other audiobooks
|
||||||
|
if (item && this.newGenreItems.includes(item)) {
|
||||||
|
var usedByOtherAb = this.audiobookCopies.find((ab) => {
|
||||||
|
return ab.book.genres && ab.book.genres.includes(item)
|
||||||
|
})
|
||||||
|
if (!usedByOtherAb) {
|
||||||
|
this.newGenreItems = this.newGenreItems.filter((t) => t !== item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
newSeriesItem(item) {
|
newSeriesItem(item) {
|
||||||
if (!item) return
|
if (item && !this.newSeriesItems.includes(item)) {
|
||||||
this.newSeriesItems.push(item)
|
this.newSeriesItems.push(item)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
seriesChanged() {
|
seriesChanged() {
|
||||||
this.newSeriesItems = this.newSeriesItems.filter((item) => {
|
this.newSeriesItems = this.newSeriesItems.filter((item) => {
|
||||||
@@ -130,7 +174,7 @@ export default {
|
|||||||
this.isProcessing = false
|
this.isProcessing = false
|
||||||
if (data.updates) {
|
if (data.updates) {
|
||||||
this.$toast.success(`Successfully updated ${data.updates} audiobooks`)
|
this.$toast.success(`Successfully updated ${data.updates} audiobooks`)
|
||||||
this.$router.replace('/library')
|
this.$router.replace(`/library/${this.currentLibraryId}`)
|
||||||
} else {
|
} else {
|
||||||
this.$toast.warning('No updates were necessary')
|
this.$toast.warning('No updates were necessary')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<div id="page-wrapper" class="page p-6 overflow-y-auto relative" :class="streamAudiobook ? 'streaming' : ''">
|
||||||
|
<app-config-side-nav />
|
||||||
|
<div class="w-full max-w-4xl mx-auto">
|
||||||
|
<nuxt-child />
|
||||||
|
</div>
|
||||||
|
<div class="fixed bottom-0 right-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
asyncData({ store, redirect }) {
|
||||||
|
if (!store.getters['user/getIsRoot']) {
|
||||||
|
redirect('/?error=unauthorized')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
streamAudiobook() {
|
||||||
|
return this.$store.state.streamAudiobook
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setDeveloperMode() {
|
||||||
|
var value = !this.$store.state.developerMode
|
||||||
|
this.$store.commit('setDeveloperMode', value)
|
||||||
|
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
|
||||||
|
}
|
||||||
|
// saveMetadataComplete(result) {
|
||||||
|
// this.savingMetadata = false
|
||||||
|
// if (!result) return
|
||||||
|
// this.$toast.success(`Metadata saved for ${result.success} audiobooks`)
|
||||||
|
// },
|
||||||
|
// saveMetadataFiles() {
|
||||||
|
// this.savingMetadata = true
|
||||||
|
// this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
|
||||||
|
// this.$root.socket.emit('save_metadata')
|
||||||
|
// }
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full">
|
||||||
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<h1 class="text-xl">Backups</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-base mb-4 text-gray-300">Backups include users, user progress, book details, server settings and covers stored in <span class="font-mono text-gray-100">/metadata/books</span>. <br />Backups <strong>do not</strong> include any files stored in your library folders.</p>
|
||||||
|
|
||||||
|
<div class="flex items-center py-2">
|
||||||
|
<ui-toggle-switch v-model="dailyBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" />
|
||||||
|
<ui-tooltip :text="dailyBackupsTooltip">
|
||||||
|
<p class="pl-4 text-lg">Run daily backups <span class="material-icons icon-text">info_outlined</span></p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center py-2">
|
||||||
|
<ui-text-input type="number" v-model="backupsToKeep" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateBackupsSettings" />
|
||||||
|
|
||||||
|
<p class="pl-4 text-lg">Number of backups to keep</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<tables-backups-table />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
updatingServerSettings: false,
|
||||||
|
dailyBackups: true,
|
||||||
|
backupsToKeep: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
dailyBackupsTooltip() {
|
||||||
|
return 'Runs at 1am every day (your server time). Saved in /metadata/backups.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateBackupsSettings() {
|
||||||
|
if (isNaN(this.backupsToKeep) || this.backupsToKeep <= 0 || this.backupsToKeep > 99) {
|
||||||
|
this.$toast.error('Invalid number of backups to keep')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var updatePayload = {
|
||||||
|
backupSchedule: this.dailyBackups ? '0 1 * * *' : false,
|
||||||
|
backupsToKeep: Number(this.backupsToKeep)
|
||||||
|
}
|
||||||
|
this.updateServerSettings(updatePayload)
|
||||||
|
},
|
||||||
|
updateServerSettings(payload) {
|
||||||
|
this.updatingServerSettings = true
|
||||||
|
this.$store
|
||||||
|
.dispatch('updateServerSettings', payload)
|
||||||
|
.then((success) => {
|
||||||
|
console.log('Updated Server Settings', success)
|
||||||
|
this.updatingServerSettings = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to update server settings', error)
|
||||||
|
this.updatingServerSettings = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
+104
-217
@@ -1,107 +1,76 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="page-wrapper" class="page p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
<div>
|
||||||
<div class="w-full max-w-4xl mx-auto">
|
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
|
||||||
|
|
||||||
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||||
<div class="flex items-center mb-2">
|
<div class="flex items-center mb-2">
|
||||||
<h1 class="text-2xl">Users</h1>
|
<h1 class="text-xl">Settings</h1>
|
||||||
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddUser">
|
|
||||||
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
|
||||||
</div>
|
|
||||||
<!-- <ui-btn small :padding-x="4" class="h-8">Create User</ui-btn> -->
|
|
||||||
</div>
|
|
||||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
|
||||||
<div class="p-4 text-center">
|
|
||||||
<table id="accounts" class="mb-8">
|
|
||||||
<tr>
|
|
||||||
<th>Username</th>
|
|
||||||
<th>Account Type</th>
|
|
||||||
<th style="width: 200px">Created At</th>
|
|
||||||
<th style="width: 100px"></th>
|
|
||||||
</tr>
|
|
||||||
<tr v-for="user in users" :key="user.id" :class="user.isActive ? '' : 'bg-error bg-opacity-20'">
|
|
||||||
<td>
|
|
||||||
{{ user.username }} <span class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
|
|
||||||
</td>
|
|
||||||
<td>{{ user.type }}</td>
|
|
||||||
<td class="text-sm font-mono">
|
|
||||||
{{ new Date(user.createdAt).toISOString() }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="w-full flex justify-center">
|
|
||||||
<span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click="editUser(user)">edit</span>
|
|
||||||
<span v-show="user.type !== 'root'" class="material-icons text-base hover:text-error cursor-pointer" @click="deleteUserClick(user)">delete</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
|
||||||
<div class="py-4 mb-8">
|
|
||||||
<p class="text-2xl">Scanner</p>
|
|
||||||
<div class="flex items-start py-2">
|
|
||||||
<div class="py-2">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" @input="updateScannerParseSubtitle" />
|
|
||||||
<ui-tooltip :text="parseSubtitleTooltip">
|
|
||||||
<p class="pl-4 text-lg">Parse Subtitles <span class="material-icons icon-text">info_outlined</span></p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<div class="w-40 flex flex-col">
|
|
||||||
<ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn>
|
|
||||||
|
|
||||||
<div class="w-full mb-4">
|
|
||||||
<ui-tooltip direction="bottom" text="Only scans audiobooks without a cover. Covers will be applied if a close match is found." class="w-full">
|
|
||||||
<ui-btn color="primary" class="w-full" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- <ui-btn color="primary" small @click="saveMetadataFiles">Save Metadata</ui-btn> -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
<div class="flex items-center py-2">
|
||||||
|
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" small :disabled="updatingServerSettings" @input="updateScannerParseSubtitle" />
|
||||||
<div class="flex items-center py-4">
|
<ui-tooltip :text="parseSubtitleTooltip">
|
||||||
<ui-btn color="bg" small :padding-x="4" :loading="isResettingAudiobooks" @click="resetAudiobooks">Reset All Audiobooks</ui-btn>
|
<p class="pl-4 text-lg">Scanner parse subtitles <span class="material-icons icon-text">info_outlined</span></p>
|
||||||
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
<div class="flex items-center py-2">
|
||||||
|
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="updateScannerFindCovers" />
|
||||||
|
<ui-tooltip :text="scannerFindCoversTooltip">
|
||||||
|
<p class="pl-4 text-lg">Scanner find covers <span class="material-icons icon-text">info_outlined</span></p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-4">
|
<div class="flex items-center py-2">
|
||||||
<p class="font-mono">v{{ $config.version }}</p>
|
<ui-toggle-switch v-model="storeCoversInAudiobookDir" :disabled="updatingServerSettings" @input="updateCoverStorageDestination" />
|
||||||
<div class="flex-grow" />
|
<ui-tooltip :text="coverDestinationTooltip">
|
||||||
<p class="pr-2 text-sm font-book text-yellow-400">Report bugs, request features, provide feedback, and contribute on <a class="underline" href="https://github.com/advplyr/audiobookshelf" target="_blank">github</a>.</p>
|
<p class="pl-4 text-lg">Store covers with audiobook <span class="material-icons icon-text">info_outlined</span></p>
|
||||||
<a href="https://github.com/advplyr/audiobookshelf" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500">
|
</ui-tooltip>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fixed bottom-0 left-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
|
|
||||||
|
|
||||||
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
|
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
||||||
|
|
||||||
|
<div class="flex items-center py-4">
|
||||||
|
<ui-btn color="bg" small :padding-x="4" :loading="isResettingAudiobooks" @click="resetAudiobooks">Reset All Audiobooks</ui-btn>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<p class="pr-2 text-sm font-book text-yellow-400">Report bugs, request features, provide feedback, and contribute on <a class="underline" href="https://github.com/advplyr/audiobookshelf" target="_blank">github</a>.</p>
|
||||||
|
<a href="https://github.com/advplyr/audiobookshelf" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
||||||
|
|
||||||
|
<div class="py-12 mb-4 opacity-60 hover:opacity-100">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
||||||
|
<ui-tooltip :text="experimentalFeaturesTooltip">
|
||||||
|
<p class="pl-4 text-lg">Experimental Features <span class="material-icons icon-text">info_outlined</span></p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="https://github.com/advplyr/audiobookshelf/discussions/75#discussion-3604812" target="_blank" class="text-blue-500 hover:text-blue-300 underline">Join the discussion</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
asyncData({ store, redirect }) {
|
|
||||||
if (!store.getters['user/getIsRoot']) {
|
|
||||||
redirect('/?error=unauthorized')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isResettingAudiobooks: false,
|
isResettingAudiobooks: false,
|
||||||
users: [],
|
storeCoversInAudiobookDir: false,
|
||||||
selectedAccount: null,
|
updatingServerSettings: false,
|
||||||
showAccountModal: false,
|
|
||||||
isDeletingUser: false,
|
|
||||||
newServerSettings: {}
|
newServerSettings: {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -109,63 +78,75 @@ export default {
|
|||||||
serverSettings(newVal, oldVal) {
|
serverSettings(newVal, oldVal) {
|
||||||
if (newVal && !oldVal) {
|
if (newVal && !oldVal) {
|
||||||
this.newServerSettings = { ...this.serverSettings }
|
this.newServerSettings = { ...this.serverSettings }
|
||||||
|
this.initServerSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
parseSubtitleTooltip() {
|
saveMetadataTooltip() {
|
||||||
return 'Extract subtitles from audiobook directory names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"'
|
return 'This will write a "metadata.nfo" file in all of your audiobook directories.'
|
||||||
|
},
|
||||||
|
experimentalFeaturesTooltip() {
|
||||||
|
return 'Features in development that could use your feedback and help testing.'
|
||||||
},
|
},
|
||||||
serverSettings() {
|
serverSettings() {
|
||||||
return this.$store.state.serverSettings
|
return this.$store.state.serverSettings
|
||||||
},
|
},
|
||||||
streamAudiobook() {
|
parseSubtitleTooltip() {
|
||||||
return this.$store.state.streamAudiobook
|
return 'Extract subtitles from audiobook directory names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"'
|
||||||
},
|
},
|
||||||
isScanning() {
|
coverDestinationTooltip() {
|
||||||
return this.$store.state.isScanning
|
return 'By default covers are stored in /metadata/books, enabling this setting will store covers inside your audiobooks directory. Only one file named "cover" will be kept.'
|
||||||
},
|
},
|
||||||
isScanningCovers() {
|
scannerFindCoversTooltip() {
|
||||||
return this.$store.state.isScanningCovers
|
return 'If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time'
|
||||||
|
},
|
||||||
|
showExperimentalFeatures: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$store.commit('setExperimentalFeatures', val)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
updateScannerFindCovers(val) {
|
||||||
|
this.updateServerSettings({
|
||||||
|
scannerFindCovers: !!val
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateCoverStorageDestination(val) {
|
||||||
|
this.newServerSettings.coverDestination = val ? this.$constants.CoverDestination.AUDIOBOOK : this.$constants.CoverDestination.METADATA
|
||||||
|
this.updateServerSettings({
|
||||||
|
coverDestination: this.newServerSettings.coverDestination
|
||||||
|
})
|
||||||
|
},
|
||||||
updateScannerParseSubtitle(val) {
|
updateScannerParseSubtitle(val) {
|
||||||
var payload = {
|
this.updateServerSettings({
|
||||||
scannerParseSubtitle: val
|
scannerParseSubtitle: !!val
|
||||||
}
|
})
|
||||||
|
},
|
||||||
|
updateServerSettings(payload) {
|
||||||
|
this.updatingServerSettings = true
|
||||||
this.$store
|
this.$store
|
||||||
.dispatch('updateServerSettings', payload)
|
.dispatch('updateServerSettings', payload)
|
||||||
.then((success) => {
|
.then((success) => {
|
||||||
console.log('Updated Server Settings', success)
|
console.log('Updated Server Settings', success)
|
||||||
|
this.updatingServerSettings = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to update server settings', error)
|
console.error('Failed to update server settings', error)
|
||||||
|
this.updatingServerSettings = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
setDeveloperMode() {
|
initServerSettings() {
|
||||||
var value = !this.$store.state.developerMode
|
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||||
this.$store.commit('setDeveloperMode', value)
|
|
||||||
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
|
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||||
},
|
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||||
scan() {
|
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
|
||||||
this.$root.socket.emit('scan')
|
this.dailyBackups = !!this.newServerSettings.backupSchedule
|
||||||
},
|
|
||||||
scanCovers() {
|
|
||||||
this.$root.socket.emit('scan_covers')
|
|
||||||
},
|
|
||||||
saveMetadataFiles() {
|
|
||||||
this.$root.socket.emit('save_metadata')
|
|
||||||
},
|
|
||||||
loadUsers() {
|
|
||||||
this.$axios
|
|
||||||
.$get('/api/users')
|
|
||||||
.then((users) => {
|
|
||||||
this.users = users
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
resetAudiobooks() {
|
resetAudiobooks() {
|
||||||
if (confirm('WARNING! This action will remove all audiobooks from the database including any updates or matches you have made. This does not do anything to your actual files. Shall we continue?')) {
|
if (confirm('WARNING! This action will remove all audiobooks from the database including any updates or matches you have made. This does not do anything to your actual files. Shall we continue?')) {
|
||||||
@@ -175,112 +156,18 @@ export default {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
this.isResettingAudiobooks = false
|
this.isResettingAudiobooks = false
|
||||||
this.$toast.success('Successfully reset audiobooks')
|
this.$toast.success('Successfully reset audiobooks')
|
||||||
|
location.reload()
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('failed to reset audiobooks', error)
|
console.error('failed to reset audiobooks', error)
|
||||||
this.isResettingAudiobooks = false
|
this.isResettingAudiobooks = false
|
||||||
this.$toast.error('Failed to reset audiobooks - stop docker and manually remove appdata')
|
this.$toast.error('Failed to reset audiobooks - manually remove the /config/audiobooks folder')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
|
||||||
clickAddUser() {
|
|
||||||
this.selectedAccount = null
|
|
||||||
this.showAccountModal = true
|
|
||||||
},
|
|
||||||
editUser(user) {
|
|
||||||
this.selectedAccount = user
|
|
||||||
this.showAccountModal = true
|
|
||||||
},
|
|
||||||
deleteUserClick(user) {
|
|
||||||
if (this.isDeletingUser) return
|
|
||||||
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
|
|
||||||
this.isDeletingUser = true
|
|
||||||
this.$axios
|
|
||||||
.$delete(`/api/user/${user.id}`)
|
|
||||||
.then((data) => {
|
|
||||||
this.isDeletingUser = false
|
|
||||||
if (data.error) {
|
|
||||||
this.$toast.error(data.error)
|
|
||||||
} else {
|
|
||||||
this.$toast.success('User deleted')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to delete user', error)
|
|
||||||
this.$toast.error('Failed to delete user')
|
|
||||||
this.isDeletingUser = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
addUpdateUser(user) {
|
|
||||||
if (!this.users) return
|
|
||||||
var index = this.users.findIndex((u) => u.id === user.id)
|
|
||||||
if (index >= 0) {
|
|
||||||
this.users.splice(index, 1, user)
|
|
||||||
} else {
|
|
||||||
this.users.push(user)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
userRemoved(user) {
|
|
||||||
this.users = this.users.filter((u) => u.id !== user.id)
|
|
||||||
},
|
|
||||||
init(attempts = 0) {
|
|
||||||
if (!this.$root.socket) {
|
|
||||||
if (attempts > 10) {
|
|
||||||
return console.error('Failed to setup socket listeners')
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
this.init(++attempts)
|
|
||||||
}, 250)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.$root.socket.on('user_added', this.addUpdateUser)
|
|
||||||
this.$root.socket.on('user_updated', this.addUpdateUser)
|
|
||||||
this.$root.socket.on('user_removed', this.userRemoved)
|
|
||||||
|
|
||||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.loadUsers()
|
this.initServerSettings()
|
||||||
this.init()
|
|
||||||
},
|
|
||||||
beforeDestroy() {
|
|
||||||
if (this.$root.socket) {
|
|
||||||
this.$root.socket.off('user_added', this.newUserAdded)
|
|
||||||
this.$root.socket.off('user_updated', this.userUpdated)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
#accounts {
|
|
||||||
table-layout: fixed;
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#accounts td,
|
|
||||||
#accounts th {
|
|
||||||
border: 1px solid #2e2e2e;
|
|
||||||
padding: 8px 8px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
#accounts tr:nth-child(even) {
|
|
||||||
background-color: #3a3a3a;
|
|
||||||
}
|
|
||||||
|
|
||||||
#accounts tr:hover {
|
|
||||||
background-color: #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
#accounts th {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding-top: 5px;
|
|
||||||
padding-bottom: 5px;
|
|
||||||
background-color: #333;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<tables-libraries-table />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<template>
|
||||||
|
<div id="page-wrapper" class="page p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
||||||
|
<div class="w-full max-w-4xl mx-auto">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<p class="text-2xl">Logger</p>
|
||||||
|
|
||||||
|
<div class="w-44">
|
||||||
|
<ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<div ref="container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="max-height: 550px; min-height: 550px">
|
||||||
|
<template v-for="(log, index) in logs">
|
||||||
|
<div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm bg-opacity-10" :class="`bg-${logColors[log.level]}`">
|
||||||
|
<p class="text-gray-400 w-40 font-mono">{{ log.timestamp.split('.')[0].split('T').join(' ') }}</p>
|
||||||
|
<p class="font-semibold w-12 text-right" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p>
|
||||||
|
<p class="px-4 logmessage">{{ log.message }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!logs.length" class="absolute top-0 left-0 w-full h-full flex flex-col items-center justify-center text-center">
|
||||||
|
<p class="text-xl text-gray-200 mb-2">No Logs</p>
|
||||||
|
<p class="text-base text-gray-400">Log listening starts when you login</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
asyncData({ store, redirect }) {
|
||||||
|
if (!store.getters['user/getIsRoot']) {
|
||||||
|
redirect('/?error=unauthorized')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
newServerSettings: {},
|
||||||
|
logColors: ['yellow-200', 'gray-400', 'info', 'warning', 'error', 'red-800', 'blue-400'],
|
||||||
|
logLevels: [
|
||||||
|
{
|
||||||
|
value: 1,
|
||||||
|
text: 'Debug'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 2,
|
||||||
|
text: 'Info'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 3,
|
||||||
|
text: 'Warn'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
serverSettings(newVal, oldVal) {
|
||||||
|
if (newVal && !oldVal) {
|
||||||
|
this.newServerSettings = { ...this.serverSettings }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
logs() {
|
||||||
|
this.updateScroll()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
logLevelItems() {
|
||||||
|
if (process.env.NODE_ENV === 'production') return this.logLevels
|
||||||
|
this.logLevels.unshift({ text: 'Trace', value: 0 })
|
||||||
|
return this.logLevels
|
||||||
|
},
|
||||||
|
logs() {
|
||||||
|
return this.$store.state.logs.logs.filter((log) => {
|
||||||
|
return log.level >= this.newServerSettings.logLevel
|
||||||
|
})
|
||||||
|
},
|
||||||
|
serverSettings() {
|
||||||
|
return this.$store.state.serverSettings
|
||||||
|
},
|
||||||
|
streamAudiobook() {
|
||||||
|
return this.$store.state.streamAudiobook
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateScroll() {
|
||||||
|
if (this.$refs.container) {
|
||||||
|
this.$refs.container.scrollTop = this.$refs.container.scrollHeight - this.$refs.container.clientHeight
|
||||||
|
}
|
||||||
|
},
|
||||||
|
logLevelUpdated(val) {
|
||||||
|
var payload = {
|
||||||
|
logLevel: Number(val)
|
||||||
|
}
|
||||||
|
this.updateServerSettings(payload)
|
||||||
|
|
||||||
|
this.$store.dispatch('logs/setLogListener', this.newServerSettings.logLevel)
|
||||||
|
this.$nextTick(this.updateScroll)
|
||||||
|
},
|
||||||
|
updateServerSettings(payload) {
|
||||||
|
this.$store
|
||||||
|
.dispatch('updateServerSettings', payload)
|
||||||
|
.then((success) => {
|
||||||
|
console.log('Updated Server Settings', success)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to update server settings', error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
init(attempts = 0) {
|
||||||
|
if (!this.$root.socket) {
|
||||||
|
if (attempts > 10) {
|
||||||
|
return console.error('Failed to setup socket listeners')
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
this.init(++attempts)
|
||||||
|
}, 250)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updated() {
|
||||||
|
this.$nextTick(this.updateScroll)
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.logmessage {
|
||||||
|
width: calc(100% - 208px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full">
|
||||||
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||||
|
<nuxt-link to="/config/users" class="text-white text-opacity-70 hover:text-opacity-100 hover:bg-white hover:bg-opacity-5 cursor-pointer rounded-full">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="h-10 w-10 flex items-center justify-center">
|
||||||
|
<span class="material-icons text-2xl">arrow_back</span>
|
||||||
|
</div>
|
||||||
|
<p class="pl-1">All Users</p>
|
||||||
|
</div>
|
||||||
|
</nuxt-link>
|
||||||
|
<div class="flex items-center mb-2 mt-4">
|
||||||
|
<widgets-online-indicator :value="!!userOnline" />
|
||||||
|
<h1 class="text-xl pl-2">{{ username }}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||||
|
<div class="py-2">
|
||||||
|
<h1 class="text-lg mb-2 text-white text-opacity-90">Reading Progress</h1>
|
||||||
|
<table v-if="userAudiobooks.length" class="userAudiobooksTable">
|
||||||
|
<tr class="bg-primary bg-opacity-40">
|
||||||
|
<th class="w-16 text-left">Book</th>
|
||||||
|
<th class="text-left"></th>
|
||||||
|
<th class="w-32">Progress</th>
|
||||||
|
<th class="w-40">Started At</th>
|
||||||
|
<th class="w-40">Last Update</th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="ab in userAudiobooks" :key="ab.audiobookId" :class="!ab.isRead ? '' : 'isRead'">
|
||||||
|
<td>
|
||||||
|
<cards-book-cover :width="50" :audiobook="ab" />
|
||||||
|
</td>
|
||||||
|
<td class="font-book">
|
||||||
|
<p>{{ ab.book ? ab.book.title : ab.audiobookTitle || 'Unknown' }}</p>
|
||||||
|
<p v-if="ab.book && ab.book.author" class="text-white text-opacity-50 text-sm font-sans">by {{ ab.book.author }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">{{ Math.floor(ab.progress * 100) }}%</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<ui-tooltip v-if="ab.startedAt" direction="top" :text="$formatDate(ab.startedAt, 'MMMM do, yyyy HH:mm')">
|
||||||
|
<p class="text-sm">{{ $dateDistanceFromNow(ab.startedAt) }}</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<ui-tooltip v-if="ab.lastUpdate" direction="top" :text="$formatDate(ab.lastUpdate, 'MMMM do, yyyy HH:mm')">
|
||||||
|
<p class="text-sm">{{ $dateDistanceFromNow(ab.lastUpdate) }}</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p v-else class="text-white text-opacity-50">Nothing read yet...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
async asyncData({ params, redirect, app }) {
|
||||||
|
var user = await app.$axios.$get(`/api/user/${params.id}`).catch((error) => {
|
||||||
|
console.error('Failed to get user', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!user) return redirect('/config/users')
|
||||||
|
return {
|
||||||
|
user
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
username() {
|
||||||
|
return this.user.username
|
||||||
|
},
|
||||||
|
userOnline() {
|
||||||
|
return this.$store.getters['users/getIsUserOnline'](this.user.id)
|
||||||
|
},
|
||||||
|
userAudiobooks() {
|
||||||
|
return Object.values(this.user.audiobooks || {}).sort((a, b) => b.lastUpdate - a.lastUpdate)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.userAudiobooksTable {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #474747;
|
||||||
|
}
|
||||||
|
.userAudiobooksTable tr:nth-child(even) {
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
}
|
||||||
|
.userAudiobooksTable tr:not(:first-child) {
|
||||||
|
background-color: #373838;
|
||||||
|
}
|
||||||
|
.userAudiobooksTable tr:hover:not(:first-child) {
|
||||||
|
background-color: #474747;
|
||||||
|
}
|
||||||
|
.userAudiobooksTable tr.isRead {
|
||||||
|
background-color: rgba(76, 175, 80, 0.1);
|
||||||
|
}
|
||||||
|
.userAudiobooksTable td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.userAudiobooksTable th {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<tables-users-table />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -20,9 +20,9 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
// asyncData({ redirect }) {
|
asyncData({ redirect, store }) {
|
||||||
// redirect('/library')
|
redirect(`/library/${store.state.libraries.currentLibraryId}`)
|
||||||
// },
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,27 +12,44 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
async asyncData({ params, query, store, app }) {
|
async asyncData({ params, query, store, app, redirect }) {
|
||||||
|
var libraryId = params.library
|
||||||
|
var library = await store.dispatch('libraries/fetch', libraryId)
|
||||||
|
if (!library) {
|
||||||
|
return redirect('/oops?message=Library not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set filter by
|
||||||
if (query.filter) {
|
if (query.filter) {
|
||||||
store.dispatch('user/updateUserSettings', { filterBy: query.filter })
|
store.dispatch('user/updateUserSettings', { filterBy: query.filter })
|
||||||
}
|
}
|
||||||
var searchResults = []
|
|
||||||
|
// Search page
|
||||||
|
var searchResults = {}
|
||||||
|
var audiobookSearchResults = []
|
||||||
var searchQuery = null
|
var searchQuery = null
|
||||||
if (params.id === 'search' && query.query) {
|
if (params.id === 'search' && query.query) {
|
||||||
searchQuery = query.query
|
searchQuery = query.query
|
||||||
searchResults = await app.$axios.$get(`/api/audiobooks?q=${query.query}`).catch((error) => {
|
|
||||||
|
searchResults = await app.$axios.$get(`/api/library/${libraryId}/search?q=${searchQuery}`).catch((error) => {
|
||||||
console.error('Search error', error)
|
console.error('Search error', error)
|
||||||
return []
|
return {}
|
||||||
})
|
})
|
||||||
|
audiobookSearchResults = searchResults.audiobooks || []
|
||||||
store.commit('audiobooks/setSearchResults', searchResults)
|
store.commit('audiobooks/setSearchResults', searchResults)
|
||||||
|
if (audiobookSearchResults.length) audiobookSearchResults.forEach((ab) => store.commit('audiobooks/addUpdate', ab.audiobook))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Series page
|
||||||
var selectedSeries = query.series ? app.$decode(query.series) : null
|
var selectedSeries = query.series ? app.$decode(query.series) : null
|
||||||
store.commit('audiobooks/setSelectedSeries', selectedSeries)
|
store.commit('audiobooks/setSelectedSeries', selectedSeries)
|
||||||
|
|
||||||
var libraryPage = params.id || ''
|
var libraryPage = params.id || ''
|
||||||
store.commit('audiobooks/setLibraryPage', libraryPage)
|
store.commit('audiobooks/setLibraryPage', libraryPage)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: libraryPage,
|
id: libraryPage,
|
||||||
|
libraryId,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
searchResults,
|
searchResults,
|
||||||
selectedSeries
|
selectedSeries
|
||||||
@@ -58,9 +75,9 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
async newQuery() {
|
async newQuery() {
|
||||||
var query = this.$route.query.query
|
var query = this.$route.query.query
|
||||||
this.searchResults = await this.$axios.$get(`/api/audiobooks?q=${query}`).catch((error) => {
|
this.searchResults = await this.$axios.$get(`/api/library/${this.libraryId}/search?q=${query}`).catch((error) => {
|
||||||
console.error('Search error', error)
|
console.error('Search error', error)
|
||||||
return []
|
return {}
|
||||||
})
|
})
|
||||||
this.searchQuery = query
|
this.searchQuery = query
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page" :class="streamAudiobook ? 'streaming' : ''">
|
||||||
|
<div class="flex h-full">
|
||||||
|
<app-side-rail />
|
||||||
|
<div class="flex-grow">
|
||||||
|
<app-book-shelf-toolbar is-home />
|
||||||
|
<app-book-shelf-categorized />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
async asyncData({ store, params, redirect }) {
|
||||||
|
var libraryId = params.library
|
||||||
|
var library = await store.dispatch('libraries/fetch', libraryId)
|
||||||
|
if (!library) {
|
||||||
|
return redirect(`/oops?message=Library "${libraryId}" not found`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
library
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
streamAudiobook() {
|
||||||
|
return this.$store.state.streamAudiobook
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
+21
-3
@@ -37,7 +37,7 @@ export default {
|
|||||||
if (this.$route.query.redirect) {
|
if (this.$route.query.redirect) {
|
||||||
this.$router.replace(this.$route.query.redirect)
|
this.$router.replace(this.$route.query.redirect)
|
||||||
} else {
|
} else {
|
||||||
this.$router.replace('/')
|
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,6 +48,24 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
setUser(user) {
|
||||||
|
// If user is not able to access main library, then set current library
|
||||||
|
// var userLibrariesAccessible = this.$store.getters['user/getLibrariesAccessible']
|
||||||
|
var userCanAccessAll = user.permissions ? !!user.permissions.accessAllLibraries : false
|
||||||
|
if (!userCanAccessAll) {
|
||||||
|
var accessibleLibraries = user.librariesAccessible || []
|
||||||
|
console.log('Setting user without all library access', accessibleLibraries)
|
||||||
|
if (accessibleLibraries.length && !accessibleLibraries.includes('main')) {
|
||||||
|
console.log('Setting current library', accessibleLibraries[0])
|
||||||
|
this.$store.commit('libraries/setCurrentLibrary', accessibleLibraries[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if (userLibrariesAccessible.length && !userLibrariesAccessible.includes('main')) {
|
||||||
|
// this.$store.commit('libraries/setCurrentLibrary', userLibrariesAccessible[0])
|
||||||
|
// }
|
||||||
|
|
||||||
|
this.$store.commit('user/setUser', user)
|
||||||
|
},
|
||||||
async submitForm() {
|
async submitForm() {
|
||||||
this.error = null
|
this.error = null
|
||||||
this.processing = true
|
this.processing = true
|
||||||
@@ -65,7 +83,7 @@ export default {
|
|||||||
if (authRes && authRes.error) {
|
if (authRes && authRes.error) {
|
||||||
this.error = authRes.error
|
this.error = authRes.error
|
||||||
} else if (authRes) {
|
} else if (authRes) {
|
||||||
this.$store.commit('user/setUser', authRes.user)
|
this.setUser(authRes.user)
|
||||||
}
|
}
|
||||||
this.processing = false
|
this.processing = false
|
||||||
},
|
},
|
||||||
@@ -83,7 +101,7 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
this.$store.commit('user/setUser', res.user)
|
this.setUser(res.user)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-screen h-screen overflow-hidden page">
|
||||||
|
<div class="flex h-1/3 items-center justify-center">
|
||||||
|
<h1 class="text-2xl">Oops... {{ message }}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
asyncData({ query }) {
|
||||||
|
return {
|
||||||
|
message: query.message || ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -4,6 +4,15 @@
|
|||||||
<article class="max-h-full overflow-y-auto relative flex flex-col rounded-md" @drop="drop" @dragover="dragover" @dragleave="dragleave" @dragenter="dragenter">
|
<article class="max-h-full overflow-y-auto relative flex flex-col rounded-md" @drop="drop" @dragover="dragover" @dragleave="dragleave" @dragenter="dragenter">
|
||||||
<h1 class="text-xl font-book px-8 pt-4 pb-2">Audiobook Uploader</h1>
|
<h1 class="text-xl font-book px-8 pt-4 pb-2">Audiobook Uploader</h1>
|
||||||
|
|
||||||
|
<div class="flex my-2 px-6">
|
||||||
|
<div class="w-1/3 px-2">
|
||||||
|
<!-- <ui-text-input-with-label v-model="title" label="Title" /> -->
|
||||||
|
<ui-dropdown v-model="selectedLibraryId" :items="libraryItems" label="Library" @input="libraryChanged" />
|
||||||
|
</div>
|
||||||
|
<div class="w-2/3 px-2">
|
||||||
|
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="!selectedLibraryId" label="Folder" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex my-2 px-6">
|
<div class="flex my-2 px-6">
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-model="title" label="Title" />
|
<ui-text-input-with-label v-model="title" label="Title" />
|
||||||
@@ -31,7 +40,7 @@
|
|||||||
|
|
||||||
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||||
<ui-btn @click="clickSelectAudioFiles">Select files</ui-btn>
|
<ui-btn @click="clickSelectAudioFiles">Select files</ui-btn>
|
||||||
<p class="text-xs text-gray-300 absolute bottom-3 right-3">{{ inputAccept.join(', ') }}</p>
|
<p class="text-xs text-gray-300 absolute bottom-3 right-3">{{ inputAccept }}</p>
|
||||||
</header>
|
</header>
|
||||||
</section>
|
</section>
|
||||||
<section v-else class="h-full overflow-auto px-8 pb-8 w-full flex flex-col">
|
<section v-else class="h-full overflow-auto px-8 pb-8 w-full flex flex-col">
|
||||||
@@ -120,14 +129,16 @@ export default {
|
|||||||
title: null,
|
title: null,
|
||||||
author: null,
|
author: null,
|
||||||
series: null,
|
series: null,
|
||||||
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a'],
|
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a', '.flac', '.opus', '.mp4'],
|
||||||
acceptedImageFormats: ['image/*'],
|
acceptedImageFormats: ['.png', '.jpg', '.jpeg', '.webp'],
|
||||||
inputAccept: ['image/*, .mp3, .m4b, .m4a'],
|
inputAccept: '.png, .jpg, .jpeg, .webp, .mp3, .m4b, .m4a, .flac, .opus, .mp4',
|
||||||
isDragOver: false,
|
isDragOver: false,
|
||||||
showUploader: true,
|
showUploader: true,
|
||||||
validAudioFiles: [],
|
validAudioFiles: [],
|
||||||
validImageFiles: [],
|
validImageFiles: [],
|
||||||
invalidFiles: []
|
invalidFiles: [],
|
||||||
|
selectedLibraryId: null,
|
||||||
|
selectedFolderId: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -140,13 +151,55 @@ export default {
|
|||||||
directory() {
|
directory() {
|
||||||
if (!this.author || !this.title) return ''
|
if (!this.author || !this.title) return ''
|
||||||
if (this.series) {
|
if (this.series) {
|
||||||
return Path.join('/audiobooks', this.author, this.series, this.title)
|
return Path.join(this.author, this.series, this.title)
|
||||||
} else {
|
} else {
|
||||||
return Path.join('/audiobooks', this.author, this.title)
|
return Path.join(this.author, this.title)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
libraries() {
|
||||||
|
return this.$store.state.libraries.libraries
|
||||||
|
},
|
||||||
|
libraryItems() {
|
||||||
|
return this.libraries.map((lib) => {
|
||||||
|
return {
|
||||||
|
value: lib.id,
|
||||||
|
text: lib.name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
selectedLibrary() {
|
||||||
|
return this.libraries.find((lib) => lib.id === this.selectedLibraryId)
|
||||||
|
},
|
||||||
|
selectedFolder() {
|
||||||
|
if (!this.selectedLibrary) return null
|
||||||
|
return this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId)
|
||||||
|
},
|
||||||
|
folderItems() {
|
||||||
|
if (!this.selectedLibrary) return []
|
||||||
|
return this.selectedLibrary.folders.map((fold) => {
|
||||||
|
return {
|
||||||
|
value: fold.id,
|
||||||
|
text: fold.fullPath
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
libraryChanged() {
|
||||||
|
if (!this.selectedLibrary && this.selectedFolderId) {
|
||||||
|
this.selectedFolderId = null
|
||||||
|
} else if (this.selectedFolderId) {
|
||||||
|
if (!this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId)) {
|
||||||
|
this.selectedFolderId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.setDefaultFolder()
|
||||||
|
},
|
||||||
|
setDefaultFolder() {
|
||||||
|
if (!this.selectedFolderId && this.selectedLibrary && this.selectedLibrary.folders.length) {
|
||||||
|
this.selectedFolderId = this.selectedLibrary.folders[0].id
|
||||||
|
}
|
||||||
|
},
|
||||||
reset() {
|
reset() {
|
||||||
this.title = ''
|
this.title = ''
|
||||||
this.author = ''
|
this.author = ''
|
||||||
@@ -218,12 +271,18 @@ export default {
|
|||||||
this.$toast.error('Must enter a title and author')
|
this.$toast.error('Must enter a title and author')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!this.selectedLibraryId || !this.selectedFolderId) {
|
||||||
|
this.$toast.error('Must select a library and folder')
|
||||||
|
return
|
||||||
|
}
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
var form = new FormData()
|
var form = new FormData()
|
||||||
form.set('title', this.title)
|
form.set('title', this.title)
|
||||||
form.set('author', this.author)
|
form.set('author', this.author)
|
||||||
form.set('series', this.series)
|
form.set('series', this.series)
|
||||||
|
form.set('library', this.selectedLibraryId)
|
||||||
|
form.set('folder', this.selectedFolderId)
|
||||||
|
|
||||||
var index = 0
|
var index = 0
|
||||||
var files = this.validAudioFiles.concat(this.validImageFiles)
|
var files = this.validAudioFiles.concat(this.validImageFiles)
|
||||||
@@ -234,21 +293,21 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.$post('/upload', form)
|
.$post('/upload', form)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.error) {
|
this.$toast.success('Audiobook Uploaded Successfully')
|
||||||
this.$toast.error(data.error)
|
this.reset()
|
||||||
} else {
|
|
||||||
this.$toast.success('Audiobook Uploaded Successfully')
|
|
||||||
this.reset()
|
|
||||||
}
|
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
this.$toast.error('Oops, something went wrong...')
|
var errorMessage = error.response && error.response.data ? error.response.data : 'Oops, something went wrong...'
|
||||||
|
this.$toast.error(errorMessage)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {
|
||||||
|
this.selectedLibraryId = this.$store.state.libraries.currentLibraryId
|
||||||
|
this.setDefaultFolder()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
export default function ({ $axios, store }) {
|
export default function ({ $axios, store }) {
|
||||||
$axios.onRequest(config => {
|
$axios.onRequest(config => {
|
||||||
|
if (!config.url) {
|
||||||
|
console.error('Axios request invalid config', config)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
|
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,28 @@ const DownloadStatus = {
|
|||||||
FAILED: 3
|
FAILED: 3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CoverDestination = {
|
||||||
|
METADATA: 0,
|
||||||
|
AUDIOBOOK: 1
|
||||||
|
}
|
||||||
|
|
||||||
const Constants = {
|
const Constants = {
|
||||||
DownloadStatus
|
DownloadStatus,
|
||||||
|
CoverDestination
|
||||||
|
}
|
||||||
|
|
||||||
|
const KeyNames = {
|
||||||
|
27: 'Escape',
|
||||||
|
32: 'Space',
|
||||||
|
37: 'ArrowLeft',
|
||||||
|
38: 'ArrowUp',
|
||||||
|
39: 'ArrowRight',
|
||||||
|
40: 'ArrowDown',
|
||||||
|
76: 'KeyL',
|
||||||
|
77: 'KeyM'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ({ app }, inject) => {
|
export default ({ app }, inject) => {
|
||||||
inject('constants', Constants)
|
inject('constants', Constants)
|
||||||
|
inject('keynames', KeyNames)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,19 @@
|
|||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
|
import { formatDistance, format } from 'date-fns'
|
||||||
|
|
||||||
|
Vue.prototype.$eventBus = new Vue()
|
||||||
|
|
||||||
Vue.prototype.$isDev = process.env.NODE_ENV !== 'production'
|
Vue.prototype.$isDev = process.env.NODE_ENV !== 'production'
|
||||||
|
|
||||||
|
Vue.prototype.$dateDistanceFromNow = (unixms) => {
|
||||||
|
if (!unixms) return ''
|
||||||
|
return formatDistance(unixms, Date.now(), { addSuffix: true })
|
||||||
|
}
|
||||||
|
Vue.prototype.$formatDate = (unixms, fnsFormat = 'MM/dd/yyyy HH:mm') => {
|
||||||
|
if (!unixms) return ''
|
||||||
|
return format(unixms, fnsFormat)
|
||||||
|
}
|
||||||
|
|
||||||
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
||||||
if (bytes === 0) {
|
if (bytes === 0) {
|
||||||
return '0 Bytes'
|
return '0 Bytes'
|
||||||
@@ -116,6 +129,19 @@ Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
|
|||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function xmlToJson(xml) {
|
||||||
|
const json = {};
|
||||||
|
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
|
||||||
|
const key = res[1] || res[3];
|
||||||
|
const value = res[2] && xmlToJson(res[2]);
|
||||||
|
json[key] = ((value && Object.keys(value).length) ? value : res[2]) || null;
|
||||||
|
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
Vue.prototype.$xmlToJson = xmlToJson
|
||||||
|
|
||||||
const encode = (text) => encodeURIComponent(Buffer.from(text).toString('base64'))
|
const encode = (text) => encodeURIComponent(Buffer.from(text).toString('base64'))
|
||||||
Vue.prototype.$encode = encode
|
Vue.prototype.$encode = encode
|
||||||
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()
|
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user