mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-01 16:30:39 +02:00
Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d38d4dcd62 | |||
| 724aa67cf4 | |||
| 5a0eb6d52b | |||
| bf9cb5ddec | |||
| e073577574 | |||
| 94f47b855b | |||
| 8a684ccdc4 | |||
| e564c80ed2 | |||
| 729654f5b2 | |||
| 6fd3317454 | |||
| 9057afb5ee | |||
| 7933eb369e | |||
| 866ee18016 | |||
| ff92fbb849 | |||
| ad4dad1c29 | |||
| 7c1789a7c2 | |||
| b6ae6d86fa | |||
| 9f66054a72 | |||
| fffa02e7e8 | |||
| 6fbd9dc260 | |||
| c6614aba05 | |||
| ee62385980 | |||
| 806017175d | |||
| 1a3a7c5823 | |||
| 550873ff87 | |||
| 4dd9f779e2 | |||
| 580b961c4a | |||
| 28b1132171 | |||
| 8b31c6555a | |||
| 8ffb4f88c9 | |||
| c5eafdfa8a | |||
| f9bf846b30 | |||
| 335bbac81d | |||
| 874c910e24 | |||
| aca88f73ad | |||
| ed80e15b7d | |||
| 3f13d35241 | |||
| 05be496817 | |||
| 1cca288031 | |||
| 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 |
@@ -6,6 +6,7 @@ npm-debug.log
|
||||
/config
|
||||
/audiobooks
|
||||
/audiobooks2
|
||||
/media/
|
||||
/metadata
|
||||
dev.js
|
||||
test/
|
||||
|
||||
@@ -4,6 +4,7 @@ node_modules/
|
||||
/config/
|
||||
/audiobooks/
|
||||
/audiobooks2/
|
||||
/media/
|
||||
/metadata/
|
||||
test/
|
||||
/client/.nuxt/
|
||||
|
||||
+3
-6
@@ -1,18 +1,15 @@
|
||||
### STAGE 0: FFMPEG ###
|
||||
FROM jrottenberg/ffmpeg:4.1-alpine AS ffmpeg
|
||||
|
||||
### STAGE 1: Build client ###
|
||||
### STAGE 0: Build client ###
|
||||
FROM node:12-alpine AS build
|
||||
WORKDIR /client
|
||||
COPY /client /client
|
||||
RUN npm install
|
||||
RUN npm run generate
|
||||
|
||||
### STAGE 2: Build server ###
|
||||
### STAGE 1: Build server ###
|
||||
FROM node:12-alpine
|
||||
RUN apk update && apk add --no-cache --update ffmpeg
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=build /client/dist /client/dist
|
||||
COPY --from=ffmpeg / /
|
||||
COPY index.js index.js
|
||||
COPY package.json package.json
|
||||
COPY server server
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
@import url('./transitions.css');
|
||||
@import './fonts.css';
|
||||
@import './transitions.css';
|
||||
@import './draggable.css';
|
||||
|
||||
.page {
|
||||
width: 100%;
|
||||
@@ -17,6 +19,9 @@
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar:horizontal {
|
||||
height: 8px;
|
||||
}
|
||||
/* ::-webkit-scrollbar:horizontal { */
|
||||
/* height: 16px; */
|
||||
/* height: 24px;
|
||||
|
||||
@@ -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-xs):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;
|
||||
}
|
||||
@@ -1,25 +1,29 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="w-full relative mb-4">
|
||||
<div class="absolute left-2 top-0 bottom-0 h-full flex items-center">
|
||||
<p ref="currentTimestamp" class="font-mono text-sm">00:00:00</p>
|
||||
</div>
|
||||
<div class="absolute right-2 top-0 bottom-0 h-full flex items-center">
|
||||
<p class="font-mono text-sm">{{ totalDurationPretty }}</p>
|
||||
<div class="w-full -mt-4">
|
||||
<div class="w-full relative mb-2">
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-red flex items-end pointer-events-none">
|
||||
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
||||
<p class="font-mono text-sm text-gray-100 pointer-events-auto"> / {{ progressPercent }}%</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute right-20 top-0 bottom-0">
|
||||
<div v-if="chapters.length" class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||
<div v-if="chapters.length" class="absolute right-20 top-0 bottom-0 h-full flex items-end">
|
||||
<div class="cursor-pointer text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||
<span class="material-icons text-3xl">format_list_bulleted</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute right-32 top-0 bottom-0">
|
||||
<controls-volume-control v-model="volume" @input="updateVolume" />
|
||||
<div class="absolute top-0 bottom-0 h-full flex items-end" :class="chapters.length ? ' right-32' : 'right-20'">
|
||||
<div class="cursor-pointer text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showBookmarks">
|
||||
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute top-0 bottom-0 h-full flex items-end" :class="chapters.length ? ' right-44' : 'right-32'">
|
||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="updateVolume" />
|
||||
</div>
|
||||
<div class="flex my-2">
|
||||
<div class="flex-grow" />
|
||||
|
||||
<div class="flex pb-2">
|
||||
<div class="flex-grow" />
|
||||
<template v-if="!loading">
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
|
||||
<span class="material-icons text-3xl">first_page</span>
|
||||
@@ -85,13 +89,17 @@ export default {
|
||||
chapters: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
bookmarks: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hlsInstance: null,
|
||||
staleHlsInstance: null,
|
||||
volume: 0.5,
|
||||
volume: 1,
|
||||
playbackRate: 1,
|
||||
trackWidth: 0,
|
||||
isPaused: true,
|
||||
@@ -116,6 +124,17 @@ export default {
|
||||
totalDurationPretty() {
|
||||
return this.$secondsToTimestamp(this.totalDuration)
|
||||
},
|
||||
timeRemaining() {
|
||||
if (!this.audioEl) return 0
|
||||
return this.totalDuration - this.currentTime
|
||||
},
|
||||
timeRemainingPretty() {
|
||||
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
||||
},
|
||||
progressPercent() {
|
||||
if (!this.totalDuration) return 0
|
||||
return Math.round((100 * this.currentTime) / this.totalDuration)
|
||||
},
|
||||
chapterTicks() {
|
||||
return this.chapters.map((chap) => {
|
||||
var perc = chap.start / this.totalDuration
|
||||
@@ -127,6 +146,9 @@ export default {
|
||||
},
|
||||
currentChapter() {
|
||||
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -134,6 +156,11 @@ export default {
|
||||
this.seek(chapter.start)
|
||||
this.showChaptersModal = false
|
||||
},
|
||||
selectBookmark(bookmark) {
|
||||
if (bookmark) {
|
||||
this.seek(bookmark.time)
|
||||
}
|
||||
},
|
||||
seek(time) {
|
||||
if (this.loading) {
|
||||
return
|
||||
@@ -232,6 +259,7 @@ export default {
|
||||
},
|
||||
restart() {
|
||||
this.seek(0)
|
||||
this.$nextTick(this.sendStreamUpdate)
|
||||
},
|
||||
backward10() {
|
||||
var newTime = this.audioEl.currentTime - 10
|
||||
@@ -346,7 +374,7 @@ export default {
|
||||
return
|
||||
}
|
||||
var lastbuff = this.getLastBufferedTime()
|
||||
this.sendStreamUpdate()
|
||||
|
||||
var bufferlen = (lastbuff / this.audioEl.duration) * this.trackWidth
|
||||
bufferlen = Math.round(bufferlen)
|
||||
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
|
||||
@@ -373,6 +401,13 @@ export default {
|
||||
|
||||
this.updateTimestamp()
|
||||
|
||||
// Send update to server when currentTime > 0
|
||||
// this prevents errors when seeking to position not yet transcoded
|
||||
// seeking to position not yet transcoded will cause audio element to set currentTime to 0
|
||||
if (this.audioEl.currentTime) {
|
||||
this.sendStreamUpdate()
|
||||
}
|
||||
|
||||
this.currentTime = this.audioEl.currentTime
|
||||
|
||||
var perc = this.audioEl.currentTime / this.audioEl.duration
|
||||
@@ -399,6 +434,7 @@ export default {
|
||||
},
|
||||
audioLoadedData() {
|
||||
this.totalDuration = this.audioEl.duration
|
||||
this.$emit('loaded', this.totalDuration)
|
||||
},
|
||||
set(url, currentTime, playOnLoad = false) {
|
||||
if (this.hlsInstance) {
|
||||
@@ -451,7 +487,11 @@ export default {
|
||||
})
|
||||
},
|
||||
showChapters() {
|
||||
this.showChaptersModal = true
|
||||
if (!this.chapters.length) return
|
||||
this.showChaptersModal = !this.showChaptersModal
|
||||
},
|
||||
showBookmarks() {
|
||||
this.$emit('showBookmarks', this.currentTime)
|
||||
},
|
||||
play() {
|
||||
if (!this.$refs.audio) {
|
||||
@@ -486,6 +526,9 @@ export default {
|
||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||
|
||||
this.audioEl = this.$refs.audio
|
||||
this.setTrackWidth()
|
||||
},
|
||||
setTrackWidth() {
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
} else {
|
||||
@@ -496,14 +539,66 @@ export default {
|
||||
if (settings.playbackRate && this.playbackRate !== 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 === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPauseClick()
|
||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.forward10()
|
||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.backward10()
|
||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.volumeUp()
|
||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.volumeDown()
|
||||
else if (action === this.$hotkeys.AudioPlayer.MUTE_UNMUTE) this.toggleMute()
|
||||
else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters()
|
||||
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
|
||||
else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate()
|
||||
else if (action === this.$hotkeys.AudioPlayer.CLOSE) this.closePlayer()
|
||||
},
|
||||
windowResize() {
|
||||
this.setTrackWidth()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('resize', this.windowResize)
|
||||
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
|
||||
this.init()
|
||||
this.$eventBus.$on('player-hotkey', this.hotkey)
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.windowResize)
|
||||
this.$store.commit('user/removeSettingsListener', 'audioplayer')
|
||||
this.$eventBus.$off('player-hotkey', this.hotkey)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,24 +7,8 @@
|
||||
<span class="material-icons text-4xl text-white">arrow_back</span>
|
||||
</a>
|
||||
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1>
|
||||
<!-- <div class="-mb-2 mr-6"> -->
|
||||
<!-- <h1 class="text-base font-book leading-3 px-1">AudioBookshelf</h1> -->
|
||||
|
||||
<!-- <div class="bg-black bg-opacity-20 rounded-sm py-1 px-2 flex items-center border border-bg mt-1.5 cursor-pointer" @click="clickLibrary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-white 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>
|
||||
|
||||
<p class="text-sm text-white text-opacity-70 leading-3 font-book pl-2">{{ libraryName }}</p>
|
||||
</div> -->
|
||||
<!-- </div> -->
|
||||
<div class="bg-black bg-opacity-20 rounded-sm py-1.5 px-3 flex items-center border border-bg text-white text-opacity-70 cursor-pointer hover:bg-opacity-10 hover:text-opacity-90" @click="clickLibrary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
|
||||
|
||||
<p class="text-sm leading-3 font-sans pl-2">{{ libraryName }}</p>
|
||||
</div>
|
||||
<ui-libraries-dropdown />
|
||||
|
||||
<controls-global-search />
|
||||
<div class="flex-grow" />
|
||||
@@ -142,9 +126,6 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickLibrary() {
|
||||
this.$store.commit('libraries/setShowModal', true)
|
||||
},
|
||||
async back() {
|
||||
var popped = await this.$store.dispatch('popRoute')
|
||||
var backTo = popped || '/'
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="outer-container">
|
||||
<!-- absolute positioned container -->
|
||||
<div class="inner-container">
|
||||
<div class="relative h-10">
|
||||
<div class="table-header" id="headerdiv">
|
||||
<table id="headertable" width="100%" cellpadding="0" cellspacing="0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="header-cell min-w-12 max-w-12"></th>
|
||||
<th class="header-cell min-w-6 max-w-6"></th>
|
||||
<th class="header-cell min-w-64 max-w-64 px-2">Title</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Author</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Series</th>
|
||||
<th class="header-cell min-w-24 max-w-24 px-2">Year</th>
|
||||
<th class="header-cell min-w-80 max-w-80 px-2">Description</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Narrator</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Genres</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Tags</th>
|
||||
<th class="header-cell min-w-24 max-w-24 px-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
<div class="absolute top-0 left-0 w-full h-full pointer-events-none" :class="isScrollable ? 'header-shadow' : ''" />
|
||||
</div>
|
||||
|
||||
<div ref="tableBody" class="table-body" onscroll="document.getElementById('headerdiv').scrollLeft = this.scrollLeft;" @scroll="tableScrolled">
|
||||
<table id="bodytable" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tbody>
|
||||
<template v-for="book in books">
|
||||
<app-book-list-row :key="book.id" :book="book" @edit="editBook" />
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
books: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isScrollable: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
checkIsScrolled() {
|
||||
if (!this.$refs.tableBody) return
|
||||
this.isScrollable = this.$refs.tableBody.scrollTop > 0
|
||||
},
|
||||
tableScrolled() {
|
||||
this.checkIsScrolled()
|
||||
},
|
||||
editBook(book) {
|
||||
var bookIds = this.books.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||
this.$store.commit('showEditModal', book)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.checkIsScrolled()
|
||||
},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.outer-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: visible;
|
||||
height: calc(100% - 50px);
|
||||
width: calc(100% - 10px);
|
||||
margin: 10px;
|
||||
}
|
||||
.inner-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.table-header {
|
||||
float: left;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
.header-shadow {
|
||||
box-shadow: 3px 8px 3px #11111155;
|
||||
}
|
||||
.table-body {
|
||||
float: left;
|
||||
height: 100%;
|
||||
width: inherit;
|
||||
overflow-y: scroll;
|
||||
padding-right: 0px;
|
||||
}
|
||||
.header-cell {
|
||||
background-color: #22222288;
|
||||
padding: 0px 4px;
|
||||
text-align: left;
|
||||
height: 40px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: semi-bold;
|
||||
}
|
||||
.body-cell {
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.book-row {
|
||||
background-color: #22222288;
|
||||
}
|
||||
.book-row:nth-child(odd) {
|
||||
background-color: #333;
|
||||
}
|
||||
.book-row.selected {
|
||||
background-color: rgba(0, 255, 0, 0.05);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<tr class="book-row" :class="selected ? 'selected' : ''">
|
||||
<td class="body-cell min-w-12 max-w-12">
|
||||
<div class="flex justify-center">
|
||||
<div class="bg-white border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center focus-within:border-blue-500 w-4 h-4" @click="selectBtnClick">
|
||||
<svg v-if="selected" class="fill-current text-green-500 pointer-events-none w-2.5 h-2.5" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="body-cell min-w-6 max-w-6">
|
||||
<cards-hover-book-cover :audiobook="book" />
|
||||
</td>
|
||||
<td class="body-cell min-w-64 max-w-64 px-2">
|
||||
<nuxt-link :to="`/audiobook/${book.id}`" class="hover:underline">
|
||||
<p class="truncate">
|
||||
{{ book.book.title }}<span v-if="book.book.subtitle">: {{ book.book.subtitle }}</span>
|
||||
</p>
|
||||
</nuxt-link>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ book.book.authorFL }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ seriesText }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-24 max-w-24 px-2">
|
||||
<p class="truncate">{{ book.book.publishYear }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-80 max-w-80 px-2">
|
||||
<p class="truncate">{{ book.book.description }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ book.book.narrator }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ genresText }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ tagsText }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-24 max-w-24 px-2">
|
||||
<div class="flex">
|
||||
<span v-if="userCanUpdate" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="editClick">edit</span>
|
||||
<span v-if="showPlayButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-2xl mx-1" @click="startStream">play_arrow</span>
|
||||
<span v-if="showReadButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="openEbook">auto_stories</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
book: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
userAudiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessingReadUpdate: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
audiobookId() {
|
||||
return this.book.id
|
||||
},
|
||||
isSelectionMode() {
|
||||
return !!this.selectedAudiobooks.length
|
||||
},
|
||||
selectedAudiobooks() {
|
||||
return this.$store.state.selectedAudiobooks
|
||||
},
|
||||
selected: {
|
||||
get() {
|
||||
return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
|
||||
},
|
||||
set(val) {
|
||||
if (this.processingBatch) return
|
||||
this.$store.commit('setAudiobookSelected', { audiobookId: this.audiobookId, selected: val })
|
||||
}
|
||||
},
|
||||
processingBatch() {
|
||||
return this.$store.state.processingBatch
|
||||
},
|
||||
bookObj() {
|
||||
return this.book.book || {}
|
||||
},
|
||||
series() {
|
||||
return this.bookObj.series || null
|
||||
},
|
||||
volumeNumber() {
|
||||
return this.bookObj.volumeNumber || null
|
||||
},
|
||||
seriesText() {
|
||||
if (!this.series) return ''
|
||||
if (!this.volumeNumber) return this.series
|
||||
return `${this.series} #${this.volumeNumber}`
|
||||
},
|
||||
genresText() {
|
||||
if (!this.bookObj.genres) return ''
|
||||
return this.bookObj.genres.join(', ')
|
||||
},
|
||||
tagsText() {
|
||||
return (this.book.tags || []).join(', ')
|
||||
},
|
||||
isMissing() {
|
||||
return this.book.isMissing
|
||||
},
|
||||
isIncomplete() {
|
||||
return this.book.isIncomplete
|
||||
},
|
||||
numEbooks() {
|
||||
return this.book.numEbooks
|
||||
},
|
||||
numTracks() {
|
||||
return this.book.numTracks
|
||||
},
|
||||
isStreaming() {
|
||||
return this.$store.getters['getAudiobookIdStreaming'] === this.audiobookId
|
||||
},
|
||||
showReadButton() {
|
||||
return this.showExperimentalFeatures && this.numEbooks
|
||||
},
|
||||
showPlayButton() {
|
||||
return !this.isMissing && !this.isIncomplete && this.numTracks && !this.isStreaming
|
||||
},
|
||||
userIsRead() {
|
||||
return this.userAudiobook ? !!this.userAudiobook.isRead : false
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectBtnClick() {
|
||||
if (this.processingBatch) return
|
||||
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
|
||||
},
|
||||
openEbook() {
|
||||
this.$store.commit('showEReader', this.book)
|
||||
},
|
||||
downloadClick() {
|
||||
this.$store.commit('showEditModalOnTab', { audiobook: this.book, tab: 'download' })
|
||||
},
|
||||
toggleRead() {
|
||||
var updatePayload = {
|
||||
isRead: !this.userIsRead
|
||||
}
|
||||
this.isProcessingReadUpdate = true
|
||||
this.$axios
|
||||
.$patch(`/api/user/audiobook/${this.audiobookId}`, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
||||
})
|
||||
},
|
||||
startStream() {
|
||||
this.$store.commit('setStreamAudiobook', this.book)
|
||||
this.$root.socket.emit('open_stream', this.book.id)
|
||||
},
|
||||
editClick() {
|
||||
this.$emit('edit', this.book)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,39 +1,56 @@
|
||||
<template>
|
||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
|
||||
<!-- Cover size widget -->
|
||||
<div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-20">
|
||||
<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>
|
||||
<p class="px-2 font-mono">{{ bookCoverWidth }}</p>
|
||||
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span>
|
||||
<div class="bookshelf overflow-hidden relative block max-h-full">
|
||||
<div ref="wrapper" class="h-full w-full relative" :class="isGridMode ? 'overflow-y-scroll' : 'overflow-hidden'">
|
||||
<!-- Cover size widget -->
|
||||
<div v-show="!isSelectionMode && isGridMode" 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>
|
||||
<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>
|
||||
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
|
||||
<div class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
||||
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
|
||||
<div class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else id="bookshelf" class="w-full flex flex-col items-center">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<div :key="index" class="w-full bookshelfRow relative">
|
||||
<div class="flex justify-center items-center">
|
||||
<template v-for="entity in shelf">
|
||||
<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-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" />
|
||||
<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 class="w-full">
|
||||
<template v-if="viewMode === 'grid'">
|
||||
<div class="w-full flex flex-col items-center">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<div :key="index" class="w-full bookshelfRow relative">
|
||||
<div class="flex justify-center items-center">
|
||||
<template v-for="entity in shelf">
|
||||
<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-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @edit="editBook" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<app-book-list :books="entities" />
|
||||
</template>
|
||||
<div v-show="!shelves.length" class="w-full py-16 text-center text-xl">
|
||||
<div v-if="page === 'search'" class="py-4 mb-6"><p class="text-2xl">No Results</p></div>
|
||||
<div v-else class="py-4">No {{ showGroups ? 'Series' : 'Audiobooks' }}</div>
|
||||
<ui-btn v-if="!showGroups && (filterBy !== 'all' || keywordFilter)" @click="clearFilter">Clear Filter</ui-btn>
|
||||
<ui-btn v-else-if="page === 'search'" to="/library">Back to Library</ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
<div v-show="!shelves.length" class="w-full py-16 text-center text-xl">
|
||||
<div v-if="page === 'search'" class="py-4 mb-6"><p class="text-2xl">No Results</p></div>
|
||||
<div v-else class="py-4">No {{ showGroups ? 'Series' : 'Audiobooks' }}</div>
|
||||
<ui-btn v-if="!showGroups && (filterBy !== 'all' || keywordFilter)" @click="clearFilter">Clear Filter</ui-btn>
|
||||
<ui-btn v-else-if="page === 'search'" to="/library">Back to Library</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,10 +62,11 @@ export default {
|
||||
page: String,
|
||||
selectedSeries: String,
|
||||
searchResults: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
searchQuery: String
|
||||
searchQuery: String,
|
||||
viewMode: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -74,12 +92,22 @@ export default {
|
||||
},
|
||||
searchResults() {
|
||||
this.$nextTick(() => {
|
||||
this.$store.commit('audiobooks/setSearchResults', this.searchResults)
|
||||
// this.$store.commit('audiobooks/setSearchResults', this.searchResults)
|
||||
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 })
|
||||
} else if (!this.$route.query.filter && this.filterBy) {
|
||||
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isGridMode() {
|
||||
return this.viewMode === 'grid'
|
||||
},
|
||||
keywordFilter() {
|
||||
return this.$store.state.audiobooks.keywordFilter
|
||||
},
|
||||
@@ -89,8 +117,12 @@ export default {
|
||||
audiobooks() {
|
||||
return this.$store.state.audiobooks.audiobooks
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.bookCoverWidth / 120
|
||||
},
|
||||
bookCoverWidth() {
|
||||
return this.availableSizes[this.selectedSizeIndex]
|
||||
var coverWidth = this.availableSizes[this.selectedSizeIndex]
|
||||
return coverWidth
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.bookCoverWidth / 120
|
||||
@@ -117,11 +149,54 @@ export default {
|
||||
showGroups() {
|
||||
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() {
|
||||
if (this.page === '') {
|
||||
return this.$store.getters['audiobooks/getFilteredAndSorted']()
|
||||
} else if (this.page === 'search') {
|
||||
return this.searchResults || []
|
||||
var audiobookSearchResults = this.searchResults ? this.searchResults.audiobooks || [] : []
|
||||
return audiobookSearchResults.map((absr) => absr.audiobook)
|
||||
} else {
|
||||
var seriesGroups = this.$store.getters['audiobooks/getSeriesGroups']()
|
||||
if (this.selectedSeries) {
|
||||
@@ -133,6 +208,11 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editBook(audiobook) {
|
||||
var bookIds = this.entities.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||
this.$store.commit('showEditModal', audiobook)
|
||||
},
|
||||
clickGroup(group) {
|
||||
this.$emit('update:selectedSeries', group.name)
|
||||
},
|
||||
@@ -171,6 +251,7 @@ export default {
|
||||
this.currSearchParams = this.buildSearchParams()
|
||||
|
||||
var entities = this.entities
|
||||
|
||||
var groups = []
|
||||
var currentRow = 0
|
||||
var currentGroup = []
|
||||
@@ -280,8 +361,9 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#bookshelf {
|
||||
.bookshelf {
|
||||
height: calc(100% - 40px);
|
||||
width: calc(100vw - 80px);
|
||||
}
|
||||
.bookshelfRow {
|
||||
background-image: url(/wood_panels.jpg);
|
||||
|
||||
@@ -51,9 +51,6 @@ export default {
|
||||
sizeMultiplier() {
|
||||
return this.bookCoverWidth / 120
|
||||
},
|
||||
signSizeMultiplier() {
|
||||
return (1 - this.sizeMultiplier) / 2 + this.sizeMultiplier
|
||||
},
|
||||
paddingX() {
|
||||
return 16 * this.sizeMultiplier
|
||||
},
|
||||
|
||||
@@ -2,9 +2,21 @@
|
||||
<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 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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,6 +65,11 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editBook(audiobook) {
|
||||
var bookIds = this.shelf.books.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||
this.$store.commit('showEditModal', audiobook)
|
||||
},
|
||||
scrolled() {
|
||||
clearTimeout(this.scrollTimer)
|
||||
this.scrollTimer = setTimeout(() => {
|
||||
@@ -62,7 +79,6 @@ export default {
|
||||
},
|
||||
scrollLeft() {
|
||||
if (!this.$refs.shelf) {
|
||||
console.error('No Shelf', this.index)
|
||||
return
|
||||
}
|
||||
this.isScrolling = true
|
||||
@@ -70,7 +86,6 @@ export default {
|
||||
},
|
||||
scrollRight() {
|
||||
if (!this.$refs.shelf) {
|
||||
console.error('No Shelf', this.index)
|
||||
return
|
||||
}
|
||||
this.isScrolling = true
|
||||
@@ -84,7 +99,6 @@ export default {
|
||||
},
|
||||
checkCanScroll() {
|
||||
if (!this.$refs.shelf) {
|
||||
console.error('No Shelf', this.index)
|
||||
return
|
||||
}
|
||||
var clientWidth = this.$refs.shelf.clientWidth
|
||||
|
||||
@@ -4,19 +4,30 @@
|
||||
<template v-if="page !== 'search' && !isHome">
|
||||
<p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
|
||||
<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">
|
||||
<span class="material-icons text-3xl text-white">west</span>
|
||||
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
<span class="material-icons text-2xl text-white">west</span>
|
||||
</div>
|
||||
<!-- <span class="material-icons text-2xl cursor-pointer" @click="seriesBackArrow">west</span> -->
|
||||
<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>
|
||||
<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 class="flex-grow" />
|
||||
|
||||
<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-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5 ml-4" @change="updateOrder" />
|
||||
<div class="h-7 ml-4 flex border border-white border-opacity-25 rounded-md">
|
||||
<div class="h-full px-2 text-white flex items-center rounded-l-md hover:bg-primary hover:bg-opacity-75 cursor-pointer" :class="isGridMode ? 'bg-primary' : 'text-opacity-70'" @click="$emit('update:viewMode', 'grid')">
|
||||
<span class="material-icons" style="font-size: 1.4rem">view_module</span>
|
||||
</div>
|
||||
<div class="h-full px-2 text-white flex items-center rounded-r-md hover:bg-primary hover:bg-opacity-75 cursor-pointer" :class="!isGridMode ? 'bg-primary' : 'text-opacity-70'" @click="$emit('update:viewMode', 'list')">
|
||||
<span class="material-icons" style="font-size: 1.4rem">view_list</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="!isHome">
|
||||
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
@@ -38,10 +49,11 @@ export default {
|
||||
isHome: Boolean,
|
||||
selectedSeries: String,
|
||||
searchResults: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
searchQuery: String
|
||||
searchQuery: String,
|
||||
viewMode: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -50,6 +62,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isGridMode() {
|
||||
return this.viewMode === 'grid'
|
||||
},
|
||||
showSortFilters() {
|
||||
return this.page === ''
|
||||
},
|
||||
@@ -57,7 +72,8 @@ export default {
|
||||
if (this.page === '') {
|
||||
return this.$store.getters['audiobooks/getFiltered']().length
|
||||
} else if (this.page === 'search') {
|
||||
return (this.searchResults || []).length
|
||||
var audiobookSearchResults = this.searchResults ? this.searchResults.audiobooks || [] : []
|
||||
return audiobookSearchResults.length
|
||||
} else {
|
||||
var groups = this.$store.getters['audiobooks/getSeriesGroups']()
|
||||
if (this.selectedSeries) {
|
||||
|
||||
@@ -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>
|
||||
@@ -11,14 +11,14 @@
|
||||
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="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="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 1rem">Library</p>
|
||||
|
||||
<div v-show="paramId === '' && !homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link :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'">
|
||||
@@ -31,6 +31,16 @@
|
||||
<div v-show="paramId === 'series'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
|
||||
<span class="material-icons text-2xl">warning</span>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 1rem">Issues</p>
|
||||
|
||||
<div v-show="showingIssues" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
<div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center">
|
||||
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<!-- <nuxt-link to="/library/collections" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
@@ -80,6 +90,19 @@ export default {
|
||||
},
|
||||
homePage() {
|
||||
return this.$route.name === 'library-library'
|
||||
},
|
||||
libraryBookshelfPage() {
|
||||
return this.$route.name === 'library-library-bookshelf-id'
|
||||
},
|
||||
showLibrary() {
|
||||
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
||||
},
|
||||
showingIssues() {
|
||||
if (!this.$route.query) return false
|
||||
return this.libraryBookshelfPage && this.$route.query.filter === 'issues'
|
||||
},
|
||||
numIssues() {
|
||||
return this.$store.getters['audiobooks/getAudiobooksWithIssues'].length
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
<template>
|
||||
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary p-4">
|
||||
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary px-4 pb-4 pt-2">
|
||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute -top-16 left-4 cursor-pointer">
|
||||
<cards-book-cover :audiobook="streamAudiobook" :width="88" />
|
||||
</nuxt-link>
|
||||
<div class="flex items-center pl-24">
|
||||
<div>
|
||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer">
|
||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer text-lg">
|
||||
{{ title }} <span v-if="stream && $isDev" class="text-xs text-gray-400">({{ stream.id }})</span>
|
||||
</nuxt-link>
|
||||
<p class="text-gray-400 text-sm hover:underline cursor-pointer" @click="filterByAuthor">by {{ author }}</p>
|
||||
<div class="text-gray-400 flex items-center">
|
||||
<span class="material-icons text-sm">person</span>
|
||||
<p class="text-base hover:underline cursor-pointer pl-2" @click="filterByAuthor">{{ author }}</p>
|
||||
</div>
|
||||
|
||||
<div class="text-gray-400 flex items-center">
|
||||
<span class="material-icons text-xs">schedule</span>
|
||||
<p class="font-mono text-sm pl-2 pb-px">{{ totalDurationPretty }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span>
|
||||
</div>
|
||||
|
||||
<audio-player ref="audioPlayer" :chapters="chapters" :loading="isLoading" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
|
||||
<audio-player ref="audioPlayer" :chapters="chapters" :loading="isLoading" :bookmarks="bookmarks" @close="cancelStream" @updateTime="updateTime" @loaded="(d) => (totalDuration = d)" @showBookmarks="showBookmarks" @hook:mounted="audioPlayerMounted" />
|
||||
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @select="selectBookmark" @create="createBookmark" @update="updateBookmark" @delete="deleteBookmark" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -24,7 +34,11 @@ export default {
|
||||
return {
|
||||
audioPlayerReady: false,
|
||||
lastServerUpdateSentSeconds: 0,
|
||||
stream: null
|
||||
stream: null,
|
||||
totalDuration: 0,
|
||||
showBookmarksModal: false,
|
||||
bookmarkCurrentTime: 0,
|
||||
bookmarkAudiobookId: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -35,6 +49,14 @@ export default {
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
userAudiobook() {
|
||||
if (!this.audiobookId) return
|
||||
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
|
||||
},
|
||||
bookmarks() {
|
||||
if (!this.userAudiobook) return []
|
||||
return this.userAudiobook.bookmarks || []
|
||||
},
|
||||
isLoading() {
|
||||
if (!this.streamAudiobook) return false
|
||||
if (this.stream) {
|
||||
@@ -46,6 +68,9 @@ export default {
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
audiobookId() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.id : null
|
||||
},
|
||||
book() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
|
||||
},
|
||||
@@ -66,9 +91,49 @@ export default {
|
||||
},
|
||||
libraryId() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.libraryId : null
|
||||
},
|
||||
totalDurationPretty() {
|
||||
return this.$secondsToTimestamp(this.totalDuration)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showBookmarks(currentTime) {
|
||||
this.bookmarkAudiobookId = this.audiobookId
|
||||
this.bookmarkCurrentTime = currentTime
|
||||
this.showBookmarksModal = true
|
||||
},
|
||||
// bookmarkCreated(time) {
|
||||
// if (time === this.bookmarkTimeProcessing) {
|
||||
// this.bookmarkTimeProcessing = 0
|
||||
// this.$toast.success(`${this.$secondsToTimestamp(time)} Bookmarked`)
|
||||
// }
|
||||
// },
|
||||
createBookmark(bookmark) {
|
||||
// this.bookmarkTimeProcessing = bookmark.time
|
||||
this.$root.socket.emit('create_bookmark', bookmark)
|
||||
this.showBookmarksModal = false
|
||||
},
|
||||
// bookmarkUpdated(time) {
|
||||
// if (time === this.bookmarkTimeProcessing) {
|
||||
// this.bookmarkTimeProcessing = 0
|
||||
// this.$toast.success(`Bookmark @${this.$secondsToTimestamp(time)} Updated`)
|
||||
// }
|
||||
// },
|
||||
updateBookmark(bookmark) {
|
||||
// this.bookmarkTimeProcessing = bookmark.time
|
||||
this.$root.socket.emit('update_bookmark', bookmark)
|
||||
this.showBookmarksModal = false
|
||||
},
|
||||
selectBookmark(bookmark) {
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.selectBookmark(bookmark)
|
||||
}
|
||||
this.showBookmarksModal = false
|
||||
},
|
||||
deleteBookmark(bookmark) {
|
||||
this.$root.socket.emit('delete_bookmark', bookmark)
|
||||
this.showBookmarksModal = false
|
||||
},
|
||||
filterByAuthor() {
|
||||
if (this.$route.name !== 'index') {
|
||||
this.$router.push(`/library/${this.libraryId || this.$store.state.libraries.currentLibraryId}/bookshelf`)
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<cards-book-cover :audiobook="audiobook" :width="40" />
|
||||
<div class="flex-grow px-2 searchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ title }}</p>
|
||||
<p class="text-xs text-gray-200 truncate">by {{ author }}</p>
|
||||
<cards-book-cover :audiobook="audiobook" :width="50" />
|
||||
<div class="flex-grow px-2 audiobookSearchCardContent">
|
||||
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</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>
|
||||
</template>
|
||||
@@ -14,7 +21,10 @@ export default {
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
search: String,
|
||||
matchKey: String,
|
||||
matchText: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -26,8 +36,35 @@ export default {
|
||||
title() {
|
||||
return this.book ? this.book.title : 'No Title'
|
||||
},
|
||||
author() {
|
||||
return this.book ? this.book.author : 'Unknown'
|
||||
subtitle() {
|
||||
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: {},
|
||||
@@ -36,9 +73,9 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.searchCardContent {
|
||||
.audiobookSearchCardContent {
|
||||
width: calc(100% - 80px);
|
||||
height: calc(40px * 1.5);
|
||||
height: 75px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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>
|
||||
@@ -8,17 +8,22 @@
|
||||
<div class="absolute -bottom-4 left-0 triangle-right" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @click.stop>
|
||||
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `${paddingY}px ${paddingX}px` }" @click.stop>
|
||||
<nuxt-link :to="isSelectionMode ? '' : `/audiobook/${audiobookId}`" class="cursor-pointer">
|
||||
<div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }" @click="clickCard" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||
<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="!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">
|
||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="showReadButton" class="h-full flex items-center justify-center">
|
||||
<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">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||
@@ -34,9 +39,14 @@
|
||||
</div>
|
||||
|
||||
<!-- EBook Icon -->
|
||||
<div v-if="showExperimentalFeatures && hasEbook" class="absolute rounded-full bg-blue-500 w-6 h-6 flex items-center justify-center bg-opacity-90" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<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 text-base">auto_stories</span>
|
||||
<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>
|
||||
@@ -67,6 +77,10 @@ export default {
|
||||
type: Number,
|
||||
default: 120
|
||||
},
|
||||
paddingY: {
|
||||
type: Number,
|
||||
default: 16
|
||||
},
|
||||
showVolumeNumber: Boolean
|
||||
},
|
||||
data() {
|
||||
@@ -90,8 +104,10 @@ export default {
|
||||
hasEbook() {
|
||||
return this.audiobook.numEbooks
|
||||
},
|
||||
hasTracks() {
|
||||
return this.audiobook.numTracks
|
||||
},
|
||||
isSelectionMode() {
|
||||
// return this.$store.getters['getNumAudiobooksSelected']
|
||||
return !!this.selectedAudiobooks.length
|
||||
},
|
||||
selectedAudiobooks() {
|
||||
@@ -150,11 +166,26 @@ export default {
|
||||
return this.userProgress ? !!this.userProgress.isRead : false
|
||||
},
|
||||
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() {
|
||||
return this.audiobook.isMissing
|
||||
},
|
||||
isIncomplete() {
|
||||
return this.audiobook.isIncomplete
|
||||
},
|
||||
hasMissingParts() {
|
||||
return this.audiobook.hasMissingParts
|
||||
},
|
||||
@@ -163,6 +194,7 @@ export default {
|
||||
},
|
||||
errorText() {
|
||||
if (this.isMissing) return 'Audiobook directory is missing!'
|
||||
else if (this.isIncomplete) return 'Audiobook has no audio tracks & ebook'
|
||||
var txt = ''
|
||||
if (this.hasMissingParts) {
|
||||
txt = `${this.hasMissingParts} missing parts.`
|
||||
@@ -203,7 +235,8 @@ export default {
|
||||
this.$root.socket.emit('open_stream', this.audiobookId)
|
||||
},
|
||||
editClick() {
|
||||
this.$store.commit('showEditModal', this.audiobook)
|
||||
// this.$store.commit('showEditModal', this.audiobook)
|
||||
this.$emit('edit', this.audiobook)
|
||||
},
|
||||
clickCard(e) {
|
||||
if (this.isSelectionMode) {
|
||||
@@ -211,6 +244,9 @@ export default {
|
||||
e.preventDefault()
|
||||
this.selectBtnClick()
|
||||
}
|
||||
},
|
||||
clickReadEBook() {
|
||||
this.$store.commit('showEReader', this.audiobook)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
<div class="flex items-center">
|
||||
<h1>{{ book.title }}</h1>
|
||||
<div class="flex-grow" />
|
||||
<p>{{ book.year || book.first_publish_date }}</p>
|
||||
<p>{{ book.publishYear }}</p>
|
||||
</div>
|
||||
<p class="text-gray-400">{{ book.author }}</p>
|
||||
<div class="w-full max-h-12 overflow-hidden">
|
||||
<p class="text-gray-500 text-xs" v-html="book.description"></p>
|
||||
<p class="text-gray-500 text-xs">{{ book.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,7 +53,7 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : null
|
||||
this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : this.book.cover || null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<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">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series?${groupType}=${groupEncode}`" class="cursor-pointer">
|
||||
<div class="rounded-sm h-full relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
||||
<nuxt-link :to="groupTo" class="cursor-pointer">
|
||||
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: 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" :group-to="groupTo" :book-items="bookItems" :width="height" :height="height" />
|
||||
|
||||
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||
<div v-if="hasValidCovers && !showExperimentalFeatures" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity z-30" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||
<p class="font-book" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ groupName }}</p>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,16 @@ export default {
|
||||
_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() {
|
||||
return this.width * 1.6
|
||||
},
|
||||
@@ -90,9 +100,20 @@ export default {
|
||||
hasValidCovers() {
|
||||
var validCovers = this.bookItems.map((bookItem) => bookItem.book.cover)
|
||||
return !!validCovers.length
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mouseoverCard() {
|
||||
this.isHovering = true
|
||||
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(true)
|
||||
},
|
||||
mouseleaveCard() {
|
||||
this.isHovering = false
|
||||
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(false)
|
||||
},
|
||||
clickCard() {
|
||||
this.$emit('click', this.group)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="wrapper" :style="{ height: height + 'px', width: width + 'px' }" class="relative">
|
||||
<div ref="wrapper" :style="{ height: height + 'px', width: width + 'px' }" class="relative" @mouseover="mouseoverCover" @mouseleave="mouseleaveCover">
|
||||
<div v-if="noValidCovers" class="absolute top-0 left-0 w-full h-full flex items-center justify-center box-shadow-book" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier + 'rem' }">{{ name }}</p>
|
||||
</div>
|
||||
@@ -15,12 +15,21 @@ export default {
|
||||
default: () => []
|
||||
},
|
||||
width: Number,
|
||||
height: Number
|
||||
height: Number,
|
||||
groupTo: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
noValidCovers: false,
|
||||
coverDiv: null
|
||||
coverDiv: null,
|
||||
isHovering: false,
|
||||
coverWrapperEl: null,
|
||||
coverImageEls: [],
|
||||
coverWidth: 0,
|
||||
offsetIncrement: 0,
|
||||
isFannedOut: false,
|
||||
isDetached: false,
|
||||
isAttaching: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -37,13 +46,152 @@ export default {
|
||||
computed: {
|
||||
sizeMultiplier() {
|
||||
return this.width / 192
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mouseoverCover() {
|
||||
if (this.showExperimentalFeatures) this.setHover(true)
|
||||
},
|
||||
mouseleaveCover() {
|
||||
if (this.showExperimentalFeatures) this.setHover(false)
|
||||
},
|
||||
detchCoverWrapper() {
|
||||
if (!this.coverWrapperEl || !this.$refs.wrapper || this.isDetached) return
|
||||
|
||||
this.coverWrapperEl.remove()
|
||||
|
||||
this.isDetached = true
|
||||
document.body.appendChild(this.coverWrapperEl)
|
||||
this.coverWrapperEl.addEventListener('mouseleave', this.mouseleaveCover)
|
||||
|
||||
this.coverWrapperEl.style.position = 'absolute'
|
||||
this.coverWrapperEl.style.zIndex = 40
|
||||
|
||||
this.updatePosition()
|
||||
},
|
||||
attachCoverWrapper() {
|
||||
if (!this.coverWrapperEl || !this.$refs.wrapper || !this.isDetached) return
|
||||
|
||||
this.coverWrapperEl.remove()
|
||||
this.coverWrapperEl.style.position = 'relative'
|
||||
this.coverWrapperEl.style.left = 'unset'
|
||||
this.coverWrapperEl.style.top = 'unset'
|
||||
this.coverWrapperEl.style.width = this.$refs.wrapper.clientWidth + 'px'
|
||||
|
||||
this.$refs.wrapper.appendChild(this.coverWrapperEl)
|
||||
console.log('Appended to wrapper', this.$refs.wrapper.children)
|
||||
this.isDetached = false
|
||||
},
|
||||
updatePosition() {
|
||||
var rect = this.$refs.wrapper.getBoundingClientRect()
|
||||
this.coverWrapperEl.style.top = rect.top + window.scrollY + 'px'
|
||||
|
||||
this.coverWrapperEl.style.left = rect.left + window.scrollX + 4 + 'px'
|
||||
|
||||
this.coverWrapperEl.style.height = rect.height + 'px'
|
||||
this.coverWrapperEl.style.width = rect.width + 'px'
|
||||
},
|
||||
setHover(val) {
|
||||
if (this.isAttaching) return
|
||||
if (val && !this.isHovering) {
|
||||
this.detchCoverWrapper()
|
||||
this.fanOutCovers()
|
||||
} else if (!val && this.isHovering) {
|
||||
this.isAttaching = true
|
||||
this.reverseFan()
|
||||
setTimeout(() => {
|
||||
this.attachCoverWrapper()
|
||||
this.isAttaching = false
|
||||
}, 100)
|
||||
}
|
||||
this.isHovering = val
|
||||
},
|
||||
fanOutCovers() {
|
||||
if (this.coverImageEls.length < 2 || this.isFannedOut) return
|
||||
this.isFannedOut = true
|
||||
var fanCoverWidth = this.coverWidth * 0.75
|
||||
var maximumWidth = window.innerWidth - 80
|
||||
|
||||
var totalFanWidth = (this.coverImageEls.length + 1) * fanCoverWidth
|
||||
|
||||
// If Fan width is too large, set new fan cover width
|
||||
if (totalFanWidth > maximumWidth) {
|
||||
fanCoverWidth = maximumWidth / (this.coverImageEls.length + 1)
|
||||
}
|
||||
|
||||
var fanWidth = (this.coverImageEls.length - 1) * fanCoverWidth
|
||||
var offsetLeft = (-1 * fanWidth) / 2
|
||||
|
||||
var rect = this.$refs.wrapper.getBoundingClientRect()
|
||||
|
||||
// If fan is going off page left or right, make adjustment
|
||||
var leftEdge = rect.left + offsetLeft
|
||||
var rightEdge = rect.left + rect.width - offsetLeft
|
||||
if (leftEdge < 0) {
|
||||
offsetLeft += leftEdge * -1
|
||||
}
|
||||
if (rightEdge + 80 > window.innerWidth) {
|
||||
var difference = rightEdge + 80 - window.innerWidth
|
||||
offsetLeft -= difference / 2
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.coverImageEls.length; i++) {
|
||||
var coverEl = this.coverImageEls[i]
|
||||
|
||||
// Series name card pop out further
|
||||
if (i === this.coverImageEls.length - 1) {
|
||||
offsetLeft += fanCoverWidth * 0.25
|
||||
}
|
||||
|
||||
coverEl.style.transform = `translateX(${offsetLeft}px)`
|
||||
offsetLeft += fanCoverWidth
|
||||
|
||||
var coverOverlay = document.createElement('div')
|
||||
coverOverlay.className = 'absolute top-0 left-0 w-full h-full hover:bg-black hover:bg-opacity-40 text-white text-opacity-0 hover:text-opacity-100 flex items-center justify-center cursor-pointer'
|
||||
|
||||
if (coverEl.dataset.volumeNumber) {
|
||||
var pEl = document.createElement('p')
|
||||
pEl.className = 'text-2xl'
|
||||
pEl.textContent = `#${coverEl.dataset.volumeNumber}`
|
||||
coverOverlay.appendChild(pEl)
|
||||
}
|
||||
if (coverEl.dataset.audiobookId) {
|
||||
let audiobookId = coverEl.dataset.audiobookId
|
||||
coverOverlay.addEventListener('click', (e) => {
|
||||
this.$router.push(`/audiobook/${audiobookId}`)
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
})
|
||||
} else {
|
||||
// Is Series
|
||||
coverOverlay.addEventListener('click', (e) => {
|
||||
this.$router.push(this.groupTo)
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
})
|
||||
}
|
||||
|
||||
coverEl.appendChild(coverOverlay)
|
||||
}
|
||||
},
|
||||
reverseFan() {
|
||||
if (this.coverImageEls.length < 2 || !this.isFannedOut) return
|
||||
this.isFannedOut = false
|
||||
for (let i = 0; i < this.coverImageEls.length; i++) {
|
||||
var coverEl = this.coverImageEls[i]
|
||||
coverEl.style.transform = 'translateX(0px)'
|
||||
if (coverEl.lastChild) coverEl.lastChild.remove() // Remove cover overlay
|
||||
}
|
||||
},
|
||||
getCoverUrl(book) {
|
||||
return this.$store.getters['audiobooks/getBookCoverSrc'](book, '')
|
||||
},
|
||||
async buildCoverImg(src, bgCoverWidth, offsetLeft, forceCoverBg = false) {
|
||||
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
|
||||
var src = coverData.coverUrl
|
||||
|
||||
var showCoverBg =
|
||||
forceCoverBg ||
|
||||
(await new Promise((resolve) => {
|
||||
@@ -72,8 +220,11 @@ export default {
|
||||
imgdiv.style.height = this.height + 'px'
|
||||
imgdiv.style.width = bgCoverWidth + 'px'
|
||||
imgdiv.style.left = offsetLeft + 'px'
|
||||
imgdiv.className = 'absolute top-0 box-shadow-book'
|
||||
imgdiv.style.boxShadow = '-4px 0px 4px #11111166'
|
||||
imgdiv.style.zIndex = zIndex
|
||||
imgdiv.dataset.audiobookId = coverData.id
|
||||
imgdiv.dataset.volumeNumber = coverData.volumeNumber || ''
|
||||
imgdiv.className = 'absolute top-0 box-shadow-book transition-transform'
|
||||
imgdiv.style.boxShadow = '4px 0px 4px #11111166'
|
||||
// imgdiv.style.transform = 'skew(0deg, 15deg)'
|
||||
|
||||
if (showCoverBg) {
|
||||
@@ -100,12 +251,36 @@ export default {
|
||||
imgdiv.appendChild(img)
|
||||
return imgdiv
|
||||
},
|
||||
createSeriesNameCover(offsetLeft) {
|
||||
var imgdiv = document.createElement('div')
|
||||
imgdiv.style.height = this.height + 'px'
|
||||
imgdiv.style.width = this.height / 1.6 + 'px'
|
||||
imgdiv.style.left = offsetLeft + 'px'
|
||||
imgdiv.className = 'absolute top-0 box-shadow-book transition-transform flex items-center justify-center'
|
||||
imgdiv.style.boxShadow = '4px 0px 4px #11111166'
|
||||
imgdiv.style.backgroundColor = '#111'
|
||||
|
||||
var innerP = document.createElement('p')
|
||||
innerP.textContent = this.name
|
||||
innerP.className = 'text-sm font-book text-white'
|
||||
imgdiv.appendChild(innerP)
|
||||
|
||||
return imgdiv
|
||||
},
|
||||
async init() {
|
||||
if (this.coverDiv) {
|
||||
this.coverDiv.remove()
|
||||
this.coverDiv = null
|
||||
}
|
||||
var validCovers = this.bookItems.map((bookItem) => this.getCoverUrl(bookItem)).filter((b) => b !== '')
|
||||
var validCovers = this.bookItems
|
||||
.map((bookItem) => {
|
||||
return {
|
||||
id: bookItem.id,
|
||||
volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null,
|
||||
coverUrl: this.getCoverUrl(bookItem)
|
||||
}
|
||||
})
|
||||
.filter((b) => b.coverUrl !== '')
|
||||
if (!validCovers.length) {
|
||||
this.noValidCovers = true
|
||||
return
|
||||
@@ -118,22 +293,40 @@ export default {
|
||||
coverWidth = this.height / 1.6
|
||||
widthPer = (this.width - coverWidth) / (validCovers.length - 1)
|
||||
}
|
||||
this.coverWidth = coverWidth
|
||||
this.offsetIncrement = widthPer
|
||||
|
||||
var outerdiv = document.createElement('div')
|
||||
this.coverWrapperEl = outerdiv
|
||||
outerdiv.className = 'w-full h-full relative'
|
||||
|
||||
var coverImageEls = []
|
||||
var offsetLeft = 0
|
||||
for (let i = 0; i < validCovers.length; i++) {
|
||||
var offsetLeft = widthPer * i
|
||||
var img = await this.buildCoverImg(validCovers[i], coverWidth, offsetLeft, validCovers.length === 1)
|
||||
offsetLeft = widthPer * i
|
||||
var zIndex = validCovers.length - i
|
||||
var img = await this.buildCoverImg(validCovers[i], coverWidth, offsetLeft, zIndex, validCovers.length === 1)
|
||||
outerdiv.appendChild(img)
|
||||
coverImageEls.push(img)
|
||||
}
|
||||
|
||||
if (this.showExperimentalFeatures) {
|
||||
var seriesNameCover = this.createSeriesNameCover(offsetLeft)
|
||||
outerdiv.appendChild(seriesNameCover)
|
||||
coverImageEls.push(seriesNameCover)
|
||||
}
|
||||
|
||||
this.coverImageEls = coverImageEls
|
||||
|
||||
if (this.$refs.wrapper) {
|
||||
this.coverDiv = outerdiv
|
||||
this.$refs.wrapper.appendChild(outerdiv)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {},
|
||||
beforeDestroy() {
|
||||
if (this.coverWrapperEl) this.coverWrapperEl.remove()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div ref="container" @mouseover="mouseover" @mouseleave="mouseleave" class="relative">
|
||||
<cards-book-cover :width="24" :audiobook="audiobook" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
placeholderUrl() {
|
||||
return '/book_placeholder.jpg'
|
||||
},
|
||||
fullCoverUrl() {
|
||||
return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
|
||||
},
|
||||
hasCover() {
|
||||
return !!this.audiobook.book.cover
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,10 +1,14 @@
|
||||
<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 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>
|
||||
<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 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: {
|
||||
type: Number,
|
||||
default: 120
|
||||
}
|
||||
},
|
||||
showOpenNewTab: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
imageFailed: false,
|
||||
showCoverBg: false
|
||||
showCoverBg: false,
|
||||
isHovering: false,
|
||||
naturalHeight: 0,
|
||||
naturalWidth: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -60,6 +68,9 @@ export default {
|
||||
imageLoaded() {
|
||||
if (this.$refs.cover) {
|
||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||
this.naturalHeight = naturalHeight
|
||||
this.naturalWidth = naturalWidth
|
||||
|
||||
var aspectRatio = naturalHeight / naturalWidth
|
||||
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>
|
||||
@@ -101,6 +101,11 @@ export default {
|
||||
text: 'Progress',
|
||||
value: 'progress',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Issues',
|
||||
value: 'issues',
|
||||
sublist: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-64 ml-8 relative">
|
||||
<div class="w-80 ml-6 relative">
|
||||
<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" />
|
||||
</form>
|
||||
@@ -7,23 +7,51 @@
|
||||
<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>
|
||||
</div>
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -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">
|
||||
<li v-if="isTyping" class="py-2 px-2">
|
||||
<p>Typing...</p>
|
||||
<p>Thinking...</p>
|
||||
</li>
|
||||
<li v-else-if="isFetching" class="py-2 px-2">
|
||||
<p>Fetching...</p>
|
||||
</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>
|
||||
</li>
|
||||
<template v-else>
|
||||
<template v-for="item in items">
|
||||
<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-if="item.type === 'audiobook'">
|
||||
<cards-audiobook-search-card :audiobook="item.data" />
|
||||
</template>
|
||||
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
|
||||
<template v-for="item in audiobookResults">
|
||||
<li :key="item.audiobook.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/audiobook/${item.audiobook.id}`">
|
||||
<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>
|
||||
</template>
|
||||
</template>
|
||||
@@ -42,7 +70,10 @@ export default {
|
||||
isTyping: false,
|
||||
isFetching: false,
|
||||
search: null,
|
||||
items: [],
|
||||
audiobookResults: [],
|
||||
authorResults: [],
|
||||
seriesResults: [],
|
||||
tagResults: [],
|
||||
searchTimeout: null,
|
||||
lastSearch: null
|
||||
}
|
||||
@@ -53,16 +84,29 @@ export default {
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
totalResults() {
|
||||
return this.audiobookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitSearch() {
|
||||
if (!this.search) return
|
||||
this.$router.push(`/library/${this.currentLibraryId}/bookshelf/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.items = []
|
||||
this.lastSearch = null
|
||||
this.audiobookResults = []
|
||||
this.authorResults = []
|
||||
this.seriesResults = []
|
||||
this.tagResults = []
|
||||
this.showMenu = false
|
||||
this.isFetching = false
|
||||
this.isTyping = false
|
||||
clearTimeout(this.searchTimeout)
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.input) {
|
||||
this.$refs.input.blur()
|
||||
@@ -86,22 +130,24 @@ export default {
|
||||
return
|
||||
}
|
||||
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)
|
||||
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
|
||||
if (!this.showMenu) {
|
||||
return
|
||||
}
|
||||
|
||||
this.items = results.map((res) => {
|
||||
return {
|
||||
id: res.id,
|
||||
data: res,
|
||||
type: 'audiobook'
|
||||
}
|
||||
})
|
||||
},
|
||||
inputUpdate(val) {
|
||||
clearTimeout(this.searchTimeout)
|
||||
@@ -112,23 +158,23 @@ export default {
|
||||
}
|
||||
this.isTyping = true
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
// Canceled search
|
||||
if (!this.isTyping) return
|
||||
|
||||
this.isTyping = false
|
||||
this.runSearch(val)
|
||||
}, 1000)
|
||||
},
|
||||
clickedOption(option) {
|
||||
if (option.type === 'audiobook') {
|
||||
this.$router.push(`/audiobook/${option.data.id}`)
|
||||
}
|
||||
}, 750)
|
||||
},
|
||||
clickClear() {
|
||||
if (this.search) {
|
||||
this.search = null
|
||||
this.items = []
|
||||
this.showMenu = false
|
||||
}
|
||||
this.clearResults()
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.globalSearchMenu {
|
||||
max-height: 80vh;
|
||||
}
|
||||
</style>
|
||||
@@ -114,6 +114,9 @@ export default {
|
||||
this.volume = this.lastValue || 0.5
|
||||
}
|
||||
},
|
||||
toggleMute() {
|
||||
this.clickVolumeIcon()
|
||||
},
|
||||
clickVolumeTrack(e) {
|
||||
var vol = e.offsetX / this.trackWidth
|
||||
vol = Math.min(Math.max(vol, 0), 1)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -64,6 +64,19 @@
|
||||
<ui-toggle-switch v-model="newUser.permissions.upload" />
|
||||
</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 class="flex pt-4">
|
||||
@@ -116,14 +129,31 @@ export default {
|
||||
},
|
||||
isEditingRoot() {
|
||||
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: {
|
||||
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() {
|
||||
if (!this.newUser.username) {
|
||||
this.$toast.error('Enter a username')
|
||||
return
|
||||
}
|
||||
if (!this.newUser.permissions.accessAllLibraries && !this.newUser.librariesAccessible.length) {
|
||||
this.$toast.error('Must select at least one library')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isNew) {
|
||||
this.submitCreateAccount()
|
||||
@@ -139,6 +169,7 @@ export default {
|
||||
if (account.type === 'root' && !account.isActive) return
|
||||
|
||||
this.processing = true
|
||||
console.log('Calling update', account)
|
||||
this.$axios
|
||||
.$patch(`/api/user/${this.account.id}`, account)
|
||||
.then((data) => {
|
||||
@@ -146,14 +177,16 @@ export default {
|
||||
if (data.error) {
|
||||
this.$toast.error(`Failed to update account: ${data.error}`)
|
||||
} else {
|
||||
console.log('Account updated', data.user)
|
||||
this.$toast.success('Account updated')
|
||||
this.show = false
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update account', error)
|
||||
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() {
|
||||
@@ -176,9 +209,10 @@ export default {
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to create account', error)
|
||||
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() {
|
||||
@@ -195,12 +229,14 @@ export default {
|
||||
init() {
|
||||
this.isNew = !this.account
|
||||
if (this.account) {
|
||||
var librariesAccessible = this.account.librariesAccessible || []
|
||||
this.newUser = {
|
||||
username: this.account.username,
|
||||
password: this.account.password,
|
||||
type: this.account.type,
|
||||
isActive: this.account.isActive,
|
||||
permissions: { ...this.account.permissions }
|
||||
permissions: { ...this.account.permissions },
|
||||
librariesAccessible: [...librariesAccessible]
|
||||
}
|
||||
} else {
|
||||
this.newUser = {
|
||||
@@ -212,8 +248,10 @@ export default {
|
||||
download: true,
|
||||
update: false,
|
||||
delete: false,
|
||||
upload: false
|
||||
}
|
||||
upload: false,
|
||||
accessAllLibraries: true
|
||||
},
|
||||
librariesAccessible: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="bookmarks" :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 class="w-full h-full px-6 py-6" v-show="showBookmarkTitleInput">
|
||||
<div class="flex mb-4 items-center">
|
||||
<div class="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white hover:bg-opacity-10 cursor-pointer" @click="showBookmarkTitleInput = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="text-xl pl-2">{{ selectedBookmark ? 'Edit Bookmark' : 'New Bookmark' }}</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-xl font-mono">
|
||||
{{ this.$secondsToTimestamp(currentTime) }}
|
||||
</p>
|
||||
</div>
|
||||
<form @submit.prevent="submitBookmark">
|
||||
<ui-text-input-with-label v-model="newBookmarkTitle" label="Note" />
|
||||
<div class="flex justify-end mt-6">
|
||||
<ui-btn color="success" class="w-1/2" type="submit">{{ selectedBookmark ? 'Update' : 'Create' }} Bookmark</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="w-full h-full" v-show="!showBookmarkTitleInput">
|
||||
<template v-for="bookmark in bookmarks">
|
||||
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @edit="editBookmark" @delete="deleteBookmark" />
|
||||
</template>
|
||||
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
|
||||
<p class="text-xl">No Bookmarks</p>
|
||||
</div>
|
||||
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center justify-between border-b border-white border-opacity-10 bg-blue-500 bg-opacity-20 cursor-pointer text-white text-opacity-80 hover:bg-opacity-40 hover:text-opacity-100" @click="createBookmark">
|
||||
<span class="material-icons">add</span>
|
||||
<p class="text-base pl-2">Create Bookmark</p>
|
||||
<p class="text-sm font-mono">
|
||||
{{ this.$secondsToTimestamp(currentTime) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
bookmarks: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
currentTime: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
audiobookId: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedBookmark: null,
|
||||
showBookmarkTitleInput: false,
|
||||
newBookmarkTitle: ''
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.showBookmarkTitleInput = false
|
||||
this.newBookmarkTitle = ''
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
canCreateBookmark() {
|
||||
return !this.bookmarks.find((bm) => bm.time === this.currentTime)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editBookmark(bm) {
|
||||
this.selectedBookmark = bm
|
||||
this.newBookmarkTitle = bm.title
|
||||
this.showBookmarkTitleInput = true
|
||||
},
|
||||
deleteBookmark(bm) {
|
||||
var bookmark = { ...bm, audiobookId: this.audiobookId }
|
||||
this.$emit('delete', bookmark)
|
||||
},
|
||||
clickBookmark(bm) {
|
||||
this.$emit('select', bm)
|
||||
},
|
||||
createBookmark() {
|
||||
this.selectedBookmark = null
|
||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||
this.showBookmarkTitleInput = true
|
||||
},
|
||||
submitBookmark() {
|
||||
if (this.selectedBookmark) {
|
||||
if (this.selectedBookmark.title !== this.newBookmarkTitle) {
|
||||
var bookmark = { ...this.selectedBookmark }
|
||||
bookmark.audiobookId = this.audiobookId
|
||||
bookmark.title = this.newBookmarkTitle
|
||||
this.$emit('update', bookmark)
|
||||
}
|
||||
} else {
|
||||
var bookmark = {
|
||||
audiobookId: this.audiobookId,
|
||||
title: this.newBookmarkTitle,
|
||||
time: this.currentTime
|
||||
}
|
||||
this.$emit('create', bookmark)
|
||||
}
|
||||
this.newBookmarkTitle = ''
|
||||
this.showBookmarkTitleInput = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<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)">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="700" :height="'unset'" :processing="processing">
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -10,9 +10,17 @@
|
||||
<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>
|
||||
</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">
|
||||
<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" @selectTab="selectTab" />
|
||||
</keep-alive>
|
||||
</div>
|
||||
</modals-modal>
|
||||
@@ -36,11 +44,6 @@ export default {
|
||||
title: 'Cover',
|
||||
component: 'modals-edit-tabs-cover'
|
||||
},
|
||||
{
|
||||
id: 'match',
|
||||
title: 'Match',
|
||||
component: 'modals-edit-tabs-match'
|
||||
},
|
||||
{
|
||||
id: 'tracks',
|
||||
title: 'Tracks',
|
||||
@@ -51,10 +54,20 @@ export default {
|
||||
title: 'Chapters',
|
||||
component: 'modals-edit-tabs-chapters'
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
title: 'Files',
|
||||
component: 'modals-edit-tabs-files'
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
title: 'Download',
|
||||
component: 'modals-edit-tabs-download'
|
||||
},
|
||||
{
|
||||
id: 'match',
|
||||
title: 'Match',
|
||||
component: 'modals-edit-tabs-match'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -68,6 +81,7 @@ export default {
|
||||
this.show = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!availableTabIds.includes(this.selectedTab)) {
|
||||
this.selectedTab = availableTabIds[0]
|
||||
}
|
||||
@@ -79,6 +93,9 @@ export default {
|
||||
this.fetchOnShow = false
|
||||
this.audiobook = null
|
||||
this.init()
|
||||
this.registerListeners()
|
||||
} else {
|
||||
this.unregisterListeners()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,12 +123,16 @@ export default {
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
availableTabs() {
|
||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||
return this.tabs.filter((tab) => {
|
||||
if (tab.id === 'download' && this.isMissing) return false
|
||||
if ((tab.id === 'download' || tab.id === 'tracks') && this.userCanDownload) return true
|
||||
if (tab.id !== 'download' && tab.id !== 'tracks' && this.userCanUpdate) return true
|
||||
if (tab.id === 'match' && this.showExperimentalFeatures) return true
|
||||
return false
|
||||
})
|
||||
},
|
||||
@@ -137,11 +158,49 @@ export default {
|
||||
},
|
||||
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: {
|
||||
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) {
|
||||
this.selectedTab = tab
|
||||
if (this.availableTabs.find((t) => t.id === tab)) {
|
||||
this.selectedTab = tab
|
||||
}
|
||||
},
|
||||
audiobookUpdated() {
|
||||
if (!this.show) this.fetchOnShow = true
|
||||
@@ -155,14 +214,33 @@ export default {
|
||||
},
|
||||
async fetchFull() {
|
||||
try {
|
||||
this.processing = true
|
||||
this.audiobook = await this.$axios.$get(`/api/audiobook/${this.selectedAudiobookId}`)
|
||||
this.processing = false
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch audiobook', this.selectedAudiobookId, error)
|
||||
this.processing = false
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
hotkey(action) {
|
||||
if (action === this.$hotkeys.Modal.NEXT_PAGE) {
|
||||
this.goNextBook()
|
||||
} else if (action === this.$hotkeys.Modal.PREV_PAGE) {
|
||||
this.goPrevBook()
|
||||
}
|
||||
},
|
||||
registerListeners() {
|
||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||
},
|
||||
unregisterListeners() {
|
||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {},
|
||||
beforeDestroy() {
|
||||
this.unregisterListeners()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :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: 200px; max-height: 80vh">
|
||||
<div v-if="!showAddLibrary" class="w-full h-full flex flex-col justify-center px-4">
|
||||
<div class="flex items-center mb-4">
|
||||
<p>{{ libraries.length }} Libraries</p>
|
||||
<!-- <div class="flex-grow" />
|
||||
<ui-btn @click="addLibraryClick">Add Library</ui-btn> -->
|
||||
</div>
|
||||
|
||||
<template v-for="library in libraries">
|
||||
<modals-libraries-library-item :key="library.id" :library="library" :selected="currentLibraryId === library.id" :show-edit="false" @edit="editLibrary" @delete="deleteLibrary" @click="clickLibrary" />
|
||||
</template>
|
||||
</div>
|
||||
<modals-libraries-edit-library v-else :library="selectedLibrary" :show="showAddLibrary" :processing.sync="processing" @back="showAddLibrary = false" @close="showAddLibrary = false" />
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
selectedLibrary: null,
|
||||
processing: false,
|
||||
showAddLibrary: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.$store.state.libraries.showModal
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('libraries/setShowModal', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return 'Libraries'
|
||||
},
|
||||
currentLibrary() {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.currentLibrary ? this.currentLibrary.id : null
|
||||
},
|
||||
libraries() {
|
||||
return this.$store.state.libraries.libraries
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) this.showAddLibrary = false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async clickLibrary(library) {
|
||||
await this.$store.dispatch('libraries/fetch', library.id)
|
||||
this.$router.push(`/library/${library.id}`)
|
||||
this.show = false
|
||||
},
|
||||
editLibrary(library) {
|
||||
this.selectedLibrary = library
|
||||
this.showAddLibrary = true
|
||||
},
|
||||
addLibraryClick() {
|
||||
this.selectedLibrary = null
|
||||
this.showAddLibrary = true
|
||||
},
|
||||
deleteLibrary(library) {
|
||||
if (confirm(`Are you sure you want to delete library "${library.name}"?\n(no files will be deleted but book data will be lost)`)) {
|
||||
console.log('Delete library', library)
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/library/${library.id}`)
|
||||
.then(() => {
|
||||
console.log('Library delete success')
|
||||
this.$toast.success(`Library "${library.name}" deleted`)
|
||||
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete library', error)
|
||||
var errMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
this.$toast.error(errMsg)
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</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 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>
|
||||
</div>
|
||||
<slot name="outer" />
|
||||
@@ -18,6 +18,7 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
name: String,
|
||||
value: Boolean,
|
||||
processing: Boolean,
|
||||
persistent: {
|
||||
@@ -73,23 +74,37 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickClose() {
|
||||
this.show = false
|
||||
},
|
||||
clickBg(vm, ev) {
|
||||
if (this.processing && this.persistent) return
|
||||
if (vm.srcElement.classList.contains('modal-bg')) {
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
hotkey(action) {
|
||||
if (action === this.$hotkeys.Modal.CLOSE) {
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
setShow() {
|
||||
document.body.appendChild(this.el)
|
||||
setTimeout(() => {
|
||||
this.content.style.transform = 'scale(1)'
|
||||
}, 10)
|
||||
document.documentElement.classList.add('modal-open')
|
||||
|
||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||
this.$store.commit('setOpenModal', this.name)
|
||||
},
|
||||
setHide() {
|
||||
this.content.style.transform = 'scale(0)'
|
||||
this.el.remove()
|
||||
document.documentElement.classList.remove('modal-open')
|
||||
|
||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||
this.$store.commit('setOpenModal', null)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div :key="bookmark.id" :id="`bookmark-row-${bookmark.id}`" class="flex items-center px-4 py-4 justify-start cursor-pointer hover:bg-bg relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||
<span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-60'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span>
|
||||
<div class="flex-grow overflow-hidden">
|
||||
<p class="pl-2 pr-2 truncate">{{ bookmark.title }}</p>
|
||||
</div>
|
||||
<div class="h-full flex items-center w-16 justify-end">
|
||||
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(bookmark.time) }}</span>
|
||||
</div>
|
||||
<div class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
|
||||
<span class="material-icons text-lg mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
|
||||
<span class="material-icons text-lg text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
bookmark: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
highlight: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
click() {
|
||||
this.$emit('click', this.bookmark)
|
||||
},
|
||||
deleteClick() {
|
||||
this.$emit('delete', this.bookmark)
|
||||
},
|
||||
editClick() {
|
||||
this.$emit('edit', this.bookmark)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<div v-if="showLocalCovers" class="flex items-center justify-center">
|
||||
<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">
|
||||
<img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
@@ -53,14 +53,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<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 class="h-24 bg-primary" style="width: 60px">
|
||||
<img :src="cover" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
<!-- <img :src="cover" class="h-24 object-cover" style="width: 60px" /> -->
|
||||
<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)">
|
||||
<cards-preview-cover :src="cover" :width="80" show-open-new-tab />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -82,8 +79,6 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Path from 'path'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
@@ -193,13 +188,13 @@ export default {
|
||||
},
|
||||
init() {
|
||||
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.hasSearched = false
|
||||
}
|
||||
this.imageUrl = this.book.cover || ''
|
||||
this.searchTitle = this.book.title || ''
|
||||
this.searchAuthor = this.book.author || ''
|
||||
this.searchAuthor = this.book.authorFL || ''
|
||||
},
|
||||
removeCover() {
|
||||
if (!this.book.cover) {
|
||||
@@ -268,8 +263,24 @@ export default {
|
||||
this.isProcessing = false
|
||||
this.hasSearched = true
|
||||
},
|
||||
setCover(cover) {
|
||||
this.updateCover(cover)
|
||||
setCover(coverFile) {
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,18 +53,19 @@
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
|
||||
<div class="flex px-4">
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
|
||||
<div class="flex items-center px-4">
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
|
||||
|
||||
<ui-tooltip text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="ml-4">
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-tooltip v-if="!isMissing" text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="mr-4">
|
||||
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :disabled="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 :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
|
||||
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<ui-btn type="submit">Submit</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,6 +122,9 @@ export default {
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
isMissing() {
|
||||
return !!this.audiobook && !!this.audiobook.isMissing
|
||||
},
|
||||
audiobookId() {
|
||||
return this.audiobook ? this.audiobook.id : null
|
||||
},
|
||||
@@ -214,8 +218,6 @@ export default {
|
||||
this.details.volumeNumber = this.book.volumeNumber
|
||||
this.details.publishYear = this.book.publishYear
|
||||
|
||||
console.log('INIT', this.details)
|
||||
|
||||
this.newTags = this.audiobook.tags || []
|
||||
},
|
||||
resetProgress() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||
<p class="text-center text-lg mb-4 py-8">Preparing downloads can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted.<br />Download will timeout after 15 minutes.</p>
|
||||
<div 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>
|
||||
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
|
||||
@@ -44,7 +44,7 @@
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -89,6 +89,9 @@ export default {
|
||||
audiobookId() {
|
||||
return this.audiobook ? this.audiobook.id : null
|
||||
},
|
||||
_audiobook() {
|
||||
return this.audiobook || {}
|
||||
},
|
||||
downloads() {
|
||||
return this.$store.getters['downloads/getDownloads'](this.audiobookId)
|
||||
},
|
||||
@@ -120,6 +123,9 @@ export default {
|
||||
},
|
||||
totalFiles() {
|
||||
return this.audioFiles.length + this.otherFiles.length
|
||||
},
|
||||
showM4bDownload() {
|
||||
return !this._audiobook.isMissing && !this._audiobook.isIncomplete && this._audiobook.tracks.length
|
||||
}
|
||||
},
|
||||
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,15 +1,17 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden px-4 py-6">
|
||||
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<div class="flex items-center justify-start -mx-1 h-20">
|
||||
<div class="w-72 px-1">
|
||||
<ui-text-input-with-label v-model="searchTitle" label="Search Title" placeholder="Search" :disabled="processing" />
|
||||
<div class="w-40 px-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
||||
</div>
|
||||
<div class="w-72 px-1">
|
||||
<ui-text-input-with-label v-model="searchAuthor" label="Author" :disabled="processing" />
|
||||
<ui-text-input-with-label v-model="searchTitle" label="Search Title" placeholder="Search" />
|
||||
</div>
|
||||
<div class="w-72 px-1">
|
||||
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
||||
</div>
|
||||
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
</form>
|
||||
<div v-show="processing" class="flex h-full items-center justify-center">
|
||||
@@ -23,6 +25,51 @@
|
||||
<cards-book-match-card :key="index" :book="res" @select="selectMatch" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="flex mb-2">
|
||||
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="text-xl pl-3">Update Book Details</p>
|
||||
</div>
|
||||
<form @submit.prevent="submitMatchUpdate">
|
||||
<div v-if="selectedMatch.cover" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.cover" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" label="Cover" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.title" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.title" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" label="Title" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.subtitle" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.subtitle" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" label="Subtitle" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.author" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.author" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" label="Author" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.description" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.description" />
|
||||
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.publisher" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.publisher" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" label="Publisher" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.publishYear" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.publishYear" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.publishYear" :disabled="!selectedMatchUsage.publishYear" label="Publish Year" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.isbn" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end py-2">
|
||||
<ui-btn color="success" type="submit">Update</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -41,9 +88,30 @@ export default {
|
||||
searchTitle: null,
|
||||
searchAuthor: null,
|
||||
lastSearch: null,
|
||||
provider: 'best',
|
||||
providers: [
|
||||
{
|
||||
text: 'Google Books',
|
||||
value: 'google'
|
||||
},
|
||||
{
|
||||
text: 'Open Library',
|
||||
value: 'openlibrary'
|
||||
}
|
||||
],
|
||||
provider: 'google',
|
||||
searchResults: [],
|
||||
hasSearched: false
|
||||
hasSearched: false,
|
||||
selectedMatch: null,
|
||||
selectedMatchUsage: {
|
||||
title: true,
|
||||
subtitle: true,
|
||||
cover: true,
|
||||
author: true,
|
||||
description: true,
|
||||
isbn: true,
|
||||
publisher: true,
|
||||
publishYear: true
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -95,6 +163,18 @@ export default {
|
||||
this.hasSearched = true
|
||||
},
|
||||
init() {
|
||||
this.selectedMatch = null
|
||||
this.selectedMatchUsage = {
|
||||
title: true,
|
||||
subtitle: true,
|
||||
cover: true,
|
||||
author: true,
|
||||
description: true,
|
||||
isbn: true,
|
||||
publisher: true,
|
||||
publishYear: true
|
||||
}
|
||||
|
||||
if (this.audiobook.id !== this.audiobookId) {
|
||||
this.searchResults = []
|
||||
this.hasSearched = false
|
||||
@@ -107,31 +187,63 @@ export default {
|
||||
return
|
||||
}
|
||||
this.searchTitle = this.audiobook.book.title
|
||||
this.searchAuthor = this.audiobook.book.author || ''
|
||||
this.searchAuthor = this.audiobook.book.authorFL || ''
|
||||
},
|
||||
async selectMatch(match) {
|
||||
selectMatch(match) {
|
||||
this.selectedMatch = match
|
||||
},
|
||||
buildMatchUpdatePayload() {
|
||||
var updatePayload = {}
|
||||
for (const key in this.selectedMatchUsage) {
|
||||
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
||||
updatePayload[key] = this.selectedMatch[key]
|
||||
}
|
||||
}
|
||||
return updatePayload
|
||||
},
|
||||
async submitMatchUpdate() {
|
||||
var updatePayload = this.buildMatchUpdatePayload()
|
||||
if (!Object.keys(updatePayload).length) {
|
||||
return
|
||||
}
|
||||
this.isProcessing = true
|
||||
const updatePayload = {
|
||||
book: {}
|
||||
|
||||
if (updatePayload.cover) {
|
||||
var coverPayload = {
|
||||
url: updatePayload.cover
|
||||
}
|
||||
var success = await this.$axios.$post(`/api/audiobook/${this.audiobook.id}/cover`, coverPayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
if (success) {
|
||||
this.$toast.success('Book Cover Updated')
|
||||
} else {
|
||||
this.$toast.error('Book Cover Failed to Update')
|
||||
}
|
||||
console.log('Updated cover')
|
||||
delete updatePayload.cover
|
||||
}
|
||||
if (match.cover) {
|
||||
updatePayload.book.cover = match.cover
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
var bookUpdatePayload = {
|
||||
book: updatePayload
|
||||
}
|
||||
var success = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, bookUpdatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
if (success) {
|
||||
this.$toast.success('Book Details Updated')
|
||||
this.selectedMatch = null
|
||||
this.$emit('selectTab', 'details')
|
||||
} else {
|
||||
this.$toast.error('Book Details Failed to Update')
|
||||
}
|
||||
} else {
|
||||
this.selectedMatch = null
|
||||
}
|
||||
if (match.title) {
|
||||
updatePayload.book.title = match.title
|
||||
}
|
||||
if (match.description) {
|
||||
updatePayload.book.description = match.description
|
||||
}
|
||||
var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (updatedAudiobook) {
|
||||
this.$toast.success('Update Successful')
|
||||
this.$emit('close')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,44 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div class="w-full bg-primary px-4 py-2 flex items-center">
|
||||
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
||||
<template v-if="hasTracks">
|
||||
<div class="w-full bg-primary px-4 py-2 flex items-center">
|
||||
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`" class="mr-4">
|
||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<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/${audiobook.id}/edit`" class="mr-4">
|
||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th>#</th>
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-left">Size</th>
|
||||
<th class="text-left">Duration</th>
|
||||
<th v-if="showDownload" class="text-center">Download</th>
|
||||
</tr>
|
||||
<template v-for="track in tracksCleaned">
|
||||
<tr :key="track.index">
|
||||
<td class="text-center">
|
||||
<p>{{ track.index }}</p>
|
||||
</td>
|
||||
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(track.size) }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="showDownload" class="font-mono text-center">
|
||||
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th>#</th>
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-left">Size</th>
|
||||
<th class="text-left">Duration</th>
|
||||
<th v-if="showDownload" class="text-center">Download</th>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
<template v-for="track in tracksCleaned">
|
||||
<tr :key="track.index">
|
||||
<td class="text-center">
|
||||
<p>{{ track.index }}</p>
|
||||
</td>
|
||||
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(track.size) }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="showDownload" class="font-mono text-center">
|
||||
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</template>
|
||||
<div v-else class="flex my-4 text-center justify-center text-xl">No Audio Tracks</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -50,7 +53,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
tracks: null,
|
||||
audioFiles: null,
|
||||
showFullPath: false
|
||||
}
|
||||
},
|
||||
@@ -94,11 +96,13 @@ export default {
|
||||
},
|
||||
showDownload() {
|
||||
return this.userCanDownload && !this.isMissing
|
||||
},
|
||||
hasTracks() {
|
||||
return this.audiobook.tracks.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.audioFiles = this.audiobook.audioFiles
|
||||
this.tracks = this.audiobook.tracks
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
<template>
|
||||
<div class="w-full px-4 h-12 border border-white border-opacity-10 cursor-pointer 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" @click="itemClicked">
|
||||
<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="mouseover ? 'text-opacity-90' : 'text-opacity-50'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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" :class="mouseover ? 'underline' : ''">{{ library.name }}</p>
|
||||
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-show="mouseover && !libraryScan && canScan" small color="bg" @click.stop="scan">Scan</ui-btn>
|
||||
<span v-show="mouseover && !libraryScan && showEdit && canEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4" @click.stop="editClick">edit</span>
|
||||
<span v-show="!libraryScan && mouseover && showEdit && canDelete" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50'" @click.stop="deleteClick">delete</span>
|
||||
<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>
|
||||
|
||||
@@ -23,14 +28,19 @@ export default {
|
||||
default: () => {}
|
||||
},
|
||||
selected: Boolean,
|
||||
showEdit: Boolean
|
||||
showEdit: Boolean,
|
||||
dragging: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
mouseover: false
|
||||
mouseover: false,
|
||||
isDeleting: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isHovering() {
|
||||
return this.mouseover && !this.dragging
|
||||
},
|
||||
isMain() {
|
||||
return this.library.id === 'main'
|
||||
},
|
||||
@@ -49,17 +59,34 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
itemClicked() {
|
||||
this.$emit('click', this.library)
|
||||
// this.$emit('click', this.library)
|
||||
},
|
||||
editClick() {
|
||||
this.$emit('edit', this.library)
|
||||
},
|
||||
deleteClick() {
|
||||
if (this.isMain) return
|
||||
this.$emit('delete', 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() {}
|
||||
|
||||
@@ -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>
|
||||
@@ -1,31 +1,18 @@
|
||||
<template>
|
||||
<div v-if="value" class="w-screen h-screen fixed top-0 left-0 z-50 bg-white text-black">
|
||||
<div class="absolute top-4 right-4 z-10">
|
||||
<span class="material-icons cursor-pointer text-4xl" @click="show = false">close</span>
|
||||
</div>
|
||||
<!-- <div v-if="chapters.length" class="absolute top-0 left-0 w-52">
|
||||
<select v-model="selectedChapter" class="w-52" @change="changedChapter">
|
||||
<option v-for="chapter in chapters" :key="chapter.href" :value="chapter.href">{{ chapter.label }}</option>
|
||||
</select>
|
||||
</div> -->
|
||||
<div class="absolute top-4 left-4 font-book">
|
||||
<h1 class="text-2xl mb-1">{{ title }}</h1>
|
||||
|
||||
<p v-if="author">by {{ author }}</p>
|
||||
</div>
|
||||
<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">
|
||||
<span v-show="hasPrev" class="material-icons text-black text-opacity-30 hover:text-opacity-80 cursor-pointer text-8xl" @mousedown.prevent @click="pageLeft">chevron_left</span>
|
||||
<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="spreads"></div>
|
||||
<div id="viewer" class="border border-gray-100 bg-white shadow-md"></div>
|
||||
|
||||
<div class="px-16 flex justify-center" style="height: 50px">
|
||||
<p class="px-4">{{ progress }}%</p>
|
||||
<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 overflow-x-hidden">
|
||||
<span v-show="hasNext" class="material-icons text-black text-opacity-30 hover:text-opacity-80 cursor-pointer text-8xl" @mousedown.prevent @click="pageRight">chevron_right</span>
|
||||
<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>
|
||||
@@ -36,7 +23,6 @@ import ePub from 'epubjs'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
url: String
|
||||
},
|
||||
data() {
|
||||
@@ -51,72 +37,31 @@ export default {
|
||||
hasPrev: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
} else {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
}
|
||||
// fullUrl() {
|
||||
// var serverUrl = process.env.serverUrl || `/s/book/${this.audiobookId}`
|
||||
// return `${serverUrl}/${this.url}`
|
||||
// }
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
changedChapter() {
|
||||
if (this.rendition) {
|
||||
this.rendition.display(this.selectedChapter)
|
||||
}
|
||||
},
|
||||
pageLeft() {
|
||||
prev() {
|
||||
if (this.rendition) {
|
||||
this.rendition.prev()
|
||||
}
|
||||
},
|
||||
pageRight() {
|
||||
next() {
|
||||
if (this.rendition) {
|
||||
this.rendition.next()
|
||||
}
|
||||
},
|
||||
keyUp(e) {
|
||||
if (!this.rendition) {
|
||||
console.error('No rendition')
|
||||
return
|
||||
}
|
||||
|
||||
keyUp() {
|
||||
if ((e.keyCode || e.which) == 37) {
|
||||
this.rendition.prev()
|
||||
this.prev()
|
||||
} else if ((e.keyCode || e.which) == 39) {
|
||||
this.rendition.next()
|
||||
} else if ((e.keyCode || e.which) == 27) {
|
||||
this.show = false
|
||||
this.next()
|
||||
}
|
||||
},
|
||||
registerListeners() {
|
||||
document.addEventListener('keyup', this.keyUp)
|
||||
},
|
||||
unregisterListeners() {
|
||||
document.removeEventListener('keyup', this.keyUp)
|
||||
},
|
||||
init() {
|
||||
this.registerListeners()
|
||||
|
||||
console.log('epub', this.url)
|
||||
initEpub() {
|
||||
// var book = ePub(this.url, {
|
||||
// requestHeaders: {
|
||||
// Authorization: `Bearer ${this.userToken}`
|
||||
@@ -170,22 +115,15 @@ export default {
|
||||
|
||||
this.rendition.on('relocated', (location) => {
|
||||
var percent = book.locations.percentageFromCfi(location.start.cfi)
|
||||
var percentage = Math.floor(percent * 100)
|
||||
this.progress = percentage
|
||||
this.progress = Math.floor(percent * 100)
|
||||
|
||||
this.hasNext = !location.atEnd
|
||||
this.hasPrev = !location.atStart
|
||||
})
|
||||
},
|
||||
close() {
|
||||
this.unregisterListeners()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.show) this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.unregisterListeners()
|
||||
this.initEpub()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</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,155 @@
|
||||
<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 === this.$hotkeys.EReader.NEXT_PAGE) {
|
||||
if (this.$refs.readerComponent.next) this.$refs.readerComponent.next()
|
||||
} else if (action === this.$hotkeys.EReader.PREV_PAGE) {
|
||||
if (this.$refs.readerComponent.prev) this.$refs.readerComponent.prev()
|
||||
} else if (action === this.$hotkeys.EReader.CLOSE) {
|
||||
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'
|
||||
} else if (this.selectedAudiobookFile.ext === '.epub') {
|
||||
this.ebookType = 'epub'
|
||||
} else if (this.selectedAudiobookFile.ext === '.cbr' || this.selectedAudiobookFile.ext === '.cbz') {
|
||||
this.ebookType = 'comic'
|
||||
}
|
||||
} else if (this.epubEbook) {
|
||||
this.ebookType = 'epub'
|
||||
this.ebookUrl = this.getEbookUrl(this.epubEbook.path)
|
||||
} else if (this.mobiEbook) {
|
||||
this.ebookType = 'mobi'
|
||||
this.ebookUrl = this.getEbookUrl(this.mobiEbook.path)
|
||||
} else if (this.pdfEbook) {
|
||||
this.ebookType = 'pdf'
|
||||
this.ebookUrl = this.getEbookUrl(this.pdfEbook.path)
|
||||
} else if (this.comicEbook) {
|
||||
this.ebookType = 'comic'
|
||||
this.ebookUrl = this.getEbookUrl(this.comicEbook.path)
|
||||
}
|
||||
},
|
||||
close() {
|
||||
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>
|
||||
@@ -102,7 +102,7 @@ export default {
|
||||
applyBackupComplete(success) {
|
||||
if (success) {
|
||||
// this.$toast.success('Backup Applied, refresh the page')
|
||||
location.replace('/config?backup=1')
|
||||
location.replace('/config/backups?backup=1')
|
||||
} else {
|
||||
this.$toast.error('Failed to apply backup')
|
||||
}
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
<template>
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<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>
|
||||
|
||||
<template v-for="library in libraries">
|
||||
<modals-libraries-library-item :key="library.id" :library="library" :selected="currentLibraryId === library.id" :show-edit="true" @edit="editLibrary" @delete="deleteLibrary" @click="clickLibrary" />
|
||||
</template>
|
||||
<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
|
||||
selectedLibrary: null,
|
||||
drag: false,
|
||||
dragOptions: {
|
||||
animation: 200,
|
||||
group: 'description',
|
||||
ghostClass: 'ghost'
|
||||
},
|
||||
orderTimeout: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -30,35 +47,49 @@ export default {
|
||||
return this.currentLibrary ? this.currentLibrary.id : null
|
||||
},
|
||||
libraries() {
|
||||
return this.$store.state.libraries.libraries
|
||||
return this.$store.getters['libraries/getSortedLibraries']()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async clickLibrary(library) {
|
||||
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}`)
|
||||
},
|
||||
deleteLibrary(library) {
|
||||
if (library.id === 'main') 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
|
||||
// })
|
||||
// }
|
||||
},
|
||||
clickAddLibrary() {
|
||||
this.selectedLibrary = null
|
||||
this.showLibraryModal = true
|
||||
@@ -67,11 +98,21 @@ export default {
|
||||
this.selectedLibrary = library
|
||||
this.showLibraryModal = true
|
||||
},
|
||||
init() {}
|
||||
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() {}
|
||||
beforeDestroy() {
|
||||
this.$store.commit('libraries/removeListener', 'libraries-table')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -20,7 +20,7 @@
|
||||
<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>
|
||||
<th v-if="userCanDownload && !isMissing" class="text-center w-20">Download</th>
|
||||
</tr>
|
||||
<template v-for="file in otherFilesCleaned">
|
||||
<tr :key="file.path">
|
||||
@@ -28,9 +28,12 @@
|
||||
{{ showFullPath ? file.fullPath : file.path }}
|
||||
</td>
|
||||
<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" class="text-center">
|
||||
<td v-if="userCanDownload && !isMissing" class="text-center">
|
||||
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -83,11 +86,17 @@ export default {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
isMissing() {
|
||||
return this.audiobook.isMissing
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
readEbookClick(file) {
|
||||
this.$store.commit('showEReaderForFile', { audiobook: this.audiobook, file })
|
||||
},
|
||||
clickBar() {
|
||||
this.showFiles = !this.showFiles
|
||||
}
|
||||
|
||||
@@ -11,22 +11,47 @@
|
||||
<table id="accounts">
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Account Type</th>
|
||||
<th style="width: 200px">Created At</th>
|
||||
<th style="width: 100px"></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="user.isActive ? '' : 'bg-error bg-opacity-20'">
|
||||
<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>
|
||||
{{ 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() }}
|
||||
<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="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>
|
||||
<!-- <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>
|
||||
@@ -47,8 +72,30 @@ export default {
|
||||
isDeletingUser: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
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}"?`)) {
|
||||
@@ -82,7 +129,9 @@ export default {
|
||||
this.$axios
|
||||
.$get('/api/users')
|
||||
.then((users) => {
|
||||
this.users = users
|
||||
this.users = users.sort((a, b) => {
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
@@ -133,16 +182,21 @@ export default {
|
||||
#accounts {
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #474747;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#accounts td,
|
||||
#accounts th {
|
||||
border: 1px solid #2e2e2e;
|
||||
/* 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<label class="flex justify-start items-start">
|
||||
<div class="bg-white border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center focus-within:border-blue-500" :class="wrapperClass">
|
||||
<input v-model="selected" type="checkbox" class="opacity-0 absolute" />
|
||||
<svg v-if="selected" class="fill-current text-green-500 pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
||||
</div>
|
||||
<div v-if="label" class="select-none">{{ label }}</div>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
label: Boolean,
|
||||
small: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
selected: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', !!val)
|
||||
}
|
||||
},
|
||||
wrapperClass() {
|
||||
if (this.small) return 'w-4 h-4'
|
||||
return 'w-6 h-6'
|
||||
},
|
||||
svgClass() {
|
||||
if (this.small) return 'w-3 h-3'
|
||||
return 'w-4 h-4'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,17 +1,17 @@
|
||||
<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 bg-fg 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">
|
||||
<p class="text-sm font-semibold">{{ label }}</p>
|
||||
<button type="button" :disabled="disabled" class="relative w-full border border-gray-500 rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" :class="small ? 'h-9' : 'h-10'" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate">{{ selectedText }}</span>
|
||||
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ 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">chevron_down</span>
|
||||
<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" aria-activedescendant="listbox-option-3">
|
||||
<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">
|
||||
@@ -36,7 +36,8 @@ export default {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
disabled: Boolean
|
||||
disabled: Boolean,
|
||||
small: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<button class="icon-btn rounded-md border border-gray-600 flex items-center justify-center h-9 w-9 relative" :disabled="disabled" :class="className" @click="clickBtn">
|
||||
<span class="material-icons icon-text">{{ icon }}</span>
|
||||
<span class="material-icons" :style="{ fontSize }">{{ icon }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -22,6 +22,10 @@ export default {
|
||||
var classes = []
|
||||
classes.push(`bg-${this.bgColor}`)
|
||||
return classes.join(' ')
|
||||
},
|
||||
fontSize() {
|
||||
if (this.icon === 'edit') return '1.25rem'
|
||||
return '1.4rem'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -116,9 +116,6 @@ export default {
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.input = item
|
||||
|
||||
// this.input = this.textInput ? this.textInput.trim() : null
|
||||
console.log('Clicked option', item)
|
||||
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
|
||||
}
|
||||
this.isFocused = false
|
||||
if (this.textInput) this.submitForm()
|
||||
}, 50)
|
||||
},
|
||||
focus() {
|
||||
@@ -145,6 +146,7 @@ export default {
|
||||
var newSelected = null
|
||||
if (this.selected.includes(itemValue)) {
|
||||
newSelected = this.selected.filter((s) => s !== itemValue)
|
||||
this.$emit('removedItem', itemValue)
|
||||
} else {
|
||||
newSelected = this.selected.concat([itemValue])
|
||||
}
|
||||
@@ -164,6 +166,7 @@ export default {
|
||||
removeItem(item) {
|
||||
var remaining = this.selected.filter((i) => i !== item)
|
||||
this.$emit('input', remaining)
|
||||
this.$emit('removedItem', item)
|
||||
this.$nextTick(() => {
|
||||
this.recalcMenuPos()
|
||||
})
|
||||
@@ -171,6 +174,7 @@ export default {
|
||||
insertNewItem(item) {
|
||||
this.selected.push(item)
|
||||
this.$emit('input', this.selected)
|
||||
this.$emit('newItem', item)
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
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>
|
||||
<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">
|
||||
<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" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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-500 focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<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>
|
||||
@@ -80,6 +80,7 @@ input {
|
||||
border-style: inherit !important;
|
||||
}
|
||||
input:read-only {
|
||||
color: #aaa;
|
||||
background-color: #444;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold">
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">
|
||||
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</p>
|
||||
<ui-text-input v-model="inputValue" :disabled="disabled" :type="type" class="w-full" />
|
||||
|
||||
@@ -38,10 +38,11 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input {
|
||||
textarea {
|
||||
border-style: inherit !important;
|
||||
}
|
||||
input:read-only {
|
||||
background-color: #eee;
|
||||
textarea:read-only {
|
||||
color: #aaa;
|
||||
background-color: #444;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold">{{ label }}</p>
|
||||
<ui-textarea-input v-model="inputValue" :rows="rows" class="w-full" />
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||
<ui-textarea-input v-model="inputValue" :disabled="disabled" :rows="rows" class="w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,6 +10,7 @@ export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
label: String,
|
||||
disabled: Boolean,
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 2
|
||||
|
||||
@@ -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>
|
||||
@@ -5,9 +5,9 @@
|
||||
<Nuxt />
|
||||
|
||||
<app-stream-container ref="streamContainer" />
|
||||
<modals-libraries-modal />
|
||||
|
||||
<modals-edit-modal />
|
||||
<!-- <widgets-scan-alert /> -->
|
||||
<readers-reader />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -75,6 +75,12 @@ export default {
|
||||
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) {
|
||||
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
|
||||
@@ -162,6 +168,19 @@ export default {
|
||||
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)
|
||||
},
|
||||
currentUserAudiobookUpdate(payload) {
|
||||
// console.log('Received user audiobook update', payload)
|
||||
this.$store.commit('user/updateUserAudiobook', payload)
|
||||
},
|
||||
downloadToastClick(download) {
|
||||
if (!download || !download.audiobookId) {
|
||||
return console.error('Invalid download object', download)
|
||||
@@ -220,6 +239,12 @@ export default {
|
||||
download.status = this.$constants.DownloadStatus.EXPIRED
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
showErrorToast(message) {
|
||||
this.$toast.error(message)
|
||||
},
|
||||
showSuccessToast(message) {
|
||||
this.$toast.success(message)
|
||||
},
|
||||
logEvtReceived(payload) {
|
||||
this.$store.commit('logs/logEvt', payload)
|
||||
},
|
||||
@@ -267,6 +292,10 @@ export default {
|
||||
|
||||
// User Listeners
|
||||
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)
|
||||
this.socket.on('current_user_audiobook_update', this.currentUserAudiobookUpdate)
|
||||
|
||||
// Scan Listeners
|
||||
this.socket.on('scan_start', this.scanStart)
|
||||
@@ -280,6 +309,10 @@ export default {
|
||||
this.socket.on('download_killed', this.downloadKilled)
|
||||
this.socket.on('download_expired', this.downloadExpired)
|
||||
|
||||
// Toast Listeners
|
||||
this.socket.on('show_error_toast', this.showErrorToast)
|
||||
this.socket.on('show_success_toast', this.showSuccessToast)
|
||||
|
||||
this.socket.on('log', this.logEvtReceived)
|
||||
|
||||
this.socket.on('backup_applied', this.backupApplied)
|
||||
@@ -307,9 +340,68 @@ export default {
|
||||
} else {
|
||||
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
|
||||
},
|
||||
getHotkeyName(e) {
|
||||
var keyCode = e.keyCode || e.which
|
||||
if (!this.$keynames[keyCode]) {
|
||||
// Unused hotkey
|
||||
return null
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return name
|
||||
},
|
||||
keyDown(e) {
|
||||
var name = this.getHotkeyName(e)
|
||||
if (!name) return
|
||||
|
||||
// Input is focused then ignore key press
|
||||
if (this.checkActiveElementIsInput()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Modal is open
|
||||
if (this.$store.state.openModal && Object.values(this.$hotkeys.Modal).includes(name)) {
|
||||
this.$eventBus.$emit('modal-hotkey', name)
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// EReader is open
|
||||
if (this.$store.state.showEReader && Object.values(this.$hotkeys.EReader).includes(name)) {
|
||||
this.$eventBus.$emit('reader-hotkey', name)
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// Batch selecting
|
||||
if (this.$store.getters['getNumAudiobooksSelected'] && name === 'Escape') {
|
||||
// ESCAPE key cancels batch selection
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// Playing audiobook
|
||||
if (this.$store.state.streamAudiobook && Object.values(this.$hotkeys.AudioPlayer).includes(name)) {
|
||||
this.$eventBus.$emit('player-hotkey', name)
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('keydown', this.keyDown)
|
||||
|
||||
this.initializeSocket()
|
||||
this.$store.dispatch('libraries/load')
|
||||
|
||||
@@ -330,6 +422,9 @@ export default {
|
||||
this.$toast.error(this.$route.query.error)
|
||||
this.$router.replace(this.$route.path)
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('keydown', this.keyDown)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function (context) {
|
||||
}
|
||||
|
||||
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 !== 'config' && from.name !== 'config-log' && from.name !== 'upload' && from.name !== 'account') {
|
||||
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]
|
||||
if (!_history.length || _history[_history.length - 1] !== from.fullPath) {
|
||||
_history.push(from.fullPath)
|
||||
|
||||
@@ -19,7 +19,7 @@ module.exports = {
|
||||
|
||||
// Global page headers: https://go.nuxtjs.dev/config-head
|
||||
head: {
|
||||
title: 'AudioBookshelf',
|
||||
title: 'Audiobookshelf',
|
||||
htmlAttrs: {
|
||||
lang: 'en'
|
||||
},
|
||||
@@ -35,8 +35,8 @@ module.exports = {
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
||||
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Fira+Mono&family=Ubuntu+Mono&family=Gentium+Book+Basic&&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/css2?family=Ubuntu+Mono&family=Source+Sans+Pro:wght@300;400;600' },
|
||||
// { rel: 'stylesheet', href: 'https://fonts.googleapis.com/icon?family=Material+Icons' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -98,8 +98,7 @@ module.exports = {
|
||||
},
|
||||
|
||||
// Build Configuration: https://go.nuxtjs.dev/config-build
|
||||
build: {
|
||||
},
|
||||
build: {},
|
||||
watchers: {
|
||||
webpack: {
|
||||
aggregateTimeout: 300,
|
||||
|
||||
Generated
+90
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "1.3.4",
|
||||
"version": "1.4.10",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -3415,6 +3415,11 @@
|
||||
"@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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
|
||||
@@ -4995,6 +5000,11 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||
@@ -7375,6 +7385,11 @@
|
||||
"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",
|
||||
@@ -8489,6 +8504,11 @@
|
||||
"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": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz",
|
||||
@@ -11234,6 +11254,37 @@
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-1.2.0.tgz",
|
||||
@@ -13309,6 +13360,24 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
|
||||
"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": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.2.tgz",
|
||||
@@ -14176,6 +14245,26 @@
|
||||
"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": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
|
||||
+5
-1
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "1.4.2",
|
||||
"version": "1.6.0",
|
||||
"description": "Audiobook manager and player",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "nuxt",
|
||||
"dev2": "nuxt --hostname localhost --port 1337",
|
||||
"build": "nuxt build",
|
||||
"start": "nuxt start",
|
||||
"generate": "nuxt generate"
|
||||
@@ -15,10 +16,13 @@
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
"@nuxtjs/proxy": "^2.1.0",
|
||||
"core-js": "^3.16.0",
|
||||
"date-fns": "^2.25.0",
|
||||
"epubjs": "^0.3.88",
|
||||
"hls.js": "^1.0.7",
|
||||
"libarchive.js": "^1.3.0",
|
||||
"nuxt": "^2.15.7",
|
||||
"nuxt-socket-io": "^1.1.18",
|
||||
"vue-pdf": "^4.3.0",
|
||||
"vue-toastification": "^1.7.11",
|
||||
"vuedraggable": "^2.24.3"
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<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>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-show="password && newPassword && confirmPassword" 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>
|
||||
</form>
|
||||
</div>
|
||||
@@ -61,7 +61,11 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
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)
|
||||
})
|
||||
if (localStorage.getItem('token')) {
|
||||
|
||||
@@ -10,10 +10,24 @@
|
||||
</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="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-32">Track Parsed from Filename</div>
|
||||
<div class="font-book text-center px-4 w-32">Track From Metadata</div>
|
||||
<div class="font-book truncate px-4 flex-grow">Filename</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>
|
||||
<span class="text-white">Current</span>
|
||||
<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>
|
||||
<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">Duration</div>
|
||||
@@ -21,19 +35,22 @@
|
||||
<div class="font-mono w-56">Notes</div>
|
||||
<div class="font-book w-40">Include in Tracklist</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">
|
||||
<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">
|
||||
{{ audio.include ? index - numExcluded + 1 : -1 }}
|
||||
</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">
|
||||
{{ audio.trackNumFromFilename }}
|
||||
</div>
|
||||
<div class="font-book text-center w-32">
|
||||
{{ audio.trackNumFromMeta }}
|
||||
</div>
|
||||
<div class="font-book truncate px-4 w-20">
|
||||
{{ audio.cdNumFromFilename }}
|
||||
</div>
|
||||
<div class="font-book truncate px-4 flex-grow">
|
||||
{{ audio.filename }}
|
||||
</div>
|
||||
@@ -56,6 +73,30 @@
|
||||
</li>
|
||||
</transition-group>
|
||||
</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>
|
||||
</template>
|
||||
@@ -95,7 +136,10 @@ export default {
|
||||
group: 'description',
|
||||
ghostClass: 'ghost'
|
||||
},
|
||||
saving: false
|
||||
saving: false,
|
||||
checkingTrackNumbers: false,
|
||||
trackNumData: [],
|
||||
currentSort: 'current'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -172,9 +216,55 @@ export default {
|
||||
},
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
}
|
||||
},
|
||||
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) {
|
||||
var new_index = 0
|
||||
if (audio.include) {
|
||||
@@ -224,41 +314,3 @@ export default {
|
||||
mounted() {}
|
||||
}
|
||||
</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>
|
||||
@@ -13,23 +13,17 @@
|
||||
<div class="mb-4">
|
||||
<div class="flex items-end">
|
||||
<h1 class="text-3xl font-sans">
|
||||
{{ title }}<span v-if="isDeveloperMode"> ({{ audiobook.ino }})</span>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p v-if="subtitle" class="ml-4 text-gray-400 text-2xl">{{ subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<p class="mb-2 mt-0.5 text-gray-100 text-xl">
|
||||
by <nuxt-link v-if="author" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}</nuxt-link
|
||||
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>
|
||||
|
||||
<h3 v-if="series" class="font-sans text-gray-300 text-lg leading-7 mb-4">{{ seriesText }}</h3>
|
||||
|
||||
<!-- <div class="w-min">
|
||||
<ui-tooltip :text="authorTooltipText" direction="bottom">
|
||||
<span class="text-base text-gray-100 leading-8 whitespace-nowrap"><span class="text-white text-opacity-60">By:</span> {{ author }}</span>
|
||||
</ui-tooltip>
|
||||
</div> -->
|
||||
<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>
|
||||
@@ -57,7 +51,7 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-0.5">
|
||||
<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>
|
||||
@@ -65,7 +59,7 @@
|
||||
{{ durationPretty }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-0.5">
|
||||
<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>
|
||||
@@ -73,16 +67,16 @@
|
||||
{{ sizePretty }}
|
||||
</div>
|
||||
</div>
|
||||
<!--
|
||||
<p v-if="narrator" class="text-base">
|
||||
<span class="text-white text-opacity-60">By:</span> <nuxt-link :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}</nuxt-link>
|
||||
</p> -->
|
||||
<!-- <p v-if="narrator" class="text-base"><span class="text-white text-opacity-60">Narrated by:</span> {{ narrator }}</p>
|
||||
<p v-if="publishYear" class="text-base"><span class="text-white text-opacity-60">Publish year:</span> {{ publishYear }}</p>
|
||||
<p v-if="genres.length" class="text-base"><span class="text-white text-opacity-60">Genres:</span> {{ genres.join(', ') }}</p> -->
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
|
||||
<!-- Alerts -->
|
||||
<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' : ''">
|
||||
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||
@@ -92,16 +86,16 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{{ streaming ? 'Streaming' : 'Play' }}
|
||||
</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>
|
||||
Missing
|
||||
{{ isMissing ? 'Missing' : 'Incomplete' }}
|
||||
</ui-btn>
|
||||
|
||||
<ui-btn v-if="showExperimentalFeatures && epubEbook" 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>
|
||||
Read
|
||||
</ui-btn>
|
||||
@@ -141,7 +135,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tables-tracks-table :tracks="tracks" :audiobook="audiobook" 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" />
|
||||
|
||||
@@ -149,8 +143,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-reader v-if="showExperimentalFeatures" v-model="showReader" :url="epubUrl" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -175,7 +167,6 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showReader: false,
|
||||
isRead: false,
|
||||
resettingProgress: false,
|
||||
isProcessingReadUpdate: false
|
||||
@@ -230,6 +221,12 @@ export default {
|
||||
isMissing() {
|
||||
return this.audiobook.isMissing
|
||||
},
|
||||
isIncomplete() {
|
||||
return this.audiobook.isIncomplete
|
||||
},
|
||||
showPlayButton() {
|
||||
return !this.isMissing && !this.isIncomplete && this.tracks.length
|
||||
},
|
||||
missingParts() {
|
||||
return this.audiobook.missingParts || []
|
||||
},
|
||||
@@ -313,18 +310,11 @@ export default {
|
||||
ebooks() {
|
||||
return this.audiobook.ebooks
|
||||
},
|
||||
epubEbook() {
|
||||
return this.audiobook.ebooks.find((eb) => eb.ext === '.epub')
|
||||
showExperimentalReadAlert() {
|
||||
return !this.tracks.length && this.ebooks.length && !this.showExperimentalFeatures
|
||||
},
|
||||
epubPath() {
|
||||
return this.epubEbook ? this.epubEbook.path : null
|
||||
},
|
||||
epubUrl() {
|
||||
if (!this.epubPath) return null
|
||||
return `/ebook/${this.libraryId}/${this.folderId}/${this.epubPath}`
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
numEbooks() {
|
||||
return this.audiobook.numEbooks
|
||||
},
|
||||
description() {
|
||||
return this.book.description || ''
|
||||
@@ -365,7 +355,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
openEbook() {
|
||||
this.showReader = true
|
||||
this.$store.commit('showEReader', this.audiobook)
|
||||
},
|
||||
toggleRead() {
|
||||
var updatePayload = {
|
||||
@@ -401,18 +391,9 @@ export default {
|
||||
this.$root.socket.emit('open_stream', this.audiobook.id)
|
||||
},
|
||||
editClick() {
|
||||
this.$store.commit('setBookshelfBookIds', [])
|
||||
this.$store.commit('showEditModal', this.audiobook)
|
||||
},
|
||||
lookupMetadata(index) {
|
||||
this.$axios
|
||||
.$get(`/api/metadata/${this.audiobookId}/${index}`)
|
||||
.then((metadata) => {
|
||||
console.log('Metadata for ' + index, metadata)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
},
|
||||
audiobookUpdated() {
|
||||
console.log('Audiobook Updated - Fetch full audiobook')
|
||||
this.$axios
|
||||
@@ -429,7 +410,7 @@ export default {
|
||||
if (confirm(`Are you sure you want to reset your progress?`)) {
|
||||
this.resettingProgress = true
|
||||
this.$axios
|
||||
.$delete(`/api/user/audiobook/${this.audiobookId}`)
|
||||
.$patch(`/api/user/audiobook/${this.audiobookId}/reset-progress`)
|
||||
.then(() => {
|
||||
console.log('Progress reset complete')
|
||||
this.$toast.success(`Your progress was reset`)
|
||||
@@ -447,6 +428,11 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
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() {
|
||||
this.$store.commit('audiobooks/removeListener', 'audiobook')
|
||||
|
||||
@@ -33,10 +33,10 @@
|
||||
|
||||
<div class="flex mt-2 -mx-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 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>
|
||||
|
||||
@@ -76,7 +76,9 @@ export default {
|
||||
isProcessing: false,
|
||||
audiobookCopies: [],
|
||||
isScrollable: false,
|
||||
newSeriesItems: []
|
||||
newSeriesItems: [],
|
||||
newTagItems: [],
|
||||
newGenreItems: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -86,9 +88,15 @@ export default {
|
||||
genres() {
|
||||
return this.$store.state.audiobooks.genres
|
||||
},
|
||||
genreItems() {
|
||||
return this.genres.concat(this.newGenreItems)
|
||||
},
|
||||
tags() {
|
||||
return this.$store.state.audiobooks.tags
|
||||
},
|
||||
tagItems() {
|
||||
return this.tags.concat(this.newTagItems)
|
||||
},
|
||||
series() {
|
||||
return this.$store.state.audiobooks.series
|
||||
},
|
||||
@@ -100,9 +108,42 @@ export default {
|
||||
}
|
||||
},
|
||||
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) {
|
||||
if (!item) return
|
||||
this.newSeriesItems.push(item)
|
||||
if (item && !this.newSeriesItems.includes(item)) {
|
||||
this.newSeriesItems.push(item)
|
||||
}
|
||||
},
|
||||
seriesChanged() {
|
||||
this.newSeriesItems = this.newSeriesItems.filter((item) => {
|
||||
|
||||
@@ -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,90 @@
|
||||
<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,
|
||||
newServerSettings: {}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
serverSettings(newVal, oldVal) {
|
||||
if (newVal && !oldVal) {
|
||||
this.newServerSettings = { ...this.serverSettings }
|
||||
this.initServerSettings()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dailyBackupsTooltip() {
|
||||
return 'Runs at 1am every day (your server time). Saved in /metadata/backups.'
|
||||
},
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
}
|
||||
},
|
||||
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
|
||||
})
|
||||
},
|
||||
initServerSettings() {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
|
||||
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
|
||||
this.dailyBackups = !!this.newServerSettings.backupSchedule
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initServerSettings()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+70
-166
@@ -1,125 +1,77 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="page p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<tables-users-table />
|
||||
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
|
||||
<div>
|
||||
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
|
||||
|
||||
<tables-libraries-table />
|
||||
<!-- <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">
|
||||
<h1 class="text-xl">Settings</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" small :disabled="updatingServerSettings" @input="updateScannerParseSubtitle" />
|
||||
<ui-tooltip :text="parseSubtitleTooltip">
|
||||
<p class="pl-4 text-lg">Scanner parse subtitles <span class="material-icons icon-text">info_outlined</span></p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<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-2">
|
||||
<ui-toggle-switch v-model="storeCoversInAudiobookDir" :disabled="updatingServerSettings" @input="updateCoverStorageDestination" />
|
||||
<ui-tooltip :text="coverDestinationTooltip">
|
||||
<p class="pl-4 text-lg">Store covers with audiobook <span class="material-icons icon-text">info_outlined</span></p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<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">Settings</h1>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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 class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" small :disabled="updatingServerSettings" @input="updateScannerParseSubtitle" />
|
||||
<ui-tooltip :text="parseSubtitleTooltip">
|
||||
<p class="pl-4 text-lg">Scanner parse subtitles <span class="material-icons icon-text">info_outlined</span></p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 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" />
|
||||
<ui-btn to="/config/log">View Logger</ui-btn>
|
||||
<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="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
|
||||
<div class="flex items-center py-4">
|
||||
<p class="font-mono">v{{ $config.version }}</p>
|
||||
<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 class="flex-grow" /> -->
|
||||
<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 class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="storeCoversInAudiobookDir" :disabled="updatingServerSettings" @input="updateCoverStorageDestination" />
|
||||
<ui-tooltip :text="coverDestinationTooltip">
|
||||
<p class="pl-4 text-lg">Store covers with audiobook <span class="material-icons icon-text">info_outlined</span></p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed bottom-0 left-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsRoot']) {
|
||||
redirect('/?error=unauthorized')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
storeCoversInAudiobookDir: false,
|
||||
isResettingAudiobooks: false,
|
||||
newServerSettings: {},
|
||||
storeCoversInAudiobookDir: false,
|
||||
updatingServerSettings: false,
|
||||
dailyBackups: true,
|
||||
backupsToKeep: 2
|
||||
newServerSettings: {}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -131,6 +83,15 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
saveMetadataTooltip() {
|
||||
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() {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
parseSubtitleTooltip() {
|
||||
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"'
|
||||
},
|
||||
@@ -140,30 +101,6 @@ export default {
|
||||
scannerFindCoversTooltip() {
|
||||
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'
|
||||
},
|
||||
saveMetadataTooltip() {
|
||||
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.'
|
||||
},
|
||||
dailyBackupsTooltip() {
|
||||
return 'Runs at 1am every day (your server time). Saved in /metadata/backups.'
|
||||
},
|
||||
backupsToKeepTooltip() {
|
||||
return ''
|
||||
},
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
isScanning() {
|
||||
return this.$store.state.isScanning
|
||||
},
|
||||
isScanningCovers() {
|
||||
return this.$store.state.isScanningCovers
|
||||
},
|
||||
showExperimentalFeatures: {
|
||||
get() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
@@ -174,17 +111,6 @@ export default {
|
||||
}
|
||||
},
|
||||
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)
|
||||
},
|
||||
updateScannerFindCovers(val) {
|
||||
this.updateServerSettings({
|
||||
scannerFindCovers: !!val
|
||||
@@ -214,23 +140,11 @@ export default {
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
},
|
||||
setDeveloperMode() {
|
||||
var value = !this.$store.state.developerMode
|
||||
this.$store.commit('setDeveloperMode', value)
|
||||
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
|
||||
},
|
||||
scan() {
|
||||
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
|
||||
},
|
||||
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')
|
||||
initServerSettings() {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
|
||||
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||
},
|
||||
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?')) {
|
||||
@@ -248,20 +162,10 @@ export default {
|
||||
this.$toast.error('Failed to reset audiobooks - manually remove the /config/audiobooks folder')
|
||||
})
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
this.initServerSettings()
|
||||
},
|
||||
initServerSettings() {
|
||||
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
|
||||
this.dailyBackups = !!this.newServerSettings.backupSchedule
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
this.initServerSettings()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div>
|
||||
<tables-libraries-table />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -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>
|
||||
+1
-11
@@ -1,13 +1,5 @@
|
||||
<template>
|
||||
<div class="page" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<!-- <app-book-shelf-toolbar /> -->
|
||||
<!-- <div class="flex h-full">
|
||||
<app-side-rail />
|
||||
<div class="flex-grow"> -->
|
||||
<!-- <app-book-shelf /> -->
|
||||
<!-- </div> -->
|
||||
<!-- </div> -->
|
||||
|
||||
<div class="flex h-full">
|
||||
<app-side-rail />
|
||||
<div class="flex-grow">
|
||||
@@ -21,9 +13,7 @@
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ redirect, store }) {
|
||||
var currentLibraryId = store.state.libraries.currentLibraryId
|
||||
console.log('Redir', currentLibraryId)
|
||||
redirect(`/library/${currentLibraryId}`)
|
||||
redirect(`/library/${store.state.libraries.currentLibraryId}`)
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<div class="flex h-full">
|
||||
<app-side-rail />
|
||||
<div class="flex-grow">
|
||||
<app-book-shelf-toolbar :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" />
|
||||
<app-book-shelf :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" />
|
||||
<app-book-shelf-toolbar :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" :view-mode.sync="viewMode" />
|
||||
<app-book-shelf :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" :view-mode="viewMode" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,33 +19,46 @@ export default {
|
||||
return redirect('/oops?message=Library not found')
|
||||
}
|
||||
|
||||
// Set filter by
|
||||
if (query.filter) {
|
||||
store.dispatch('user/updateUserSettings', { filterBy: query.filter })
|
||||
}
|
||||
var searchResults = []
|
||||
|
||||
// Search page
|
||||
var searchResults = {}
|
||||
var audiobookSearchResults = []
|
||||
var searchQuery = null
|
||||
if (params.id === 'search' && query.query) {
|
||||
searchQuery = query.query
|
||||
searchResults = await app.$axios.$get(`/api/library/${libraryId}/audiobooks?q=${query.query}`).catch((error) => {
|
||||
|
||||
searchResults = await app.$axios.$get(`/api/library/${libraryId}/search?q=${searchQuery}`).catch((error) => {
|
||||
console.error('Search error', error)
|
||||
return []
|
||||
return {}
|
||||
})
|
||||
audiobookSearchResults = searchResults.audiobooks || []
|
||||
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
|
||||
store.commit('audiobooks/setSelectedSeries', selectedSeries)
|
||||
|
||||
var libraryPage = params.id || ''
|
||||
store.commit('audiobooks/setLibraryPage', libraryPage)
|
||||
|
||||
return {
|
||||
id: libraryPage,
|
||||
libraryId,
|
||||
searchQuery,
|
||||
searchResults,
|
||||
selectedSeries
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
viewMode: 'grid'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route.query'(newVal) {
|
||||
@@ -53,6 +66,17 @@ export default {
|
||||
if (this.$route.query.query !== this.searchQuery) {
|
||||
this.newQuery()
|
||||
}
|
||||
} else if (this.id === 'series') {
|
||||
if (this.selectedSeries && this.$route.query.series && this.$route.query.series !== this.$encode(this.selectedSeries)) {
|
||||
// Series changed
|
||||
this.selectedSeries = this.$decode(this.$route.query.series)
|
||||
} else if (!this.selectedSeries && this.$route.query.series) {
|
||||
// Series selected
|
||||
this.selectedSeries = this.$decode(this.$route.query.series)
|
||||
} else if (this.selectedSeries && !this.$route.query.series) {
|
||||
// Series unselected
|
||||
this.selectedSeries = null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -64,9 +88,9 @@ export default {
|
||||
methods: {
|
||||
async newQuery() {
|
||||
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)
|
||||
return []
|
||||
return {}
|
||||
})
|
||||
this.searchQuery = query
|
||||
}
|
||||
|
||||
+20
-2
@@ -48,6 +48,24 @@ export default {
|
||||
}
|
||||
},
|
||||
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() {
|
||||
this.error = null
|
||||
this.processing = true
|
||||
@@ -65,7 +83,7 @@ export default {
|
||||
if (authRes && authRes.error) {
|
||||
this.error = authRes.error
|
||||
} else if (authRes) {
|
||||
this.$store.commit('user/setUser', authRes.user)
|
||||
this.setUser(authRes.user)
|
||||
}
|
||||
this.processing = false
|
||||
},
|
||||
@@ -83,7 +101,7 @@ export default {
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
this.$store.commit('user/setUser', res.user)
|
||||
this.setUser(res.user)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -129,9 +129,9 @@ export default {
|
||||
title: null,
|
||||
author: null,
|
||||
series: null,
|
||||
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a', '.flac', '.opus'],
|
||||
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a', '.flac', '.opus', '.mp4'],
|
||||
acceptedImageFormats: ['.png', '.jpg', '.jpeg', '.webp'],
|
||||
inputAccept: '.png, .jpg, .jpeg, .webp, .mp3, .m4b, .m4a, .flac, .opus',
|
||||
inputAccept: '.png, .jpg, .jpeg, .webp, .mp3, .m4b, .m4a, .flac, .opus, .mp4',
|
||||
isDragOver: false,
|
||||
showUploader: true,
|
||||
validAudioFiles: [],
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export default function ({ $axios, store }) {
|
||||
$axios.onRequest(config => {
|
||||
if (!config.url) {
|
||||
console.error('Axios request invalid config', config)
|
||||
return
|
||||
}
|
||||
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -15,6 +15,43 @@ const Constants = {
|
||||
CoverDestination
|
||||
}
|
||||
|
||||
const KeyNames = {
|
||||
27: 'Escape',
|
||||
32: 'Space',
|
||||
37: 'ArrowLeft',
|
||||
38: 'ArrowUp',
|
||||
39: 'ArrowRight',
|
||||
40: 'ArrowDown',
|
||||
76: 'KeyL',
|
||||
77: 'KeyM'
|
||||
}
|
||||
const Hotkeys = {
|
||||
AudioPlayer: {
|
||||
PLAY_PAUSE: 'Space',
|
||||
JUMP_FORWARD: 'ArrowRight',
|
||||
JUMP_BACKWARD: 'ArrowLeft',
|
||||
VOLUME_UP: 'ArrowUp',
|
||||
VOLUME_DOWN: 'ArrowDown',
|
||||
MUTE_UNMUTE: 'KeyM',
|
||||
SHOW_CHAPTERS: 'KeyL',
|
||||
INCREASE_PLAYBACK_RATE: 'Shift-ArrowUp',
|
||||
DECREASE_PLAYBACK_RATE: 'Shift-ArrowDown',
|
||||
CLOSE: 'Escape'
|
||||
},
|
||||
EReader: {
|
||||
NEXT_PAGE: 'ArrowRight',
|
||||
PREV_PAGE: 'ArrowLeft',
|
||||
CLOSE: 'Escape'
|
||||
},
|
||||
Modal: {
|
||||
NEXT_PAGE: 'ArrowRight',
|
||||
PREV_PAGE: 'ArrowLeft',
|
||||
CLOSE: 'Escape'
|
||||
}
|
||||
}
|
||||
|
||||
export default ({ app }, inject) => {
|
||||
inject('constants', Constants)
|
||||
inject('keynames', KeyNames)
|
||||
inject('hotkeys', Hotkeys)
|
||||
}
|
||||
@@ -1,6 +1,19 @@
|
||||
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.$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) => {
|
||||
if (bytes === 0) {
|
||||
return '0 Bytes'
|
||||
@@ -31,7 +44,7 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||
_seconds -= _minutes * 60
|
||||
var _hours = Math.floor(_minutes / 60)
|
||||
_minutes -= _hours * 60
|
||||
_seconds = Math.round(_seconds)
|
||||
_seconds = Math.floor(_seconds)
|
||||
if (!_hours) {
|
||||
return `${_minutes}:${_seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
@@ -116,6 +129,19 @@ Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
|
||||
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'))
|
||||
Vue.prototype.$encode = encode
|
||||
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
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user