mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-31 11:38:47 -05:00
Compare commits
536 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2db81bf7d | ||
|
|
b376f89ce5 | ||
|
|
5633113f25 | ||
|
|
669415cfbf | ||
|
|
9f366863a9 | ||
|
|
0d644fe0c9 | ||
|
|
72fa6b8200 | ||
|
|
6d3f1d263a | ||
|
|
47bf9f7836 | ||
|
|
2738402aac | ||
|
|
68d36522b1 | ||
|
|
24a587b944 | ||
|
|
76119445a3 | ||
|
|
46ec59c74e | ||
|
|
2b7122c744 | ||
|
|
52f0a5432b | ||
|
|
7391b4d0ec | ||
|
|
aa7ee3e8ff | ||
|
|
bef0f3709f | ||
|
|
f33b011847 | ||
|
|
2d8d11d4da | ||
|
|
10b1784f6d | ||
|
|
f2f2ea161c | ||
|
|
dc67a52000 | ||
|
|
05820aa820 | ||
|
|
8966dbbcd1 | ||
|
|
cf32819c01 | ||
|
|
728496010c | ||
|
|
0a08f47942 | ||
|
|
39ceb02500 | ||
|
|
4336714248 | ||
|
|
1d41904fc3 | ||
|
|
fae383a045 | ||
|
|
9720ba3eed | ||
|
|
d3256d59d5 | ||
|
|
fa5f7ab7a5 | ||
|
|
6f26fd7238 | ||
|
|
6abc0819d9 | ||
|
|
b580a23e7e | ||
|
|
f659c3f11c | ||
|
|
0282a0521b | ||
|
|
75637e4b94 | ||
|
|
b6c789dee6 | ||
|
|
8d3d636329 | ||
|
|
b8c8d2a02e | ||
|
|
98104a3c03 | ||
|
|
8f4c65ec8c | ||
|
|
341a0452da | ||
|
|
b5e255a384 | ||
|
|
34156af403 | ||
|
|
7c9c278cc4 | ||
|
|
450507a812 | ||
|
|
cf00650c6d | ||
|
|
e6ab28365f | ||
|
|
80fd2a1a18 | ||
|
|
84160b2f07 | ||
|
|
fbc2c2b481 | ||
|
|
57a5005197 | ||
|
|
9350c5513e | ||
|
|
f59516cc6e | ||
|
|
88078ff813 | ||
|
|
281de48ed4 | ||
|
|
3c6d6bf688 | ||
|
|
8ac0ce399f | ||
|
|
80458e24bd | ||
|
|
6ab966ee2f | ||
|
|
166477ae27 | ||
|
|
a719065b8d | ||
|
|
36599a2984 | ||
|
|
d9c9289d65 | ||
|
|
e5579b2c33 | ||
|
|
618028503b | ||
|
|
2f6756eddf | ||
|
|
ad53894ea1 | ||
|
|
086954fb9c | ||
|
|
f243ad14e0 | ||
|
|
2e5822b7c8 | ||
|
|
3d468339b3 | ||
|
|
b4c14fc78d | ||
|
|
d9584174ff | ||
|
|
36e00e8d6a | ||
|
|
5e69b54eb0 | ||
|
|
5a8c60a8bc | ||
|
|
3ff41f2b43 | ||
|
|
17cab0d3a8 | ||
|
|
0fac9e367d | ||
|
|
bf0bcf8967 | ||
|
|
2e06ae01a1 | ||
|
|
288a32cc1e | ||
|
|
26fc3a1966 | ||
|
|
9d257ebecd | ||
|
|
1a046a9bcb | ||
|
|
7a9c869ac5 | ||
|
|
572fb0993c | ||
|
|
9beee3ed65 | ||
|
|
ab19e25586 | ||
|
|
07d7d16418 | ||
|
|
5e1e748c71 | ||
|
|
6651ad0d45 | ||
|
|
288beae874 | ||
|
|
32ce771911 | ||
|
|
d944ecaa21 | ||
|
|
5aeb6ade72 | ||
|
|
107b4b83c1 | ||
|
|
0d61e29ecf | ||
|
|
781d4f570f | ||
|
|
a4d4f1bc2e | ||
|
|
048e27f03f | ||
|
|
8c434703fb | ||
|
|
3cc900ffbf | ||
|
|
7b6aa3ba5a | ||
|
|
aa933df525 | ||
|
|
a0f137936d | ||
|
|
dcbfc963c1 | ||
|
|
91fa78d740 | ||
|
|
89eb857c14 | ||
|
|
e07d17c472 | ||
|
|
4c2c320b9d | ||
|
|
56c574c928 | ||
|
|
d2aea86957 | ||
|
|
80e061115f | ||
|
|
4299627f5f | ||
|
|
6a722102c5 | ||
|
|
f22f3361d5 | ||
|
|
4dec8c265d | ||
|
|
d990e5b909 | ||
|
|
fb48636510 | ||
|
|
1ad6722e6d | ||
|
|
557ef2ef79 | ||
|
|
cff2caa07a | ||
|
|
237fe84c54 | ||
|
|
078cb0855f | ||
|
|
ecba67da6d | ||
|
|
ea05e1f559 | ||
|
|
d3a55c8b1a | ||
|
|
d6b17678ec | ||
|
|
33e287a543 | ||
|
|
08f045a02b | ||
|
|
e8c14dbb58 | ||
|
|
bf48eee705 | ||
|
|
8f4c75ff2b | ||
|
|
ee75d672e6 | ||
|
|
e140897313 | ||
|
|
d1671f0ddc | ||
|
|
2730486ba5 | ||
|
|
49e4515785 | ||
|
|
819c524f51 | ||
|
|
6d968f9044 | ||
|
|
23fa9e8d7f | ||
|
|
59a428d549 | ||
|
|
70c213ad22 | ||
|
|
aad6402fdb | ||
|
|
5ce1cda2d0 | ||
|
|
ba60fc7581 | ||
|
|
0344e8cf1b | ||
|
|
f840aa80f8 | ||
|
|
c17540e191 | ||
|
|
309ef807ab | ||
|
|
61e05e92a8 | ||
|
|
1e5d6a5d52 | ||
|
|
ff831678e8 | ||
|
|
910be21e93 | ||
|
|
89055f8655 | ||
|
|
b9ccc28baa | ||
|
|
5a3d450482 | ||
|
|
047e7a72f2 | ||
|
|
3a9d09ea63 | ||
|
|
ee3d3808ef | ||
|
|
8f5a6b7c95 | ||
|
|
840811b464 | ||
|
|
567e1c46db | ||
|
|
cfe0c2a986 | ||
|
|
68546acf2a | ||
|
|
5220361151 | ||
|
|
076e01dbfe | ||
|
|
f15ed08b6a | ||
|
|
828b96b2d9 | ||
|
|
3100437651 | ||
|
|
20880a6bf6 | ||
|
|
2eff69fe9f | ||
|
|
5f035db0a9 | ||
|
|
e4a7e9d6b5 | ||
|
|
ab14b561f5 | ||
|
|
5ce4734a70 | ||
|
|
1ae2089253 | ||
|
|
3c21e9d413 | ||
|
|
9616d99640 | ||
|
|
2ef11e5ad0 | ||
|
|
27497451d9 | ||
|
|
94fd3841aa | ||
|
|
225dcdeafd | ||
|
|
2c9f2e0d68 | ||
|
|
a9f74ace5a | ||
|
|
6dc5b58d8e | ||
|
|
88c794e710 | ||
|
|
61f2fb28e0 | ||
|
|
1df4dca4bb | ||
|
|
6278bb8665 | ||
|
|
4229cb7fb6 | ||
|
|
5778200c8f | ||
|
|
5c1c511718 | ||
|
|
f9c4dd2457 | ||
|
|
3bccd52196 | ||
|
|
0c23da7b02 | ||
|
|
d577cae393 | ||
|
|
24228b4424 | ||
|
|
8dc4490169 | ||
|
|
ef1cdf6ad2 | ||
|
|
e054b9a54c | ||
|
|
32616aa441 | ||
|
|
0ee6336b02 | ||
|
|
9a477a9270 | ||
|
|
976ae502bb | ||
|
|
c4c12836a4 | ||
|
|
5a70c0d7be | ||
|
|
60a80a2996 | ||
|
|
ce88c6ccc3 | ||
|
|
b42edfe7a7 | ||
|
|
0cbcfbd273 | ||
|
|
8ecec93e67 | ||
|
|
49403771c9 | ||
|
|
50215dab9a | ||
|
|
58b9a42c84 | ||
|
|
d7264f8c22 | ||
|
|
bef6549805 | ||
|
|
6f65350269 | ||
|
|
5644a40a03 | ||
|
|
920ddf43d7 | ||
|
|
4a5f534a65 | ||
|
|
24031f12db | ||
|
|
22361d785d | ||
|
|
8c5ce6149f | ||
|
|
516b0b4464 | ||
|
|
d22052c612 | ||
|
|
b4ce5342c0 | ||
|
|
0d5792405f | ||
|
|
48a590df4a | ||
|
|
c264332994 | ||
|
|
cdd740015c | ||
|
|
07ad81969c | ||
|
|
dcdd4bb20b | ||
|
|
c98fac30b6 | ||
|
|
1f8372f5e5 | ||
|
|
616ecf77b0 | ||
|
|
656c81a1fa | ||
|
|
290a377ef9 | ||
|
|
05731c9f72 | ||
|
|
3108bc5ccc | ||
|
|
e687a3403e | ||
|
|
753ae3d7dc | ||
|
|
c9a2fdcb29 | ||
|
|
f84634e978 | ||
|
|
89821b91b0 | ||
|
|
347b49f564 | ||
|
|
5ad9f507ba | ||
|
|
f8f555b4b6 | ||
|
|
786df450e5 | ||
|
|
db9d5c9d43 | ||
|
|
b447cf5c1c | ||
|
|
f44b7ed1d0 | ||
|
|
b0b7a0a618 | ||
|
|
bf9f3895db | ||
|
|
f3555a12ce | ||
|
|
b2acdadcea | ||
|
|
9eff471afa | ||
|
|
8979586404 | ||
|
|
bfe514b7d4 | ||
|
|
752bfffb11 | ||
|
|
10f5bc8cbe | ||
|
|
565ff36d4e | ||
|
|
401bd91204 | ||
|
|
5d7c197c89 | ||
|
|
8e97be8ef4 | ||
|
|
733ad52684 | ||
|
|
5ccf0df308 | ||
|
|
a3a8937ba3 | ||
|
|
2662e8f715 | ||
|
|
28b2005068 | ||
|
|
7c9631c1b0 | ||
|
|
4352989242 | ||
|
|
73bb73a04a | ||
|
|
20a1d40d99 | ||
|
|
e10b178565 | ||
|
|
46b0b3a6ef | ||
|
|
f2aed08d51 | ||
|
|
c2c8cf919e | ||
|
|
9ebe23e91b | ||
|
|
3d96749d38 | ||
|
|
1dc369180c | ||
|
|
8d3a326216 | ||
|
|
1d3ad38187 | ||
|
|
1b22205f74 | ||
|
|
826fee4590 | ||
|
|
f0929729a3 | ||
|
|
98ed2e01cc | ||
|
|
ed82a5aa19 | ||
|
|
d7b2476473 | ||
|
|
ee162f468a | ||
|
|
0d5a30b214 | ||
|
|
cb6678fa71 | ||
|
|
10011d3886 | ||
|
|
0367d9ec2a | ||
|
|
26f520ca4a | ||
|
|
e282142d3f | ||
|
|
7ba10db7d4 | ||
|
|
f6de373388 | ||
|
|
8683fc9fe4 | ||
|
|
fd0920c808 | ||
|
|
9922294507 | ||
|
|
f42ab45e1b | ||
|
|
7a131880e5 | ||
|
|
a446fc0f20 | ||
|
|
202c26acf5 | ||
|
|
f0b2acb4c7 | ||
|
|
102c90c4e8 | ||
|
|
7c484d8e96 | ||
|
|
e9f0f7d1bc | ||
|
|
f37ab53eff | ||
|
|
97b0b98605 | ||
|
|
1ab34fa77f | ||
|
|
b64ecc7c6f | ||
|
|
a11fc214e9 | ||
|
|
61c48602e8 | ||
|
|
452d59dcf6 | ||
|
|
5e976c08af | ||
|
|
f1cce76e2c | ||
|
|
872fba1103 | ||
|
|
944f5950ca | ||
|
|
bfa87a2131 | ||
|
|
0e75c80627 | ||
|
|
2c25f64652 | ||
|
|
45cf00bd04 | ||
|
|
f6113e85c7 | ||
|
|
2c90bba774 | ||
|
|
51b0750a3f | ||
|
|
6eab985b1e | ||
|
|
81a9b8d158 | ||
|
|
9519f6418d | ||
|
|
9967a5dc66 | ||
|
|
9382055bf2 | ||
|
|
604f52762b | ||
|
|
ae88a4d20a | ||
|
|
b5a27226cc | ||
|
|
2c71324381 | ||
|
|
207ba7ec8e | ||
|
|
e56b8edc0a | ||
|
|
8ab0a0a14d | ||
|
|
4e01722ba6 | ||
|
|
87eaacea22 | ||
|
|
3ad4f05449 | ||
|
|
817be40959 | ||
|
|
d18592eaeb | ||
|
|
0aae672e19 | ||
|
|
0a6cd89090 | ||
|
|
cfd9a01da7 | ||
|
|
942aa93f57 | ||
|
|
763c0f4a3d | ||
|
|
7af3033f8d | ||
|
|
91d8451ab3 | ||
|
|
6aaf3f0f02 | ||
|
|
226a774ab9 | ||
|
|
19cf3bfb9f | ||
|
|
67bbe21513 | ||
|
|
b668c6e37a | ||
|
|
71762ef837 | ||
|
|
b1524d245e | ||
|
|
8b39b01269 | ||
|
|
f7849d2956 | ||
|
|
ac746f199b | ||
|
|
af4c35069b | ||
|
|
fea28351f9 | ||
|
|
bb124d3274 | ||
|
|
6cd1b82ada | ||
|
|
c701617fbb | ||
|
|
405c954b65 | ||
|
|
5d84c426fe | ||
|
|
083ba2fe19 | ||
|
|
1024bc5a75 | ||
|
|
9553c19b33 | ||
|
|
2cbc9a07cb | ||
|
|
ab97a9d613 | ||
|
|
f1a7fd0d50 | ||
|
|
e9d7efbc5c | ||
|
|
f0f03efe17 | ||
|
|
6e5d334874 | ||
|
|
6822628994 | ||
|
|
98d9fd8c32 | ||
|
|
e2cca60853 | ||
|
|
e80b313a7b | ||
|
|
b09b95ef24 | ||
|
|
aec45d04f7 | ||
|
|
87d037cb0a | ||
|
|
f6baf06164 | ||
|
|
7e75845851 | ||
|
|
2a11932822 | ||
|
|
80fee92037 | ||
|
|
d0c02a801a | ||
|
|
9e13c64408 | ||
|
|
826963bf00 | ||
|
|
39b6ede1e9 | ||
|
|
066d853156 | ||
|
|
efae529fac | ||
|
|
934c0b9093 | ||
|
|
f02992dd4d | ||
|
|
10011bd6a3 | ||
|
|
a44ee913c4 | ||
|
|
adccccbd7a | ||
|
|
05b1b2be36 | ||
|
|
7cc35a2cbe | ||
|
|
8d479b6e34 | ||
|
|
74d300f048 | ||
|
|
1dd1fe8994 | ||
|
|
03115e5e53 | ||
|
|
b1c07834be | ||
|
|
b9da3fa30e | ||
|
|
42ff3d8314 | ||
|
|
e63aab95d8 | ||
|
|
9123dcb365 | ||
|
|
7567e91878 | ||
|
|
1b1bdea3c8 | ||
|
|
2df95c1712 | ||
|
|
4ad1cd2968 | ||
|
|
0ecfdab463 | ||
|
|
75276f5a44 | ||
|
|
4585d2816b | ||
|
|
f8f94f2a6d | ||
|
|
2c8448d147 | ||
|
|
ea1d051cfb | ||
|
|
a38e43213d | ||
|
|
6cac8fcd6e | ||
|
|
8e65c78869 | ||
|
|
a3899b68e1 | ||
|
|
1187f91063 | ||
|
|
7c288a5ff9 | ||
|
|
e0dae44c7d | ||
|
|
754498958d | ||
|
|
ec15978e26 | ||
|
|
469167df66 | ||
|
|
e7c43a3f32 | ||
|
|
24989e73ae | ||
|
|
13427b9f70 | ||
|
|
adafefecd4 | ||
|
|
6f96b069b5 | ||
|
|
6c1b4e3a36 | ||
|
|
21343ffbd1 | ||
|
|
4f94deefa0 | ||
|
|
332078e6c1 | ||
|
|
ff0d6326d3 | ||
|
|
8d451217a3 | ||
|
|
f21d69339f | ||
|
|
c77cead9ae | ||
|
|
b334d40998 | ||
|
|
4e4a976050 | ||
|
|
9d7d4c6902 | ||
|
|
7222171c5b | ||
|
|
361732a463 | ||
|
|
1ebe8a6f4c | ||
|
|
a98942a361 | ||
|
|
0bc89cd40f | ||
|
|
2ae86ab5bb | ||
|
|
c707bcf0f6 | ||
|
|
10040ba9fa | ||
|
|
7afda1295b | ||
|
|
6d6e8613cf | ||
|
|
3651fffbee | ||
|
|
8d03b23f46 | ||
|
|
fc44c801f2 | ||
|
|
6056c14926 | ||
|
|
f465193b9c | ||
|
|
09c9c28028 | ||
|
|
f1130eb63a | ||
|
|
db80cec168 | ||
|
|
dd9a3858d7 | ||
|
|
38029d1202 | ||
|
|
aac2879652 | ||
|
|
8c9fc3ddb5 | ||
|
|
33e04d0cbb | ||
|
|
fbb5fd41fb | ||
|
|
43a5296dd7 | ||
|
|
345ff1aa66 | ||
|
|
56e3449db6 | ||
|
|
1372c24535 | ||
|
|
409c5f7b75 | ||
|
|
83d0db0607 | ||
|
|
91b6c4412d | ||
|
|
09eefae808 | ||
|
|
80b3bfea51 | ||
|
|
516298b5b2 | ||
|
|
8edab98163 | ||
|
|
58da095bcf | ||
|
|
b9633691f4 | ||
|
|
7ec1d8ee5f | ||
|
|
83a1374e79 | ||
|
|
5ef00bac92 | ||
|
|
95c4b3862b | ||
|
|
eeaf012cdc | ||
|
|
11120a3765 | ||
|
|
4d0acb30ba | ||
|
|
4dbe8d29d9 | ||
|
|
0ca4ff4fca | ||
|
|
8be1651c6b | ||
|
|
af2db86d1a | ||
|
|
57c834f88d | ||
|
|
65fdebde20 | ||
|
|
b58e42ebf3 | ||
|
|
b2d45f598b | ||
|
|
09c4e690c6 | ||
|
|
67ba481dca | ||
|
|
710a62c2af | ||
|
|
5a9eed0a5a | ||
|
|
354e16e462 | ||
|
|
1d974375a0 | ||
|
|
1c40af3eef | ||
|
|
daa8c4cd67 | ||
|
|
d5da4441cd | ||
|
|
80aea0c82d | ||
|
|
14836eeb0d | ||
|
|
85e9883d3e | ||
|
|
80ca73e491 | ||
|
|
22323f606d | ||
|
|
01b65eb678 | ||
|
|
d1d94c37a7 | ||
|
|
838a24c8a5 | ||
|
|
3f380b0839 | ||
|
|
7fdf1a1d7f | ||
|
|
38596d017f | ||
|
|
95e6fef3d1 | ||
|
|
4359ca28df | ||
|
|
8b685436de | ||
|
|
8d0064763c | ||
|
|
7010a13648 | ||
|
|
812395b21b | ||
|
|
62b0940766 | ||
|
|
08676a675a | ||
|
|
be53b31712 | ||
|
|
e1ddb95250 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -7,11 +7,13 @@
|
||||
/podcasts/
|
||||
/media/
|
||||
/metadata/
|
||||
test/
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
/dist/
|
||||
/deploy/
|
||||
/coverage/
|
||||
/.nyc_output/
|
||||
|
||||
sw.*
|
||||
.DS_STORE
|
||||
.idea/*
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -16,5 +16,6 @@
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.detectIndentation": true,
|
||||
"editor.tabSize": 2
|
||||
"editor.tabSize": 2,
|
||||
"javascript.format.semicolons": "remove"
|
||||
}
|
||||
@@ -29,12 +29,6 @@ RUN npm ci --only=production
|
||||
|
||||
RUN apk del make python3 g++
|
||||
|
||||
ENV NODE_OPTIONS=--max-old-space-size=4096
|
||||
|
||||
EXPOSE 80
|
||||
HEALTHCHECK \
|
||||
--interval=30s \
|
||||
--timeout=3s \
|
||||
--start-period=10s \
|
||||
CMD curl -f http://127.0.0.1/healthcheck || exit 1
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
@@ -60,13 +60,13 @@ install_ffmpeg() {
|
||||
fi
|
||||
|
||||
$WGET
|
||||
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1
|
||||
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1 --no-same-owner
|
||||
rm ffmpeg-git-amd64-static.tar.xz
|
||||
|
||||
# Temp downloading tone library to the ffmpeg dir
|
||||
echo "Getting tone.."
|
||||
$WGET_TONE
|
||||
tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1
|
||||
tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1 --no-same-owner
|
||||
rm tone-0.1.5-linux-x64.tar.gz
|
||||
|
||||
echo "Good to go on Ffmpeg (& tone)... hopefully"
|
||||
|
||||
@@ -258,4 +258,24 @@ Bookshelf Label
|
||||
|
||||
.no-bars .Vue-Toastification__container.top-right {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.abs-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 6px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.abs-btn:hover:not(:disabled)::before {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.abs-btn:disabled::before {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
@@ -186,7 +186,7 @@ export default {
|
||||
methods: {
|
||||
requestBatchQuickEmbed() {
|
||||
const payload = {
|
||||
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
|
||||
message: this.$strings.MessageConfirmQuickEmbed,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
@@ -219,7 +219,7 @@ export default {
|
||||
},
|
||||
async batchRescan() {
|
||||
const payload = {
|
||||
message: `Are you sure you want to re-scan ${this.selectedMediaItems.length} items?`,
|
||||
message: this.$getString('MessageConfirmReScanLibraryItems', [this.selectedMediaItems.length]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
@@ -316,13 +316,15 @@ export default {
|
||||
},
|
||||
batchDeleteClick() {
|
||||
const payload = {
|
||||
message: `This will delete ${this.numMediaItemsSelected} library items from the database and your file system. Are you sure?`,
|
||||
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
|
||||
message: this.$getString('MessageConfirmDeleteLibraryItems', [this.numMediaItemsSelected]),
|
||||
checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox,
|
||||
yesButtonText: this.$strings.ButtonDelete,
|
||||
yesButtonColor: 'error',
|
||||
checkboxDefaultValue: true,
|
||||
checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0),
|
||||
callback: (confirmed, hardDelete) => {
|
||||
if (confirmed) {
|
||||
localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)
|
||||
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
|
||||
this.$axios
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
|
||||
<!-- Cover size widget -->
|
||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
|
||||
<widgets-cover-size-widget class="fixed right-4 z-50" :style="{ bottom: streamLibraryItem ? '181px' : '16px' }" />
|
||||
|
||||
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||
@@ -68,6 +68,9 @@ export default {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
libraryName() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||
},
|
||||
@@ -91,6 +94,9 @@ export default {
|
||||
},
|
||||
selectedMediaItems() {
|
||||
return this.$store.state.globals.selectedMediaItems || []
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -335,9 +341,15 @@ export default {
|
||||
libraryItemsAdded(libraryItems) {
|
||||
console.log('libraryItems added', libraryItems)
|
||||
|
||||
const isThisLibrary = !libraryItems.some((li) => li.libraryId !== this.currentLibraryId)
|
||||
if (!this.search && isThisLibrary) {
|
||||
this.fetchCategories()
|
||||
const recentlyAddedShelf = this.shelves.find((shelf) => shelf.id === 'recently-added')
|
||||
if (!recentlyAddedShelf) return
|
||||
|
||||
// Add new library item to the recently added shelf
|
||||
for (const libraryItem of libraryItems) {
|
||||
if (libraryItem.libraryId === this.currentLibraryId && !recentlyAddedShelf.entities.some((ent) => ent.id === libraryItem.id)) {
|
||||
// Add to front of array
|
||||
recentlyAddedShelf.entities.unshift(libraryItem)
|
||||
}
|
||||
}
|
||||
},
|
||||
libraryItemsUpdated(items) {
|
||||
@@ -346,8 +358,6 @@ export default {
|
||||
})
|
||||
},
|
||||
episodeAdded(episodeWithLibraryItem) {
|
||||
console.log('Podcast episode added', episodeWithLibraryItem)
|
||||
|
||||
const isThisLibrary = episodeWithLibraryItem.libraryItem?.libraryId === this.currentLibraryId
|
||||
if (!this.search && isThisLibrary) {
|
||||
this.fetchCategories()
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</svg>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p class="text-sm">{{ $strings.ButtonSearch }}</p>
|
||||
<p class="text-sm">{{ $strings.ButtonAdd }}</p>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
</div>
|
||||
|
||||
<div class="w-44 h-12 px-4 border-t bg-bg border-black border-opacity-20 fixed left-0 flex flex-col justify-center" :class="wrapperClass" :style="{ bottom: streamLibraryItem ? '160px' : '0px' }">
|
||||
<div class="flex justify-between">
|
||||
<p class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<button type="button" class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</button>
|
||||
|
||||
<p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p>
|
||||
<p class="text-xs text-gray-300 italic">{{ Source }}</p>
|
||||
</div>
|
||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
|
||||
</div>
|
||||
@@ -99,6 +99,16 @@ export default {
|
||||
id: 'config-item-metadata-utils',
|
||||
title: this.$strings.HeaderItemMetadataUtils,
|
||||
path: '/config/item-metadata-utils'
|
||||
},
|
||||
{
|
||||
id: 'config-rss-feeds',
|
||||
title: this.$strings.HeaderRSSFeeds,
|
||||
path: '/config/rss-feeds'
|
||||
},
|
||||
{
|
||||
id: 'config-authentication',
|
||||
title: this.$strings.HeaderAuthentication,
|
||||
path: '/config/authentication'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
|
||||
<widgets-cover-size-widget class="fixed right-4 z-50" :style="{ bottom: streamLibraryItem ? '181px' : '16px' }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -205,6 +205,9 @@ export default {
|
||||
sizeMultiplier() {
|
||||
const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
return this.entityWidth / baseSize
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -313,7 +316,7 @@ export default {
|
||||
this.currentSFQueryString = this.buildSearchParams()
|
||||
}
|
||||
|
||||
const entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
|
||||
let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
|
||||
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete`
|
||||
|
||||
@@ -623,6 +626,11 @@ export default {
|
||||
return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed
|
||||
},
|
||||
async init(bookshelf) {
|
||||
if (this.entityName === 'series') {
|
||||
this.booksPerFetch = 50
|
||||
} else {
|
||||
this.booksPerFetch = 100
|
||||
}
|
||||
this.checkUpdateSearchParams()
|
||||
this.initSizeData(bookshelf)
|
||||
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
<div class="flex items-center mb-2">
|
||||
<h1 class="text-xl">{{ headerText }}</h1>
|
||||
|
||||
<div v-if="showAddButton" class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clicked">
|
||||
<button type="button" class="material-icons" :aria-label="$strings.ButtonAdd + ': ' + headerText" style="font-size: 1.4rem">add</button>
|
||||
</div>
|
||||
<slot name="header-items"></slot>
|
||||
</div>
|
||||
|
||||
<p v-if="description" id="settings-description" class="mb-6 text-gray-200" v-html="description" />
|
||||
@@ -19,14 +17,9 @@ export default {
|
||||
props: {
|
||||
headerText: String,
|
||||
description: String,
|
||||
note: String,
|
||||
showAddButton: Boolean
|
||||
note: String
|
||||
},
|
||||
methods: {
|
||||
clicked() {
|
||||
this.$emit('clicked')
|
||||
}
|
||||
}
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,117 +3,119 @@
|
||||
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
|
||||
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||
|
||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
<div id="siderail-buttons-container" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p>
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p>
|
||||
|
||||
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons text-2xl">format_list_bulleted</span>
|
||||
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons text-2xl">format_list_bulleted</span>
|
||||
|
||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p>
|
||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p>
|
||||
|
||||
<div v-show="isPodcastLatestPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
<div v-show="isPodcastLatestPage" 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="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>
|
||||
<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="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p>
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p>
|
||||
|
||||
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||
</svg>
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||
</svg>
|
||||
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p>
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p>
|
||||
|
||||
<div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
<div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons-outlined text-2xl">collections_bookmark</span>
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons-outlined text-2xl">collections_bookmark</span>
|
||||
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
|
||||
|
||||
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons text-2.5xl">queue_music</span>
|
||||
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons text-2.5xl">queue_music</span>
|
||||
|
||||
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
|
||||
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
|
||||
|
||||
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z"
|
||||
/>
|
||||
</svg>
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p>
|
||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p>
|
||||
|
||||
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/narrators`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isNarratorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons text-2xl">record_voice_over</span>
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/narrators`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isNarratorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons text-2xl">record_voice_over</span>
|
||||
|
||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p>
|
||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p>
|
||||
|
||||
<div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
<div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="abs-icons icon-podcast text-xl"></span>
|
||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="abs-icons icon-podcast text-xl"></span>
|
||||
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSearch }}</p>
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAdd }}</p>
|
||||
|
||||
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isMusicLibrary" :to="`/library/${currentLibraryId}/bookshelf/albums`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isMusicAlbumsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons-outlined text-xl">album</span>
|
||||
<nuxt-link v-if="isMusicLibrary" :to="`/library/${currentLibraryId}/bookshelf/albums`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isMusicAlbumsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons-outlined text-xl">album</span>
|
||||
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p>
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p>
|
||||
|
||||
<div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
<div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons text-2xl">file_download</span>
|
||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons text-2xl">file_download</span>
|
||||
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonDownloadQueue }}</p>
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonDownloadQueue }}</p>
|
||||
|
||||
<div v-show="isPodcastDownloadQueuePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
<div v-show="isPodcastDownloadQueuePage" 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>
|
||||
<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="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p>
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p>
|
||||
|
||||
<div v-show="showingIssues" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
<div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center">
|
||||
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<div v-show="showingIssues" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
<div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center">
|
||||
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-12 px-1 py-2 border-t border-black border-opacity-20 absolute left-0" :style="{ bottom: streamLibraryItem ? '240px' : '65px' }">
|
||||
<div class="w-full h-12 px-1 py-2 border-t border-black/20 bg-bg absolute left-0" :style="{ bottom: streamLibraryItem ? '224px' : '65px' }">
|
||||
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p>
|
||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
|
||||
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
||||
@@ -235,3 +237,12 @@ export default {
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#siderail-buttons-container {
|
||||
max-height: calc(100vh - 64px - 48px);
|
||||
}
|
||||
#siderail-buttons-container.player-open {
|
||||
max-height: calc(100vh - 64px - 48px - 160px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
|
||||
<div id="videoDock" />
|
||||
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-2 top-2 md:left-4 cursor-pointer">
|
||||
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||
</nuxt-link>
|
||||
<div class="absolute left-2 top-2 md:left-4 cursor-pointer">
|
||||
<covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||
</div>
|
||||
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
||||
<div class="min-w-0">
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
|
||||
@@ -15,7 +15,7 @@
|
||||
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
|
||||
<div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
|
||||
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}?library=${libraryId}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</div>
|
||||
<div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</div>
|
||||
<widgets-explicit-indicator :explicit="isExplicit"></widgets-explicit-indicator>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<nuxt-link :to="`/author/${author.id}?library=${currentLibraryId}`">
|
||||
<nuxt-link :to="`/author/${author.id}`">
|
||||
<div @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
<!-- Image or placeholder -->
|
||||
@@ -8,7 +8,7 @@
|
||||
<!-- Author name & num books overlay -->
|
||||
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} {{ $strings.LabelBooks }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Search icon btn -->
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
</div>
|
||||
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">by {{ book.author }}</p>
|
||||
<p v-if="book.narrator" class="text-gray-400 text-xs">Narrated by {{ book.narrator }}</p>
|
||||
<p v-if="book.duration" class="text-gray-400 text-xs">Runtime: {{ $elapsedPrettyExtended(book.duration * 60) }}</p>
|
||||
<div v-if="book.series && book.series.length" class="flex py-1 -mx-1">
|
||||
<p v-if="book.duration" class="text-gray-400 text-xs">Runtime: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</p>
|
||||
<div v-if="book.series?.length" class="flex py-1 -mx-1">
|
||||
<div v-for="(series, index) in book.series" :key="index" class="bg-white bg-opacity-10 rounded-full px-1 py-0.5 mx-1">
|
||||
<p class="leading-3 text-xs text-gray-400">
|
||||
{{ series.series }}<span v-if="series.sequence"> #{{ series.sequence }}</span>
|
||||
@@ -29,9 +29,7 @@
|
||||
</div>
|
||||
<div v-else class="px-4 flex-grow">
|
||||
<h1>
|
||||
<div class="flex items-center">
|
||||
{{ book.title }}<widgets-explicit-indicator :explicit="book.explicit" />
|
||||
</div>
|
||||
<div class="flex items-center">{{ book.title }}<widgets-explicit-indicator :explicit="book.explicit" /></div>
|
||||
</h1>
|
||||
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p>
|
||||
<p v-if="book.genres" class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>
|
||||
@@ -56,7 +54,8 @@ export default {
|
||||
default: () => {}
|
||||
},
|
||||
isPodcast: Boolean,
|
||||
bookCoverAspectRatio: Number
|
||||
bookCoverAspectRatio: Number,
|
||||
currentBookDuration: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -65,12 +64,27 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
bookCovers() {
|
||||
return this.book.covers ? this.book.covers || [] : []
|
||||
return this.book.covers || []
|
||||
},
|
||||
bookDuration() {
|
||||
return (this.book.duration || 0) * 60
|
||||
},
|
||||
bookDurationComparison() {
|
||||
if (!this.book.duration || !this.currentBookDuration) return ''
|
||||
const currentBookDurationMinutes = Math.floor(this.currentBookDuration / 60)
|
||||
let differenceInMinutes = currentBookDurationMinutes - this.book.duration
|
||||
if (differenceInMinutes < 0) {
|
||||
differenceInMinutes = Math.abs(differenceInMinutes)
|
||||
return `(${this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)} shorter)`
|
||||
} else if (differenceInMinutes > 0) {
|
||||
return `(${this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)} longer)`
|
||||
}
|
||||
return '(exact match)'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectMatch() {
|
||||
var book = { ...this.book }
|
||||
const book = { ...this.book }
|
||||
book.cover = this.selectedCover
|
||||
this.$emit('select', book)
|
||||
},
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<template>
|
||||
<div class="flex items-center px-1 overflow-hidden">
|
||||
<div class="w-8 flex items-center justify-center">
|
||||
<!-- <div class="text-lg"> -->
|
||||
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{ actionIcon }}</span>
|
||||
<widgets-loading-spinner v-else />
|
||||
<!-- </div> -->
|
||||
</div>
|
||||
<div class="flex-grow px-2 taskRunningCardContent">
|
||||
<p class="truncate text-sm">{{ title }}</p>
|
||||
@@ -12,7 +10,9 @@
|
||||
<p class="truncate text-xs text-gray-300">{{ description }}</p>
|
||||
|
||||
<p v-if="isFailed && failedMessage" class="text-xs truncate text-red-500">{{ failedMessage }}</p>
|
||||
<p v-else-if="!isFinished && cancelingScan" class="text-xs truncate">Canceling...</p>
|
||||
</div>
|
||||
<ui-btn v-if="userIsAdminOrUp && !isFinished && isLibraryScan && !cancelingScan" color="primary" :padding-y="1" :padding-x="1" class="text-xs w-16 max-w-16 truncate mr-1" @click.stop="cancelScan">{{ this.$strings.ButtonCancel }}</ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -25,9 +25,14 @@ export default {
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
cancelingScan: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
title() {
|
||||
return this.task.title || 'No Title'
|
||||
},
|
||||
@@ -76,9 +81,22 @@ export default {
|
||||
}
|
||||
|
||||
return ''
|
||||
},
|
||||
isLibraryScan() {
|
||||
return this.action === 'library-scan' || this.action === 'library-match-all'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cancelScan() {
|
||||
const libraryId = this.task?.data?.libraryId
|
||||
if (!libraryId) {
|
||||
console.error('No library id in library-scan task', this.task)
|
||||
return
|
||||
}
|
||||
this.cancelingScan = true
|
||||
this.$root.socket.emit('cancel_scan', libraryId)
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -15,24 +15,33 @@
|
||||
|
||||
<div class="flex my-2 -mx-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" />
|
||||
<ui-text-input-with-label v-model.trim="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-if="!isPodcast" v-model="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
|
||||
<div v-if="!isPodcast" class="flex items-end">
|
||||
<ui-text-input-with-label v-model.trim="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
|
||||
<ui-tooltip :text="$strings.LabelUploaderItemFetchMetadataHelp">
|
||||
<div
|
||||
class="ml-2 mb-1 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer"
|
||||
@click="fetchMetadata">
|
||||
<span class="text-base text-white text-opacity-80 font-mono material-icons">sync</span>
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-else class="w-full">
|
||||
<p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p>
|
||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
|
||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isPodcast" class="flex my-2 -mx-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" />
|
||||
<ui-text-input-with-label v-model.trim="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" inputClass="h-10" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p>
|
||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
|
||||
<label class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></label>
|
||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs h-10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,8 +57,8 @@
|
||||
<p class="text-base">{{ $strings.MessageUploaderItemFailed }}</p>
|
||||
</widgets-alert>
|
||||
|
||||
<div v-if="isUploading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
|
||||
<ui-loading-indicator :text="$strings.MessageUploading" />
|
||||
<div v-if="isNonInteractable" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
|
||||
<ui-loading-indicator :text="nonInteractionLabel" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -61,10 +70,11 @@ export default {
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
default: () => { }
|
||||
},
|
||||
mediaType: String,
|
||||
processing: Boolean
|
||||
processing: Boolean,
|
||||
provider: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -76,7 +86,8 @@ export default {
|
||||
error: '',
|
||||
isUploading: false,
|
||||
uploadFailed: false,
|
||||
uploadSuccess: false
|
||||
uploadSuccess: false,
|
||||
isFetchingMetadata: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -87,12 +98,19 @@ export default {
|
||||
if (!this.itemData.title) return ''
|
||||
if (this.isPodcast) return this.itemData.title
|
||||
|
||||
if (this.itemData.series && this.itemData.author) {
|
||||
return Path.join(this.itemData.author, this.itemData.series, this.itemData.title)
|
||||
} else if (this.itemData.author) {
|
||||
return Path.join(this.itemData.author, this.itemData.title)
|
||||
} else {
|
||||
return this.itemData.title
|
||||
const outputPathParts = [this.itemData.author, this.itemData.series, this.itemData.title]
|
||||
const cleanedOutputPathParts = outputPathParts.filter(Boolean).map(part => this.$sanitizeFilename(part))
|
||||
|
||||
return Path.join(...cleanedOutputPathParts)
|
||||
},
|
||||
isNonInteractable() {
|
||||
return this.isUploading || this.isFetchingMetadata
|
||||
},
|
||||
nonInteractionLabel() {
|
||||
if (this.isUploading) {
|
||||
return this.$strings.MessageUploading
|
||||
} else if (this.isFetchingMetadata) {
|
||||
return this.$strings.LabelFetchingMetadata
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -105,9 +123,42 @@ export default {
|
||||
titleUpdated() {
|
||||
this.error = ''
|
||||
},
|
||||
async fetchMetadata() {
|
||||
if (!this.itemData.title.trim().length) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isFetchingMetadata = true
|
||||
this.error = ''
|
||||
|
||||
try {
|
||||
const searchQueryString = new URLSearchParams({
|
||||
title: this.itemData.title,
|
||||
author: this.itemData.author,
|
||||
provider: this.provider
|
||||
})
|
||||
const [bestCandidate, ..._rest] = await this.$axios.$get(`/api/search/books?${searchQueryString}`)
|
||||
|
||||
if (bestCandidate) {
|
||||
this.itemData = {
|
||||
...this.itemData,
|
||||
title: bestCandidate.title,
|
||||
author: bestCandidate.author,
|
||||
series: (bestCandidate.series || [])[0]?.series
|
||||
}
|
||||
} else {
|
||||
this.error = this.$strings.ErrorUploadFetchMetadataNoResults
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed', e)
|
||||
this.error = this.$strings.ErrorUploadFetchMetadataAPI
|
||||
} finally {
|
||||
this.isFetchingMetadata = false
|
||||
}
|
||||
},
|
||||
getData() {
|
||||
if (!this.itemData.title) {
|
||||
this.error = 'Must have a title'
|
||||
this.error = this.$strings.ErrorUploadLacksTitle
|
||||
return null
|
||||
}
|
||||
this.error = ''
|
||||
@@ -128,4 +179,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -68,7 +68,8 @@
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||
</div>
|
||||
|
||||
<div class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick">
|
||||
<!-- Radio button -->
|
||||
<div v-if="!isAuthorBookshelfView" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick">
|
||||
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +90,7 @@
|
||||
|
||||
<!-- Series name overlay -->
|
||||
<div v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
|
||||
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
|
||||
<p v-if="seriesName" class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ seriesName }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Error widget -->
|
||||
@@ -218,8 +219,11 @@ export default {
|
||||
// Only included when filtering by series or collapse series or Continue Series shelf on home page
|
||||
return this.mediaMetadata.series
|
||||
},
|
||||
seriesName() {
|
||||
return this.series?.name || null
|
||||
},
|
||||
seriesSequence() {
|
||||
return this.series ? this.series.sequence : null
|
||||
return this.series?.sequence || null
|
||||
},
|
||||
libraryId() {
|
||||
return this._libraryItem.libraryId
|
||||
@@ -318,6 +322,7 @@ export default {
|
||||
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
|
||||
if (this.orderBy === 'media.metadata.publishedYear' && this.mediaMetadata.publishedYear) return 'Published ' + this.mediaMetadata.publishedYear
|
||||
return null
|
||||
},
|
||||
episodeProgress() {
|
||||
@@ -839,13 +844,15 @@ export default {
|
||||
},
|
||||
deleteLibraryItem() {
|
||||
const payload = {
|
||||
message: 'This will delete the library item from the database and your file system. Are you sure?',
|
||||
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
|
||||
message: this.$strings.MessageConfirmDeleteLibraryItem,
|
||||
checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox,
|
||||
yesButtonText: this.$strings.ButtonDelete,
|
||||
yesButtonColor: 'error',
|
||||
checkboxDefaultValue: true,
|
||||
checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0),
|
||||
callback: (confirmed, hardDelete) => {
|
||||
if (confirmed) {
|
||||
localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)
|
||||
|
||||
this.processing = true
|
||||
const axios = this.$axios || this.$nuxt.$axios
|
||||
axios
|
||||
|
||||
@@ -36,7 +36,7 @@ export default {
|
||||
return this.narrator?.name || ''
|
||||
},
|
||||
numBooks() {
|
||||
return this.narrator?.books?.length || 0
|
||||
return this.narrator?.numBooks || this.narrator?.books?.length || 0
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
|
||||
@@ -103,7 +103,7 @@ export default {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
totalResults() {
|
||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length
|
||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length + this.narratorResults.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -348,6 +348,10 @@ export default {
|
||||
},
|
||||
tracks() {
|
||||
return [
|
||||
{
|
||||
id: 'none',
|
||||
name: this.$strings.LabelTracksNone
|
||||
},
|
||||
{
|
||||
id: 'single',
|
||||
name: this.$strings.LabelTracksSingleTrack
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
<div class="absolute cover-bg" ref="coverBg" />
|
||||
</div>
|
||||
|
||||
<img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
||||
<img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" draggable="false" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" @click="clickCover" />
|
||||
|
||||
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
|
||||
<p class="text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
<div class="absolute top-2 right-2">
|
||||
@@ -43,6 +44,7 @@ export default {
|
||||
type: Number,
|
||||
default: 120
|
||||
},
|
||||
expandOnClick: Boolean,
|
||||
bookCoverAspectRatio: Number
|
||||
},
|
||||
data() {
|
||||
@@ -132,6 +134,11 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickCover() {
|
||||
if (this.expandOnClick && this.libraryItem) {
|
||||
this.$store.commit('globals/setRawCoverPreviewModal', this.libraryItem.id)
|
||||
}
|
||||
},
|
||||
setCoverBg() {
|
||||
if (this.$refs.coverBg) {
|
||||
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
|
||||
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
|
||||
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
||||
<p class="text-center text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
|
||||
<img v-if="width > 100" src="/Logo.png" class="mb-2" :style="{ height: 40 * sizeMultiplier + 'px' }" />
|
||||
<p class="text-center text-error" :style="{ fontSize: invalidCoverFontSize + 'rem' }">Invalid Cover</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,6 +58,9 @@ export default {
|
||||
sizeMultiplier() {
|
||||
return this.width / 120
|
||||
},
|
||||
invalidCoverFontSize() {
|
||||
return Math.max(this.sizeMultiplier * 0.8, 0.5)
|
||||
},
|
||||
placeholderCoverPadding() {
|
||||
return 0.8 * this.sizeMultiplier
|
||||
},
|
||||
|
||||
@@ -14,13 +14,17 @@
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? $strings.LabelPassword : $strings.LabelChangePassword" type="password" />
|
||||
<ui-text-input-with-label v-else v-model="newUser.email" :label="$strings.LabelEmail" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!isEditingRoot" class="flex py-2">
|
||||
<div class="px-2 w-52">
|
||||
<ui-dropdown v-model="newUser.type" :label="$strings.LabelAccountType" :disabled="isEditingRoot" :items="accountTypes" @input="userTypeUpdated" />
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="newUser.email" :label="$strings.LabelEmail" />
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div class="px-2 w-52">
|
||||
<ui-dropdown v-model="newUser.type" :label="$strings.LabelAccountType" :disabled="isEditingRoot" :items="accountTypes" small @input="userTypeUpdated" />
|
||||
</div>
|
||||
<!-- <div class="flex-grow" /> -->
|
||||
<div class="flex items-center pt-4 px-2">
|
||||
<p class="px-3 font-semibold" id="user-enabled-toggle" :class="isEditingRoot ? 'text-gray-300' : ''">{{ $strings.LabelEnable }}</p>
|
||||
<ui-toggle-switch labeledBy="user-enabled-toggle" v-model="newUser.isActive" :disabled="isEditingRoot" />
|
||||
@@ -257,7 +261,6 @@ export default {
|
||||
if (account.type === 'root' && !account.isActive) return
|
||||
|
||||
this.processing = true
|
||||
console.log('Calling update', account)
|
||||
this.$axios
|
||||
.$patch(`/api/users/${this.account.id}`, account)
|
||||
.then((data) => {
|
||||
@@ -326,9 +329,11 @@ export default {
|
||||
init() {
|
||||
this.fetchAllTags()
|
||||
this.isNew = !this.account
|
||||
|
||||
if (this.account) {
|
||||
this.newUser = {
|
||||
username: this.account.username,
|
||||
email: this.account.email,
|
||||
password: this.account.password,
|
||||
type: this.account.type,
|
||||
isActive: this.account.isActive,
|
||||
@@ -337,9 +342,9 @@ export default {
|
||||
itemTagsSelected: [...(this.account.itemTagsSelected || [])]
|
||||
}
|
||||
} else {
|
||||
this.fetchAllTags()
|
||||
this.newUser = {
|
||||
username: null,
|
||||
email: null,
|
||||
password: null,
|
||||
type: 'user',
|
||||
isActive: true,
|
||||
|
||||
33
client/components/modals/RawCoverPreviewModal.vue
Normal file
33
client/components/modals/RawCoverPreviewModal.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="cover" :width="'90%'" :height="'90%'" :contentMarginTop="0">
|
||||
<div class="w-full h-full" @click="show = false">
|
||||
<img loading="lazy" :src="rawCoverUrl" class="w-full h-full z-10 object-scale-down" />
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.$store.state.globals.showRawCoverPreviewModal
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('globals/setShowRawCoverPreviewModal', val)
|
||||
}
|
||||
},
|
||||
selectedLibraryItemId() {
|
||||
return this.$store.state.globals.selectedLibraryItemId
|
||||
},
|
||||
rawCoverUrl() {
|
||||
return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.selectedLibraryItemId, null, true)
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -5,18 +5,23 @@
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<form v-if="author" @submit.prevent="submitForm">
|
||||
<div class="flex">
|
||||
<div class="w-40 p-2">
|
||||
<div class="w-full h-45 relative">
|
||||
<covers-author-image :author="author" />
|
||||
<div v-show="!processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
|
||||
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
|
||||
</div>
|
||||
<div v-if="author" class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<div class="flex">
|
||||
<div class="w-40 p-2">
|
||||
<div class="w-full h-45 relative">
|
||||
<covers-author-image :author="author" />
|
||||
<div v-if="userCanDelete && !processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
|
||||
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<form @submit.prevent="submitUploadCover" class="flex flex-grow mb-2 p-2">
|
||||
<ui-text-input v-model="imageUrl" :placeholder="$strings.LabelImageURLFromTheWeb" class="h-9 w-full" />
|
||||
<ui-btn color="success" type="submit" :padding-x="4" :disabled="!imageUrl" class="ml-2 sm:ml-3 w-24 h-9">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</form>
|
||||
|
||||
<form v-if="author" @submit.prevent="submitForm">
|
||||
<div class="flex">
|
||||
<div class="w-3/4 p-2">
|
||||
<ui-text-input-with-label v-model="authorCopy.name" :disabled="processing" :label="$strings.LabelName" />
|
||||
@@ -25,21 +30,23 @@
|
||||
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<!-- <div class="p-2">
|
||||
<ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" :label="$strings.LabelPhotoPathURL" />
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="p-2">
|
||||
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" :label="$strings.LabelDescription" :rows="8" />
|
||||
</div>
|
||||
|
||||
<div class="flex pt-2 px-2">
|
||||
<ui-btn type="button" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
||||
<ui-btn v-if="userCanDelete" small color="error" type="button" @click.stop="removeClick">{{ $strings.ButtonRemove }}</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn type="button" class="mx-2" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
||||
|
||||
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
@@ -51,9 +58,9 @@ export default {
|
||||
authorCopy: {
|
||||
name: '',
|
||||
asin: '',
|
||||
description: '',
|
||||
imagePath: ''
|
||||
description: ''
|
||||
},
|
||||
imageUrl: '',
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
@@ -91,17 +98,45 @@ export default {
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.imageUrl = ''
|
||||
this.authorCopy.name = this.author.name
|
||||
this.authorCopy.asin = this.author.asin
|
||||
this.authorCopy.description = this.author.description
|
||||
this.authorCopy.imagePath = this.author.imagePath
|
||||
},
|
||||
removeClick() {
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmRemoveAuthor', [this.author.name]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/authors/${this.authorId}`)
|
||||
.then(() => {
|
||||
this.$toast.success('Author removed')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove author', error)
|
||||
this.$toast.error('Failed to remove author')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
async submitForm() {
|
||||
var keysToCheck = ['name', 'asin', 'description', 'imagePath']
|
||||
var keysToCheck = ['name', 'asin', 'description']
|
||||
var updatePayload = {}
|
||||
keysToCheck.forEach((key) => {
|
||||
if (this.authorCopy[key] !== this.author[key]) {
|
||||
@@ -130,21 +165,46 @@ export default {
|
||||
}
|
||||
this.processing = false
|
||||
},
|
||||
async removeCover() {
|
||||
var updatePayload = {
|
||||
imagePath: null
|
||||
}
|
||||
removeCover() {
|
||||
this.processing = true
|
||||
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed)
|
||||
return null
|
||||
})
|
||||
if (result && result.updated) {
|
||||
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
|
||||
this.$store.commit('globals/showEditAuthorModal', result.author)
|
||||
this.$axios
|
||||
.$delete(`/api/authors/${this.authorId}/image`)
|
||||
.then((data) => {
|
||||
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
|
||||
this.$store.commit('globals/showEditAuthorModal', data.author)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
submitUploadCover() {
|
||||
if (!this.imageUrl?.startsWith('http:') && !this.imageUrl?.startsWith('https:')) {
|
||||
this.$toast.error('Invalid image url')
|
||||
return
|
||||
}
|
||||
this.processing = false
|
||||
|
||||
this.processing = true
|
||||
const updatePayload = {
|
||||
url: this.imageUrl
|
||||
}
|
||||
this.$axios
|
||||
.$post(`/api/authors/${this.authorId}/image`, updatePayload)
|
||||
.then((data) => {
|
||||
this.imageUrl = ''
|
||||
this.$toast.success('Author image updated')
|
||||
this.$store.commit('globals/showEditAuthorModal', data.author)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error(error.response.data || 'Failed to remove author image')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
async searchAuthor() {
|
||||
if (!this.authorCopy.name && !this.authorCopy.asin) {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<div class="w-full px-3 py-5 md:p-12">
|
||||
<div class="flex items-center -mx-1 mb-2">
|
||||
<div class="flex items-center -mx-1 mb-4">
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="ereaderNameInput" v-model="newDevice.name" :disabled="processing" :label="$strings.LabelName" />
|
||||
</div>
|
||||
@@ -16,6 +16,14 @@
|
||||
<ui-text-input-with-label ref="ereaderEmailInput" v-model="newDevice.email" :disabled="processing" :label="$strings.LabelEmail" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-4">
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-dropdown v-model="newDevice.availabilityOption" :label="$strings.LabelDeviceIsAvailableTo" :items="userAvailabilityOptions" @input="availabilityOptionChanged" />
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-multi-select-dropdown v-if="newDevice.availabilityOption === 'specificUsers'" v-model="newDevice.users" :label="$strings.HeaderUsers" :items="userOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-4">
|
||||
<div class="flex-grow" />
|
||||
@@ -45,8 +53,11 @@ export default {
|
||||
processing: false,
|
||||
newDevice: {
|
||||
name: '',
|
||||
email: ''
|
||||
}
|
||||
email: '',
|
||||
availabilityOption: 'adminAndUp',
|
||||
users: []
|
||||
},
|
||||
users: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -68,10 +79,55 @@ export default {
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.ereaderDevice ? 'Create Device' : 'Update Device'
|
||||
return !this.ereaderDevice ? 'Create Device' : 'Update Device'
|
||||
},
|
||||
userAvailabilityOptions() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelAdminUsersOnly,
|
||||
value: 'adminOrUp'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAllUsersExcludingGuests,
|
||||
value: 'userOrUp'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAllUsersIncludingGuests,
|
||||
value: 'guestOrUp'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelSelectUsers,
|
||||
value: 'specificUsers'
|
||||
}
|
||||
]
|
||||
},
|
||||
userOptions() {
|
||||
return this.users.map((u) => ({ text: u.username, value: u.id }))
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
availabilityOptionChanged(option) {
|
||||
if (option === 'specificUsers' && !this.users.length) {
|
||||
this.loadUsers()
|
||||
}
|
||||
},
|
||||
async loadUsers() {
|
||||
this.processing = true
|
||||
this.users = await this.$axios
|
||||
.$get('/api/users')
|
||||
.then((res) => {
|
||||
return res.users.sort((a, b) => {
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return []
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
submitForm() {
|
||||
this.$refs.ereaderNameInput.blur()
|
||||
this.$refs.ereaderEmailInput.blur()
|
||||
@@ -81,19 +137,27 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.newDevice.availabilityOption === 'specificUsers' && !this.newDevice.users.length) {
|
||||
this.$toast.error('Must select at least one user')
|
||||
return
|
||||
}
|
||||
if (this.newDevice.availabilityOption !== 'specificUsers') {
|
||||
this.newDevice.users = []
|
||||
}
|
||||
|
||||
this.newDevice.name = this.newDevice.name.trim()
|
||||
this.newDevice.email = this.newDevice.email.trim()
|
||||
|
||||
if (!this.ereaderDevice) {
|
||||
if (this.existingDevices.some((d) => d.name === this.newDevice.name)) {
|
||||
this.$toast.error('EReader device with that name already exists')
|
||||
this.$toast.error('Ereader device with that name already exists')
|
||||
return
|
||||
}
|
||||
|
||||
this.submitCreate()
|
||||
} else {
|
||||
if (this.ereaderDevice.name !== this.newDevice.name && this.existingDevices.some((d) => d.name === this.newDevice.name)) {
|
||||
this.$toast.error('EReader device with that name already exists')
|
||||
this.$toast.error('Ereader device with that name already exists')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -160,9 +224,17 @@ export default {
|
||||
if (this.ereaderDevice) {
|
||||
this.newDevice.name = this.ereaderDevice.name
|
||||
this.newDevice.email = this.ereaderDevice.email
|
||||
this.newDevice.availabilityOption = this.ereaderDevice.availabilityOption || 'adminOrUp'
|
||||
this.newDevice.users = this.ereaderDevice.users || []
|
||||
|
||||
if (this.newDevice.availabilityOption === 'specificUsers' && !this.users.length) {
|
||||
this.loadUsers()
|
||||
}
|
||||
} else {
|
||||
this.newDevice.name = ''
|
||||
this.newDevice.email = ''
|
||||
this.newDevice.availabilityOption = 'adminOrUp'
|
||||
this.newDevice.users = []
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<!-- book cover overlay -->
|
||||
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
||||
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
|
||||
<div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
|
||||
<div v-if="userCanDelete" class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
|
||||
<ui-tooltip direction="top" :text="$strings.LabelRemoveCover">
|
||||
<span class="material-icons text-2xl">delete</span>
|
||||
</ui-tooltip>
|
||||
@@ -16,15 +16,16 @@
|
||||
</div>
|
||||
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
|
||||
<div class="flex items-center">
|
||||
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 pt-4 md:min-w-32">
|
||||
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 md:min-w-32">
|
||||
<ui-file-input ref="fileInput" @change="fileUploadSelected">
|
||||
<span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span>
|
||||
<span class="material-icons text-2xl inline-block md:!hidden">upload</span>
|
||||
</ui-file-input>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitForm" class="flex flex-grow">
|
||||
<ui-text-input-with-label v-model="imageUrl" :label="$strings.LabelCoverImageURL" />
|
||||
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-2 sm:ml-3 w-24">{{ $strings.ButtonSave }}</ui-btn>
|
||||
<ui-text-input v-model="imageUrl" :placeholder="$strings.LabelImageURLFromTheWeb" class="h-9 w-full" />
|
||||
<ui-btn color="success" type="submit" :padding-x="4" :disabled="!imageUrl" class="ml-2 sm:ml-3 w-24 h-9">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -64,7 +65,7 @@
|
||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-80 overflow-y-scroll mt-2 max-w-full">
|
||||
<p v-if="!coversFound.length">{{ $strings.MessageNoCoversFound }}</p>
|
||||
<template v-for="cover in coversFound">
|
||||
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="updateCover(cover)">
|
||||
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === coverPath ? 'border-yellow-300' : ''" @click="updateCover(cover)">
|
||||
<covers-preview-cover :src="cover" :width="80" show-open-new-tab :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -165,6 +166,9 @@ export default {
|
||||
userCanUpload() {
|
||||
return this.$store.getters['user/getUserCanUpload']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
@@ -222,72 +226,53 @@ export default {
|
||||
this.coversFound = []
|
||||
this.hasSearched = false
|
||||
}
|
||||
this.imageUrl = this.media.coverPath || ''
|
||||
this.imageUrl = ''
|
||||
this.searchTitle = this.mediaMetadata.title || ''
|
||||
this.searchAuthor = this.mediaMetadata.authorName || ''
|
||||
if (this.isPodcast) this.provider = 'itunes'
|
||||
else this.provider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'
|
||||
},
|
||||
removeCover() {
|
||||
if (!this.media.coverPath) {
|
||||
this.imageUrl = ''
|
||||
if (!this.coverPath) {
|
||||
return
|
||||
}
|
||||
this.updateCover('')
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}/cover`)
|
||||
.then(() => {})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove cover', error)
|
||||
if (error.response?.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.isProcessing = false
|
||||
})
|
||||
},
|
||||
submitForm() {
|
||||
this.updateCover(this.imageUrl)
|
||||
},
|
||||
async updateCover(cover) {
|
||||
if (cover === this.coverPath) {
|
||||
console.warn('Cover has not changed..', cover)
|
||||
if (!cover.startsWith('http:') && !cover.startsWith('https:')) {
|
||||
this.$toast.error('Invalid URL')
|
||||
return
|
||||
}
|
||||
|
||||
this.isProcessing = true
|
||||
var success = false
|
||||
|
||||
if (!cover) {
|
||||
// Remove cover
|
||||
success = await this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}/cover`)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove cover', error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
}
|
||||
return false
|
||||
})
|
||||
} else if (cover.startsWith('http:') || cover.startsWith('https:')) {
|
||||
// Download cover from url and use
|
||||
success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover }).catch((error) => {
|
||||
console.error('Failed to download cover from url', error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
}
|
||||
return false
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover })
|
||||
.then(() => {
|
||||
this.imageUrl = ''
|
||||
this.$toast.success('Update Successful')
|
||||
})
|
||||
} else {
|
||||
// Update local cover url
|
||||
const updatePayload = {
|
||||
cover
|
||||
}
|
||||
success = await this.$axios.$patch(`/api/items/${this.libraryItemId}/cover`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
}
|
||||
return false
|
||||
.catch((error) => {
|
||||
console.error('Failed to update cover', error)
|
||||
this.$toast.error(error.response?.data || 'Failed to update cover')
|
||||
})
|
||||
.finally(() => {
|
||||
this.isProcessing = false
|
||||
})
|
||||
}
|
||||
if (success) {
|
||||
this.$toast.success('Update Successful')
|
||||
// this.$emit('close')
|
||||
} else {
|
||||
this.imageUrl = this.media.coverPath || ''
|
||||
}
|
||||
this.isProcessing = false
|
||||
},
|
||||
getSearchQuery() {
|
||||
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
|
||||
@@ -320,7 +305,19 @@ export default {
|
||||
this.hasSearched = true
|
||||
},
|
||||
setCover(coverFile) {
|
||||
this.updateCover(coverFile.metadata.path)
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$patch(`/api/items/${this.libraryItemId}/cover`, { cover: coverFile.metadata.path })
|
||||
.then(() => {
|
||||
this.$toast.success('Update Successful')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to set local cover', error)
|
||||
this.$toast.error(error.response?.data || 'Failed to set cover')
|
||||
})
|
||||
.finally(() => {
|
||||
this.isProcessing = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :disabled="!!libraryScan" text="Rescan library item including metadata" direction="bottom" class="mr-2 md:mr-4">
|
||||
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">{{ $strings.ButtonReScan }}</ui-btn>
|
||||
<ui-tooltip :disabled="isLibraryScanning" text="Rescan library item including metadata" direction="bottom" class="mr-2 md:mr-4">
|
||||
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="isLibraryScanning" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">{{ $strings.ButtonReScan }}</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<div class="flex-grow" />
|
||||
@@ -80,9 +80,9 @@ export default {
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
|
||||
},
|
||||
libraryScan() {
|
||||
isLibraryScanning() {
|
||||
if (!this.libraryId) return null
|
||||
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
|
||||
return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.libraryId)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -20,18 +20,14 @@
|
||||
<div v-if="!episodes.length" class="flex my-4 text-center justify-center text-xl">{{ $strings.MessageNoEpisodes }}</div>
|
||||
<table v-else class="text-sm tracksTable">
|
||||
<tr>
|
||||
<th class="text-left">Sort #</th>
|
||||
<th class="text-left whitespace-nowrap">{{ $strings.LabelEpisode }}</th>
|
||||
<th class="text-left">{{ $strings.EpisodeTitle }}</th>
|
||||
<th class="text-center w-28">{{ $strings.EpisodeDuration }}</th>
|
||||
<th class="text-center w-28">{{ $strings.EpisodeSize }}</th>
|
||||
<th class="text-center w-20 min-w-20">{{ $strings.LabelEpisode }}</th>
|
||||
<th class="text-left">{{ $strings.LabelEpisodeTitle }}</th>
|
||||
<th class="text-center w-28">{{ $strings.LabelEpisodeDuration }}</th>
|
||||
<th class="text-center w-28">{{ $strings.LabelEpisodeSize }}</th>
|
||||
</tr>
|
||||
<tr v-for="episode in episodes" :key="episode.id">
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ episode.index }}</p>
|
||||
</td>
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ episode.episode }}</p>
|
||||
<td class="text-center w-20 min-w-20">
|
||||
<p>{{ episode.episode }}</p>
|
||||
</td>
|
||||
<td>
|
||||
{{ episode.title }}
|
||||
|
||||
@@ -14,8 +14,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tracks: [],
|
||||
showFullPath: false
|
||||
tracks: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper mt-4">
|
||||
<template v-for="(res, index) in searchResults">
|
||||
<cards-book-match-card :key="index" :book="res" :is-podcast="isPodcast" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
|
||||
<cards-book-match-card :key="index" :book="res" :current-book-duration="currentBookDuration" :is-podcast="isPodcast" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig" class="absolute top-0 left-0 w-full bg-bg h-full px-2 py-6 md:p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
||||
@@ -290,13 +290,17 @@ export default {
|
||||
return this.$strings.LabelSearchTitle
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
return this.libraryItem?.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
currentBookDuration() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.duration || 0
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||
return this.libraryItem?.mediaType || null
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType == 'podcast'
|
||||
@@ -305,7 +309,7 @@ export default {
|
||||
const filterData = this.$store.state.libraries.filterData || {}
|
||||
const currentGenres = filterData.genres || []
|
||||
const selectedMatchGenres = this.selectedMatch.genres || []
|
||||
return [...new Set([...currentGenres ,...selectedMatchGenres])]
|
||||
return [...new Set([...currentGenres, ...selectedMatchGenres])]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -325,9 +329,10 @@ export default {
|
||||
}
|
||||
},
|
||||
getSearchQuery() {
|
||||
if (this.isPodcast) return `term=${this.searchTitle}`
|
||||
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${this.searchTitle}`
|
||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
||||
if (this.isPodcast) return `term=${encodeURIComponent(this.searchTitle)}`
|
||||
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${encodeURIComponent(this.searchTitle)}`
|
||||
if (this.searchAuthor) searchQuery += `&author=${encodeURIComponent(this.searchAuthor)}`
|
||||
if (this.libraryItemId) searchQuery += `&id=${this.libraryItemId}`
|
||||
return searchQuery
|
||||
},
|
||||
submitSearch() {
|
||||
@@ -580,6 +585,7 @@ export default {
|
||||
.matchListWrapper {
|
||||
height: calc(100% - 124px);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.matchListWrapper {
|
||||
height: calc(100% - 80px);
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<p class="px-1 text-sm font-semibold">{{ $strings.LabelFolders }}</p>
|
||||
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2">
|
||||
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<ui-editable-text ref="folderInput" v-model="folder.fullPath" readonly type="text" class="w-full" />
|
||||
<ui-editable-text ref="folderInput" v-model="folder.fullPath" :readonly="!!folder.id" type="text" class="w-full" @blur="existingFolderInputBlurred(folder)" />
|
||||
<span v-show="folders.length > 1" class="material-icons text-2xl ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
|
||||
</div>
|
||||
<div class="flex py-1 px-2 items-center w-full">
|
||||
@@ -67,10 +67,6 @@ export default {
|
||||
value: 'podcast',
|
||||
text: this.$strings.LabelPodcasts
|
||||
}
|
||||
// {
|
||||
// value: 'music',
|
||||
// text: 'Music'
|
||||
// }
|
||||
]
|
||||
},
|
||||
folderPaths() {
|
||||
@@ -110,6 +106,11 @@ export default {
|
||||
formUpdated() {
|
||||
this.$emit('update', this.getLibraryData())
|
||||
},
|
||||
existingFolderInputBlurred(folder) {
|
||||
if (!folder.fullPath) {
|
||||
this.removeFolder(folder)
|
||||
}
|
||||
},
|
||||
newFolderInputBlurred() {
|
||||
if (this.newFolderPath) {
|
||||
this.folders.push({ fullPath: this.newFolderPath })
|
||||
@@ -149,6 +150,7 @@ export default {
|
||||
this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
|
||||
this.icon = this.library ? this.library.icon : 'default'
|
||||
this.mediaType = this.library ? this.library.mediaType : 'book'
|
||||
|
||||
this.showDirectoryPicker = false
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing">
|
||||
<modals-modal v-model="show" name="edit-library" :width="800" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-xl md:text-3xl text-white truncate">{{ title }}</p>
|
||||
@@ -12,9 +12,9 @@
|
||||
</div>
|
||||
|
||||
<div class="px-2 md:px-4 w-full text-sm pt-2 md:pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<component v-if="libraryCopy && show" ref="tabComponent" :is="tabName" :is-new="!library" :library="libraryCopy" :processing.sync="processing" @update="updateLibrary" @close="show = false" />
|
||||
<component v-if="libraryCopy && show" ref="tabComponent" :is="tabName" :is-new="!library" :library="libraryCopy" :library-id="libraryId" :processing.sync="processing" @update="updateLibrary" @close="show = false" />
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10">
|
||||
<div v-show="selectedTab !== 'tools'" class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10">
|
||||
<div class="flex justify-end">
|
||||
<ui-btn @click="submit">{{ buttonText }}</ui-btn>
|
||||
</div>
|
||||
@@ -54,6 +54,12 @@ export default {
|
||||
buttonText() {
|
||||
return this.library ? this.$strings.ButtonSave : this.$strings.ButtonCreate
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryCopy?.mediaType
|
||||
},
|
||||
libraryId() {
|
||||
return this.library?.id
|
||||
},
|
||||
tabs() {
|
||||
return [
|
||||
{
|
||||
@@ -66,12 +72,26 @@ export default {
|
||||
title: this.$strings.HeaderSettings,
|
||||
component: 'modals-libraries-library-settings'
|
||||
},
|
||||
{
|
||||
id: 'scanner',
|
||||
title: this.$strings.HeaderSettingsScanner,
|
||||
component: 'modals-libraries-library-scanner-settings'
|
||||
},
|
||||
{
|
||||
id: 'schedule',
|
||||
title: this.$strings.HeaderSchedule,
|
||||
component: 'modals-libraries-schedule-scan'
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
title: this.$strings.HeaderTools,
|
||||
component: 'modals-libraries-library-tools'
|
||||
}
|
||||
]
|
||||
].filter((tab) => {
|
||||
// Do not show tools tab for new libraries
|
||||
if (tab.id === 'tools' && !this.library) return false
|
||||
return tab.id !== 'scanner' || this.mediaType === 'book'
|
||||
})
|
||||
},
|
||||
tabName() {
|
||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||
@@ -105,7 +125,9 @@ export default {
|
||||
disableWatcher: false,
|
||||
skipMatchingMediaWithAsin: false,
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
autoScanCronExpression: null
|
||||
autoScanCronExpression: null,
|
||||
hideSingleBookSeries: false,
|
||||
metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -120,7 +142,7 @@ export default {
|
||||
for (const key in this.libraryCopy) {
|
||||
if (library[key] !== undefined) {
|
||||
if (key === 'folders') {
|
||||
this.libraryCopy.folders = library.folders.map((f) => ({ ...f }))
|
||||
this.libraryCopy.folders = library.folders.map((f) => ({ ...f })).filter((f) => !!f.fullPath?.trim())
|
||||
} else if (key === 'settings') {
|
||||
for (const settingKey in library.settings) {
|
||||
this.libraryCopy.settings[settingKey] = library.settings[settingKey]
|
||||
|
||||
160
client/components/modals/libraries/LibraryScannerSettings.vue
Normal file
160
client/components/modals/libraries/LibraryScannerSettings.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-base md:text-lg text-gray-200">{{ $strings.HeaderMetadataOrderOfPrecedence }}</h2>
|
||||
<ui-btn small @click="resetToDefault">{{ $strings.ButtonResetToDefault }}</ui-btn>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between md:justify-start mb-4">
|
||||
<p class="text-sm text-gray-300 pr-2">{{ $strings.LabelMetadataOrderOfPrecedenceDescription }}</p>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex">
|
||||
<a href="https://www.audiobookshelf.org/guides/book-scanner" target="_blank" class="inline-flex">
|
||||
<span class="material-icons text-xl w-5">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<draggable v-model="metadataSourceMapped" v-bind="dragOptions" class="list-group" draggable=".item" handle=".drag-handle" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
||||
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
|
||||
<li v-for="(source, index) in metadataSourceMapped" :key="source.id" :class="source.include ? 'item' : 'opacity-50'" class="w-full px-2 flex items-center relative border border-white/10">
|
||||
<span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 mr-2 md:mr-4">reorder</span>
|
||||
<div class="text-center py-1 w-8 min-w-8">
|
||||
{{ source.include ? getSourceIndex(source.id) : '' }}
|
||||
</div>
|
||||
<div class="flex-grow inline-flex justify-between px-4 py-3">
|
||||
{{ source.name }} <span v-if="source.include && (index === firstActiveSourceIndex || index === lastActiveSourceIndex)" class="px-2 italic font-semibold text-xs text-gray-400">{{ index === firstActiveSourceIndex ? $strings.LabelHighestPriority : $strings.LabelLowestPriority }}</span>
|
||||
</div>
|
||||
<div class="px-2 opacity-100">
|
||||
<ui-toggle-switch v-model="source.include" :off-color="'error'" @input="includeToggled(source)" />
|
||||
</div>
|
||||
</li>
|
||||
</transition-group>
|
||||
</draggable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
draggable
|
||||
},
|
||||
props: {
|
||||
library: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
processing: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
drag: false,
|
||||
dragOptions: {
|
||||
animation: 200,
|
||||
group: 'description',
|
||||
ghostClass: 'ghost'
|
||||
},
|
||||
metadataSourceData: {
|
||||
folderStructure: {
|
||||
id: 'folderStructure',
|
||||
name: 'Folder structure',
|
||||
include: true
|
||||
},
|
||||
audioMetatags: {
|
||||
id: 'audioMetatags',
|
||||
name: 'Audio file meta tags',
|
||||
include: true
|
||||
},
|
||||
nfoFile: {
|
||||
id: 'nfoFile',
|
||||
name: 'NFO file',
|
||||
include: true
|
||||
},
|
||||
txtFiles: {
|
||||
id: 'txtFiles',
|
||||
name: 'desc.txt & reader.txt files',
|
||||
include: true
|
||||
},
|
||||
opfFile: {
|
||||
id: 'opfFile',
|
||||
name: 'OPF file',
|
||||
include: true
|
||||
},
|
||||
absMetadata: {
|
||||
id: 'absMetadata',
|
||||
name: 'Audiobookshelf metadata file',
|
||||
include: true
|
||||
}
|
||||
},
|
||||
metadataSourceMapped: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
librarySettings() {
|
||||
return this.library.settings || {}
|
||||
},
|
||||
mediaType() {
|
||||
return this.library.mediaType
|
||||
},
|
||||
isBookLibrary() {
|
||||
return this.mediaType === 'book'
|
||||
},
|
||||
firstActiveSourceIndex() {
|
||||
return this.metadataSourceMapped.findIndex((source) => source.include)
|
||||
},
|
||||
lastActiveSourceIndex() {
|
||||
return this.metadataSourceMapped.findLastIndex((source) => source.include)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getSourceIndex(source) {
|
||||
const activeSources = (this.librarySettings.metadataPrecedence || []).map((s) => s).reverse()
|
||||
return activeSources.findIndex((s) => s === source) + 1
|
||||
},
|
||||
resetToDefault() {
|
||||
this.metadataSourceMapped = []
|
||||
for (const key in this.metadataSourceData) {
|
||||
this.metadataSourceMapped.push({ ...this.metadataSourceData[key] })
|
||||
}
|
||||
this.metadataSourceMapped.reverse()
|
||||
|
||||
this.$emit('update', this.getLibraryData())
|
||||
},
|
||||
getLibraryData() {
|
||||
const metadataSourceIds = this.metadataSourceMapped.map((source) => (source.include ? source.id : null)).filter((s) => s)
|
||||
metadataSourceIds.reverse()
|
||||
return {
|
||||
settings: {
|
||||
metadataPrecedence: metadataSourceIds
|
||||
}
|
||||
}
|
||||
},
|
||||
includeToggled(source) {
|
||||
this.updated()
|
||||
},
|
||||
draggableUpdate() {
|
||||
this.updated()
|
||||
},
|
||||
updated() {
|
||||
this.$emit('update', this.getLibraryData())
|
||||
},
|
||||
init() {
|
||||
const metadataPrecedence = this.librarySettings.metadataPrecedence || []
|
||||
this.metadataSourceMapped = metadataPrecedence.map((source) => this.metadataSourceData[source]).filter((s) => s)
|
||||
|
||||
for (const sourceKey in this.metadataSourceData) {
|
||||
if (!metadataPrecedence.includes(sourceKey)) {
|
||||
const unusedSourceData = { ...this.metadataSourceData[sourceKey], include: false }
|
||||
this.metadataSourceMapped.unshift(unusedSourceData)
|
||||
}
|
||||
}
|
||||
|
||||
this.metadataSourceMapped.reverse()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -11,9 +11,9 @@
|
||||
</div>
|
||||
<div class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" @input="formUpdated" />
|
||||
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="enableWatcher" @input="formUpdated" />
|
||||
<ui-toggle-switch v-else disabled :value="false" />
|
||||
<p class="pl-4 text-base">{{ $strings.LabelSettingsDisableWatcherForLibrary }}</p>
|
||||
<p class="pl-4 text-base">{{ $strings.LabelSettingsEnableWatcherForLibrary }}</p>
|
||||
</div>
|
||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p>
|
||||
</div>
|
||||
@@ -65,7 +65,7 @@ export default {
|
||||
return {
|
||||
provider: null,
|
||||
useSquareBookCovers: false,
|
||||
disableWatcher: false,
|
||||
enableWatcher: false,
|
||||
skipMatchingMediaWithAsin: false,
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
audiobooksOnly: false,
|
||||
@@ -95,7 +95,7 @@ export default {
|
||||
return {
|
||||
settings: {
|
||||
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
|
||||
disableWatcher: !!this.disableWatcher,
|
||||
disableWatcher: !this.enableWatcher,
|
||||
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
||||
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
|
||||
audiobooksOnly: !!this.audiobooksOnly,
|
||||
@@ -108,7 +108,7 @@ export default {
|
||||
},
|
||||
init() {
|
||||
this.useSquareBookCovers = this.librarySettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||
this.disableWatcher = !!this.librarySettings.disableWatcher
|
||||
this.enableWatcher = !this.librarySettings.disableWatcher
|
||||
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
|
||||
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
||||
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
|
||||
|
||||
81
client/components/modals/libraries/LibraryTools.vue
Normal file
81
client/components/modals/libraries/LibraryTools.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-1 md:px-2 py-1 mb-4">
|
||||
<div class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex flex-wrap items-center">
|
||||
<div>
|
||||
<p class="text-lg">Remove metadata files in library item folders</p>
|
||||
<p class="max-w-sm text-sm pt-2 text-gray-300">Remove all metadata.json or metadata.abs files in your {{ mediaType }} folders</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<ui-btn class="mb-4 block" @click.stop="removeAllMetadataClick('json')">Remove all metadata.json</ui-btn>
|
||||
<ui-btn @click.stop="removeAllMetadataClick('abs')">Remove all metadata.abs</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
library: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
libraryId: String,
|
||||
processing: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
librarySettings() {
|
||||
return this.library.settings || {}
|
||||
},
|
||||
mediaType() {
|
||||
return this.library.mediaType
|
||||
},
|
||||
isBookLibrary() {
|
||||
return this.mediaType === 'book'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
removeAllMetadataClick(ext) {
|
||||
const payload = {
|
||||
message: `Are you sure you want to remove all metadata.${ext} files in your library item folders?`,
|
||||
persistent: true,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.removeAllMetadataInLibrary(ext)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
removeAllMetadataInLibrary(ext) {
|
||||
this.$emit('update:processing', true)
|
||||
this.$axios
|
||||
.$post(`/api/libraries/${this.libraryId}/remove-metadata?ext=${ext}`)
|
||||
.then((data) => {
|
||||
if (!data.found) {
|
||||
this.$toast.info(`No metadata.${ext} files were found in library`)
|
||||
} else if (!data.removed) {
|
||||
this.$toast.success(`No metadata.${ext} files removed`)
|
||||
} else {
|
||||
this.$toast.success(`Successfully removed ${data.removed} metadata.${ext} files`)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove metadata files', error)
|
||||
this.$toast.error('Failed to remove metadata files')
|
||||
})
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -16,11 +16,11 @@
|
||||
v-for="(episode, index) in episodesList"
|
||||
:key="index"
|
||||
class="relative"
|
||||
:class="itemEpisodeMap[episode.cleanUrl] ? 'bg-primary bg-opacity-40' : selectedEpisodes[episode.cleanUrl] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
|
||||
:class="getIsEpisodeDownloaded(episode) ? 'bg-primary bg-opacity-40' : selectedEpisodes[episode.cleanUrl] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
|
||||
@click="toggleSelectEpisode(episode)"
|
||||
>
|
||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||
<span v-if="itemEpisodeMap[episode.cleanUrl]" class="material-icons text-success text-xl">download_done</span>
|
||||
<span v-if="getIsEpisodeDownloaded(episode)" class="material-icons text-success text-xl">download_done</span>
|
||||
<ui-checkbox v-else v-model="selectedEpisodes[episode.cleanUrl]" small checkbox-bg="primary" border-color="gray-600" />
|
||||
</div>
|
||||
<div class="px-8 py-2">
|
||||
@@ -93,7 +93,7 @@ export default {
|
||||
return this.libraryItem.media.metadata.title || 'Unknown'
|
||||
},
|
||||
allDownloaded() {
|
||||
return !this.episodesCleaned.some((episode) => !this.itemEpisodeMap[episode.cleanUrl])
|
||||
return !this.episodesCleaned.some((episode) => !this.getIsEpisodeDownloaded(episode))
|
||||
},
|
||||
episodesSelected() {
|
||||
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
||||
@@ -104,18 +104,7 @@ export default {
|
||||
return this.$getString('LabelDownloadNEpisodes', [this.episodesSelected.length])
|
||||
},
|
||||
itemEpisodes() {
|
||||
if (!this.libraryItem) return []
|
||||
return this.libraryItem.media.episodes || []
|
||||
},
|
||||
itemEpisodeMap() {
|
||||
const map = {}
|
||||
this.itemEpisodes.forEach((item) => {
|
||||
if (item.enclosure) {
|
||||
const cleanUrl = this.getCleanEpisodeUrl(item.enclosure.url)
|
||||
map[cleanUrl] = true
|
||||
}
|
||||
})
|
||||
return map
|
||||
return this.libraryItem?.media.episodes || []
|
||||
},
|
||||
episodesList() {
|
||||
return this.episodesCleaned.filter((episode) => {
|
||||
@@ -127,12 +116,23 @@ export default {
|
||||
if (this.episodesList.length === this.episodesCleaned.length) {
|
||||
return this.$strings.LabelSelectAllEpisodes
|
||||
}
|
||||
const episodesNotDownloaded = this.episodesList.filter((ep) => !this.itemEpisodeMap[ep.cleanUrl]).length
|
||||
const episodesNotDownloaded = this.episodesList.filter((ep) => !this.getIsEpisodeDownloaded(ep)).length
|
||||
return this.$getString('LabelSelectEpisodesShowing', [episodesNotDownloaded])
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getIsEpisodeDownloaded(episode) {
|
||||
return this.itemEpisodes.some((downloadedEpisode) => {
|
||||
if (episode.guid && downloadedEpisode.guid === episode.guid) return true
|
||||
if (!downloadedEpisode.enclosure?.url) return false
|
||||
return this.getCleanEpisodeUrl(downloadedEpisode.enclosure.url) === episode.cleanUrl
|
||||
})
|
||||
},
|
||||
/**
|
||||
* UPDATE: As of v2.4.5 guid is used for matching existing downloaded episodes if it is found on the RSS feed.
|
||||
* Fallback to checking the clean url
|
||||
* @see https://github.com/advplyr/audiobookshelf/issues/2207
|
||||
*
|
||||
* RSS feed episode url is used for matching with existing downloaded episodes.
|
||||
* Some RSS feeds include timestamps in the episode url (e.g. patreon) that can change on requests.
|
||||
* These need to be removed in order to detect the same episode each time the feed is pulled.
|
||||
@@ -169,13 +169,13 @@ export default {
|
||||
},
|
||||
toggleSelectAll(val) {
|
||||
for (const episode of this.episodesList) {
|
||||
if (this.itemEpisodeMap[episode.cleanUrl]) this.selectedEpisodes[episode.cleanUrl] = false
|
||||
if (this.getIsEpisodeDownloaded(episode)) this.selectedEpisodes[episode.cleanUrl] = false
|
||||
else this.$set(this.selectedEpisodes, episode.cleanUrl, val)
|
||||
}
|
||||
},
|
||||
checkSetIsSelectedAll() {
|
||||
for (const episode of this.episodesList) {
|
||||
if (!this.itemEpisodeMap[episode.cleanUrl] && !this.selectedEpisodes[episode.cleanUrl]) {
|
||||
if (!this.getIsEpisodeDownloaded(episode) && !this.selectedEpisodes[episode.cleanUrl]) {
|
||||
this.selectAll = false
|
||||
return
|
||||
}
|
||||
@@ -183,7 +183,7 @@ export default {
|
||||
this.selectAll = true
|
||||
},
|
||||
toggleSelectEpisode(episode) {
|
||||
if (this.itemEpisodeMap[episode.cleanUrl]) return
|
||||
if (this.getIsEpisodeDownloaded(episode)) return
|
||||
this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl])
|
||||
this.checkSetIsSelectedAll()
|
||||
},
|
||||
|
||||
@@ -132,6 +132,8 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
|
||||
const payload = {
|
||||
serverAddress: window.origin,
|
||||
slug: this.newFeedSlug,
|
||||
@@ -151,6 +153,9 @@ export default {
|
||||
const errorMsg = error.response ? error.response.data : null
|
||||
this.$toast.error(errorMsg || 'Failed to open RSS Feed')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
copyToClipboard(str) {
|
||||
this.$copyToClipboard(str, this)
|
||||
|
||||
124
client/components/modals/rssfeed/ViewFeedModal.vue
Normal file
124
client/components/modals/rssfeed/ViewFeedModal.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="rss-feed-view-modal" :processing="processing" :width="700" :height="'unset'">
|
||||
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||
<div v-if="feed" class="w-full">
|
||||
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p>
|
||||
|
||||
<div class="w-full relative">
|
||||
<ui-text-input v-model="feed.feedUrl" readonly />
|
||||
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feed.feedUrl)">content_copy</span>
|
||||
</div>
|
||||
|
||||
<div v-if="feed.meta" class="mt-5">
|
||||
<div class="flex py-0.5">
|
||||
<div class="w-48">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedPreventIndexing }}</span>
|
||||
</div>
|
||||
<div>{{ feed.meta.preventIndexing ? 'Yes' : 'No' }}</div>
|
||||
</div>
|
||||
<div v-if="feed.meta.ownerName" class="flex py-0.5">
|
||||
<div class="w-48">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerName }}</span>
|
||||
</div>
|
||||
<div>{{ feed.meta.ownerName }}</div>
|
||||
</div>
|
||||
<div v-if="feed.meta.ownerEmail" class="flex py-0.5">
|
||||
<div class="w-48">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerEmail }}</span>
|
||||
</div>
|
||||
<div>{{ feed.meta.ownerEmail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- -->
|
||||
<div class="episodesTable mt-2">
|
||||
<div class="bg-primary bg-opacity-40 h-12 header">
|
||||
{{ $strings.LabelEpisodeTitle }}
|
||||
</div>
|
||||
<div class="scroller">
|
||||
<div v-for="episode in feed.episodes" :key="episode.id" class="h-8 text-xs truncate">
|
||||
{{ episode.title }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
feed: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
_feed() {
|
||||
return this.feed || {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
copyToClipboard(str) {
|
||||
this.$copyToClipboard(str, this)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.episodesTable {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border: 1px solid #474747;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.episodesTable div.header {
|
||||
background-color: #272727;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.episodesTable .scroller {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 250px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.episodesTable .scroller div {
|
||||
background-color: #373838;
|
||||
padding: 4px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
height: 32px;
|
||||
flex: 0 0 32px;
|
||||
}
|
||||
|
||||
.episodesTable .scroller div:nth-child(even) {
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,34 +14,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a v-if="pages && numPages" :href="mainImg" :download="pages[page - 1]" 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" :class="comicMetadata ? 'left-32' : 'left-20'">
|
||||
<span class="material-icons text-xl">download</span>
|
||||
</a>
|
||||
<div v-if="comicMetadata" class="absolute top-0 left-20 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="clickShowInfoMenu">
|
||||
<span class="material-icons text-xl">more</span>
|
||||
</div>
|
||||
<div v-if="numPages" class="absolute top-0 left-8 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="clickShowPageMenu">
|
||||
<div v-if="numPages" class="absolute top-0 left-4 sm:left-8 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="clickShowPageMenu">
|
||||
<span class="material-icons text-xl">menu</span>
|
||||
</div>
|
||||
<div v-if="numPages" class="absolute top-0 right-16 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">
|
||||
<div v-if="comicMetadata" class="absolute top-0 left-16 sm:left-20 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="clickShowInfoMenu">
|
||||
<span class="material-icons text-xl">more</span>
|
||||
</div>
|
||||
<a v-if="pages && numPages" :href="mainImg" :download="pages[page - 1]" 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" :class="comicMetadata ? 'left-28 sm:left-32' : 'left-16 sm:left-20'">
|
||||
<span class="material-icons text-xl">download</span>
|
||||
</a>
|
||||
|
||||
<div v-if="numPages" class="absolute top-0 right-14 sm:right-16 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 }} / {{ numPages }}</p>
|
||||
</div>
|
||||
<div v-if="mainImg" class="absolute top-0 right-36 sm:right-40 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">
|
||||
<ui-icon-btn icon="zoom_out" :size="8" :disabled="!canScaleDown" borderless class="mr-px" @click="zoomOut" />
|
||||
<ui-icon-btn icon="zoom_in" :size="8" :disabled="!canScaleUp" borderless class="ml-px" @click="zoomIn" />
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden w-full h-full relative">
|
||||
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
|
||||
<div class="w-full h-full relative">
|
||||
<div v-show="canGoPrev" ref="prevButton" class="absolute top-0 left-0 h-full w-1/2 lg:w-1/3 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 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
|
||||
<div v-show="canGoNext" ref="nextButton" class="absolute top-0 right-0 h-full w-1/2 lg:w-1/3 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 h-full m-auto" />
|
||||
<div ref="imageContainer" class="w-full h-full relative overflow-auto">
|
||||
<div class="h-full flex" :class="scale > 100 ? '' : 'justify-center'">
|
||||
<img v-if="mainImg" :style="{ minWidth: scale + '%', width: scale + '%' }" :src="mainImg" class="object-contain m-auto" />
|
||||
</div>
|
||||
</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>
|
||||
@@ -54,6 +60,10 @@ import Path from 'path'
|
||||
import { Archive } from 'libarchive.js/main.js'
|
||||
import { CompressedFile } from 'libarchive.js/src/compressed-file'
|
||||
|
||||
// This is % with respect to the screen width
|
||||
const MAX_SCALE = 400
|
||||
const MIN_SCALE = 10
|
||||
|
||||
Archive.init({
|
||||
workerUrl: '/libarchive/worker-bundle.js'
|
||||
})
|
||||
@@ -81,7 +91,8 @@ export default {
|
||||
showInfoMenu: false,
|
||||
loadTimeout: null,
|
||||
loadedFirstPage: false,
|
||||
comicMetadata: null
|
||||
comicMetadata: null,
|
||||
scale: 80
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -136,6 +147,12 @@ export default {
|
||||
return p
|
||||
}) || []
|
||||
)
|
||||
},
|
||||
canScaleUp() {
|
||||
return this.scale < MAX_SCALE
|
||||
},
|
||||
canScaleDown() {
|
||||
return this.scale > MIN_SCALE
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -303,8 +320,8 @@ export default {
|
||||
},
|
||||
parseImageFilename(filename) {
|
||||
var basename = Path.basename(filename, Path.extname(filename))
|
||||
var numbersinpath = basename.match(/\d{1,5}/g)
|
||||
if (!numbersinpath || !numbersinpath.length) {
|
||||
var numbersinpath = basename.match(/\d+/g)
|
||||
if (!numbersinpath?.length) {
|
||||
return {
|
||||
index: -1,
|
||||
filename
|
||||
@@ -331,10 +348,37 @@ export default {
|
||||
orderedImages = orderedImages.concat(noNumImages.map((i) => i.filename))
|
||||
|
||||
this.pages = orderedImages
|
||||
},
|
||||
zoomIn() {
|
||||
this.scale += 10
|
||||
},
|
||||
zoomOut() {
|
||||
this.scale -= 10
|
||||
},
|
||||
scroll(event) {
|
||||
const imageContainer = this.$refs.imageContainer
|
||||
|
||||
imageContainer.scrollBy({
|
||||
top: event.deltaY,
|
||||
left: event.deltaX,
|
||||
behavior: 'auto'
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
mounted() {
|
||||
const prevButton = this.$refs.prevButton
|
||||
const nextButton = this.$refs.nextButton
|
||||
|
||||
prevButton.addEventListener('wheel', this.scroll, { passive: false })
|
||||
nextButton.addEventListener('wheel', this.scroll, { passive: false })
|
||||
},
|
||||
beforeDestroy() {
|
||||
const prevButton = this.$refs.prevButton
|
||||
const nextButton = this.$refs.nextButton
|
||||
|
||||
prevButton.removeEventListener('wheel', this.scroll, { passive: false })
|
||||
nextButton.removeEventListener('wheel', this.scroll, { passive: false })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -40,8 +40,10 @@ export default {
|
||||
book: null,
|
||||
/** @type {ePub.Rendition} */
|
||||
rendition: null,
|
||||
chapters: [],
|
||||
ereaderSettings: {
|
||||
theme: 'dark',
|
||||
font: 'serif',
|
||||
fontScale: 100,
|
||||
lineSpacing: 115,
|
||||
spread: 'auto'
|
||||
@@ -67,10 +69,6 @@ export default {
|
||||
hasNext() {
|
||||
return !this.rendition?.location?.atEnd
|
||||
},
|
||||
/** @returns {Array<ePub.NavItem>} */
|
||||
chapters() {
|
||||
return this.book?.navigation?.toc || []
|
||||
},
|
||||
userMediaProgress() {
|
||||
if (!this.libraryItemId) return
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
@@ -130,17 +128,55 @@ export default {
|
||||
|
||||
const fontScale = settings.fontScale || 100
|
||||
this.rendition.themes.fontSize(`${fontScale}%`)
|
||||
this.rendition.themes.font(settings.font)
|
||||
this.rendition.spread(settings.spread || 'auto')
|
||||
},
|
||||
prev() {
|
||||
if (!this.rendition?.manager) return
|
||||
return this.rendition?.prev()
|
||||
},
|
||||
next() {
|
||||
if (!this.rendition?.manager) return
|
||||
return this.rendition?.next()
|
||||
},
|
||||
goToChapter(href) {
|
||||
if (!this.rendition?.manager) return
|
||||
return this.rendition?.display(href)
|
||||
},
|
||||
/** @returns {object} Returns the chapter that the `position` in the book is in */
|
||||
findChapterFromPosition(chapters, position) {
|
||||
let foundChapter
|
||||
for (let i = 0; i < chapters.length; i++) {
|
||||
if (position >= chapters[i].start && (!chapters[i + 1] || position < chapters[i + 1].start)) {
|
||||
foundChapter = chapters[i]
|
||||
if (chapters[i].subitems && chapters[i].subitems.length > 0) {
|
||||
return this.findChapterFromPosition(chapters[i].subitems, position, foundChapter)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return foundChapter
|
||||
},
|
||||
/** @returns {Array} Returns an array of chapters that only includes chapters with query results */
|
||||
async searchBook(query) {
|
||||
const chapters = structuredClone(await this.chapters)
|
||||
const searchResults = await Promise.all(this.book.spine.spineItems.map((item) => item.load(this.book.load.bind(this.book)).then(item.find.bind(item, query)).finally(item.unload.bind(item))))
|
||||
const mergedResults = [].concat(...searchResults)
|
||||
|
||||
mergedResults.forEach((chapter) => {
|
||||
chapter.start = this.book.locations.percentageFromCfi(chapter.cfi)
|
||||
const foundChapter = this.findChapterFromPosition(chapters, chapter.start)
|
||||
if (foundChapter) foundChapter.searchResults.push(chapter)
|
||||
})
|
||||
|
||||
let filteredResults = chapters.filter(function f(o) {
|
||||
if (o.searchResults.length) return true
|
||||
if (o.subitems.length) {
|
||||
return (o.subitems = o.subitems.filter(f)).length
|
||||
}
|
||||
})
|
||||
return filteredResults
|
||||
},
|
||||
keyUp(e) {
|
||||
const rtl = this.book.package.metadata.direction === 'rtl'
|
||||
if ((e.keyCode || e.which) == 37) {
|
||||
@@ -314,8 +350,77 @@ export default {
|
||||
this.checkSaveLocations(reader.book.locations.save())
|
||||
})
|
||||
}
|
||||
this.getChapters()
|
||||
})
|
||||
},
|
||||
getChapters() {
|
||||
// Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759
|
||||
const toc = this.book?.navigation?.toc || []
|
||||
|
||||
const tocTree = []
|
||||
|
||||
const resolveURL = (url, relativeTo) => {
|
||||
// see https://github.com/futurepress/epub.js/issues/1084
|
||||
// HACK-ish: abuse the URL API a little to resolve the path
|
||||
// the base needs to be a valid URL, or it will throw a TypeError,
|
||||
// so we just set a random base URI and remove it later
|
||||
const base = 'https://example.invalid/'
|
||||
return new URL(url, base + relativeTo).href.replace(base, '')
|
||||
}
|
||||
|
||||
const basePath = this.book.packaging.navPath || this.book.packaging.ncxPath
|
||||
|
||||
const createTree = async (toc, parent) => {
|
||||
const promises = toc.map(async (tocItem, i) => {
|
||||
const href = resolveURL(tocItem.href, basePath)
|
||||
const id = href.split('#')[1]
|
||||
const item = this.book.spine.get(href)
|
||||
await item.load(this.book.load.bind(this.book))
|
||||
const el = id ? item.document.getElementById(id) : item.document.body
|
||||
|
||||
const cfi = item.cfiFromElement(el)
|
||||
|
||||
parent[i] = {
|
||||
title: tocItem.label.trim(),
|
||||
subitems: [],
|
||||
href,
|
||||
cfi,
|
||||
start: this.book.locations.percentageFromCfi(cfi),
|
||||
end: null, // set by flattenChapters()
|
||||
id: null, // set by flattenChapters()
|
||||
searchResults: []
|
||||
}
|
||||
|
||||
if (tocItem.subitems) {
|
||||
await createTree(tocItem.subitems, parent[i].subitems)
|
||||
}
|
||||
})
|
||||
await Promise.all(promises)
|
||||
}
|
||||
return createTree(toc, tocTree).then(() => {
|
||||
this.chapters = tocTree
|
||||
})
|
||||
},
|
||||
flattenChapters(chapters) {
|
||||
// Convert the nested epub chapters into something that looks like audiobook chapters for player-ui
|
||||
const unwrap = (chapters) => {
|
||||
return chapters.reduce((acc, chapter) => {
|
||||
return chapter.subitems ? [...acc, chapter, ...unwrap(chapter.subitems)] : [...acc, chapter]
|
||||
}, [])
|
||||
}
|
||||
let flattenedChapters = unwrap(chapters)
|
||||
|
||||
flattenedChapters = flattenedChapters.sort((a, b) => a.start - b.start)
|
||||
for (let i = 0; i < flattenedChapters.length; i++) {
|
||||
flattenedChapters[i].id = i
|
||||
if (i < flattenedChapters.length - 1) {
|
||||
flattenedChapters[i].end = flattenedChapters[i + 1].start
|
||||
} else {
|
||||
flattenedChapters[i].end = 1
|
||||
}
|
||||
}
|
||||
return flattenedChapters
|
||||
},
|
||||
resize() {
|
||||
this.windowWidth = window.innerWidth
|
||||
this.windowHeight = window.innerHeight
|
||||
|
||||
@@ -26,9 +26,9 @@
|
||||
<component v-if="componentName" ref="readerComponent" :is="componentName" :library-item="selectedLibraryItem" :player-open="!!streamLibraryItem" :keep-progress="keepProgress" :file-id="ebookFileId" @touchstart="touchstart" @touchend="touchend" @hook:mounted="readerMounted" />
|
||||
|
||||
<!-- TOC side nav -->
|
||||
<div v-if="tocOpen" class="w-full h-full fixed inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
|
||||
<div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent="toggleToC">
|
||||
<div class="p-4 h-full">
|
||||
<div v-if="tocOpen" class="w-full h-full overflow-y-scroll absolute inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
|
||||
<div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent>
|
||||
<div class="flex flex-col p-4 h-full">
|
||||
<div class="flex items-center mb-2">
|
||||
<button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100">
|
||||
<span class="material-icons text-2xl">arrow_back</span>
|
||||
@@ -36,13 +36,28 @@
|
||||
|
||||
<p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p>
|
||||
</div>
|
||||
<div class="tocContent">
|
||||
<form @submit.prevent="searchBook" @click.stop.prevent>
|
||||
<ui-text-input clearable ref="input" @clear="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" class="h-8 w-full text-sm flex mb-2" />
|
||||
</form>
|
||||
|
||||
<div class="overflow-y-auto">
|
||||
<div v-if="isSearching && !this.searchResults.length" class="w-full h-40 justify-center">
|
||||
<p class="text-center text-xl py-4">{{ $strings.MessageNoResults }}</p>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
|
||||
<a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a>
|
||||
<li v-for="chapter in isSearching ? this.searchResults : chapters" :key="chapter.id" class="py-1">
|
||||
<a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(chapter.href)">{{ chapter.title }}</a>
|
||||
<div v-for="searchResults in chapter.searchResults" :key="searchResults.cfi" class="text-sm py-1 pl-4">
|
||||
<a :href="searchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="goToChapter(searchResults.cfi)">{{ searchResults.excerpt }}</a>
|
||||
</div>
|
||||
|
||||
<ul v-if="chapter.subitems.length">
|
||||
<li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4">
|
||||
<a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subchapter.href)">{{ subchapter.label }}</a>
|
||||
<a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(subchapter.href)">{{ subchapter.title }}</a>
|
||||
<div v-for="subChapterSearchResults in subchapter.searchResults" :key="subChapterSearchResults.cfi" class="text-sm py-1 pl-4">
|
||||
<a :href="subChapterSearchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="goToChapter(subChapterSearchResults.cfi)">{{ subChapterSearchResults.excerpt }}</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
@@ -63,7 +78,13 @@
|
||||
<div class="w-40">
|
||||
<p class="text-lg">{{ $strings.LabelTheme }}:</p>
|
||||
</div>
|
||||
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" />
|
||||
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems.theme" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-40">
|
||||
<p class="text-lg">{{ $strings.LabelFontFamily }}:</p>
|
||||
</div>
|
||||
<ui-toggle-btns v-model="ereaderSettings.font" :items="themeItems.font" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-40">
|
||||
@@ -99,10 +120,14 @@ export default {
|
||||
touchstartTime: 0,
|
||||
touchIdentifier: null,
|
||||
chapters: [],
|
||||
isSearching: false,
|
||||
searchResults: [],
|
||||
searchQuery: '',
|
||||
tocOpen: false,
|
||||
showSettings: false,
|
||||
ereaderSettings: {
|
||||
theme: 'dark',
|
||||
font: 'serif',
|
||||
fontScale: 100,
|
||||
lineSpacing: 115,
|
||||
spread: 'auto'
|
||||
@@ -142,16 +167,28 @@ export default {
|
||||
]
|
||||
},
|
||||
themeItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelThemeDark,
|
||||
value: 'dark'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelThemeLight,
|
||||
value: 'light'
|
||||
}
|
||||
]
|
||||
return {
|
||||
theme: [
|
||||
{
|
||||
text: this.$strings.LabelThemeDark,
|
||||
value: 'dark'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelThemeLight,
|
||||
value: 'light'
|
||||
}
|
||||
],
|
||||
font: [
|
||||
{
|
||||
text: 'Sans',
|
||||
value: 'sans-serif'
|
||||
},
|
||||
{
|
||||
text: 'Serif',
|
||||
value: 'serif'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
componentName() {
|
||||
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
||||
@@ -235,6 +272,10 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
goToChapter(uri) {
|
||||
this.toggleToC()
|
||||
this.$refs.readerComponent.goToChapter(uri)
|
||||
},
|
||||
readerMounted() {
|
||||
if (this.isEpub) {
|
||||
this.loadEreaderSettings()
|
||||
@@ -262,6 +303,15 @@ export default {
|
||||
this.close()
|
||||
}
|
||||
},
|
||||
async searchBook() {
|
||||
if (this.searchQuery.length > 1) {
|
||||
this.searchResults = await this.$refs.readerComponent.searchBook(this.searchQuery)
|
||||
this.isSearching = true
|
||||
} else {
|
||||
this.isSearching = false
|
||||
this.searchResults = []
|
||||
}
|
||||
},
|
||||
next() {
|
||||
if (this.$refs.readerComponent?.next) this.$refs.readerComponent.next()
|
||||
},
|
||||
@@ -340,6 +390,8 @@ export default {
|
||||
},
|
||||
close() {
|
||||
this.unregisterListeners()
|
||||
this.isSearching = false
|
||||
this.searchQuery = ''
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
@@ -353,10 +405,6 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.tocContent {
|
||||
height: calc(100% - 36px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
#reader {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex px-4">
|
||||
<div v-if="isBookLibrary" class="flex px-4">
|
||||
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" />
|
||||
</svg>
|
||||
@@ -58,26 +58,32 @@ export default {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isBookLibrary() {
|
||||
return this.currentLibraryMediaType === 'book'
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
totalItems() {
|
||||
return this.libraryStats ? this.libraryStats.totalItems : 0
|
||||
return this.libraryStats?.totalItems || 0
|
||||
},
|
||||
totalAuthors() {
|
||||
return this.libraryStats ? this.libraryStats.totalAuthors : 0
|
||||
return this.libraryStats?.totalAuthors || 0
|
||||
},
|
||||
numAudioTracks() {
|
||||
return this.libraryStats ? this.libraryStats.numAudioTracks : 0
|
||||
return this.libraryStats?.numAudioTracks || 0
|
||||
},
|
||||
totalDuration() {
|
||||
return this.libraryStats ? this.libraryStats.totalDuration : 0
|
||||
return this.libraryStats?.totalDuration || 0
|
||||
},
|
||||
totalHours() {
|
||||
return Math.round(this.totalDuration / (60 * 60))
|
||||
},
|
||||
totalSizePretty() {
|
||||
var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0
|
||||
var totalSize = this.libraryStats?.totalSize || 0
|
||||
return this.$bytesPretty(totalSize, 1)
|
||||
},
|
||||
totalSizeNum() {
|
||||
|
||||
285
client/components/stats/YearInReview.vue
Normal file
285
client/components/stats/YearInReview.vue
Normal file
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
variant: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
year: Number,
|
||||
processing: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canvas: null,
|
||||
dataUrl: null,
|
||||
yearStats: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
variant() {
|
||||
this.init()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initCanvas() {
|
||||
if (!this.yearStats) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 800
|
||||
canvas.height = 800
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
const createRoundedRect = (x, y, w, h) => {
|
||||
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
|
||||
grd1.addColorStop(0, '#44444455')
|
||||
grd1.addColorStop(1, '#ffffff11')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.strokeStyle = '#C0C0C088'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, w, h, [20])
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||
ctx.letterSpacing = letterSpacing
|
||||
|
||||
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
|
||||
if (maxWidth) {
|
||||
let txtWidth = ctx.measureText(text).width
|
||||
while (txtWidth > maxWidth) {
|
||||
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
|
||||
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
|
||||
else text = text.slice(0, -3) // First check remove last 3 chars
|
||||
text += '...'
|
||||
txtWidth = ctx.measureText(text).width
|
||||
console.log(`Checking text "${text}" (width:${txtWidth})`)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillText(text, x, y)
|
||||
}
|
||||
|
||||
const addIcon = (icon, color, fontSize, x, y) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontSize} Material Icons Outlined`
|
||||
ctx.fillText(icon, x, y)
|
||||
}
|
||||
|
||||
// Bg color
|
||||
ctx.fillStyle = '#232323'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Cover image tiles
|
||||
const bookCovers = this.yearStats.finishedBooksWithCovers
|
||||
bookCovers.push(...this.yearStats.booksWithCovers)
|
||||
|
||||
let finishedBookCoverImgs = {}
|
||||
|
||||
if (bookCovers.length) {
|
||||
let index = 0
|
||||
ctx.globalAlpha = 0.25
|
||||
ctx.save()
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||
ctx.rotate((-Math.PI / 180) * 25)
|
||||
ctx.translate(-canvas.width / 2, -canvas.height / 2)
|
||||
ctx.translate(-130, -120)
|
||||
for (let x = 0; x < 5; x++) {
|
||||
for (let y = 0; y < 5; y++) {
|
||||
const coverIndex = index % bookCovers.length
|
||||
let libraryItemId = bookCovers[coverIndex]
|
||||
index++
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.addEventListener('load', () => {
|
||||
let sw = img.width
|
||||
if (img.width > img.height) {
|
||||
sw = img.height
|
||||
}
|
||||
let sx = -(sw - img.width) / 2
|
||||
let sy = -(sw - img.height) / 2
|
||||
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
|
||||
resolve()
|
||||
if (this.yearStats.finishedBooksWithCovers.includes(libraryItemId) && !finishedBookCoverImgs[libraryItemId]) {
|
||||
finishedBookCoverImgs[libraryItemId] = {
|
||||
img,
|
||||
sx,
|
||||
sy,
|
||||
sw
|
||||
}
|
||||
}
|
||||
})
|
||||
img.addEventListener('error', () => {
|
||||
resolve()
|
||||
})
|
||||
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// Create gradient
|
||||
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||
grd1.addColorStop(0, '#000000aa')
|
||||
grd1.addColorStop(1, '#cd9d49aa')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Top Abs icon
|
||||
let tanColor = '#ffdb70'
|
||||
ctx.fillStyle = tanColor
|
||||
ctx.font = '42px absicons'
|
||||
ctx.fillText('\ue900', 15, 36)
|
||||
|
||||
// Top text
|
||||
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||
|
||||
// Top left box
|
||||
createRoundedRect(50, 100, 340, 160)
|
||||
addText(this.yearStats.numBooksFinished, '64px', 'bold', 'white', '0px', 160, 165)
|
||||
addText('books finished', '28px', 'normal', tanColor, '0px', 160, 210)
|
||||
const readIconPath = new Path2D()
|
||||
readIconPath.addPath(new Path2D('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'), { a: 2, d: 2, e: 100, f: 160 })
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fill(readIconPath)
|
||||
|
||||
// Box top right
|
||||
createRoundedRect(410, 100, 340, 160)
|
||||
addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165)
|
||||
addText('spent listening', '28px', 'normal', tanColor, '0px', 500, 205)
|
||||
addIcon('watch_later', 'white', '52px', 440, 180)
|
||||
|
||||
// Box bottom left
|
||||
createRoundedRect(50, 280, 340, 160)
|
||||
addText(this.yearStats.totalListeningSessions, '64px', 'bold', 'white', '0px', 160, 345)
|
||||
addText('sessions', '28px', 'normal', tanColor, '1px', 160, 390)
|
||||
addIcon('headphones', 'white', '52px', 95, 360)
|
||||
|
||||
// Box bottom right
|
||||
createRoundedRect(410, 280, 340, 160)
|
||||
addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345)
|
||||
addText('books listened to', '28px', 'normal', tanColor, '0px', 500, 390)
|
||||
addIcon('local_library', 'white', '52px', 440, 360)
|
||||
|
||||
if (!this.variant) {
|
||||
// Text stats
|
||||
const topNarrator = this.yearStats.mostListenedNarrator
|
||||
if (topNarrator) {
|
||||
addText('TOP NARRATOR', '24px', 'normal', tanColor, '1px', 70, 520)
|
||||
addText(topNarrator.name, '36px', 'bolder', 'white', '0px', 70, 564, 330)
|
||||
addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '24px', 'lighter', 'white', '1px', 70, 599)
|
||||
}
|
||||
|
||||
const topGenre = this.yearStats.topGenres[0]
|
||||
if (topGenre) {
|
||||
addText('TOP GENRE', '24px', 'normal', tanColor, '1px', 430, 520)
|
||||
addText(topGenre.genre, '36px', 'bolder', 'white', '0px', 430, 564, 330)
|
||||
addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '24px', 'lighter', 'white', '1px', 430, 599)
|
||||
}
|
||||
|
||||
const topAuthor = this.yearStats.topAuthors[0]
|
||||
if (topAuthor) {
|
||||
addText('TOP AUTHOR', '24px', 'normal', tanColor, '1px', 70, 670)
|
||||
addText(topAuthor.name, '36px', 'bolder', 'white', '0px', 70, 714, 330)
|
||||
addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749)
|
||||
}
|
||||
|
||||
if (this.yearStats.mostListenedMonth?.time) {
|
||||
const jsdate = new Date(this.year, this.yearStats.mostListenedMonth.month, 1)
|
||||
const monthName = this.$formatJsDate(jsdate, 'LLLL')
|
||||
addText('TOP MONTH', '24px', 'normal', tanColor, '1px', 430, 670)
|
||||
addText(monthName, '36px', 'bolder', 'white', '0px', 430, 714, 330)
|
||||
addText(this.$elapsedPrettyExtended(this.yearStats.mostListenedMonth.time, true, false), '24px', 'lighter', 'white', '1px', 430, 749)
|
||||
}
|
||||
} else if (this.variant === 1) {
|
||||
// Bottom images
|
||||
finishedBookCoverImgs = Object.values(finishedBookCoverImgs)
|
||||
if (finishedBookCoverImgs.length > 0) {
|
||||
ctx.textAlign = 'center'
|
||||
addText('Some books finished this year...', '28px', 'normal', tanColor, '0px', canvas.width / 2, 530)
|
||||
|
||||
for (let i = 0; i < Math.min(5, finishedBookCoverImgs.length); i++) {
|
||||
let imgToAdd = finishedBookCoverImgs[i]
|
||||
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 570, 140, 140)
|
||||
}
|
||||
}
|
||||
} else if (this.variant === 2) {
|
||||
// Text stats
|
||||
if (this.yearStats.topAuthors.length) {
|
||||
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 524)
|
||||
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 584 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.yearStats.topGenres.length) {
|
||||
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 524)
|
||||
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
|
||||
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 584 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.canvas = canvas
|
||||
this.dataUrl = canvas.toDataURL('png')
|
||||
},
|
||||
refresh() {
|
||||
this.init()
|
||||
},
|
||||
share() {
|
||||
this.canvas.toBlob((blob) => {
|
||||
const file = new File([blob], 'yearinreview.png', { type: blob.type })
|
||||
const shareData = {
|
||||
files: [file]
|
||||
}
|
||||
if (navigator.canShare(shareData)) {
|
||||
navigator
|
||||
.share(shareData)
|
||||
.then(() => {
|
||||
console.log('Share success')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to share', error)
|
||||
if (error.name !== 'AbortError') {
|
||||
this.$toast.error('Failed to share: ' + error.message)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.$toast.error('Cannot share natively on this device')
|
||||
}
|
||||
})
|
||||
},
|
||||
async init() {
|
||||
this.$emit('update:processing', true)
|
||||
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => {
|
||||
console.error('Failed to load stats for year', err)
|
||||
this.$toast.error('Failed to load year stats')
|
||||
return null
|
||||
})
|
||||
await this.initCanvas()
|
||||
this.$emit('update:processing', false)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
141
client/components/stats/YearInReviewBanner.vue
Normal file
141
client/components/stats/YearInReviewBanner.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-1 sm:p-4 mb-4">
|
||||
<!-- hack to get icon fonts loaded on init -->
|
||||
<div class="h-0 w-0 overflow-hidden opacity-0">
|
||||
<span class="material-icons-outlined">close</span>
|
||||
<span class="abs-icons icon-audiobookshelf" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<p class="hidden md:block text-xl font-semibold">{{ yearInReviewYear }} Year in Review</p>
|
||||
<div class="hidden md:block flex-grow" />
|
||||
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? 'Hide Year in Review' : 'See Year in Review' }}</ui-btn>
|
||||
</div>
|
||||
|
||||
<!-- your year in review -->
|
||||
<div v-if="showYearInReview">
|
||||
<div class="w-full h-px bg-slate-200/10 my-4" />
|
||||
|
||||
<div class="flex items-center justify-center mb-2 max-w-[800px] mx-auto">
|
||||
<!-- previous button -->
|
||||
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
|
||||
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="hidden sm:inline-block pr-2">Previous</span>
|
||||
</ui-btn>
|
||||
<!-- share button -->
|
||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview"> Share </ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<p class="hidden sm:block text-lg font-semibold">Your Year in Review ({{ yearInReviewVariant + 1 }})</p>
|
||||
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<!-- refresh button -->
|
||||
<ui-btn small :disabled="processingYearInReview" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReview">
|
||||
<span class="hidden sm:inline-block">Refresh</span>
|
||||
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||
</ui-btn>
|
||||
<!-- next button -->
|
||||
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
|
||||
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
<stats-year-in-review ref="yearInReview" :variant="yearInReviewVariant" :year="yearInReviewYear" :processing.sync="processingYearInReview" />
|
||||
|
||||
<!-- your year in review short -->
|
||||
<div class="w-full max-w-[800px] mx-auto my-4">
|
||||
<!-- share button -->
|
||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewShort" class="inline-flex sm:hidden items-center font-semibold mb-1" @click="shareYearInReviewShort"> Share </ui-btn>
|
||||
<stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" />
|
||||
</div>
|
||||
|
||||
<!-- your server in review -->
|
||||
<div v-if="isAdminOrUp" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10">
|
||||
<div class="flex items-center justify-center mb-2">
|
||||
<!-- previous button -->
|
||||
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
|
||||
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="hidden sm:inline-block pr-2">Previous</span>
|
||||
</ui-btn>
|
||||
<!-- share button -->
|
||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer"> Share </ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<p class="hidden sm:block text-lg font-semibold">Server Year in Review ({{ yearInReviewServerVariant + 1 }})</p>
|
||||
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<!-- refresh button -->
|
||||
<ui-btn small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReviewServer">
|
||||
<span class="hidden sm:inline-block">Refresh</span>
|
||||
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||
</ui-btn>
|
||||
<!-- next button -->
|
||||
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
|
||||
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<stats-year-in-review-server v-if="isAdminOrUp" ref="yearInReviewServer" :year="yearInReviewYear" :variant="yearInReviewServerVariant" :processing.sync="processingYearInReviewServer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
showYearInReview: false,
|
||||
yearInReviewYear: 0,
|
||||
yearInReviewVariant: 0,
|
||||
yearInReviewServerVariant: 0,
|
||||
processingYearInReview: false,
|
||||
processingYearInReviewShort: false,
|
||||
processingYearInReviewServer: false,
|
||||
showShareButton: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
shareYearInReviewServer() {
|
||||
this.$refs.yearInReviewServer.share()
|
||||
},
|
||||
shareYearInReview() {
|
||||
this.$refs.yearInReview.share()
|
||||
},
|
||||
shareYearInReviewShort() {
|
||||
this.$refs.yearInReviewShort.share()
|
||||
},
|
||||
refreshYearInReviewServer() {
|
||||
this.$refs.yearInReviewServer.refresh()
|
||||
},
|
||||
refreshYearInReview() {
|
||||
this.$refs.yearInReview.refresh()
|
||||
this.$refs.yearInReviewShort.refresh()
|
||||
},
|
||||
clickShowYearInReview() {
|
||||
this.showYearInReview = !this.showYearInReview
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
this.yearInReviewYear = new Date().getFullYear()
|
||||
// When not December show previous year
|
||||
if (new Date().getMonth() < 11) {
|
||||
this.yearInReviewYear--
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (typeof navigator.share !== 'undefined' && navigator.share) {
|
||||
this.showShareButton = true
|
||||
} else {
|
||||
console.warn('Navigator.share not supported')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
264
client/components/stats/YearInReviewServer.vue
Normal file
264
client/components/stats/YearInReviewServer.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
variant: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
processing: Boolean,
|
||||
year: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canvas: null,
|
||||
dataUrl: null,
|
||||
yearStats: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
variant() {
|
||||
this.init()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initCanvas() {
|
||||
if (!this.yearStats) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 800
|
||||
canvas.height = 800
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
const createRoundedRect = (x, y, w, h) => {
|
||||
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
|
||||
grd1.addColorStop(0, '#44444455')
|
||||
grd1.addColorStop(1, '#ffffff11')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.strokeStyle = '#C0C0C088'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, w, h, [20])
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||
ctx.letterSpacing = letterSpacing
|
||||
|
||||
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
|
||||
if (maxWidth) {
|
||||
let txtWidth = ctx.measureText(text).width
|
||||
while (txtWidth > maxWidth) {
|
||||
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
|
||||
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
|
||||
else text = text.slice(0, -3) // First check remove last 3 chars
|
||||
text += '...'
|
||||
txtWidth = ctx.measureText(text).width
|
||||
console.log(`Checking text "${text}" (width:${txtWidth})`)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillText(text, x, y)
|
||||
}
|
||||
|
||||
// Bg color
|
||||
ctx.fillStyle = '#232323'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Cover image tiles
|
||||
let imgsToAdd = {}
|
||||
|
||||
if (this.yearStats.booksAddedWithCovers.length) {
|
||||
let index = 0
|
||||
ctx.globalAlpha = 0.25
|
||||
ctx.save()
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||
ctx.rotate((-Math.PI / 180) * 25)
|
||||
ctx.translate(-canvas.width / 2, -canvas.height / 2)
|
||||
ctx.translate(-130, -120)
|
||||
for (let x = 0; x < 5; x++) {
|
||||
for (let y = 0; y < 5; y++) {
|
||||
const coverIndex = index % this.yearStats.booksAddedWithCovers.length
|
||||
let libraryItemId = this.yearStats.booksAddedWithCovers[coverIndex]
|
||||
index++
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.addEventListener('load', () => {
|
||||
let sw = img.width
|
||||
if (img.width > img.height) {
|
||||
sw = img.height
|
||||
}
|
||||
let sx = -(sw - img.width) / 2
|
||||
let sy = -(sw - img.height) / 2
|
||||
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
|
||||
if (!imgsToAdd[libraryItemId]) {
|
||||
imgsToAdd[libraryItemId] = {
|
||||
img,
|
||||
sx,
|
||||
sy,
|
||||
sw
|
||||
}
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
img.addEventListener('error', () => {
|
||||
resolve()
|
||||
})
|
||||
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// Create gradient
|
||||
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||
grd1.addColorStop(0, '#000000aa')
|
||||
grd1.addColorStop(1, '#cd9d49aa')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Top Abs icon
|
||||
let tanColor = '#ffdb70'
|
||||
ctx.fillStyle = tanColor
|
||||
ctx.font = '42px absicons'
|
||||
ctx.fillText('\ue900', 15, 36)
|
||||
|
||||
// Top text
|
||||
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||
|
||||
// Top left box
|
||||
createRoundedRect(40, 100, 230, 100)
|
||||
ctx.textAlign = 'center'
|
||||
addText(this.yearStats.numBooksAdded, '48px', 'bold', 'white', '0px', 155, 140)
|
||||
addText('books added', '18px', 'normal', tanColor, '0px', 155, 170)
|
||||
|
||||
// Box top right
|
||||
createRoundedRect(285, 100, 230, 100)
|
||||
addText(this.yearStats.numAuthorsAdded, '48px', 'bold', 'white', '0px', 400, 140)
|
||||
addText('authors added', '18px', 'normal', tanColor, '0px', 400, 170)
|
||||
|
||||
// Box bottom left
|
||||
createRoundedRect(530, 100, 230, 100)
|
||||
addText(this.yearStats.numListeningSessions, '48px', 'bold', 'white', '0px', 645, 140)
|
||||
addText('sessions', '18px', 'normal', tanColor, '1px', 645, 170)
|
||||
|
||||
// Text stats
|
||||
if (this.yearStats.totalBooksAddedSize) {
|
||||
addText('Your book collection grew to...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 260)
|
||||
addText(this.$bytesPretty(this.yearStats.totalBooksSize), '36px', 'bolder', 'white', '0px', canvas.width / 2, 300)
|
||||
addText('+' + this.$bytesPretty(this.yearStats.totalBooksAddedSize), '20px', 'lighter', 'white', '0px', canvas.width / 2, 330)
|
||||
}
|
||||
|
||||
if (this.yearStats.totalBooksAddedDuration) {
|
||||
addText('With a total duration of...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 400)
|
||||
addText(this.$elapsedPrettyExtended(this.yearStats.totalBooksDuration, true, false), '36px', 'bolder', 'white', '0px', canvas.width / 2, 440)
|
||||
addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470)
|
||||
}
|
||||
|
||||
if (!this.variant) {
|
||||
// Bottom images
|
||||
imgsToAdd = Object.values(imgsToAdd)
|
||||
if (imgsToAdd.length > 0) {
|
||||
addText('Some additions include...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 540)
|
||||
|
||||
for (let i = 0; i < Math.min(5, imgsToAdd.length); i++) {
|
||||
let imgToAdd = imgsToAdd[i]
|
||||
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 580, 140, 140)
|
||||
}
|
||||
}
|
||||
} else if (this.variant === 1) {
|
||||
// Text stats
|
||||
ctx.textAlign = 'left'
|
||||
if (this.yearStats.topAuthors.length) {
|
||||
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
|
||||
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.yearStats.topNarrators.length) {
|
||||
addText('TOP NARRATORS', '24px', 'normal', tanColor, '1px', 430, 549)
|
||||
for (let i = 0; i < this.yearStats.topNarrators.length; i++) {
|
||||
addText(this.yearStats.topNarrators[i].name, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
} else if (this.variant === 2) {
|
||||
// Text stats
|
||||
ctx.textAlign = 'left'
|
||||
if (this.yearStats.topAuthors.length) {
|
||||
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
|
||||
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.yearStats.topGenres.length) {
|
||||
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 549)
|
||||
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
|
||||
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.canvas = canvas
|
||||
this.dataUrl = canvas.toDataURL('png')
|
||||
},
|
||||
share() {
|
||||
this.canvas.toBlob((blob) => {
|
||||
const file = new File([blob], 'yearinreviewserver.png', { type: blob.type })
|
||||
const shareData = {
|
||||
files: [file]
|
||||
}
|
||||
if (navigator.canShare(shareData)) {
|
||||
navigator
|
||||
.share(shareData)
|
||||
.then(() => {
|
||||
console.log('Share success')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to share', error)
|
||||
if (error.name !== 'AbortError') {
|
||||
this.$toast.error('Failed to share: ' + error.message)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.$toast.error('Cannot share natively on this device')
|
||||
}
|
||||
})
|
||||
},
|
||||
refresh() {
|
||||
this.init()
|
||||
},
|
||||
async init() {
|
||||
this.$emit('update:processing', true)
|
||||
this.yearStats = await this.$axios.$get(`/api/stats/year/${this.year}`).catch((err) => {
|
||||
console.error('Failed to load stats for year', err)
|
||||
this.$toast.error('Failed to load year stats')
|
||||
return null
|
||||
})
|
||||
await this.initCanvas()
|
||||
this.$emit('update:processing', false)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
194
client/components/stats/YearInReviewShort.vue
Normal file
194
client/components/stats/YearInReviewShort.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="processing" class="max-w-[600px] h-32 sm:h-[200px] flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
<img v-else-if="dataUrl" :src="dataUrl" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
year: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canvas: null,
|
||||
dataUrl: null,
|
||||
yearStats: null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initCanvas() {
|
||||
if (!this.yearStats) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 600
|
||||
canvas.height = 200
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
const createRoundedRect = (x, y, w, h) => {
|
||||
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
|
||||
grd1.addColorStop(0, '#44444455')
|
||||
grd1.addColorStop(1, '#ffffff11')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.strokeStyle = '#C0C0C088'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, w, h, [20])
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||
ctx.letterSpacing = letterSpacing
|
||||
|
||||
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
|
||||
if (maxWidth) {
|
||||
let txtWidth = ctx.measureText(text).width
|
||||
while (txtWidth > maxWidth) {
|
||||
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
|
||||
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
|
||||
else text = text.slice(0, -3) // First check remove last 3 chars
|
||||
text += '...'
|
||||
txtWidth = ctx.measureText(text).width
|
||||
console.log(`Checking text "${text}" (width:${txtWidth})`)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillText(text, x, y)
|
||||
}
|
||||
|
||||
const addIcon = (icon, color, fontSize, x, y) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontSize} Material Icons Outlined`
|
||||
ctx.fillText(icon, x, y)
|
||||
}
|
||||
|
||||
// Bg color
|
||||
ctx.fillStyle = '#232323'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Cover image tiles
|
||||
const bookCovers = this.yearStats.finishedBooksWithCovers
|
||||
bookCovers.push(...this.yearStats.booksWithCovers)
|
||||
|
||||
if (bookCovers.length) {
|
||||
let index = 0
|
||||
ctx.globalAlpha = 0.25
|
||||
ctx.save()
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||
ctx.rotate((-Math.PI / 180) * 25)
|
||||
ctx.translate(-canvas.width / 2, -canvas.height / 2)
|
||||
ctx.translate(-10, -90)
|
||||
for (let x = 0; x < 4; x++) {
|
||||
for (let y = 0; y < 3; y++) {
|
||||
const coverIndex = index % bookCovers.length
|
||||
let libraryItemId = bookCovers[coverIndex]
|
||||
index++
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.addEventListener('load', () => {
|
||||
let sw = img.width
|
||||
if (img.width > img.height) {
|
||||
sw = img.height
|
||||
}
|
||||
let sx = -(sw - img.width) / 2
|
||||
let sy = -(sw - img.height) / 2
|
||||
ctx.drawImage(img, sx, sy, sw, sw, 155 * x, 155 * y, 155, 155)
|
||||
resolve()
|
||||
})
|
||||
img.addEventListener('error', () => {
|
||||
resolve()
|
||||
})
|
||||
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// Create gradient
|
||||
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||
grd1.addColorStop(0, '#000000aa')
|
||||
grd1.addColorStop(1, '#cd9d49aa')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Top Abs icon
|
||||
let tanColor = '#ffdb70'
|
||||
ctx.fillStyle = tanColor
|
||||
ctx.font = '42px absicons'
|
||||
ctx.fillText('\ue900', 15, 36)
|
||||
|
||||
// Top text
|
||||
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||
|
||||
// Top left box
|
||||
createRoundedRect(15, 75, 280, 110)
|
||||
addText(this.yearStats.numBooksFinished, '48px', 'bold', 'white', '0px', 105, 120)
|
||||
addText('books finished', '20px', 'normal', tanColor, '0px', 105, 155)
|
||||
const readIconPath = new Path2D()
|
||||
readIconPath.addPath(new Path2D('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'), { a: 1.5, d: 1.5, e: 55, f: 115 })
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fill(readIconPath)
|
||||
|
||||
createRoundedRect(305, 75, 280, 110)
|
||||
addText(this.yearStats.numBooksListened, '48px', 'bold', 'white', '0px', 400, 120)
|
||||
addText('books listened to', '20px', 'normal', tanColor, '0px', 400, 155)
|
||||
addIcon('local_library', 'white', '42px', 345, 130)
|
||||
|
||||
this.canvas = canvas
|
||||
this.dataUrl = canvas.toDataURL('png')
|
||||
},
|
||||
share() {
|
||||
this.canvas.toBlob((blob) => {
|
||||
const file = new File([blob], 'yearinreviewshort.png', { type: blob.type })
|
||||
const shareData = {
|
||||
files: [file]
|
||||
}
|
||||
if (navigator.canShare(shareData)) {
|
||||
navigator
|
||||
.share(shareData)
|
||||
.then(() => {
|
||||
console.log('Share success')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to share', error)
|
||||
if (error.name !== 'AbortError') {
|
||||
this.$toast.error('Failed to share: ' + error.message)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.$toast.error('Cannot share natively on this device')
|
||||
}
|
||||
})
|
||||
},
|
||||
refresh() {
|
||||
this.init()
|
||||
},
|
||||
async init() {
|
||||
this.$emit('update:processing', true)
|
||||
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => {
|
||||
console.error('Failed to load stats for year', err)
|
||||
this.$toast.error('Failed to load year stats')
|
||||
return null
|
||||
})
|
||||
await this.initCanvas()
|
||||
this.$emit('update:processing', false)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -164,6 +164,7 @@ export default {
|
||||
this.$axios
|
||||
.$get('/api/backups')
|
||||
.then((data) => {
|
||||
this.$emit('loaded', data.backupLocation)
|
||||
this.setBackups(data.backups || [])
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<span class="text-sm font-mono">{{ ebookFiles.length }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="toggleFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-4xl">expand_more</span>
|
||||
</div>
|
||||
@@ -75,6 +75,10 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleFullPath() {
|
||||
this.showFullPath = !this.showFullPath
|
||||
localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0)
|
||||
},
|
||||
readEbook(fileIno) {
|
||||
this.$store.commit('showEReader', { libraryItem: this.libraryItem, keepProgress: false, fileId: fileIno })
|
||||
},
|
||||
@@ -82,6 +86,10 @@ export default {
|
||||
this.showFiles = !this.showFiles
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
if (this.userIsAdmin) {
|
||||
this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -6,7 +6,7 @@
|
||||
<span class="text-sm font-mono">{{ files.length }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="toggleFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-4xl">expand_more</span>
|
||||
</div>
|
||||
@@ -84,6 +84,10 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleFullPath() {
|
||||
this.showFullPath = !this.showFullPath
|
||||
localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0)
|
||||
},
|
||||
clickBar() {
|
||||
this.showFiles = !this.showFiles
|
||||
},
|
||||
@@ -93,6 +97,9 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.userIsAdmin) {
|
||||
this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0)
|
||||
}
|
||||
this.showFiles = this.expanded
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="toggleFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||
<nuxt-link v-if="userCanUpdate && !isFile" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
|
||||
<ui-btn small color="primary">{{ $strings.ButtonManageTracks }}</ui-btn>
|
||||
</nuxt-link>
|
||||
@@ -74,6 +74,10 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleFullPath() {
|
||||
this.showFullPath = !this.showFullPath
|
||||
localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0)
|
||||
},
|
||||
clickBar() {
|
||||
this.showTracks = !this.showTracks
|
||||
},
|
||||
@@ -82,6 +86,10 @@ export default {
|
||||
this.showAudioFileDataModal = true
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
if (this.userIsAdmin) {
|
||||
this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -52,8 +52,6 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -62,8 +60,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
users: [],
|
||||
selectedAccount: null,
|
||||
showAccountModal: false,
|
||||
isDeletingUser: false
|
||||
}
|
||||
},
|
||||
@@ -114,13 +110,8 @@ export default {
|
||||
})
|
||||
}
|
||||
},
|
||||
clickAddUser() {
|
||||
this.selectedAccount = null
|
||||
this.showAccountModal = true
|
||||
},
|
||||
editUser(user) {
|
||||
this.selectedAccount = user
|
||||
this.showAccountModal = true
|
||||
this.$emit('edit', user)
|
||||
},
|
||||
loadUsers() {
|
||||
this.$axios
|
||||
@@ -129,7 +120,6 @@ export default {
|
||||
this.users = res.users.sort((a, b) => {
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
console.log('Loaded users', this.users)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
|
||||
@@ -26,11 +26,11 @@
|
||||
</div>
|
||||
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
|
||||
<template v-for="(author, index) in bookAuthors">
|
||||
<nuxt-link :key="author.id" :to="`/author/${author.id}?library=${book.libraryId}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
|
||||
<nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
|
||||
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
<p class="text-xs md:text-sm text-gray-400">{{ bookDuration }}</p>
|
||||
<p v-if="media.duration" class="text-xs md:text-sm text-gray-400">{{ bookDuration }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,10 +11,6 @@
|
||||
<ui-btn @click="clickAddLibrary">{{ $strings.ButtonAddYourFirstLibrary }}</ui-btn>
|
||||
</div>
|
||||
|
||||
<p v-if="libraries.length" class="text-xs mt-4 text-gray-200">
|
||||
*<strong>{{ $strings.ButtonForceReScan }}</strong> {{ $strings.MessageForceReScanDescription }}
|
||||
</p>
|
||||
|
||||
<p v-if="libraries.length && libraries.some((li) => li.mediaType === 'book')" class="text-xs mt-4 text-gray-200">
|
||||
**<strong>{{ $strings.ButtonMatchBooks }}</strong> {{ $strings.MessageMatchBooksDescription }}
|
||||
</p>
|
||||
@@ -46,13 +42,10 @@ export default {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.currentLibrary ? this.currentLibrary.id : null
|
||||
return this.currentLibrary?.id || null
|
||||
},
|
||||
libraries() {
|
||||
return this.$store.getters['libraries/getSortedLibraries']()
|
||||
},
|
||||
libraryScans() {
|
||||
return this.$store.state.scanners.libraryScans
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full pl-2 pr-4 md: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" />
|
||||
<ui-library-icon v-if="!libraryScan" :icon="library.icon" :size="6" font-size="lg md:text-xl" class="text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" />
|
||||
<ui-library-icon v-if="!isScanning" :icon="library.icon" :size="6" font-size="lg md:text-xl" class="text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" />
|
||||
<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>
|
||||
@@ -9,11 +9,14 @@
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<!-- Scan button only shown on desktop -->
|
||||
<ui-btn v-if="!isScanning && !isDeleting" color="bg" class="hidden md:block mx-2 text-xs" :padding-y="1" :padding-x="3" @click.stop="scanBtnClick">{{ this.$strings.ButtonScan }}</ui-btn>
|
||||
|
||||
<!-- Desktop context menu icon -->
|
||||
<ui-context-menu-dropdown v-if="!libraryScan && !isDeleting" :items="contextMenuItems" :icon-class="`text-1.5xl text-gray-${isHovering ? 50 : 400}`" class="!hidden md:!block" @action="contextMenuAction" />
|
||||
<ui-context-menu-dropdown v-if="!isScanning && !isDeleting" :items="contextMenuItems" :icon-class="`text-1.5xl text-gray-${isHovering ? 50 : 400}`" class="!hidden md:!block" @action="contextMenuAction" />
|
||||
|
||||
<!-- Mobile context menu icon -->
|
||||
<span v-if="!libraryScan && !isDeleting" class="!block md:!hidden material-icons text-xl text-gray-300 ml-3 cursor-pointer" @click.stop="showMenu">more_vert</span>
|
||||
<span v-if="!isScanning && !isDeleting" class="!block md:!hidden material-icons text-xl text-gray-300 ml-3 cursor-pointer" @click.stop="showMenu">more_vert</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">
|
||||
@@ -48,8 +51,8 @@ export default {
|
||||
isHovering() {
|
||||
return this.mouseover && !this.dragging
|
||||
},
|
||||
libraryScan() {
|
||||
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
|
||||
isScanning() {
|
||||
return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.library.id)
|
||||
},
|
||||
mediaType() {
|
||||
return this.library.mediaType
|
||||
@@ -71,11 +74,6 @@ export default {
|
||||
text: this.$strings.ButtonScan,
|
||||
action: 'scan',
|
||||
value: 'scan'
|
||||
},
|
||||
{
|
||||
text: this.$strings.ButtonForceReScan,
|
||||
action: 'force-scan',
|
||||
value: 'force-scan'
|
||||
}
|
||||
]
|
||||
if (this.isBookLibrary) {
|
||||
@@ -94,14 +92,17 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
scanBtnClick() {
|
||||
this.scan()
|
||||
},
|
||||
contextMenuAction({ action }) {
|
||||
this.showMobileMenu = false
|
||||
if (action === 'edit') {
|
||||
this.editClick()
|
||||
} else if (action === 'scan') {
|
||||
this.scan()
|
||||
} else if (action === 'force-scan') {
|
||||
this.forceScan()
|
||||
} else if (action === 'force-rescan') {
|
||||
this.scan(true)
|
||||
} else if (action === 'match-books') {
|
||||
this.matchAll()
|
||||
} else if (action === 'delete') {
|
||||
@@ -126,37 +127,17 @@ export default {
|
||||
editClick() {
|
||||
this.$emit('edit', this.library)
|
||||
},
|
||||
scan() {
|
||||
scan(force = false) {
|
||||
this.$store
|
||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id })
|
||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force })
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastLibraryScanStarted)
|
||||
// this.$toast.success(this.$strings.ToastLibraryScanStarted)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to start scan', error)
|
||||
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
|
||||
})
|
||||
},
|
||||
forceScan() {
|
||||
const payload = {
|
||||
message: this.$strings.MessageConfirmForceReScan,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$store
|
||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastLibraryScanStarted)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to start scan', error)
|
||||
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
deleteClick() {
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmDeleteLibrary', [this.library.name]),
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
|
||||
<template v-for="(author, index) in bookAuthors">
|
||||
<nuxt-link :key="author.id" :to="`/author/${author.id}?library=${libraryItem.libraryId}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
|
||||
<nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
|
||||
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">, </span>
|
||||
</template>
|
||||
<nuxt-link v-if="episode" :to="`/item/${libraryItem.id}`" class="truncate hover:underline">{{ mediaMetadata.title }}</nuxt-link>
|
||||
|
||||
@@ -191,6 +191,7 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submit() {},
|
||||
inputUpdate() {
|
||||
clearTimeout(this.searchTimeout)
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
|
||||
<nuxt-link v-if="to" :to="to" class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
|
||||
<slot />
|
||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
@@ -7,7 +7,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click">
|
||||
<button v-else class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click">
|
||||
<slot />
|
||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
@@ -72,23 +72,3 @@ export default {
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 6px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
.btn:hover:not(:disabled)::before {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
button:disabled::before {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,8 @@
|
||||
<label class="flex justify-start items-center" :class="!disabled ? 'cursor-pointer' : ''">
|
||||
<div class="border-2 rounded flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
|
||||
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
|
||||
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
||||
<span v-if="partial" class="material-icons text-base leading-none text-gray-400">remove</span>
|
||||
<svg v-else-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
||||
</div>
|
||||
<div v-if="label" class="select-none" :class="[labelClassname, disabled ? 'text-gray-400' : 'text-gray-100']">{{ label }}</div>
|
||||
</label>
|
||||
@@ -31,7 +32,8 @@ export default {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
disabled: Boolean
|
||||
disabled: Boolean,
|
||||
partial: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
||||
<p class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<button type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
|
||||
@@ -13,7 +13,7 @@
|
||||
</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 sm:text-sm" tabindex="-1" role="listbox">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="listbox">
|
||||
<template v-for="item in itemsToShow">
|
||||
<li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" :id="'listbox-option-' + item.value" role="option" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
@@ -64,7 +64,7 @@ export default {
|
||||
},
|
||||
itemsToShow() {
|
||||
return this.items.map((i) => {
|
||||
if (typeof i === 'string') {
|
||||
if (typeof i === 'string' || typeof i === 'number') {
|
||||
return {
|
||||
text: i,
|
||||
value: i
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<label class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</label>
|
||||
<label v-if="label" class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</label>
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'">
|
||||
<input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||
<input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @focus="inputFocus" @blur="inputBlur" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -48,8 +48,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
isFocused: false,
|
||||
// currentSearch: null,
|
||||
typingTimeout: null,
|
||||
textInput: null
|
||||
}
|
||||
},
|
||||
@@ -83,12 +81,6 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
keydownInput() {
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
// this.currentSearch = this.textInput
|
||||
}, 100)
|
||||
},
|
||||
setFocus() {
|
||||
if (this.$refs.input && this.editable) this.$refs.input.focus()
|
||||
},
|
||||
@@ -133,11 +125,9 @@ export default {
|
||||
if (val && !this.items.includes(val)) {
|
||||
this.$emit('newItem', val)
|
||||
}
|
||||
// this.currentSearch = null
|
||||
},
|
||||
clickedOption(e, item) {
|
||||
this.textInput = null
|
||||
// this.currentSearch = null
|
||||
this.input = item
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
{{ item }}
|
||||
</div>
|
||||
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -50,7 +50,11 @@ export default {
|
||||
label: String,
|
||||
disabled: Boolean,
|
||||
readonly: Boolean,
|
||||
showEdit: Boolean
|
||||
showEdit: Boolean,
|
||||
menuDisabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -77,7 +81,7 @@ export default {
|
||||
}
|
||||
},
|
||||
showMenu() {
|
||||
return this.isFocused
|
||||
return this.isFocused && !this.menuDisabled
|
||||
},
|
||||
wrapperClass() {
|
||||
var classes = []
|
||||
@@ -145,6 +149,31 @@ export default {
|
||||
this.menu.style.left = boundingBox.x + 'px'
|
||||
this.menu.style.width = boundingBox.width + 'px'
|
||||
},
|
||||
inputPaste(evt) {
|
||||
setTimeout(() => {
|
||||
const pastedText = evt.target?.value || ''
|
||||
console.log('Pasted text=', pastedText)
|
||||
const pastedItems = [
|
||||
...new Set(
|
||||
pastedText
|
||||
.split(';')
|
||||
.map((i) => i.trim())
|
||||
.filter((i) => i)
|
||||
)
|
||||
]
|
||||
|
||||
// Filter out items already selected
|
||||
const itemsToAdd = pastedItems.filter((i) => !this.selected.some((_i) => _i.toLowerCase() === i.toLowerCase()))
|
||||
if (pastedItems.length && !itemsToAdd.length) {
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
} else {
|
||||
for (const itemToAdd of itemsToAdd) {
|
||||
this.insertNewItem(itemToAdd)
|
||||
}
|
||||
}
|
||||
}, 10)
|
||||
},
|
||||
inputFocus() {
|
||||
if (!this.menu) {
|
||||
this.unmountMountMenu()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full" v-click-outside="closeMenu">
|
||||
<div class="w-full" v-click-outside="clickOutsideObj">
|
||||
<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>
|
||||
@@ -11,23 +11,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul ref="menu" v-show="showMenu" class="absolute z-60 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>
|
||||
<transition name="menu">
|
||||
<ul ref="menu" v-show="showMenu" class="absolute z-60 -mt-px w-full bg-primary 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>
|
||||
<p class="font-normal ml-3 block truncate">{{ item.text }}</p>
|
||||
|
||||
<div v-if="selected.includes(item.value)" class="text-yellow-400 absolute inset-y-0 right-0 my-auto w-5 h-5 mr-3 overflow-hidden">
|
||||
<span class="material-icons text-xl">checkmark</span>
|
||||
</div>
|
||||
</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">{{ $strings.MessageNoItems }}</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">{{ $strings.MessageNoItems }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</ul>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -48,7 +49,12 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
showMenu: false,
|
||||
menu: null
|
||||
menu: null,
|
||||
clickOutsideObj: {
|
||||
handler: this.closeMenu,
|
||||
events: ['mousedown'],
|
||||
isActive: true
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div v-if="showEdit && !disabled" class="rounded-full cursor-pointer w-6 h-6 mx-0.5 bg-bg flex items-center justify-center">
|
||||
<span class="material-icons text-white hover:text-success pt-px pr-px" style="font-size: 1.1rem" @click.stop="addItem">add</span>
|
||||
</div>
|
||||
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -46,7 +46,7 @@ export default {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
endpoint: String,
|
||||
filterKey: String,
|
||||
label: String,
|
||||
disabled: Boolean,
|
||||
readonly: Boolean,
|
||||
@@ -60,7 +60,6 @@ export default {
|
||||
return {
|
||||
textInput: null,
|
||||
currentSearch: null,
|
||||
searching: false,
|
||||
typingTimeout: null,
|
||||
isFocused: false,
|
||||
menu: null,
|
||||
@@ -97,6 +96,9 @@ export default {
|
||||
},
|
||||
itemsToShow() {
|
||||
return this.items
|
||||
},
|
||||
filterData() {
|
||||
return this.$store.state.libraries.filterData || {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -109,20 +111,16 @@ export default {
|
||||
getIsSelected(itemValue) {
|
||||
return !!this.selected.find((i) => i.id === itemValue)
|
||||
},
|
||||
async search() {
|
||||
if (this.searching) return
|
||||
search() {
|
||||
if (!this.textInput) return
|
||||
this.currentSearch = this.textInput
|
||||
this.searching = true
|
||||
const results = await this.$axios
|
||||
.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15&token=${this.userToken}`)
|
||||
.then((res) => res.results || res)
|
||||
.catch((error) => {
|
||||
console.error('Failed to get search results', error)
|
||||
return []
|
||||
})
|
||||
const dataToSearch = this.filterData[this.filterKey] || []
|
||||
|
||||
const results = dataToSearch.filter((au) => {
|
||||
return au.name.toLowerCase().includes(this.currentSearch.toLowerCase().trim())
|
||||
})
|
||||
|
||||
this.items = results || []
|
||||
this.searching = false
|
||||
},
|
||||
keydownInput() {
|
||||
clearTimeout(this.typingTimeout)
|
||||
@@ -168,6 +166,34 @@ export default {
|
||||
this.menu.style.left = boundingBox.x + 'px'
|
||||
this.menu.style.width = boundingBox.width + 'px'
|
||||
},
|
||||
inputPaste(evt) {
|
||||
setTimeout(() => {
|
||||
const pastedText = evt.target?.value || ''
|
||||
console.log('Pasted text=', pastedText)
|
||||
const pastedItems = [
|
||||
...new Set(
|
||||
pastedText
|
||||
.split(';')
|
||||
.map((i) => i.trim())
|
||||
.filter((i) => i)
|
||||
)
|
||||
]
|
||||
|
||||
// Filter out items already selected
|
||||
const itemsToAdd = pastedItems.filter((i) => !this.selected.some((_i) => _i[this.textKey].toLowerCase() === i.toLowerCase()))
|
||||
if (pastedItems.length && !itemsToAdd.length) {
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
} else {
|
||||
for (const [index, itemToAdd] of itemsToAdd.entries()) {
|
||||
this.insertNewItem({
|
||||
id: `new-${Date.now()}-${index}`,
|
||||
name: itemToAdd
|
||||
})
|
||||
}
|
||||
}
|
||||
}, 10)
|
||||
},
|
||||
inputFocus() {
|
||||
if (!this.menu) {
|
||||
this.unmountMountMenu()
|
||||
|
||||
@@ -68,6 +68,7 @@ export default {
|
||||
methods: {
|
||||
clear() {
|
||||
this.inputValue = ''
|
||||
this.$emit('clear')
|
||||
},
|
||||
focused() {
|
||||
this.isFocused = true
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
<div class="flex flex-wrap mt-2 -mx-1">
|
||||
<div class="w-full md:w-3/4 px-1">
|
||||
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
|
||||
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" :label="$strings.LabelAuthors" endpoint="authors/search" />
|
||||
<!-- Authors filter only contains authors in this library, uses filter data -->
|
||||
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" :label="$strings.LabelAuthors" filter-key="authors" />
|
||||
</div>
|
||||
<div class="flex-grow px-1 mt-2 md:mt-0">
|
||||
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" :label="$strings.LabelPublishYear" />
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<template>
|
||||
<button class="bg-error text-white px-2 py-1 shadow-md" @click="$emit('click', $event)">Cancel</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.Vue-Toastification__close-button.cancel-scan-btn {
|
||||
background-color: rgb(255, 82, 82);
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
opacity: 1;
|
||||
padding: 0px 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: normal;
|
||||
font-family: 'Open Sans';
|
||||
margin-left: 10px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
.Vue-Toastification__close-button.cancel-scan-btn:hover {
|
||||
background-color: rgb(235, 65, 65);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,8 @@
|
||||
<span class="material-icons text-1.5xl" aria-label="Activities" role="button">notifications</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-if="showUnseenSuccessIndicator" class="w-2 h-2 rounded-full bg-success pointer-events-none absolute -top-1 -right-0.5" />
|
||||
<div v-if="showUnseenSuccessIndicator" class="w-2 h-2 rounded-full bg-success/50 pointer-events-none absolute animate-ping -top-1 -right-0.5" />
|
||||
</button>
|
||||
<transition name="menu">
|
||||
<div class="sm:w-80 w-full relative">
|
||||
@@ -46,7 +48,8 @@ export default {
|
||||
isActive: true
|
||||
},
|
||||
showMenu: false,
|
||||
disabled: false
|
||||
disabled: false,
|
||||
tasksSeen: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -60,12 +63,20 @@ export default {
|
||||
// return just the tasks that are running or failed (or show success) in the last 1 minute
|
||||
const tasks = this.tasks.filter((t) => !t.isFinished || ((t.isFailed || t.showSuccess) && t.finishedAt > new Date().getTime() - 1000 * 60)) || []
|
||||
return tasks.sort((a, b) => b.startedAt - a.startedAt)
|
||||
},
|
||||
showUnseenSuccessIndicator() {
|
||||
return this.tasksToShow.some((t) => t.isFinished && !t.isFailed && !this.tasksSeen.includes(t.id))
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickShowMenu() {
|
||||
if (this.disabled) return
|
||||
this.showMenu = !this.showMenu
|
||||
if (this.showMenu) {
|
||||
this.tasksToShow.forEach((t) => {
|
||||
if (!this.tasksSeen.includes(t.id)) this.tasksSeen.push(t.id)
|
||||
})
|
||||
}
|
||||
},
|
||||
clickedOutside() {
|
||||
this.showMenu = false
|
||||
@@ -83,9 +94,20 @@ export default {
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
},
|
||||
taskFinished(task) {
|
||||
// add task as seen if menu is open when it finished
|
||||
if (this.showMenu && !this.tasksSeen.includes(task.id)) {
|
||||
this.tasksSeen.push(task.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
this.$root.socket?.on('task_finished', this.taskFinished)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$root.socket?.off('task_finished', this.taskFinished)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -19,14 +19,13 @@
|
||||
<modals-authors-edit-modal />
|
||||
<modals-batch-quick-match-model />
|
||||
<modals-rssfeed-open-close-modal />
|
||||
<modals-raw-cover-preview-modal />
|
||||
<prompt-confirm />
|
||||
<readers-reader />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CloseButton from '@/components/widgets/CloseButton'
|
||||
|
||||
export default {
|
||||
middleware: 'authenticated',
|
||||
data() {
|
||||
@@ -123,22 +122,6 @@ export default {
|
||||
init(payload) {
|
||||
console.log('Init Payload', payload)
|
||||
|
||||
// Start scans currently running
|
||||
if (payload.librariesScanning) {
|
||||
payload.librariesScanning.forEach((libraryScan) => {
|
||||
this.scanStart(libraryScan)
|
||||
})
|
||||
}
|
||||
|
||||
// Remove any current scans that are no longer running
|
||||
var currentScans = [...this.$store.state.scanners.libraryScans]
|
||||
currentScans.forEach((ls) => {
|
||||
if (!payload.librariesScanning || !payload.librariesScanning.find((_ls) => _ls.id === ls.id)) {
|
||||
this.$toast.dismiss(ls.toastId)
|
||||
this.$store.commit('scanners/remove', ls)
|
||||
}
|
||||
})
|
||||
|
||||
if (payload.usersOnline) {
|
||||
this.$store.commit('users/setUsersOnline', payload.usersOnline)
|
||||
}
|
||||
@@ -228,50 +211,6 @@ export default {
|
||||
this.libraryItemAdded(ab)
|
||||
})
|
||||
},
|
||||
scanComplete(data) {
|
||||
console.log('Scan complete received', data)
|
||||
|
||||
var message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" complete!`
|
||||
if (data.results) {
|
||||
var scanResultMsgs = []
|
||||
var results = data.results
|
||||
if (results.added) scanResultMsgs.push(`${results.added} added`)
|
||||
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
|
||||
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
|
||||
if (results.missing) scanResultMsgs.push(`${results.missing} missing`)
|
||||
if (!scanResultMsgs.length) message += '\nEverything was up to date'
|
||||
else message += '\n' + scanResultMsgs.join('\n')
|
||||
} else {
|
||||
message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" was canceled`
|
||||
}
|
||||
|
||||
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
|
||||
if (existingScan && !isNaN(existingScan.toastId)) {
|
||||
this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: 'success', closeButton: false, onClose: () => null } }, true)
|
||||
} else {
|
||||
this.$toast.success(message, { timeout: 5000 })
|
||||
}
|
||||
|
||||
this.$store.commit('scanners/remove', data)
|
||||
},
|
||||
onScanToastCancel(id) {
|
||||
this.$root.socket.emit('cancel_scan', id)
|
||||
},
|
||||
scanStart(data) {
|
||||
data.toastId = this.$toast(`${data.type === 'match' ? 'Matching' : 'Scanning'} "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, onClose: () => this.onScanToastCancel(data.id) })
|
||||
this.$store.commit('scanners/addUpdate', data)
|
||||
},
|
||||
scanProgress(data) {
|
||||
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
|
||||
if (existingScan && !isNaN(existingScan.toastId)) {
|
||||
data.toastId = existingScan.toastId
|
||||
this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true)
|
||||
} else {
|
||||
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, onClose: () => this.onScanToastCancel(data.id) })
|
||||
}
|
||||
|
||||
this.$store.commit('scanners/addUpdate', data)
|
||||
},
|
||||
taskStarted(task) {
|
||||
console.log('Task started', task)
|
||||
this.$store.commit('tasks/addUpdateTask', task)
|
||||
@@ -343,6 +282,10 @@ export default {
|
||||
}
|
||||
this.$store.commit('libraries/removeCollection', collection)
|
||||
},
|
||||
seriesRemoved({ id, libraryId }) {
|
||||
if (this.currentLibraryId !== libraryId) return
|
||||
this.$store.commit('libraries/removeSeriesFromFilterData', id)
|
||||
},
|
||||
playlistAdded(playlist) {
|
||||
if (playlist.userId !== this.user.id || this.currentLibraryId !== playlist.libraryId) return
|
||||
this.$store.commit('libraries/addUpdateUserPlaylist', playlist)
|
||||
@@ -442,16 +385,14 @@ export default {
|
||||
this.socket.on('collection_updated', this.collectionUpdated)
|
||||
this.socket.on('collection_removed', this.collectionRemoved)
|
||||
|
||||
// Series Listeners
|
||||
this.socket.on('series_removed', this.seriesRemoved)
|
||||
|
||||
// User Playlist Listeners
|
||||
this.socket.on('playlist_added', this.playlistAdded)
|
||||
this.socket.on('playlist_updated', this.playlistUpdated)
|
||||
this.socket.on('playlist_removed', this.playlistRemoved)
|
||||
|
||||
// Scan Listeners
|
||||
this.socket.on('scan_start', this.scanStart)
|
||||
this.socket.on('scan_complete', this.scanComplete)
|
||||
this.socket.on('scan_progress', this.scanProgress)
|
||||
|
||||
// Task Listeners
|
||||
this.socket.on('task_started', this.taskStarted)
|
||||
this.socket.on('task_finished', this.taskFinished)
|
||||
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.3.3",
|
||||
"version": "2.7.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.3.3",
|
||||
"version": "2.7.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.3.3",
|
||||
"version": "2.7.0",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
|
||||
<div class="w-full h-px bg-white/10 my-4" />
|
||||
|
||||
<p v-if="!isGuest" class="mb-4 text-lg">{{ $strings.HeaderChangePassword }}</p>
|
||||
<form v-if="!isGuest" @submit.prevent="submitChangePassword">
|
||||
<p v-if="showChangePasswordForm" class="mb-4 text-lg">{{ $strings.HeaderChangePassword }}</p>
|
||||
<form v-if="showChangePasswordForm" @submit.prevent="submitChangePassword">
|
||||
<ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" :label="$strings.LabelPassword" class="my-2" />
|
||||
<ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" :label="$strings.LabelNewPassword" class="my-2" />
|
||||
<ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" :label="$strings.LabelConfirmPassword" class="my-2" />
|
||||
@@ -68,6 +68,13 @@ export default {
|
||||
},
|
||||
isGuest() {
|
||||
return this.usertype === 'guest'
|
||||
},
|
||||
isPasswordAuthEnabled() {
|
||||
const activeAuthMethods = this.$store.getters['getServerSetting']('authActiveAuthMethods') || []
|
||||
return activeAuthMethods.includes('local')
|
||||
},
|
||||
showChangePasswordForm() {
|
||||
return !this.isGuest && this.isPasswordAuthEnabled
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="flex flex-wrap sm:flex-nowrap justify-center mb-6">
|
||||
<div class="w-48 min-w-48">
|
||||
<div class="w-full h-52">
|
||||
<div class="w-full h-60">
|
||||
<covers-author-image :author="author" rounded="0" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
|
||||
<p v-if="author.description" class="text-white text-opacity-60 uppercase text-xs mb-2">{{ $strings.LabelDescription }}</p>
|
||||
<p class="text-white max-w-3xl text-sm leading-5">{{ author.description }}</p>
|
||||
<p class="text-white max-w-3xl text-sm leading-5 whitespace-pre-wrap">{{ author.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, app, params, redirect, query }) {
|
||||
const author = await app.$axios.$get(`/api/authors/${params.id}?library=${query.library || store.state.libraries.currentLibraryId}&include=items,series`).catch((error) => {
|
||||
const author = await app.$axios.$get(`/api/authors/${params.id}?include=items,series`).catch((error) => {
|
||||
console.error('Failed to get author', error)
|
||||
return null
|
||||
})
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
</div>
|
||||
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.authors" />
|
||||
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
|
||||
<ui-multi-select-query-input ref="authorsSelect" v-model="batchDetails.authors" :disabled="!selectedBatchUsage.authors" :label="$strings.LabelAuthors" endpoint="authors/search" class="mb-4 ml-4" />
|
||||
<!-- Authors filter only contains authors in this library, uses filter data -->
|
||||
<ui-multi-select-query-input ref="authorsSelect" v-model="batchDetails.authors" :disabled="!selectedBatchUsage.authors" :label="$strings.LabelAuthors" filter-key="authors" class="mb-4 ml-4" />
|
||||
</div>
|
||||
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.publishedYear" />
|
||||
|
||||
@@ -55,7 +55,9 @@ export default {
|
||||
else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats
|
||||
else if (pageName === 'users') return this.$strings.HeaderUsers
|
||||
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
|
||||
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
|
||||
else if (pageName === 'email') return this.$strings.HeaderEmail
|
||||
else if (pageName === 'authentication') return this.$strings.HeaderAuthentication
|
||||
}
|
||||
return this.$strings.HeaderSettings
|
||||
}
|
||||
|
||||
270
client/pages/config/authentication.vue
Normal file
270
client/pages/config/authentication.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<div id="authentication-settings">
|
||||
<app-settings-content :header-text="$strings.HeaderAuthentication">
|
||||
<div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25">
|
||||
<div class="flex items-center">
|
||||
<ui-checkbox v-model="enableLocalAuth" checkbox-bg="bg" />
|
||||
<p class="text-lg pl-4">{{ $strings.HeaderPasswordAuthentication }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25">
|
||||
<div class="flex items-center">
|
||||
<ui-checkbox v-model="enableOpenIDAuth" checkbox-bg="bg" />
|
||||
<p class="text-lg pl-4">{{ $strings.HeaderOpenIDConnectAuthentication }}</p>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/oidc_authentication" target="_blank" class="inline-flex">
|
||||
<span class="material-icons text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<transition name="slide">
|
||||
<div v-if="enableOpenIDAuth" class="flex flex-wrap pt-4">
|
||||
<div class="w-full flex items-center mb-2">
|
||||
<div class="flex-grow">
|
||||
<ui-text-input-with-label ref="issuerUrl" v-model="newAuthSettings.authOpenIDIssuerURL" :disabled="savingSettings" :label="'Issuer URL'" />
|
||||
</div>
|
||||
<div class="w-36 mx-1 mt-[1.375rem]">
|
||||
<ui-btn class="h-[2.375rem] text-sm inline-flex items-center justify-center w-full" type="button" :padding-y="0" :padding-x="4" @click.stop="autoPopulateOIDCClick">
|
||||
<span class="material-icons text-base">auto_fix_high</span>
|
||||
<span class="whitespace-nowrap break-keep pl-1">Auto-populate</span></ui-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label ref="authorizationUrl" v-model="newAuthSettings.authOpenIDAuthorizationURL" :disabled="savingSettings" :label="'Authorize URL'" class="mb-2" />
|
||||
|
||||
<ui-text-input-with-label ref="tokenUrl" v-model="newAuthSettings.authOpenIDTokenURL" :disabled="savingSettings" :label="'Token URL'" class="mb-2" />
|
||||
|
||||
<ui-text-input-with-label ref="userInfoUrl" v-model="newAuthSettings.authOpenIDUserInfoURL" :disabled="savingSettings" :label="'Userinfo URL'" class="mb-2" />
|
||||
|
||||
<ui-text-input-with-label ref="jwksUrl" v-model="newAuthSettings.authOpenIDJwksURL" :disabled="savingSettings" :label="'JWKS URL'" class="mb-2" />
|
||||
|
||||
<ui-text-input-with-label ref="logoutUrl" v-model="newAuthSettings.authOpenIDLogoutURL" :disabled="savingSettings" :label="'Logout URL'" class="mb-2" />
|
||||
|
||||
<ui-text-input-with-label ref="openidClientId" v-model="newAuthSettings.authOpenIDClientID" :disabled="savingSettings" :label="'Client ID'" class="mb-2" />
|
||||
|
||||
<ui-text-input-with-label ref="openidClientSecret" v-model="newAuthSettings.authOpenIDClientSecret" :disabled="savingSettings" :label="'Client Secret'" class="mb-2" />
|
||||
|
||||
<ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" />
|
||||
<p class="pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" />
|
||||
|
||||
<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" />
|
||||
|
||||
<div class="flex items-center pt-1 mb-2">
|
||||
<div class="w-44">
|
||||
<ui-dropdown v-model="newAuthSettings.authOpenIDMatchExistingBy" small :items="matchingExistingOptions" :label="$strings.LabelMatchExistingUsersBy" :disabled="savingSettings" />
|
||||
</div>
|
||||
<p class="pl-4 text-sm text-gray-300 mt-5">{{ $strings.LabelMatchExistingUsersByDescription }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-4 px-1">
|
||||
<ui-toggle-switch labeledBy="auto-redirect-toggle" v-model="newAuthSettings.authOpenIDAutoLaunch" :disabled="savingSettings" />
|
||||
<p id="auto-redirect-toggle" class="pl-4 whitespace-nowrap">{{ $strings.LabelAutoLaunch }}</p>
|
||||
<p class="pl-4 text-sm text-gray-300" v-html="$strings.LabelAutoLaunchDescription" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-4 px-1">
|
||||
<ui-toggle-switch labeledBy="auto-register-toggle" v-model="newAuthSettings.authOpenIDAutoRegister" :disabled="savingSettings" />
|
||||
<p id="auto-register-toggle" class="pl-4 whitespace-nowrap">{{ $strings.LabelAutoRegister }}</p>
|
||||
<p class="pl-4 text-sm text-gray-300">{{ $strings.LabelAutoRegisterDescription }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<div class="w-full flex items-center justify-end p-4">
|
||||
<ui-btn color="success" :padding-x="8" small class="text-base" :loading="savingSettings" @click="saveSettings">{{ $strings.ButtonSave }}</ui-btn>
|
||||
</div>
|
||||
</app-settings-content>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, redirect, app }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
return
|
||||
}
|
||||
|
||||
const authSettings = await app.$axios.$get('/api/auth-settings').catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
})
|
||||
if (!authSettings) {
|
||||
redirect('/config')
|
||||
return
|
||||
}
|
||||
return {
|
||||
authSettings
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
enableLocalAuth: false,
|
||||
enableOpenIDAuth: false,
|
||||
savingSettings: false,
|
||||
newAuthSettings: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
authMethods() {
|
||||
return this.authSettings.authActiveAuthMethods || []
|
||||
},
|
||||
matchingExistingOptions() {
|
||||
return [
|
||||
{
|
||||
text: 'Do not match',
|
||||
value: null
|
||||
},
|
||||
{
|
||||
text: 'Match by email',
|
||||
value: 'email'
|
||||
},
|
||||
{
|
||||
text: 'Match by username',
|
||||
value: 'username'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
autoPopulateOIDCClick() {
|
||||
if (!this.newAuthSettings.authOpenIDIssuerURL) {
|
||||
this.$toast.error('Issuer URL required')
|
||||
return
|
||||
}
|
||||
// Remove trailing slash
|
||||
let issuerUrl = this.newAuthSettings.authOpenIDIssuerURL
|
||||
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
|
||||
|
||||
// If the full config path is on the issuer url then remove it
|
||||
if (issuerUrl.endsWith('/.well-known/openid-configuration')) {
|
||||
issuerUrl = issuerUrl.replace('/.well-known/openid-configuration', '')
|
||||
this.newAuthSettings.authOpenIDIssuerURL = this.newAuthSettings.authOpenIDIssuerURL.replace('/.well-known/openid-configuration', '')
|
||||
}
|
||||
|
||||
this.$axios
|
||||
.$get(`/auth/openid/config?issuer=${issuerUrl}`)
|
||||
.then((data) => {
|
||||
if (data.issuer) this.newAuthSettings.authOpenIDIssuerURL = data.issuer
|
||||
if (data.authorization_endpoint) this.newAuthSettings.authOpenIDAuthorizationURL = data.authorization_endpoint
|
||||
if (data.token_endpoint) this.newAuthSettings.authOpenIDTokenURL = data.token_endpoint
|
||||
if (data.userinfo_endpoint) this.newAuthSettings.authOpenIDUserInfoURL = data.userinfo_endpoint
|
||||
if (data.end_session_endpoint) this.newAuthSettings.authOpenIDLogoutURL = data.end_session_endpoint
|
||||
if (data.jwks_uri) this.newAuthSettings.authOpenIDJwksURL = data.jwks_uri
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to receive data', error)
|
||||
const errorMsg = error.response?.data || 'Unknown error'
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
},
|
||||
validateOpenID() {
|
||||
let isValid = true
|
||||
if (!this.newAuthSettings.authOpenIDIssuerURL) {
|
||||
this.$toast.error('Issuer URL required')
|
||||
isValid = false
|
||||
}
|
||||
if (!this.newAuthSettings.authOpenIDAuthorizationURL) {
|
||||
this.$toast.error('Authorize URL required')
|
||||
isValid = false
|
||||
}
|
||||
if (!this.newAuthSettings.authOpenIDTokenURL) {
|
||||
this.$toast.error('Token URL required')
|
||||
isValid = false
|
||||
}
|
||||
if (!this.newAuthSettings.authOpenIDUserInfoURL) {
|
||||
this.$toast.error('Userinfo URL required')
|
||||
isValid = false
|
||||
}
|
||||
if (!this.newAuthSettings.authOpenIDJwksURL) {
|
||||
this.$toast.error('JWKS URL required')
|
||||
isValid = false
|
||||
}
|
||||
if (!this.newAuthSettings.authOpenIDClientID) {
|
||||
this.$toast.error('Client ID required')
|
||||
isValid = false
|
||||
}
|
||||
if (!this.newAuthSettings.authOpenIDClientSecret) {
|
||||
this.$toast.error('Client Secret required')
|
||||
isValid = false
|
||||
}
|
||||
|
||||
function isValidRedirectURI(uri) {
|
||||
// Check for somestring://someother/string
|
||||
const pattern = new RegExp('^\\w+://[\\w\\.-]+$', 'i')
|
||||
return pattern.test(uri)
|
||||
}
|
||||
|
||||
const uris = this.newAuthSettings.authOpenIDMobileRedirectURIs
|
||||
if (uris.includes('*') && uris.length > 1) {
|
||||
this.$toast.error('Mobile Redirect URIs: Asterisk (*) must be the only entry if used')
|
||||
isValid = false
|
||||
} else {
|
||||
uris.forEach((uri) => {
|
||||
if (uri !== '*' && !isValidRedirectURI(uri)) {
|
||||
this.$toast.error(`Mobile Redirect URIs: Invalid URI ${uri}`)
|
||||
isValid = false
|
||||
}
|
||||
})
|
||||
}
|
||||
return isValid
|
||||
},
|
||||
async saveSettings() {
|
||||
if (!this.enableLocalAuth && !this.enableOpenIDAuth) {
|
||||
this.$toast.error('Must have at least one authentication method enabled')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.enableOpenIDAuth && !this.validateOpenID()) {
|
||||
return
|
||||
}
|
||||
|
||||
this.newAuthSettings.authActiveAuthMethods = []
|
||||
if (this.enableLocalAuth) this.newAuthSettings.authActiveAuthMethods.push('local')
|
||||
if (this.enableOpenIDAuth) this.newAuthSettings.authActiveAuthMethods.push('openid')
|
||||
|
||||
this.savingSettings = true
|
||||
this.$axios
|
||||
.$patch('/api/auth-settings', this.newAuthSettings)
|
||||
.then((data) => {
|
||||
this.$store.commit('setServerSettings', data.serverSettings)
|
||||
if (data.updated) {
|
||||
this.$toast.success('Server settings updated')
|
||||
} else {
|
||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.$toast.error('Failed to update server settings')
|
||||
})
|
||||
.finally(() => {
|
||||
this.savingSettings = false
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.newAuthSettings = {
|
||||
...this.authSettings
|
||||
}
|
||||
this.enableLocalAuth = this.authMethods.includes('local')
|
||||
this.enableOpenIDAuth = this.authMethods.includes('openid')
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#authentication-settings code {
|
||||
font-size: 0.8rem;
|
||||
border-radius: 6px;
|
||||
background-color: rgb(82, 82, 82);
|
||||
color: white;
|
||||
padding: 2px 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-settings-content :header-text="$strings.HeaderBackups" :description="$strings.MessageBackupsDescription">
|
||||
<div v-if="backupLocation" class="flex items-center mb-4">
|
||||
<span class="material-icons-outlined text-2xl text-black-50 mr-2">folder</span>
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelBackupLocation }}:</span>
|
||||
<div class="text-gray-100 pl-4">{{ backupLocation }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="enableBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" />
|
||||
<ui-tooltip :text="$strings.LabelBackupsEnableAutomaticBackupsHelp">
|
||||
@@ -11,7 +17,7 @@
|
||||
<div v-if="enableBackups" class="mb-6">
|
||||
<div class="flex items-center pl-6 mb-2">
|
||||
<span class="material-icons-outlined text-2xl text-black-50 mr-2">schedule</span>
|
||||
<div class="w-48">
|
||||
<div class="w-40">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.HeaderSchedule }}:</span>
|
||||
</div>
|
||||
<div class="text-gray-100">{{ scheduleDescription }}</div>
|
||||
@@ -20,7 +26,7 @@
|
||||
|
||||
<div v-if="nextBackupDate" class="flex items-center pl-6 py-0.5 px-2">
|
||||
<span class="material-icons-outlined text-2xl text-black-50 mr-2">event</span>
|
||||
<div class="w-48">
|
||||
<div class="w-40">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNextBackupDate }}:</span>
|
||||
</div>
|
||||
<div class="text-gray-100">{{ nextBackupDate }}</div>
|
||||
@@ -43,7 +49,7 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<tables-backups-table />
|
||||
<tables-backups-table @loaded="backupsLoaded" />
|
||||
|
||||
<modals-backup-schedule-modal v-model="showCronBuilder" :cron-expression.sync="cronExpression" />
|
||||
</app-settings-content>
|
||||
@@ -65,7 +71,8 @@ export default {
|
||||
maxBackupSize: 1,
|
||||
cronExpression: '',
|
||||
newServerSettings: {},
|
||||
showCronBuilder: false
|
||||
showCronBuilder: false,
|
||||
backupLocation: ''
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -98,6 +105,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
backupsLoaded(backupLocation) {
|
||||
this.backupLocation = backupLocation
|
||||
},
|
||||
updateBackupsSettings() {
|
||||
if (isNaN(this.maxBackupSize) || this.maxBackupSize <= 0) {
|
||||
this.$toast.error('Invalid maximum backup size')
|
||||
|
||||
@@ -51,8 +51,14 @@
|
||||
</div>
|
||||
</app-settings-content>
|
||||
|
||||
<app-settings-content :header-text="$strings.HeaderEreaderDevices" showAddButton :description="''" @clicked="addNewDeviceClick">
|
||||
<table v-if="existingEReaderDevices.length" class="tracksTable my-4">
|
||||
<app-settings-content :header-text="$strings.HeaderEreaderDevices" :description="''">
|
||||
<template #header-items>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-btn color="primary" small @click="addNewDeviceClick">{{ $strings.ButtonAddDevice }}</ui-btn>
|
||||
</template>
|
||||
|
||||
<table v-if="existingEReaderDevices.length" class="tracksTable mt-4">
|
||||
<tr>
|
||||
<th class="text-left">{{ $strings.LabelName }}</th>
|
||||
<th class="text-left">{{ $strings.LabelEmail }}</th>
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-if="newServerSettings.sortingIgnorePrefix" class="w-72 ml-14 mb-2">
|
||||
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" :label="$strings.LabelPrefixesToIgnore" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
|
||||
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" :label="$strings.LabelPrefixesToIgnore" @input="sortingPrefixesUpdated" :disabled="savingPrefixes" />
|
||||
<div class="flex justify-end py-1">
|
||||
<ui-btn v-if="hasPrefixesChanged" color="success" :loading="savingPrefixes" small @click="updateSortingPrefixes">Save</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2 mb-2">
|
||||
@@ -44,10 +47,56 @@
|
||||
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
|
||||
</div>
|
||||
|
||||
<div class="w-44 mb-2">
|
||||
<ui-dropdown v-model="newServerSettings.metadataFileFormat" small :items="metadataFileFormats" label="Metadata File Format" @input="updateMetadataFileFormat" :disabled="updatingServerSettings" />
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-parse-subtitles" v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsParseSubtitlesHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-parse-subtitles">{{ $strings.LabelSettingsParseSubtitles }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-find-covers" v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsFindCoversHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-find-covers">{{ $strings.LabelSettingsFindCovers }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
<div v-if="newServerSettings.scannerFindCovers" class="w-44 ml-14 mb-2">
|
||||
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-prefer-matched-metadata" v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-prefer-matched-metadata">{{ $strings.LabelSettingsPreferMatchedMetadata }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-disable-watcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsEnableWatcherHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-disable-watcher">{{ $strings.LabelSettingsEnableWatcher }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsDisplay }}</h2>
|
||||
</div>
|
||||
@@ -85,86 +134,6 @@
|
||||
<div class="py-2">
|
||||
<ui-dropdown :label="$strings.LabelLanguageDefaultServer" ref="langDropdown" v-model="newServerSettings.language" :items="$languageCodeOptions" small class="max-w-52" @input="updateServerLanguage" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-parse-subtitles" v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsParseSubtitlesHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-parse-subtitles">{{ $strings.LabelSettingsParseSubtitles }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-find-covers" v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsFindCoversHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-find-covers">{{ $strings.LabelSettingsFindCovers }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
<div v-if="newServerSettings.scannerFindCovers" class="w-44 ml-14 mb-2">
|
||||
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-overdrive-media-markers" v-model="newServerSettings.scannerPreferOverdriveMediaMarker" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOverdriveMediaMarker', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsOverdriveMediaMarkersHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-overdrive-media-markers">{{ $strings.LabelSettingsOverdriveMediaMarkers }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-prefer-audio-metadata" v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsPreferAudioMetadataHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-prefer-audio-metadata">{{ $strings.LabelSettingsPreferAudioMetadata }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-prefer-opf-metadata" v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsPreferOPFMetadataHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-prefer-opf-metadata">{{ $strings.LabelSettingsPreferOPFMetadata }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-prefer-matched-metadata" v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-prefer-matched-metadata">{{ $strings.LabelSettingsPreferMatchedMetadata }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-disable-watcher" v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsDisableWatcherHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-disable-watcher">{{ $strings.LabelSettingsDisableWatcher }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- old experimental features -->
|
||||
<!-- <div class="pt-4">
|
||||
@@ -259,19 +228,12 @@ export default {
|
||||
updatingServerSettings: false,
|
||||
homepageUseBookshelfView: false,
|
||||
useBookshelfView: false,
|
||||
scannerEnableWatcher: false,
|
||||
isPurgingCache: false,
|
||||
hasPrefixesChanged: false,
|
||||
newServerSettings: {},
|
||||
showConfirmPurgeCache: false,
|
||||
metadataFileFormats: [
|
||||
{
|
||||
text: '.json',
|
||||
value: 'json'
|
||||
},
|
||||
{
|
||||
text: '.abs',
|
||||
value: 'abs'
|
||||
}
|
||||
]
|
||||
savingPrefixes: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -304,15 +266,36 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateSortingPrefixes(val) {
|
||||
if (!val || !val.length) {
|
||||
sortingPrefixesUpdated(val) {
|
||||
const prefixes = [...new Set(val?.map((prefix) => prefix.trim().toLowerCase()) || [])]
|
||||
this.newServerSettings.sortingPrefixes = prefixes
|
||||
const serverPrefixes = this.serverSettings.sortingPrefixes || []
|
||||
this.hasPrefixesChanged = prefixes.some((p) => !serverPrefixes.includes(p)) || serverPrefixes.some((p) => !prefixes.includes(p))
|
||||
},
|
||||
updateSortingPrefixes() {
|
||||
const prefixes = [...new Set(this.newServerSettings.sortingPrefixes.map((prefix) => prefix.trim().toLowerCase()) || [])]
|
||||
if (!prefixes.length) {
|
||||
this.$toast.error('Must have at least 1 prefix')
|
||||
return
|
||||
}
|
||||
var prefixes = val.map((prefix) => prefix.trim().toLowerCase())
|
||||
this.updateServerSettings({
|
||||
sortingPrefixes: prefixes
|
||||
})
|
||||
|
||||
this.savingPrefixes = true
|
||||
this.$axios
|
||||
.$patch(`/api/sorting-prefixes`, { sortingPrefixes: prefixes })
|
||||
.then((data) => {
|
||||
this.$toast.success(`Sorting prefixes updated. ${data.rowsUpdated} rows`)
|
||||
if (data.serverSettings) {
|
||||
this.$store.commit('setServerSettings', data.serverSettings)
|
||||
}
|
||||
this.hasPrefixesChanged = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update prefixes', error)
|
||||
this.$toast.error('Failed to update sorting prefixes')
|
||||
})
|
||||
.finally(() => {
|
||||
this.savingPrefixes = false
|
||||
})
|
||||
},
|
||||
updateScannerCoverProvider(val) {
|
||||
this.updateServerSettings({
|
||||
@@ -332,11 +315,10 @@ export default {
|
||||
updateServerLanguage(val) {
|
||||
this.updateSettingsKey('language', val)
|
||||
},
|
||||
updateMetadataFileFormat(val) {
|
||||
if (this.serverSettings.metadataFileFormat === val) return
|
||||
this.updateSettingsKey('metadataFileFormat', val)
|
||||
},
|
||||
updateSettingsKey(key, val) {
|
||||
if (key === 'scannerDisableWatcher') {
|
||||
this.newServerSettings.scannerDisableWatcher = val
|
||||
}
|
||||
this.updateServerSettings({
|
||||
[key]: val
|
||||
})
|
||||
@@ -363,6 +345,7 @@ export default {
|
||||
initServerSettings() {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
this.newServerSettings.sortingPrefixes = [...(this.newServerSettings.sortingPrefixes || [])]
|
||||
this.scannerEnableWatcher = !this.newServerSettings.scannerDisableWatcher
|
||||
|
||||
this.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL
|
||||
this.useBookshelfView = this.newServerSettings.bookshelfView != this.$constants.BookshelfView.DETAIL
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-settings-content :header-text="$strings.HeaderLibraries" show-add-button @clicked="setShowLibraryModal">
|
||||
<tables-library-libraries-table @showLibraryModal="setShowLibraryModal" />
|
||||
<app-settings-content :header-text="$strings.HeaderLibraries">
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/library_creation" target="_blank" class="inline-flex">
|
||||
<span class="material-icons text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-btn color="primary" small @click="setShowLibraryModal()">{{ $strings.ButtonAddLibrary }}</ui-btn>
|
||||
</template>
|
||||
<tables-library-libraries-table @showLibraryModal="setShowLibraryModal" class="pt-2" />
|
||||
</app-settings-content>
|
||||
<modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" />
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="w-80 my-6 mx-auto">
|
||||
<div v-if="isBookLibrary" class="w-80 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTop10Authors }}</h1>
|
||||
<p v-if="!top10Authors.length">{{ $strings.MessageNoAuthors }}</p>
|
||||
<template v-for="(author, index) in top10Authors">
|
||||
@@ -114,43 +114,49 @@ export default {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
totalItems() {
|
||||
return this.libraryStats ? this.libraryStats.totalItems : 0
|
||||
return this.libraryStats?.totalItems || 0
|
||||
},
|
||||
genresWithCount() {
|
||||
return this.libraryStats ? this.libraryStats.genresWithCount : []
|
||||
return this.libraryStats?.genresWithCount || []
|
||||
},
|
||||
top5Genres() {
|
||||
return this.genresWithCount.slice(0, 5)
|
||||
return this.genresWithCount?.slice(0, 5) || []
|
||||
},
|
||||
top10LongestItems() {
|
||||
return this.libraryStats ? this.libraryStats.longestItems || [] : []
|
||||
return this.libraryStats?.longestItems || []
|
||||
},
|
||||
longestItemDuration() {
|
||||
if (!this.top10LongestItems.length) return 0
|
||||
return this.top10LongestItems[0].duration
|
||||
},
|
||||
top10LargestItems() {
|
||||
return this.libraryStats ? this.libraryStats.largestItems || [] : []
|
||||
return this.libraryStats?.largestItems || []
|
||||
},
|
||||
largestItemSize() {
|
||||
if (!this.top10LargestItems.length) return 0
|
||||
return this.top10LargestItems[0].size
|
||||
},
|
||||
authorsWithCount() {
|
||||
return this.libraryStats ? this.libraryStats.authorsWithCount : []
|
||||
return this.libraryStats?.authorsWithCount || []
|
||||
},
|
||||
mostUsedAuthorCount() {
|
||||
if (!this.authorsWithCount.length) return 0
|
||||
return this.authorsWithCount[0].count
|
||||
},
|
||||
top10Authors() {
|
||||
return this.authorsWithCount.slice(0, 10)
|
||||
return this.authorsWithCount?.slice(0, 10) || []
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
currentLibraryName() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||
},
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isBookLibrary() {
|
||||
return this.currentLibraryMediaType === 'book'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
184
client/pages/config/rss-feeds.vue
Normal file
184
client/pages/config/rss-feeds.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-settings-content :header-text="$strings.HeaderRSSFeeds">
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/rss_feeds" target="_blank" class="inline-flex">
|
||||
<span class="material-icons text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
</template>
|
||||
|
||||
<div v-if="feeds.length" class="block max-w-full pt-2">
|
||||
<table class="rssFeedsTable text-xs">
|
||||
<tr class="bg-primary bg-opacity-40 h-12">
|
||||
<th class="w-16 min-w-16"></th>
|
||||
<th class="w-48 max-w-64 min-w-24 text-left truncate">{{ $strings.LabelTitle }}</th>
|
||||
<th class="w-48 min-w-24 text-left hidden xl:table-cell">{{ $strings.LabelSlug }}</th>
|
||||
<th class="w-24 min-w-16 text-left hidden md:table-cell">{{ $strings.LabelType }}</th>
|
||||
<th class="w-16 min-w-16 text-center">{{ $strings.HeaderEpisodes }}</th>
|
||||
<th class="w-16 min-w-16 text-center hidden lg:table-cell">{{ $strings.LabelRSSFeedPreventIndexing }}</th>
|
||||
<th class="w-48 min-w-24 flex-grow hidden md:table-cell">{{ $strings.LabelLastUpdate }}</th>
|
||||
<th class="w-16 text-left"></th>
|
||||
</tr>
|
||||
|
||||
<tr v-for="feed in feeds" :key="feed.id" class="cursor-pointer h-12" @click="showFeed(feed)">
|
||||
<!-- -->
|
||||
<td>
|
||||
<img :src="coverUrl(feed)" class="h-full w-full" />
|
||||
</td>
|
||||
<!-- -->
|
||||
<td class="w-48 max-w-64 min-w-24 text-left truncate">
|
||||
<p class="truncate">{{ feed.meta.title }}</p>
|
||||
</td>
|
||||
<!-- -->
|
||||
<td class="hidden xl:table-cell">
|
||||
<p class="truncate">{{ feed.slug }}</p>
|
||||
</td>
|
||||
<!-- -->
|
||||
<td class="hidden md:table-cell">
|
||||
<p class="">{{ getEntityType(feed.entityType) }}</p>
|
||||
</td>
|
||||
<!-- -->
|
||||
<td class="text-center">
|
||||
<p class="">{{ feed.episodes.length }}</p>
|
||||
</td>
|
||||
<!-- -->
|
||||
<td class="text-center leading-none hidden lg:table-cell">
|
||||
<p v-if="feed.meta.preventIndexing" class="">
|
||||
<span class="material-icons text-2xl">check</span>
|
||||
</p>
|
||||
</td>
|
||||
<!-- -->
|
||||
<td class="text-center hidden md:table-cell">
|
||||
<ui-tooltip v-if="feed.updatedAt" direction="top" :text="$formatDatetime(feed.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-gray-200">{{ $dateDistanceFromNow(feed.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
<!-- -->
|
||||
<td class="text-center">
|
||||
<ui-icon-btn icon="delete" class="mx-0.5" :size="7" bg-color="error" outlined @click.stop="deleteFeedClick(feed)" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</app-settings-content>
|
||||
<modals-rssfeed-view-feed-modal v-model="showFeedModal" :feed="selectedFeed" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
showFeedModal: false,
|
||||
selectedFeed: null,
|
||||
feeds: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showFeed(feed) {
|
||||
this.selectedFeed = feed
|
||||
this.showFeedModal = true
|
||||
},
|
||||
deleteFeedClick(feed) {
|
||||
const payload = {
|
||||
message: this.$strings.MessageConfirmCloseFeed,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.deleteFeed(feed)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
deleteFeed(feed) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/feeds/${feed.id}/close`)
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastRSSFeedCloseSuccess)
|
||||
this.show = false
|
||||
this.loadFeeds()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to close RSS feed', error)
|
||||
this.$toast.error(this.$strings.ToastRSSFeedCloseFailed)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
getEntityType(entityType) {
|
||||
if (entityType === 'libraryItem') return this.$strings.LabelItem
|
||||
else if (entityType === 'series') return this.$strings.LabelSeries
|
||||
else if (entityType === 'collection') return this.$strings.LabelCollection
|
||||
return this.$strings.LabelUnknown
|
||||
},
|
||||
coverUrl(feed) {
|
||||
if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png`
|
||||
return `${feed.feedUrl}/cover`
|
||||
},
|
||||
async loadFeeds() {
|
||||
const data = await this.$axios.$get(`/api/feeds`).catch((err) => {
|
||||
console.error('Failed to load RSS feeds', err)
|
||||
return null
|
||||
})
|
||||
if (!data) {
|
||||
this.$toast.error('Failed to load RSS feeds')
|
||||
return
|
||||
}
|
||||
this.feeds = data.feeds
|
||||
},
|
||||
init() {
|
||||
this.loadFeeds()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rssFeedsTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border: 1px solid #474747;
|
||||
}
|
||||
|
||||
.rssFeedsTable tr:first-child {
|
||||
background-color: #272727;
|
||||
}
|
||||
|
||||
.rssFeedsTable tr:not(:first-child) {
|
||||
background-color: #373838;
|
||||
}
|
||||
|
||||
.rssFeedsTable tr:not(:first-child):nth-child(odd) {
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
|
||||
.rssFeedsTable tr:hover:not(:first-child) {
|
||||
background-color: #474747;
|
||||
}
|
||||
|
||||
.rssFeedsTable td {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.rssFeedsTable th {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -5,37 +5,72 @@
|
||||
<ui-dropdown v-model="selectedUser" :items="userItems" :label="$strings.LabelFilterByUser" small class="max-w-48" @input="updateUserFilter" />
|
||||
</div>
|
||||
|
||||
<div v-if="listeningSessions.length" class="block max-w-full">
|
||||
<div v-if="listeningSessions.length" class="block max-w-full relative">
|
||||
<table class="userSessionsTable">
|
||||
<tr class="bg-primary bg-opacity-40">
|
||||
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
|
||||
<th class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
|
||||
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
|
||||
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
|
||||
<th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th>
|
||||
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
|
||||
<th class="flex-grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
|
||||
<th class="w-6 min-w-6 text-left hidden md:table-cell h-11">
|
||||
<ui-checkbox v-model="isAllSelected" :partial="numSelected > 0 && !isAllSelected" small checkbox-bg="bg" />
|
||||
</th>
|
||||
<th v-if="numSelected" class="flex-grow text-left" :colspan="7">
|
||||
<div class="flex items-center">
|
||||
<p>{{ $getString('MessageSelected', [numSelected]) }}</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small color="error" :loading="deletingSessions" @click.stop="removeSessionsClick">{{ $strings.ButtonRemove }}</ui-btn>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="flex-grow sm:flex-grow-0 sm:w-48 sm:max-w-48 text-left group cursor-pointer" @click.stop="sortColumn('displayTitle')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelItem }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('displayTitle') }" class="material-icons text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
|
||||
<th v-if="!numSelected" class="w-26 min-w-26 text-left hidden md:table-cell group cursor-pointer" @click.stop="sortColumn('playMethod')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelPlayMethod }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('playMethod') }" class="material-icons text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
|
||||
<th v-if="!numSelected" class="w-24 min-w-24 sm:w-32 sm:min-w-32 group cursor-pointer" @click.stop="sortColumn('timeListening')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelTimeListened }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('timeListening') }" class="material-icons text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="w-24 min-w-24 group cursor-pointer" @click.stop="sortColumn('currentTime')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelLastTime }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('currentTime') }" class="material-icons text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!numSelected" class="flex-grow hidden sm:table-cell cursor-pointer group" @click.stop="sortColumn('updatedAt')">
|
||||
<div class="inline-flex items-center">
|
||||
{{ $strings.LabelLastUpdate }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('updatedAt') }" class="material-icons text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
|
||||
<td class="py-1 max-w-48">
|
||||
<tr v-for="session in listeningSessions" :key="session.id" :class="{ selected: session.selected }" class="cursor-pointer" @click="clickSessionRow(session)">
|
||||
<td class="hidden md:table-cell py-1 max-w-6 relative">
|
||||
<ui-checkbox v-model="session.selected" small checkbox-bg="bg" />
|
||||
<!-- overlay of the checkbox so that the entire box is clickable -->
|
||||
<div class="absolute inset-0 w-full h-full" @click.stop="session.selected = !session.selected" />
|
||||
</td>
|
||||
<td class="py-1 flex-grow sm:flex-grow-0 sm:w-48 sm:max-w-48">
|
||||
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
<td class="hidden md:table-cell w-20 min-w-20">
|
||||
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
|
||||
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
<td class="hidden md:table-cell w-26 min-w-26">
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell">
|
||||
<td class="hidden sm:table-cell w-32 min-w-32">
|
||||
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<td class="text-center w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||
</td>
|
||||
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
|
||||
<td class="text-center hover:underline w-24 min-w-24" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
@@ -45,16 +80,30 @@
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="flex items-center justify-end my-2">
|
||||
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
|
||||
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p>
|
||||
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
|
||||
<!-- table bottom options -->
|
||||
<div class="flex items-center my-2">
|
||||
<div class="flex-grow" />
|
||||
<div class="hidden sm:inline-flex items-center">
|
||||
<p class="text-sm">{{ $strings.LabelRowsPerPage }}</p>
|
||||
<ui-dropdown v-model="itemsPerPage" :items="itemsPerPageOptions" small class="w-24 mx-2" @input="updatedItemsPerPage" />
|
||||
</div>
|
||||
<div class="inline-flex items-center">
|
||||
<p class="text-sm mx-2">Page {{ currentPage + 1 }} of {{ numPages }}</p>
|
||||
<ui-icon-btn icon="arrow_back_ios_new" :size="9" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
|
||||
<ui-icon-btn icon="arrow_forward_ios" :size="9" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="deletingSessions || loading" class="absolute inset-0 w-full h-full flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p>
|
||||
|
||||
<div class="w-full my-8 h-px bg-white/10" />
|
||||
|
||||
<!-- open listening sessions table -->
|
||||
<p v-if="openListeningSessions.length" class="text-lg mb-4 mt-8">Open Listening Sessions</p>
|
||||
<p v-if="openListeningSessions.length" class="text-lg my-4">Open Listening Sessions</p>
|
||||
<div v-if="openListeningSessions.length" class="block max-w-full">
|
||||
<table class="userSessionsTable">
|
||||
<tr class="bg-primary bg-opacity-40">
|
||||
@@ -73,8 +122,7 @@
|
||||
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
|
||||
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
|
||||
<p class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
@@ -127,6 +175,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
showSessionModal: false,
|
||||
selectedSession: null,
|
||||
listeningSessions: [],
|
||||
@@ -137,7 +186,11 @@ export default {
|
||||
itemsPerPage: 10,
|
||||
userFilter: null,
|
||||
selectedUser: '',
|
||||
processingGoToTimestamp: false
|
||||
sortBy: 'updatedAt',
|
||||
sortDesc: true,
|
||||
processingGoToTimestamp: false,
|
||||
deletingSessions: false,
|
||||
itemsPerPageOptions: [10, 25, 50, 100]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -153,17 +206,93 @@ export default {
|
||||
},
|
||||
filteredUserUsername() {
|
||||
if (!this.userFilter) return null
|
||||
var user = this.users.find((u) => u.id === this.userFilter)
|
||||
return user ? user.username : null
|
||||
const user = this.users.find((u) => u.id === this.userFilter)
|
||||
return user?.username || null
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
},
|
||||
numSelected() {
|
||||
return this.listeningSessions.filter((s) => s.selected).length
|
||||
},
|
||||
isAllSelected: {
|
||||
get() {
|
||||
return this.numSelected === this.listeningSessions.length
|
||||
},
|
||||
set(val) {
|
||||
this.setSelectionForAll(val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isSortSelected(column) {
|
||||
return this.sortBy === column
|
||||
},
|
||||
sortColumn(column) {
|
||||
if (this.sortBy === column) {
|
||||
this.sortDesc = !this.sortDesc
|
||||
} else {
|
||||
this.sortBy = column
|
||||
}
|
||||
this.loadSessions(this.currentPage)
|
||||
},
|
||||
removeSelectedSessions() {
|
||||
if (!this.numSelected) return
|
||||
this.deletingSessions = true
|
||||
|
||||
let isAllSessions = this.isAllSelected
|
||||
const payload = {
|
||||
sessions: this.listeningSessions.filter((s) => s.selected).map((s) => s.id)
|
||||
}
|
||||
this.$axios
|
||||
.$post(`/api/sessions/batch/delete`, payload)
|
||||
.then(() => {
|
||||
this.$toast.success('Sessions removed')
|
||||
if (isAllSessions) {
|
||||
// If all sessions were removed from the current page then go to the previous page
|
||||
if (this.currentPage > 0) {
|
||||
this.currentPage--
|
||||
}
|
||||
this.loadSessions(this.currentPage)
|
||||
} else {
|
||||
// Filter out the deleted sessions
|
||||
this.listeningSessions = this.listeningSessions.filter((ls) => !payload.sessions.includes(ls.id))
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = error.response?.data || 'Failed to remove sessions'
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.deletingSessions = false
|
||||
})
|
||||
},
|
||||
removeSessionsClick() {
|
||||
if (!this.numSelected) return
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmRemoveListeningSessions', [this.numSelected]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.removeSelectedSessions()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
setSelectionForAll(val) {
|
||||
this.listeningSessions = this.listeningSessions.map((s) => {
|
||||
s.selected = val
|
||||
return s
|
||||
})
|
||||
},
|
||||
updatedItemsPerPage() {
|
||||
this.currentPage = 0
|
||||
this.loadSessions(this.currentPage)
|
||||
},
|
||||
closedSession() {
|
||||
this.loadOpenSessions()
|
||||
},
|
||||
@@ -251,6 +380,13 @@ export default {
|
||||
nextPage() {
|
||||
this.loadSessions(this.currentPage + 1)
|
||||
},
|
||||
clickSessionRow(session) {
|
||||
if (this.numSelected > 0) {
|
||||
session.selected = !session.selected
|
||||
} else {
|
||||
this.showSession(session)
|
||||
}
|
||||
},
|
||||
showSession(session) {
|
||||
this.selectedSession = session
|
||||
this.showSessionModal = true
|
||||
@@ -273,11 +409,21 @@ export default {
|
||||
return 'Unknown'
|
||||
},
|
||||
async loadSessions(page) {
|
||||
var userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : ''
|
||||
const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=${this.itemsPerPage}${userFilterQuery}`).catch((err) => {
|
||||
this.loading = true
|
||||
const urlSearchParams = new URLSearchParams()
|
||||
urlSearchParams.set('page', page)
|
||||
urlSearchParams.set('itemsPerPage', this.itemsPerPage)
|
||||
urlSearchParams.set('sort', this.sortBy)
|
||||
urlSearchParams.set('desc', this.sortDesc ? '1' : '0')
|
||||
if (this.selectedUser) {
|
||||
urlSearchParams.set('user', this.selectedUser)
|
||||
}
|
||||
|
||||
const data = await this.$axios.$get(`/api/sessions?${urlSearchParams.toString()}`).catch((err) => {
|
||||
console.error('Failed to load listening sessions', err)
|
||||
return null
|
||||
})
|
||||
this.loading = false
|
||||
if (!data) {
|
||||
this.$toast.error('Failed to load listening sessions')
|
||||
return
|
||||
@@ -286,8 +432,13 @@ export default {
|
||||
this.numPages = data.numPages
|
||||
this.total = data.total
|
||||
this.currentPage = data.page
|
||||
this.listeningSessions = data.sessions
|
||||
this.userFilter = data.userFilter
|
||||
this.listeningSessions = data.sessions.map((ls) => {
|
||||
return {
|
||||
...ls,
|
||||
selected: false
|
||||
}
|
||||
})
|
||||
this.userFilter = data.userId
|
||||
},
|
||||
async loadOpenSessions() {
|
||||
const data = await this.$axios.$get('/api/sessions/open').catch((err) => {
|
||||
@@ -325,15 +476,18 @@ export default {
|
||||
.userSessionsTable tr:first-child {
|
||||
background-color: #272727;
|
||||
}
|
||||
.userSessionsTable tr:not(:first-child) {
|
||||
.userSessionsTable tr:not(:first-child):not(.selected) {
|
||||
background-color: #373838;
|
||||
}
|
||||
.userSessionsTable tr:not(:first-child):nth-child(odd) {
|
||||
.userSessionsTable tr:not(:first-child):nth-child(odd):not(.selected):not(:hover) {
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.userSessionsTable tr:hover:not(:first-child) {
|
||||
background-color: #474747;
|
||||
}
|
||||
.userSessionsTable tr.selected {
|
||||
background-color: #474747;
|
||||
}
|
||||
.userSessionsTable td {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-settings-content :header-text="$strings.HeaderYourStats">
|
||||
<!-- Year in review banner shown at the top in December and January -->
|
||||
<stats-year-in-review-banner v-if="showYearInReviewBanner" />
|
||||
|
||||
<app-settings-content :header-text="$strings.HeaderYourStats" class="!mb-4">
|
||||
<div class="flex justify-center">
|
||||
<div class="flex p-2">
|
||||
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
|
||||
@@ -63,6 +66,9 @@
|
||||
</div>
|
||||
<stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
|
||||
</app-settings-content>
|
||||
|
||||
<!-- Year in review banner shown at the bottom Feb - Nov -->
|
||||
<stats-year-in-review-banner v-if="!showYearInReviewBanner" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -71,7 +77,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
listeningStats: null,
|
||||
windowWidth: 0
|
||||
windowWidth: 0,
|
||||
showYearInReviewBanner: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -119,6 +126,12 @@ export default {
|
||||
console.error('Failed to load listening sesions', err)
|
||||
return []
|
||||
})
|
||||
|
||||
let month = new Date().getMonth()
|
||||
// January and December show year in review banner
|
||||
if (month === 11 || month === 0) {
|
||||
this.showYearInReviewBanner = true
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<div class="py-2">
|
||||
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">{{ $strings.HeaderSavedMediaProgress }}</h1>
|
||||
|
||||
<table v-if="mediaProgressWithMedia.length" class="userAudiobooksTable">
|
||||
<table v-if="mediaProgress.length" class="userAudiobooksTable">
|
||||
<tr class="bg-primary bg-opacity-40">
|
||||
<th class="w-16 text-left">{{ $strings.LabelItem }}</th>
|
||||
<th class="text-left"></th>
|
||||
@@ -55,19 +55,14 @@
|
||||
<th class="w-40 hidden sm:table-cell">{{ $strings.LabelStartedAt }}</th>
|
||||
<th class="w-40 hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
|
||||
</tr>
|
||||
<tr v-for="item in mediaProgressWithMedia" :key="item.id" :class="!item.isFinished ? '' : 'isFinished'">
|
||||
<tr v-for="item in mediaProgress" :key="item.id" :class="!item.isFinished ? '' : 'isFinished'">
|
||||
<td>
|
||||
<covers-book-cover :width="50" :library-item="item" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-preview-cover v-if="item.coverPath" :width="50" :src="$store.getters['globals/getLibraryItemCoverSrcById'](item.libraryItemId, item.mediaUpdatedAt)" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" />
|
||||
<div v-else class="bg-primary flex items-center justify-center text-center text-xs text-gray-400 p-1" :style="{ width: '50px', height: 50 * bookCoverAspectRatio + 'px' }">No Cover</div>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="item.media && item.media.metadata && item.episode">
|
||||
<p>{{ item.episode.title || 'Unknown' }}</p>
|
||||
<p class="text-white text-opacity-50 text-sm font-sans">{{ item.media.metadata.title }}</p>
|
||||
</template>
|
||||
<template v-else-if="item.media && item.media.metadata">
|
||||
<p>{{ item.media.metadata.title || 'Unknown' }}</p>
|
||||
<p v-if="item.media.metadata.authorName" class="text-white text-opacity-50 text-sm font-sans">by {{ item.media.metadata.authorName }}</p>
|
||||
</template>
|
||||
<p>{{ item.displayTitle || 'Unknown' }}</p>
|
||||
<p v-if="item.displaySubtitle" class="text-white text-opacity-50 text-sm font-sans">{{ item.displaySubtitle }}</p>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<p class="text-sm">{{ Math.floor(item.progress * 100) }}%</p>
|
||||
@@ -124,9 +119,6 @@ export default {
|
||||
mediaProgress() {
|
||||
return this.user.mediaProgress.sort((a, b) => b.lastUpdate - a.lastUpdate)
|
||||
},
|
||||
mediaProgressWithMedia() {
|
||||
return this.mediaProgress.filter((mp) => mp.media)
|
||||
},
|
||||
totalListeningTime() {
|
||||
return this.listeningStats.totalTime || 0
|
||||
},
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-settings-content :header-text="$strings.HeaderUsers" show-add-button @clicked="setShowUserModal">
|
||||
<tables-users-table />
|
||||
<app-settings-content :header-text="$strings.HeaderUsers">
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex">
|
||||
<span class="material-icons text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-btn color="primary" small @click="setShowUserModal()">{{ $strings.ButtonAddUser }}</ui-btn>
|
||||
</template>
|
||||
|
||||
<tables-users-table class="pt-2" @edit="setShowUserModal" />
|
||||
</app-settings-content>
|
||||
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
|
||||
</div>
|
||||
|
||||
@@ -3,21 +3,21 @@
|
||||
<div class="w-full h-full overflow-y-auto px-2 py-6 lg:p-8">
|
||||
<div class="flex flex-col lg:flex-row max-w-6xl mx-auto">
|
||||
<div class="w-full flex justify-center lg:block lg:w-52" style="min-width: 208px">
|
||||
<div class="relative" style="height: fit-content">
|
||||
<covers-book-cover :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="relative group" style="height: fit-content">
|
||||
<covers-book-cover class="relative group-hover:brightness-75 transition cursor-pointer" expand-on-click :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
|
||||
<!-- Item Progress Bar -->
|
||||
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1.5 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div>
|
||||
|
||||
<!-- Item Cover Overlay -->
|
||||
<div class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-30 opacity-0 hover:opacity-100 transition-opacity" @mousedown.prevent @mouseup.prevent>
|
||||
<div class="absolute top-0 left-0 w-full h-full z-10 opacity-0 group-hover:opacity-100 pointer-events-none">
|
||||
<div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="playItem">
|
||||
<span class="material-icons text-4xl">play_circle_filled</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="absolute bottom-2.5 right-2.5 z-10 material-icons text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200" @click="showEditCover">edit</span>
|
||||
<span class="absolute bottom-2.5 right-2.5 z-10 material-icons text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" @click="showEditCover">edit</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,7 +42,7 @@
|
||||
<nuxt-link v-for="(artist, index) in musicArtists" :key="index" :to="`/artist/${$encode(artist)}`" class="hover:underline">{{ artist }}<span v-if="index < musicArtists.length - 1">, </span></nuxt-link>
|
||||
</p>
|
||||
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
|
||||
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}?library=${libraryItem.libraryId}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</p>
|
||||
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
||||
</template>
|
||||
@@ -124,7 +124,7 @@
|
||||
</ui-context-menu-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="my-4 max-w-2xl">
|
||||
<div class="my-4 w-full">
|
||||
<p class="text-base text-gray-100 whitespace-pre-line">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
@@ -160,7 +160,7 @@ export default {
|
||||
}
|
||||
|
||||
// Include episode downloads for podcasts
|
||||
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=authors,downloads,rssfeed`).catch((error) => {
|
||||
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=downloads,rssfeed`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
@@ -682,13 +682,15 @@ export default {
|
||||
},
|
||||
deleteLibraryItem() {
|
||||
const payload = {
|
||||
message: 'This will delete the library item from the database and your file system. Are you sure?',
|
||||
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
|
||||
message: this.$strings.MessageConfirmDeleteLibraryItem,
|
||||
checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox,
|
||||
yesButtonText: this.$strings.ButtonDelete,
|
||||
yesButtonColor: 'error',
|
||||
checkboxDefaultValue: true,
|
||||
checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0),
|
||||
callback: (confirmed, hardDelete) => {
|
||||
if (confirmed) {
|
||||
localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)
|
||||
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
|
||||
.then(() => {
|
||||
@@ -761,6 +763,7 @@ export default {
|
||||
if (this.libraryId) {
|
||||
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
|
||||
}
|
||||
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
|
||||
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
|
||||
@@ -769,6 +772,7 @@ export default {
|
||||
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
|
||||
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
|
||||
|
||||
@@ -45,6 +45,11 @@
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ params, query, store, app, redirect }) {
|
||||
// Podcast search/add page is restricted to admins
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
return redirect(`/library/${params.library}`)
|
||||
}
|
||||
|
||||
var libraryId = params.library
|
||||
var libraryData = await store.dispatch('libraries/fetch', libraryId)
|
||||
if (!libraryData) {
|
||||
|
||||
@@ -25,9 +25,12 @@
|
||||
</div>
|
||||
<div v-else-if="isInit" class="w-full max-w-md px-8 pb-8 pt-4 -mt-40">
|
||||
<p class="text-3xl text-white text-center mb-4">{{ $strings.HeaderLogin }}</p>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<p v-if="error" class="text-error text-center py-2">{{ error }}</p>
|
||||
<form @submit.prevent="submitForm">
|
||||
|
||||
<form v-show="login_local" @submit.prevent="submitForm">
|
||||
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label>
|
||||
<ui-text-input v-model="username" :disabled="processing" class="mb-3 w-full" />
|
||||
|
||||
@@ -37,6 +40,14 @@
|
||||
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="login_local && login_openid" class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<div class="w-full flex py-3">
|
||||
<a v-if="login_openid" :href="openidAuthUri" class="w-full abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center bg-primary text-white px-8 py-2 leading-none">
|
||||
{{ openIDButtonText }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,7 +71,10 @@ export default {
|
||||
},
|
||||
confirmPassword: '',
|
||||
ConfigPath: '',
|
||||
MetadataPath: ''
|
||||
MetadataPath: '',
|
||||
login_local: true,
|
||||
login_openid: false,
|
||||
authFormData: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -93,6 +107,12 @@ export default {
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
openidAuthUri() {
|
||||
return `${process.env.serverUrl}/auth/openid?callback=${location.href.split('?').shift()}`
|
||||
},
|
||||
openIDButtonText() {
|
||||
return this.authFormData?.authOpenIDButtonText || 'Login with OpenId'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -162,6 +182,7 @@ export default {
|
||||
else this.error = 'Unknown Error'
|
||||
return false
|
||||
})
|
||||
|
||||
if (authRes?.error) {
|
||||
this.error = authRes.error
|
||||
} else if (authRes) {
|
||||
@@ -196,28 +217,62 @@ export default {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$get('/status')
|
||||
.then((res) => {
|
||||
this.processing = false
|
||||
this.isInit = res.isInit
|
||||
this.showInitScreen = !res.isInit
|
||||
this.$setServerLanguageCode(res.language)
|
||||
.then((data) => {
|
||||
this.isInit = data.isInit
|
||||
this.showInitScreen = !data.isInit
|
||||
this.$setServerLanguageCode(data.language)
|
||||
if (this.showInitScreen) {
|
||||
this.ConfigPath = res.ConfigPath || ''
|
||||
this.MetadataPath = res.MetadataPath || ''
|
||||
this.ConfigPath = data.ConfigPath || ''
|
||||
this.MetadataPath = data.MetadataPath || ''
|
||||
} else {
|
||||
this.authFormData = data.authFormData
|
||||
this.updateLoginVisibility(data.authMethods || [])
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Status check failed', error)
|
||||
this.processing = false
|
||||
this.criticalError = 'Status check failed'
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
updateLoginVisibility(authMethods) {
|
||||
if (this.$route.query?.error) {
|
||||
this.error = this.$route.query.error
|
||||
|
||||
// Remove error query string
|
||||
const newurl = new URL(location.href)
|
||||
newurl.searchParams.delete('error')
|
||||
window.history.replaceState({ path: newurl.href }, '', newurl.href)
|
||||
}
|
||||
|
||||
if (authMethods.includes('local') || !authMethods.length) {
|
||||
this.login_local = true
|
||||
} else {
|
||||
this.login_local = false
|
||||
}
|
||||
|
||||
if (authMethods.includes('openid')) {
|
||||
// Auto redirect unless query string ?autoLaunch=0
|
||||
if (this.authFormData?.authOpenIDAutoLaunch && this.$route.query?.autoLaunch !== '0') {
|
||||
window.location.href = this.openidAuthUri
|
||||
}
|
||||
|
||||
this.login_openid = true
|
||||
} else {
|
||||
this.login_openid = false
|
||||
}
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
if (localStorage.getItem('token')) {
|
||||
var userfound = await this.checkAuth()
|
||||
if (userfound) return // if valid user no need to check status
|
||||
if (this.$route.query?.setToken) {
|
||||
localStorage.setItem('token', this.$route.query.setToken)
|
||||
}
|
||||
if (localStorage.getItem('token')) {
|
||||
if (await this.checkAuth()) return // if valid user no need to check status
|
||||
}
|
||||
|
||||
this.checkStatus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!selectedLibraryIsPodcast" class="flex items-center mb-6">
|
||||
<label class="flex cursor-pointer pt-4">
|
||||
<ui-toggle-switch v-model="fetchMetadata.enabled" class="inline-flex" />
|
||||
<span class="pl-2 text-base">{{ $strings.LabelAutoFetchMetadata }}</span>
|
||||
</label>
|
||||
<ui-tooltip :text="$strings.LabelAutoFetchMetadataHelp" class="inline-flex pt-4">
|
||||
<span class="pl-1 material-icons icon-text text-sm cursor-pointer">info_outlined</span>
|
||||
</ui-tooltip>
|
||||
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-dropdown v-model="fetchMetadata.provider" :items="providers" :label="$strings.LabelProvider" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<widgets-alert v-if="error" type="error">
|
||||
<p class="text-lg">{{ error }}</p>
|
||||
</widgets-alert>
|
||||
@@ -61,9 +75,7 @@
|
||||
</widgets-alert>
|
||||
|
||||
<!-- Item Upload cards -->
|
||||
<template v-for="item in items">
|
||||
<cards-item-upload-card :ref="`itemCard-${item.index}`" :key="item.index" :media-type="selectedLibraryMediaType" :item="item" :processing="processing" @remove="removeItem(item)" />
|
||||
</template>
|
||||
<cards-item-upload-card v-for="item in items" :key="item.index" :ref="`itemCard-${item.index}`" :media-type="selectedLibraryMediaType" :item="item" :provider="fetchMetadata.provider" :processing="processing" @remove="removeItem(item)" />
|
||||
|
||||
<!-- Upload/Reset btns -->
|
||||
<div v-show="items.length" class="flex justify-end pb-8 pt-4">
|
||||
@@ -92,13 +104,18 @@ export default {
|
||||
selectedLibraryId: null,
|
||||
selectedFolderId: null,
|
||||
processing: false,
|
||||
uploadFinished: false
|
||||
uploadFinished: false,
|
||||
fetchMetadata: {
|
||||
enabled: false,
|
||||
provider: null
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
selectedLibrary(newVal) {
|
||||
if (newVal && !this.selectedFolderId) {
|
||||
this.setDefaultFolder()
|
||||
this.setMetadataProvider()
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -133,6 +150,13 @@ export default {
|
||||
selectedLibraryIsPodcast() {
|
||||
return this.selectedLibraryMediaType === 'podcast'
|
||||
},
|
||||
providers() {
|
||||
if (this.selectedLibraryIsPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
},
|
||||
canFetchMetadata() {
|
||||
return !this.selectedLibraryIsPodcast && this.fetchMetadata.enabled
|
||||
},
|
||||
selectedFolder() {
|
||||
if (!this.selectedLibrary) return null
|
||||
return this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId)
|
||||
@@ -160,12 +184,16 @@ export default {
|
||||
}
|
||||
}
|
||||
this.setDefaultFolder()
|
||||
this.setMetadataProvider()
|
||||
},
|
||||
setDefaultFolder() {
|
||||
if (!this.selectedFolderId && this.selectedLibrary && this.selectedLibrary.folders.length) {
|
||||
this.selectedFolderId = this.selectedLibrary.folders[0].id
|
||||
}
|
||||
},
|
||||
setMetadataProvider() {
|
||||
this.fetchMetadata.provider ||= this.$store.getters['libraries/getLibraryProvider'](this.selectedLibraryId)
|
||||
},
|
||||
removeItem(item) {
|
||||
this.items = this.items.filter((b) => b.index !== item.index)
|
||||
if (!this.items.length) {
|
||||
@@ -213,27 +241,49 @@ export default {
|
||||
var items = e.dataTransfer.items || []
|
||||
|
||||
var itemResults = await this.uploadHelpers.getItemsFromDrop(items, this.selectedLibraryMediaType)
|
||||
this.setResults(itemResults)
|
||||
this.onItemsSelected(itemResults)
|
||||
},
|
||||
inputChanged(e) {
|
||||
if (!e.target || !e.target.files) return
|
||||
var _files = Array.from(e.target.files)
|
||||
if (_files && _files.length) {
|
||||
var itemResults = this.uploadHelpers.getItemsFromPicker(_files, this.selectedLibraryMediaType)
|
||||
this.setResults(itemResults)
|
||||
this.onItemsSelected(itemResults)
|
||||
}
|
||||
},
|
||||
setResults(itemResults) {
|
||||
onItemsSelected(itemResults) {
|
||||
if (this.itemSelectionSuccessful(itemResults)) {
|
||||
// setTimeout ensures the new item ref is attached before this method is called
|
||||
setTimeout(this.attemptMetadataFetch, 0)
|
||||
}
|
||||
},
|
||||
itemSelectionSuccessful(itemResults) {
|
||||
console.log('Upload results', itemResults)
|
||||
|
||||
if (itemResults.error) {
|
||||
this.error = itemResults.error
|
||||
this.items = []
|
||||
this.ignoredFiles = []
|
||||
} else {
|
||||
this.error = ''
|
||||
this.items = itemResults.items
|
||||
this.ignoredFiles = itemResults.ignoredFiles
|
||||
return false
|
||||
}
|
||||
console.log('Upload results', itemResults)
|
||||
|
||||
this.error = ''
|
||||
this.items = itemResults.items
|
||||
this.ignoredFiles = itemResults.ignoredFiles
|
||||
return true
|
||||
},
|
||||
attemptMetadataFetch() {
|
||||
if (!this.canFetchMetadata) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.items.forEach((item) => {
|
||||
let itemRef = this.$refs[`itemCard-${item.index}`]
|
||||
|
||||
if (itemRef?.length) {
|
||||
itemRef[0].fetchMetadata(this.fetchMetadata.provider)
|
||||
}
|
||||
})
|
||||
},
|
||||
updateItemCardStatus(index, status) {
|
||||
var ref = this.$refs[`itemCard-${index}`]
|
||||
@@ -248,8 +298,8 @@ export default {
|
||||
var form = new FormData()
|
||||
form.set('title', item.title)
|
||||
if (!this.selectedLibraryIsPodcast) {
|
||||
form.set('author', item.author)
|
||||
form.set('series', item.series)
|
||||
form.set('author', item.author || '')
|
||||
form.set('series', item.series || '')
|
||||
}
|
||||
form.set('library', this.selectedLibraryId)
|
||||
form.set('folder', this.selectedFolderId)
|
||||
@@ -346,6 +396,8 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.selectedLibraryId = this.$store.state.libraries.currentLibraryId
|
||||
this.setMetadataProvider()
|
||||
|
||||
this.setDefaultFolder()
|
||||
window.addEventListener('dragenter', this.dragenter)
|
||||
window.addEventListener('dragleave', this.dragleave)
|
||||
@@ -359,4 +411,4 @@ export default {
|
||||
window.removeEventListener('drop', this.drop)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -5,15 +5,20 @@ import { supplant } from './utils'
|
||||
const defaultCode = 'en-us'
|
||||
|
||||
const languageCodeMap = {
|
||||
'cs': { label: 'Čeština', dateFnsLocale: 'cs' },
|
||||
'da': { label: 'Dansk', dateFnsLocale: 'da' },
|
||||
'de': { label: 'Deutsch', dateFnsLocale: 'de' },
|
||||
'en-us': { label: 'English', dateFnsLocale: 'enUS' },
|
||||
'es': { label: 'Español', dateFnsLocale: 'es' },
|
||||
'fr': { label: 'Français', dateFnsLocale: 'fr' },
|
||||
'hr': { label: 'Hrvatski', dateFnsLocale: 'hr' },
|
||||
'it': { label: 'Italiano', dateFnsLocale: 'it' },
|
||||
'lt': { label: 'Lietuvių', dateFnsLocale: 'lt' },
|
||||
'nl': { label: 'Nederlands', dateFnsLocale: 'nl' },
|
||||
'no': { label: 'Norsk', dateFnsLocale: 'no' },
|
||||
'pl': { label: 'Polski', dateFnsLocale: 'pl' },
|
||||
'ru': { label: 'Русский', dateFnsLocale: 'ru' },
|
||||
'sv': { label: 'Svenska', dateFnsLocale: 'sv' },
|
||||
'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' },
|
||||
}
|
||||
Vue.prototype.$languageCodeOptions = Object.keys(languageCodeMap).map(code => {
|
||||
|
||||
@@ -77,6 +77,7 @@ Vue.prototype.$sanitizeFilename = (filename, colonReplacement = ' - ') => {
|
||||
.replace(lineBreaks, replacement)
|
||||
.replace(windowsReservedRe, replacement)
|
||||
.replace(windowsTrailingRe, replacement)
|
||||
.replace(/\s+/g, ' ') // Replace consecutive spaces with a single space
|
||||
|
||||
// Check if basename is too many bytes
|
||||
const ext = Path.extname(sanitized) // separate out file extension
|
||||
|
||||
@@ -54,7 +54,7 @@ Vue.prototype.$secondsToTimestamp = (seconds, includeMs = false, alwaysIncludeHo
|
||||
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}${msString}`
|
||||
}
|
||||
|
||||
Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true) => {
|
||||
Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true, showSeconds = true) => {
|
||||
if (isNaN(seconds) || seconds === null) return ''
|
||||
seconds = Math.round(seconds)
|
||||
|
||||
@@ -69,11 +69,16 @@ Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true) => {
|
||||
hours -= days * 24
|
||||
}
|
||||
|
||||
// If not showing seconds then round minutes up
|
||||
if (minutes && seconds && !showSeconds) {
|
||||
if (seconds >= 30) minutes++
|
||||
}
|
||||
|
||||
const strs = []
|
||||
if (days) strs.push(`${days}d`)
|
||||
if (hours) strs.push(`${hours}h`)
|
||||
if (minutes) strs.push(`${minutes}m`)
|
||||
if (seconds) strs.push(`${seconds}s`)
|
||||
if (seconds && showSeconds) strs.push(`${seconds}s`)
|
||||
return strs.join(' ')
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export const state = () => ({
|
||||
showViewPodcastEpisodeModal: false,
|
||||
showRSSFeedOpenCloseModal: false,
|
||||
showConfirmPrompt: false,
|
||||
showRawCoverPreviewModal: false,
|
||||
confirmPromptOptions: null,
|
||||
showEditAuthorModal: false,
|
||||
rssFeedEntity: null,
|
||||
@@ -20,6 +21,7 @@ export const state = () => ({
|
||||
selectedCollection: null,
|
||||
selectedAuthor: null,
|
||||
selectedMediaItems: [],
|
||||
selectedLibraryItemId: null,
|
||||
isCasting: false, // Actively casting
|
||||
isChromecastInitialized: false, // Script loadeds
|
||||
showBatchQuickMatchModal: false,
|
||||
@@ -80,7 +82,7 @@ export const state = () => ({
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getLibraryItemCoverSrc: (state, getters, rootState, rootGetters) => (libraryItem, placeholder = null) => {
|
||||
getLibraryItemCoverSrc: (state, getters, rootState, rootGetters) => (libraryItem, placeholder = null, raw = false) => {
|
||||
if (!placeholder) placeholder = `${rootState.routerBasePath}/book_placeholder.jpg`
|
||||
if (!libraryItem) return placeholder
|
||||
const media = libraryItem.media
|
||||
@@ -94,7 +96,7 @@ export const getters = {
|
||||
const libraryItemId = libraryItem.libraryItemId || libraryItem.id // Workaround for /users/:id page showing media progress covers
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') { // Testing
|
||||
return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}`
|
||||
return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}${raw ? '&raw=1' : ''}`
|
||||
}
|
||||
|
||||
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}`
|
||||
@@ -156,6 +158,13 @@ export const mutations = {
|
||||
state.confirmPromptOptions = options
|
||||
state.showConfirmPrompt = true
|
||||
},
|
||||
setShowRawCoverPreviewModal(state, val) {
|
||||
state.showRawCoverPreviewModal = val
|
||||
},
|
||||
setRawCoverPreviewModal(state, libraryItemId) {
|
||||
state.selectedLibraryItemId = libraryItemId
|
||||
state.showRawCoverPreviewModal = true
|
||||
},
|
||||
setEditCollection(state, collection) {
|
||||
state.selectedCollection = collection
|
||||
state.showEditCollectionModal = true
|
||||
|
||||
@@ -66,7 +66,7 @@ export const getters = {
|
||||
|
||||
export const actions = {
|
||||
updateServerSettings({ commit }, payload) {
|
||||
var updatePayload = {
|
||||
const updatePayload = {
|
||||
...payload
|
||||
}
|
||||
return this.$axios.$patch('/api/settings', updatePayload).then((result) => {
|
||||
|
||||
@@ -234,6 +234,10 @@ export const mutations = {
|
||||
setNumUserPlaylists(state, numUserPlaylists) {
|
||||
state.numUserPlaylists = numUserPlaylists
|
||||
},
|
||||
removeSeriesFromFilterData(state, seriesId) {
|
||||
if (!seriesId || !state.filterData) return
|
||||
state.filterData.series = state.filterData.series.filter(se => se.id !== seriesId)
|
||||
},
|
||||
updateFilterDataWithItem(state, libraryItem) {
|
||||
if (!libraryItem || !state.filterData) return
|
||||
if (state.currentLibraryId !== libraryItem.libraryId) return
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export const state = () => ({
|
||||
libraryScans: [],
|
||||
providers: [
|
||||
{
|
||||
text: 'Google Books',
|
||||
@@ -72,26 +71,8 @@ export const state = () => ({
|
||||
]
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getLibraryScan: state => id => {
|
||||
return state.libraryScans.find(ls => ls.id === id)
|
||||
}
|
||||
}
|
||||
export const getters = {}
|
||||
|
||||
export const actions = {
|
||||
export const actions = {}
|
||||
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
addUpdate(state, data) {
|
||||
var index = state.libraryScans.findIndex(lib => lib.id === data.id)
|
||||
if (index >= 0) {
|
||||
state.libraryScans.splice(index, 1, data)
|
||||
} else {
|
||||
state.libraryScans.push(data)
|
||||
}
|
||||
},
|
||||
remove(state, data) {
|
||||
state.libraryScans = state.libraryScans.filter(scan => scan.id !== data.id)
|
||||
}
|
||||
}
|
||||
export const mutations = {}
|
||||
@@ -6,7 +6,11 @@ export const state = () => ({
|
||||
|
||||
export const getters = {
|
||||
getTasksByLibraryItemId: (state) => (libraryItemId) => {
|
||||
return state.tasks.filter(t => t.data && t.data.libraryItemId === libraryItemId)
|
||||
return state.tasks.filter(t => t.data?.libraryItemId === libraryItemId)
|
||||
},
|
||||
getRunningLibraryScanTask: (state) => (libraryId) => {
|
||||
const libraryScanActions = ['library-scan', 'library-match-all']
|
||||
return state.tasks.find(t => libraryScanActions.includes(t.action) && t.data?.libraryId === libraryId && !t.isFinished)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
753
client/strings/cs.json
Normal file
753
client/strings/cs.json
Normal file
@@ -0,0 +1,753 @@
|
||||
{
|
||||
"ButtonAdd": "Přidat",
|
||||
"ButtonAddChapters": "Přidat kapitoly",
|
||||
"ButtonAddDevice": "Přidat zařízení",
|
||||
"ButtonAddLibrary": "Přidat knihovnu",
|
||||
"ButtonAddPodcasts": "Přidat podcasty",
|
||||
"ButtonAddUser": "Přidat uživatele",
|
||||
"ButtonAddYourFirstLibrary": "Vytvořte svou první knihovnu",
|
||||
"ButtonApply": "Aplikovat",
|
||||
"ButtonApplyChapters": "Aplikovat kapitoly",
|
||||
"ButtonAuthors": "Autoři",
|
||||
"ButtonBrowseForFolder": "Vyhledat složku",
|
||||
"ButtonCancel": "Zrušit",
|
||||
"ButtonCancelEncode": "Zrušit kódování",
|
||||
"ButtonChangeRootPassword": "Změnit 'Root' heslo",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Zkontrolovat & stáhnout nové epizody",
|
||||
"ButtonChooseAFolder": "Vybrat složku",
|
||||
"ButtonChooseFiles": "Vybrat soubory",
|
||||
"ButtonClearFilter": "Vymazat filtr",
|
||||
"ButtonCloseFeed": "Zavřít kanál",
|
||||
"ButtonCollections": "Kolekce",
|
||||
"ButtonConfigureScanner": "Konfigurovat Prohledávání",
|
||||
"ButtonCreate": "Vytvořit",
|
||||
"ButtonCreateBackup": "Vytvořit zálohu",
|
||||
"ButtonDelete": "Smazat",
|
||||
"ButtonDownloadQueue": "Fronta",
|
||||
"ButtonEdit": "Upravit",
|
||||
"ButtonEditChapters": "Upravit kapitoly",
|
||||
"ButtonEditPodcast": "Upravit podcast",
|
||||
"ButtonForceReScan": "Vynutit opětovné prohledání",
|
||||
"ButtonFullPath": "Úplná cesta",
|
||||
"ButtonHide": "Skrýt",
|
||||
"ButtonHome": "Domů",
|
||||
"ButtonIssues": "Problémy",
|
||||
"ButtonLatest": "Nejnovější",
|
||||
"ButtonLibrary": "Knihovna",
|
||||
"ButtonLogout": "Odhlásit",
|
||||
"ButtonLookup": "Vyhledat",
|
||||
"ButtonManageTracks": "Správa stop",
|
||||
"ButtonMapChapterTitles": "Mapovat názvy kapitol",
|
||||
"ButtonMatchAllAuthors": "Spárovat všechny autory",
|
||||
"ButtonMatchBooks": "Spárovat Knihy",
|
||||
"ButtonNevermind": "Nevadí",
|
||||
"ButtonOk": "Ok",
|
||||
"ButtonOpenFeed": "Otevřít kanál",
|
||||
"ButtonOpenManager": "Otevřít správce",
|
||||
"ButtonPlay": "Přehrát",
|
||||
"ButtonPlaying": "Hraje",
|
||||
"ButtonPlaylists": "Seznamy skladeb",
|
||||
"ButtonPurgeAllCache": "Vyčistit veškerou mezipaměť",
|
||||
"ButtonPurgeItemsCache": "Vyčistit mezipaměť položek",
|
||||
"ButtonPurgeMediaProgress": "Vyčistit průběh médií",
|
||||
"ButtonQueueAddItem": "Přidat do fronty",
|
||||
"ButtonQueueRemoveItem": "Odstranit z fronty",
|
||||
"ButtonQuickMatch": "Rychlé přiřazení",
|
||||
"ButtonRead": "Číst",
|
||||
"ButtonRemove": "Odstranit",
|
||||
"ButtonRemoveAll": "Odstranit vše",
|
||||
"ButtonRemoveAllLibraryItems": "Odstranit všechny položky knihovny",
|
||||
"ButtonRemoveFromContinueListening": "Odstranit z Pokračovat v poslechu",
|
||||
"ButtonRemoveFromContinueReading": "Odstranit z Pokračovat ve čtení",
|
||||
"ButtonRemoveSeriesFromContinueSeries": "Odstranit sérii z Pokračovat v sérii",
|
||||
"ButtonReScan": "Znovu prohledat",
|
||||
"ButtonReset": "Resetovat",
|
||||
"ButtonResetToDefault": "Obnovit výchozí",
|
||||
"ButtonRestore": "Obnovit",
|
||||
"ButtonSave": "Uložit",
|
||||
"ButtonSaveAndClose": "Uložit a zavřít",
|
||||
"ButtonSaveTracklist": "Uložit seznam skladeb",
|
||||
"ButtonScan": "Prohledat",
|
||||
"ButtonScanLibrary": "Prohledat Knihovnu",
|
||||
"ButtonSearch": "Hledat",
|
||||
"ButtonSelectFolderPath": "Vybrat cestu ke složce",
|
||||
"ButtonSeries": "Série",
|
||||
"ButtonSetChaptersFromTracks": "Nastavit kapitoly ze stop",
|
||||
"ButtonShiftTimes": "Časy posunu",
|
||||
"ButtonShow": "Zobrazit",
|
||||
"ButtonStartM4BEncode": "Spustit kódování M4B",
|
||||
"ButtonStartMetadataEmbed": "Spustit vkládání metadat",
|
||||
"ButtonSubmit": "Odeslat",
|
||||
"ButtonTest": "Test",
|
||||
"ButtonUpload": "Nahrát",
|
||||
"ButtonUploadBackup": "Nahrát zálohu",
|
||||
"ButtonUploadCover": "Nahrát obálku",
|
||||
"ButtonUploadOPMLFile": "Nahrát soubor OPML",
|
||||
"ButtonUserDelete": "Smazat uživatelský {0}",
|
||||
"ButtonUserEdit": "Upravit uživatelské {0}",
|
||||
"ButtonViewAll": "Zobrazit vše",
|
||||
"ButtonYes": "Ano",
|
||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||
"ErrorUploadLacksTitle": "Must have a title",
|
||||
"HeaderAccount": "Účet",
|
||||
"HeaderAdvanced": "Pokročilé",
|
||||
"HeaderAppriseNotificationSettings": "Nastavení oznámení Apprise",
|
||||
"HeaderAudiobookTools": "Nástroje pro správu souborů audioknih",
|
||||
"HeaderAudioTracks": "Zvukové stopy",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Zálohy",
|
||||
"HeaderChangePassword": "Změnit heslo",
|
||||
"HeaderChapters": "Kapitoly",
|
||||
"HeaderChooseAFolder": "Zvolte složku",
|
||||
"HeaderCollection": "Kolekce",
|
||||
"HeaderCollectionItems": "Položky kolekce",
|
||||
"HeaderCover": "Obálka",
|
||||
"HeaderCurrentDownloads": "Aktuální stahování",
|
||||
"HeaderDetails": "Podrobnosti",
|
||||
"HeaderDownloadQueue": "Fronta stahování",
|
||||
"HeaderEbookFiles": "Soubory elektronických knih",
|
||||
"HeaderEmail": "E-mail",
|
||||
"HeaderEmailSettings": "Nastavení e-mailu",
|
||||
"HeaderEpisodes": "Epizody",
|
||||
"HeaderEreaderDevices": "Čtečky elektronických knih",
|
||||
"HeaderEreaderSettings": "Nastavení čtečky elektronických knih",
|
||||
"HeaderFiles": "Soubory",
|
||||
"HeaderFindChapters": "Najít kapitoly",
|
||||
"HeaderIgnoredFiles": "Ignorované soubory",
|
||||
"HeaderItemFiles": "Soubory položek",
|
||||
"HeaderItemMetadataUtils": "Nástroje metadat položek",
|
||||
"HeaderLastListeningSession": "Poslední poslechová relace",
|
||||
"HeaderLatestEpisodes": "Poslední epizody",
|
||||
"HeaderLibraries": "Knihovny",
|
||||
"HeaderLibraryFiles": "Soubory knihovny",
|
||||
"HeaderLibraryStats": "Statistiky knihovny",
|
||||
"HeaderListeningSessions": "Poslechové relace",
|
||||
"HeaderListeningStats": "Statistiky poslechu",
|
||||
"HeaderLogin": "Přihlásit",
|
||||
"HeaderLogs": "Záznamy",
|
||||
"HeaderManageGenres": "Spravovat žánry",
|
||||
"HeaderManageTags": "Spravovat štítky",
|
||||
"HeaderMapDetails": "Podrobnosti mapování",
|
||||
"HeaderMatch": "Shoda",
|
||||
"HeaderMetadataOrderOfPrecedence": "Pořadí priorit metadat",
|
||||
"HeaderMetadataToEmbed": "Metadata k vložení",
|
||||
"HeaderNewAccount": "Nový účet",
|
||||
"HeaderNewLibrary": "Nová knihovna",
|
||||
"HeaderNotifications": "Oznámení",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenRSSFeed": "Otevřít RSS kanál",
|
||||
"HeaderOtherFiles": "Ostatní soubory",
|
||||
"HeaderPasswordAuthentication": "Password Authentication",
|
||||
"HeaderPermissions": "Oprávnění",
|
||||
"HeaderPlayerQueue": "Fronta přehrávače",
|
||||
"HeaderPlaylist": "Seznam skladeb",
|
||||
"HeaderPlaylistItems": "Položky seznamu přehrávání",
|
||||
"HeaderPodcastsToAdd": "Podcasty k přidání",
|
||||
"HeaderPreviewCover": "Náhled obálky",
|
||||
"HeaderRemoveEpisode": "Odstranit epizodu",
|
||||
"HeaderRemoveEpisodes": "Odstranit {0} epizody",
|
||||
"HeaderRSSFeedGeneral": "Podrobnosti o RSS",
|
||||
"HeaderRSSFeedIsOpen": "Informační kanál RSS je otevřený",
|
||||
"HeaderRSSFeeds": "RSS kanály",
|
||||
"HeaderSavedMediaProgress": "Průběh uložených médií",
|
||||
"HeaderSchedule": "Plán",
|
||||
"HeaderScheduleLibraryScans": "Naplánovat automatické prohledávání knihoven",
|
||||
"HeaderSession": "Relace",
|
||||
"HeaderSetBackupSchedule": "Nastavit plán zálohování",
|
||||
"HeaderSettings": "Nastavení",
|
||||
"HeaderSettingsDisplay": "Zobrazit",
|
||||
"HeaderSettingsExperimental": "Experimentální funkce",
|
||||
"HeaderSettingsGeneral": "Obecné",
|
||||
"HeaderSettingsScanner": "Skener",
|
||||
"HeaderSleepTimer": "Časovač vypnutí",
|
||||
"HeaderStatsLargestItems": "Největší položky",
|
||||
"HeaderStatsLongestItems": "Nejdelší položky (hod.)",
|
||||
"HeaderStatsMinutesListeningChart": "Počet minut poslechu (posledních 7 dní)",
|
||||
"HeaderStatsRecentSessions": "Poslední relace",
|
||||
"HeaderStatsTop10Authors": "Top 10 autorů",
|
||||
"HeaderStatsTop5Genres": "Top 5 žánrů",
|
||||
"HeaderTableOfContents": "Obsah",
|
||||
"HeaderTools": "Nástroje",
|
||||
"HeaderUpdateAccount": "Aktualizovat účet",
|
||||
"HeaderUpdateAuthor": "Aktualizovat autora",
|
||||
"HeaderUpdateDetails": "Aktualizovat podrobnosti",
|
||||
"HeaderUpdateLibrary": "Aktualizovat knihovnu",
|
||||
"HeaderUsers": "Uživatelé",
|
||||
"HeaderYourStats": "Vaše statistiky",
|
||||
"LabelAbridged": "Zkráceno",
|
||||
"LabelAccountType": "Typ účtu",
|
||||
"LabelAccountTypeAdmin": "Správce",
|
||||
"LabelAccountTypeGuest": "Host",
|
||||
"LabelAccountTypeUser": "Uživatel",
|
||||
"LabelActivity": "Aktivita",
|
||||
"LabelAdded": "Přidáno",
|
||||
"LabelAddedAt": "Přidáno v",
|
||||
"LabelAddToCollection": "Přidat do kolekce",
|
||||
"LabelAddToCollectionBatch": "Přidat {0} knihy do kolekce",
|
||||
"LabelAddToPlaylist": "Přidat do seznamu přehrávání",
|
||||
"LabelAddToPlaylistBatch": "Přidat {0} položky do seznamu přehrávání",
|
||||
"LabelAdminUsersOnly": "Pouze administrátoři",
|
||||
"LabelAll": "Vše",
|
||||
"LabelAllUsers": "Všichni uživatelé",
|
||||
"LabelAllUsersExcludingGuests": "Všichni uživatelé kromě hostů",
|
||||
"LabelAllUsersIncludingGuests": "Všichni uživatelé včetně hostů",
|
||||
"LabelAlreadyInYourLibrary": "Již ve vaší knihovně",
|
||||
"LabelAppend": "Připojit",
|
||||
"LabelAuthor": "Autor",
|
||||
"LabelAuthorFirstLast": "Autor (jméno a příjmení)",
|
||||
"LabelAuthorLastFirst": "Autor (příjmení a jméno)",
|
||||
"LabelAuthors": "Autoři",
|
||||
"LabelAutoDownloadEpisodes": "Automaticky stahovat epizody",
|
||||
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
||||
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
|
||||
"LabelAutoLaunch": "Auto Launch",
|
||||
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
||||
"LabelAutoRegister": "Auto Register",
|
||||
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
|
||||
"LabelBackToUser": "Zpět k uživateli",
|
||||
"LabelBackupLocation": "Umístění zálohy",
|
||||
"LabelBackupsEnableAutomaticBackups": "Povolit automatické zálohování",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Zálohy uložené v /metadata/backups",
|
||||
"LabelBackupsMaxBackupSize": "Maximální velikost zálohy (v GB)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "Ochrana proti chybné konfiguraci: Zálohování se nezdaří, pokud překročí nastavenou velikost.",
|
||||
"LabelBackupsNumberToKeep": "Počet záloh, které se mají uchovat",
|
||||
"LabelBackupsNumberToKeepHelp": "Najednou bude odstraněna pouze 1 záloha, takže pokud již máte více záloh, měli byste je odstranit ručně.",
|
||||
"LabelBitrate": "Datový tok",
|
||||
"LabelBooks": "Knihy",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "Změnit heslo",
|
||||
"LabelChannels": "Kanály",
|
||||
"LabelChapters": "Kapitoly",
|
||||
"LabelChaptersFound": "Kapitoly nalezeny",
|
||||
"LabelChapterTitle": "Název kapitoly",
|
||||
"LabelClickForMoreInfo": "Klikněte pro více informací",
|
||||
"LabelClosePlayer": "Zavřít přehrávač",
|
||||
"LabelCodec": "Kodek",
|
||||
"LabelCollapseSeries": "Sbalit sérii",
|
||||
"LabelCollection": "Kolekce",
|
||||
"LabelCollections": "Kolekce",
|
||||
"LabelComplete": "Dokončeno",
|
||||
"LabelConfirmPassword": "Potvrdit heslo",
|
||||
"LabelContinueListening": "Pokračovat v poslechu",
|
||||
"LabelContinueReading": "Pokračovat ve čtení",
|
||||
"LabelContinueSeries": "Pokračovat v sérii",
|
||||
"LabelCover": "Obálka",
|
||||
"LabelCoverImageURL": "URL obrázku obálky",
|
||||
"LabelCreatedAt": "Vytvořeno v",
|
||||
"LabelCronExpression": "Výraz Cronu",
|
||||
"LabelCurrent": "Aktuální",
|
||||
"LabelCurrently": "Aktuálně:",
|
||||
"LabelCustomCronExpression": "Vlastní výraz cronu:",
|
||||
"LabelDatetime": "Datum a čas",
|
||||
"LabelDeleteFromFileSystemCheckbox": "Smazat ze souborového systému (zrušte zaškrtnutí pro odstranění pouze z databáze)",
|
||||
"LabelDescription": "Popis",
|
||||
"LabelDeselectAll": "Odznačit vše",
|
||||
"LabelDevice": "Zařízení",
|
||||
"LabelDeviceInfo": "Informace o zařízení",
|
||||
"LabelDeviceIsAvailableTo": "Zařízení je dostupné pro...",
|
||||
"LabelDirectory": "Adresář",
|
||||
"LabelDiscFromFilename": "Disk z názvu souboru",
|
||||
"LabelDiscFromMetadata": "Disk z metadat",
|
||||
"LabelDiscover": "Objevit",
|
||||
"LabelDownload": "Stáhnout",
|
||||
"LabelDownloadNEpisodes": "Stáhnout {0} epizody",
|
||||
"LabelDuration": "Doba trvání",
|
||||
"LabelDurationFound": "Doba trvání nalezena:",
|
||||
"LabelEbook": "Elektronická kniha",
|
||||
"LabelEbooks": "Elektronické knihy",
|
||||
"LabelEdit": "Upravit",
|
||||
"LabelEmail": "E-mail",
|
||||
"LabelEmailSettingsFromAddress": "Z adresy",
|
||||
"LabelEmailSettingsSecure": "Zabezpečené",
|
||||
"LabelEmailSettingsSecureHelp": "Pokud je true, připojení bude při připojování k serveru používat TLS. Pokud je false, použije se protokol TLS, pokud server podporuje rozšíření STARTTLS. Ve většině případů nastavte tuto hodnotu na true, pokud se připojujete k portu 465. Pro port 587 nebo 25 ponechte hodnotu false. (z nodemailer.com/smtp/#authentication)",
|
||||
"LabelEmailSettingsTestAddress": "Testovací adresa",
|
||||
"LabelEmbeddedCover": "Vložená obálka",
|
||||
"LabelEnable": "Povolit",
|
||||
"LabelEnd": "Konec",
|
||||
"LabelEpisode": "Epizoda",
|
||||
"LabelEpisodeTitle": "Název epizody",
|
||||
"LabelEpisodeType": "Typ epizody",
|
||||
"LabelExample": "Příklad",
|
||||
"LabelExplicit": "Explicitní",
|
||||
"LabelFeedURL": "URL zdroje",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
"LabelFile": "Soubor",
|
||||
"LabelFileBirthtime": "Čas vzniku souboru",
|
||||
"LabelFileModified": "Soubor změněn",
|
||||
"LabelFilename": "Název souboru",
|
||||
"LabelFilterByUser": "Filtrovat podle uživatele",
|
||||
"LabelFindEpisodes": "Najít epizody",
|
||||
"LabelFinished": "Dokončeno",
|
||||
"LabelFolder": "Složka",
|
||||
"LabelFolders": "Složky",
|
||||
"LabelFontFamily": "Rodina písem",
|
||||
"LabelFontScale": "Měřítko písma",
|
||||
"LabelFormat": "Formát",
|
||||
"LabelGenre": "Žánr",
|
||||
"LabelGenres": "Žánry",
|
||||
"LabelHardDeleteFile": "Trvale smazat soubor",
|
||||
"LabelHasEbook": "Obsahuje elektronickou knihu",
|
||||
"LabelHasSupplementaryEbook": "Obsahuje doplňkovou elektronickou knihu",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Hostitel",
|
||||
"LabelHour": "Hodina",
|
||||
"LabelIcon": "Ikona",
|
||||
"LabelImageURLFromTheWeb": "URL obrázku z webu",
|
||||
"LabelIncludeInTracklist": "Zahrnout do seznamu stop",
|
||||
"LabelIncomplete": "Neúplné",
|
||||
"LabelInProgress": "Probíhá",
|
||||
"LabelInterval": "Interval",
|
||||
"LabelIntervalCustomDailyWeekly": "Vlastní denně/týdně",
|
||||
"LabelIntervalEvery12Hours": "Každých 12 hodin",
|
||||
"LabelIntervalEvery15Minutes": "Každých 15 minut",
|
||||
"LabelIntervalEvery2Hours": "Každé 2 hodiny",
|
||||
"LabelIntervalEvery30Minutes": "Každých 30 minut",
|
||||
"LabelIntervalEvery6Hours": "Každých 6 hodin",
|
||||
"LabelIntervalEveryDay": "Každý den",
|
||||
"LabelIntervalEveryHour": "Každou hodinu",
|
||||
"LabelInvalidParts": "Neplatné části",
|
||||
"LabelInvert": "Invertovat",
|
||||
"LabelItem": "Položka",
|
||||
"LabelLanguage": "Jazyk",
|
||||
"LabelLanguageDefaultServer": "Výchozí jazyk serveru",
|
||||
"LabelLastBookAdded": "Poslední kniha přidána",
|
||||
"LabelLastBookUpdated": "Poslední kniha aktualizována",
|
||||
"LabelLastSeen": "Naposledy viděno",
|
||||
"LabelLastTime": "Naposledy",
|
||||
"LabelLastUpdate": "Poslední aktualizace",
|
||||
"LabelLayout": "Rozvržení",
|
||||
"LabelLayoutSinglePage": "Jedna stránka",
|
||||
"LabelLayoutSplitPage": "Rozdělit stránku",
|
||||
"LabelLess": "Méně",
|
||||
"LabelLibrariesAccessibleToUser": "Knihovny přístupné uživateli",
|
||||
"LabelLibrary": "Knihovna",
|
||||
"LabelLibraryItem": "Položka knihovny",
|
||||
"LabelLibraryName": "Název knihovny",
|
||||
"LabelLimit": "Omezit",
|
||||
"LabelLineSpacing": "Řádkování",
|
||||
"LabelListenAgain": "Poslouchat znovu",
|
||||
"LabelLogLevelDebug": "Ladit",
|
||||
"LabelLogLevelInfo": "Informace",
|
||||
"LabelLogLevelWarn": "Varovat",
|
||||
"LabelLookForNewEpisodesAfterDate": "Hledat nové epizody po tomto datu",
|
||||
"LabelLowestPriority": "Lowest Priority",
|
||||
"LabelMatchExistingUsersBy": "Match existing users by",
|
||||
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
|
||||
"LabelMediaPlayer": "Přehrávač médií",
|
||||
"LabelMediaType": "Typ média",
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
|
||||
"LabelMetadataProvider": "Poskytovatel metadat",
|
||||
"LabelMetaTag": "Metaznačka",
|
||||
"LabelMetaTags": "Metaznačky",
|
||||
"LabelMinute": "Minuta",
|
||||
"LabelMissing": "Chybějící",
|
||||
"LabelMissingParts": "Chybějící díly",
|
||||
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
||||
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
|
||||
"LabelMore": "Více",
|
||||
"LabelMoreInfo": "Více informací",
|
||||
"LabelName": "Jméno",
|
||||
"LabelNarrator": "Interpret",
|
||||
"LabelNarrators": "Interpreti",
|
||||
"LabelNew": "Nový",
|
||||
"LabelNewestAuthors": "Nejnovější autoři",
|
||||
"LabelNewestEpisodes": "Nejnovější epizody",
|
||||
"LabelNewPassword": "Nové heslo",
|
||||
"LabelNextBackupDate": "Datum příští zálohy",
|
||||
"LabelNextScheduledRun": "Další naplánované spuštění",
|
||||
"LabelNoEpisodesSelected": "Nebyly vybrány žádné epizody",
|
||||
"LabelNotes": "Poznámky",
|
||||
"LabelNotFinished": "Nedokončeno",
|
||||
"LabelNotificationAppriseURL": "URL adresy Apprise",
|
||||
"LabelNotificationAvailableVariables": "Dostupné proměnné",
|
||||
"LabelNotificationBodyTemplate": "Šablona těla",
|
||||
"LabelNotificationEvent": "Událost oznámení",
|
||||
"LabelNotificationsMaxFailedAttempts": "Maximální počet neúspěšných pokusů",
|
||||
"LabelNotificationsMaxFailedAttemptsHelp": "Oznámení jsou vypnuta, pokud se jim to nepodaří odeslat",
|
||||
"LabelNotificationsMaxQueueSize": "Maximální velikost fronty pro oznamovací události",
|
||||
"LabelNotificationsMaxQueueSizeHelp": "Události jsou omezeny na 1 za sekundu. Události budou ignorovány, pokud je fronta v maximální velikosti. Tím se zabrání spamování oznámení.",
|
||||
"LabelNotificationTitleTemplate": "Šablona názvu",
|
||||
"LabelNotStarted": "Nezahájeno",
|
||||
"LabelNumberOfBooks": "Počet knih",
|
||||
"LabelNumberOfEpisodes": "Počet epizod",
|
||||
"LabelOpenRSSFeed": "Otevřít RSS kanál",
|
||||
"LabelOverwrite": "Přepsat",
|
||||
"LabelPassword": "Heslo",
|
||||
"LabelPath": "Cesta",
|
||||
"LabelPermissionsAccessAllLibraries": "Má přístup ke všem knihovnám",
|
||||
"LabelPermissionsAccessAllTags": "Má přístup ke všem značkám",
|
||||
"LabelPermissionsAccessExplicitContent": "Má přístup k explicitnímu obsahu",
|
||||
"LabelPermissionsDelete": "Může mazat",
|
||||
"LabelPermissionsDownload": "Může stahovat",
|
||||
"LabelPermissionsUpdate": "Může aktualizovat",
|
||||
"LabelPermissionsUpload": "Může nahrávat",
|
||||
"LabelPhotoPathURL": "Cesta k fotografii/URL",
|
||||
"LabelPlaylists": "Seznamy skladeb",
|
||||
"LabelPlayMethod": "Metoda přehrávání",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcasts": "Podcasty",
|
||||
"LabelPodcastType": "Typ podcastu",
|
||||
"LabelPort": "Port",
|
||||
"LabelPrefixesToIgnore": "Předpony, které se mají ignorovat (nerozlišují se malá a velká písmena)",
|
||||
"LabelPreventIndexing": "Zabránit indexování vašeho kanálu v adresářích podcastů iTunes a Google",
|
||||
"LabelPrimaryEbook": "Hlavní e-kniha",
|
||||
"LabelProgress": "Průběh",
|
||||
"LabelProvider": "Poskytovatel",
|
||||
"LabelPubDate": "Datum vydání",
|
||||
"LabelPublisher": "Vydavatel",
|
||||
"LabelPublishYear": "Rok vydání",
|
||||
"LabelRead": "Číst",
|
||||
"LabelReadAgain": "Číst znovu",
|
||||
"LabelReadEbookWithoutProgress": "Číst e-knihu bez zachování průběhu",
|
||||
"LabelRecentlyAdded": "Nedávno přidané",
|
||||
"LabelRecentSeries": "Nedávné série",
|
||||
"LabelRecommended": "Doporučeno",
|
||||
"LabelRegion": "Region",
|
||||
"LabelReleaseDate": "Datum vydání",
|
||||
"LabelRemoveCover": "Odstranit obálku",
|
||||
"LabelRowsPerPage": "Rows per page",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka",
|
||||
"LabelRSSFeedCustomOwnerName": "Vlastní jméno vlastníka",
|
||||
"LabelRSSFeedOpen": "Otevření RSS kanálu",
|
||||
"LabelRSSFeedPreventIndexing": "Zabránit indexování",
|
||||
"LabelRSSFeedSlug": "RSS kanál Slug",
|
||||
"LabelRSSFeedURL": "URL RSS kanálu",
|
||||
"LabelSearchTerm": "Vyhledat termín",
|
||||
"LabelSearchTitle": "Vyhledat název",
|
||||
"LabelSearchTitleOrASIN": "Vyhledat název nebo ASIN",
|
||||
"LabelSeason": "Sezóna",
|
||||
"LabelSelectAllEpisodes": "Vybrat všechny epizody",
|
||||
"LabelSelectEpisodesShowing": "Vyberte {0} epizody, které se zobrazují",
|
||||
"LabelSelectUsers": "Vybrat uživatele",
|
||||
"LabelSendEbookToDevice": "Odeslat e-knihu do...",
|
||||
"LabelSequence": "Sekvence",
|
||||
"LabelSeries": "Série",
|
||||
"LabelSeriesName": "Název série",
|
||||
"LabelSeriesProgress": "Průběh série",
|
||||
"LabelSetEbookAsPrimary": "Nastavit jako primární",
|
||||
"LabelSetEbookAsSupplementary": "Nastavit jako doplňkové",
|
||||
"LabelSettingsAudiobooksOnly": "Pouze audioknihy",
|
||||
"LabelSettingsAudiobooksOnlyHelp": "Povolením tohoto nastavení budou soubory e-knih ignorovány, pokud nejsou ve složce audioknih, v takovém případě budou nastaveny jako doplňkové e-knihy",
|
||||
"LabelSettingsBookshelfViewHelp": "Skeumorfní design s dřevěnými policemi",
|
||||
"LabelSettingsChromecastSupport": "Podpora Chromecastu",
|
||||
"LabelSettingsDateFormat": "Formát data",
|
||||
"LabelSettingsDisableWatcher": "Zakázat sledování",
|
||||
"LabelSettingsDisableWatcherForLibrary": "Zakázat sledování složky pro knihovnu",
|
||||
"LabelSettingsDisableWatcherHelp": "Zakáže automatické přidávání/aktualizaci položek při zjištění změn v souboru. *Vyžaduje restart serveru",
|
||||
"LabelSettingsEnableWatcher": "Povolit sledování",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Povolit sledování složky pro knihovnu",
|
||||
"LabelSettingsEnableWatcherHelp": "Povoluje automatické přidávání/aktualizaci položek, když jsou zjištěny změny souborů. *Vyžaduje restart serveru",
|
||||
"LabelSettingsExperimentalFeatures": "Experimentální funkce",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Funkce ve vývoji, které by mohly využít vaši zpětnou vazbu a pomoc s testováním. Kliknutím otevřete diskuzi na githubu.",
|
||||
"LabelSettingsFindCovers": "Najít obálky",
|
||||
"LabelSettingsFindCoversHelp": "Pokud vaše audiokniha nemá vloženou obálku nebo obrázek obálky uvnitř složky, skener se pokusí obálku najít.<br>Poznámka: Tím se prodlouží doba prohledávání",
|
||||
"LabelSettingsHideSingleBookSeries": "Skrýt sérii s jedinou knihou",
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Série, které mají jedinou knihu, budou skryty na stránce série a na domovské stránce.",
|
||||
"LabelSettingsHomePageBookshelfView": "Domovská stránka používá zobrazení police s knihami",
|
||||
"LabelSettingsLibraryBookshelfView": "Knihovna používá zobrazení police s knihami",
|
||||
"LabelSettingsParseSubtitles": "Analzyovat podtitul",
|
||||
"LabelSettingsParseSubtitlesHelp": "Rozparsovat podtitul z názvů složek audioknih.<br>Podtiul musí být oddělen znakem \" - \"<br>tj. \"Název knihy - Zde Podtitul\" má podtitul \"Zde podtitul\"",
|
||||
"LabelSettingsPreferMatchedMetadata": "Preferovat spárovaná metadata",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Spárovaná data budou mít při použití funkce Rychlé párování přednost před údaji o položce. Ve výchozím nastavení funkce Rychlé párování pouze doplní chybějící údaje.",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Přeskočit párování knih, které již mají ASIN",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Přeskočit párování knih, které již mají ISBN",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Ignorovat předpony při třídění",
|
||||
"LabelSettingsSortingIgnorePrefixesHelp": "tj. pro předponu \"the\" název knihy \"Název knihy\" by se třídil jako \"Název knihy, The\"",
|
||||
"LabelSettingsSquareBookCovers": "Použít čtvercové obálky knih",
|
||||
"LabelSettingsSquareBookCoversHelp": "Preferovat použití čtvercových obálek před standardními obálkami 1.6:1",
|
||||
"LabelSettingsStoreCoversWithItem": "Uložit obálky s položkou",
|
||||
"LabelSettingsStoreCoversWithItemHelp": "Ve výchozím nastavení jsou obálky uloženy v adresáři /metadata/items, povolením tohoto nastavení se obálky uloží do složky položek knihovny. Zůstane zachován pouze jeden soubor s názvem \"cover\"",
|
||||
"LabelSettingsStoreMetadataWithItem": "Uložit metadata s položkou",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Ve výchozím nastavení jsou soubory metadat uloženy v adresáři /metadata/items, povolením tohoto nastavení budou soubory metadat uloženy ve složkách položek knihovny",
|
||||
"LabelSettingsTimeFormat": "Formát času",
|
||||
"LabelShowAll": "Zobrazit vše",
|
||||
"LabelSize": "Velikost",
|
||||
"LabelSleepTimer": "Časovač vypnutí",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelStart": "Spustit",
|
||||
"LabelStarted": "Spuštěno",
|
||||
"LabelStartedAt": "Spuštěno v",
|
||||
"LabelStartTime": "Čas Spuštění",
|
||||
"LabelStatsAudioTracks": "Zvukové stopy",
|
||||
"LabelStatsAuthors": "Autoři",
|
||||
"LabelStatsBestDay": "Nejlepší den",
|
||||
"LabelStatsDailyAverage": "Denní průměr",
|
||||
"LabelStatsDays": "Dny",
|
||||
"LabelStatsDaysListened": "Dny poslechu",
|
||||
"LabelStatsHours": "Hodiny",
|
||||
"LabelStatsInARow": "v řadě",
|
||||
"LabelStatsItemsFinished": "Dokončené Položky",
|
||||
"LabelStatsItemsInLibrary": "Položky v knihovně",
|
||||
"LabelStatsMinutes": "minut",
|
||||
"LabelStatsMinutesListening": "Minuty poslechu",
|
||||
"LabelStatsOverallDays": "Celkový počet dní",
|
||||
"LabelStatsOverallHours": "Celkový počet hodin",
|
||||
"LabelStatsWeekListening": "Týdenní poslech",
|
||||
"LabelSubtitle": "Podtitul",
|
||||
"LabelSupportedFileTypes": "Podporované typy souborů",
|
||||
"LabelTag": "Značka",
|
||||
"LabelTags": "Značky",
|
||||
"LabelTagsAccessibleToUser": "Značky přístupné uživateli",
|
||||
"LabelTagsNotAccessibleToUser": "Značky nepřístupné uživateli",
|
||||
"LabelTasks": "Spuštěné Úlohy",
|
||||
"LabelTheme": "Téma",
|
||||
"LabelThemeDark": "Tmavé",
|
||||
"LabelThemeLight": "Světlé",
|
||||
"LabelTimeBase": "Časová základna",
|
||||
"LabelTimeListened": "Čas poslechu",
|
||||
"LabelTimeListenedToday": "Čas poslechu dnes",
|
||||
"LabelTimeRemaining": "{0} zbývá",
|
||||
"LabelTimeToShift": "Čas posunu v sekundách",
|
||||
"LabelTitle": "Název",
|
||||
"LabelToolsEmbedMetadata": "Vložit metadata",
|
||||
"LabelToolsEmbedMetadataDescription": "Vložit metadata do zvukových souborů včetně obálky a kapitol.",
|
||||
"LabelToolsMakeM4b": "Vytvořit soubor audioknihy M4B",
|
||||
"LabelToolsMakeM4bDescription": "Vygenerovat soubor audioknihy M4B s vloženými metadaty, obálkou a kapitolami.",
|
||||
"LabelToolsSplitM4b": "Rozdělit M4B na MP3",
|
||||
"LabelToolsSplitM4bDescription": "Vytvořit soubory MP3 z M4B rozděleného podle kapitol s vloženými metadaty, obrázku obálky a kapitol.",
|
||||
"LabelTotalDuration": "Celková doba trvání",
|
||||
"LabelTotalTimeListened": "Celkový čas poslechu",
|
||||
"LabelTrackFromFilename": "Stopa z názvu souboru",
|
||||
"LabelTrackFromMetadata": "Stopa z metadat",
|
||||
"LabelTracks": "Stopy",
|
||||
"LabelTracksMultiTrack": "Více stop",
|
||||
"LabelTracksNone": "Žádné stopy",
|
||||
"LabelTracksSingleTrack": "Jedna stopa",
|
||||
"LabelType": "Typ",
|
||||
"LabelUnabridged": "Nezkráceno",
|
||||
"LabelUnknown": "Neznámý",
|
||||
"LabelUpdateCover": "Aktualizovat obálku",
|
||||
"LabelUpdateCoverHelp": "Povolit přepsání existujících obálek pro vybrané knihy, pokud je nalezena shoda",
|
||||
"LabelUpdatedAt": "Aktualizováno v",
|
||||
"LabelUpdateDetails": "Aktualizovat podrobnosti",
|
||||
"LabelUpdateDetailsHelp": "Povolit přepsání existujících údajů o vybraných knihách, když je nalezena shoda",
|
||||
"LabelUploaderDragAndDrop": "Přetáhnout soubory nebo složky",
|
||||
"LabelUploaderDropFiles": "Odstranit soubory",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseChapterTrack": "Použít stopu kapitoly",
|
||||
"LabelUseFullTrack": "Použít celou stopu",
|
||||
"LabelUser": "Uživatel",
|
||||
"LabelUsername": "Uživatelské jméno",
|
||||
"LabelValue": "Hodnota",
|
||||
"LabelVersion": "Verze",
|
||||
"LabelViewBookmarks": "Zobrazit záložky",
|
||||
"LabelViewChapters": "Zobrazit kapitoly",
|
||||
"LabelViewQueue": "Zobrazit frontu přehrávače",
|
||||
"LabelVolume": "Hlasitost",
|
||||
"LabelWeekdaysToRun": "Dny v týdnu ke spuštění",
|
||||
"LabelYourAudiobookDuration": "Doba trvání vaší audioknihy",
|
||||
"LabelYourBookmarks": "Vaše záložky",
|
||||
"LabelYourPlaylists": "Vaše seznamy přehrávání",
|
||||
"LabelYourProgress": "Váš pokrok",
|
||||
"MessageAddToPlayerQueue": "Přidat do fronty přehrávače",
|
||||
"MessageAppriseDescription": "Abyste mohli používat tuto funkci, musíte mít spuštěnou instanci <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> nebo API, které bude zpracovávat stejné požadavky. <br />Adresa URL API Apprise by měla být úplná URL cesta pro odeslání oznámení, např. pokud je vaše instance API obsluhována na adrese <code>http://192.168.1.1:8337</code> pak byste měli zadat <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageBackupsDescription": "Zálohy zahrnují uživatele, průběh uživatele, podrobnosti o položkách knihovny, nastavení serveru a obrázky uložené v <code>/metadata/items</code> a <code>/metadata/authors</code>. Zálohy <strong>ne</strong> zahrnují všechny soubory uložené ve složkách knihovny.",
|
||||
"MessageBatchQuickMatchDescription": "Rychlá párování se pokusí přidat chybějící obálky a metadata pro vybrané položky. Povolením níže uvedených možností umožníte funkci Rychlé párování přepsat stávající obálky a/nebo metadata.",
|
||||
"MessageBookshelfNoCollections": "Ještě jste nevytvořili žádnou sbírku",
|
||||
"MessageBookshelfNoResultsForFilter": "Filtr \"{0}: {1}\"",
|
||||
"MessageBookshelfNoRSSFeeds": "Nejsou otevřeny žádné RSS kanály",
|
||||
"MessageBookshelfNoSeries": "Nemáte žádnou sérii",
|
||||
"MessageChapterEndIsAfter": "Konec kapitoly přesahuje konec audioknihy",
|
||||
"MessageChapterErrorFirstNotZero": "První kapitola musí začínat na 0",
|
||||
"MessageChapterErrorStartGteDuration": "Neplatný čas začátku, musí být kratší než doba trvání audioknihy",
|
||||
"MessageChapterErrorStartLtPrev": "Neplatný čas začátku, musí být větší nebo roven času začátku předchozí kapitoly",
|
||||
"MessageChapterStartIsAfter": "Začátek kapitoly přesahuje konec audioknihy",
|
||||
"MessageCheckingCron": "Kontrola cronu...",
|
||||
"MessageConfirmCloseFeed": "Opravdu chcete zavřít tento kanál?",
|
||||
"MessageConfirmDeleteBackup": "Opravdu chcete smazat zálohu pro {0}?",
|
||||
"MessageConfirmDeleteFile": "Tento krok smaže soubor ze souborového systému. Jsi si jisti?",
|
||||
"MessageConfirmDeleteLibrary": "Opravdu chcete trvale smazat knihovnu \"{0}\"?",
|
||||
"MessageConfirmDeleteLibraryItem": "Tento krok odstraní položku knihovny z databáze a vašeho souborového systému. Jste si jisti?",
|
||||
"MessageConfirmDeleteLibraryItems": "Tímto smažete {0} položkek knihovny z databáze a vašeho souborového systému. Jsi si jisti?",
|
||||
"MessageConfirmDeleteSession": "Opravdu chcete smazat tuto relaci?",
|
||||
"MessageConfirmForceReScan": "Opravdu chcete vynutit opětovné prohledání?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Opravdu chcete označit všechny epizody jako dokončené?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Opravdu chcete označit všechny epizody jako nedokončené?",
|
||||
"MessageConfirmMarkSeriesFinished": "Opravdu chcete označit všechny knihy z této série jako dokončené?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Opravdu chcete označit všechny knihy z této série jako nedokončené?",
|
||||
"MessageConfirmQuickEmbed": "Varování! Rychlé vložení nezálohuje vaše zvukové soubory. Ujistěte se, že máte zálohu zvukových souborů. <br><br>Chcete pokračovat?",
|
||||
"MessageConfirmRemoveAllChapters": "Opravdu chcete odstranit všechny kapitoly?",
|
||||
"MessageConfirmRemoveAuthor": "Opravdu chcete odstranit autora \"{0}\"?",
|
||||
"MessageConfirmRemoveCollection": "Opravdu chcete odstranit kolekci \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?",
|
||||
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||
"MessageConfirmRemoveNarrator": "Opravdu chcete odebrat předčítání \"{0}\"?",
|
||||
"MessageConfirmRemovePlaylist": "Opravdu chcete odstranit svůj playlist \"{0}\"?",
|
||||
"MessageConfirmRenameGenre": "Opravdu chcete přejmenovat žánr \"{0}\" na \"{1}\" pro všechny položky?",
|
||||
"MessageConfirmRenameGenreMergeNote": "Poznámka: Tento žánr již existuje, takže budou sloučeny.",
|
||||
"MessageConfirmRenameGenreWarning": "Varování! Podobný žánr s jiným obalem již existuje \"{0}\".",
|
||||
"MessageConfirmRenameTag": "Opravdu chcete přejmenovat tag \"{0}\" na \"{1}\" pro všechny položky?",
|
||||
"MessageConfirmRenameTagMergeNote": "Poznámka: Tato značka již existuje, takže budou sloučeny.",
|
||||
"MessageConfirmRenameTagWarning": "Varování! Podobná značka s jinými velkými a malými písmeny již existuje \"{0}\".",
|
||||
"MessageConfirmReScanLibraryItems": "Opravdu chcete znovu prohledat {0} položky?",
|
||||
"MessageConfirmSendEbookToDevice": "Opravdu chcete odeslat e-knihu {0} {1}\" do zařízení \"{2}\"?",
|
||||
"MessageDownloadingEpisode": "Stahuji epizodu",
|
||||
"MessageDragFilesIntoTrackOrder": "Přetáhněte soubory do správného pořadí stop",
|
||||
"MessageEmbedFinished": "Vložení dokončeno!",
|
||||
"MessageEpisodesQueuedForDownload": "{0} epizody zařazené do fronty ke stažení",
|
||||
"MessageFeedURLWillBe": "URL zdroje bude {0}",
|
||||
"MessageFetching": "Stahování...",
|
||||
"MessageForceReScanDescription": "znovu prohledá všechny soubory jako při novém skenování. ID3 tagy zvukových souborů OPF soubory a textové soubory budou skenovány jako nové.",
|
||||
"MessageImportantNotice": "Důležité upozornění!",
|
||||
"MessageInsertChapterBelow": "Vložit kapitolu níže",
|
||||
"MessageItemsSelected": "{0} vybraných položek",
|
||||
"MessageItemsUpdated": "{0} položky byly aktualizovány",
|
||||
"MessageJoinUsOn": "Přidejte se k nám",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} poslechových relací za poslední rok",
|
||||
"MessageLoading": "Načítá se...",
|
||||
"MessageLoadingFolders": "Načítám složky...",
|
||||
"MessageM4BFailed": "M4B se nezdařil!",
|
||||
"MessageM4BFinished": "M4B dokončen!",
|
||||
"MessageMapChapterTitles": "Mapování názvů kapitol ke stávajícím kapitolám audioknihy bez úpravy časových razítek",
|
||||
"MessageMarkAllEpisodesFinished": "Označit všechny epizody za dokončené",
|
||||
"MessageMarkAllEpisodesNotFinished": "Označit všechny epizody jako nedokončené",
|
||||
"MessageMarkAsFinished": "Označit jako dokončené",
|
||||
"MessageMarkAsNotFinished": "Označit jako nedokončené",
|
||||
"MessageMatchBooksDescription": "pokusí se spárovat knihy v knihovně s knihou od vybraného vyhledávače a vyplnit prázdné údaje a obálku. Nepřepisuje detaily.",
|
||||
"MessageNoAudioTracks": "Žádné zvukové stopy",
|
||||
"MessageNoAuthors": "Žádní autoři",
|
||||
"MessageNoBackups": "Žádné zálohy",
|
||||
"MessageNoBookmarks": "Žádné záložky",
|
||||
"MessageNoChapters": "Žádné kapitoly",
|
||||
"MessageNoCollections": "Žádné kolekce",
|
||||
"MessageNoCoversFound": "Nebyly nalezeny žádné obálky",
|
||||
"MessageNoDescription": "Bez popisu",
|
||||
"MessageNoDownloadsInProgress": "Momentálně neprobíhá žádné stahování",
|
||||
"MessageNoDownloadsQueued": "Žádné stahování ve frontě",
|
||||
"MessageNoEpisodeMatchesFound": "Nebyly nalezeny žádné odpovídající epizody",
|
||||
"MessageNoEpisodes": "Žádné epizody",
|
||||
"MessageNoFoldersAvailable": "Nejsou k dispozici žádné složky",
|
||||
"MessageNoGenres": "Žádné žánry",
|
||||
"MessageNoIssues": "Žádné výtisk",
|
||||
"MessageNoItems": "Žádné položky",
|
||||
"MessageNoItemsFound": "Nebyly nalezeny žádné položky",
|
||||
"MessageNoListeningSessions": "Žádné poslechové relace",
|
||||
"MessageNoLogs": "Žádné protokoly",
|
||||
"MessageNoMediaProgress": "Žádný průběh médií",
|
||||
"MessageNoNotifications": "Žádná oznámení",
|
||||
"MessageNoPodcastsFound": "Nebyly nalezeny žádné podcasty",
|
||||
"MessageNoResults": "Žádné výsledky",
|
||||
"MessageNoSearchResultsFor": "Nebyly nalezeny žádné výsledky hledání pro \"{0}\"",
|
||||
"MessageNoSeries": "Žádné série",
|
||||
"MessageNoTags": "Žádné značky",
|
||||
"MessageNoTasksRunning": "Nejsou spuštěny žádné úlohy",
|
||||
"MessageNotYetImplemented": "Ještě není implementováno",
|
||||
"MessageNoUpdateNecessary": "Není nutná žádná aktualizace",
|
||||
"MessageNoUpdatesWereNecessary": "Nebyly nutné žádné aktualizace",
|
||||
"MessageNoUserPlaylists": "Nemáte žádné seznamy skladeb",
|
||||
"MessageOr": "nebo",
|
||||
"MessagePauseChapter": "Pozastavit přehrávání kapitoly",
|
||||
"MessagePlayChapter": "Poslechnout si začátek kapitoly",
|
||||
"MessagePlaylistCreateFromCollection": "Vytvořit seznam skladeb z kolekce",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nemá žádnou adresu URL kanálu RSS, kterou by mohl použít pro porovnávání",
|
||||
"MessageQuickMatchDescription": "Vyplňte prázdné detaily položky a obálku prvním výsledkem shody z '{0}'. Nepřepisuje podrobnosti, pokud není povoleno nastavení serveru \"Preferovat párování metadata\".",
|
||||
"MessageRemoveChapter": "Odstranit kapitolu",
|
||||
"MessageRemoveEpisodes": "Odstranit {0} epizodu",
|
||||
"MessageRemoveFromPlayerQueue": "Odstranit z fronty přehrávače",
|
||||
"MessageRemoveUserWarning": "Opravdu chcete trvale smazat uživatele \"{0}\"?",
|
||||
"MessageReportBugsAndContribute": "Hlásit chyby, žádat o funkce a přispívat",
|
||||
"MessageResetChaptersConfirm": "Opravdu chcete resetovat kapitoly a vrátit zpět provedené změny?",
|
||||
"MessageRestoreBackupConfirm": "Opravdu chcete obnovit zálohu vytvořenou dne?",
|
||||
"MessageRestoreBackupWarning": "Obnovení zálohy přepíše celou databázi umístěnou v /config a obálku obrázků v /metadata/items & /metadata/authors.<br /><br />Backups nezmění žádné soubory ve složkách knihovny. Pokud jste povolili nastavení serveru pro ukládání obrázků obalu a metadat do složek knihovny, nebudou zálohovány ani přepsány.<br /><br />Všichni klienti používající váš server budou automaticky obnoveni.",
|
||||
"MessageSearchResultsFor": "Výsledky hledání pro",
|
||||
"MessageSelected": "{0} selected",
|
||||
"MessageServerCouldNotBeReached": "Server je nedostupný",
|
||||
"MessageSetChaptersFromTracksDescription": "Nastavit kapitoly jako kapitolu a název kapitoly jako název zvukového souboru",
|
||||
"MessageStartPlaybackAtTime": "Spustit přehrávání pro \"{0}\" v {1}?",
|
||||
"MessageThinking": "Přemýšlení...",
|
||||
"MessageUploaderItemFailed": "Nahrávání se nezdařilo",
|
||||
"MessageUploaderItemSuccess": "Nahráno bylo úspěšně!",
|
||||
"MessageUploading": "Odesílám...",
|
||||
"MessageValidCronExpression": "Platný výraz cronu",
|
||||
"MessageWatcherIsDisabledGlobally": "Hlídač je globálně zakázán v nastavení serveru",
|
||||
"MessageXLibraryIsEmpty": "{0} knihovna je prázdná!",
|
||||
"MessageYourAudiobookDurationIsLonger": "Doba trvání audioknihy je delší než nalezená délka",
|
||||
"MessageYourAudiobookDurationIsShorter": "Délka audioknihy je kratší, než byla nalezena.",
|
||||
"NoteChangeRootPassword": "Uživatel root je jediný uživatel, který může mít prázdné heslo",
|
||||
"NoteChapterEditorTimes": "Poznámka: Čas začátku první kapitoly musí zůstat v 0:00 a čas začátku poslední kapitoly nesmí překročit tuto dobu trvání audioknihy.",
|
||||
"NoteFolderPicker": "Poznámka: složky, které jsou již namapovány, nebudou zobrazeny",
|
||||
"NoteFolderPickerDebian": "Poznámka: Výběr složek pro instalaci debianu není plně implementován. Cestu ke své knihovně byste měli zadat přímo.",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Upozornění: Většina aplikací pro podcasty bude vyžadovat, aby adresa URL kanálu RSS používala protokol HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Upozornění: 1 nebo více epizod nemá datum vydání. Některé podcastové aplikace to vyžadují.",
|
||||
"NoteUploaderFoldersWithMediaFiles": "Se složkami s multimediálními soubory bude zacházeno jako se samostatnými položkami knihovny.",
|
||||
"NoteUploaderOnlyAudioFiles": "Pokud nahráváte pouze zvukové soubory, bude s každým zvukovým souborem zacházeno jako se samostatnou audioknihou.",
|
||||
"NoteUploaderUnsupportedFiles": "Nepodporované soubory jsou ignorovány. Při výběru nebo přetažení složky jsou ostatní soubory, které nejsou ve složce položek, ignorovány.",
|
||||
"PlaceholderNewCollection": "Nový název kolekce",
|
||||
"PlaceholderNewFolderPath": "Nová cesta ke složce",
|
||||
"PlaceholderNewPlaylist": "Nový název seznamu přehrávání",
|
||||
"PlaceholderSearch": "Hledat..",
|
||||
"PlaceholderSearchEpisode": "Hledat epizodu..",
|
||||
"ToastAccountUpdateFailed": "Aktualizace účtu se nezdařila",
|
||||
"ToastAccountUpdateSuccess": "Účet aktualizován",
|
||||
"ToastAuthorImageRemoveFailed": "Nepodařilo se odstranit obrázek",
|
||||
"ToastAuthorImageRemoveSuccess": "Obrázek autora odstraněn",
|
||||
"ToastAuthorUpdateFailed": "Aktualizace autora se nezdařila",
|
||||
"ToastAuthorUpdateMerged": "Autor sloučen",
|
||||
"ToastAuthorUpdateSuccess": "Autor aktualizován",
|
||||
"ToastAuthorUpdateSuccessNoImageFound": "Autor aktualizován (nebyl nalezen žádný obrázek)",
|
||||
"ToastBackupCreateFailed": "Vytvoření zálohy se nezdařilo",
|
||||
"ToastBackupCreateSuccess": "Záloha vytvořena",
|
||||
"ToastBackupDeleteFailed": "Nepodařilo se smazat zálohu",
|
||||
"ToastBackupDeleteSuccess": "Záloha smazána",
|
||||
"ToastBackupRestoreFailed": "Nepodařilo se obnovit zálohu",
|
||||
"ToastBackupUploadFailed": "Nepodařilo se nahrát zálohu",
|
||||
"ToastBackupUploadSuccess": "Záloha nahrána",
|
||||
"ToastBatchUpdateFailed": "Dávková aktualizace se nezdařila",
|
||||
"ToastBatchUpdateSuccess": "Dávková aktualizace proběhla úspěšně",
|
||||
"ToastBookmarkCreateFailed": "Vytvoření záložky se nezdařilo",
|
||||
"ToastBookmarkCreateSuccess": "Přidána záložka",
|
||||
"ToastBookmarkRemoveFailed": "Nepodařilo se odstranit záložku",
|
||||
"ToastBookmarkRemoveSuccess": "Záložka odstraněna",
|
||||
"ToastBookmarkUpdateFailed": "Aktualizace záložky se nezdařila",
|
||||
"ToastBookmarkUpdateSuccess": "Záložka aktualizována",
|
||||
"ToastChaptersHaveErrors": "Kapitoly obsahují chyby",
|
||||
"ToastChaptersMustHaveTitles": "Kapitoly musí mít názvy",
|
||||
"ToastCollectionItemsRemoveFailed": "Nepodařilo se odstranit položky z kolekce",
|
||||
"ToastCollectionItemsRemoveSuccess": "Položky odstraněny z kolekce",
|
||||
"ToastCollectionRemoveFailed": "Nepodařilo se odstranit kolekci",
|
||||
"ToastCollectionRemoveSuccess": "Kolekce odstraněna",
|
||||
"ToastCollectionUpdateFailed": "Aktualizace kolekce se nezdařila",
|
||||
"ToastCollectionUpdateSuccess": "Kolekce aktualizována",
|
||||
"ToastItemCoverUpdateFailed": "Aktualizace obálky se nezdařila",
|
||||
"ToastItemCoverUpdateSuccess": "Obálka předmětu byl aktualizována",
|
||||
"ToastItemDetailsUpdateFailed": "Nepodařilo se aktualizovat podrobnosti o položce",
|
||||
"ToastItemDetailsUpdateSuccess": "Podrobnosti o položce byly aktualizovány",
|
||||
"ToastItemDetailsUpdateUnneeded": "Podrobnosti o položce nejsou potřeba aktualizovat",
|
||||
"ToastItemMarkedAsFinishedFailed": "Nepodařilo se označit jako dokončené",
|
||||
"ToastItemMarkedAsFinishedSuccess": "Položka označena jako dokončená",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Nepodařilo se označit jako nedokončené",
|
||||
"ToastItemMarkedAsNotFinishedSuccess": "Položka označena jako nedokončená",
|
||||
"ToastLibraryCreateFailed": "Vytvoření knihovny se nezdařilo",
|
||||
"ToastLibraryCreateSuccess": "Knihovna \"{0}\" vytvořena",
|
||||
"ToastLibraryDeleteFailed": "Nepodařilo se smazat knihovnu",
|
||||
"ToastLibraryDeleteSuccess": "Knihovna smazána",
|
||||
"ToastLibraryScanFailedToStart": "Nepodařilo se spustit kontrolu",
|
||||
"ToastLibraryScanStarted": "Kontrola knihovny spuštěna",
|
||||
"ToastLibraryUpdateFailed": "Aktualizace knihovny se nezdařila",
|
||||
"ToastLibraryUpdateSuccess": "Knihovna \"{0}\" aktualizována",
|
||||
"ToastPlaylistCreateFailed": "Vytvoření seznamu přehrávání se nezdařilo",
|
||||
"ToastPlaylistCreateSuccess": "Seznam přehrávání vytvořen",
|
||||
"ToastPlaylistRemoveFailed": "Nepodařilo se odstranit seznamu přehrávání",
|
||||
"ToastPlaylistRemoveSuccess": "Seznam přehrávání odstraněn",
|
||||
"ToastPlaylistUpdateFailed": "Aktualizace seznamu přehrávání se nezdařila",
|
||||
"ToastPlaylistUpdateSuccess": "Seznam přehrávání aktualizován",
|
||||
"ToastPodcastCreateFailed": "Vytvoření podcastu se nezdařilo",
|
||||
"ToastPodcastCreateSuccess": "Podcast byl úspěšně vytvořen",
|
||||
"ToastRemoveItemFromCollectionFailed": "Nepodařilo se odebrat položku z kolekce",
|
||||
"ToastRemoveItemFromCollectionSuccess": "Položka odstraněna z kolekce",
|
||||
"ToastRSSFeedCloseFailed": "Nepodařilo se zavřít RSS kanál",
|
||||
"ToastRSSFeedCloseSuccess": "RSS kanál uzavřen",
|
||||
"ToastSendEbookToDeviceFailed": "Odeslání e-knihy do zařízení se nezdařilo",
|
||||
"ToastSendEbookToDeviceSuccess": "E-kniha odeslána do zařízení \"{0}\"",
|
||||
"ToastSeriesUpdateFailed": "Aktualizace série se nezdařila",
|
||||
"ToastSeriesUpdateSuccess": "Aktualizace série byla úspěšná",
|
||||
"ToastSessionDeleteFailed": "Nepodařilo se smazat relaci",
|
||||
"ToastSessionDeleteSuccess": "Relace smazána",
|
||||
"ToastSocketConnected": "Socket připojen",
|
||||
"ToastSocketDisconnected": "Socket odpojen",
|
||||
"ToastSocketFailedToConnect": "Socket se nepodařilo připojit",
|
||||
"ToastUserDeleteFailed": "Nepodařilo se smazat uživatele",
|
||||
"ToastUserDeleteSuccess": "Uživatel smazán"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user