mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-31 11:38:47 -05:00
Compare commits
1893 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c881bcbe59 | ||
|
|
89aa4a8bdc | ||
|
|
c5a4f63670 | ||
|
|
1b97582975 | ||
|
|
9b7aacf3ea | ||
|
|
e40e0bfa25 | ||
|
|
d56e3a3617 | ||
|
|
78fe6d47ba | ||
|
|
995cf51ae3 | ||
|
|
d838ff2f2e | ||
|
|
f2f07ff534 | ||
|
|
8cff68ca64 | ||
|
|
eb5331d34a | ||
|
|
f425185575 | ||
|
|
9fc352a5a4 | ||
|
|
e85ddc1aa1 | ||
|
|
b9be7510f8 | ||
|
|
f4497acd48 | ||
|
|
f73a0cce72 | ||
|
|
254ba1f089 | ||
|
|
0a179e4eed | ||
|
|
0ac63b2678 | ||
|
|
1d13d0a553 | ||
|
|
fc6ff016a7 | ||
|
|
e378b79fbc | ||
|
|
7e377297d7 | ||
|
|
00a02921dd | ||
|
|
b5d4c11f6f | ||
|
|
a0bc959850 | ||
|
|
a4b0f6c202 | ||
|
|
65cf928afe | ||
|
|
cf7fd315b6 | ||
|
|
d86a3b3dc2 | ||
|
|
e07e2cd359 | ||
|
|
8140d7021a | ||
|
|
bdbc5e3161 | ||
|
|
bb9013541b | ||
|
|
1668153acd | ||
|
|
aeba7674f8 | ||
|
|
5b0d105e21 | ||
|
|
feb54d0629 | ||
|
|
3284fe8f31 | ||
|
|
18cb394884 | ||
|
|
d0bce2949e | ||
|
|
a0e80772cd | ||
|
|
e44595521d | ||
|
|
fdf647eb32 | ||
|
|
71369bd2a0 | ||
|
|
36b1f43f4c | ||
|
|
a8bc1df3e7 | ||
|
|
a96869f547 | ||
|
|
77b030199e | ||
|
|
0e1c6c0ba7 | ||
|
|
c397422d3b | ||
|
|
15313826bf | ||
|
|
c6405b9013 | ||
|
|
d748d43efc | ||
|
|
d54edb93d6 | ||
|
|
b8ca6671fc | ||
|
|
cb7fb646ba | ||
|
|
aa82c8a253 | ||
|
|
aae92649b1 | ||
|
|
a9f5c64204 | ||
|
|
1392baf1eb | ||
|
|
0ec50bb570 | ||
|
|
b60473d7ae | ||
|
|
014fc45c15 | ||
|
|
4b4fb33d8f | ||
|
|
35e3458fb4 | ||
|
|
8f42153bee | ||
|
|
2f04d34bce | ||
|
|
09566c02ea | ||
|
|
d714ef37d9 | ||
|
|
fde07d26e5 | ||
|
|
9547824aaa | ||
|
|
5a01be1ee3 | ||
|
|
5dc4606657 | ||
|
|
2fd3238576 | ||
|
|
c1bcfe8304 | ||
|
|
a3642b204d | ||
|
|
8243da69f6 | ||
|
|
6d5987b2e0 | ||
|
|
a2fdc3e876 | ||
|
|
f92b66a469 | ||
|
|
c3d256c42b | ||
|
|
fdc792cb82 | ||
|
|
a16fb31e6e | ||
|
|
4d8a1b5b6d | ||
|
|
c382f07b05 | ||
|
|
9f6a7d065c | ||
|
|
11aa75ecbe | ||
|
|
05ce9c6eda | ||
|
|
15aaf2863c | ||
|
|
019063e6f4 | ||
|
|
ea79948122 | ||
|
|
7a0f27e3cc | ||
|
|
4f75a89633 | ||
|
|
b3f19ef628 | ||
|
|
f16e312319 | ||
|
|
056da0ef70 | ||
|
|
ca5f781531 | ||
|
|
53c96b2540 | ||
|
|
9712bdf5f0 | ||
|
|
0678c26627 | ||
|
|
b52e240025 | ||
|
|
2fa73f7a8d | ||
|
|
2cc23b6d6b | ||
|
|
9a617226b3 | ||
|
|
fbfc015d92 | ||
|
|
3e4c94e2b4 | ||
|
|
1da471e136 | ||
|
|
4dba95c000 | ||
|
|
36477a832c | ||
|
|
b4aa8f0c9a | ||
|
|
6a974d5ef0 | ||
|
|
304eda9f8c | ||
|
|
581f2e3d15 | ||
|
|
be2d317325 | ||
|
|
9f6bfeb839 | ||
|
|
f4f5f79af7 | ||
|
|
92bb2fb23d | ||
|
|
3c406c12b4 | ||
|
|
81d4ac3ed2 | ||
|
|
32bdae31a8 | ||
|
|
84c16c4a39 | ||
|
|
b8b3d05f5e | ||
|
|
bac09de23d | ||
|
|
b0bf9604bb | ||
|
|
688531f0a7 | ||
|
|
dfc7877f69 | ||
|
|
e00116a0e3 | ||
|
|
2ab287e2a9 | ||
|
|
1e0da09b2f | ||
|
|
0e7a5649cc | ||
|
|
30009e45da | ||
|
|
f9a668cb41 | ||
|
|
c848f366de | ||
|
|
25daab2f34 | ||
|
|
7170ab7239 | ||
|
|
063b3bb8db | ||
|
|
6eb6a7b115 | ||
|
|
d0972348b9 | ||
|
|
0e70af77c6 | ||
|
|
4efca78602 | ||
|
|
87d10bd6f5 | ||
|
|
0f82aed4ce | ||
|
|
58f10ad7af | ||
|
|
68dcf87aea | ||
|
|
c2f85deb11 | ||
|
|
0dd3a52cc8 | ||
|
|
c07c73c649 | ||
|
|
dbde5f773c | ||
|
|
68bf038205 | ||
|
|
eb7f66c89e | ||
|
|
58ebde2982 | ||
|
|
604a671549 | ||
|
|
5286b53334 | ||
|
|
4db26f9f79 | ||
|
|
ff8a58c7bc | ||
|
|
6f67c7bfa2 | ||
|
|
e9f5bd9bfe | ||
|
|
56e213d654 | ||
|
|
98323de64c | ||
|
|
4a13712b1c | ||
|
|
0387436111 | ||
|
|
7685ead000 | ||
|
|
8665d66923 | ||
|
|
9a808602c4 | ||
|
|
813e553dbb | ||
|
|
be050a7d57 | ||
|
|
065675697d | ||
|
|
8f6832fc2e | ||
|
|
bdb154a6e5 | ||
|
|
f557289274 | ||
|
|
a5627a1b52 | ||
|
|
33f20d54cc | ||
|
|
dadd41cb5c | ||
|
|
35e27e4f61 | ||
|
|
84839bea44 | ||
|
|
1342897858 | ||
|
|
c32efb8db8 | ||
|
|
f9ed412e4e | ||
|
|
6ae3ad508e | ||
|
|
24af702b41 | ||
|
|
a57ff20f35 | ||
|
|
39e710deb1 | ||
|
|
3b6fa73ac0 | ||
|
|
e2dd66d450 | ||
|
|
b1b53a1eae | ||
|
|
6f73345f39 | ||
|
|
c7b4b3bd3e | ||
|
|
98d543e3e5 | ||
|
|
4de4e958a0 | ||
|
|
cc5e92ec8e | ||
|
|
6cb9dfaa85 | ||
|
|
8790166ac1 | ||
|
|
3b97e2146d | ||
|
|
0bb1cf002d | ||
|
|
307c7ebc9d | ||
|
|
cc1b41995d | ||
|
|
730d60575e | ||
|
|
1b96297cc7 | ||
|
|
128c554543 | ||
|
|
1b5ab6c378 | ||
|
|
e4961feffb | ||
|
|
eb5f257b8c | ||
|
|
e271e89835 | ||
|
|
f5009f76f4 | ||
|
|
a3e63e03d2 | ||
|
|
2ae3ea346f | ||
|
|
8542d433a2 | ||
|
|
03984f96d4 | ||
|
|
eab019c577 | ||
|
|
179f11f55d | ||
|
|
5a21e63d0b | ||
|
|
24ef105732 | ||
|
|
589c4f73d2 | ||
|
|
55fdc48d5d | ||
|
|
4d45a902bb | ||
|
|
69bac2ec1e | ||
|
|
122ec140e8 | ||
|
|
6a0adf7433 | ||
|
|
c1b2aaec9f | ||
|
|
a49acdb2e4 | ||
|
|
9b67fbe8d9 | ||
|
|
2d13215f1f | ||
|
|
a77c3aae93 | ||
|
|
164937b454 | ||
|
|
b0a8f3d207 | ||
|
|
77cc0934be | ||
|
|
718890cfad | ||
|
|
418adcf891 | ||
|
|
b96f878d69 | ||
|
|
22b8622c67 | ||
|
|
3dc9416da6 | ||
|
|
5e5b674c17 | ||
|
|
3656eab8bf | ||
|
|
25ca950dd0 | ||
|
|
8fca84e4bd | ||
|
|
56579f440b | ||
|
|
a59311f795 | ||
|
|
042c89039c | ||
|
|
d94482827a | ||
|
|
a8dab5653b | ||
|
|
1d1200a3f2 | ||
|
|
4d110ebe7e | ||
|
|
b300f0d10c | ||
|
|
6dc4dc8f49 | ||
|
|
dfae6cf89f | ||
|
|
d7f18bdd8b | ||
|
|
05b102722b | ||
|
|
ef954ee68f | ||
|
|
dbaea9f87d | ||
|
|
64768ec2f9 | ||
|
|
14ee17de47 | ||
|
|
034b8956a2 | ||
|
|
1a3f0e332e | ||
|
|
fc36e86db7 | ||
|
|
60b4bc1a7e | ||
|
|
9fdc8df8bc | ||
|
|
212b97fa20 | ||
|
|
704fbaced8 | ||
|
|
575a162f8b | ||
|
|
d2e0844493 | ||
|
|
f2baf3fafd | ||
|
|
916fd039ca | ||
|
|
e248b6d8d8 | ||
|
|
936de68622 | ||
|
|
a99257e758 | ||
|
|
c89d77dd06 | ||
|
|
3138865d69 | ||
|
|
4d29ebd647 | ||
|
|
fd58df4729 | ||
|
|
5078818295 | ||
|
|
7181df0479 | ||
|
|
6c618d7760 | ||
|
|
17b8cf19b7 | ||
|
|
e018f8341e | ||
|
|
59b5f8cbbe | ||
|
|
d6108a0722 | ||
|
|
1af7e59d88 | ||
|
|
7b425e9a9d | ||
|
|
596a03900b | ||
|
|
b283644d95 | ||
|
|
808690c137 | ||
|
|
136c347586 | ||
|
|
e81238038e | ||
|
|
fcf6964d7d | ||
|
|
bd75ad4576 | ||
|
|
f970d8e539 | ||
|
|
c49010b4e1 | ||
|
|
146093d81e | ||
|
|
11ccbf1913 | ||
|
|
a4a334a18a | ||
|
|
387a37e4da | ||
|
|
ebad304aa9 | ||
|
|
8b557a0cb9 | ||
|
|
40b808e73d | ||
|
|
a8b57a1ce9 | ||
|
|
35315843f2 | ||
|
|
27b9d3b94f | ||
|
|
0010ac5a40 | ||
|
|
884808f34e | ||
|
|
f75ed07497 | ||
|
|
b707d6f3c9 | ||
|
|
a2d4a4a906 | ||
|
|
434d743d99 | ||
|
|
30f16b05fe | ||
|
|
92a88f4416 | ||
|
|
5c9c122af2 | ||
|
|
620d5ce578 | ||
|
|
363e1cee4b | ||
|
|
93f576772a | ||
|
|
d4612bae92 | ||
|
|
e01af27008 | ||
|
|
657fe0a650 | ||
|
|
9a6ec5548e | ||
|
|
0807509ea7 | ||
|
|
d9d1c4e360 | ||
|
|
2135e5b066 | ||
|
|
b69eb10ae0 | ||
|
|
e1512b6f54 | ||
|
|
1b8e8215d6 | ||
|
|
9b44e36e7b | ||
|
|
db1ca08c2e | ||
|
|
557d3243c3 | ||
|
|
785942b94f | ||
|
|
3df7caa838 | ||
|
|
aef2c52630 | ||
|
|
dccad3055b | ||
|
|
c629923a80 | ||
|
|
b4f1fd5b25 | ||
|
|
267897ce74 | ||
|
|
022bf9d0ef | ||
|
|
61c759e0c4 | ||
|
|
cfb3ce0c60 | ||
|
|
72396c5a98 | ||
|
|
12f231b886 | ||
|
|
6aeed24296 | ||
|
|
d8b6e09bc0 | ||
|
|
d95975cade | ||
|
|
c4208a4690 | ||
|
|
7c7a6df6e4 | ||
|
|
791c058ef8 | ||
|
|
c847aea0a4 | ||
|
|
e56164aa5a | ||
|
|
cfb5e909a9 | ||
|
|
071444a9e7 | ||
|
|
34ac972130 | ||
|
|
97b5cf04f5 | ||
|
|
0d50d730d9 | ||
|
|
3a7fd0bcc9 | ||
|
|
f0edea5d52 | ||
|
|
9c6b07df99 | ||
|
|
caacf461ab | ||
|
|
5bdbc75522 | ||
|
|
0d3e6b1d0a | ||
|
|
a122e25cba | ||
|
|
d7b287bfed | ||
|
|
ba4f585318 | ||
|
|
3f859723a6 | ||
|
|
c820d0e62b | ||
|
|
7a47032a96 | ||
|
|
2db4dd6a40 | ||
|
|
f58e2b6dce | ||
|
|
859a53e79a | ||
|
|
ad0edc6329 | ||
|
|
002fb7a35e | ||
|
|
cc62a20a5d | ||
|
|
ec7e965dfa | ||
|
|
9c3f5406a9 | ||
|
|
f4ec6948d2 | ||
|
|
9a51c3be0f | ||
|
|
b1ee54522a | ||
|
|
c14d13440f | ||
|
|
8c84640484 | ||
|
|
0d8917ced6 | ||
|
|
a006eb489d | ||
|
|
f2941e04d3 | ||
|
|
2728546660 | ||
|
|
eeb7c80518 | ||
|
|
c8c40360ad | ||
|
|
79ab656217 | ||
|
|
5c250da388 | ||
|
|
505e0eb3a2 | ||
|
|
388444e51f | ||
|
|
08d7a9aa14 | ||
|
|
f650ae7f18 | ||
|
|
6d138ae905 | ||
|
|
956678c08c | ||
|
|
911c854365 | ||
|
|
3c5dc17e3c | ||
|
|
e709cc4cb1 | ||
|
|
da7825e3e3 | ||
|
|
4039dc7968 | ||
|
|
e345c4cc9e | ||
|
|
a08cfa436e | ||
|
|
7207efb4da | ||
|
|
481611ff33 | ||
|
|
b67cd37a38 | ||
|
|
6e8547f0c8 | ||
|
|
96930d7ecc | ||
|
|
23f2c8a251 | ||
|
|
c5372d1405 | ||
|
|
dcfbed5f30 | ||
|
|
895ab8d18a | ||
|
|
8b5d05739f | ||
|
|
a8f6202302 | ||
|
|
5d40fdf277 | ||
|
|
f35c96e118 | ||
|
|
8f8d6f81ab | ||
|
|
e195eec1c5 | ||
|
|
33846e46fa | ||
|
|
2ad03bcb9a | ||
|
|
371cd3b2e5 | ||
|
|
b9307143bd | ||
|
|
36e44e902a | ||
|
|
4529fc0124 | ||
|
|
ba07761de3 | ||
|
|
3b7ce69327 | ||
|
|
cf927f61a0 | ||
|
|
61c32d99e7 | ||
|
|
19e39f6321 | ||
|
|
f9e6655359 | ||
|
|
debf0f495d | ||
|
|
3383ec2046 | ||
|
|
b957e1a36b | ||
|
|
c93f17051a | ||
|
|
5983f0262f | ||
|
|
96a8e74d38 | ||
|
|
6f3d488c3d | ||
|
|
74dcc4f9e4 | ||
|
|
8c4d3b93c8 | ||
|
|
c411cf04cc | ||
|
|
d1b25da408 | ||
|
|
08f765fa51 | ||
|
|
337cf90c4b | ||
|
|
17b930e13d | ||
|
|
639b600570 | ||
|
|
7a751b8f91 | ||
|
|
68621e0c07 | ||
|
|
d2512d324a | ||
|
|
5abb02e93a | ||
|
|
573079c5a1 | ||
|
|
5bde320ac7 | ||
|
|
5f63d97e59 | ||
|
|
bf2fe3faea | ||
|
|
ea60f19e7a | ||
|
|
cefc75a4ed | ||
|
|
d8753aafb9 | ||
|
|
ba5ad228cc | ||
|
|
0203f9cc1b | ||
|
|
4770be5a39 | ||
|
|
1bac395bed | ||
|
|
e818f270cd | ||
|
|
c4e2726622 | ||
|
|
74d8a09f31 | ||
|
|
618338165e | ||
|
|
db494001a2 | ||
|
|
a67213fb7b | ||
|
|
5d96b2cc6e | ||
|
|
72d0b097ab | ||
|
|
36d2957fb4 | ||
|
|
b5de517aad | ||
|
|
41db0e3bb1 | ||
|
|
e8d582269b | ||
|
|
80ef8ee890 | ||
|
|
a65859f575 | ||
|
|
5724887785 | ||
|
|
8908aa7a82 | ||
|
|
f83dd29213 | ||
|
|
99d90778f4 | ||
|
|
49279430fc | ||
|
|
030c20b12e | ||
|
|
5e943ef152 | ||
|
|
4ae057f626 | ||
|
|
9ebe4b55dd | ||
|
|
2f7403adec | ||
|
|
2777b496ad | ||
|
|
f7a3dbf209 | ||
|
|
d900093976 | ||
|
|
08250e266e | ||
|
|
da2d1455d7 | ||
|
|
b6c6c4c939 | ||
|
|
22179d82b8 | ||
|
|
343ce312f1 | ||
|
|
10677d6fb0 | ||
|
|
49a8aead9b | ||
|
|
274b0e48be | ||
|
|
4d8ffc5d99 | ||
|
|
4f3029e5b2 | ||
|
|
a1b49f5fcf | ||
|
|
89d497a305 | ||
|
|
9e095a4bc1 | ||
|
|
024d052a7b | ||
|
|
c312979aec | ||
|
|
773e621944 | ||
|
|
ed4f33b565 | ||
|
|
f8a0852dfc | ||
|
|
6dec750d3e | ||
|
|
3c98a5fb24 | ||
|
|
702ee3d350 | ||
|
|
fcc2f3650b | ||
|
|
e4ad622c01 | ||
|
|
458403eec9 | ||
|
|
aaede2752c | ||
|
|
39d8c2cf04 | ||
|
|
dd5c940d36 | ||
|
|
277f024bbc | ||
|
|
59ad1e5e36 | ||
|
|
02c4b21d3f | ||
|
|
33ae5445be | ||
|
|
5ed06871b6 | ||
|
|
e98eb8f1eb | ||
|
|
ebedaeb3b0 | ||
|
|
62aec63d1d | ||
|
|
3c25e87e8d | ||
|
|
08d16ce7c2 | ||
|
|
2cb3808326 | ||
|
|
bdb6f0c0aa | ||
|
|
5255bf13cc | ||
|
|
3588e1e8d3 | ||
|
|
8fa8360e99 | ||
|
|
b305cfd268 | ||
|
|
ff10287d05 | ||
|
|
7a7708403f | ||
|
|
ddabd0ee75 | ||
|
|
5a26704c32 | ||
|
|
7ccf36a896 | ||
|
|
e9a84dd7dd | ||
|
|
b00510855e | ||
|
|
2cd9079692 | ||
|
|
3e4b1652fc | ||
|
|
878330b4fb | ||
|
|
9a85ad1f6b | ||
|
|
f76f9c7f84 | ||
|
|
3426832f2b | ||
|
|
10fd51498c | ||
|
|
49c581ed35 | ||
|
|
f095d89980 | ||
|
|
1609f1a499 | ||
|
|
88bd51e2da | ||
|
|
74388fe0b9 | ||
|
|
7f5356100d | ||
|
|
84d2d00a30 | ||
|
|
31dddfbb60 | ||
|
|
d6da161b13 | ||
|
|
9de7be1cb4 | ||
|
|
5410aae8fc | ||
|
|
86bf6bfc62 | ||
|
|
0807146aab | ||
|
|
591d8a8ab1 | ||
|
|
b1d4e28027 | ||
|
|
44363f05ac | ||
|
|
452af43916 | ||
|
|
70ba2f7850 | ||
|
|
a364fe5031 | ||
|
|
ca6765c8e7 | ||
|
|
6bfa281dc5 | ||
|
|
d8ee61bfab | ||
|
|
c6763dee2d | ||
|
|
0e6b0d3eff | ||
|
|
8bbfee334c | ||
|
|
f806e4cce3 | ||
|
|
209ba308bd | ||
|
|
4cd9088a66 | ||
|
|
ac5e2e5c73 | ||
|
|
f1329d2847 | ||
|
|
27faefc64d | ||
|
|
0fa7e61dc1 | ||
|
|
5a3f14ae51 | ||
|
|
4e61185136 | ||
|
|
6ee06d5dae | ||
|
|
2c344a0bc0 | ||
|
|
315c83e4c3 | ||
|
|
9e4bc582cb | ||
|
|
fc6aa1f91f | ||
|
|
d4bea34423 | ||
|
|
a551a2d288 | ||
|
|
4b0c59b174 | ||
|
|
a0840d2a08 | ||
|
|
308ccf470f | ||
|
|
4021b6eca1 | ||
|
|
061695f922 | ||
|
|
e803dcd325 | ||
|
|
128796bd36 | ||
|
|
775dedc338 | ||
|
|
45c9038954 | ||
|
|
8acf962864 | ||
|
|
c3fc38639e | ||
|
|
b60b75c8da | ||
|
|
0f7edec73b | ||
|
|
321277826f | ||
|
|
6e752af2c0 | ||
|
|
0717ae39db | ||
|
|
7bc5902ea8 | ||
|
|
a28e1ed5e0 | ||
|
|
43d9e129a6 | ||
|
|
b516019ddd | ||
|
|
e4c20d677c | ||
|
|
33e183b802 | ||
|
|
b884f8fe11 | ||
|
|
2cba83f1dd | ||
|
|
a9ee9031c3 | ||
|
|
c3717f6979 | ||
|
|
657d4dd705 | ||
|
|
17356ffd79 | ||
|
|
c4be75b5bd | ||
|
|
57422d0759 | ||
|
|
d2454201b4 | ||
|
|
3a92a69693 | ||
|
|
d733c9ccc6 | ||
|
|
3e15e09c07 | ||
|
|
0592a41d4f | ||
|
|
c32e33f804 | ||
|
|
616ffb8f79 | ||
|
|
bc771a3a44 | ||
|
|
539d1a2d4f | ||
|
|
4d8cea0bb4 | ||
|
|
8b46262e93 | ||
|
|
eb9a077520 | ||
|
|
3d3a224402 | ||
|
|
e1397a6dda | ||
|
|
8f49aae979 | ||
|
|
c0a13f01d4 | ||
|
|
efcebc616c | ||
|
|
902867c3bc | ||
|
|
b7abd372e4 | ||
|
|
147ffc0210 | ||
|
|
1b2ccb6cee | ||
|
|
c58a6b9047 | ||
|
|
b787fb18f3 | ||
|
|
17cce9c914 | ||
|
|
90299e348c | ||
|
|
fe25a1bc54 | ||
|
|
edbe1851b5 | ||
|
|
ad6c5a4f00 | ||
|
|
4971787482 | ||
|
|
56d2ec9c22 | ||
|
|
106ddc9541 | ||
|
|
4d93e39fa9 | ||
|
|
54b41b15c2 | ||
|
|
54ca42a903 | ||
|
|
d7cc8a052a | ||
|
|
5165f11460 | ||
|
|
b47ce4fb24 | ||
|
|
9b1f7f566f | ||
|
|
10295b000a | ||
|
|
c06d734d5e | ||
|
|
49a69193d8 | ||
|
|
7852804a9c | ||
|
|
415dda37a4 | ||
|
|
179d339afd | ||
|
|
858c1a7353 | ||
|
|
0b42b81558 | ||
|
|
f9678dec2f | ||
|
|
82642b295c | ||
|
|
ba3d84a924 | ||
|
|
96e2f934a3 | ||
|
|
a68ade2b3d | ||
|
|
4fcdeda447 | ||
|
|
dc03835742 | ||
|
|
50430e6b27 | ||
|
|
d130dd6d5e | ||
|
|
793cc989de | ||
|
|
27d8c4d67c | ||
|
|
48f493a9f5 | ||
|
|
04992ee3fb | ||
|
|
4d8e2a1279 | ||
|
|
2af7b6b6f1 | ||
|
|
e59351566d | ||
|
|
05d10b73c3 | ||
|
|
41e192c6a5 | ||
|
|
ea42ab7624 | ||
|
|
2d9035d90b | ||
|
|
0ae853c119 | ||
|
|
3c0fdff7b4 | ||
|
|
eede2bbd46 | ||
|
|
5c31687a0f | ||
|
|
6b654d3c2d | ||
|
|
91cbe45839 | ||
|
|
7883d4a97f | ||
|
|
9f4547cff8 | ||
|
|
a98106593d | ||
|
|
c625b3f08c | ||
|
|
9e7f09c21b | ||
|
|
616caecdf1 | ||
|
|
cee19c5128 | ||
|
|
67db41a525 | ||
|
|
3ea3e55d17 | ||
|
|
4959a28485 | ||
|
|
6d2482a98e | ||
|
|
4b23b842bb | ||
|
|
07bebc8808 | ||
|
|
027d7f7a5b | ||
|
|
6baa0fa047 | ||
|
|
8425fac543 | ||
|
|
7b2ac7b9e9 | ||
|
|
bf071be247 | ||
|
|
6c05a0af8a | ||
|
|
0e292c64c4 | ||
|
|
725f8eecdb | ||
|
|
521a673094 | ||
|
|
d917f0e37d | ||
|
|
7ed5b1744f | ||
|
|
c9ab2a242d | ||
|
|
13532cba14 | ||
|
|
3fb2bd3362 | ||
|
|
e80c3a1c5a | ||
|
|
e04d26307e | ||
|
|
b8f74e1c98 | ||
|
|
0851050392 | ||
|
|
b84882d9d1 | ||
|
|
cd37a7618e | ||
|
|
64a7cfac3b | ||
|
|
1ee7ba54f8 | ||
|
|
6bb18f8800 | ||
|
|
b26b854963 | ||
|
|
7d58361ced | ||
|
|
a3723f3d06 | ||
|
|
78d1cd0cfb | ||
|
|
d41366a417 | ||
|
|
a2347150a2 | ||
|
|
d33f23dede | ||
|
|
cfca2be1b2 | ||
|
|
73f07c1392 | ||
|
|
4541e9ddc3 | ||
|
|
972271a1a9 | ||
|
|
e97d92a8ac | ||
|
|
9a73e352d1 | ||
|
|
08f09f81fa | ||
|
|
c72609013c | ||
|
|
29a6434fdc | ||
|
|
eb2ea9950a | ||
|
|
e307ded192 | ||
|
|
2d6c997b38 | ||
|
|
232a80a848 | ||
|
|
083f8faa46 | ||
|
|
0fcf978ffe | ||
|
|
c1360267c6 | ||
|
|
084bea6b15 | ||
|
|
2032dd88ba | ||
|
|
b11b1be432 | ||
|
|
b743b34fab | ||
|
|
950d10091d | ||
|
|
af0e02b9a2 | ||
|
|
1332147c4a | ||
|
|
f07cb1e7a3 | ||
|
|
53dbdd115f | ||
|
|
a217ed5574 | ||
|
|
531f947754 | ||
|
|
c957e9483e | ||
|
|
623a706555 | ||
|
|
7e171576e0 | ||
|
|
0979b3e03d | ||
|
|
1131bfa751 | ||
|
|
f9b87b94bf | ||
|
|
59ed2ec87f | ||
|
|
7b0b79e3a1 | ||
|
|
53f73e1201 | ||
|
|
c62a1dfff0 | ||
|
|
61f8055493 | ||
|
|
000d7fd249 | ||
|
|
087de03a1f | ||
|
|
a3ca6159fb | ||
|
|
5de6ee136a | ||
|
|
d5a19f2b42 | ||
|
|
e3ec5dd506 | ||
|
|
762748225d | ||
|
|
4db34e0c56 | ||
|
|
fb078d05bc | ||
|
|
f59edffa43 | ||
|
|
7aa0ddb71f | ||
|
|
a0a6256c7a | ||
|
|
df7e331605 | ||
|
|
8c23704e17 | ||
|
|
12abb1731c | ||
|
|
180293ebc1 | ||
|
|
e2af33e136 | ||
|
|
42e68edc65 | ||
|
|
47e732c213 | ||
|
|
77a86d92f4 | ||
|
|
64a8a046c1 | ||
|
|
1f02cbddd3 | ||
|
|
5e7bca02b3 | ||
|
|
097f9549b1 | ||
|
|
45434b16e0 | ||
|
|
6af5ac2be1 | ||
|
|
34ff7efa27 | ||
|
|
8f4391003f | ||
|
|
ecefb30f3d | ||
|
|
a8162b57ba | ||
|
|
b0edac4234 | ||
|
|
98c4045a71 | ||
|
|
24e90e2ead | ||
|
|
145e0217b6 | ||
|
|
e5925fb1b6 | ||
|
|
9e416d02bd | ||
|
|
82b7068130 | ||
|
|
579ee36857 | ||
|
|
4f2d7a519d | ||
|
|
a3642d92c5 | ||
|
|
224f36164f | ||
|
|
638c220ae8 | ||
|
|
51070b3e7b | ||
|
|
0aa2723063 | ||
|
|
1af66c8e8b | ||
|
|
7df8795d52 | ||
|
|
a0e9ae7092 | ||
|
|
0f0d8e317a | ||
|
|
3d5ca7d5c4 | ||
|
|
e33104fa2b | ||
|
|
a2f1723642 | ||
|
|
93357cf280 | ||
|
|
767427c787 | ||
|
|
9377631896 | ||
|
|
d08af094b8 | ||
|
|
c307b1e6fb | ||
|
|
d387d5b758 | ||
|
|
c285dd666d | ||
|
|
b37b382ea7 | ||
|
|
a2cd755ffa | ||
|
|
340aedfe13 | ||
|
|
6fafa7a75e | ||
|
|
03df5aaf42 | ||
|
|
6d84db08a8 | ||
|
|
1a5e0d2a5e | ||
|
|
70d887bada | ||
|
|
ee0ac00f80 | ||
|
|
fdfb07ff2c | ||
|
|
b648155170 | ||
|
|
59dc5299b1 | ||
|
|
357a63a4d9 | ||
|
|
94912c7542 | ||
|
|
fae182b328 | ||
|
|
9ba2f3e33a | ||
|
|
5ca2bc5d64 | ||
|
|
442687b198 | ||
|
|
7e400d3e9c | ||
|
|
e3ba739db5 | ||
|
|
cd92a22f4d | ||
|
|
d24ed98bcd | ||
|
|
bcd224f534 | ||
|
|
1a93103e50 | ||
|
|
45ccf9d4be | ||
|
|
052a8307b3 | ||
|
|
a0c0b9ea76 | ||
|
|
7485cf1a26 | ||
|
|
8931702f1b | ||
|
|
00fae3eb16 | ||
|
|
003e8e17be | ||
|
|
edd9443d51 | ||
|
|
b93a4c6792 | ||
|
|
30cf144090 | ||
|
|
f17abef20a | ||
|
|
937438800e | ||
|
|
892fb6410c | ||
|
|
7008267e42 | ||
|
|
2e5e02472c | ||
|
|
f9d37228cf | ||
|
|
f48d52a489 | ||
|
|
7d8c8fa5bb | ||
|
|
96a739e22d | ||
|
|
c3ec036009 | ||
|
|
c7794e00f6 | ||
|
|
3316394f5c | ||
|
|
c5d66989a6 | ||
|
|
e6b886a511 | ||
|
|
9bdfb05ea6 | ||
|
|
52d02b32f7 | ||
|
|
adff5a7705 | ||
|
|
60fb4090ff | ||
|
|
dd28be0113 | ||
|
|
5a60bb8267 | ||
|
|
2749b710e6 | ||
|
|
55ddcde631 | ||
|
|
4d2bcfd167 | ||
|
|
1fe4cffd3b | ||
|
|
8f83752abc | ||
|
|
31be2ba4fb | ||
|
|
dc156a2eac | ||
|
|
42050a5f17 | ||
|
|
bcc7fcb645 | ||
|
|
d96f427b83 | ||
|
|
bba8d0a46f | ||
|
|
a07a69e7de | ||
|
|
cbc2f64e2e | ||
|
|
ef622108c9 | ||
|
|
78559520ab | ||
|
|
61a8f31802 | ||
|
|
3357ccfaf3 | ||
|
|
92e3e0ef6e | ||
|
|
ed76f51f4b | ||
|
|
7d569e1e3e | ||
|
|
16cf5b5616 | ||
|
|
b260bcaeb1 | ||
|
|
3ffc481a54 | ||
|
|
b9b38d82f2 | ||
|
|
9635d72cef | ||
|
|
288edae3d1 | ||
|
|
ec90aafed1 | ||
|
|
c023678c11 | ||
|
|
cada1a6857 | ||
|
|
5eac2a91fb | ||
|
|
eb295453fc | ||
|
|
28feed6ea2 | ||
|
|
c6dc4054be | ||
|
|
6f901defd6 | ||
|
|
4cbc8676c6 | ||
|
|
0d587b6aae | ||
|
|
a47bf7a835 | ||
|
|
fce9e72851 | ||
|
|
6357fb26bf | ||
|
|
d2aabde8fe | ||
|
|
fdf67e17a0 | ||
|
|
abb4137d4c | ||
|
|
a237058e30 | ||
|
|
06851f50f4 | ||
|
|
54c1a49e1e | ||
|
|
12e47fb034 | ||
|
|
c91897ae99 | ||
|
|
26f4479859 | ||
|
|
c33314edfb | ||
|
|
b083f6ab96 | ||
|
|
8d5e08b76a | ||
|
|
a7019e2f11 | ||
|
|
a7163f7a00 | ||
|
|
a1f758cd7b | ||
|
|
946e4f39cc | ||
|
|
6e064eeafb | ||
|
|
400e34a4c7 | ||
|
|
780a0a9dd6 | ||
|
|
c1b3d7779b | ||
|
|
2662b3ec49 | ||
|
|
042a175d16 | ||
|
|
5e50ac91ff | ||
|
|
faac6f677a | ||
|
|
46d02744a1 | ||
|
|
d7e61c3aba | ||
|
|
c23c51eb78 | ||
|
|
270b2bb826 | ||
|
|
0643116e9b | ||
|
|
03ea055299 | ||
|
|
da12f94be4 | ||
|
|
64d196c347 | ||
|
|
c8d3e0c912 | ||
|
|
eb463a2958 | ||
|
|
3282ac67e4 | ||
|
|
8319891c96 | ||
|
|
24d97d17ba | ||
|
|
7425622d93 | ||
|
|
5050de3a17 | ||
|
|
b1111912f7 | ||
|
|
c1035d97e8 | ||
|
|
b322d0207b | ||
|
|
d64932dad7 | ||
|
|
a3dc79121e | ||
|
|
9627f58541 | ||
|
|
1118b8b782 | ||
|
|
36626d43a1 | ||
|
|
af9a87f8bd | ||
|
|
056de09645 | ||
|
|
f5c394c96d | ||
|
|
3824154c15 | ||
|
|
586c8a550a | ||
|
|
d57effe97c | ||
|
|
473257f65e | ||
|
|
c1938f78c2 | ||
|
|
e97171d953 | ||
|
|
c6e9fe6513 | ||
|
|
765a11f135 | ||
|
|
491bb04877 | ||
|
|
fbbcbb4af1 | ||
|
|
ce133cd6f2 | ||
|
|
dc4c30d791 | ||
|
|
e752b4071d | ||
|
|
685b4e77eb | ||
|
|
1a35def375 | ||
|
|
76d55e72df | ||
|
|
8127ee7e56 | ||
|
|
efecf7ed82 | ||
|
|
ac46548c4d | ||
|
|
40384dd442 | ||
|
|
05b4124761 | ||
|
|
e1e10dca50 | ||
|
|
0e96465d74 | ||
|
|
88e9dabaaa | ||
|
|
d65ab0e35d | ||
|
|
f55559e9a3 | ||
|
|
4ea1e4460a | ||
|
|
b16e69ee86 | ||
|
|
6b8d71c0b0 | ||
|
|
cb762c97a8 | ||
|
|
77139c7256 | ||
|
|
4cf43bc105 | ||
|
|
588b8ff209 | ||
|
|
62a8301938 | ||
|
|
ce4e48cbd7 | ||
|
|
067d90474b | ||
|
|
e0e69fb164 | ||
|
|
365610d918 | ||
|
|
fdece944f4 | ||
|
|
d7952dab04 | ||
|
|
bec599f325 | ||
|
|
affcc03c61 | ||
|
|
db18c71857 | ||
|
|
dcc223949a | ||
|
|
6a6d384d88 | ||
|
|
cd57667444 | ||
|
|
3900db14d3 | ||
|
|
1fa94cbfad | ||
|
|
793233e782 | ||
|
|
94012e5dff | ||
|
|
d440a9fd6a | ||
|
|
928c6cf5b3 | ||
|
|
23a25d420c | ||
|
|
dc779a3fc5 | ||
|
|
876badbeea | ||
|
|
8563bdde74 | ||
|
|
803c9699ef | ||
|
|
c254dc5144 | ||
|
|
d22b475539 | ||
|
|
142205f060 | ||
|
|
02d997897c | ||
|
|
39979ff8a3 | ||
|
|
441b8c5bb7 | ||
|
|
d456ec2786 | ||
|
|
a729ce1512 | ||
|
|
3949896d88 | ||
|
|
14e5e11344 | ||
|
|
c23f31216a | ||
|
|
cd04533eea | ||
|
|
6701551289 | ||
|
|
1a4833f873 | ||
|
|
3a7639f690 | ||
|
|
63c55f08dc | ||
|
|
98e79f144c | ||
|
|
3b9236a7ce | ||
|
|
ac30a971c5 | ||
|
|
9ee6eaade9 | ||
|
|
8c32fed911 | ||
|
|
f36a5eae6d | ||
|
|
b7bdaac163 | ||
|
|
162a1b7971 | ||
|
|
97da73baf3 | ||
|
|
b6e3559aba | ||
|
|
39a13e3610 | ||
|
|
7aa89f16c9 | ||
|
|
88726bed86 | ||
|
|
a35b35c062 | ||
|
|
951afaa568 | ||
|
|
5e8979876f | ||
|
|
eb0ef8c696 | ||
|
|
066b6c13c6 | ||
|
|
014ad668a5 | ||
|
|
62c59c634c | ||
|
|
f3f2d614b1 | ||
|
|
7fd70c1c86 | ||
|
|
46a3974b79 | ||
|
|
f851cde1f4 | ||
|
|
0f772fd3cf | ||
|
|
dd0d2e9f55 | ||
|
|
022c506eda | ||
|
|
dd8577354b | ||
|
|
3e7a76574b | ||
|
|
0ef2a2e4b6 | ||
|
|
8e8046541e | ||
|
|
2d6f9bab8b | ||
|
|
11e3cf4f19 | ||
|
|
37a3fdb606 | ||
|
|
9983fe7d66 | ||
|
|
731cf8e4ed | ||
|
|
c3f2e606dd | ||
|
|
dbb62069ef | ||
|
|
b08ad8785e | ||
|
|
ff04eb8d5e | ||
|
|
9a7503cde2 | ||
|
|
7d4e7ce2c0 | ||
|
|
565bb4cd6b | ||
|
|
be592a04d0 | ||
|
|
ae4ac392c6 | ||
|
|
f6b6c0a41e | ||
|
|
83e4a8f4ed | ||
|
|
70ef09f451 | ||
|
|
b91b320006 | ||
|
|
d139fffa96 | ||
|
|
845fc0794e | ||
|
|
ac6c885878 | ||
|
|
b2b5111c50 | ||
|
|
e11629a161 | ||
|
|
ff2fb2b2ba | ||
|
|
b9a9c0e717 | ||
|
|
c16e6d19ae | ||
|
|
0e98620939 | ||
|
|
e32f51f58a | ||
|
|
1ec12a547e | ||
|
|
baedced83f | ||
|
|
174decf8da | ||
|
|
0700f12896 | ||
|
|
3dc848a106 | ||
|
|
c17612a233 | ||
|
|
7313d151f8 | ||
|
|
97dc9fbccf | ||
|
|
9a87e4af73 | ||
|
|
4ccb4243f7 | ||
|
|
eb25ca7af5 | ||
|
|
872d5178e6 | ||
|
|
d11501b2c6 | ||
|
|
7e05804bcf | ||
|
|
a73b72a07b | ||
|
|
8ec4bd4279 | ||
|
|
e362456895 | ||
|
|
8cd7de25ad | ||
|
|
99ea7866c5 | ||
|
|
3194b4cd87 | ||
|
|
149f52b33c | ||
|
|
575ec9d00b | ||
|
|
40e999fcae | ||
|
|
ac57b2b867 | ||
|
|
3cafa87eda | ||
|
|
dee4ca3559 | ||
|
|
772c7b3217 | ||
|
|
c0dd58a94e | ||
|
|
91e116969a | ||
|
|
1f37e32f91 | ||
|
|
221061ea30 | ||
|
|
1e8e45431d | ||
|
|
381a81e4bb | ||
|
|
be28b9899e | ||
|
|
37ca139195 | ||
|
|
6b02779e0f | ||
|
|
ff6d95dc4d | ||
|
|
e611d7a8fd | ||
|
|
67f6cd3c56 | ||
|
|
d0ab13865c | ||
|
|
33ae93e61e | ||
|
|
3b961c424f | ||
|
|
389b603d7d | ||
|
|
721de0a343 | ||
|
|
0aadf579f3 | ||
|
|
4ec217e5d0 | ||
|
|
0f01f21a0a | ||
|
|
46668854ad | ||
|
|
a690dfe671 | ||
|
|
7528e8df41 | ||
|
|
8224ca7650 | ||
|
|
a574d06e22 | ||
|
|
dd9a072231 | ||
|
|
2304f37cbe | ||
|
|
0c20988e18 | ||
|
|
9a57fcad40 | ||
|
|
01333b6401 | ||
|
|
8509ca3249 | ||
|
|
7a69afdcd9 | ||
|
|
2c0c53bbf1 | ||
|
|
9f200ece99 | ||
|
|
c5f91ec508 | ||
|
|
d06c61b329 | ||
|
|
be4f11a60e | ||
|
|
0c5db214d1 | ||
|
|
1ad9ea92b6 | ||
|
|
d15120eb5f | ||
|
|
b9deb32b20 | ||
|
|
dd2d61f38e | ||
|
|
ca2c2f2702 | ||
|
|
1fc929ab33 | ||
|
|
f5495d64a9 | ||
|
|
d6afb17bf2 | ||
|
|
2cba9d8f4a | ||
|
|
e02169907d | ||
|
|
24a142e718 | ||
|
|
2cb4f972d7 | ||
|
|
513d946faa | ||
|
|
87d1f457ba | ||
|
|
8810f90226 | ||
|
|
3d3571013f | ||
|
|
605a6d8b25 | ||
|
|
1bfa4b31f2 | ||
|
|
7a14b49aea | ||
|
|
95ac74d748 | ||
|
|
fddf850a41 | ||
|
|
d93d4f3236 | ||
|
|
91f15d5a23 | ||
|
|
516c5c3308 | ||
|
|
f702c02859 | ||
|
|
ad88de0571 | ||
|
|
b64a651b27 | ||
|
|
06b8d1194c | ||
|
|
377ae7ab19 | ||
|
|
53cf6edd6a | ||
|
|
92bedeac15 | ||
|
|
3cf8b9dca9 | ||
|
|
bcc2f847f9 | ||
|
|
f1421f351b | ||
|
|
ed23feaf3f | ||
|
|
668ebf8550 | ||
|
|
a8c7905f6d | ||
|
|
45cd39ac0c | ||
|
|
21e1f62c65 | ||
|
|
8416f2d6be | ||
|
|
3b4ac3a230 | ||
|
|
6244909332 | ||
|
|
5db949e4a7 | ||
|
|
c453d3e8c7 | ||
|
|
9d7ffdfcd0 | ||
|
|
976427b0b3 | ||
|
|
6cbfd8679b | ||
|
|
217bbb4a8e | ||
|
|
9916a1e8f6 | ||
|
|
372101592c | ||
|
|
18123664ee | ||
|
|
2e6e4f970c | ||
|
|
1c9e56ce2e | ||
|
|
9e7b84f289 | ||
|
|
7b83ab8970 | ||
|
|
86ee4dcff2 | ||
|
|
277a5fa37c | ||
|
|
51b87912f8 | ||
|
|
653019921e | ||
|
|
ccc291067d | ||
|
|
af7e3a03f0 | ||
|
|
7c40d26857 | ||
|
|
6c507de501 | ||
|
|
482a4340f5 | ||
|
|
21e704e12c | ||
|
|
2b91bff1af | ||
|
|
d11f9608b4 | ||
|
|
2b0b691b69 | ||
|
|
5dfd5c4971 | ||
|
|
201f1bff3e | ||
|
|
a22ebb257f | ||
|
|
bf6e87d4bc | ||
|
|
b823a93ae2 | ||
|
|
05afd12682 | ||
|
|
997e23150e | ||
|
|
3c5bf376b5 | ||
|
|
bca2cfda13 | ||
|
|
916b41d587 | ||
|
|
ab08d83c04 | ||
|
|
415e0a7b5a | ||
|
|
d301c12acd | ||
|
|
7aa7e662b2 | ||
|
|
1dbfb5637a | ||
|
|
4e1aacb44f | ||
|
|
954cf3e14e | ||
|
|
b61ecefce4 | ||
|
|
8562b8d1b3 | ||
|
|
06ec2159f5 | ||
|
|
68b565505e | ||
|
|
83ff2752dd | ||
|
|
d0af1c3c9a | ||
|
|
1ad46d4fb8 | ||
|
|
d3dd13eae5 | ||
|
|
f27982d887 | ||
|
|
624a44f572 | ||
|
|
e623bf7fde | ||
|
|
6fc70b8656 | ||
|
|
354cefb9f4 | ||
|
|
a78aa88dbc | ||
|
|
9ac2453676 | ||
|
|
bb70800b4e | ||
|
|
855272a558 | ||
|
|
ebb2c5f791 | ||
|
|
2e466bb164 | ||
|
|
95ebe0f087 | ||
|
|
0a6aa43b07 | ||
|
|
806a8cf659 | ||
|
|
1a32fbfeec | ||
|
|
67396c16dd | ||
|
|
b0684b6f1b | ||
|
|
661778c02c | ||
|
|
5c4241aefe | ||
|
|
3f6bc90824 | ||
|
|
4ade6e04a8 | ||
|
|
49d0835236 | ||
|
|
d90bd92bcc | ||
|
|
41c016b8c7 | ||
|
|
5b4d3f71f9 | ||
|
|
256a9322ef | ||
|
|
793f82e445 | ||
|
|
ab6da3914b | ||
|
|
0b53f0ebf3 | ||
|
|
76d668514e | ||
|
|
3c347bef7d | ||
|
|
e837e5f780 | ||
|
|
26348ccc74 | ||
|
|
729a756e21 | ||
|
|
4dbddcf179 | ||
|
|
f2fff34d4d | ||
|
|
59c5e2c1d9 | ||
|
|
067006f406 | ||
|
|
93d82b973e | ||
|
|
a9a3423b58 | ||
|
|
f4ee215ad8 | ||
|
|
48431b1c35 | ||
|
|
ce961f90ba | ||
|
|
916d2f6bb3 | ||
|
|
01e7098f00 | ||
|
|
e02fbac4cd | ||
|
|
a8fce32e70 | ||
|
|
d0637c1e3d | ||
|
|
f6702d299d | ||
|
|
033b7ece28 | ||
|
|
5f5dce6d53 | ||
|
|
82c5c7518b | ||
|
|
7a60ffb3c4 | ||
|
|
2795f657b5 | ||
|
|
9ef5b5830e | ||
|
|
879adfa633 | ||
|
|
b12a344776 | ||
|
|
50b1098797 | ||
|
|
fdfaa7eba4 | ||
|
|
5525587513 | ||
|
|
1f20ed7640 | ||
|
|
f741064843 | ||
|
|
d5138e4c0a | ||
|
|
42a30c33db | ||
|
|
e5d978f8e8 | ||
|
|
ccc82520a9 | ||
|
|
22acf52a26 | ||
|
|
2ccd2786f4 | ||
|
|
0028136935 | ||
|
|
0edc46b771 | ||
|
|
2261f3d1c3 | ||
|
|
5c0e792782 | ||
|
|
644882e04f | ||
|
|
67f51c6de9 | ||
|
|
0c8fd6ab0e | ||
|
|
5452a57a14 | ||
|
|
19f020e7a6 | ||
|
|
825641f2a9 | ||
|
|
35ab4cb2fe | ||
|
|
fd13607d89 | ||
|
|
f79b4d44b9 | ||
|
|
91e30a6e84 | ||
|
|
8ab0f164a6 | ||
|
|
578bb03404 | ||
|
|
06582b5371 | ||
|
|
7f6baf35b7 | ||
|
|
6227d0baa1 | ||
|
|
e334b585be | ||
|
|
c83b3f19f7 | ||
|
|
d31ec055f9 | ||
|
|
38c259a45e | ||
|
|
b2ee24de98 | ||
|
|
9ba0e52bb7 | ||
|
|
edc712e6f6 | ||
|
|
485888b2d9 | ||
|
|
c2e90d4d83 | ||
|
|
f5d89b8f52 | ||
|
|
378b40790a | ||
|
|
be3d38392d | ||
|
|
27fef50983 | ||
|
|
167df85c1e | ||
|
|
009e16c9a4 | ||
|
|
b40cc767b2 | ||
|
|
4f5f2d32be | ||
|
|
66be3e0281 | ||
|
|
987f188f00 | ||
|
|
daca2bdf2a | ||
|
|
8894f52439 | ||
|
|
863f81e55a | ||
|
|
d03d3735e5 | ||
|
|
3bb2df6e12 | ||
|
|
80c9efc618 | ||
|
|
3279901ab0 | ||
|
|
d43d351721 | ||
|
|
8210eba439 | ||
|
|
cbd7294b0b | ||
|
|
6064e8af87 | ||
|
|
8754f0c25f | ||
|
|
f31700f668 | ||
|
|
9877b139f6 | ||
|
|
5643c846ee | ||
|
|
5a071babe9 | ||
|
|
3d85d0bce6 | ||
|
|
68afc2c718 | ||
|
|
b3d9323f66 | ||
|
|
effc63755b | ||
|
|
b90934a72a | ||
|
|
e01748eb2f | ||
|
|
430fbf5e46 | ||
|
|
27e6b9ce0d | ||
|
|
fc614b9833 | ||
|
|
a97c102369 | ||
|
|
745a491f90 | ||
|
|
b2880ab0a9 | ||
|
|
f916454c55 | ||
|
|
701b8ea12e | ||
|
|
2079942ccd | ||
|
|
140b718592 | ||
|
|
2de8c72131 | ||
|
|
089d4b5cee | ||
|
|
e06a015d6e | ||
|
|
b7e546f2f5 | ||
|
|
26ef275ab4 | ||
|
|
416db7c981 | ||
|
|
78079b2e60 | ||
|
|
03bffb725a | ||
|
|
46fc89e247 | ||
|
|
fbbceaa642 | ||
|
|
f5aae25cc8 | ||
|
|
8d03943acb | ||
|
|
853513b926 | ||
|
|
c606a41314 | ||
|
|
35f29ca22b | ||
|
|
ac00f3ebe7 | ||
|
|
6846de98f8 | ||
|
|
881baa818d | ||
|
|
b671145e73 | ||
|
|
8809c7b900 | ||
|
|
ae8f3aa918 | ||
|
|
5d4047c171 | ||
|
|
6f80591afd | ||
|
|
9b6fa8fe8c | ||
|
|
d6c02ebb2c | ||
|
|
788d867ec3 | ||
|
|
3bc3914fd9 | ||
|
|
3d821dacb7 | ||
|
|
e0546c6164 | ||
|
|
be7ccfb209 | ||
|
|
938a8c6f80 | ||
|
|
5cd343cb01 | ||
|
|
ab0094a53b | ||
|
|
2d5e4ebcf0 | ||
|
|
3171ce5aba | ||
|
|
0e1692d26b | ||
|
|
e8cd18eac2 | ||
|
|
bf928692d5 | ||
|
|
792490b629 | ||
|
|
0d1ff35c5e | ||
|
|
67e02fddbd | ||
|
|
09beb6a2ae | ||
|
|
1bd657f07d | ||
|
|
2dba17a7ae | ||
|
|
4900649908 | ||
|
|
c3b33ea37a | ||
|
|
36bd6e649a | ||
|
|
4621c78573 | ||
|
|
c88bbf1ce4 | ||
|
|
d37b25a6f6 | ||
|
|
792268f5ee | ||
|
|
5f2d6f4d5e | ||
|
|
1350a91fba | ||
|
|
acf22ca4fa | ||
|
|
705aac40d7 | ||
|
|
7456052620 | ||
|
|
6cd4ec7fce | ||
|
|
93b8e11378 | ||
|
|
6161daeef0 | ||
|
|
cfcd351570 | ||
|
|
514893646a | ||
|
|
e5469cc0f8 | ||
|
|
ec6e70725c | ||
|
|
160dac109d | ||
|
|
6be741045f | ||
|
|
f41d6d5c77 | ||
|
|
a5dacd7821 | ||
|
|
8b12508b0c | ||
|
|
a394f38fe9 | ||
|
|
c4bfa266b0 | ||
|
|
96232676cb | ||
|
|
b2aab06e01 | ||
|
|
f002532c1e | ||
|
|
54663f0f01 | ||
|
|
d8df9a9dff | ||
|
|
68efd30a54 | ||
|
|
27407d49dd | ||
|
|
97d4330cda | ||
|
|
3153bdc5bb | ||
|
|
31fd75a895 | ||
|
|
b22173a631 | ||
|
|
d2e012d7b1 | ||
|
|
d4fe0be386 | ||
|
|
6d947bbc29 | ||
|
|
5187d0e55f | ||
|
|
c6253e4fd4 | ||
|
|
1ab933c8b0 | ||
|
|
e2e5dd372a | ||
|
|
aeb87c81a1 | ||
|
|
3e98b6f749 | ||
|
|
3c465994fe | ||
|
|
6cfe583535 | ||
|
|
0ad7a98fc7 | ||
|
|
ce88ebb55b | ||
|
|
c7e3f08d39 | ||
|
|
d15264832d | ||
|
|
a8d5b543d7 | ||
|
|
f2e16017f6 | ||
|
|
4d227cbade | ||
|
|
15a85299b9 | ||
|
|
d22e9e32ed | ||
|
|
8beac53f5f | ||
|
|
cbad435690 | ||
|
|
169b637720 | ||
|
|
f083d4b5f6 | ||
|
|
3451a312e9 | ||
|
|
927c1a3514 | ||
|
|
dabcad5ebd | ||
|
|
796602d1b2 | ||
|
|
302870a101 | ||
|
|
3954aa1963 | ||
|
|
2d8c840ad6 | ||
|
|
f1f02b185e | ||
|
|
13d21e90f8 | ||
|
|
dd664da871 | ||
|
|
6ff66370fe | ||
|
|
23904d57ad | ||
|
|
efdb43e2d2 | ||
|
|
67523095d6 | ||
|
|
e2d869bb19 | ||
|
|
d38e9499db | ||
|
|
c7429efe95 | ||
|
|
b925dbcc95 | ||
|
|
2a235b8324 | ||
|
|
06cc2a1b21 | ||
|
|
4bcca97b1f | ||
|
|
313b9026f1 | ||
|
|
139ee013a7 | ||
|
|
7e5ab477b2 | ||
|
|
eba37c46cb | ||
|
|
228d9cc301 | ||
|
|
85946dd1d5 | ||
|
|
b40598593d | ||
|
|
e918a46d09 | ||
|
|
8061ee29d5 | ||
|
|
e15e04f085 | ||
|
|
958d68ffa9 | ||
|
|
c8a743ccc1 | ||
|
|
09dc95f560 | ||
|
|
853858825b | ||
|
|
c962090c3a | ||
|
|
63a8e2433e | ||
|
|
f78d287b59 | ||
|
|
eaa383b6d8 | ||
|
|
113026ce13 | ||
|
|
578a946ca5 | ||
|
|
f31306eda0 | ||
|
|
c62b716a2c | ||
|
|
97ed20c683 | ||
|
|
d5c46dcbfb | ||
|
|
30934edd57 | ||
|
|
d06fd1a1b1 | ||
|
|
6bb36381f1 | ||
|
|
a1331fb3f8 | ||
|
|
17d15144eb | ||
|
|
74d26eece4 | ||
|
|
474a7d08d0 | ||
|
|
639c930779 | ||
|
|
c6323f8ad9 | ||
|
|
caea6c6371 | ||
|
|
d285845e04 | ||
|
|
5a6867e98a | ||
|
|
621444114f | ||
|
|
5591704aad | ||
|
|
cc1181b301 | ||
|
|
095f49824e | ||
|
|
b330030f50 | ||
|
|
a7d422e23f | ||
|
|
f51a31c8ca | ||
|
|
290340a385 | ||
|
|
0137f6dfeb | ||
|
|
7f27eabf3e | ||
|
|
4f7588c87d | ||
|
|
a19b6370c4 | ||
|
|
fbd7ae10d1 | ||
|
|
f94c706fc8 | ||
|
|
9de4b1069a | ||
|
|
8fbe3c3884 | ||
|
|
abf9120363 | ||
|
|
69f250cba5 | ||
|
|
2103edfcdc | ||
|
|
02ba147bd4 | ||
|
|
230b548921 | ||
|
|
f34ebdc016 | ||
|
|
69ad651671 | ||
|
|
edc919b3f5 | ||
|
|
c8c7a9ece5 | ||
|
|
8702ac1ccf | ||
|
|
33833e0a36 | ||
|
|
6b98baafdf | ||
|
|
cc285bb685 | ||
|
|
ef0243f1d7 | ||
|
|
7a7d53f92e | ||
|
|
2e070227ab | ||
|
|
195a30096f | ||
|
|
55c40658f2 | ||
|
|
db48a486e5 | ||
|
|
d869a9836e | ||
|
|
55680cbc98 | ||
|
|
9b7e6a6058 | ||
|
|
a482e5d316 | ||
|
|
5ac342defd | ||
|
|
944a5b3e92 | ||
|
|
9b9de84740 | ||
|
|
2746e61cb3 | ||
|
|
7f1d797fb2 | ||
|
|
2059c9f14a | ||
|
|
0e16a9c8de | ||
|
|
b6a33bf7bb | ||
|
|
ce88ac9f33 | ||
|
|
678dceefed | ||
|
|
8b38dda229 | ||
|
|
7373c7159b | ||
|
|
e34a39dde4 | ||
|
|
d4cd8c6db9 | ||
|
|
9e93a3c7e6 | ||
|
|
4a8bcc90ea | ||
|
|
84c12a6e7e | ||
|
|
2a513ac8b8 | ||
|
|
97687c96cd | ||
|
|
a42c13aec2 | ||
|
|
5f0f8b92d1 | ||
|
|
78ca6aa679 | ||
|
|
22e3d4a150 | ||
|
|
e3fba1fb2b | ||
|
|
4d95250990 | ||
|
|
4776368501 | ||
|
|
8b0ed2bf29 | ||
|
|
54389e3c25 | ||
|
|
bf0da1c6ec | ||
|
|
591a866f8c | ||
|
|
fc8473ed84 | ||
|
|
b19442e440 | ||
|
|
7a51e0693d | ||
|
|
21785c8e72 | ||
|
|
bdf6ccbd2d | ||
|
|
ceb163570f | ||
|
|
049ae73d74 | ||
|
|
729fdd5c9f | ||
|
|
4dac8ac16c | ||
|
|
220bbc3d2d | ||
|
|
c2a4b32192 | ||
|
|
09d0d47549 | ||
|
|
4185807da4 | ||
|
|
8abda14e0f | ||
|
|
619e5c0895 | ||
|
|
3a2594cde9 | ||
|
|
5cca2d0155 | ||
|
|
a467637cb5 | ||
|
|
1a23001955 | ||
|
|
8942dca31d | ||
|
|
2a919012b6 | ||
|
|
40b342498f | ||
|
|
e220b2818a | ||
|
|
620bf7990f | ||
|
|
0df36d2609 | ||
|
|
adfe50a841 | ||
|
|
35925ddc1b | ||
|
|
33dfb764fa | ||
|
|
49bef2c641 | ||
|
|
ac58536501 | ||
|
|
c344555be3 | ||
|
|
645bcc53c6 | ||
|
|
84dd06dfc4 | ||
|
|
0a73dd6437 | ||
|
|
2cc055a1ad | ||
|
|
d8ec3bd218 | ||
|
|
d189ec74c9 | ||
|
|
4291769b93 | ||
|
|
22900a3f67 | ||
|
|
7fa08449de | ||
|
|
4f7203fccb | ||
|
|
0eea766931 | ||
|
|
5c054aef90 | ||
|
|
a1674d5da1 | ||
|
|
91597a5454 | ||
|
|
11354a3e3f | ||
|
|
dcd4f69383 | ||
|
|
e253939c1e | ||
|
|
f25ce1c0e7 | ||
|
|
7717e57c16 | ||
|
|
2e28c9b06d | ||
|
|
4bc7cd2045 | ||
|
|
5389115120 | ||
|
|
6e99cf6570 | ||
|
|
21bdd9f9ec | ||
|
|
e3ae3f7e6a | ||
|
|
74bf917150 | ||
|
|
5666b263f5 | ||
|
|
fc8fec62a0 | ||
|
|
034d858f18 | ||
|
|
ebc9e1a888 | ||
|
|
c5a9c2bf5a | ||
|
|
3dbce8fd71 | ||
|
|
b2d299dba6 | ||
|
|
cb5d9a8287 | ||
|
|
f9530897c0 | ||
|
|
7c7e8285a4 | ||
|
|
7b3f9a1e0c | ||
|
|
399e0ea0bc | ||
|
|
a47b0bce57 | ||
|
|
4b60b4f73e | ||
|
|
d88b20addd | ||
|
|
5d12cc3f23 | ||
|
|
84fb7ce8b3 | ||
|
|
243cc672f7 | ||
|
|
663546dd77 | ||
|
|
1b79b3f42d | ||
|
|
d4525ad5ca | ||
|
|
dc9c307663 | ||
|
|
554e9ec238 | ||
|
|
2276228531 | ||
|
|
6f7d2ef4cd | ||
|
|
ad3fbe7abf | ||
|
|
c58110c7b7 | ||
|
|
f781fa9e6b | ||
|
|
7f3543400a | ||
|
|
1ff5637c1b | ||
|
|
f2d9de5a5f | ||
|
|
8be3bebee8 | ||
|
|
ef88972b25 | ||
|
|
35f3b5863f | ||
|
|
ff294867f8 | ||
|
|
1c6cd7499b | ||
|
|
ce35ae6b03 | ||
|
|
28c99cf17f | ||
|
|
584e754eae | ||
|
|
68cf748e77 | ||
|
|
9b8f53caf6 | ||
|
|
fdf332937f | ||
|
|
182545a729 | ||
|
|
e83df2bf4b | ||
|
|
10299e3037 | ||
|
|
6a43672973 | ||
|
|
02bf55b401 | ||
|
|
f0615c2971 | ||
|
|
7ef44eb75b | ||
|
|
044804115b | ||
|
|
3b941d59a3 | ||
|
|
d69f6020c6 | ||
|
|
2fc60e4e9c | ||
|
|
cdcfd01da2 | ||
|
|
d6c5b6e8c6 | ||
|
|
5d305c96ad | ||
|
|
6d823f4e42 | ||
|
|
bd5e865a11 | ||
|
|
cd274e0844 | ||
|
|
e9249430c3 | ||
|
|
cd5e5099f2 | ||
|
|
09dd90e3fc | ||
|
|
a62f7a4861 | ||
|
|
5a26b01ffb | ||
|
|
cbde451120 | ||
|
|
8bbeae4873 | ||
|
|
05dff2583a | ||
|
|
79a82df914 | ||
|
|
3f6ed6dbf9 | ||
|
|
4edba20e9e | ||
|
|
2c6e1cc2b5 | ||
|
|
e1af25d9d8 | ||
|
|
9b30a8ff4b | ||
|
|
b1a9de819e | ||
|
|
68da974c12 | ||
|
|
8c47ccb651 | ||
|
|
d544ecc657 | ||
|
|
9f69a8ace3 | ||
|
|
a90cfc4d04 | ||
|
|
88354de495 | ||
|
|
5b02c5185f | ||
|
|
1152e5513e | ||
|
|
8ce9b55969 | ||
|
|
ccf08e9e80 | ||
|
|
b0b1d2707d | ||
|
|
469278cd1e | ||
|
|
10d9e11387 | ||
|
|
5328f4cddb | ||
|
|
4154022ad1 | ||
|
|
642e9787c0 | ||
|
|
da2e65c042 | ||
|
|
ab895fa8ed | ||
|
|
f5e892b862 | ||
|
|
ac097862fc | ||
|
|
23cc6bb210 | ||
|
|
c60807f998 | ||
|
|
99e2ea228d | ||
|
|
8df05896b5 | ||
|
|
174dac8fd4 | ||
|
|
2a386ca2a9 | ||
|
|
fc228013d3 | ||
|
|
64b824ef6b | ||
|
|
96cd91a385 | ||
|
|
5c91c1e2c7 | ||
|
|
2df5ab0dde | ||
|
|
baf738f5ba | ||
|
|
3a7cafbb95 | ||
|
|
3276b04256 | ||
|
|
ac3fa31d1e | ||
|
|
6e5e638076 | ||
|
|
609bf4309f | ||
|
|
66b5c14c6b | ||
|
|
e4936ed522 | ||
|
|
c201e2aa98 | ||
|
|
3d3f20296c | ||
|
|
9ae71615bc | ||
|
|
292840a0e3 | ||
|
|
84e6e6fdbe | ||
|
|
cfe27dff80 | ||
|
|
c75895d711 | ||
|
|
c0ff28ffff | ||
|
|
58dfa65660 | ||
|
|
3f8e685d64 | ||
|
|
08e1782253 | ||
|
|
0dd219f303 | ||
|
|
d5e96a3422 | ||
|
|
03bfecefee | ||
|
|
12027b9a76 | ||
|
|
0e665e2091 | ||
|
|
e32d05ea27 | ||
|
|
5446aea910 | ||
|
|
86e7c7fc33 | ||
|
|
173b72c3b5 | ||
|
|
3150822117 | ||
|
|
9a96d17a30 | ||
|
|
c98409b9ae | ||
|
|
0e3640c246 | ||
|
|
e030b59bae | ||
|
|
920ca683b9 | ||
|
|
28d76d21f1 | ||
|
|
e1e6b46456 | ||
|
|
122f2a2556 | ||
|
|
27f1bd90f9 | ||
|
|
f8d0384155 | ||
|
|
43bbfbfee3 | ||
|
|
deadc63dbb | ||
|
|
a9b9e23f46 | ||
|
|
6a06ba4327 | ||
|
|
3d2bbc7719 | ||
|
|
c9ea5dd2d7 | ||
|
|
eea3e2583c | ||
|
|
57399bb79e | ||
|
|
69fcb103e4 | ||
|
|
f00b120e96 | ||
|
|
14a8f84446 | ||
|
|
099ae7c776 | ||
|
|
1cf9e85272 | ||
|
|
c4eeb1cfb7 | ||
|
|
1dde02b170 | ||
|
|
08e648a3bc | ||
|
|
755e70b4a9 | ||
|
|
5ff4cd2c0b | ||
|
|
e36c31c5e7 | ||
|
|
d561a48229 | ||
|
|
5243a225e8 | ||
|
|
4fe60465e5 | ||
|
|
0af6ad63c1 | ||
|
|
68b13ae45f | ||
|
|
4c2ad3ede5 | ||
|
|
deea6702f0 | ||
|
|
7348432594 | ||
|
|
7d66f1eec9 | ||
|
|
be1e1e7ba0 | ||
|
|
4bdef893af | ||
|
|
6597fca576 | ||
|
|
ea9ec13845 | ||
|
|
30f15d3575 | ||
|
|
dad12537b6 | ||
|
|
65df377a49 | ||
|
|
2d19208340 | ||
|
|
73257188f6 | ||
|
|
5f4e5cd3d8 | ||
|
|
f2be3bc95e | ||
|
|
2a30cc428f | ||
|
|
b97ed953f7 | ||
|
|
65793f7109 | ||
|
|
2b7f53b0a7 | ||
|
|
c6eb1096e8 | ||
|
|
a907c88f66 | ||
|
|
43f48b65f8 | ||
|
|
2a4cbd48b8 | ||
|
|
b6e4f3a8c5 | ||
|
|
83976b5549 | ||
|
|
9f29b245d7 | ||
|
|
ecf62c5443 | ||
|
|
eb82d9c300 | ||
|
|
45582343b8 | ||
|
|
6a4d3a55b1 | ||
|
|
95bacce5e5 | ||
|
|
408775a25a | ||
|
|
d3a8ecc8d1 | ||
|
|
ecc425d89d | ||
|
|
289b3e9f94 | ||
|
|
74a68a4557 | ||
|
|
42604331ff | ||
|
|
428a515c6a | ||
|
|
c81b12f459 | ||
|
|
779d22bf55 | ||
|
|
295c6b0c74 | ||
|
|
aa50cc2d81 | ||
|
|
eb109c398f | ||
|
|
4e7d2ddc58 | ||
|
|
a5b10aac96 | ||
|
|
fcbccaeb4e |
15
.devcontainer/Dockerfile
Normal file
15
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
|
||||
ARG VARIANT=16
|
||||
FROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} as base
|
||||
|
||||
# Setup the node environment
|
||||
ENV NODE_ENV=development
|
||||
|
||||
# Install additional OS packages.
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \
|
||||
curl tzdata ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Move tone executable to appropriate directory
|
||||
COPY --from=sandreas/tone:v0.1.5 /usr/local/bin/tone /usr/local/bin/
|
||||
9
.devcontainer/dev.js
Normal file
9
.devcontainer/dev.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// Using port 3333 is important when running the client web app separately
|
||||
const Path = require('path')
|
||||
module.exports.config = {
|
||||
Port: 3333,
|
||||
ConfigPath: Path.resolve('config'),
|
||||
MetadataPath: Path.resolve('metadata'),
|
||||
FFmpegPath: '/usr/bin/ffmpeg',
|
||||
FFProbePath: '/usr/bin/ffprobe'
|
||||
}
|
||||
40
.devcontainer/devcontainer.json
Normal file
40
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,40 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
|
||||
{
|
||||
"name": "Audiobookshelf",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
|
||||
// Append -bullseye or -buster to pin to an OS version.
|
||||
// Use -bullseye variants on local arm64/Apple Silicon.
|
||||
"args": {
|
||||
"VARIANT": "16"
|
||||
}
|
||||
},
|
||||
"mounts": [
|
||||
"source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume",
|
||||
"source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume"
|
||||
],
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [
|
||||
3000,
|
||||
3333
|
||||
],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "sh .devcontainer/post-create.sh",
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"octref.vetur"
|
||||
]
|
||||
}
|
||||
}
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
29
.devcontainer/post-create.sh
Normal file
29
.devcontainer/post-create.sh
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Mark the working directory as safe for use with git
|
||||
git config --global --add safe.directory $PWD
|
||||
|
||||
# If there is no dev.js file, create it
|
||||
if [ ! -f dev.js ]; then
|
||||
cp .devcontainer/dev.js .
|
||||
fi
|
||||
|
||||
# Update permissions for node_modules folders
|
||||
# https://code.visualstudio.com/remote/advancedcontainers/improve-performance#_use-a-targeted-named-volume
|
||||
if [ -d node_modules ]; then
|
||||
sudo chown $(id -u):$(id -g) node_modules
|
||||
fi
|
||||
|
||||
if [ -d client/node_modules ]; then
|
||||
sudo chown $(id -u):$(id -g) client/node_modules
|
||||
fi
|
||||
|
||||
# Install packages for the server
|
||||
if [ -f package.json ]; then
|
||||
npm ci
|
||||
fi
|
||||
|
||||
# Install packages and build the client
|
||||
if [ -f client/package.json ]; then
|
||||
(cd client; npm ci; npm run generate)
|
||||
fi
|
||||
@@ -12,4 +12,5 @@ dev.js
|
||||
test/
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
/dist/
|
||||
/dist/
|
||||
/deploy/
|
||||
5
.gitattributes
vendored
Normal file
5
.gitattributes
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Set the default behavior, in case people don't have core.autocrlf set.
|
||||
* text=auto
|
||||
|
||||
# Declare files that will always have CRLF line endings on checkout.
|
||||
.devcontainer/post-create.sh text eol=lf
|
||||
7
.github/ISSUE_TEMPLATE/bug.yaml
vendored
7
.github/ISSUE_TEMPLATE/bug.yaml
vendored
@@ -9,6 +9,12 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "### Join the [discord server](https://discord.gg/pJsjuNCKRq) for questions or if you are not sure about a bug."
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "## Be as descriptive as you can. Include screenshots, error logs, browser, file types, everything you can think of that might be relevant."
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
@@ -27,6 +33,7 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: Audiobookshelf version
|
||||
description: Do not put 'Latest version', please put the actual version here
|
||||
placeholder: "e.g. v1.6.60"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord
|
||||
url: https://discord.gg/pJsjuNCKRq
|
||||
about: Ask questions, get help troubleshooting, and join the Abs community here.
|
||||
- name: Matrix
|
||||
url: https://matrix.to/#/#audiobookshelf:matrix.org
|
||||
about: Ask questions, get help troubleshooting, and join the Abs community here.
|
||||
82
.github/workflows/docker-build.yml
vendored
Normal file
82
.github/workflows/docker-build.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
# Allows you to run workflow manually from Actions tab
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tags:
|
||||
description: 'Docker Tag'
|
||||
required: true
|
||||
default: 'latest'
|
||||
push:
|
||||
branches: [main,master]
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
# Only build when files in these directories have been changed
|
||||
paths:
|
||||
- client/**
|
||||
- server/**
|
||||
- index.js
|
||||
- package.json
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: "!contains(github.event.head_commit.message, 'skip ci')"
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
|
||||
tags: |
|
||||
type=edge,branch=master
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Login to Dockerhub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Login to ghcr
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GHCR_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||
|
||||
- name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
44
.github/workflows/integration-test.yml
vendored
Normal file
44
.github/workflows/integration-test.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Integration Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build and test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: setup nade
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: install pkg
|
||||
run: npm install -g pkg
|
||||
|
||||
- name: get client dependencies
|
||||
working-directory: client
|
||||
run: npm ci
|
||||
|
||||
- name: build client
|
||||
working-directory: client
|
||||
run: npm run generate
|
||||
|
||||
- name: get server dependencies
|
||||
run: npm ci --only=production
|
||||
|
||||
- name: build binary
|
||||
run: pkg -t node18-linux-x64 -o audiobookshelf .
|
||||
|
||||
- name: run audiobookshelf
|
||||
run: |
|
||||
./audiobookshelf &
|
||||
sleep 5
|
||||
|
||||
- name: test if server is available
|
||||
run: curl -sf http://127.0.0.1:3333 | grep Audiobookshelf
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,14 +1,17 @@
|
||||
.env
|
||||
dev.js
|
||||
node_modules/
|
||||
/dev.js
|
||||
**/node_modules/
|
||||
/config/
|
||||
/audiobooks/
|
||||
/audiobooks2/
|
||||
/podcasts/
|
||||
/media/
|
||||
/metadata/
|
||||
test/
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
/dist/
|
||||
/deploy/
|
||||
|
||||
sw.*
|
||||
sw.*
|
||||
.DS_STORE
|
||||
|
||||
44
.vscode/launch.json
vendored
Normal file
44
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug server",
|
||||
"runtimeExecutable": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug client (nuxt)",
|
||||
"runtimeExecutable": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/client",
|
||||
"skipFiles": [
|
||||
"${workspaceFolder}/<node_internals>/**"
|
||||
]
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Debug server and client (nuxt)",
|
||||
"configurations": [
|
||||
"Debug server",
|
||||
"Debug client (nuxt)"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
20
.vscode/settings.json
vendored
Normal file
20
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"vetur.format.defaultFormatterOptions": {
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 400,
|
||||
"proseWrap": "never",
|
||||
"trailingComma": "none"
|
||||
},
|
||||
"prettyhtml": {
|
||||
"printWidth": 400,
|
||||
"singleQuote": false,
|
||||
"wrapAttributes": false,
|
||||
"sortAttributes": false
|
||||
}
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.detectIndentation": true,
|
||||
"editor.tabSize": 2
|
||||
}
|
||||
40
.vscode/tasks.json
vendored
Normal file
40
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"path": "client",
|
||||
"type": "npm",
|
||||
"script": "generate",
|
||||
"detail": "nuxt generate",
|
||||
"label": "Build client",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"dependsOn": [
|
||||
"Build client"
|
||||
],
|
||||
"type": "npm",
|
||||
"script": "dev",
|
||||
"detail": "nodemon --watch server index.js",
|
||||
"label": "Run server",
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "client",
|
||||
"type": "npm",
|
||||
"script": "dev",
|
||||
"detail": "nuxt",
|
||||
"label": "Run Live-reload client",
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
36
Dockerfile
36
Dockerfile
@@ -1,19 +1,37 @@
|
||||
### STAGE 0: Build client ###
|
||||
FROM node:12-alpine AS build
|
||||
FROM node:16-alpine AS build
|
||||
WORKDIR /client
|
||||
COPY /client /client
|
||||
RUN npm install
|
||||
RUN npm ci && npm cache clean --force
|
||||
RUN npm run generate
|
||||
|
||||
### STAGE 1: Build server ###
|
||||
FROM node:12-alpine
|
||||
RUN apk update && apk add --no-cache --update ffmpeg
|
||||
FROM sandreas/tone:v0.1.5 AS tone
|
||||
FROM node:16-alpine
|
||||
|
||||
ENV NODE_ENV=production
|
||||
RUN apk update && \
|
||||
apk add --no-cache --update \
|
||||
curl \
|
||||
tzdata \
|
||||
ffmpeg \
|
||||
make \
|
||||
python3 \
|
||||
g++
|
||||
|
||||
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
|
||||
COPY --from=build /client/dist /client/dist
|
||||
COPY index.js index.js
|
||||
COPY package-lock.json package-lock.json
|
||||
COPY package.json package.json
|
||||
COPY index.js package* /
|
||||
COPY server server
|
||||
RUN npm ci --production
|
||||
|
||||
RUN npm ci --only=production
|
||||
|
||||
RUN apk del make python3 g++
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["npm", "start"]
|
||||
HEALTHCHECK \
|
||||
--interval=30s \
|
||||
--timeout=3s \
|
||||
--start-period=10s \
|
||||
CMD curl -f http://127.0.0.1/healthcheck || exit 1
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
@@ -2,49 +2,11 @@
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
|
||||
ABS_LOG_DIR="/var/log/audiobookshelf"
|
||||
|
||||
declare -r init_type='auto'
|
||||
declare -ri no_rebuild='0'
|
||||
|
||||
add_user() {
|
||||
: "${1:?'User was not defined'}"
|
||||
declare -r user="$1"
|
||||
declare -r uid="$2"
|
||||
|
||||
if [ -z "$uid" ]; then
|
||||
declare -r uid_flags=""
|
||||
else
|
||||
declare -r uid_flags="--uid $uid"
|
||||
fi
|
||||
|
||||
declare -r group="${3:-$user}"
|
||||
declare -r descr="${4:-No description}"
|
||||
declare -r shell="${5:-/bin/false}"
|
||||
|
||||
if ! getent passwd | grep -q "^$user:"; then
|
||||
echo "Creating system user: $user in $group with $descr and shell $shell"
|
||||
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
|
||||
fi
|
||||
}
|
||||
|
||||
add_group() {
|
||||
: "${1:?'Group was not defined'}"
|
||||
declare -r group="$1"
|
||||
declare -r gid="$2"
|
||||
|
||||
if [ -z "$gid" ]; then
|
||||
declare -r gid_flags=""
|
||||
else
|
||||
declare -r gid_flags="--gid $gid"
|
||||
fi
|
||||
|
||||
if ! getent group | grep -q "^$group:" ; then
|
||||
echo "Creating system group: $group"
|
||||
groupadd $gid_flags --system $group
|
||||
fi
|
||||
}
|
||||
|
||||
start_service () {
|
||||
: "${1:?'Service name was not defined'}"
|
||||
declare -r service_name="$1"
|
||||
@@ -76,13 +38,10 @@ start_service () {
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
add_group 'audiobookshelf' ''
|
||||
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
|
||||
|
||||
mkdir -p '/var/log/audiobookshelf'
|
||||
chown -R 'audiobookshelf:audiobookshelf' '/var/log/audiobookshelf'
|
||||
chown -R 'audiobookshelf:audiobookshelf' '/usr/share/audiobookshelf'
|
||||
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
|
||||
# Create log directory if not there and set ownership
|
||||
if [ ! -d "$ABS_LOG_DIR" ]; then
|
||||
mkdir -p "$ABS_LOG_DIR"
|
||||
chown -R 'audiobookshelf:audiobookshelf' "$ABS_LOG_DIR"
|
||||
fi
|
||||
|
||||
start_service 'audiobookshelf'
|
||||
|
||||
@@ -2,21 +2,60 @@
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
|
||||
DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks"
|
||||
DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
|
||||
DEFAULT_PORT=7331
|
||||
|
||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg"
|
||||
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
|
||||
CONFIG_PATH="/etc/default/audiobookshelf"
|
||||
DEFAULT_PORT=13378
|
||||
DEFAULT_HOST="0.0.0.0"
|
||||
|
||||
add_user() {
|
||||
: "${1:?'User was not defined'}"
|
||||
declare -r user="$1"
|
||||
declare -r uid="$2"
|
||||
|
||||
if [ -z "$uid" ]; then
|
||||
declare -r uid_flags=""
|
||||
else
|
||||
declare -r uid_flags="--uid $uid"
|
||||
fi
|
||||
|
||||
declare -r group="${3:-$user}"
|
||||
declare -r descr="${4:-No description}"
|
||||
declare -r shell="${5:-/bin/false}"
|
||||
|
||||
if ! getent passwd | grep -q "^$user:"; then
|
||||
echo "Creating system user: $user in $group with $descr and shell $shell"
|
||||
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
|
||||
fi
|
||||
}
|
||||
|
||||
add_group() {
|
||||
: "${1:?'Group was not defined'}"
|
||||
declare -r group="$1"
|
||||
declare -r gid="$2"
|
||||
|
||||
if [ -z "$gid" ]; then
|
||||
declare -r gid_flags=""
|
||||
else
|
||||
declare -r gid_flags="--gid $gid"
|
||||
fi
|
||||
|
||||
if ! getent group | grep -q "^$group:" ; then
|
||||
echo "Creating system group: $group"
|
||||
groupadd $gid_flags --system $group
|
||||
fi
|
||||
}
|
||||
|
||||
install_ffmpeg() {
|
||||
echo "Starting FFMPEG Install"
|
||||
|
||||
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz"
|
||||
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz --output-document=ffmpeg-git-amd64-static.tar.xz"
|
||||
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.5/tone-0.1.5-linux-x64.tar.gz --output-document=tone-0.1.5-linux-x64.tar.gz"
|
||||
|
||||
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
||||
echo "WARNING: can't access working directory ($FFMPEG_INSTALL_DIR) creating it" >&2
|
||||
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
|
||||
mkdir "$FFMPEG_INSTALL_DIR"
|
||||
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
|
||||
cd "$FFMPEG_INSTALL_DIR"
|
||||
fi
|
||||
|
||||
@@ -24,88 +63,44 @@ install_ffmpeg() {
|
||||
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1
|
||||
rm ffmpeg-git-amd64-static.tar.xz
|
||||
|
||||
echo "Good to go on Ffmpeg... hopefully"
|
||||
}
|
||||
# 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
|
||||
rm tone-0.1.5-linux-x64.tar.gz
|
||||
|
||||
should_build_config() {
|
||||
if [ -f "$CONFIG_PATH" ]; then
|
||||
echo "You already have a config file. Do you want to use it?"
|
||||
|
||||
options=("Yes" "No")
|
||||
select yn in "${options[@]}"
|
||||
do
|
||||
case $yn in
|
||||
"Yes")
|
||||
false; return
|
||||
;;
|
||||
"No")
|
||||
true; return
|
||||
;;
|
||||
esac
|
||||
done
|
||||
else
|
||||
echo "No existing config found in $CONFIG_PATH"
|
||||
true; return
|
||||
fi
|
||||
}
|
||||
|
||||
setup_config_interactive() {
|
||||
if should_build_config; then
|
||||
echo "Okay, let's setup a new config."
|
||||
|
||||
AUDIOBOOK_PATH=""
|
||||
read -p "
|
||||
Enter path for your audiobooks [Default: $DEFAULT_AUDIOBOOK_PATH]:" AUDIOBOOK_PATH
|
||||
|
||||
if [[ -z "$AUDIOBOOK_PATH" ]]; then
|
||||
AUDIOBOOK_PATH="$DEFAULT_AUDIOBOOK_PATH"
|
||||
fi
|
||||
|
||||
DATA_PATH=""
|
||||
read -p "
|
||||
Enter path for data files, i.e. streams, downloads, database [Default: $DEFAULT_DATA_PATH]:" DATA_PATH
|
||||
|
||||
if [[ -z "$DATA_PATH" ]]; then
|
||||
DATA_PATH="$DEFAULT_DATA_PATH"
|
||||
fi
|
||||
|
||||
PORT=""
|
||||
read -p "
|
||||
Port for the web ui [Default: $DEFAULT_PORT]:" PORT
|
||||
|
||||
if [[ -z "$PORT" ]]; then
|
||||
PORT="$DEFAULT_PORT"
|
||||
fi
|
||||
|
||||
config_text="AUDIOBOOK_PATH=$AUDIOBOOK_PATH
|
||||
METADATA_PATH=$DATA_PATH/metadata
|
||||
CONFIG_PATH=$DATA_PATH/config
|
||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
||||
PORT=$PORT"
|
||||
|
||||
echo "$config_text"
|
||||
|
||||
echo "$config_text" > /etc/default/audiobookshelf;
|
||||
|
||||
echo "Config created"
|
||||
|
||||
fi
|
||||
echo "Good to go on Ffmpeg (& tone)... hopefully"
|
||||
}
|
||||
|
||||
setup_config() {
|
||||
if [ -f "$CONFIG_PATH" ]; then
|
||||
echo "Existing config found."
|
||||
cat $CONFIG_PATH
|
||||
|
||||
# TONE_PATH variable added in 2.1.6, if it doesnt exist then add it
|
||||
if ! grep -q "TONE_PATH" "$CONFIG_PATH"; then
|
||||
echo "Adding TONE_PATH to existing config"
|
||||
echo "TONE_PATH=$FFMPEG_INSTALL_DIR/tone" >> "$CONFIG_PATH"
|
||||
fi
|
||||
|
||||
else
|
||||
|
||||
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
|
||||
# Create directory and set permissions
|
||||
echo "Creating default data dir at $DEFAULT_DATA_DIR"
|
||||
mkdir "$DEFAULT_DATA_DIR"
|
||||
chown -R 'audiobookshelf:audiobookshelf' "$DEFAULT_DATA_DIR"
|
||||
fi
|
||||
|
||||
echo "Creating default config."
|
||||
|
||||
config_text="AUDIOBOOK_PATH=$DEFAULT_AUDIOBOOK_PATH
|
||||
METADATA_PATH=$DEFAULT_DATA_PATH/metadata
|
||||
CONFIG_PATH=$DEFAULT_DATA_PATH/config
|
||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
||||
PORT=$DEFAULT_PORT"
|
||||
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
|
||||
CONFIG_PATH=$DEFAULT_DATA_DIR/config
|
||||
FFMPEG_PATH=$FFMPEG_INSTALL_DIR/ffmpeg
|
||||
FFPROBE_PATH=$FFMPEG_INSTALL_DIR/ffprobe
|
||||
TONE_PATH=$FFMPEG_INSTALL_DIR/tone
|
||||
PORT=$DEFAULT_PORT
|
||||
HOST=$DEFAULT_HOST"
|
||||
|
||||
echo "$config_text"
|
||||
|
||||
@@ -115,6 +110,10 @@ setup_config() {
|
||||
fi
|
||||
}
|
||||
|
||||
add_group 'audiobookshelf' ''
|
||||
|
||||
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
|
||||
|
||||
setup_config
|
||||
|
||||
install_ffmpeg
|
||||
|
||||
@@ -10,7 +10,7 @@ ExecStart=/usr/share/audiobookshelf/audiobookshelf
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
User=audiobookshelf
|
||||
PermissionsStartOnly=true
|
||||
Group=audiobookshelf
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -48,7 +48,7 @@ Description: $DESCRIPTION"
|
||||
echo "$controlfile" > dist/debian/DEBIAN/control;
|
||||
|
||||
# Package debian
|
||||
pkg -t node12-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
||||
pkg -t node16-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
||||
|
||||
fakeroot dpkg-deb --build dist/debian
|
||||
|
||||
|
||||
106
client/assets/absicons.css
Normal file
106
client/assets/absicons.css
Normal file
@@ -0,0 +1,106 @@
|
||||
@font-face {
|
||||
font-family: 'absicons';
|
||||
src: url('~static/fonts/absicons/absicons.eot?2jfq33');
|
||||
src: url('~static/fonts/absicons/absicons.eot?2jfq33#iefix') format('embedded-opentype'),
|
||||
url('~static/fonts/absicons/absicons.ttf?2jfq33') format('truetype'),
|
||||
url('~static/fonts/absicons/absicons.woff?2jfq33') format('woff'),
|
||||
url('~static/fonts/absicons/absicons.svg?2jfq33#absicons') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
.abs-icons {
|
||||
/* use !important to prevent issues with browser extensions that change fonts */
|
||||
font-family: 'absicons' !important;
|
||||
speak: never;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
|
||||
/* Better Font Rendering =========== */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-books-1:before {
|
||||
content: "\e905";
|
||||
}
|
||||
|
||||
.icon-microphone-1:before {
|
||||
content: "\e902";
|
||||
}
|
||||
|
||||
.icon-radio:before {
|
||||
content: "\e903";
|
||||
}
|
||||
|
||||
.icon-podcast:before {
|
||||
content: "\e904";
|
||||
}
|
||||
|
||||
.icon-audiobookshelf:before {
|
||||
content: "\e900";
|
||||
}
|
||||
|
||||
.icon-database:before {
|
||||
content: "\e906";
|
||||
}
|
||||
|
||||
.icon-microphone-2:before {
|
||||
content: "\e901";
|
||||
}
|
||||
|
||||
.icon-headphones:before {
|
||||
content: "\e910";
|
||||
}
|
||||
|
||||
.icon-music:before {
|
||||
content: "\e911";
|
||||
}
|
||||
|
||||
.icon-video:before {
|
||||
content: "\e914";
|
||||
}
|
||||
|
||||
.icon-microphone-3:before {
|
||||
content: "\e91e";
|
||||
}
|
||||
|
||||
.icon-book-1:before {
|
||||
content: "\e91f";
|
||||
}
|
||||
|
||||
.icon-books-2:before {
|
||||
content: "\e920";
|
||||
}
|
||||
|
||||
.icon-file-picture:before {
|
||||
content: "\e927";
|
||||
}
|
||||
|
||||
.icon-database-1:before {
|
||||
content: "\e964";
|
||||
}
|
||||
|
||||
.icon-rocket:before {
|
||||
content: "\e9a5";
|
||||
}
|
||||
|
||||
.icon-power:before {
|
||||
content: "\e9b5";
|
||||
}
|
||||
|
||||
.icon-star:before {
|
||||
content: "\e9d9";
|
||||
}
|
||||
|
||||
.icon-heart:before {
|
||||
content: "\e9da";
|
||||
}
|
||||
|
||||
.icon-rss:before {
|
||||
content: "\ea9b";
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
@import './fonts.css';
|
||||
@import './transitions.css';
|
||||
@import './draggable.css';
|
||||
@import './defaultStyles.css';
|
||||
@import './absicons.css';
|
||||
|
||||
:root {
|
||||
--bookshelf-texture-img: url(/textures/wood_default.jpg);
|
||||
@@ -12,18 +14,34 @@
|
||||
height: calc(100% - 64px);
|
||||
max-height: calc(100% - 64px);
|
||||
}
|
||||
|
||||
.page.streaming {
|
||||
height: calc(100% - 64px - 165px);
|
||||
max-height: calc(100% - 64px - 165px);
|
||||
}
|
||||
|
||||
#bookshelf {
|
||||
height: calc(100% - 40px);
|
||||
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
|
||||
|
||||
/* For Firefox */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #855620 rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.bookshelf-row {
|
||||
/* Sidebar width + scrollbar width */
|
||||
width: calc(100vw - 88px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#bookshelf {
|
||||
height: calc(100% - 80px);
|
||||
}
|
||||
|
||||
.bookshelf-row {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
#page-wrapper {
|
||||
@@ -34,36 +52,25 @@
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar:horizontal {
|
||||
height: 8px;
|
||||
}
|
||||
/* ::-webkit-scrollbar:horizontal { */
|
||||
/* height: 16px; */
|
||||
/* height: 24px;
|
||||
} */
|
||||
|
||||
/* Track */
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: rgba(0,0,0,0);
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
/* ::-webkit-scrollbar-track:horizontal { */
|
||||
/* background: rgb(149, 119, 90); */
|
||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
||||
/* background: linear-gradient(180deg, rgb(117, 88, 60) 0%, rgb(65, 41, 17) 17%, rgb(71, 43, 15) 88%, rgb(3, 2, 1) 100%);
|
||||
box-shadow: 2px 14px 8px #111111aa;
|
||||
} */
|
||||
|
||||
/* Handle */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #855620;
|
||||
background: #855620;
|
||||
border-radius: 4px;
|
||||
}
|
||||
/* ::-webkit-scrollbar-thumb:horizontal { */
|
||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
||||
/* box-shadow: 2px 14px 8px #111111aa;
|
||||
border-radius: 4px;
|
||||
} */
|
||||
|
||||
/* Handle on hover */
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #704922;
|
||||
background: #704922;
|
||||
}
|
||||
|
||||
.no-scroll::-webkit-scrollbar {
|
||||
@@ -71,6 +78,13 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.no-scroll {
|
||||
-ms-overflow-style: none;
|
||||
/* IE and Edge */
|
||||
scrollbar-width: none;
|
||||
/* Firefox */
|
||||
}
|
||||
|
||||
/* Chrome, Safari, Edge, Opera */
|
||||
.no-spinner::-webkit-outer-spin-button,
|
||||
.no-spinner::-webkit-inner-spin-button {
|
||||
@@ -89,18 +103,23 @@ input[type=number] {
|
||||
width: 100%;
|
||||
border: 1px solid #474747;
|
||||
}
|
||||
|
||||
.tracksTable tr:nth-child(even) {
|
||||
background-color: #2e2e2e;
|
||||
}
|
||||
|
||||
.tracksTable tr {
|
||||
background-color: #373838;
|
||||
}
|
||||
.tracksTable tr:hover {
|
||||
|
||||
.tracksTable tr:hover:not(:has(th)) {
|
||||
background-color: #474747;
|
||||
}
|
||||
|
||||
.tracksTable td {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.tracksTable th {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
@@ -113,13 +132,22 @@ input[type=number] {
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid white;
|
||||
}
|
||||
|
||||
.arrow-down-small {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 4px solid currentColor;
|
||||
}
|
||||
|
||||
.triangle-right {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 8px solid transparent;
|
||||
border-bottom: 8px solid transparent;
|
||||
border-top: 8px solid rgb(34,127,35);
|
||||
border-right: 8px solid rgb(34,127,35);
|
||||
border-top: 8px solid rgb(34, 127, 35);
|
||||
border-right: 8px solid rgb(34, 127, 35);
|
||||
}
|
||||
|
||||
.icon-text {
|
||||
@@ -149,6 +177,7 @@ input[type=number] {
|
||||
.box-shadow-book {
|
||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||
}
|
||||
|
||||
.shadow-height {
|
||||
height: calc(100% - 4px);
|
||||
}
|
||||
@@ -165,9 +194,9 @@ input[type=number] {
|
||||
Bookshelf Label
|
||||
*/
|
||||
.categoryPlacard {
|
||||
background-image: url(https://image.freepik.com/free-photo/brown-wooden-textured-flooring-background_53876-128537.jpg);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.shinyBlack {
|
||||
background-color: #2d3436;
|
||||
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
|
||||
@@ -187,3 +216,46 @@ Bookshelf Label
|
||||
opacity: 1;
|
||||
filter: blur(20px);
|
||||
}
|
||||
|
||||
|
||||
.episode-subtitle {
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
line-height: 16px;
|
||||
/* fallback */
|
||||
max-height: 32px;
|
||||
/* fallback */
|
||||
-webkit-line-clamp: 2;
|
||||
/* number of lines to show */
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.episode-subtitle-long {
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
line-height: 16px;
|
||||
/* fallback */
|
||||
max-height: 72px;
|
||||
/* fallback */
|
||||
-webkit-line-clamp: 6;
|
||||
/* number of lines to show */
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
|
||||
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
|
||||
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
|
||||
padding-top: 104px;
|
||||
}
|
||||
|
||||
.app-bar .Vue-Toastification__container.top-right {
|
||||
padding-top: 64px;
|
||||
}
|
||||
|
||||
.no-bars .Vue-Toastification__container.top-right {
|
||||
padding-top: 8px;
|
||||
}
|
||||
55
client/assets/defaultStyles.css
Normal file
55
client/assets/defaultStyles.css
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
|
||||
This is for setting regular html styles for places where embedding HTML will be
|
||||
like podcast episode descriptions. Otherwise TailwindCSS will have stripped all default markup.
|
||||
|
||||
*/
|
||||
|
||||
.default-style p {
|
||||
display: block;
|
||||
margin-block-start: 1em;
|
||||
margin-block-end: 1em;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
|
||||
.default-style a {
|
||||
text-decoration: none;
|
||||
color: #5985ff;
|
||||
}
|
||||
|
||||
.default-style ul {
|
||||
display: block;
|
||||
list-style: circle;
|
||||
list-style-type: disc;
|
||||
margin-block-start: 1em;
|
||||
margin-block-end: 1em;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
padding-inline-start: 40px;
|
||||
}
|
||||
|
||||
.default-style ol {
|
||||
display: block;
|
||||
list-style: decimal;
|
||||
list-style-type: decimal;
|
||||
margin-block-start: 1em;
|
||||
margin-block-end: 1em;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
padding-inline-start: 40px;
|
||||
}
|
||||
|
||||
.default-style li {
|
||||
display: list-item;
|
||||
text-align: -webkit-match-parent;
|
||||
}
|
||||
|
||||
.default-style li::marker {
|
||||
unicode-bidi: isolate;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-transform: none;
|
||||
text-indent: 0px !important;
|
||||
text-align: start !important;
|
||||
text-align-last: start !important;
|
||||
}
|
||||
@@ -1,34 +1,40 @@
|
||||
.flip-list-move {
|
||||
transition: transform 0.5s;
|
||||
}
|
||||
|
||||
.no-move {
|
||||
transition: transform 0s;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.list-group {
|
||||
min-height: 30px;
|
||||
}
|
||||
#librariesTable .item {
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.list-group-item:not(.exclude) {
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.list-group-item.exclude {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.list-group-item:not(.ghost):not(.exclude):hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude) {
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -36,6 +42,7 @@
|
||||
.list-group-item.exclude:not(.ghost) {
|
||||
background-color: rgba(255, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.list-group-item.exclude:not(.ghost):hover {
|
||||
background-color: rgba(223, 0, 0, 0.25);
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(/fonts/MaterialIcons.woff2) format('woff2');
|
||||
src: url(~static/fonts/MaterialIcons.woff2) format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons Outlined';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(/fonts/MaterialIconsOutlined.woff2) format('woff2');
|
||||
src: url(~static/fonts/MaterialIconsOutlined.woff2) format('woff2');
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
@@ -23,12 +23,13 @@
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
||||
|
||||
.material-icons:not([class*="text-"]) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.material-icons-outlined {
|
||||
font-family: 'Material Icons Outlined';
|
||||
font-weight: normal;
|
||||
@@ -40,28 +41,279 @@
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
||||
|
||||
.material-icons-outlined:not([class*="text-"]) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Gentium Book Basic';
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Gentium Book Basic';
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@@ -20,42 +20,67 @@
|
||||
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
.slide-enter-to, .slide-leave {
|
||||
.slide-enter-to,
|
||||
.slide-leave {
|
||||
max-height: 600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slide-enter, .slide-leave-to {
|
||||
.slide-enter,
|
||||
.slide-leave-to {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.menu-enter, .menu-leave-active {
|
||||
.menu-enter,
|
||||
.menu-leave-active {
|
||||
transform: translateY(-15px);
|
||||
}
|
||||
|
||||
.menu-enter-active {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.menu-leave-active {
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.menu-enter,
|
||||
.menu-leave-active {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.menux-enter, .menux-leave-active {
|
||||
.menux-enter,
|
||||
.menux-leave-active {
|
||||
transform: translateX(15px);
|
||||
}
|
||||
|
||||
.menux-enter-active {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.menux-leave-active {
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.menux-enter,
|
||||
.menux-leave-active {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
||||
.list-complete-item {
|
||||
transition: all 0.8s ease;
|
||||
}
|
||||
|
||||
.list-complete-enter-from,
|
||||
.list-complete-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
.list-complete-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
563
client/assets/trix.css
Normal file
563
client/assets/trix.css
Normal file
@@ -0,0 +1,563 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
/*
|
||||
Trix 1.3.1
|
||||
Copyright © 2020 Basecamp, LLC
|
||||
http://trix-editor.org/*/
|
||||
trix-editor {
|
||||
border: 1px solid rgb(75, 85, 99);
|
||||
border-radius: 3px;
|
||||
background: rgb(35, 35, 35);
|
||||
margin: 0;
|
||||
padding: 0.4em 0.6em;
|
||||
min-height: 5em;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
trix-toolbar * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button-row {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button-group {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid rgb(75, 85, 99);
|
||||
border-top-color: rgb(75, 85, 99);
|
||||
border-bottom-color: rgb(75, 85, 99);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button-group:not(:first-child) {
|
||||
margin-left: 1.5vw;
|
||||
}
|
||||
|
||||
@media (max-device-width: 768px) {
|
||||
trix-toolbar .trix-button-group:not(:first-child) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button-group-spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@media (max-device-width: 768px) {
|
||||
trix-toolbar .trix-button-group-spacer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button {
|
||||
position: relative;
|
||||
float: left;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
padding: 0 0.5em;
|
||||
margin: 0;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button:not(:first-child) {
|
||||
border-left: 1px solid rgb(75, 85, 99);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button.trix-active {
|
||||
background: #bbb;
|
||||
color: black;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button:not(:disabled) {
|
||||
cursor: pointer;
|
||||
background: rgb(35, 35, 35);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button:disabled {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
@media (max-device-width: 768px) {
|
||||
trix-toolbar .trix-button {
|
||||
letter-spacing: -0.01em;
|
||||
padding: 0 0.3em;
|
||||
}
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon {
|
||||
font-size: inherit;
|
||||
width: 2.6em;
|
||||
height: 1.6em;
|
||||
max-width: calc(0.8em + 4vw);
|
||||
text-indent: -9999px;
|
||||
}
|
||||
|
||||
@media (max-device-width: 768px) {
|
||||
trix-toolbar .trix-button--icon {
|
||||
height: 2em;
|
||||
max-width: calc(0.8em + 3.5vw);
|
||||
}
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon::before {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0.6;
|
||||
content: "";
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
filter: invert(100%);
|
||||
}
|
||||
|
||||
@media (max-device-width: 768px) {
|
||||
trix-toolbar .trix-button--icon::before {
|
||||
right: 6%;
|
||||
left: 6%;
|
||||
}
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon.trix-active::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon:disabled::before {
|
||||
opacity: 0.125;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-attach::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M16.5%206v11.5a4%204%200%201%201-8%200V5a2.5%202.5%200%200%201%205%200v10.5a1%201%200%201%201-2%200V6H10v9.5a2.5%202.5%200%200%200%205%200V5a4%204%200%201%200-8%200v12.5a5.5%205.5%200%200%200%2011%200V6h-1.5z%22%2F%3E%3C%2Fsvg%3E);
|
||||
top: 8%;
|
||||
bottom: 4%;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-bold::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M15.6%2011.8c1-.7%201.6-1.8%201.6-2.8a4%204%200%200%200-4-4H7v14h7c2.1%200%203.7-1.7%203.7-3.8%200-1.5-.8-2.8-2.1-3.4zM10%207.5h3a1.5%201.5%200%201%201%200%203h-3v-3zm3.5%209H10v-3h3.5a1.5%201.5%200%201%201%200%203z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-italic::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M10%205v3h2.2l-3.4%208H6v3h8v-3h-2.2l3.4-8H18V5h-8z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-link::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M9.88%2013.7a4.3%204.3%200%200%201%200-6.07l3.37-3.37a4.26%204.26%200%200%201%206.07%200%204.3%204.3%200%200%201%200%206.06l-1.96%201.72a.91.91%200%201%201-1.3-1.3l1.97-1.71a2.46%202.46%200%200%200-3.48-3.48l-3.38%203.37a2.46%202.46%200%200%200%200%203.48.91.91%200%201%201-1.3%201.3z%22%2F%3E%3Cpath%20d%3D%22M4.25%2019.46a4.3%204.3%200%200%201%200-6.07l1.93-1.9a.91.91%200%201%201%201.3%201.3l-1.93%201.9a2.46%202.46%200%200%200%203.48%203.48l3.37-3.38c.96-.96.96-2.52%200-3.48a.91.91%200%201%201%201.3-1.3%204.3%204.3%200%200%201%200%206.07l-3.38%203.38a4.26%204.26%200%200%201-6.07%200z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-strike::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12.73%2014l.28.14c.26.15.45.3.57.44.12.14.18.3.18.5%200%20.3-.15.56-.44.75-.3.2-.76.3-1.39.3A13.52%2013.52%200%200%201%207%2014.95v3.37a10.64%2010.64%200%200%200%204.84.88c1.26%200%202.35-.19%203.28-.56.93-.37%201.64-.9%202.14-1.57s.74-1.45.74-2.32c0-.26-.02-.51-.06-.75h-5.21zm-5.5-4c-.08-.34-.12-.7-.12-1.1%200-1.29.52-2.3%201.58-3.02%201.05-.72%202.5-1.08%204.34-1.08%201.62%200%203.28.34%204.97%201l-1.3%202.93c-1.47-.6-2.73-.9-3.8-.9-.55%200-.96.08-1.2.26-.26.17-.38.38-.38.64%200%20.27.16.52.48.74.17.12.53.3%201.05.53H7.23zM3%2013h18v-2H3v2z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-quote::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M6%2017h3l2-4V7H5v6h3zm8%200h3l2-4V7h-6v6h3z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-heading-1::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12%209v3H9v7H6v-7H3V9h9zM8%204h14v3h-6v12h-3V7H8V4z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-code::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M18.2%2012L15%2015.2l1.4%201.4L21%2012l-4.6-4.6L15%208.8l3.2%203.2zM5.8%2012L9%208.8%207.6%207.4%203%2012l4.6%204.6L9%2015.2%205.8%2012z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-bullet-list::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%204a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm0%206a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm0%206a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm4%203h14v-2H8v2zm0-6h14v-2H8v2zm0-8v2h14V5H8z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-number-list::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M2%2017h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1%203h1.8L2%2013.1v.9h3v-1H3.2L5%2010.9V10H2v1zm5-6v2h14V5H7zm0%2014h14v-2H7v2zm0-6h14v-2H7v2z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-undo::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12.5%208c-2.6%200-5%201-6.9%202.6L2%207v9h9l-3.6-3.6A8%208%200%200%201%2020%2016l2.4-.8a10.5%2010.5%200%200%200-10-7.2z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-redo::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M18.4%2010.6a10.5%2010.5%200%200%200-16.9%204.6L4%2016a8%208%200%200%201%2012.7-3.6L13%2016h9V7l-3.6%203.6z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-decrease-nesting-level::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M3%2019h19v-2H3v2zm7-6h12v-2H10v2zm-8.3-.3l2.8%202.9L6%2014.2%204%2012l2-2-1.4-1.5L1%2012l.7.7zM3%205v2h19V5H3z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-increase-nesting-level::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M3%2019h19v-2H3v2zm7-6h12v-2H10v2zm-6.9-1L1%2014.2l1.4%201.4L6%2012l-.7-.7-2.8-2.8L1%209.9%203.1%2012zM3%205v2h19V5H3z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-dialogs {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-dialog {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
font-size: 0.75em;
|
||||
padding: 15px 10px;
|
||||
background: rgb(48, 48, 48);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgb(112, 112, 112);
|
||||
border-radius: 5px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-input--dialog {
|
||||
font-size: inherit;
|
||||
font-weight: normal;
|
||||
padding: 0.5em 0.8em;
|
||||
margin: 0 10px 0 0;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #bbb;
|
||||
background-color: rgb(95, 95, 95);
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-input--dialog.validate:invalid {
|
||||
box-shadow: #F00 0px 0px 1.5px 1px;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--dialog {
|
||||
font-size: inherit;
|
||||
padding: 0.5em;
|
||||
border-bottom: none;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-dialog--link {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-dialog__link-fields {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-dialog__link-fields .trix-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-dialog__link-fields .trix-button-group {
|
||||
flex: 0 0 content;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
trix-editor [data-trix-mutable]:not(.attachment__caption-editor) {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
trix-editor [data-trix-mutable]::-moz-selection,
|
||||
trix-editor [data-trix-cursor-target]::-moz-selection,
|
||||
trix-editor [data-trix-mutable] ::-moz-selection {
|
||||
background: none;
|
||||
}
|
||||
|
||||
trix-editor [data-trix-mutable]::selection,
|
||||
trix-editor [data-trix-cursor-target]::selection,
|
||||
trix-editor [data-trix-mutable] ::selection {
|
||||
background: none;
|
||||
}
|
||||
|
||||
trix-editor [data-trix-mutable].attachment__caption-editor:focus::-moz-selection {
|
||||
background: highlight;
|
||||
}
|
||||
|
||||
trix-editor [data-trix-mutable].attachment__caption-editor:focus::selection {
|
||||
background: highlight;
|
||||
}
|
||||
|
||||
trix-editor [data-trix-mutable].attachment.attachment--file {
|
||||
box-shadow: 0 0 0 2px highlight;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
trix-editor [data-trix-mutable].attachment img {
|
||||
box-shadow: 0 0 0 2px highlight;
|
||||
}
|
||||
|
||||
trix-editor .attachment {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
trix-editor .attachment:hover {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
trix-editor .attachment--preview .attachment__caption:hover {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
trix-editor .attachment__progress {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
height: 20px;
|
||||
top: calc(50% - 10px);
|
||||
left: 5%;
|
||||
width: 90%;
|
||||
opacity: 0.9;
|
||||
transition: opacity 200ms ease-in;
|
||||
}
|
||||
|
||||
trix-editor .attachment__progress[value="100"] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
trix-editor .attachment__caption-editor {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
border: none;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
}
|
||||
|
||||
trix-editor .attachment__toolbar {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: -0.9em;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
trix-editor .trix-button-group {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
trix-editor .trix-button {
|
||||
position: relative;
|
||||
float: left;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
font-size: 80%;
|
||||
padding: 0 0.8em;
|
||||
margin: 0;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
trix-editor .trix-button:not(:first-child) {
|
||||
border-left: 1px solid #ccc;
|
||||
}
|
||||
|
||||
trix-editor .trix-button.trix-active {
|
||||
background: #cbeefa;
|
||||
}
|
||||
|
||||
trix-editor .trix-button:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
trix-editor .trix-button--remove {
|
||||
text-indent: -9999px;
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
width: 1.8em;
|
||||
height: 1.8em;
|
||||
line-height: 1.8em;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
border: 2px solid highlight;
|
||||
box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
trix-editor .trix-button--remove::before {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0.7;
|
||||
content: "";
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20height%3D%2224%22%20width%3D%2224%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M19%206.4L17.6%205%2012%2010.6%206.4%205%205%206.4l5.6%205.6L5%2017.6%206.4%2019l5.6-5.6%205.6%205.6%201.4-1.4-5.6-5.6z%22%2F%3E%3Cpath%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%2F%3E%3C%2Fsvg%3E);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 90%;
|
||||
}
|
||||
|
||||
trix-editor .trix-button--remove:hover {
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
trix-editor .trix-button--remove:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
trix-editor .attachment__metadata-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
trix-editor .attachment__metadata {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 2em;
|
||||
transform: translate(-50%, 0);
|
||||
max-width: 90%;
|
||||
padding: 0.1em 0.6em;
|
||||
font-size: 0.8em;
|
||||
color: #fff;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
trix-editor .attachment__metadata .attachment__name {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
vertical-align: bottom;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
trix-editor .attachment__metadata .attachment__size {
|
||||
margin-left: 0.2em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trix-content {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.trix-content * {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.trix-content h1 {
|
||||
font-size: 1.2em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.trix-content blockquote {
|
||||
border: 0 solid #ccc;
|
||||
border-left-width: 0.3em;
|
||||
margin-left: 0.3em;
|
||||
padding-left: 0.6em;
|
||||
}
|
||||
|
||||
.trix-content [dir=rtl] blockquote,
|
||||
.trix-content blockquote[dir=rtl] {
|
||||
border-width: 0;
|
||||
border-right-width: 0.3em;
|
||||
margin-right: 0.3em;
|
||||
padding-right: 0.6em;
|
||||
}
|
||||
|
||||
.trix-content li {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.trix-content [dir=rtl] li {
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.trix-content pre {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
vertical-align: top;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
padding: 0.5em;
|
||||
white-space: pre;
|
||||
background-color: #eee;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.trix-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.trix-content .attachment {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.trix-content .attachment a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.trix-content .attachment a:hover,
|
||||
.trix-content .attachment a:visited:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.trix-content .attachment__caption {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trix-content .attachment__caption .attachment__name+.attachment__size::before {
|
||||
content: ' · ';
|
||||
}
|
||||
|
||||
.trix-content .attachment--preview {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trix-content .attachment--preview .attachment__caption {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.trix-content .attachment--file {
|
||||
color: #333;
|
||||
line-height: 1;
|
||||
margin: 0 2px 2px 2px;
|
||||
padding: 0.4em 1em;
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.trix-content .attachment-gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trix-content .attachment-gallery .attachment {
|
||||
flex: 1 0 33%;
|
||||
padding: 0 0.5em;
|
||||
max-width: 33%;
|
||||
}
|
||||
|
||||
.trix-content .attachment-gallery.attachment-gallery--2 .attachment,
|
||||
.trix-content .attachment-gallery.attachment-gallery--4 .attachment {
|
||||
flex-basis: 50%;
|
||||
max-width: 50%;
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full -mt-6">
|
||||
<div class="w-full relative mb-1">
|
||||
<div v-if="chapters.length" class="hidden md:flex absolute right-20 top-0 bottom-0 h-full items-end">
|
||||
<div class="cursor-pointer text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||
<span class="material-icons text-3xl">format_list_bulleted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute top-0 bottom-0 h-full hidden md:flex items-end" :class="chapters.length ? ' right-32' : 'right-20'">
|
||||
<div class="cursor-pointer text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showBookmarks">
|
||||
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute top-0 bottom-0 h-full hidden md:flex items-end" :class="chapters.length ? ' right-44' : 'right-32'">
|
||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" />
|
||||
</div>
|
||||
|
||||
<div class="flex pb-4 md:pb-2">
|
||||
<div class="flex-grow" />
|
||||
<template v-if="!loading">
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
|
||||
<span class="material-icons text-3xl">first_page</span>
|
||||
</div>
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||
<span class="material-icons text-3xl">replay_10</span>
|
||||
</div>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
||||
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
||||
</div>
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||
<span class="material-icons text-3xl">forward_10</span>
|
||||
</div>
|
||||
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
|
||||
<span class="material-icons">autorenew</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<!-- Track -->
|
||||
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="bufferTrack" class="h-full bg-gray-400 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
|
||||
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
|
||||
</div>
|
||||
<div ref="track" class="w-full h-2 relative overflow-hidden">
|
||||
<template v-for="(tick, index) in chapterTicks">
|
||||
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Hover timestamp -->
|
||||
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
|
||||
</div>
|
||||
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
|
||||
<div class="arrow-down" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
||||
<p class="font-mono text-sm text-gray-100 pointer-events-auto"> / {{ progressPercent }}%</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-sm text-gray-300 pt-0.5">{{ currentChapterName }}</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
|
||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
loading: Boolean,
|
||||
paused: Boolean,
|
||||
chapters: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
bookmarks: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
volume: 1,
|
||||
playbackRate: 1,
|
||||
trackWidth: 0,
|
||||
playedTrackWidth: 0,
|
||||
bufferTrackWidth: 0,
|
||||
readyTrackWidth: 0,
|
||||
audioEl: null,
|
||||
seekLoading: false,
|
||||
showChaptersModal: false,
|
||||
currentTime: 0,
|
||||
trackOffsetLeft: 16, // Track is 16px from edge
|
||||
duration: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
token() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
timeRemaining() {
|
||||
return (this.duration - this.currentTime) / this.playbackRate
|
||||
},
|
||||
timeRemainingPretty() {
|
||||
if (this.timeRemaining < 0) {
|
||||
console.warn('Time remaining < 0', this.duration, this.currentTime, this.timeRemaining)
|
||||
return this.$secondsToTimestamp(this.timeRemaining * -1)
|
||||
}
|
||||
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
||||
},
|
||||
progressPercent() {
|
||||
if (!this.duration) return 0
|
||||
return Math.round((100 * this.currentTime) / this.duration)
|
||||
},
|
||||
chapterTicks() {
|
||||
return this.chapters.map((chap) => {
|
||||
var perc = chap.start / this.duration
|
||||
return {
|
||||
title: chap.title,
|
||||
left: perc * this.trackWidth
|
||||
}
|
||||
})
|
||||
},
|
||||
currentChapter() {
|
||||
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
||||
},
|
||||
currentChapterName() {
|
||||
return this.currentChapter ? this.currentChapter.title : ''
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setDuration(duration) {
|
||||
this.duration = duration
|
||||
},
|
||||
setCurrentTime(time) {
|
||||
this.currentTime = time
|
||||
this.updateTimestamp()
|
||||
this.updatePlayedTrack()
|
||||
},
|
||||
playPause() {
|
||||
this.$emit('playPause')
|
||||
},
|
||||
jumpBackward() {
|
||||
this.$emit('jumpBackward')
|
||||
},
|
||||
jumpForward() {
|
||||
this.$emit('jumpForward')
|
||||
},
|
||||
increaseVolume() {
|
||||
if (this.volume >= 1) return
|
||||
this.volume = Math.min(1, this.volume + 0.1)
|
||||
this.setVolume(this.volume)
|
||||
},
|
||||
decreaseVolume() {
|
||||
if (this.volume <= 0) return
|
||||
this.volume = Math.max(0, this.volume - 0.1)
|
||||
this.setVolume(this.volume)
|
||||
},
|
||||
setVolume(volume) {
|
||||
this.$emit('setVolume', volume)
|
||||
},
|
||||
toggleMute() {
|
||||
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
|
||||
this.$refs.volumeControl.toggleMute()
|
||||
}
|
||||
},
|
||||
increasePlaybackRate() {
|
||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||
if (currentRateIndex >= rates.length - 1) return
|
||||
this.playbackRate = rates[currentRateIndex + 1] || 1
|
||||
this.playbackRateChanged(this.playbackRate)
|
||||
},
|
||||
decreasePlaybackRate() {
|
||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||
if (currentRateIndex <= 0) return
|
||||
this.playbackRate = rates[currentRateIndex - 1] || 1
|
||||
this.playbackRateChanged(this.playbackRate)
|
||||
},
|
||||
setPlaybackRate(playbackRate) {
|
||||
this.$emit('setPlaybackRate', playbackRate)
|
||||
},
|
||||
selectChapter(chapter) {
|
||||
this.seek(chapter.start)
|
||||
this.showChaptersModal = false
|
||||
},
|
||||
seek(time) {
|
||||
this.$emit('seek', time)
|
||||
},
|
||||
playbackRateUpdated(playbackRate) {
|
||||
this.setPlaybackRate(playbackRate)
|
||||
},
|
||||
playbackRateChanged(playbackRate) {
|
||||
this.setPlaybackRate(playbackRate)
|
||||
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
|
||||
console.error('Failed to update settings', err)
|
||||
})
|
||||
},
|
||||
mousemoveTrack(e) {
|
||||
var offsetX = e.offsetX
|
||||
var time = (offsetX / this.trackWidth) * this.duration
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
var width = this.$refs.hoverTimestamp.clientWidth
|
||||
this.$refs.hoverTimestamp.style.opacity = 1
|
||||
var posLeft = offsetX - width / 2
|
||||
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
|
||||
posLeft = window.innerWidth - width - this.trackOffsetLeft
|
||||
} else if (posLeft < -this.trackOffsetLeft) {
|
||||
posLeft = -this.trackOffsetLeft
|
||||
}
|
||||
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
|
||||
}
|
||||
|
||||
if (this.$refs.hoverTimestampArrow) {
|
||||
var width = this.$refs.hoverTimestampArrow.clientWidth
|
||||
var posLeft = offsetX - width / 2
|
||||
this.$refs.hoverTimestampArrow.style.opacity = 1
|
||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||
}
|
||||
if (this.$refs.hoverTimestampText) {
|
||||
var hoverText = this.$secondsToTimestamp(time)
|
||||
|
||||
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
|
||||
if (chapter && chapter.title) {
|
||||
hoverText += ` - ${chapter.title}`
|
||||
}
|
||||
this.$refs.hoverTimestampText.innerText = hoverText
|
||||
}
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.opacity = 1
|
||||
this.$refs.trackCursor.style.left = offsetX - 1 + 'px'
|
||||
}
|
||||
},
|
||||
mouseleaveTrack() {
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
this.$refs.hoverTimestamp.style.opacity = 0
|
||||
}
|
||||
if (this.$refs.hoverTimestampArrow) {
|
||||
this.$refs.hoverTimestampArrow.style.opacity = 0
|
||||
}
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.opacity = 0
|
||||
}
|
||||
},
|
||||
restart() {
|
||||
this.seek(0)
|
||||
},
|
||||
setStreamReady() {
|
||||
this.readyTrackWidth = this.trackWidth
|
||||
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
|
||||
},
|
||||
setChunksReady(chunks, numSegments) {
|
||||
var largestSeg = 0
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
var chunk = chunks[i]
|
||||
if (typeof chunk === 'string') {
|
||||
var chunkRange = chunk.split('-').map((c) => Number(c))
|
||||
if (chunkRange.length < 2) continue
|
||||
if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1]
|
||||
} else if (chunk > largestSeg) {
|
||||
largestSeg = chunk
|
||||
}
|
||||
}
|
||||
var percentageReady = largestSeg / numSegments
|
||||
var widthReady = Math.round(this.trackWidth * percentageReady)
|
||||
if (this.readyTrackWidth === widthReady) return
|
||||
this.readyTrackWidth = widthReady
|
||||
this.$refs.readyTrack.style.width = widthReady + 'px'
|
||||
},
|
||||
updateTimestamp() {
|
||||
var ts = this.$refs.currentTimestamp
|
||||
if (!ts) {
|
||||
console.error('No timestamp el')
|
||||
return
|
||||
}
|
||||
var currTimeClean = this.$secondsToTimestamp(this.currentTime)
|
||||
ts.innerText = currTimeClean
|
||||
},
|
||||
updatePlayedTrack() {
|
||||
var perc = this.currentTime / this.duration
|
||||
var ptWidth = Math.round(perc * this.trackWidth)
|
||||
if (this.playedTrackWidth === ptWidth) {
|
||||
return
|
||||
}
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.playedTrackWidth = ptWidth
|
||||
},
|
||||
clickTrack(e) {
|
||||
if (this.loading) return
|
||||
|
||||
var offsetX = e.offsetX
|
||||
var perc = offsetX / this.trackWidth
|
||||
var time = perc * this.duration
|
||||
if (isNaN(time) || time === null) {
|
||||
console.error('Invalid time', perc, time)
|
||||
return
|
||||
}
|
||||
this.seek(time)
|
||||
},
|
||||
setBufferTime(bufferTime) {
|
||||
if (!this.audioEl) {
|
||||
return
|
||||
}
|
||||
var bufferlen = (bufferTime / this.duration) * this.trackWidth
|
||||
bufferlen = Math.round(bufferlen)
|
||||
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
|
||||
this.$refs.bufferTrack.style.width = bufferlen + 'px'
|
||||
this.bufferTrackWidth = bufferlen
|
||||
},
|
||||
showChapters() {
|
||||
if (!this.chapters.length) return
|
||||
this.showChaptersModal = !this.showChaptersModal
|
||||
},
|
||||
showBookmarks() {
|
||||
this.$emit('showBookmarks', this.currentTime)
|
||||
},
|
||||
init() {
|
||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||
this.$emit('setPlaybackRate', this.playbackRate)
|
||||
this.setTrackWidth()
|
||||
},
|
||||
setTrackWidth() {
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
} else {
|
||||
console.error('Track not loaded', this.$refs)
|
||||
}
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
||||
this.setPlaybackRate(settings.playbackRate)
|
||||
}
|
||||
},
|
||||
closePlayer() {
|
||||
if (this.loading) return
|
||||
this.$emit('close')
|
||||
},
|
||||
hotkey(action) {
|
||||
if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPause()
|
||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.jumpForward()
|
||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.jumpBackward()
|
||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.increaseVolume()
|
||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.decreaseVolume()
|
||||
else if (action === this.$hotkeys.AudioPlayer.MUTE_UNMUTE) this.toggleMute()
|
||||
else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters()
|
||||
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
|
||||
else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate()
|
||||
else if (action === this.$hotkeys.AudioPlayer.CLOSE) this.closePlayer()
|
||||
},
|
||||
windowResize() {
|
||||
this.setTrackWidth()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('resize', this.windowResize)
|
||||
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
|
||||
this.init()
|
||||
this.$eventBus.$on('player-hotkey', this.hotkey)
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.windowResize)
|
||||
this.$store.commit('user/removeSettingsListener', 'audioplayer')
|
||||
this.$eventBus.$off('player-hotkey', this.hotkey)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.loadingTrack {
|
||||
animation-name: loadingTrack;
|
||||
animation-duration: 1s;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
@keyframes loadingTrack {
|
||||
0% {
|
||||
left: -25%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,63 +1,83 @@
|
||||
<template>
|
||||
<div class="w-full h-16 bg-primary relative">
|
||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
|
||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
|
||||
<div class="flex h-full items-center">
|
||||
<img v-if="!showBack" src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" />
|
||||
<a v-if="showBack" @click="back" class="rounded-full h-12 w-12 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-4 cursor-pointer">
|
||||
<span class="material-icons text-4xl text-white">arrow_back</span>
|
||||
</a>
|
||||
<h1 class="text-2xl font-book mr-6 hidden lg:block">audiobookshelf</h1>
|
||||
<nuxt-link to="/">
|
||||
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" />
|
||||
</nuxt-link>
|
||||
|
||||
<ui-libraries-dropdown />
|
||||
<nuxt-link to="/">
|
||||
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
|
||||
</nuxt-link>
|
||||
|
||||
<controls-global-search class="hidden md:block" />
|
||||
<ui-libraries-dropdown class="mr-2" />
|
||||
|
||||
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
|
||||
<div class="flex-grow" />
|
||||
|
||||
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
||||
|
||||
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
|
||||
<span class="material-icons-outlined text-warning text-opacity-50"> cast </span>
|
||||
<span class="material-icons-outlined text-2xl text-warning text-opacity-50"> cast </span>
|
||||
</ui-tooltip>
|
||||
<div v-if="isChromecastInitialized" class="w-6 h-6 mr-2 cursor-pointer">
|
||||
<div v-if="isChromecastInitialized" class="w-6 min-w-6 h-6 ml-2 mr-1 sm:mx-2 cursor-pointer">
|
||||
<google-cast-launcher></google-cast-launcher>
|
||||
</div>
|
||||
|
||||
<nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<span class="material-icons">equalizer</span>
|
||||
<widgets-notification-widget class="hidden md:block" />
|
||||
|
||||
<nuxt-link v-if="currentLibrary" to="/config/stats" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
|
||||
<ui-tooltip :text="$strings.HeaderYourStats" direction="bottom" class="flex items-center">
|
||||
<span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
|
||||
</ui-tooltip>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<span class="material-icons">upload</span>
|
||||
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<ui-tooltip :text="$strings.ButtonUpload" direction="bottom" class="flex items-center">
|
||||
<span class="material-icons text-2xl" aria-label="Upload Media" role="button">upload</span>
|
||||
</ui-tooltip>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<span class="material-icons">settings</span>
|
||||
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<ui-tooltip :text="$strings.HeaderSettings" direction="bottom" class="flex items-center">
|
||||
<span class="material-icons text-2xl" aria-label="System Settings" role="button">settings</span>
|
||||
</ui-tooltip>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
|
||||
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
|
||||
<span class="items-center hidden md:flex">
|
||||
<span class="block truncate">{{ username }}</span>
|
||||
</span>
|
||||
<span class="h-full md:ml-3 md:absolute inset-y-0 md:right-0 flex items-center justify-center md:pr-2 pointer-events-none">
|
||||
<span class="material-icons text-gray-100">person</span>
|
||||
<span class="material-icons text-xl text-gray-100">person</span>
|
||||
</span>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
||||
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
||||
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1>
|
||||
<div v-show="numMediaItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
||||
<h1 class="text-lg md:text-2xl px-4">{{ $getString('MessageItemsSelected', [numMediaItemsSelected]) }}</h1>
|
||||
<div class="flex-grow" />
|
||||
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
|
||||
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" />
|
||||
<ui-btn v-if="!isPodcastLibrary && selectedMediaItemsArePlayable" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playSelectedItems">
|
||||
<span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
|
||||
{{ $strings.ButtonPlay }}
|
||||
</ui-btn>
|
||||
<ui-tooltip v-if="isBookLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
|
||||
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
|
||||
</ui-tooltip>
|
||||
<ui-tooltip v-if="userCanUpdate" text="Add to Collection" direction="bottom">
|
||||
<ui-tooltip v-if="userCanUpdate && isBookLibrary" :text="$strings.LabelAddToCollection" direction="bottom">
|
||||
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
|
||||
</ui-tooltip>
|
||||
<template v-if="userCanUpdate && numAudiobooksSelected < 50">
|
||||
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
||||
<template v-if="userCanUpdate">
|
||||
<ui-tooltip :text="$strings.LabelEdit" direction="bottom">
|
||||
<ui-icon-btn :disabled="processingBatch" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
||||
</ui-tooltip>
|
||||
</template>
|
||||
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
||||
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||
<ui-tooltip v-if="userCanDelete" :text="$strings.ButtonRemove" direction="bottom">
|
||||
<ui-icon-btn :disabled="processingBatch" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length && !processingBatch" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
|
||||
|
||||
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom" class="flex items-center">
|
||||
<span class="material-icons text-3xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,9 +87,7 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
processingBatchDelete: false,
|
||||
totalEntities: 0,
|
||||
isAllSelected: false
|
||||
totalEntities: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -79,32 +97,38 @@ export default {
|
||||
libraryName() {
|
||||
return this.currentLibrary ? this.currentLibrary.name : 'unknown'
|
||||
},
|
||||
libraryMediaType() {
|
||||
return this.currentLibrary ? this.currentLibrary.mediaType : null
|
||||
},
|
||||
isPodcastLibrary() {
|
||||
return this.libraryMediaType === 'podcast'
|
||||
},
|
||||
isBookLibrary() {
|
||||
return this.libraryMediaType === 'book'
|
||||
},
|
||||
isHome() {
|
||||
return this.$route.name === 'library-library'
|
||||
},
|
||||
showBack() {
|
||||
return this.$route.name !== 'library-library-bookshelf-id' && !this.isHome
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
username() {
|
||||
return this.user ? this.user.username : 'err'
|
||||
},
|
||||
numAudiobooksSelected() {
|
||||
return this.selectedAudiobooks.length
|
||||
numMediaItemsSelected() {
|
||||
return this.selectedMediaItems.length
|
||||
},
|
||||
selectedAudiobooks() {
|
||||
return this.$store.state.selectedAudiobooks
|
||||
selectedMediaItems() {
|
||||
return this.$store.state.globals.selectedMediaItems
|
||||
},
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user.user.audiobooks || {}
|
||||
selectedMediaItemsArePlayable() {
|
||||
return !this.selectedMediaItems.some((i) => !i.hasTracks)
|
||||
},
|
||||
selectedSeries() {
|
||||
return this.$store.state.audiobooks.selectedSeries
|
||||
userMediaProgress() {
|
||||
return this.$store.state.user.user.mediaProgress || []
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
@@ -115,19 +139,16 @@ export default {
|
||||
userCanUpload() {
|
||||
return this.$store.getters['user/getUserCanUpload']
|
||||
},
|
||||
selectedIsRead() {
|
||||
// Find an audiobook that is not read, if none then all audiobooks read
|
||||
return !this.selectedAudiobooks.find((ab) => {
|
||||
var userAb = this.userAudiobooks[ab]
|
||||
return !userAb || !userAb.isRead
|
||||
selectedIsFinished() {
|
||||
// Find an item that is not finished, if none then all items finished
|
||||
return !this.selectedMediaItems.find((item) => {
|
||||
const itemProgress = this.userMediaProgress.find((lip) => lip.libraryItemId === item.id)
|
||||
return !itemProgress || !itemProgress.isFinished
|
||||
})
|
||||
},
|
||||
processingBatch() {
|
||||
return this.$store.state.processingBatch
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
isChromecastEnabled() {
|
||||
return this.$store.getters['getServerSetting']('chromecastEnabled')
|
||||
},
|
||||
@@ -136,80 +157,207 @@ export default {
|
||||
},
|
||||
isHttps() {
|
||||
return location.protocol === 'https:' || process.env.NODE_ENV === 'development'
|
||||
},
|
||||
contextMenuItems() {
|
||||
if (!this.userIsAdminOrUp) return []
|
||||
|
||||
const options = [
|
||||
{
|
||||
text: this.$strings.ButtonQuickMatch,
|
||||
action: 'quick-match'
|
||||
}
|
||||
]
|
||||
|
||||
if (!this.isPodcastLibrary && this.selectedMediaItemsArePlayable) {
|
||||
options.push({
|
||||
text: 'Quick Embed Metadata',
|
||||
action: 'quick-embed'
|
||||
})
|
||||
}
|
||||
|
||||
options.push({
|
||||
text: 'Re-Scan',
|
||||
action: 'rescan'
|
||||
})
|
||||
|
||||
return options
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleBookshelfTexture() {
|
||||
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
|
||||
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?',
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
.$post(`/api/tools/batch/embed-metadata`, {
|
||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Audio metadata embed started')
|
||||
this.cancelSelectionMode()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Audio metadata embed failed', error)
|
||||
const errorMsg = error.response.data || 'Failed to embed metadata'
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
async back() {
|
||||
var popped = await this.$store.dispatch('popRoute')
|
||||
if (popped) this.$store.commit('setIsRoutingBack', true)
|
||||
var backTo = popped || '/'
|
||||
this.$router.push(backTo)
|
||||
contextMenuAction({ action }) {
|
||||
if (action === 'quick-embed') {
|
||||
this.requestBatchQuickEmbed()
|
||||
} else if (action === 'quick-match') {
|
||||
this.batchAutoMatchClick()
|
||||
} else if (action === 'rescan') {
|
||||
this.batchRescan()
|
||||
}
|
||||
},
|
||||
async batchRescan() {
|
||||
const payload = {
|
||||
message: `Are you sure you want to re-scan ${this.selectedMediaItems.length} items?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
.$post(`/api/items/batch/scan`, {
|
||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Batch Re-Scan started')
|
||||
this.cancelSelectionMode()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Batch Re-Scan failed', error)
|
||||
const errorMsg = error.response.data || 'Failed to batch re-scan'
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
async playSelectedItems() {
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
|
||||
const libraryItemIds = this.selectedMediaItems.map((i) => i.id)
|
||||
const libraryItems = await this.$axios
|
||||
.$post(`/api/items/batch/get`, { libraryItemIds })
|
||||
.then((res) => res.libraryItems)
|
||||
.catch((error) => {
|
||||
const errorMsg = error.response.data || 'Failed to get items'
|
||||
console.error(errorMsg, error)
|
||||
this.$toast.error(errorMsg)
|
||||
return []
|
||||
})
|
||||
|
||||
if (!libraryItems.length) {
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
return
|
||||
}
|
||||
|
||||
const queueItems = []
|
||||
libraryItems.forEach((item) => {
|
||||
let subtitle = ''
|
||||
if (item.mediaType === 'book') subtitle = item.media.metadata.authors.map((au) => au.name).join(', ')
|
||||
else if (item.mediaType === 'music') subtitle = item.media.metadata.artists.join(', ')
|
||||
queueItems.push({
|
||||
libraryItemId: item.id,
|
||||
libraryId: item.libraryId,
|
||||
episodeId: null,
|
||||
title: item.media.metadata.title,
|
||||
subtitle,
|
||||
caption: '',
|
||||
duration: item.media.duration || null,
|
||||
coverPath: item.media.coverPath || null
|
||||
})
|
||||
})
|
||||
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: queueItems[0].libraryItemId,
|
||||
queueItems
|
||||
})
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||
},
|
||||
cancelSelectionMode() {
|
||||
if (this.processingBatchDelete) return
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||
this.isAllSelected = false
|
||||
if (this.processingBatch) return
|
||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||
},
|
||||
toggleBatchRead() {
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
var newIsRead = !this.selectedIsRead
|
||||
var updateProgressPayloads = this.selectedAudiobooks.map((ab) => {
|
||||
const newIsFinished = !this.selectedIsFinished
|
||||
const updateProgressPayloads = this.selectedMediaItems.map((item) => {
|
||||
return {
|
||||
audiobookId: ab,
|
||||
isRead: newIsRead
|
||||
libraryItemId: item.id,
|
||||
isFinished: newIsFinished
|
||||
}
|
||||
})
|
||||
console.log('Progress payloads', updateProgressPayloads)
|
||||
this.$axios
|
||||
.patch(`/api/me/audiobook/batch/update`, updateProgressPayloads)
|
||||
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||
.then(() => {
|
||||
this.$toast.success('Batch update success!')
|
||||
this.$toast.success(this.$strings.ToastBatchUpdateSuccess)
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error('Batch update failed')
|
||||
this.$toast.error(this.$strings.ToastBatchUpdateFailed)
|
||||
console.error('Failed to batch update read/not read', error)
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
})
|
||||
},
|
||||
batchDeleteClick() {
|
||||
var audiobookText = this.numAudiobooksSelected > 1 ? `these ${this.numAudiobooksSelected} audiobooks` : 'this audiobook'
|
||||
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the audiobooks from AudioBookshelf`
|
||||
if (confirm(confirmMsg)) {
|
||||
this.processingBatchDelete = true
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
this.$axios
|
||||
.$post(`/api/books/batch/delete`, {
|
||||
audiobookIds: this.selectedAudiobooks
|
||||
})
|
||||
.then(() => {
|
||||
this.$toast.success('Batch delete success!')
|
||||
this.processingBatchDelete = false
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
this.$eventBus.$emit('bookshelf-clear-selection')
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error('Batch delete failed')
|
||||
console.error('Failed to batch delete', error)
|
||||
this.processingBatchDelete = false
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
})
|
||||
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.',
|
||||
yesButtonText: this.$strings.ButtonDelete,
|
||||
yesButtonColor: 'error',
|
||||
checkboxDefaultValue: true,
|
||||
callback: (confirmed, hardDelete) => {
|
||||
if (confirmed) {
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
|
||||
this.$axios
|
||||
.$post(`/api/items/batch/delete?hard=${hardDelete ? 1 : 0}`, {
|
||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||
})
|
||||
.then(() => {
|
||||
this.$toast.success('Batch delete success')
|
||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Batch delete failed', error)
|
||||
this.$toast.error('Batch delete failed')
|
||||
})
|
||||
.finally(() => {
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
batchEditClick() {
|
||||
this.$router.push('/batch')
|
||||
},
|
||||
batchAddToCollectionClick() {
|
||||
this.$store.commit('globals/setShowBatchUserCollectionsModal', true)
|
||||
this.$store.commit('globals/setShowBatchCollectionsModal', true)
|
||||
},
|
||||
setBookshelfTotalEntities(totalEntities) {
|
||||
this.totalEntities = totalEntities
|
||||
},
|
||||
batchAutoMatchClick() {
|
||||
this.$store.commit('globals/setShowBatchQuickMatchModal', true)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -225,4 +373,4 @@ export default {
|
||||
#appbar {
|
||||
box-shadow: 0px 5px 5px #11111155;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
<template>
|
||||
<div class="outer-container">
|
||||
<!-- absolute positioned container -->
|
||||
<div class="inner-container">
|
||||
<div class="relative h-10">
|
||||
<div class="table-header" id="headerdiv">
|
||||
<table id="headertable" width="100%" cellpadding="0" cellspacing="0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="header-cell min-w-12 max-w-12"></th>
|
||||
<th class="header-cell min-w-6 max-w-6"></th>
|
||||
<th class="header-cell min-w-64 max-w-64 px-2">Title</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Author</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Series</th>
|
||||
<th class="header-cell min-w-24 max-w-24 px-2">Year</th>
|
||||
<th class="header-cell min-w-80 max-w-80 px-2">Description</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Narrator</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Genres</th>
|
||||
<th class="header-cell min-w-48 max-w-48 px-2">Tags</th>
|
||||
<th class="header-cell min-w-24 max-w-24 px-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
<div class="absolute top-0 left-0 w-full h-full pointer-events-none" :class="isScrollable ? 'header-shadow' : ''" />
|
||||
</div>
|
||||
|
||||
<div ref="tableBody" class="table-body" onscroll="document.getElementById('headerdiv').scrollLeft = this.scrollLeft;" @scroll="tableScrolled">
|
||||
<table id="bodytable" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tbody>
|
||||
<template v-for="book in books">
|
||||
<app-book-list-row :key="book.id" :book="book" @edit="editBook" />
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
books: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isScrollable: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
checkIsScrolled() {
|
||||
if (!this.$refs.tableBody) return
|
||||
this.isScrollable = this.$refs.tableBody.scrollTop > 0
|
||||
},
|
||||
tableScrolled() {
|
||||
this.checkIsScrolled()
|
||||
},
|
||||
editBook(book) {
|
||||
var bookIds = this.books.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||
this.$store.commit('showEditModal', book)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.checkIsScrolled()
|
||||
},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.outer-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: visible;
|
||||
height: calc(100% - 50px);
|
||||
width: calc(100% - 10px);
|
||||
margin: 10px;
|
||||
}
|
||||
.inner-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.table-header {
|
||||
float: left;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
.header-shadow {
|
||||
box-shadow: 3px 8px 3px #11111155;
|
||||
}
|
||||
.table-body {
|
||||
float: left;
|
||||
height: 100%;
|
||||
width: inherit;
|
||||
overflow-y: scroll;
|
||||
padding-right: 0px;
|
||||
}
|
||||
.header-cell {
|
||||
background-color: #22222288;
|
||||
padding: 0px 4px;
|
||||
text-align: left;
|
||||
height: 40px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: semi-bold;
|
||||
}
|
||||
.body-cell {
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.book-row {
|
||||
background-color: #22222288;
|
||||
}
|
||||
.book-row:nth-child(odd) {
|
||||
background-color: #333;
|
||||
}
|
||||
.book-row.selected {
|
||||
background-color: rgba(0, 255, 0, 0.05);
|
||||
}
|
||||
</style>
|
||||
@@ -1,179 +0,0 @@
|
||||
<template>
|
||||
<tr class="book-row" :class="selected ? 'selected' : ''">
|
||||
<td class="body-cell min-w-12 max-w-12">
|
||||
<div class="flex justify-center">
|
||||
<div class="bg-white border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center focus-within:border-blue-500 w-4 h-4" @click="selectBtnClick">
|
||||
<svg v-if="selected" class="fill-current text-green-500 pointer-events-none w-2.5 h-2.5" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="body-cell min-w-6 max-w-6">
|
||||
<covers-hover-book-cover :audiobook="book" />
|
||||
</td>
|
||||
<td class="body-cell min-w-64 max-w-64 px-2">
|
||||
<nuxt-link :to="`/audiobook/${book.id}`" class="hover:underline">
|
||||
<p class="truncate">
|
||||
{{ book.book.title }}<span v-if="book.book.subtitle">: {{ book.book.subtitle }}</span>
|
||||
</p>
|
||||
</nuxt-link>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ book.book.authorFL }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ seriesText }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-24 max-w-24 px-2">
|
||||
<p class="truncate">{{ book.book.publishYear }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-80 max-w-80 px-2">
|
||||
<p class="truncate">{{ book.book.description }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ book.book.narrator }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ genresText }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-48 max-w-48 px-2">
|
||||
<p class="truncate">{{ tagsText }}</p>
|
||||
</td>
|
||||
<td class="body-cell min-w-24 max-w-24 px-2">
|
||||
<div class="flex">
|
||||
<span v-if="userCanUpdate" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="editClick">edit</span>
|
||||
<span v-if="showPlayButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-2xl mx-1" @click="startStream">play_arrow</span>
|
||||
<span v-if="showReadButton" class="material-icons cursor-pointer text-white text-opacity-60 hover:text-opacity-100 text-xl" @click="openEbook">auto_stories</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
book: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
userAudiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessingReadUpdate: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
audiobookId() {
|
||||
return this.book.id
|
||||
},
|
||||
selected: {
|
||||
get() {
|
||||
return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
|
||||
},
|
||||
set(val) {
|
||||
if (this.processingBatch) return
|
||||
this.$store.commit('setAudiobookSelected', { audiobookId: this.audiobookId, selected: val })
|
||||
}
|
||||
},
|
||||
processingBatch() {
|
||||
return this.$store.state.processingBatch
|
||||
},
|
||||
bookObj() {
|
||||
return this.book.book || {}
|
||||
},
|
||||
series() {
|
||||
return this.bookObj.series || null
|
||||
},
|
||||
volumeNumber() {
|
||||
return this.bookObj.volumeNumber || null
|
||||
},
|
||||
seriesText() {
|
||||
if (!this.series) return ''
|
||||
if (!this.volumeNumber) return this.series
|
||||
return `${this.series} #${this.volumeNumber}`
|
||||
},
|
||||
genresText() {
|
||||
if (!this.bookObj.genres) return ''
|
||||
return this.bookObj.genres.join(', ')
|
||||
},
|
||||
tagsText() {
|
||||
return (this.book.tags || []).join(', ')
|
||||
},
|
||||
isMissing() {
|
||||
return this.book.isMissing
|
||||
},
|
||||
isInvalid() {
|
||||
return this.book.isInvalid
|
||||
},
|
||||
numEbooks() {
|
||||
return this.book.numEbooks
|
||||
},
|
||||
numTracks() {
|
||||
return this.book.numTracks
|
||||
},
|
||||
isStreaming() {
|
||||
return this.$store.getters['getAudiobookIdStreaming'] === this.audiobookId
|
||||
},
|
||||
showReadButton() {
|
||||
return this.showExperimentalFeatures && this.numEbooks
|
||||
},
|
||||
showPlayButton() {
|
||||
return !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
|
||||
},
|
||||
userIsRead() {
|
||||
return this.userAudiobook ? !!this.userAudiobook.isRead : false
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectBtnClick() {
|
||||
if (this.processingBatch) return
|
||||
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
|
||||
},
|
||||
openEbook() {
|
||||
this.$store.commit('showEReader', this.book)
|
||||
},
|
||||
downloadClick() {
|
||||
this.$store.commit('showEditModalOnTab', { audiobook: this.book, tab: 'download' })
|
||||
},
|
||||
toggleRead() {
|
||||
var updatePayload = {
|
||||
isRead: !this.userIsRead
|
||||
}
|
||||
this.isProcessingReadUpdate = true
|
||||
this.$axios
|
||||
.$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
||||
})
|
||||
},
|
||||
startStream() {
|
||||
this.$eventBus.$emit('play-audiobook', this.book.id)
|
||||
},
|
||||
editClick() {
|
||||
this.$emit('edit', this.book)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,22 +1,42 @@
|
||||
<template>
|
||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
|
||||
<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-30" />
|
||||
<!-- Experimental Bookshelf Texture -->
|
||||
<div v-show="showExperimentalFeatures" class="fixed bottom-4 right-28 z-40">
|
||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
|
||||
</div>
|
||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
|
||||
|
||||
<div v-if="loaded && !shelves.length && isRootUser" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">Audiobookshelf is empty!</p>
|
||||
<div class="flex">
|
||||
<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>
|
||||
<div v-if="userIsAdminOrUp" class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full flex flex-col items-center">
|
||||
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
|
||||
<p class="text-center text-xl py-4">No results for query</p>
|
||||
</div>
|
||||
<!-- Alternate plain view -->
|
||||
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
</widgets-item-slider>
|
||||
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
</widgets-episode-slider>
|
||||
<widgets-series-slider v-else-if="shelf.type === 'series'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
</widgets-series-slider>
|
||||
<widgets-authors-slider v-else-if="shelf.type === 'authors'" :key="index + '.'" :items="shelf.entities" :height="192 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
</widgets-authors-slider>
|
||||
<widgets-narrators-slider v-else-if="shelf.type === 'narrators'" :key="index + '.'" :items="shelf.entities" :height="100 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
</widgets-narrators-slider>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Regular bookshelf view -->
|
||||
<div v-else class="w-full">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" @selectEntity="(payload) => selectEntity(payload, index)" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,41 +57,104 @@ export default {
|
||||
keywordFilterTimeout: null,
|
||||
scannerParseSubtitle: false,
|
||||
wrapperClientWidth: 0,
|
||||
shelves: []
|
||||
shelves: [],
|
||||
lastItemIndexSelected: -1
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
libraryName() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
return this.$store.getters['getHomeBookshelfView'] === this.$constants.BookshelfView.DETAIL
|
||||
},
|
||||
bookCoverWidth() {
|
||||
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
||||
return coverSize
|
||||
},
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
isCoverSquareAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.isCoverSquareAspectRatio ? 1 : 1.6
|
||||
return this.coverAspectRatio == 1
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
return this.bookCoverWidth / baseSize
|
||||
},
|
||||
selectedMediaItems() {
|
||||
return this.$store.state.globals.selectedMediaItems || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showBookshelfTextureModal() {
|
||||
this.$store.commit('globals/setShowBookshelfTextureModal', true)
|
||||
selectEntity({ entity, shiftKey }, shelfIndex) {
|
||||
const shelf = this.shelves[shelfIndex]
|
||||
const entityShelfIndex = shelf.entities.findIndex((ent) => ent.id === entity.id)
|
||||
const indexOf = shelf.shelfStartIndex + entityShelfIndex
|
||||
|
||||
const lastLastItemIndexSelected = this.lastItemIndexSelected
|
||||
if (!this.selectedMediaItems.some((i) => i.id === entity.id)) {
|
||||
this.lastItemIndexSelected = indexOf
|
||||
} else {
|
||||
this.lastItemIndexSelected = -1
|
||||
}
|
||||
|
||||
if (shiftKey && lastLastItemIndexSelected >= 0) {
|
||||
let loopStart = indexOf
|
||||
let loopEnd = lastLastItemIndexSelected
|
||||
if (indexOf > lastLastItemIndexSelected) {
|
||||
loopStart = lastLastItemIndexSelected
|
||||
loopEnd = indexOf
|
||||
}
|
||||
|
||||
const flattenedEntitiesArray = []
|
||||
this.shelves.map((s) => flattenedEntitiesArray.push(...s.entities))
|
||||
|
||||
let isSelecting = false
|
||||
// If any items in this range is not selected then select all otherwise unselect all
|
||||
for (let i = loopStart; i <= loopEnd; i++) {
|
||||
const thisEntity = flattenedEntitiesArray[i]
|
||||
if (thisEntity) {
|
||||
if (!this.selectedMediaItems.some((i) => i.id === thisEntity.id)) {
|
||||
isSelecting = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isSelecting) this.lastItemIndexSelected = indexOf
|
||||
|
||||
for (let i = loopStart; i <= loopEnd; i++) {
|
||||
const thisEntity = flattenedEntitiesArray[i]
|
||||
if (thisEntity) {
|
||||
const mediaItem = {
|
||||
id: thisEntity.id,
|
||||
mediaType: thisEntity.mediaType,
|
||||
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.audioFile || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
|
||||
}
|
||||
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })
|
||||
} else {
|
||||
console.error('Invalid entity index', i)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const mediaItem = {
|
||||
id: entity.id,
|
||||
mediaType: entity.mediaType,
|
||||
hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
|
||||
}
|
||||
this.$store.commit('globals/toggleMediaItemSelected', mediaItem)
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$eventBus.$emit('item-selected', entity)
|
||||
})
|
||||
},
|
||||
async init() {
|
||||
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
||||
@@ -84,8 +167,8 @@ export default {
|
||||
this.loaded = true
|
||||
},
|
||||
async fetchCategories() {
|
||||
var categories = await this.$axios
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/categories?minified=1`)
|
||||
const categories = await this.$axios
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed`)
|
||||
.then((data) => {
|
||||
return data
|
||||
})
|
||||
@@ -93,147 +176,259 @@ export default {
|
||||
console.error('Failed to fetch categories', error)
|
||||
return []
|
||||
})
|
||||
|
||||
let totalEntityCount = 0
|
||||
for (const shelf of categories) {
|
||||
shelf.shelfStartIndex = totalEntityCount
|
||||
totalEntityCount += shelf.entities.length
|
||||
}
|
||||
this.shelves = categories
|
||||
},
|
||||
async setShelvesFromSearch() {
|
||||
var shelves = []
|
||||
if (this.results.audiobooks) {
|
||||
const shelves = []
|
||||
if (this.results.books?.length) {
|
||||
shelves.push({
|
||||
id: 'audiobooks',
|
||||
id: 'books',
|
||||
label: 'Books',
|
||||
type: 'books',
|
||||
entities: this.results.audiobooks.map((ab) => ab.audiobook)
|
||||
labelStringKey: 'LabelBooks',
|
||||
type: 'book',
|
||||
entities: this.results.books.map((res) => res.libraryItem)
|
||||
})
|
||||
}
|
||||
|
||||
if (this.results.series) {
|
||||
if (this.results.podcasts?.length) {
|
||||
shelves.push({
|
||||
id: 'podcasts',
|
||||
label: 'Podcasts',
|
||||
labelStringKey: 'LabelPodcasts',
|
||||
type: 'podcast',
|
||||
entities: this.results.podcasts.map((res) => res.libraryItem)
|
||||
})
|
||||
}
|
||||
|
||||
if (this.results.series?.length) {
|
||||
shelves.push({
|
||||
id: 'series',
|
||||
label: 'Series',
|
||||
labelStringKey: 'LabelSeries',
|
||||
type: 'series',
|
||||
entities: this.results.series.map((seriesObj) => {
|
||||
return {
|
||||
name: seriesObj.series,
|
||||
books: seriesObj.audiobooks,
|
||||
...seriesObj.series,
|
||||
books: seriesObj.books,
|
||||
type: 'series'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
if (this.results.tags) {
|
||||
if (this.results.tags?.length) {
|
||||
shelves.push({
|
||||
id: 'tags',
|
||||
label: 'Tags',
|
||||
labelStringKey: 'LabelTags',
|
||||
type: 'tags',
|
||||
entities: this.results.tags.map((tagObj) => {
|
||||
return {
|
||||
name: tagObj.tag,
|
||||
books: tagObj.audiobooks,
|
||||
name: tagObj.name,
|
||||
books: tagObj.books || [],
|
||||
type: 'tags'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
if (this.results.authors) {
|
||||
if (this.results.authors?.length) {
|
||||
shelves.push({
|
||||
id: 'authors',
|
||||
label: 'Authors',
|
||||
labelStringKey: 'LabelAuthors',
|
||||
type: 'authors',
|
||||
entities: this.results.authors.map((a) => {
|
||||
return {
|
||||
id: a.author,
|
||||
name: a.author,
|
||||
numBooks: a.numBooks,
|
||||
...a,
|
||||
type: 'author'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
if (this.results.narrators?.length) {
|
||||
shelves.push({
|
||||
id: 'narrators',
|
||||
label: 'Narrators',
|
||||
labelStringKey: 'LabelNarrators',
|
||||
type: 'narrators',
|
||||
entities: this.results.narrators.map((n) => {
|
||||
return {
|
||||
...n,
|
||||
type: 'narrator'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
this.shelves = shelves
|
||||
},
|
||||
settingsUpdated(settings) {},
|
||||
scan() {
|
||||
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
|
||||
this.$store
|
||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
||||
.then(() => {
|
||||
this.$toast.success('Library scan started')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to start scan', error)
|
||||
this.$toast.error('Failed to start scan')
|
||||
})
|
||||
},
|
||||
audiobookAdded(audiobook) {
|
||||
console.log('Audiobook added', audiobook)
|
||||
// TODO: Check if audiobook would be on this shelf
|
||||
userUpdated(user) {
|
||||
if (user.seriesHideFromContinueListening && user.seriesHideFromContinueListening.length) {
|
||||
this.removeAllSeriesFromContinueSeries(user.seriesHideFromContinueListening)
|
||||
}
|
||||
if (user.mediaProgress.length) {
|
||||
const mediaProgressToHide = user.mediaProgress.filter((mp) => mp.hideFromContinueListening)
|
||||
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-listening')
|
||||
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-reading')
|
||||
}
|
||||
},
|
||||
libraryItemAdded(libraryItem) {
|
||||
console.log('libraryItem added', libraryItem)
|
||||
// TODO: Check if libraryItem would be on this shelf
|
||||
if (!this.search) {
|
||||
this.fetchCategories()
|
||||
}
|
||||
},
|
||||
audiobookUpdated(audiobook) {
|
||||
console.log('Audiobook updated', audiobook)
|
||||
libraryItemUpdated(libraryItem) {
|
||||
console.log('libraryItem updated', libraryItem)
|
||||
this.shelves.forEach((shelf) => {
|
||||
if (shelf.type === 'books') {
|
||||
if (shelf.type == 'book' || shelf.type == 'podcast') {
|
||||
shelf.entities = shelf.entities.map((ent) => {
|
||||
if (ent.id === audiobook.id) {
|
||||
return audiobook
|
||||
if (ent.id === libraryItem.id) {
|
||||
return libraryItem
|
||||
}
|
||||
return ent
|
||||
})
|
||||
} else if (shelf.type === 'series') {
|
||||
shelf.entities.forEach((ent) => {
|
||||
ent.books = ent.books.map((book) => {
|
||||
if (book.id === audiobook.id) return audiobook
|
||||
if (book.id === libraryItem.id) return libraryItem
|
||||
return book
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
removeBookFromShelf(audiobook) {
|
||||
removeBookFromShelf(libraryItem) {
|
||||
this.shelves.forEach((shelf) => {
|
||||
if (shelf.type === 'books') {
|
||||
if (shelf.type == 'book' || shelf.type == 'podcast') {
|
||||
shelf.entities = shelf.entities.filter((ent) => {
|
||||
return ent.id !== audiobook.id
|
||||
return ent.id !== libraryItem.id
|
||||
})
|
||||
} else if (shelf.type === 'series') {
|
||||
shelf.entities.forEach((ent) => {
|
||||
ent.books = ent.books.filter((book) => {
|
||||
return book.id !== audiobook.id
|
||||
return book.id !== libraryItem.id
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
audiobookRemoved(audiobook) {
|
||||
this.removeBookFromShelf(audiobook)
|
||||
libraryItemRemoved(libraryItem) {
|
||||
this.removeBookFromShelf(libraryItem)
|
||||
},
|
||||
audiobooksAdded(audiobooks) {
|
||||
console.log('audiobooks added', audiobooks)
|
||||
// TODO: Check if audiobook would be on this shelf
|
||||
if (!this.search) {
|
||||
libraryItemsAdded(libraryItems) {
|
||||
console.log('libraryItems added', libraryItems)
|
||||
|
||||
const isThisLibrary = !libraryItems.some((li) => li.libraryId !== this.currentLibraryId)
|
||||
if (!this.search && isThisLibrary) {
|
||||
this.fetchCategories()
|
||||
}
|
||||
},
|
||||
audiobooksUpdated(audiobooks) {
|
||||
audiobooks.forEach((ab) => {
|
||||
this.audiobookUpdated(ab)
|
||||
libraryItemsUpdated(items) {
|
||||
items.forEach((li) => {
|
||||
this.libraryItemUpdated(li)
|
||||
})
|
||||
},
|
||||
episodeAdded(episodeWithLibraryItem) {
|
||||
console.log('Podcast episode added', episodeWithLibraryItem)
|
||||
|
||||
const isThisLibrary = episodeWithLibraryItem.libraryItem?.libraryId === this.currentLibraryId
|
||||
if (!this.search && isThisLibrary) {
|
||||
this.fetchCategories()
|
||||
}
|
||||
},
|
||||
removeAllSeriesFromContinueSeries(seriesIds) {
|
||||
this.shelves.forEach((shelf) => {
|
||||
if (shelf.type == 'book' && shelf.id == 'continue-series') {
|
||||
// Filter out series books from continue series shelf
|
||||
shelf.entities = shelf.entities.filter((ent) => {
|
||||
if (ent.media.metadata.series && seriesIds.includes(ent.media.metadata.series.id)) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
removeItemsFromContinueListeningReading(mediaProgressItems, categoryId) {
|
||||
const continueListeningShelf = this.shelves.find((s) => s.id === categoryId)
|
||||
if (continueListeningShelf) {
|
||||
if (continueListeningShelf.type === 'book') {
|
||||
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
|
||||
if (mediaProgressItems.some((mp) => mp.libraryItemId === ent.id)) return false
|
||||
return true
|
||||
})
|
||||
} else if (continueListeningShelf.type === 'episode') {
|
||||
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
|
||||
if (!ent.recentEpisode) return true // Should always have this here
|
||||
if (mediaProgressItems.some((mp) => mp.libraryItemId === ent.id && mp.episodeId === ent.recentEpisode.id)) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
authorUpdated(author) {
|
||||
this.shelves.forEach((shelf) => {
|
||||
if (shelf.type == 'authors') {
|
||||
shelf.entities = shelf.entities.map((ent) => {
|
||||
if (ent.id === author.id) {
|
||||
return {
|
||||
...ent,
|
||||
...author
|
||||
}
|
||||
}
|
||||
return ent
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
authorRemoved(author) {
|
||||
this.shelves.forEach((shelf) => {
|
||||
if (shelf.type == 'authors') {
|
||||
shelf.entities = shelf.entities.filter((ent) => ent.id != author.id)
|
||||
}
|
||||
})
|
||||
},
|
||||
initListeners() {
|
||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
||||
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.on('audiobook_updated', this.audiobookUpdated)
|
||||
this.$root.socket.on('audiobook_added', this.audiobookAdded)
|
||||
this.$root.socket.on('audiobook_removed', this.audiobookRemoved)
|
||||
this.$root.socket.on('audiobooks_updated', this.audiobooksUpdated)
|
||||
this.$root.socket.on('audiobooks_added', this.audiobooksAdded)
|
||||
this.$root.socket.on('user_updated', this.userUpdated)
|
||||
this.$root.socket.on('author_updated', this.authorUpdated)
|
||||
this.$root.socket.on('author_removed', this.authorRemoved)
|
||||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||
this.$root.socket.on('item_added', this.libraryItemAdded)
|
||||
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
||||
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
||||
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
||||
this.$root.socket.on('episode_added', this.episodeAdded)
|
||||
} else {
|
||||
console.error('Error socket not initialized')
|
||||
}
|
||||
},
|
||||
removeListeners() {
|
||||
this.$store.commit('user/removeSettingsListener', 'bookshelf')
|
||||
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.off('audiobook_updated', this.audiobookUpdated)
|
||||
this.$root.socket.off('audiobook_added', this.audiobookAdded)
|
||||
this.$root.socket.off('audiobook_removed', this.audiobookRemoved)
|
||||
this.$root.socket.off('audiobooks_updated', this.audiobooksUpdated)
|
||||
this.$root.socket.off('audiobooks_added', this.audiobooksAdded)
|
||||
this.$root.socket.off('user_updated', this.userUpdated)
|
||||
this.$root.socket.off('author_updated', this.authorUpdated)
|
||||
this.$root.socket.off('author_removed', this.authorRemoved)
|
||||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||
this.$root.socket.off('item_added', this.libraryItemAdded)
|
||||
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
||||
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
||||
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
||||
this.$root.socket.off('episode_added', this.episodeAdded)
|
||||
} else {
|
||||
console.error('Error socket not initialized')
|
||||
}
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div ref="shelf" class="w-full max-w-full categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
|
||||
<div ref="shelf" class="w-full max-w-full bookshelf-row categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
|
||||
<div class="w-full h-full pt-6">
|
||||
<div v-if="shelf.type === 'books'" class="flex items-center">
|
||||
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
|
||||
<template v-for="(entity, index) in shelf.entities">
|
||||
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectBook" @edit="editBook" />
|
||||
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" :continue-listening-shelf="continueListeningShelf" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editItem" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
||||
<template v-for="(entity, index) in shelf.entities">
|
||||
<cards-lazy-book-card
|
||||
:key="entity.recentEpisode.id"
|
||||
:ref="`shelf-episode-${entity.recentEpisode.id}`"
|
||||
:index="index"
|
||||
:width="bookCoverWidth"
|
||||
:height="bookCoverHeight"
|
||||
:book-cover-aspect-ratio="bookCoverAspectRatio"
|
||||
:book-mount="entity"
|
||||
:continue-listening-shelf="continueListeningShelf"
|
||||
class="relative mx-2"
|
||||
@hook:updated="updatedBookCard"
|
||||
@select="selectItem"
|
||||
@editPodcast="editItem"
|
||||
@edit="editEpisode"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'series'" class="flex items-center">
|
||||
@@ -14,24 +33,25 @@
|
||||
</div>
|
||||
<div v-if="shelf.type === 'tags'" class="flex items-center">
|
||||
<template v-for="entity in shelf.entities">
|
||||
<nuxt-link :key="entity.name" :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(entity.name)}`">
|
||||
<cards-group-card is-categorized :width="bookCoverWidth" :group="entity" :book-cover-aspect-ratio="bookCoverAspectRatio" @hook:updated="updatedBookCard" />
|
||||
</nuxt-link>
|
||||
<cards-group-card :key="entity.name" :group="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'authors'" class="flex items-center">
|
||||
<template v-for="entity in shelf.entities">
|
||||
<nuxt-link :key="entity.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(entity.name)}`">
|
||||
<cards-author-card :width="bookCoverWidth" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" />
|
||||
</nuxt-link>
|
||||
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'narrators'" class="flex items-center">
|
||||
<template v-for="entity in shelf.entities">
|
||||
<cards-narrator-card :key="entity.name" :width="150" :height="100" :narrator="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-4 md:left-8 w-36 rounded-md" style="height: 22px">
|
||||
<div class="absolute text-center categoryPlacard transform z-30 bottom-px left-4 md:left-8 w-44 rounded-md" style="height: 22px">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
|
||||
<p class="transform text-sm">{{ shelf.label }}</p>
|
||||
<p class="transform text-sm">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +76,8 @@ export default {
|
||||
},
|
||||
sizeMultiplier: Number,
|
||||
bookCoverWidth: Number,
|
||||
bookCoverAspectRatio: Number
|
||||
bookCoverAspectRatio: Number,
|
||||
continueListeningShelf: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -67,21 +88,14 @@ export default {
|
||||
updateTimer: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
isSelectionMode(newVal) {
|
||||
this.updateSelectionMode(newVal)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverHeight() {
|
||||
return this.bookCoverWidth * this.bookCoverAspectRatio
|
||||
},
|
||||
shelfHeight() {
|
||||
if (this.shelf.type === 'narrators') return 148
|
||||
return this.bookCoverHeight + 48
|
||||
},
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
||||
},
|
||||
paddingLeft() {
|
||||
if (window.innerWidth < 768) return 1
|
||||
return 2.5
|
||||
@@ -90,29 +104,51 @@ export default {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumAudiobooksSelected'] > 0
|
||||
return this.$store.getters['globals/getIsBatchSelectingMediaItems']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editBook(audiobook) {
|
||||
var bookIds = this.shelf.entities.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||
this.$store.commit('showEditModal', audiobook)
|
||||
clearSelectedEntities() {
|
||||
this.updateSelectionMode(false)
|
||||
},
|
||||
editAuthor(author) {
|
||||
this.$store.commit('globals/showEditAuthorModal', author)
|
||||
},
|
||||
editItem(libraryItem) {
|
||||
var itemIds = this.shelf.entities.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', itemIds)
|
||||
this.$store.commit('showEditModal', libraryItem)
|
||||
},
|
||||
editEpisode({ libraryItem, episode }) {
|
||||
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||
},
|
||||
updateSelectionMode(val) {
|
||||
var selectedAudiobooks = this.$store.state.selectedAudiobooks
|
||||
if (this.shelf.type === 'books') {
|
||||
const selectedMediaItems = this.$store.state.globals.selectedMediaItems
|
||||
if (this.shelf.type === 'book' || this.shelf.type === 'podcast') {
|
||||
this.shelf.entities.forEach((ent) => {
|
||||
var component = this.$refs[`shelf-book-${ent.id}`]
|
||||
if (!component || !component.length) return
|
||||
component = component[0]
|
||||
component.setSelectionMode(val)
|
||||
component.selected = selectedAudiobooks.includes(ent.id)
|
||||
component.selected = selectedMediaItems.some((i) => i.id === ent.id)
|
||||
})
|
||||
} else if (this.shelf.type === 'episode') {
|
||||
this.shelf.entities.forEach((ent) => {
|
||||
var component = this.$refs[`shelf-episode-${ent.recentEpisode.id}`]
|
||||
if (!component || !component.length) return
|
||||
component = component[0]
|
||||
component.setSelectionMode(val)
|
||||
component.selected = selectedMediaItems.some((i) => i.id === ent.id)
|
||||
})
|
||||
}
|
||||
},
|
||||
selectBook(audiobook) {
|
||||
this.$store.commit('toggleAudiobookSelected', audiobook.id)
|
||||
selectItem(payload) {
|
||||
this.$emit('selectEntity', payload)
|
||||
},
|
||||
itemSelectedEvt() {
|
||||
this.updateSelectionMode(this.isSelectionMode)
|
||||
},
|
||||
scrolled() {
|
||||
clearTimeout(this.scrollTimer)
|
||||
@@ -156,6 +192,14 @@ export default {
|
||||
this.canScrollLeft = false
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -163,25 +207,13 @@ export default {
|
||||
<style>
|
||||
.categorizedBookshelfRow {
|
||||
scroll-behavior: smooth;
|
||||
width: calc(100vw - 80px);
|
||||
|
||||
/* background-color: rgb(214, 116, 36); */
|
||||
background-image: var(--bookshelf-texture-img);
|
||||
/* background-position: center; */
|
||||
/* background-size: contain; */
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.categorizedBookshelfRow {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
.bookshelfDividerCategorized {
|
||||
background: rgb(149, 119, 90);
|
||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
||||
background: linear-gradient(180deg, rgb(122, 94, 68) 0%, rgb(92, 62, 31) 17%, rgb(82, 54, 26) 88%, rgba(71, 48, 25, 1) 100%);
|
||||
/* background: linear-gradient(180deg, rgb(114, 85, 59) 0%, rgb(73, 48, 22) 17%, rgb(71, 43, 15) 88%, rgb(61, 41, 20) 100%); */
|
||||
box-shadow: 2px 14px 8px #111111aa;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,52 +1,100 @@
|
||||
<template>
|
||||
<div class="w-full h-20 md:h-10 relative">
|
||||
<div class="flex md:hidden h-10 items-center">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="flex-grow h-full flex justify-center items-center" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p>Home</p>
|
||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="flex-grow h-full flex justify-center items-center" :class="isHomePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p v-if="isHomePage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonHome }}</p>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
</nuxt-link>
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="flex-grow h-full flex justify-center items-center" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p>Library</p>
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="flex-grow h-full flex justify-center items-center" :class="isLibraryPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p v-if="isLibraryPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonLibrary }}</p>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</nuxt-link>
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p>Series</p>
|
||||
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p class="text-sm">{{ $strings.ButtonLatest }}</p>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p v-if="isSeriesPage" class="text-sm">{{ $strings.ButtonSeries }}</p>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||
</svg>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="isCollectionsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p v-if="isCollectionsPage" class="text-sm">{{ $strings.ButtonCollections }}</p>
|
||||
<span v-else class="material-icons-outlined text-lg">collections_bookmark</span>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="flex-grow h-full flex justify-center items-center" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p v-if="isAuthorsPage" class="text-sm">{{ $strings.ButtonAuthors }}</p>
|
||||
<svg v-else class="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z"
|
||||
/>
|
||||
</svg>
|
||||
</nuxt-link>
|
||||
<nuxt-link 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>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||
<template v-if="page !== 'search' && !isHome">
|
||||
<p v-if="!selectedSeries" class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p>
|
||||
<div v-else class="items-center hidden md:flex">
|
||||
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
<span class="material-icons text-2xl text-white">west</span>
|
||||
</div>
|
||||
<p class="pl-4 font-book text-lg">
|
||||
{{ selectedSeries }}
|
||||
</p>
|
||||
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
|
||||
<span class="font-mono">{{ numShowing }}</span>
|
||||
</div>
|
||||
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||
<!-- Series books page -->
|
||||
<template v-if="selectedSeries">
|
||||
<p class="pl-2 text-base md:text-lg">
|
||||
{{ seriesName }}
|
||||
</p>
|
||||
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
|
||||
<span class="font-mono">{{ numShowing }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-checkbox v-if="!isBatchSelecting" v-model="settings.collapseBookSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseBookSeries" />
|
||||
|
||||
<!-- RSS feed -->
|
||||
<ui-tooltip v-if="seriesRssFeed" :text="$strings.LabelOpenRSSFeed" direction="top">
|
||||
<ui-icon-btn icon="rss_feed" class="mx-0.5" :size="7" icon-font-size="1.2rem" bg-color="success" outlined @click="showOpenSeriesRSSFeed" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-context-menu-dropdown v-if="!isBatchSelecting && seriesContextMenuItems.length" :items="seriesContextMenuItems" class="mx-px" @action="seriesContextMenuAction" />
|
||||
</template>
|
||||
<!-- library & collections page -->
|
||||
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome">
|
||||
<p class="hidden md:block">{{ numShowing }} {{ entityName }}</p>
|
||||
|
||||
<div class="flex-grow hidden sm:inline-block" />
|
||||
|
||||
<ui-checkbox v-show="showSortFilters" v-model="settings.collapseSeries" label="Collapse Series" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
|
||||
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
|
||||
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
|
||||
<!-- <div v-show="showSortFilters" class="h-7 ml-4 flex border border-white border-opacity-25 rounded-md">
|
||||
<div class="h-full px-2 text-white flex items-center rounded-l-md hover:bg-primary hover:bg-opacity-75 cursor-pointer" :class="isGridMode ? 'bg-primary' : 'text-opacity-70'" @click="$emit('update:viewMode', 'grid')">
|
||||
<span class="material-icons" style="font-size: 1.4rem">view_module</span>
|
||||
</div>
|
||||
<div class="h-full px-2 text-white flex items-center rounded-r-md hover:bg-primary hover:bg-opacity-75 cursor-pointer" :class="!isGridMode ? 'bg-primary' : 'text-opacity-70'" @click="$emit('update:viewMode', 'list')">
|
||||
<span class="material-icons" style="font-size: 1.4rem">view_list</span>
|
||||
</div>
|
||||
</div> -->
|
||||
<!-- collapse series checkbox -->
|
||||
<ui-checkbox v-if="isLibraryPage && isBookLibrary && !isBatchSelecting" v-model="settings.collapseSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
|
||||
|
||||
<!-- library filter select -->
|
||||
<controls-library-filter-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
|
||||
|
||||
<!-- library sort select -->
|
||||
<controls-library-sort-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
|
||||
|
||||
<!-- series filter select -->
|
||||
<controls-library-filter-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesFilterBy" is-series class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesFilter" />
|
||||
|
||||
<!-- series sort select -->
|
||||
<controls-sort-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesSortBy" :descending.sync="settings.seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" />
|
||||
|
||||
<!-- issues page remove all button -->
|
||||
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
|
||||
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
||||
</template>
|
||||
<!-- search page -->
|
||||
<template v-else-if="page === 'search'">
|
||||
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
<span class="material-icons text-3xl text-white">west</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<p>Search results for "{{ searchQuery }}"</p>
|
||||
<p>{{ $strings.MessageSearchResultsFor }} "{{ searchQuery }}"</p>
|
||||
<div class="flex-grow" />
|
||||
</template>
|
||||
<!-- authors page -->
|
||||
<template v-else-if="page === 'authors'">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="userCanUpdate && authors && authors.length && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -56,57 +104,331 @@ export default {
|
||||
props: {
|
||||
page: String,
|
||||
isHome: Boolean,
|
||||
selectedSeries: String,
|
||||
selectedSeries: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
searchQuery: String,
|
||||
viewMode: String
|
||||
authors: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
settings: {},
|
||||
hasInit: false,
|
||||
totalEntities: 0,
|
||||
keywordFilter: null,
|
||||
keywordTimeout: null
|
||||
processingSeries: false,
|
||||
processingIssues: false,
|
||||
processingAuthors: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isGridMode() {
|
||||
return this.viewMode === 'grid'
|
||||
seriesContextMenuItems() {
|
||||
if (!this.selectedSeries) return []
|
||||
|
||||
const items = [
|
||||
{
|
||||
text: this.isSeriesFinished ? this.$strings.MessageMarkAsNotFinished : this.$strings.MessageMarkAsFinished,
|
||||
action: 'mark-series-finished'
|
||||
}
|
||||
]
|
||||
|
||||
if (this.userIsAdminOrUp || this.selectedSeries.rssFeed) {
|
||||
items.push({
|
||||
text: this.$strings.LabelOpenRSSFeed,
|
||||
action: 'open-rss-feed'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.isSeriesRemovedFromContinueListening) {
|
||||
items.push({
|
||||
text: 'Re-Add Series to Continue Listening',
|
||||
action: 're-add-to-continue-listening'
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
showSortFilters() {
|
||||
seriesSortItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelName,
|
||||
value: 'name'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelNumberOfBooks,
|
||||
value: 'numBooks'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAddedAt,
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLastBookAdded,
|
||||
value: 'lastBookAdded'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLastBookUpdated,
|
||||
value: 'lastBookUpdated'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelTotalDuration,
|
||||
value: 'totalDuration'
|
||||
}
|
||||
]
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
},
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isBookLibrary() {
|
||||
return this.currentLibraryMediaType === 'book'
|
||||
},
|
||||
isPodcastLibrary() {
|
||||
return this.currentLibraryMediaType === 'podcast'
|
||||
},
|
||||
isMusicLibrary() {
|
||||
return this.currentLibraryMediaType === 'music'
|
||||
},
|
||||
isLibraryPage() {
|
||||
return this.page === ''
|
||||
},
|
||||
isSeriesPage() {
|
||||
return this.page === 'series'
|
||||
},
|
||||
isCollectionsPage() {
|
||||
return this.page === 'collections'
|
||||
},
|
||||
isPlaylistsPage() {
|
||||
return this.page === 'playlists'
|
||||
},
|
||||
isHomePage() {
|
||||
return this.$route.name === 'library-library'
|
||||
},
|
||||
isPodcastSearchPage() {
|
||||
return this.$route.name === 'library-library-podcast-search'
|
||||
},
|
||||
isPodcastLatestPage() {
|
||||
return this.$route.name === 'library-library-podcast-latest'
|
||||
},
|
||||
isAuthorsPage() {
|
||||
return this.$route.name === 'library-library-authors'
|
||||
},
|
||||
isAlbumsPage() {
|
||||
return this.page === 'albums'
|
||||
},
|
||||
numShowing() {
|
||||
return this.totalEntities
|
||||
},
|
||||
entityName() {
|
||||
if (!this.page) return 'Books'
|
||||
if (this.page === 'series') return 'Series'
|
||||
if (this.page === 'collections') return 'Collections'
|
||||
if (this.isAlbumsPage) return 'Albums'
|
||||
if (this.isMusicLibrary) return 'Tracks'
|
||||
|
||||
if (this.isPodcastLibrary) return this.$strings.LabelPodcasts
|
||||
if (!this.page) return this.$strings.LabelBooks
|
||||
if (this.isSeriesPage) return this.$strings.LabelSeries
|
||||
if (this.isCollectionsPage) return this.$strings.LabelCollections
|
||||
if (this.isPlaylistsPage) return this.$strings.LabelPlaylists
|
||||
return ''
|
||||
},
|
||||
paramId() {
|
||||
return this.$route.params ? this.$route.params.id || '' : ''
|
||||
seriesId() {
|
||||
return this.selectedSeries ? this.selectedSeries.id : null
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
seriesName() {
|
||||
return this.selectedSeries ? this.selectedSeries.name : null
|
||||
},
|
||||
homePage() {
|
||||
return this.$route.name === 'library-library'
|
||||
seriesProgress() {
|
||||
return this.selectedSeries ? this.selectedSeries.progress : null
|
||||
},
|
||||
libraryBookshelfPage() {
|
||||
return this.$route.name === 'library-library-bookshelf-id'
|
||||
seriesRssFeed() {
|
||||
return this.selectedSeries ? this.selectedSeries.rssFeed : null
|
||||
},
|
||||
showLibrary() {
|
||||
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
||||
seriesLibraryItemIds() {
|
||||
if (!this.seriesProgress) return []
|
||||
return this.seriesProgress.libraryItemIds || []
|
||||
},
|
||||
isBatchSelecting() {
|
||||
return this.$store.getters['globals/getIsBatchSelectingMediaItems']
|
||||
},
|
||||
isSeriesFinished() {
|
||||
return this.seriesProgress && !!this.seriesProgress.isFinished
|
||||
},
|
||||
isSeriesRemovedFromContinueListening() {
|
||||
if (!this.seriesId) return false
|
||||
return this.$store.getters['user/getIsSeriesRemovedFromContinueListening'](this.seriesId)
|
||||
},
|
||||
filterBy() {
|
||||
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||
},
|
||||
isIssuesFilter() {
|
||||
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
||||
},
|
||||
contextMenuItems() {
|
||||
const items = []
|
||||
|
||||
if (this.isPodcastLibrary && this.isLibraryPage && this.userCanDownload) {
|
||||
items.push({
|
||||
text: 'Export OPML',
|
||||
action: 'export-opml'
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
searchBackArrow() {
|
||||
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
|
||||
contextMenuAction({ action }) {
|
||||
if (action === 'export-opml') {
|
||||
this.exportOPML()
|
||||
}
|
||||
},
|
||||
seriesBackArrow() {
|
||||
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/series`)
|
||||
exportOPML() {
|
||||
this.$downloadFile(`/api/libraries/${this.currentLibraryId}/opml?token=${this.$store.getters['user/getToken']}`, null, true)
|
||||
},
|
||||
seriesContextMenuAction({ action }) {
|
||||
if (action === 'open-rss-feed') {
|
||||
this.showOpenSeriesRSSFeed()
|
||||
} else if (action === 're-add-to-continue-listening') {
|
||||
if (this.processingSeries) {
|
||||
console.warn('Already processing series')
|
||||
return
|
||||
}
|
||||
this.reAddSeriesToContinueListening()
|
||||
} else if (action === 'mark-series-finished') {
|
||||
if (this.processingSeries) {
|
||||
console.warn('Already processing series')
|
||||
return
|
||||
}
|
||||
this.markSeriesFinished()
|
||||
}
|
||||
},
|
||||
showOpenSeriesRSSFeed() {
|
||||
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
|
||||
id: this.selectedSeries.id,
|
||||
name: this.selectedSeries.name,
|
||||
type: 'series',
|
||||
feed: this.selectedSeries.rssFeed
|
||||
})
|
||||
},
|
||||
reAddSeriesToContinueListening() {
|
||||
this.processingSeries = true
|
||||
this.$axios
|
||||
.$get(`/api/me/series/${this.seriesId}/readd-to-continue-listening`)
|
||||
.then(() => {
|
||||
this.$toast.success('Series re-added to continue listening')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to re-add series to continue listening', error)
|
||||
this.$toast.error('Failed to re-add series to continue listening')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processingSeries = false
|
||||
})
|
||||
},
|
||||
async matchAllAuthors() {
|
||||
this.processingAuthors = true
|
||||
|
||||
for (const author of this.authors) {
|
||||
const payload = {}
|
||||
if (author.asin) payload.asin = author.asin
|
||||
else payload.q = author.name
|
||||
|
||||
payload.region = 'us'
|
||||
if (this.libraryProvider.startsWith('audible.')) {
|
||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
||||
}
|
||||
|
||||
this.$eventBus.$emit(`searching-author-${author.id}`, true)
|
||||
|
||||
var response = await this.$axios.$post(`/api/authors/${author.id}/match`, payload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
})
|
||||
if (!response) {
|
||||
console.error(`Author ${author.name} not found`)
|
||||
this.$toast.error(`Author ${author.name} not found`)
|
||||
} else if (response.updated) {
|
||||
if (response.author.imagePath) console.log(`Author ${response.author.name} was updated`)
|
||||
else console.log(`Author ${response.author.name} was updated (no image found)`)
|
||||
} else {
|
||||
console.log(`No updates were made for Author ${response.author.name}`)
|
||||
}
|
||||
|
||||
this.$eventBus.$emit(`searching-author-${author.id}`, false)
|
||||
}
|
||||
this.processingAuthors = false
|
||||
},
|
||||
removeAllIssues() {
|
||||
if (confirm(`Are you sure you want to remove all library items with issues?\n\nNote: This will not delete any files`)) {
|
||||
this.processingIssues = true
|
||||
this.$axios
|
||||
.$delete(`/api/libraries/${this.currentLibraryId}/issues`)
|
||||
.then(() => {
|
||||
this.$toast.success('Removed library items with issues')
|
||||
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
|
||||
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove library items with issues', error)
|
||||
this.$toast.error('Failed to remove library items with issues')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processingIssues = false
|
||||
})
|
||||
}
|
||||
},
|
||||
markSeriesFinished() {
|
||||
const newIsFinished = !this.isSeriesFinished
|
||||
|
||||
const payload = {
|
||||
message: newIsFinished ? this.$strings.MessageConfirmMarkSeriesFinished : this.$strings.MessageConfirmMarkSeriesNotFinished,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.processingSeries = true
|
||||
const updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
|
||||
return {
|
||||
libraryItemId: lid,
|
||||
isFinished: newIsFinished
|
||||
}
|
||||
})
|
||||
console.log('Progress payloads', updateProgressPayloads)
|
||||
this.$axios
|
||||
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastSeriesUpdateSuccess)
|
||||
this.selectedSeries.progress.isFinished = newIsFinished
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error(this.$strings.ToastSeriesUpdateFailed)
|
||||
console.error('Failed to batch update read/not read', error)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processingSeries = false
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
updateOrder() {
|
||||
this.saveSettings()
|
||||
@@ -114,9 +436,18 @@ export default {
|
||||
updateFilter() {
|
||||
this.saveSettings()
|
||||
},
|
||||
updateSeriesSort() {
|
||||
this.saveSettings()
|
||||
},
|
||||
updateSeriesFilter() {
|
||||
this.saveSettings()
|
||||
},
|
||||
updateCollapseSeries() {
|
||||
this.saveSettings()
|
||||
},
|
||||
updateCollapseBookSeries() {
|
||||
this.saveSettings()
|
||||
},
|
||||
saveSettings() {
|
||||
this.$store.dispatch('user/updateUserSettings', this.settings)
|
||||
},
|
||||
@@ -131,24 +462,31 @@ export default {
|
||||
setBookshelfTotalEntities(totalEntities) {
|
||||
this.totalEntities = totalEntities
|
||||
},
|
||||
keywordFilterInput() {
|
||||
clearTimeout(this.keywordTimeout)
|
||||
this.keywordTimeout = setTimeout(() => {
|
||||
this.keywordUpdated(this.keywordFilter)
|
||||
}, 1000)
|
||||
rssFeedOpen(data) {
|
||||
if (data.entityId === this.seriesId) {
|
||||
console.log('RSS Feed Opened', data)
|
||||
this.selectedSeries.rssFeed = data
|
||||
}
|
||||
},
|
||||
keywordUpdated() {
|
||||
this.$eventBus.$emit('bookshelf-keyword-filter', this.keywordFilter)
|
||||
rssFeedClosed(data) {
|
||||
if (data.entityId === this.seriesId) {
|
||||
console.log('RSS Feed Closed', data)
|
||||
this.selectedSeries.rssFeed = null
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated })
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities)
|
||||
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
|
||||
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities)
|
||||
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
|
||||
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -158,4 +496,4 @@ export default {
|
||||
#toolbar {
|
||||
box-shadow: 0px 8px 6px #111111aa;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
<template>
|
||||
<div class="w-44 fixed left-0 top-16 h-full bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform" :class="wrapperClass" v-click-outside="clickOutside">
|
||||
<div class="md:hidden flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
|
||||
<span class="material-icons text-2xl">arrow_back</span>
|
||||
<div>
|
||||
<div class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside">
|
||||
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
|
||||
<span class="material-icons text-2xl">arrow_back</span>
|
||||
</div>
|
||||
|
||||
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-3 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||
<p class="leading-4">{{ route.title }}</p>
|
||||
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
|
||||
</div>
|
||||
|
||||
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||
<p>{{ route.title }}</p>
|
||||
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
<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="w-full h-10 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamAudiobook && isMobileLandscape ? '300px' : '65px' }">
|
||||
<p class="font-mono text-sm">v{{ $config.version }}</p>
|
||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-sm">Update available: {{ latestVersion }}</a>
|
||||
<p class="font-mono 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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -22,65 +30,98 @@ export default {
|
||||
isOpen: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
showChangelogModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userIsRoot() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
Source() {
|
||||
return this.$store.state.Source
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
configRoutes() {
|
||||
if (!this.userIsRoot) {
|
||||
if (!this.userIsAdminOrUp) {
|
||||
return [
|
||||
{
|
||||
id: 'config-stats',
|
||||
title: 'Your Stats',
|
||||
title: this.$strings.HeaderYourStats,
|
||||
path: '/config/stats'
|
||||
}
|
||||
]
|
||||
}
|
||||
return [
|
||||
const configRoutes = [
|
||||
{
|
||||
id: 'config',
|
||||
title: 'Settings',
|
||||
title: this.$strings.HeaderSettings,
|
||||
path: '/config'
|
||||
},
|
||||
{
|
||||
id: 'config-libraries',
|
||||
title: 'Libraries',
|
||||
title: this.$strings.HeaderLibraries,
|
||||
path: '/config/libraries'
|
||||
},
|
||||
{
|
||||
id: 'config-users',
|
||||
title: 'Users',
|
||||
title: this.$strings.HeaderUsers,
|
||||
path: '/config/users'
|
||||
},
|
||||
{
|
||||
id: 'config-sessions',
|
||||
title: this.$strings.HeaderListeningSessions,
|
||||
path: '/config/sessions'
|
||||
},
|
||||
{
|
||||
id: 'config-backups',
|
||||
title: 'Backups',
|
||||
title: this.$strings.HeaderBackups,
|
||||
path: '/config/backups'
|
||||
},
|
||||
{
|
||||
id: 'config-log',
|
||||
title: 'Log',
|
||||
title: this.$strings.HeaderLogs,
|
||||
path: '/config/log'
|
||||
},
|
||||
{
|
||||
id: 'config-library-stats',
|
||||
title: 'Library Stats',
|
||||
path: '/config/library-stats'
|
||||
id: 'config-notifications',
|
||||
title: this.$strings.HeaderNotifications,
|
||||
path: '/config/notifications'
|
||||
},
|
||||
{
|
||||
id: 'config-stats',
|
||||
title: 'Your Stats',
|
||||
path: '/config/stats'
|
||||
id: 'config-email',
|
||||
title: this.$strings.HeaderEmail,
|
||||
path: '/config/email'
|
||||
},
|
||||
{
|
||||
id: 'config-item-metadata-utils',
|
||||
title: this.$strings.HeaderItemMetadataUtils,
|
||||
path: '/config/item-metadata-utils'
|
||||
}
|
||||
]
|
||||
|
||||
if (this.currentLibraryId) {
|
||||
configRoutes.push({
|
||||
id: 'config-library-stats',
|
||||
title: this.$strings.HeaderLibraryStats,
|
||||
path: '/config/library-stats'
|
||||
})
|
||||
configRoutes.push({
|
||||
id: 'config-stats',
|
||||
title: this.$strings.HeaderYourStats,
|
||||
path: '/config/stats'
|
||||
})
|
||||
}
|
||||
|
||||
return configRoutes
|
||||
},
|
||||
wrapperClass() {
|
||||
var classes = []
|
||||
if (this.drawerOpen) classes.push('translate-x-0')
|
||||
else classes.push('-translate-x-44')
|
||||
if (this.isMobile) classes.push('z-50')
|
||||
if (this.isMobilePortrait) classes.push('z-50')
|
||||
else classes.push('z-40')
|
||||
return classes.join(' ')
|
||||
},
|
||||
@@ -90,9 +131,11 @@ export default {
|
||||
isMobileLandscape() {
|
||||
return this.$store.state.globals.isMobileLandscape
|
||||
},
|
||||
isMobilePortrait() {
|
||||
return this.$store.state.globals.isMobilePortrait
|
||||
},
|
||||
drawerOpen() {
|
||||
if (this.isMobile) return this.isOpen
|
||||
return true
|
||||
return !this.isMobilePortrait || this.isOpen
|
||||
},
|
||||
routeName() {
|
||||
return this.$route.name
|
||||
@@ -109,11 +152,17 @@ export default {
|
||||
githubTagUrl() {
|
||||
return this.versionData.githubTagUrl
|
||||
},
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
currentVersionChangelog() {
|
||||
return this.versionData.currentVersionChangelog || 'No Changelog Available'
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickChangelog() {
|
||||
this.showChangelogModal = true
|
||||
},
|
||||
clickOutside() {
|
||||
if (!this.isOpen) return
|
||||
this.closeDrawer()
|
||||
|
||||
@@ -6,27 +6,22 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="initialized && !totalShelves && !hasFilter && isRootUser && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">Audiobookshelf is empty!</p>
|
||||
<div class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
||||
<div v-if="initialized && !totalShelves && !hasFilter && entityName === 'items'" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl mb-4 py-4">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
|
||||
<div v-if="userIsAdminOrUp" class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">{{ $strings.ButtonConfigureScanner }}</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!totalShelves && initialized" class="w-full py-16">
|
||||
<p class="text-xl text-center">{{ emptyMessage }}</p>
|
||||
<div class="flex justify-center mt-2">
|
||||
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">Clear Filter</ui-btn>
|
||||
<!-- Clear filter only available on Library bookshelf -->
|
||||
<div v-if="entityName === 'items'" class="flex justify-center mt-2">
|
||||
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">{{ $strings.ButtonClearFilter }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||
<!-- Experimental Bookshelf Texture -->
|
||||
<div v-show="showExperimentalFeatures" class="fixed bottom-4 right-28 z-40">
|
||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal">
|
||||
<p class="text-sm py-0.5">Texture</p>
|
||||
</div>
|
||||
</div>
|
||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -41,6 +36,7 @@ export default {
|
||||
mixins: [bookshelfCardsHelpers],
|
||||
data() {
|
||||
return {
|
||||
routeFullPath: null,
|
||||
initialized: false,
|
||||
bookshelfHeight: 0,
|
||||
bookshelfWidth: 0,
|
||||
@@ -60,13 +56,13 @@ export default {
|
||||
totalShelves: 0,
|
||||
bookshelfMarginLeft: 0,
|
||||
isSelectionMode: false,
|
||||
isSelectAll: false,
|
||||
currentSFQueryString: null,
|
||||
pendingReset: false,
|
||||
keywordFilter: null,
|
||||
currScrollTop: 0,
|
||||
resizeTimeout: null,
|
||||
mountWindowWidth: 0
|
||||
mountWindowWidth: 0,
|
||||
lastItemIndexSelected: -1
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -79,22 +75,39 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
libraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isPodcast() {
|
||||
return this.libraryMediaType === 'podcast'
|
||||
},
|
||||
emptyMessage() {
|
||||
if (this.page === 'series') return `You have no series`
|
||||
if (this.page === 'collections') return "You haven't made any collections yet"
|
||||
if (this.hasFilter) return `No Results for filter "${this.filterValue}"`
|
||||
return 'No results'
|
||||
if (this.page === 'series') return this.$strings.MessageBookshelfNoSeries
|
||||
if (this.page === 'collections') return this.$strings.MessageBookshelfNoCollections
|
||||
if (this.page === 'playlists') return this.$strings.MessageNoUserPlaylists
|
||||
if (this.hasFilter) {
|
||||
if (this.filterName === 'Issues') return this.$strings.MessageNoIssues
|
||||
else if (this.filterName === 'Feed-open') return this.$strings.MessageBookshelfNoRSSFeeds
|
||||
return this.$getString('MessageBookshelfNoResultsForFilter', [this.filterName, this.filterValue])
|
||||
}
|
||||
return this.$strings.MessageNoResults
|
||||
},
|
||||
entityName() {
|
||||
if (!this.page) return 'books'
|
||||
if (!this.page) return 'items'
|
||||
return this.page
|
||||
},
|
||||
seriesSortBy() {
|
||||
return this.$store.getters['user/getUserSetting']('seriesSortBy')
|
||||
},
|
||||
seriesSortDesc() {
|
||||
return this.$store.getters['user/getUserSetting']('seriesSortDesc')
|
||||
},
|
||||
seriesFilterBy() {
|
||||
return this.$store.getters['user/getUserSetting']('seriesFilterBy')
|
||||
},
|
||||
orderBy() {
|
||||
return this.$store.getters['user/getUserSetting']('orderBy')
|
||||
},
|
||||
@@ -107,24 +120,23 @@ export default {
|
||||
collapseSeries() {
|
||||
return this.$store.getters['user/getUserSetting']('collapseSeries')
|
||||
},
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
collapseBookSeries() {
|
||||
return this.$store.getters['user/getUserSetting']('collapseBookSeries')
|
||||
},
|
||||
bookshelfView() {
|
||||
return this.$store.getters['getServerSetting']('bookshelfView')
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
sortingIgnorePrefix() {
|
||||
return this.$store.getters['getServerSetting']('sortingIgnorePrefix')
|
||||
},
|
||||
isCoverSquareAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||
return this.coverAspectRatio == 1
|
||||
},
|
||||
bookshelfView() {
|
||||
return this.$store.getters['getBookshelfView']
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
if (!this.isEntityBook) return false // Only used for bookshelf showing books
|
||||
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.isCoverSquareAspectRatio ? 1 : 1.6
|
||||
return this.bookshelfView === this.$constants.BookshelfView.DETAIL
|
||||
},
|
||||
hasFilter() {
|
||||
return this.filterBy && this.filterBy !== 'all'
|
||||
@@ -143,16 +155,16 @@ export default {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
isEntityBook() {
|
||||
return this.entityName === 'series-books' || this.entityName === 'books'
|
||||
libraryName() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||
},
|
||||
bookWidth() {
|
||||
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
||||
const coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return coverSize * 1.6
|
||||
return coverSize
|
||||
},
|
||||
bookHeight() {
|
||||
if (this.isCoverSquareAspectRatio) return this.bookWidth
|
||||
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return this.bookWidth
|
||||
return this.bookWidth * 1.6
|
||||
},
|
||||
shelfPadding() {
|
||||
@@ -176,47 +188,106 @@ export default {
|
||||
return 6
|
||||
},
|
||||
shelfHeight() {
|
||||
if (this.isAlternativeBookshelfView) return this.entityHeight + 80 * this.sizeMultiplier
|
||||
if (this.isAlternativeBookshelfView) {
|
||||
const isItemEntity = this.entityName === 'series-books' || this.entityName === 'items'
|
||||
const extraTitleSpace = isItemEntity ? 80 : this.entityName === 'albums' ? 60 : 40
|
||||
return this.entityHeight + extraTitleSpace * this.sizeMultiplier
|
||||
}
|
||||
return this.entityHeight + 40
|
||||
},
|
||||
totalEntityCardWidth() {
|
||||
// Includes margin
|
||||
return this.entityWidth + 24
|
||||
},
|
||||
selectedAudiobooks() {
|
||||
return this.$store.state.selectedAudiobooks || []
|
||||
selectedMediaItems() {
|
||||
return this.$store.state.globals.selectedMediaItems || []
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
return this.entityWidth / baseSize
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showBookshelfTextureModal() {
|
||||
this.$store.commit('globals/setShowBookshelfTextureModal', true)
|
||||
},
|
||||
clearFilter() {
|
||||
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
|
||||
},
|
||||
editEntity(entity) {
|
||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||
var bookIds = this.entities.map((e) => e.id)
|
||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||
const bookIds = this.entities.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||
this.$store.commit('showEditModal', entity)
|
||||
} else if (this.entityName === 'collections') {
|
||||
this.$store.commit('globals/setEditCollection', entity)
|
||||
} else if (this.entityName === 'playlists') {
|
||||
this.$store.commit('globals/setEditPlaylist', entity)
|
||||
}
|
||||
},
|
||||
clearSelectedEntities() {
|
||||
this.updateBookSelectionMode(false)
|
||||
this.isSelectionMode = false
|
||||
this.isSelectAll = false
|
||||
},
|
||||
selectEntity(entity) {
|
||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||
this.$store.commit('toggleAudiobookSelected', entity.id)
|
||||
selectEntity(entity, shiftKey) {
|
||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||
const indexOf = this.entities.findIndex((ent) => ent && ent.id === entity.id)
|
||||
const lastLastItemIndexSelected = this.lastItemIndexSelected
|
||||
if (!this.selectedMediaItems.some((i) => i.id === entity.id)) {
|
||||
this.lastItemIndexSelected = indexOf
|
||||
} else {
|
||||
this.lastItemIndexSelected = -1
|
||||
}
|
||||
|
||||
var newIsSelectionMode = !!this.selectedAudiobooks.length
|
||||
if (shiftKey && lastLastItemIndexSelected >= 0) {
|
||||
let loopStart = indexOf
|
||||
let loopEnd = lastLastItemIndexSelected
|
||||
if (indexOf > lastLastItemIndexSelected) {
|
||||
loopStart = lastLastItemIndexSelected
|
||||
loopEnd = indexOf
|
||||
}
|
||||
|
||||
let isSelecting = false
|
||||
// If any items in this range is not selected then select all otherwise unselect all
|
||||
for (let i = loopStart; i <= loopEnd; i++) {
|
||||
const thisEntity = this.entities[i]
|
||||
if (thisEntity && !thisEntity.collapsedSeries) {
|
||||
if (!this.selectedMediaItems.some((i) => i.id === thisEntity.id)) {
|
||||
isSelecting = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isSelecting) this.lastItemIndexSelected = indexOf
|
||||
|
||||
for (let i = loopStart; i <= loopEnd; i++) {
|
||||
const thisEntity = this.entities[i]
|
||||
if (thisEntity.collapsedSeries) {
|
||||
console.warn('Ignoring collapsed series')
|
||||
continue
|
||||
}
|
||||
|
||||
const entityComponentRef = this.entityComponentRefs[i]
|
||||
if (thisEntity && entityComponentRef) {
|
||||
entityComponentRef.selected = isSelecting
|
||||
|
||||
const mediaItem = {
|
||||
id: thisEntity.id,
|
||||
mediaType: thisEntity.mediaType,
|
||||
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.audioFile || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
|
||||
}
|
||||
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })
|
||||
} else {
|
||||
console.error('Invalid entity index', i)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const mediaItem = {
|
||||
id: entity.id,
|
||||
mediaType: entity.mediaType,
|
||||
hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
|
||||
}
|
||||
this.$store.commit('globals/toggleMediaItemSelected', mediaItem)
|
||||
}
|
||||
|
||||
const newIsSelectionMode = !!this.selectedMediaItems.length
|
||||
if (this.isSelectionMode !== newIsSelectionMode) {
|
||||
this.isSelectionMode = newIsSelectionMode
|
||||
this.updateBookSelectionMode(newIsSelectionMode)
|
||||
@@ -229,9 +300,12 @@ export default {
|
||||
this.entityComponentRefs[key].setSelectionMode(isSelectionMode)
|
||||
}
|
||||
}
|
||||
if (!isSelectionMode) {
|
||||
this.lastItemIndexSelected = -1
|
||||
}
|
||||
},
|
||||
async fetchEntites(page = 0) {
|
||||
var startIndex = page * this.booksPerFetch
|
||||
const startIndex = page * this.booksPerFetch
|
||||
|
||||
this.isFetchingEntities = true
|
||||
|
||||
@@ -239,11 +313,11 @@ export default {
|
||||
this.currentSFQueryString = this.buildSearchParams()
|
||||
}
|
||||
|
||||
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `books/all` : this.entityName
|
||||
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
|
||||
const 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`
|
||||
|
||||
var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
||||
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
||||
console.error('failed to fetch books', error)
|
||||
return null
|
||||
})
|
||||
@@ -260,16 +334,17 @@ export default {
|
||||
this.totalEntities = payload.total
|
||||
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
|
||||
this.entities = new Array(this.totalEntities)
|
||||
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
||||
}
|
||||
|
||||
for (let i = 0; i < payload.results.length; i++) {
|
||||
var index = i + startIndex
|
||||
const index = i + startIndex
|
||||
this.entities[index] = payload.results[i]
|
||||
if (this.entityComponentRefs[index]) {
|
||||
this.entityComponentRefs[index].setEntity(this.entities[index])
|
||||
}
|
||||
}
|
||||
|
||||
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
||||
}
|
||||
},
|
||||
loadPage(page) {
|
||||
@@ -300,11 +375,11 @@ export default {
|
||||
var firstBookPage = Math.floor(firstBookIndex / this.booksPerFetch)
|
||||
var lastBookPage = Math.floor(lastBookIndex / this.booksPerFetch)
|
||||
if (!this.pagesLoaded[firstBookPage]) {
|
||||
console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex)
|
||||
// console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex)
|
||||
this.loadPage(firstBookPage)
|
||||
}
|
||||
if (!this.pagesLoaded[lastBookPage]) {
|
||||
console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
|
||||
// console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
|
||||
this.loadPage(lastBookPage)
|
||||
}
|
||||
|
||||
@@ -332,7 +407,6 @@ export default {
|
||||
this.totalEntities = 0
|
||||
this.currentPage = 0
|
||||
this.isSelectionMode = false
|
||||
this.isSelectAll = false
|
||||
this.initialized = false
|
||||
|
||||
this.initSizeData()
|
||||
@@ -368,15 +442,20 @@ export default {
|
||||
this.$nextTick(this.remountEntities)
|
||||
},
|
||||
buildSearchParams() {
|
||||
if (this.page === 'search' || this.page === 'series' || this.page === 'collections') {
|
||||
if (this.page === 'search' || this.page === 'collections') {
|
||||
return ''
|
||||
}
|
||||
|
||||
let searchParams = new URLSearchParams()
|
||||
if (this.page === 'series-books') {
|
||||
searchParams.set('filter', `series.${this.seriesId}`)
|
||||
searchParams.set('sort', 'book.volumeNumber')
|
||||
searchParams.set('desc', 0)
|
||||
if (this.page === 'series') {
|
||||
searchParams.set('sort', this.seriesSortBy)
|
||||
searchParams.set('desc', this.seriesSortDesc ? 1 : 0)
|
||||
searchParams.set('filter', this.seriesFilterBy)
|
||||
} else if (this.page === 'series-books') {
|
||||
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
|
||||
if (this.collapseBookSeries) {
|
||||
searchParams.set('collapseseries', 1)
|
||||
}
|
||||
} else {
|
||||
if (this.filterBy && this.filterBy !== 'all') {
|
||||
searchParams.set('filter', this.filterBy)
|
||||
@@ -385,15 +464,13 @@ export default {
|
||||
searchParams.set('sort', this.orderBy)
|
||||
searchParams.set('desc', this.orderDesc ? 1 : 0)
|
||||
}
|
||||
if (this.collapseSeries) {
|
||||
if (this.collapseSeries && !this.isPodcast) {
|
||||
searchParams.set('collapseseries', 1)
|
||||
}
|
||||
}
|
||||
return searchParams.toString()
|
||||
},
|
||||
checkUpdateSearchParams() {
|
||||
if (this.page === 'series-books') return false
|
||||
|
||||
var newSearchParams = this.buildSearchParams()
|
||||
var currentQueryString = window.location.search
|
||||
if (currentQueryString && currentQueryString.startsWith('?')) currentQueryString = currentQueryString.slice(1)
|
||||
@@ -404,13 +481,21 @@ export default {
|
||||
if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {
|
||||
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
|
||||
window.history.replaceState({ path: newurl }, '', newurl)
|
||||
|
||||
this.routeFullPath = window.location.pathname + (window.location.search || '') // Update for saving scroll position
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
seriesSortUpdated() {
|
||||
var wasUpdated = this.checkUpdateSearchParams()
|
||||
if (wasUpdated) {
|
||||
this.resetEntities()
|
||||
}
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
const wasUpdated = this.checkUpdateSearchParams()
|
||||
if (wasUpdated) {
|
||||
this.resetEntities()
|
||||
} else if (settings.bookshelfCoverSize !== this.currentBookWidth) {
|
||||
@@ -420,49 +505,100 @@ export default {
|
||||
scroll(e) {
|
||||
if (!e || !e.target) return
|
||||
var { scrollTop } = e.target
|
||||
// clearTimeout(this.scrollTimeout)
|
||||
// this.scrollTimeout = setTimeout(() => {
|
||||
this.handleScroll(scrollTop)
|
||||
// }, 250)
|
||||
},
|
||||
audiobookAdded(audiobook) {
|
||||
console.log('Audiobook added', audiobook)
|
||||
libraryItemAdded(libraryItem) {
|
||||
console.log('libraryItem added', libraryItem)
|
||||
// TODO: Check if audiobook would be on this shelf
|
||||
this.resetEntities()
|
||||
},
|
||||
audiobookUpdated(audiobook) {
|
||||
console.log('Audiobook updated', audiobook)
|
||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id)
|
||||
libraryItemUpdated(libraryItem) {
|
||||
console.log('Item updated', libraryItem)
|
||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||
if (indexOf >= 0) {
|
||||
this.entities[indexOf] = audiobook
|
||||
this.entities[indexOf] = libraryItem
|
||||
if (this.entityComponentRefs[indexOf]) {
|
||||
this.entityComponentRefs[indexOf].setEntity(audiobook)
|
||||
this.entityComponentRefs[indexOf].setEntity(libraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
audiobookRemoved(audiobook) {
|
||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id)
|
||||
libraryItemRemoved(libraryItem) {
|
||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||
if (indexOf >= 0) {
|
||||
this.entities = this.entities.filter((ent) => ent.id !== audiobook.id)
|
||||
this.totalEntities = this.entities.length
|
||||
this.entities = this.entities.filter((ent) => ent.id !== libraryItem.id)
|
||||
this.totalEntities--
|
||||
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
||||
this.remountEntities()
|
||||
this.executeRebuild()
|
||||
}
|
||||
}
|
||||
},
|
||||
audiobooksAdded(audiobooks) {
|
||||
console.log('audiobooks added', audiobooks)
|
||||
libraryItemsAdded(libraryItems) {
|
||||
console.log('items added', libraryItems)
|
||||
// TODO: Check if audiobook would be on this shelf
|
||||
this.resetEntities()
|
||||
},
|
||||
audiobooksUpdated(audiobooks) {
|
||||
audiobooks.forEach((ab) => {
|
||||
this.audiobookUpdated(ab)
|
||||
libraryItemsUpdated(libraryItems) {
|
||||
libraryItems.forEach((ab) => {
|
||||
this.libraryItemUpdated(ab)
|
||||
})
|
||||
},
|
||||
collectionAdded(collection) {
|
||||
if (this.entityName !== 'collections') return
|
||||
console.log(`[LazyBookshelf] collectionAdded ${collection.id}`, collection)
|
||||
this.resetEntities()
|
||||
},
|
||||
collectionUpdated(collection) {
|
||||
if (this.entityName !== 'collections') return
|
||||
console.log(`[LazyBookshelf] collectionUpdated ${collection.id}`, collection)
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === collection.id)
|
||||
if (indexOf >= 0) {
|
||||
this.entities[indexOf] = collection
|
||||
if (this.entityComponentRefs[indexOf]) {
|
||||
this.entityComponentRefs[indexOf].setEntity(collection)
|
||||
}
|
||||
}
|
||||
},
|
||||
collectionRemoved(collection) {
|
||||
if (this.entityName !== 'collections') return
|
||||
console.log(`[LazyBookshelf] collectionRemoved ${collection.id}`, collection)
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === collection.id)
|
||||
if (indexOf >= 0) {
|
||||
this.entities = this.entities.filter((ent) => ent.id !== collection.id)
|
||||
this.totalEntities--
|
||||
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
||||
this.executeRebuild()
|
||||
}
|
||||
},
|
||||
playlistAdded(playlist) {
|
||||
if (this.entityName !== 'playlists') return
|
||||
console.log(`[LazyBookshelf] playlistAdded ${playlist.id}`, playlist)
|
||||
this.resetEntities()
|
||||
},
|
||||
playlistUpdated(playlist) {
|
||||
if (this.entityName !== 'playlists') return
|
||||
console.log(`[LazyBookshelf] playlistUpdated ${playlist.id}`, playlist)
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === playlist.id)
|
||||
if (indexOf >= 0) {
|
||||
this.entities[indexOf] = playlist
|
||||
if (this.entityComponentRefs[indexOf]) {
|
||||
this.entityComponentRefs[indexOf].setEntity(playlist)
|
||||
}
|
||||
}
|
||||
},
|
||||
playlistRemoved(playlist) {
|
||||
if (this.entityName !== 'playlists') return
|
||||
console.log(`[LazyBookshelf] playlistRemoved ${playlist.id}`, playlist)
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === playlist.id)
|
||||
if (indexOf >= 0) {
|
||||
this.entities = this.entities.filter((ent) => ent.id !== playlist.id)
|
||||
this.totalEntities--
|
||||
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
||||
this.executeRebuild()
|
||||
}
|
||||
},
|
||||
initSizeData(_bookshelf) {
|
||||
var bookshelf = _bookshelf || document.getElementById('bookshelf')
|
||||
if (!bookshelf) {
|
||||
@@ -494,6 +630,15 @@ export default {
|
||||
await this.fetchEntites(0)
|
||||
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
||||
this.mountEntites(0, lastBookIndex)
|
||||
|
||||
// Set last scroll position for this bookshelf page
|
||||
if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {
|
||||
const { path, scrollTop } = this.$store.state.lastBookshelfScrollData[this.page]
|
||||
if (path === this.routeFullPath) {
|
||||
// Exact path match with query so use scroll position
|
||||
window.bookshelf.scrollTop = scrollTop
|
||||
}
|
||||
}
|
||||
},
|
||||
executeRebuild() {
|
||||
clearTimeout(this.resizeTimeout)
|
||||
@@ -519,17 +664,22 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$on('socket_init', this.socketInit)
|
||||
|
||||
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.on('audiobook_updated', this.audiobookUpdated)
|
||||
this.$root.socket.on('audiobook_added', this.audiobookAdded)
|
||||
this.$root.socket.on('audiobook_removed', this.audiobookRemoved)
|
||||
this.$root.socket.on('audiobooks_updated', this.audiobooksUpdated)
|
||||
this.$root.socket.on('audiobooks_added', this.audiobooksAdded)
|
||||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||
this.$root.socket.on('item_added', this.libraryItemAdded)
|
||||
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
||||
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
||||
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
||||
this.$root.socket.on('collection_added', this.collectionAdded)
|
||||
this.$root.socket.on('collection_updated', this.collectionUpdated)
|
||||
this.$root.socket.on('collection_removed', this.collectionRemoved)
|
||||
this.$root.socket.on('playlist_added', this.playlistAdded)
|
||||
this.$root.socket.on('playlist_updated', this.playlistUpdated)
|
||||
this.$root.socket.on('playlist_removed', this.playlistRemoved)
|
||||
} else {
|
||||
console.error('Bookshelf - Socket not initialized')
|
||||
}
|
||||
@@ -540,17 +690,23 @@ export default {
|
||||
if (bookshelf) {
|
||||
bookshelf.removeEventListener('scroll', this.scroll)
|
||||
}
|
||||
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$off('socket_init', this.socketInit)
|
||||
|
||||
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
|
||||
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$off('socket_init', this.socketInit)
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.off('audiobook_updated', this.audiobookUpdated)
|
||||
this.$root.socket.off('audiobook_added', this.audiobookAdded)
|
||||
this.$root.socket.off('audiobook_removed', this.audiobookRemoved)
|
||||
this.$root.socket.off('audiobooks_updated', this.audiobooksUpdated)
|
||||
this.$root.socket.off('audiobooks_added', this.audiobooksAdded)
|
||||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||
this.$root.socket.off('item_added', this.libraryItemAdded)
|
||||
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
||||
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
||||
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
||||
this.$root.socket.off('collection_added', this.collectionAdded)
|
||||
this.$root.socket.off('collection_updated', this.collectionUpdated)
|
||||
this.$root.socket.off('collection_removed', this.collectionRemoved)
|
||||
this.$root.socket.off('playlist_added', this.playlistAdded)
|
||||
this.$root.socket.off('playlist_updated', this.playlistUpdated)
|
||||
this.$root.socket.off('playlist_removed', this.playlistRemoved)
|
||||
} else {
|
||||
console.error('Bookshelf - Socket not initialized')
|
||||
}
|
||||
@@ -563,13 +719,25 @@ export default {
|
||||
}
|
||||
},
|
||||
scan() {
|
||||
this.$root.socket.emit('scan', this.currentLibraryId)
|
||||
this.$store
|
||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
|
||||
.then(() => {
|
||||
this.$toast.success('Library scan started')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to start scan', error)
|
||||
this.$toast.error('Failed to start scan')
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initListeners()
|
||||
|
||||
this.routeFullPath = window.location.pathname + (window.location.search || '')
|
||||
},
|
||||
updated() {
|
||||
this.routeFullPath = window.location.pathname + (window.location.search || '')
|
||||
|
||||
setTimeout(() => {
|
||||
if (window.innerWidth > 0 && window.innerWidth !== this.mountWindowWidth) {
|
||||
console.log('Updated window width', window.innerWidth, 'from', this.mountWindowWidth)
|
||||
@@ -580,6 +748,11 @@ export default {
|
||||
beforeDestroy() {
|
||||
this.destroyEntityComponents()
|
||||
this.removeListeners()
|
||||
|
||||
// Set bookshelf scroll position for specific bookshelf page and query
|
||||
if (window.bookshelf) {
|
||||
this.$store.commit('setLastBookshelfScrollData', { scrollTop: window.bookshelf.scrollTop || 0, path: this.routeFullPath, name: this.page })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -588,6 +761,7 @@ export default {
|
||||
.bookshelfRow {
|
||||
background-image: var(--bookshelf-texture-img);
|
||||
}
|
||||
|
||||
.bookshelfDivider {
|
||||
background: rgb(149, 119, 90);
|
||||
background: var(--bookshelf-divider-bg);
|
||||
|
||||
48
client/components/app/SettingsContent.vue
Normal file
48
client/components/app/SettingsContent.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<div class="flex items-center mb-2">
|
||||
<h1 class="text-xl">{{ 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>
|
||||
</div>
|
||||
|
||||
<p v-if="description" id="settings-description" class="mb-6 text-gray-200" v-html="description" />
|
||||
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
headerText: String,
|
||||
description: String,
|
||||
note: String,
|
||||
showAddButton: Boolean
|
||||
},
|
||||
methods: {
|
||||
clicked() {
|
||||
this.$emit('clicked')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#settings-description a {
|
||||
color: rgb(96 165 250);
|
||||
}
|
||||
#settings-description a:hover {
|
||||
color: rgb(147 197 253);
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
#settings-description code {
|
||||
font-size: 0.875rem;
|
||||
border-radius: 6px;
|
||||
background-color: rgb(82, 82, 82);
|
||||
color: white;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,45 +1,63 @@
|
||||
<template>
|
||||
<div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px">
|
||||
<div class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
|
||||
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
|
||||
<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>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 0.9rem">Home</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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 0.9rem">Library</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>
|
||||
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<nuxt-link v-if="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="font-book pt-1.5" style="font-size: 0.9rem">Series</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>
|
||||
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons-outlined">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="font-book pt-1.5" style="font-size: 0.9rem">Collections</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>
|
||||
|
||||
<nuxt-link v-if="showExperimentalFeatures" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<nuxt-link v-if="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>
|
||||
|
||||
<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"
|
||||
@@ -47,15 +65,47 @@
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 0.9rem">Authors</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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSearch }}</p>
|
||||
|
||||
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
|
||||
<span class="material-icons text-2xl">warning</span>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 1rem">Issues</p>
|
||||
<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">
|
||||
@@ -63,46 +113,39 @@
|
||||
</div>
|
||||
</nuxt-link>
|
||||
|
||||
<!-- <nuxt-link to="/library/collections" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<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' }">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 0.8rem">Collections</p>
|
||||
|
||||
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link> -->
|
||||
|
||||
<!-- <nuxt-link to="/library/tags" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'tags' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 0.8rem">Tags</p>
|
||||
|
||||
<div v-show="paramId === 'tags'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link> -->
|
||||
|
||||
<!-- <nuxt-link to="/library/authors" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'authors' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 0.8rem">Authors</p>
|
||||
|
||||
<div v-show="paramId === 'authors'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link> -->
|
||||
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
showChangelogModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
Source() {
|
||||
return this.$store.state.Source
|
||||
},
|
||||
isMobileLandscape() {
|
||||
return this.$store.state.globals.isMobileLandscape
|
||||
},
|
||||
isShowingBookshelfToolbar() {
|
||||
if (!this.$route.name) return false
|
||||
return this.$route.name.startsWith('library')
|
||||
},
|
||||
offsetTop() {
|
||||
return 64
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
paramId() {
|
||||
return this.$route.params ? this.$route.params.id || '' : ''
|
||||
@@ -110,6 +153,30 @@ export default {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isBookLibrary() {
|
||||
return this.currentLibraryMediaType === 'book'
|
||||
},
|
||||
isPodcastLibrary() {
|
||||
return this.currentLibraryMediaType === 'podcast'
|
||||
},
|
||||
isMusicLibrary() {
|
||||
return this.currentLibraryMediaType === 'music'
|
||||
},
|
||||
isPodcastDownloadQueuePage() {
|
||||
return this.$route.name === 'library-library-podcast-download-queue'
|
||||
},
|
||||
isPodcastSearchPage() {
|
||||
return this.$route.name === 'library-library-podcast-search'
|
||||
},
|
||||
isPodcastLatestPage() {
|
||||
return this.$route.name === 'library-library-podcast-latest'
|
||||
},
|
||||
isMusicAlbumsPage() {
|
||||
return this.paramId === 'albums'
|
||||
},
|
||||
homePage() {
|
||||
return this.$route.name === 'library-library'
|
||||
},
|
||||
@@ -119,21 +186,52 @@ export default {
|
||||
isAuthorsPage() {
|
||||
return this.$route.name === 'library-library-authors'
|
||||
},
|
||||
isNarratorsPage() {
|
||||
return this.$route.name === 'library-library-narrators'
|
||||
},
|
||||
isPlaylistsPage() {
|
||||
return this.paramId === 'playlists'
|
||||
},
|
||||
libraryBookshelfPage() {
|
||||
return this.$route.name === 'library-library-bookshelf-id'
|
||||
},
|
||||
showLibrary() {
|
||||
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
||||
},
|
||||
filterBy() {
|
||||
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||
},
|
||||
showingIssues() {
|
||||
if (!this.$route.query) return false
|
||||
return this.libraryBookshelfPage && this.$route.query.filter === 'issues'
|
||||
},
|
||||
numIssues() {
|
||||
return this.$store.state.libraries.issues || 0
|
||||
},
|
||||
versionData() {
|
||||
return this.$store.state.versionData || {}
|
||||
},
|
||||
hasUpdate() {
|
||||
return !!this.versionData.hasUpdate
|
||||
},
|
||||
githubTagUrl() {
|
||||
return this.versionData.githubTagUrl
|
||||
},
|
||||
currentVersionChangelog() {
|
||||
return this.versionData.currentVersionChangelog || 'No Changelog Available'
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
showPlaylists() {
|
||||
return this.$store.state.libraries.numUserPlaylists > 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickChangelog() {
|
||||
this.showChangelogModal = true
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,33 +1,63 @@
|
||||
<template>
|
||||
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-4 pt-2">
|
||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||
<covers-book-cover :audiobook="streamAudiobook" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<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="flex items-start pl-24 mb-6 md:mb-0">
|
||||
<div>
|
||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
|
||||
<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">
|
||||
{{ title }}
|
||||
</nuxt-link>
|
||||
<div class="text-gray-400 flex items-center">
|
||||
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
|
||||
<span class="material-icons text-sm">person</span>
|
||||
<p v-if="authorFL" class="pl-1.5 text-sm sm:text-base">
|
||||
<nuxt-link v-for="(author, index) in authorsList" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}<span v-if="index < authorsList.length - 1">, </span></nuxt-link>
|
||||
</p>
|
||||
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p>
|
||||
<div class="flex items-center">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-gray-400 flex items-center">
|
||||
<span class="material-icons text-xs">schedule</span>
|
||||
<p class="font-mono text-sm pl-2 pb-px">{{ totalDurationPretty }}</p>
|
||||
<p class="font-mono text-xs sm:text-sm pl-1 sm:pl-1.5 pb-px">{{ totalDurationPretty }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<span class="material-icons p-4 cursor-pointer" @click="closePlayer">close</span>
|
||||
<ui-tooltip direction="top" :text="$strings.LabelClosePlayer">
|
||||
<span class="material-icons sm:px-2 py-1 md:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<player-ui
|
||||
ref="audioPlayer"
|
||||
:chapters="chapters"
|
||||
:paused="!isPlaying"
|
||||
:loading="playerLoading"
|
||||
:bookmarks="bookmarks"
|
||||
:sleep-timer-set="sleepTimerSet"
|
||||
:sleep-timer-remaining="sleepTimerRemaining"
|
||||
:is-podcast="isPodcast"
|
||||
@playPause="playPause"
|
||||
@jumpForward="jumpForward"
|
||||
@jumpBackward="jumpBackward"
|
||||
@setVolume="setVolume"
|
||||
@setPlaybackRate="setPlaybackRate"
|
||||
@seek="seek"
|
||||
@close="closePlayer"
|
||||
@showBookmarks="showBookmarks"
|
||||
@showSleepTimer="showSleepTimerModal = true"
|
||||
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
||||
/>
|
||||
|
||||
<audio-player ref="audioPlayer" :chapters="chapters" :paused="!isPlaying" :loading="playerLoading" :bookmarks="bookmarks" @playPause="playPause" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setVolume="setVolume" @setPlaybackRate="setPlaybackRate" @seek="seek" @close="closePlayer" @showBookmarks="showBookmarks" />
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
||||
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @select="selectBookmark" />
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||
|
||||
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" :library-item-id="libraryItemId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -41,76 +71,184 @@ export default {
|
||||
totalDuration: 0,
|
||||
showBookmarksModal: false,
|
||||
bookmarkCurrentTime: 0,
|
||||
bookmarkAudiobookId: null,
|
||||
playerLoading: false,
|
||||
isPlaying: false,
|
||||
currentTime: 0
|
||||
currentTime: 0,
|
||||
showSleepTimerModal: false,
|
||||
showPlayerQueueItemsModal: false,
|
||||
sleepTimerSet: false,
|
||||
sleepTimerTime: 0,
|
||||
sleepTimerRemaining: 0,
|
||||
sleepTimer: null,
|
||||
displayTitle: null,
|
||||
currentPlaybackRate: 1,
|
||||
syncFailedToast: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||
isSquareCover() {
|
||||
return this.coverAspectRatio === 1
|
||||
},
|
||||
isMobile() {
|
||||
return this.$store.state.globals.isMobile
|
||||
},
|
||||
bookCoverWidth() {
|
||||
return 88
|
||||
},
|
||||
bookCoverPosTop() {
|
||||
if (this.bookCoverAspectRatio === 1) return -10
|
||||
return -64
|
||||
if (this.isMobile) return 64 / this.coverAspectRatio
|
||||
return 77 / this.coverAspectRatio
|
||||
},
|
||||
cover() {
|
||||
if (this.streamAudiobook && this.streamAudiobook.cover) return this.streamAudiobook.cover
|
||||
if (this.media.coverPath) return this.media.coverPath
|
||||
return 'Logo.png'
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
userAudiobook() {
|
||||
if (!this.audiobookId) return
|
||||
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
|
||||
userMediaProgress() {
|
||||
if (!this.libraryItemId) return
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
userAudiobookCurrentTime() {
|
||||
return this.userAudiobook ? this.userAudiobook.currentTime || 0 : 0
|
||||
userItemCurrentTime() {
|
||||
return this.userMediaProgress ? this.userMediaProgress.currentTime || 0 : 0
|
||||
},
|
||||
bookmarks() {
|
||||
if (!this.userAudiobook) return []
|
||||
return (this.userAudiobook.bookmarks || []).map((bm) => ({ ...bm })).sort((a, b) => a.time - b.time)
|
||||
if (!this.libraryItemId) return []
|
||||
return this.$store.getters['user/getUserBookmarksForItem'](this.libraryItemId)
|
||||
},
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
audiobookId() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.id : null
|
||||
streamEpisode() {
|
||||
if (!this.$store.state.streamEpisodeId) return null
|
||||
const episodes = this.streamLibraryItem.media.episodes || []
|
||||
return episodes.find((ep) => ep.id === this.$store.state.streamEpisodeId)
|
||||
},
|
||||
book() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
|
||||
libraryItemId() {
|
||||
return this.streamLibraryItem?.id || null
|
||||
},
|
||||
media() {
|
||||
return this.streamLibraryItem?.media || {}
|
||||
},
|
||||
isPodcast() {
|
||||
return this.streamLibraryItem?.mediaType === 'podcast'
|
||||
},
|
||||
isMusic() {
|
||||
return this.streamLibraryItem?.mediaType === 'music'
|
||||
},
|
||||
isExplicit() {
|
||||
return this.mediaMetadata.explicit || false
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
chapters() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.chapters || [] : []
|
||||
if (this.streamEpisode) return this.streamEpisode.chapters || []
|
||||
return this.media.chapters || []
|
||||
},
|
||||
title() {
|
||||
return this.book.title || 'No Title'
|
||||
if (this.playerHandler.displayTitle) return this.playerHandler.displayTitle
|
||||
return this.mediaMetadata.title || 'No Title'
|
||||
},
|
||||
author() {
|
||||
return this.book.author || 'Unknown'
|
||||
},
|
||||
authorFL() {
|
||||
return this.book.authorFL
|
||||
},
|
||||
authorsList() {
|
||||
return this.authorFL ? this.authorFL.split(', ') : []
|
||||
authors() {
|
||||
return this.mediaMetadata.authors || []
|
||||
},
|
||||
libraryId() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.libraryId : null
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
|
||||
},
|
||||
totalDurationPretty() {
|
||||
return this.$secondsToTimestamp(this.totalDuration)
|
||||
// Adjusted by playback rate
|
||||
return this.$secondsToTimestamp(this.totalDuration / this.currentPlaybackRate)
|
||||
},
|
||||
podcastAuthor() {
|
||||
if (!this.isPodcast) return null
|
||||
return this.mediaMetadata.author || 'Unknown'
|
||||
},
|
||||
musicArtists() {
|
||||
if (!this.isMusic) return null
|
||||
return this.mediaMetadata.artists.join(', ')
|
||||
},
|
||||
playerQueueItems() {
|
||||
return this.$store.state.playerQueueItems || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mediaFinished(libraryItemId, episodeId) {
|
||||
// Play next item in queue
|
||||
if (!this.playerQueueItems.length || !this.$store.state.playerQueueAutoPlay) {
|
||||
// TODO: Set media finished flag so play button will play next queue item
|
||||
return
|
||||
}
|
||||
var currentQueueIndex = this.playerQueueItems.findIndex((i) => {
|
||||
if (episodeId) return i.libraryItemId === libraryItemId && i.episodeId === episodeId
|
||||
return i.libraryItemId === libraryItemId
|
||||
})
|
||||
if (currentQueueIndex < 0) {
|
||||
console.error('Media finished not found in queue - using first in queue', this.playerQueueItems)
|
||||
currentQueueIndex = -1
|
||||
}
|
||||
if (currentQueueIndex === this.playerQueueItems.length - 1) {
|
||||
console.log('Finished last item in queue')
|
||||
return
|
||||
}
|
||||
const nextItemInQueue = this.playerQueueItems[currentQueueIndex + 1]
|
||||
if (nextItemInQueue) {
|
||||
this.playLibraryItem({
|
||||
libraryItemId: nextItemInQueue.libraryItemId,
|
||||
episodeId: nextItemInQueue.episodeId || null,
|
||||
queueItems: this.playerQueueItems
|
||||
})
|
||||
}
|
||||
},
|
||||
setPlaying(isPlaying) {
|
||||
this.isPlaying = isPlaying
|
||||
this.$store.commit('setIsPlaying', isPlaying)
|
||||
this.updateMediaSessionPlaybackState()
|
||||
},
|
||||
setSleepTimer(seconds) {
|
||||
this.sleepTimerSet = true
|
||||
this.sleepTimerTime = seconds
|
||||
this.sleepTimerRemaining = seconds
|
||||
this.runSleepTimer()
|
||||
this.showSleepTimerModal = false
|
||||
},
|
||||
runSleepTimer() {
|
||||
var lastTick = Date.now()
|
||||
clearInterval(this.sleepTimer)
|
||||
this.sleepTimer = setInterval(() => {
|
||||
var elapsed = Date.now() - lastTick
|
||||
lastTick = Date.now()
|
||||
this.sleepTimerRemaining -= elapsed / 1000
|
||||
|
||||
if (this.sleepTimerRemaining <= 0) {
|
||||
this.clearSleepTimer()
|
||||
this.playerHandler.pause()
|
||||
this.$toast.info('Sleep Timer Done.. zZzzZz')
|
||||
}
|
||||
}, 1000)
|
||||
},
|
||||
cancelSleepTimer() {
|
||||
this.showSleepTimerModal = false
|
||||
this.clearSleepTimer()
|
||||
},
|
||||
clearSleepTimer() {
|
||||
clearInterval(this.sleepTimer)
|
||||
this.sleepTimerRemaining = 0
|
||||
this.sleepTimer = null
|
||||
this.sleepTimerSet = false
|
||||
},
|
||||
incrementSleepTimer(amount) {
|
||||
if (!this.sleepTimerSet) return
|
||||
this.sleepTimerRemaining += amount
|
||||
},
|
||||
decrementSleepTimer(amount) {
|
||||
if (this.sleepTimerRemaining < amount) {
|
||||
this.sleepTimerRemaining = 3
|
||||
return
|
||||
}
|
||||
this.sleepTimerRemaining = Math.max(0, this.sleepTimerRemaining - amount)
|
||||
},
|
||||
playPause() {
|
||||
this.playerHandler.playPause()
|
||||
},
|
||||
@@ -124,11 +262,16 @@ export default {
|
||||
this.playerHandler.setVolume(volume)
|
||||
},
|
||||
setPlaybackRate(playbackRate) {
|
||||
this.currentPlaybackRate = playbackRate
|
||||
this.playerHandler.setPlaybackRate(playbackRate)
|
||||
},
|
||||
seek(time) {
|
||||
this.playerHandler.seek(time)
|
||||
},
|
||||
playbackTimeUpdate(time) {
|
||||
// When updating progress from another session
|
||||
this.playerHandler.seek(time, false)
|
||||
},
|
||||
setCurrentTime(time) {
|
||||
this.currentTime = time
|
||||
if (this.$refs.audioPlayer) {
|
||||
@@ -146,9 +289,8 @@ export default {
|
||||
this.$refs.audioPlayer.setBufferTime(buffertime)
|
||||
}
|
||||
},
|
||||
showBookmarks(currentTime) {
|
||||
this.bookmarkAudiobookId = this.audiobookId
|
||||
this.bookmarkCurrentTime = currentTime
|
||||
showBookmarks() {
|
||||
this.bookmarkCurrentTime = this.currentTime
|
||||
this.showBookmarksModal = true
|
||||
},
|
||||
selectBookmark(bookmark) {
|
||||
@@ -157,31 +299,115 @@ export default {
|
||||
},
|
||||
closePlayer() {
|
||||
this.playerHandler.closePlayer()
|
||||
this.$store.commit('setStreamAudiobook', null)
|
||||
this.$store.commit('setMediaPlaying', null)
|
||||
},
|
||||
streamProgress(data) {
|
||||
if (!data.numSegments) return
|
||||
var chunks = data.chunks
|
||||
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
||||
} else {
|
||||
console.error('No Audio Ref')
|
||||
mediaSessionPlay() {
|
||||
console.log('Media session play')
|
||||
this.playerHandler.play()
|
||||
},
|
||||
mediaSessionPause() {
|
||||
console.log('Media session pause')
|
||||
this.playerHandler.pause()
|
||||
},
|
||||
mediaSessionStop() {
|
||||
console.log('Media session stop')
|
||||
this.playerHandler.pause()
|
||||
},
|
||||
mediaSessionSeekBackward() {
|
||||
console.log('Media session seek backward')
|
||||
this.playerHandler.jumpBackward()
|
||||
},
|
||||
mediaSessionSeekForward() {
|
||||
console.log('Media session seek forward')
|
||||
this.playerHandler.jumpForward()
|
||||
},
|
||||
mediaSessionSeekTo(e) {
|
||||
console.log('Media session seek to', e)
|
||||
if (e.seekTime !== null && !isNaN(e.seekTime)) {
|
||||
this.playerHandler.seek(e.seekTime)
|
||||
}
|
||||
},
|
||||
streamOpen(stream) {
|
||||
this.$store.commit('setStreamAudiobook', stream.audiobook)
|
||||
this.playerHandler.prepareStream(stream)
|
||||
mediaSessionPreviousTrack() {
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.prevChapter()
|
||||
}
|
||||
},
|
||||
mediaSessionNextTrack() {
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.nextChapter()
|
||||
}
|
||||
},
|
||||
updateMediaSessionPlaybackState() {
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'
|
||||
}
|
||||
},
|
||||
setMediaSession() {
|
||||
if (!this.streamLibraryItem) {
|
||||
console.error('setMediaSession: No library item set')
|
||||
return
|
||||
}
|
||||
|
||||
if ('mediaSession' in navigator) {
|
||||
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png')
|
||||
const artwork = [
|
||||
{
|
||||
src: coverImageSrc
|
||||
}
|
||||
]
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: this.title,
|
||||
artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown',
|
||||
album: this.mediaMetadata.seriesName || '',
|
||||
artwork
|
||||
})
|
||||
console.log('Set media session metadata', navigator.mediaSession.metadata)
|
||||
|
||||
navigator.mediaSession.setActionHandler('play', this.mediaSessionPlay)
|
||||
navigator.mediaSession.setActionHandler('pause', this.mediaSessionPause)
|
||||
navigator.mediaSession.setActionHandler('stop', this.mediaSessionStop)
|
||||
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
|
||||
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
|
||||
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
|
||||
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
|
||||
navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
|
||||
} else {
|
||||
console.warn('Media session not available')
|
||||
}
|
||||
},
|
||||
streamProgress(data) {
|
||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === data.stream) {
|
||||
if (!data.numSegments) return
|
||||
var chunks = data.chunks
|
||||
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
||||
} else {
|
||||
console.error('No Audio Ref')
|
||||
}
|
||||
}
|
||||
},
|
||||
sessionOpen(session) {
|
||||
// For opening session on init (temporarily unused)
|
||||
this.$store.commit('setMediaPlaying', {
|
||||
libraryItem: session.libraryItem,
|
||||
episodeId: session.episodeId
|
||||
})
|
||||
this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)
|
||||
},
|
||||
streamOpen(session) {
|
||||
console.log(`[StreamContainer] Stream session open`, session)
|
||||
},
|
||||
streamClosed(streamId) {
|
||||
// Stream was closed from the server
|
||||
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
|
||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
||||
console.warn('[StreamContainer] Closing stream due to request from server')
|
||||
this.playerHandler.closePlayer()
|
||||
}
|
||||
},
|
||||
streamReady() {
|
||||
console.log(`[STREAM-CONTAINER] Stream Ready`)
|
||||
console.log(`[StreamContainer] Stream Ready`)
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.setStreamReady()
|
||||
} else {
|
||||
@@ -190,7 +416,7 @@ export default {
|
||||
},
|
||||
streamError(streamId) {
|
||||
// Stream had critical error from the server
|
||||
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
|
||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
||||
console.warn('[StreamContainer] Closing stream due to stream error from server')
|
||||
this.playerHandler.closePlayer()
|
||||
}
|
||||
@@ -199,32 +425,72 @@ export default {
|
||||
this.playerHandler.resetStream(startTime, streamId)
|
||||
},
|
||||
castSessionActive(isActive) {
|
||||
if (isActive && this.playerHandler.isPlayingLocalAudiobook) {
|
||||
if (isActive && this.playerHandler.isPlayingLocalItem) {
|
||||
// Cast session started switch to cast player
|
||||
this.playerHandler.switchPlayer()
|
||||
} else if (!isActive && this.playerHandler.isPlayingCastedAudiobook) {
|
||||
} else if (!isActive && this.playerHandler.isPlayingCastedItem) {
|
||||
// Cast session ended switch to local player
|
||||
this.playerHandler.switchPlayer()
|
||||
}
|
||||
},
|
||||
async playAudiobook(audiobookId) {
|
||||
var audiobook = await this.$axios.$get(`/api/books/${audiobookId}`).catch((error) => {
|
||||
console.error('Failed to fetch full audiobook', error)
|
||||
async playLibraryItem(payload) {
|
||||
const libraryItemId = payload.libraryItemId
|
||||
const episodeId = payload.episodeId || null
|
||||
|
||||
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
|
||||
if (payload.startTime !== null && !isNaN(payload.startTime)) {
|
||||
this.seek(payload.startTime)
|
||||
} else {
|
||||
this.playerHandler.play()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
|
||||
console.error('Failed to fetch full item', error)
|
||||
return null
|
||||
})
|
||||
if (!audiobook) return
|
||||
this.$store.commit('setStreamAudiobook', audiobook)
|
||||
if (!libraryItem) return
|
||||
|
||||
this.playerHandler.load(audiobook, true, this.userAudiobookCurrentTime)
|
||||
this.$store.commit('setMediaPlaying', {
|
||||
libraryItem,
|
||||
episodeId,
|
||||
queueItems: payload.queueItems || []
|
||||
})
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
||||
})
|
||||
|
||||
this.playerHandler.load(libraryItem, episodeId, true, this.currentPlaybackRate, payload.startTime)
|
||||
},
|
||||
pauseItem() {
|
||||
this.playerHandler.pause()
|
||||
},
|
||||
showFailedProgressSyncs() {
|
||||
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
||||
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
|
||||
},
|
||||
sessionClosedEvent(sessionId) {
|
||||
if (this.playerHandler.currentSessionId === sessionId) {
|
||||
console.log('sessionClosedEvent closing current session', sessionId)
|
||||
this.playerHandler.resetPlayer() // Closes player without reporting to server
|
||||
this.$store.commit('setMediaPlaying', null)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
||||
this.$eventBus.$on('play-audiobook', this.playAudiobook)
|
||||
this.$eventBus.$on('playback-seek', this.seek)
|
||||
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
||||
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
||||
this.$eventBus.$off('play-audiobook', this.playAudiobook)
|
||||
this.$eventBus.$off('playback-seek', this.seek)
|
||||
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -233,4 +499,4 @@ export default {
|
||||
#streamContainer {
|
||||
box-shadow: 0px -6px 8px #1111113f;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center h-full px-1 overflow-hidden">
|
||||
<covers-book-cover :audiobook="audiobook" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="flex-grow px-2 audiobookSearchCardContent">
|
||||
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
||||
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
||||
|
||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
|
||||
|
||||
<p v-if="matchKey !== 'authorFL'" class="text-xs text-gray-200 truncate">by {{ authorFL }}</p>
|
||||
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
||||
|
||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
search: String,
|
||||
matchKey: String,
|
||||
matchText: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
coverWidth() {
|
||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
||||
return 50
|
||||
},
|
||||
book() {
|
||||
return this.audiobook ? this.audiobook.book || {} : {}
|
||||
},
|
||||
title() {
|
||||
return this.book ? this.book.title : 'No Title'
|
||||
},
|
||||
subtitle() {
|
||||
return this.book ? this.book.subtitle : ''
|
||||
},
|
||||
authorFL() {
|
||||
return this.book ? this.book.authorFL : 'Unknown'
|
||||
},
|
||||
matchHtml() {
|
||||
if (!this.matchText || !this.search) return ''
|
||||
if (this.matchKey === 'subtitle') return ''
|
||||
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
|
||||
if (matchSplit.length < 2) return ''
|
||||
|
||||
var html = ''
|
||||
var totalLenSoFar = 0
|
||||
for (let i = 0; i < matchSplit.length - 1; i++) {
|
||||
var indexOf = matchSplit[i].length
|
||||
var firstPart = this.matchText.substr(totalLenSoFar, indexOf)
|
||||
var actualWasThere = this.matchText.substr(totalLenSoFar + indexOf, this.search.length)
|
||||
totalLenSoFar += indexOf + this.search.length
|
||||
|
||||
html += `${firstPart}<strong class="text-warning">${actualWasThere}</strong>`
|
||||
}
|
||||
var lastPart = this.matchText.substr(totalLenSoFar)
|
||||
html += lastPart
|
||||
|
||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||
if (this.matchKey === 'authorFL') return `by ${html}`
|
||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
||||
return `${html}`
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.audiobookSearchCardContent {
|
||||
width: calc(100% - 80px);
|
||||
height: 75px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,28 +1,38 @@
|
||||
<template>
|
||||
<div>
|
||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-lg relative">
|
||||
<div class="w-full h-full overflow-hidden max-w-full max-h-full relative">
|
||||
<svg width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
|
||||
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
|
||||
<path
|
||||
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
|
||||
</svg>
|
||||
<nuxt-link :to="`/author/${author.id}?library=${currentLibraryId}`">
|
||||
<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 -->
|
||||
<covers-author-image :author="author" />
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full py-2 bg-black bg-opacity-25 px-2">
|
||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ name }}</p>
|
||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.85 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
<!-- Search icon btn -->
|
||||
<div v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="searchAuthor">
|
||||
<ui-tooltip :text="$strings.ButtonQuickMatch" direction="bottom">
|
||||
<span class="material-icons text-lg">search</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)">
|
||||
<ui-tooltip :text="$strings.LabelEdit" direction="bottom">
|
||||
<span class="material-icons text-lg">edit</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Loading spinner -->
|
||||
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<widgets-loading-spinner size="" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="nameBelow" class="w-full py-1 px-2">
|
||||
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -34,11 +44,16 @@ export default {
|
||||
},
|
||||
width: Number,
|
||||
height: Number,
|
||||
sizeMultiplier: Number
|
||||
sizeMultiplier: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
nameBelow: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
placeholder: '/Logo.png'
|
||||
searching: false,
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -48,30 +63,69 @@ export default {
|
||||
_author() {
|
||||
return this.author || {}
|
||||
},
|
||||
authorId() {
|
||||
return this._author.id
|
||||
},
|
||||
name() {
|
||||
return this._author.name || ''
|
||||
},
|
||||
image() {
|
||||
return this._author.image || null
|
||||
},
|
||||
description() {
|
||||
return this._author.description
|
||||
},
|
||||
lastUpdate() {
|
||||
return this._author.lastUpdate
|
||||
asin() {
|
||||
return this._author.asin || ''
|
||||
},
|
||||
numBooks() {
|
||||
return this._author.numBooks || 0
|
||||
},
|
||||
imgSrc() {
|
||||
if (!this.image) return this.placeholder
|
||||
var encodedImg = this.image.replace(/%/g, '%25').replace(/#/g, '%23')
|
||||
|
||||
var url = new URL(encodedImg, document.baseURI)
|
||||
return url.href + `?token=${this.userToken}&ts=${this.lastUpdate}`
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
methods: {
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
},
|
||||
async searchAuthor() {
|
||||
this.searching = true
|
||||
const payload = {}
|
||||
if (this.asin) payload.asin = this.asin
|
||||
else payload.q = this.name
|
||||
|
||||
payload.region = 'us'
|
||||
if (this.libraryProvider.startsWith('audible.')) {
|
||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
||||
}
|
||||
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
})
|
||||
if (!response) {
|
||||
this.$toast.error(`Author ${this.name} not found`)
|
||||
} else if (response.updated) {
|
||||
if (response.author.imagePath) this.$toast.success(`Author ${response.author.name} was updated`)
|
||||
else this.$toast.success(`Author ${response.author.name} was updated (no image found)`)
|
||||
} else {
|
||||
this.$toast.info(`No updates were made for Author ${response.author.name}`)
|
||||
}
|
||||
this.searching = false
|
||||
},
|
||||
setSearching(isSearching) {
|
||||
this.searching = isSearching
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on(`searching-author-${this.authorId}`, this.setSearching)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,8 +1,10 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<img src="/icons/NoUserPhoto.png" class="w-40 h-40 max-h-40 object-contain" style="max-height: 40px; max-width: 40px" />
|
||||
<div class="overflow-hidden bg-primary rounded" style="height: 50px; width: 40px">
|
||||
<covers-author-image :author="author" />
|
||||
</div>
|
||||
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ author }}</p>
|
||||
<p class="truncate text-sm">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,12 +12,19 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
author: String
|
||||
author: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
name() {
|
||||
return this.author.name
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,42 @@
|
||||
<template>
|
||||
<div class="w-full border-b border-gray-700 pb-2">
|
||||
<div v-if="book" class="w-full border-b border-gray-700 pb-2">
|
||||
<div class="flex py-1 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer" @click="selectMatch">
|
||||
<img :src="selectedCover || '/book_placeholder.jpg'" class="h-24 object-cover" :style="{ width: 96 / bookCoverAspectRatio + 'px' }" />
|
||||
<div class="px-4 flex-grow">
|
||||
<div class="flex items-center">
|
||||
<h1>{{ book.title }}</h1>
|
||||
<div class="flex-grow" />
|
||||
<p>{{ book.publishYear }}</p>
|
||||
<div class="min-w-12 max-w-12 md:min-w-20 md:max-w-20">
|
||||
<div class="w-full bg-primary">
|
||||
<img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" />
|
||||
<div v-else class="w-12 h-12 md:w-20 md:h-20 bg-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isPodcast" class="px-2 md:px-4 flex-grow">
|
||||
<div class="flex items-center">
|
||||
<h1 class="text-sm md:text-base">{{ book.title }}</h1>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-sm md:text-base">{{ book.publishedYear }}</p>
|
||||
</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">
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-400">{{ book.author }}</p>
|
||||
<div class="w-full max-h-12 overflow-hidden">
|
||||
<p class="text-gray-500 text-xs">{{ book.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="px-4 flex-grow">
|
||||
<h1>
|
||||
<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>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ book.trackCount }} Episodes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="bookCovers.length > 1" class="flex">
|
||||
<template v-for="cover in bookCovers">
|
||||
@@ -31,6 +55,7 @@ export default {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
isPodcast: Boolean,
|
||||
bookCoverAspectRatio: Number
|
||||
},
|
||||
data() {
|
||||
@@ -47,7 +72,7 @@ export default {
|
||||
selectMatch() {
|
||||
var book = { ...this.book }
|
||||
book.cover = this.selectedCover
|
||||
this.$emit('select', this.book)
|
||||
this.$emit('select', book)
|
||||
},
|
||||
clickCover(cover) {
|
||||
this.selectedCover = cover
|
||||
@@ -57,4 +82,4 @@ export default {
|
||||
this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : this.book.cover || null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="rounded-sm h-full relative" :style="{ padding: `${paddingY}px ${paddingX}px` }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
||||
<nuxt-link :to="groupTo" class="cursor-pointer">
|
||||
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: coverHeight + 'px', width: coverWidth + 'px' }">
|
||||
<covers-collection-cover ref="groupcover" :book-items="bookItems" :width="coverWidth" :height="coverHeight" />
|
||||
|
||||
<div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||
<!-- <div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="toggleSelected">
|
||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">radio_button_unchecked</span>
|
||||
</div> -->
|
||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto bottom-0 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, coverWidth) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${1 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ collectionName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
collection: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
},
|
||||
paddingY: {
|
||||
type: Number,
|
||||
default: 24
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
width(newVal) {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.groupcover && this.$refs.groupcover.init) {
|
||||
this.$refs.groupcover.init()
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
labelFontSize() {
|
||||
if (this.coverWidth < 160) return 0.75
|
||||
return 0.875
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
_collection() {
|
||||
return this.collection || {}
|
||||
},
|
||||
groupTo() {
|
||||
return `/collection/${this._collection.id}`
|
||||
},
|
||||
coverWidth() {
|
||||
return this.width * 2
|
||||
},
|
||||
coverHeight() {
|
||||
return this.width * 1.6
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.width / 120
|
||||
},
|
||||
paddingX() {
|
||||
return 16 * this.sizeMultiplier
|
||||
},
|
||||
bookItems() {
|
||||
return this._collection.books || []
|
||||
},
|
||||
collectionName() {
|
||||
return this._collection.name || 'No Name'
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleSelected() {
|
||||
// Selected
|
||||
},
|
||||
clickEdit() {
|
||||
this.$store.commit('globals/setEditCollection', this.collection)
|
||||
},
|
||||
mouseoverCard() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleaveCard() {
|
||||
this.isHovering = false
|
||||
},
|
||||
clickCard() {
|
||||
this.$emit('click', this.collection)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
0
client/components/cards/EpisodeSearchCard.vue
Normal file
0
client/components/cards/EpisodeSearchCard.vue
Normal file
@@ -1,29 +1,18 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="rounded-sm h-full relative" :style="{ padding: `0px ${paddingX}px` }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
||||
<div class="rounded-sm h-full relative" :style="{ width: width + 'px', height: height + 'px' }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
||||
<nuxt-link :to="groupTo" class="cursor-pointer">
|
||||
<div class="w-full h-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: coverHeight + 'px', width: coverWidth + 'px' }">
|
||||
<covers-group-cover ref="groupcover" :id="seriesId" :name="groupName" :group-to="groupTo" :type="groupType" :book-items="bookItems" :width="coverWidth" :height="coverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="w-full h-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'">
|
||||
<covers-group-cover ref="groupcover" :id="groupEncode" :name="groupName" :type="groupType" :book-items="bookItems" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
|
||||
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity z-30" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||
<p class="font-book" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ groupName }}</p>
|
||||
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ groupName }}</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-2 right-2 w-7 h-7 rounded-lg bg-black bg-opacity-90 text-gray-300 box-shadow-book flex items-center justify-center border border-white border-opacity-25 pointer-events-none z-40">
|
||||
<p class="font-book text-xl">{{ bookItems.length }}</p>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full h-1 flex flex-nowrap z-40">
|
||||
<div v-for="userProgress in userProgressItems" :key="userProgress.audiobookId" class="h-full w-full" :class="userProgress.isRead ? 'bg-success' : userProgress.progress > 0 ? 'bg-yellow-400' : ''" />
|
||||
</div>
|
||||
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ bookItems.length }}</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
||||
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto bottom-0 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, coverWidth) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${1 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ groupName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -34,11 +23,8 @@ export default {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
},
|
||||
isCategorized: Boolean,
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number
|
||||
},
|
||||
data() {
|
||||
@@ -46,23 +32,7 @@ export default {
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
width(newVal) {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.groupcover) {
|
||||
this.$refs.groupcover.init()
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
seriesId() {
|
||||
return this.groupEncode
|
||||
},
|
||||
labelFontSize() {
|
||||
if (this.coverWidth < 160) return 0.75
|
||||
return 0.875
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
@@ -73,42 +43,15 @@ export default {
|
||||
return this._group.type
|
||||
},
|
||||
groupTo() {
|
||||
if (this.groupType === 'series') {
|
||||
return `/library/${this.currentLibraryId}/series/${this.groupEncode}`
|
||||
} else if (this.groupType === 'collection') {
|
||||
return `/collection/${this._group.id}`
|
||||
} else {
|
||||
return `/library/${this.currentLibraryId}/bookshelf?filter=tags.${this.groupEncode}`
|
||||
}
|
||||
},
|
||||
squareAspectRatio() {
|
||||
return this.bookCoverAspectRatio === 1
|
||||
},
|
||||
coverWidth() {
|
||||
return this.width * 2
|
||||
},
|
||||
coverHeight() {
|
||||
return this.width * this.bookCoverAspectRatio
|
||||
return `/library/${this.currentLibraryId}/bookshelf?filter=${this.filter}`
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.squareAspectRatio ? 192 : 120
|
||||
return this.width / baseSize
|
||||
},
|
||||
paddingX() {
|
||||
return 16 * this.sizeMultiplier
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||
return this.width / 240
|
||||
},
|
||||
bookItems() {
|
||||
return this._group.books || []
|
||||
},
|
||||
userAudiobooks() {
|
||||
return Object.values(this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {})
|
||||
},
|
||||
userProgressItems() {
|
||||
return this.bookItems.map((item) => {
|
||||
var userAudiobook = this.userAudiobooks.find((ab) => ab.audiobookId === item.id)
|
||||
return userAudiobook || {}
|
||||
})
|
||||
},
|
||||
groupName() {
|
||||
return this._group.name || 'No Name'
|
||||
},
|
||||
@@ -119,21 +62,16 @@ export default {
|
||||
return `${this.groupType}.${this.$encode(this.groupName)}`
|
||||
},
|
||||
hasValidCovers() {
|
||||
var validCovers = this.bookItems.map((bookItem) => bookItem.book.cover)
|
||||
var validCovers = this.bookItems.map((bookItem) => bookItem.media.coverPath)
|
||||
return !!validCovers.length
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mouseoverCard() {
|
||||
this.isHovering = true
|
||||
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(true)
|
||||
},
|
||||
mouseleaveCard() {
|
||||
this.isHovering = false
|
||||
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(false)
|
||||
},
|
||||
clickCard() {
|
||||
this.$emit('click', this.group)
|
||||
|
||||
93
client/components/cards/ItemSearchCard.vue
Normal file
93
client/components/cards/ItemSearchCard.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="flex items-center h-full px-1 overflow-hidden">
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="flex-grow px-2 audiobookSearchCardContent">
|
||||
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
||||
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
||||
|
||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300" v-html="matchHtml" />
|
||||
|
||||
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
|
||||
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
||||
|
||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
search: String,
|
||||
matchKey: String,
|
||||
matchText: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
coverWidth() {
|
||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
||||
return 50
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType == 'podcast'
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
title() {
|
||||
return this.mediaMetadata.title || 'No Title'
|
||||
},
|
||||
subtitle() {
|
||||
return this.mediaMetadata.subtitle || ''
|
||||
},
|
||||
authorName() {
|
||||
if (this.isPodcast) return this.mediaMetadata.author || 'Unknown'
|
||||
return this.mediaMetadata.authorName || 'Unknown'
|
||||
},
|
||||
matchHtml() {
|
||||
if (!this.matchText || !this.search) return ''
|
||||
|
||||
// This used to highlight the part of the search found
|
||||
// but with removing commas periods etc this is no longer plausible
|
||||
const html = this.matchText
|
||||
|
||||
if (this.matchKey === 'episode') return `<p class="truncate">${this.$strings.LabelEpisode}: ${html}</p>`
|
||||
if (this.matchKey === 'tags') return `<p class="truncate">${this.$strings.LabelTags}: ${html}</p>`
|
||||
if (this.matchKey === 'subtitle') return `<p class="truncate">${html}</p>`
|
||||
if (this.matchKey === 'authors') return `by ${html}`
|
||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||
if (this.matchKey === 'series') return `<p class="truncate">${this.$strings.LabelSeries}: ${html}</p>`
|
||||
if (this.matchKey === 'narrators') return `<p class="truncate">${this.$strings.LabelNarrator}: ${html}</p>`
|
||||
return `${html}`
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.audiobookSearchCardContent {
|
||||
width: calc(100% - 80px);
|
||||
height: 75px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
94
client/components/cards/ItemTaskRunningCard.vue
Normal file
94
client/components/cards/ItemTaskRunningCard.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<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>
|
||||
|
||||
<p class="truncate text-xs text-gray-300">{{ description }}</p>
|
||||
|
||||
<p v-if="isFailed && failedMessage" class="text-xs truncate text-red-500">{{ failedMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
task: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
title() {
|
||||
return this.task.title || 'No Title'
|
||||
},
|
||||
description() {
|
||||
return this.task.description || ''
|
||||
},
|
||||
details() {
|
||||
return this.task.details || 'Unknown'
|
||||
},
|
||||
isFinished() {
|
||||
return !!this.task.isFinished
|
||||
},
|
||||
isFailed() {
|
||||
return !!this.task.isFailed
|
||||
},
|
||||
isSuccess() {
|
||||
return this.isFinished && !this.isFailed
|
||||
},
|
||||
failedMessage() {
|
||||
return this.task.error || ''
|
||||
},
|
||||
action() {
|
||||
return this.task.action || ''
|
||||
},
|
||||
actionIcon() {
|
||||
if (this.isFailed) {
|
||||
return 'error'
|
||||
} else if (this.isSuccess) {
|
||||
return 'done'
|
||||
}
|
||||
switch (this.action) {
|
||||
case 'download-podcast-episode':
|
||||
return 'cloud_download'
|
||||
case 'encode-m4b':
|
||||
return 'sync'
|
||||
default:
|
||||
return 'settings'
|
||||
}
|
||||
},
|
||||
taskIconStatus() {
|
||||
if (this.isFinished && this.isFailed) {
|
||||
return 'text-red-500'
|
||||
}
|
||||
if (this.isFinished && !this.isFailed) {
|
||||
return 'text-green-500'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.taskRunningCardContent {
|
||||
width: calc(100% - 84px);
|
||||
height: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="relative w-full py-4 px-6 border border-white border-opacity-10 shadow-lg rounded-md my-6">
|
||||
<div class="absolute -top-3 -left-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full">
|
||||
<p class="text-base text-white text-opacity-80 font-mono">#{{ book.index }}</p>
|
||||
<p class="text-base text-white text-opacity-80 font-mono">#{{ item.index }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!processing && !uploadFailed && !uploadSuccess" class="absolute -top-3 -right-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-error cursor-pointer" @click="$emit('remove')">
|
||||
@@ -15,37 +15,41 @@
|
||||
|
||||
<div class="flex my-2 -mx-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="bookData.title" :disabled="processing" label="Title" @input="titleUpdated" />
|
||||
<ui-text-input-with-label v-model="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="bookData.author" :disabled="processing" label="Author" />
|
||||
<ui-text-input-with-label v-if="!isPodcast" v-model="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex my-2 -mx-2">
|
||||
<div v-if="!isPodcast" class="flex my-2 -mx-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="bookData.series" :disabled="processing" label="Series" note="(optional)" />
|
||||
<ui-text-input-with-label v-model="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold">Directory <em class="font-normal text-xs pl-2">(auto)</em></p>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tables-uploaded-files-table :files="book.bookFiles" title="Book Files" class="mt-8" />
|
||||
<tables-uploaded-files-table v-if="book.otherFiles.length" title="Other Files" :files="book.otherFiles" />
|
||||
<tables-uploaded-files-table v-if="book.ignoredFiles.length" title="Ignored Files" :files="book.ignoredFiles" />
|
||||
<tables-uploaded-files-table :files="item.itemFiles" :title="$strings.HeaderItemFiles" class="mt-8" />
|
||||
<tables-uploaded-files-table v-if="item.otherFiles.length" :title="$strings.HeaderOtherFiles" :files="item.otherFiles" />
|
||||
<tables-uploaded-files-table v-if="item.ignoredFiles.length" :title="$strings.HeaderIgnoredFiles" :files="item.ignoredFiles" />
|
||||
</template>
|
||||
<widgets-alert v-if="uploadSuccess" type="success">
|
||||
<p class="text-base">Successfully Uploaded!</p>
|
||||
<p class="text-base">{{ $strings.MessageUploaderItemSuccess }}</p>
|
||||
</widgets-alert>
|
||||
<widgets-alert v-if="uploadFailed" type="error">
|
||||
<p class="text-base">Failed to upload</p>
|
||||
<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="Uploading..." />
|
||||
<ui-loading-indicator :text="$strings.MessageUploading" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -55,15 +59,16 @@ import Path from 'path'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
book: {
|
||||
item: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
mediaType: String,
|
||||
processing: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
bookData: {
|
||||
itemData: {
|
||||
title: '',
|
||||
author: '',
|
||||
series: ''
|
||||
@@ -75,14 +80,19 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isPodcast() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
directory() {
|
||||
if (!this.bookData.title) return ''
|
||||
if (this.bookData.series && this.bookData.author) {
|
||||
return Path.join(this.bookData.author, this.bookData.series, this.bookData.title)
|
||||
} else if (this.bookData.author) {
|
||||
return Path.join(this.bookData.author, this.bookData.title)
|
||||
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.bookData.title
|
||||
return this.itemData.title
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -96,24 +106,25 @@ export default {
|
||||
this.error = ''
|
||||
},
|
||||
getData() {
|
||||
if (!this.bookData.title) {
|
||||
if (!this.itemData.title) {
|
||||
this.error = 'Must have a title'
|
||||
return null
|
||||
}
|
||||
this.error = ''
|
||||
var files = this.book.bookFiles.concat(this.book.otherFiles)
|
||||
var files = this.item.itemFiles.concat(this.item.otherFiles)
|
||||
return {
|
||||
index: this.book.index,
|
||||
...this.bookData,
|
||||
index: this.item.index,
|
||||
directory: this.directory,
|
||||
...this.itemData,
|
||||
files
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.book) {
|
||||
this.bookData.title = this.book.title
|
||||
this.bookData.author = this.book.author
|
||||
this.bookData.series = this.book.series
|
||||
if (this.item) {
|
||||
this.itemData.title = this.item.title
|
||||
this.itemData.author = this.item.author
|
||||
this.itemData.series = this.item.series
|
||||
}
|
||||
}
|
||||
}
|
||||
114
client/components/cards/LazyAlbumCard.vue
Normal file
114
client/components/cards/LazyAlbumCard.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div ref="card" :id="`album-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
<covers-preview-cover ref="cover" :src="coverSrc" :width="width" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ artist || ' ' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
index: Number,
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number,
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
albumMount: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
album: null,
|
||||
isSelectionMode: false,
|
||||
selected: false,
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
coverSrc() {
|
||||
const config = this.$config || this.$nuxt.$config
|
||||
if (!this.album || !this.album.libraryItemId) return `${config.routerBasePath}/book_placeholder.jpg`
|
||||
return this.store.getters['globals/getLibraryItemCoverSrcById'](this.album.libraryItemId)
|
||||
},
|
||||
labelFontSize() {
|
||||
if (this.width < 160) return 0.75
|
||||
return 0.875
|
||||
},
|
||||
sizeMultiplier() {
|
||||
const baseSize = this.bookCoverAspectRatio === 1 ? 192 : 120
|
||||
return this.width / baseSize
|
||||
},
|
||||
title() {
|
||||
return this.album ? this.album.title : ''
|
||||
},
|
||||
artist() {
|
||||
return this.album ? this.album.artist : ''
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.store.state.libraries.currentLibraryId
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
const constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView == constants.BookshelfView.DETAIL
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setEntity(album) {
|
||||
this.album = album
|
||||
},
|
||||
setSelectionMode(val) {
|
||||
this.isSelectionMode = val
|
||||
},
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
},
|
||||
clickCard() {
|
||||
if (!this.album) return
|
||||
// const router = this.$router || this.$nuxt.$router
|
||||
// router.push(`/album/${this.$encode(this.title)}`)
|
||||
},
|
||||
clickEdit() {
|
||||
this.$emit('edit', this.album)
|
||||
},
|
||||
destroy() {
|
||||
// destroy the vue listeners, etc
|
||||
this.$destroy()
|
||||
|
||||
// remove the element from the DOM
|
||||
if (this.$el && this.$el.parentNode) {
|
||||
this.$el.parentNode.removeChild(this.$el)
|
||||
} else if (this.$el && this.$el.remove) {
|
||||
this.$el.remove()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.albumMount) {
|
||||
this.setEntity(this.albumMount)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,21 +4,22 @@
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||
<!-- <div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="toggleSelected">
|
||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">radio_button_unchecked</span>
|
||||
</div> -->
|
||||
<div v-show="isHovering && userCanUpdate" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div v-if="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
|
||||
</div> -->
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
|
||||
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
|
||||
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -28,7 +29,16 @@ export default {
|
||||
index: Number,
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number
|
||||
bookCoverAspectRatio: Number,
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
collectionMount: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
isTag: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -58,6 +68,16 @@ export default {
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.store.state.libraries.currentLibraryId
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
const constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView == constants.BookshelfView.DETAIL
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
rssFeed() {
|
||||
return this.collection ? this.collection.rssFeed : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -93,6 +113,10 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
if (this.collectionMount) {
|
||||
this.setEntity(this.collectionMount)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
115
client/components/cards/LazyPlaylistCard.vue
Normal file
115
client/components/cards/LazyPlaylistCard.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div ref="card" :id="`playlist-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
<covers-playlist-cover ref="cover" :items="items" :width="width" :height="height" />
|
||||
</div>
|
||||
<div v-show="isHovering && userCanUpdate" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
index: Number,
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number,
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
playlistMount: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
playlist: null,
|
||||
isSelectionMode: false,
|
||||
selected: false,
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
labelFontSize() {
|
||||
if (this.width < 160) return 0.75
|
||||
return 0.875
|
||||
},
|
||||
sizeMultiplier() {
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6)
|
||||
return this.width / 120
|
||||
},
|
||||
title() {
|
||||
return this.playlist ? this.playlist.name : ''
|
||||
},
|
||||
items() {
|
||||
return this.playlist ? this.playlist.items || [] : []
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.store.state.libraries.currentLibraryId
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
const constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView == constants.BookshelfView.DETAIL
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setEntity(playlist) {
|
||||
this.playlist = playlist
|
||||
},
|
||||
setSelectionMode(val) {
|
||||
this.isSelectionMode = val
|
||||
},
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
},
|
||||
clickCard() {
|
||||
if (!this.playlist) return
|
||||
var router = this.$router || this.$nuxt.$router
|
||||
router.push(`/playlist/${this.playlist.id}`)
|
||||
},
|
||||
clickEdit() {
|
||||
this.$emit('edit', this.playlist)
|
||||
},
|
||||
destroy() {
|
||||
// destroy the vue listeners, etc
|
||||
this.$destroy()
|
||||
|
||||
// remove the element from the DOM
|
||||
if (this.$el && this.$el.parentNode) {
|
||||
this.$el.parentNode.removeChild(this.$el)
|
||||
} else if (this.$el && this.$el.remove) {
|
||||
this.$el.remove()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.playlistMount) {
|
||||
this.setEntity(this.playlistMount)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,21 +2,28 @@
|
||||
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
|
||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" :group-to="seriesBooksRoute" />
|
||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
|
||||
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
|
||||
|
||||
<div v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||
|
||||
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
||||
</div>
|
||||
<!-- <div v-if="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
|
||||
</div> -->
|
||||
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
|
||||
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
|
||||
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -27,11 +34,17 @@ export default {
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number,
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
isCategorized: Boolean,
|
||||
seriesMount: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
sortingIgnorePrefix: Boolean,
|
||||
orderBy: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -43,6 +56,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dateFormat() {
|
||||
return this.store.state.serverSettings.dateFormat
|
||||
},
|
||||
labelFontSize() {
|
||||
if (this.width < 160) return 0.75
|
||||
return 0.875
|
||||
@@ -51,12 +67,65 @@ export default {
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||
return this.width / 240
|
||||
},
|
||||
seriesId() {
|
||||
return this.series ? this.series.id : ''
|
||||
},
|
||||
title() {
|
||||
return this.series ? this.series.name : ''
|
||||
},
|
||||
nameIgnorePrefix() {
|
||||
return this.series ? this.series.nameIgnorePrefix : ''
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title
|
||||
return this.title
|
||||
},
|
||||
displaySortLine() {
|
||||
switch (this.orderBy) {
|
||||
case 'addedAt':
|
||||
return `${this.$strings.LabelAdded} ${this.$formatDate(this.addedAt, this.dateFormat)}`
|
||||
case 'totalDuration':
|
||||
return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}`
|
||||
case 'lastBookUpdated':
|
||||
const lastUpdated = Math.max(...this.books.map((x) => x.updatedAt), 0)
|
||||
return `${this.$strings.LabelLastBookUpdated} ${this.$formatDate(lastUpdated, this.dateFormat)}`
|
||||
case 'lastBookAdded':
|
||||
const lastBookAdded = Math.max(...this.books.map((x) => x.addedAt), 0)
|
||||
return `${this.$strings.LabelLastBookAdded} ${this.$formatDate(lastBookAdded, this.dateFormat)}`
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
books() {
|
||||
return this.series ? this.series.books || [] : []
|
||||
},
|
||||
addedAt() {
|
||||
return this.series ? this.series.addedAt : 0
|
||||
},
|
||||
totalDuration() {
|
||||
return this.series ? this.series.totalDuration : 0
|
||||
},
|
||||
seriesBookProgress() {
|
||||
return this.books
|
||||
.map((libraryItem) => {
|
||||
return this.store.getters['user/getUserMediaProgress'](libraryItem.id)
|
||||
})
|
||||
.filter((p) => !!p)
|
||||
},
|
||||
seriesBooksFinished() {
|
||||
return this.seriesBookProgress.filter((p) => p.isFinished)
|
||||
},
|
||||
hasSeriesBookInProgress() {
|
||||
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
|
||||
},
|
||||
seriesPercentInProgress() {
|
||||
let totalFinishedAndInProgress = this.seriesBooksFinished.length
|
||||
if (this.hasSeriesBookInProgress) totalFinishedAndInProgress += 1
|
||||
return Math.min(1, Math.max(0, totalFinishedAndInProgress / this.books.length))
|
||||
},
|
||||
isSeriesFinished() {
|
||||
return this.books.length === this.seriesBooksFinished.length
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
@@ -64,14 +133,18 @@ export default {
|
||||
return this.store.state.libraries.currentLibraryId
|
||||
},
|
||||
seriesBooksRoute() {
|
||||
return `/library/${this.currentLibraryId}/series/${this.$encode(this.title)}`
|
||||
},
|
||||
seriesId() {
|
||||
return this.series ? this.$encode(this.title) : null
|
||||
return `/library/${this.currentLibraryId}/series/${this.seriesId}`
|
||||
},
|
||||
hasValidCovers() {
|
||||
var validCovers = this.books.map((bookItem) => bookItem.book.cover)
|
||||
var validCovers = this.books.map((bookItem) => bookItem.media.coverPath)
|
||||
return !!validCovers.length
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
const constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView == constants.BookshelfView.DETAIL
|
||||
},
|
||||
rssFeed() {
|
||||
return this.series ? this.series.rssFeed : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
50
client/components/cards/NarratorCard.vue
Normal file
50
client/components/cards/NarratorCard.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
|
||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
<div class="absolute inset-0 w-full h-full flex items-center justify-center pointer-events-none opacity-40">
|
||||
<span class="material-icons-outlined text-[10rem]">record_voice_over</span>
|
||||
</div>
|
||||
|
||||
<!-- Narrator name & num books overlay -->
|
||||
<div 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>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
narrator: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
width: Number,
|
||||
height: Number,
|
||||
sizeMultiplier: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
name() {
|
||||
return this.narrator?.name || ''
|
||||
},
|
||||
numBooks() {
|
||||
return this.narrator?.books?.length || 0
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
}
|
||||
},
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
34
client/components/cards/NarratorSearchCard.vue
Normal file
34
client/components/cards/NarratorSearchCard.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<span class="material-icons text-2xl text-gray-200">record_voice_over</span>
|
||||
</div>
|
||||
<div class="flex-grow px-2 narratorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ narrator }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
narrator: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.narratorSearchCardContent {
|
||||
width: calc(100% - 40px);
|
||||
height: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
174
client/components/cards/NotificationCard.vue
Normal file
174
client/components/cards/NotificationCard.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="w-full border border-white border-opacity-10 rounded-xl p-4 my-2" :class="notification.enabled ? 'bg-primary bg-opacity-25' : 'bg-error bg-opacity-5'">
|
||||
<div class="flex flex-wrap items-center">
|
||||
<p class="text-base md:text-lg font-semibold pr-4">{{ eventName }}</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" @click.stop="fireTestEventAndSucceed">Fire onTest Event</ui-btn>
|
||||
<ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" color="red-600" @click.stop="fireTestEventAndFail">Fire & Fail</ui-btn>
|
||||
<!-- <ui-btn v-if="eventName === 'onTest' && notification.enabled" :loading="testing" small class="mr-2" @click.stop="rapidFireTestEvents">Rapid Fire</ui-btn> -->
|
||||
<ui-btn v-else-if="notification.enabled" :loading="sendingTest" small class="mr-2" @click.stop="sendTestClick">Test</ui-btn>
|
||||
<ui-btn v-else :loading="enabling" small color="success" class="mr-2" @click="enableNotification">Enable</ui-btn>
|
||||
|
||||
<ui-icon-btn :size="7" icon-font-size="1.1rem" icon="edit" class="mr-2" @click="editNotification" />
|
||||
<ui-icon-btn bg-color="error" :size="7" icon-font-size="1.2rem" icon="delete" @click="deleteNotificationClick" />
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<p class="text-gray-300 text-xs md:text-sm mb-2">{{ notification.urls.join(', ') }}</p>
|
||||
|
||||
<p v-if="lastFiredAt && lastAttemptFailed" class="text-red-300 text-xs">Last attempt failed {{ $dateDistanceFromNow(lastFiredAt) }} ({{ numConsecutiveFailedAttempts }} attempt{{ numConsecutiveFailedAttempts === 1 ? '' : 's' }})</p>
|
||||
<p v-else-if="lastFiredAt" class="text-gray-400 text-xs">Last fired {{ $dateDistanceFromNow(lastFiredAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
notification: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
sendingTest: false,
|
||||
enabling: false,
|
||||
deleting: false,
|
||||
testing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
eventName() {
|
||||
return this.notification ? this.notification.eventName : null
|
||||
},
|
||||
lastFiredAt() {
|
||||
return this.notification ? this.notification.lastFiredAt : null
|
||||
},
|
||||
lastAttemptFailed() {
|
||||
return this.notification ? this.notification.lastAttemptFailed : null
|
||||
},
|
||||
numConsecutiveFailedAttempts() {
|
||||
return this.notification ? this.notification.numConsecutiveFailedAttempts : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// For testing using the onTest event
|
||||
fireTestEventAndFail() {
|
||||
this.fireTestEvent(true)
|
||||
},
|
||||
fireTestEventAndSucceed() {
|
||||
this.fireTestEvent(false)
|
||||
},
|
||||
fireTestEvent(intentionallyFail = false) {
|
||||
this.testing = true
|
||||
this.$axios
|
||||
.$get(`/api/notifications/test?fail=${intentionallyFail ? 1 : 0}`)
|
||||
.then(() => {
|
||||
this.$toast.success('Triggered onTest Event')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
const errorMsg = error.response ? error.response.data : null
|
||||
this.$toast.error(`Failed: ${errorMsg}` || 'Failed to trigger onTest event')
|
||||
})
|
||||
.finally(() => {
|
||||
this.testing = false
|
||||
})
|
||||
},
|
||||
rapidFireTestEvents() {
|
||||
this.testing = true
|
||||
var numFired = 0
|
||||
var interval = setInterval(() => {
|
||||
this.fireTestEvent()
|
||||
numFired++
|
||||
if (numFired > 25) {
|
||||
this.testing = false
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 100)
|
||||
},
|
||||
// End testing functions
|
||||
sendTestClick() {
|
||||
const payload = {
|
||||
message: `Trigger this notification with test data?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.sendTest()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
sendTest() {
|
||||
this.sendingTest = true
|
||||
this.$axios
|
||||
.$get(`/api/notifications/${this.notification.id}/test`)
|
||||
.then(() => {
|
||||
this.$toast.success('Triggered test notification')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
const errorMsg = error.response ? error.response.data : null
|
||||
this.$toast.error(`Failed: ${errorMsg}` || 'Failed to trigger test notification')
|
||||
})
|
||||
.finally(() => {
|
||||
this.sendingTest = false
|
||||
})
|
||||
},
|
||||
enableNotification() {
|
||||
this.enabling = true
|
||||
const payload = {
|
||||
id: this.notification.id,
|
||||
enabled: true
|
||||
}
|
||||
this.$axios
|
||||
.$patch(`/api/notifications/${this.notification.id}`, payload)
|
||||
.then((updatedSettings) => {
|
||||
this.$emit('update', updatedSettings)
|
||||
this.$toast.success('Notification enabled')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update notification', error)
|
||||
this.$toast.error('Failed to update notification')
|
||||
})
|
||||
.finally(() => {
|
||||
this.enabling = false
|
||||
})
|
||||
},
|
||||
deleteNotificationClick() {
|
||||
const payload = {
|
||||
message: `Are you sure you want to delete this notification?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.deleteNotification()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
deleteNotification() {
|
||||
this.deleting = true
|
||||
this.$axios
|
||||
.$delete(`/api/notifications/${this.notification.id}`)
|
||||
.then((updatedSettings) => {
|
||||
this.$emit('update', updatedSettings)
|
||||
this.$toast.success('Deleted notification')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to delete notification')
|
||||
})
|
||||
.finally(() => {
|
||||
this.deleting = false
|
||||
})
|
||||
},
|
||||
editNotification() {
|
||||
this.$emit('edit', this.notification)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
71
client/components/cards/PodcastFeedSummaryCard.vue
Normal file
71
client/components/cards/PodcastFeedSummaryCard.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="w-full p-2 border border-white border-opacity-10 rounded">
|
||||
<div class="flex">
|
||||
<div class="w-16 min-w-16">
|
||||
<div class="w-full h-16 bg-primary">
|
||||
<img v-if="image" :src="image" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<p class="text-gray-400 text-xxs pt-1 text-center">{{ numEpisodes }} {{ $strings.HeaderEpisodes }}</p>
|
||||
</div>
|
||||
<div class="flex-grow pl-2" :style="{ maxWidth: detailsWidth + 'px' }">
|
||||
<p class="mb-1">{{ title }}</p>
|
||||
<p class="text-xs mb-1 text-gray-300">{{ author }}</p>
|
||||
<p class="text-xs mb-2 text-gray-200">{{ description }}</p>
|
||||
<p class="text-xs truncate text-blue-200">
|
||||
{{ $strings.LabelFolder }}: <span class="font-mono">{{ folderPath }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
feed: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
libraryFolderPath: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
width: 900
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
title() {
|
||||
return this.metadata.title || 'No Title'
|
||||
},
|
||||
image() {
|
||||
return this.metadata.imageUrl
|
||||
},
|
||||
description() {
|
||||
return this.metadata.description || ''
|
||||
},
|
||||
author() {
|
||||
return this.metadata.author || ''
|
||||
},
|
||||
metadata() {
|
||||
return this.feed || {}
|
||||
},
|
||||
numEpisodes() {
|
||||
return this.feed.numEpisodes || 0
|
||||
},
|
||||
folderPath() {
|
||||
if (!this.libraryFolderPath) return ''
|
||||
return `${this.libraryFolderPath}/${this.$sanitizeFilename(this.title)}`
|
||||
},
|
||||
detailsWidth() {
|
||||
return this.width - 85
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
updated() {
|
||||
this.width = this.$refs.wrapper.clientWidth
|
||||
},
|
||||
mounted() {
|
||||
this.width = this.$refs.wrapper.clientWidth
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,73 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<form @submit.prevent="submitSearch">
|
||||
<div class="flex items-center justify-start -mx-1 h-20">
|
||||
<!-- <div class="w-40 px-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
||||
</div> -->
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
||||
</div>
|
||||
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
authorName: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchAuthor: null,
|
||||
lastSearch: null,
|
||||
isProcessing: false,
|
||||
provider: 'audnexus',
|
||||
providers: [
|
||||
{
|
||||
text: 'Audnexus',
|
||||
value: 'audnexus'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
authorName: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
this.searchAuthor = newVal
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
getSearchQuery() {
|
||||
return `q=${this.searchAuthor}`
|
||||
},
|
||||
submitSearch() {
|
||||
if (!this.searchAuthor) {
|
||||
this.$toast.warning('Author name is required')
|
||||
return
|
||||
}
|
||||
this.runSearch()
|
||||
},
|
||||
async runSearch() {
|
||||
var searchQuery = this.getSearchQuery()
|
||||
if (this.lastSearch === searchQuery) return
|
||||
this.isProcessing = true
|
||||
this.lastSearch = searchQuery
|
||||
var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return []
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (result) {
|
||||
this.$emit('match', result)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<covers-group-cover :name="series" :book-items="bookItems" :width="60" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-group-cover :name="name" :book-items="bookItems" :width="60" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="flex-grow px-2 seriesSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ series }}</p>
|
||||
<p class="truncate text-sm">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,7 +10,10 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
series: String,
|
||||
series: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
bookItems: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
@@ -21,7 +24,10 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
name() {
|
||||
return this.series.name
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
|
||||
212
client/components/content/LibraryItemDetails.vue
Normal file
212
client/components/content/LibraryItemDetails.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="narrators?.length" class="flex py-0.5 mt-4">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNarrators }}</span>
|
||||
</div>
|
||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
||||
<template v-for="(narrator, index) in narrators">
|
||||
<nuxt-link :key="narrator" :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link
|
||||
><span :key="index" v-if="index < narrators.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="publishedYear" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublishYear }}</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ publishedYear }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="publisher" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublisher }}</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ publisher }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicAlbum" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Album</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ musicAlbum }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicAlbumArtist" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ musicAlbumArtist }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicTrackPretty" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Track</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ musicTrackPretty }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicDiscPretty" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Disc</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ musicDiscPretty }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="podcastType" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPodcastType }}</span>
|
||||
</div>
|
||||
<div class="capitalize">
|
||||
{{ podcastType }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-0.5" v-if="genres.length">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelGenres }}</span>
|
||||
</div>
|
||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
||||
<template v-for="(genre, index) in genres">
|
||||
<nuxt-link :key="genre" :to="`/library/${libraryId}/bookshelf?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link
|
||||
><span :key="index" v-if="index < genres.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-0.5" v-if="tags.length">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelTags }}</span>
|
||||
</div>
|
||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
||||
<template v-for="(tag, index) in tags">
|
||||
<nuxt-link :key="tag" :to="`/library/${libraryId}/bookshelf?filter=tags.${$encode(tag)}`" class="hover:underline">{{ tag }}</nuxt-link
|
||||
><span :key="index" v-if="index < tags.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ durationPretty }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelSize }}</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ sizePretty }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
libraryId() {
|
||||
return this.libraryItem.libraryId
|
||||
},
|
||||
isPodcast() {
|
||||
return this.libraryItem.mediaType === 'podcast'
|
||||
},
|
||||
audioFile() {
|
||||
// Music track
|
||||
return this.media.audioFile
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
tracks() {
|
||||
return this.media.tracks || []
|
||||
},
|
||||
podcastEpisodes() {
|
||||
return this.media.episodes || []
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
publishedYear() {
|
||||
return this.mediaMetadata.publishedYear
|
||||
},
|
||||
genres() {
|
||||
return this.mediaMetadata.genres || []
|
||||
},
|
||||
tags() {
|
||||
return this.media.tags || []
|
||||
},
|
||||
podcastAuthor() {
|
||||
return this.mediaMetadata.author || ''
|
||||
},
|
||||
authors() {
|
||||
return this.mediaMetadata.authors || []
|
||||
},
|
||||
publisher() {
|
||||
return this.mediaMetadata.publisher || ''
|
||||
},
|
||||
musicArtists() {
|
||||
return this.mediaMetadata.artists || []
|
||||
},
|
||||
musicAlbum() {
|
||||
return this.mediaMetadata.album || ''
|
||||
},
|
||||
musicAlbumArtist() {
|
||||
return this.mediaMetadata.albumArtist || ''
|
||||
},
|
||||
musicTrackPretty() {
|
||||
if (!this.mediaMetadata.trackNumber) return null
|
||||
if (!this.mediaMetadata.trackTotal) return this.mediaMetadata.trackNumber
|
||||
return `${this.mediaMetadata.trackNumber} / ${this.mediaMetadata.trackTotal}`
|
||||
},
|
||||
musicDiscPretty() {
|
||||
if (!this.mediaMetadata.discNumber) return null
|
||||
if (!this.mediaMetadata.discTotal) return this.mediaMetadata.discNumber
|
||||
return `${this.mediaMetadata.discNumber} / ${this.mediaMetadata.discTotal}`
|
||||
},
|
||||
narrators() {
|
||||
return this.mediaMetadata.narrators || []
|
||||
},
|
||||
durationPretty() {
|
||||
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)
|
||||
|
||||
if (!this.tracks.length && !this.audioFile) return 'N/A'
|
||||
if (this.audioFile) return this.$elapsedPrettyExtended(this.duration)
|
||||
return this.$elapsedPretty(this.duration)
|
||||
},
|
||||
duration() {
|
||||
if (!this.tracks.length && !this.audioFile) return 0
|
||||
return this.media.duration
|
||||
},
|
||||
totalPodcastDuration() {
|
||||
if (!this.podcastEpisodes.length) return 0
|
||||
let totalDuration = 0
|
||||
this.podcastEpisodes.forEach((ep) => (totalDuration += ep.duration || 0))
|
||||
return totalDuration
|
||||
},
|
||||
sizePretty() {
|
||||
return this.$bytesPretty(this.media.size)
|
||||
},
|
||||
podcastType() {
|
||||
return this.mediaMetadata.type
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
<span class="block truncate text-xs">{{ selectedText }}</span>
|
||||
</span>
|
||||
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
@@ -14,42 +14,17 @@
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-sm ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
|
||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||
</div>
|
||||
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
|
||||
<span class="material-icons">arrow_right</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-black-400" role="option" @click="sublist = null">
|
||||
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
||||
<span class="material-icons">arrow_left</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-normal ml-3 block truncate">Back</span>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('No Series'))">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">No Series</span>
|
||||
</div>
|
||||
</li>
|
||||
<template v-for="item in sublistItems">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
|
||||
|
||||
<!-- selected checkmark icon -->
|
||||
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
||||
<span class="material-icons text-base text-yellow-400">check</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
@@ -61,66 +36,15 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: String
|
||||
value: String,
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMenu: false,
|
||||
sublist: null,
|
||||
items: [
|
||||
{
|
||||
text: 'All',
|
||||
value: 'all'
|
||||
},
|
||||
{
|
||||
text: 'Genre',
|
||||
value: 'genres',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Tag',
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Series',
|
||||
value: 'series',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Authors',
|
||||
value: 'authors',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Narrator',
|
||||
value: 'narrators',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Language',
|
||||
value: 'languages',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Progress',
|
||||
value: 'progress',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Issues',
|
||||
value: 'issues',
|
||||
sublist: false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
showMenu(newVal) {
|
||||
if (!newVal) {
|
||||
if (this.sublist && !this.selectedItemSublist) this.sublist = null
|
||||
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
|
||||
}
|
||||
showMenu: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -132,47 +56,10 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
selectedItemSublist() {
|
||||
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
|
||||
},
|
||||
selectedText() {
|
||||
if (!this.selected) return ''
|
||||
var parts = this.selected.split('.')
|
||||
if (parts.length > 1) {
|
||||
return this.$decode(parts[1])
|
||||
}
|
||||
var _sel = this.items.find((i) => i.value === this.selected)
|
||||
if (!_sel) return ''
|
||||
return _sel.text
|
||||
},
|
||||
genres() {
|
||||
return this.filterData.genres || []
|
||||
},
|
||||
tags() {
|
||||
return this.filterData.tags || []
|
||||
},
|
||||
series() {
|
||||
return this.filterData.series || []
|
||||
},
|
||||
authors() {
|
||||
return this.filterData.authors || []
|
||||
},
|
||||
narrators() {
|
||||
return this.filterData.narrators || []
|
||||
},
|
||||
languages() {
|
||||
return this.filterData.languages || []
|
||||
},
|
||||
progress() {
|
||||
return ['Read', 'Unread', 'In Progress']
|
||||
},
|
||||
sublistItems() {
|
||||
return (this[this.sublist] || []).map((item) => {
|
||||
return {
|
||||
text: item,
|
||||
value: this.$encode(item)
|
||||
}
|
||||
})
|
||||
const filter = this.items.find((i) => i.value === this.selected)
|
||||
return filter ? filter.text : ''
|
||||
},
|
||||
filterData() {
|
||||
return this.$store.state.libraries.filterData || {}
|
||||
@@ -185,18 +72,9 @@ export default {
|
||||
this.$nextTick(() => this.$emit('change', 'all'))
|
||||
},
|
||||
clickOutside() {
|
||||
if (!this.selectedItemSublist) this.sublist = null
|
||||
this.showMenu = false
|
||||
},
|
||||
clickedSublistOption(item) {
|
||||
this.clickedOption({ value: `${this.sublist}.${item}` })
|
||||
},
|
||||
clickedOption(option) {
|
||||
if (option.sublist) {
|
||||
this.sublist = option.value
|
||||
return
|
||||
}
|
||||
|
||||
var val = option.value
|
||||
if (this.selected === val) {
|
||||
this.showMenu = false
|
||||
|
||||
@@ -1,56 +1,74 @@
|
||||
<template>
|
||||
<div class="w-80 ml-6 relative">
|
||||
<div class="sm:w-80 w-full relative">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||
</form>
|
||||
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
||||
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
||||
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
||||
</div>
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<li v-if="isTyping" class="py-2 px-2">
|
||||
<p>Thinking...</p>
|
||||
<p>{{ $strings.MessageThinking }}</p>
|
||||
</li>
|
||||
<li v-else-if="isFetching" class="py-2 px-2">
|
||||
<p>Fetching...</p>
|
||||
<p>{{ $strings.MessageFetching }}</p>
|
||||
</li>
|
||||
<li v-else-if="!totalResults" class="py-2 px-2">
|
||||
<p>No Results</p>
|
||||
<p>{{ $strings.MessageNoResults }}</p>
|
||||
</li>
|
||||
<template v-else>
|
||||
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
|
||||
<template v-for="item in audiobookResults">
|
||||
<li :key="item.audiobook.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/audiobook/${item.audiobook.id}`">
|
||||
<cards-audiobook-search-card :audiobook="item.audiobook" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||
<p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelBooks }}</p>
|
||||
<template v-for="item in bookResults">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
|
||||
<p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelPodcasts }}</p>
|
||||
<template v-for="item in podcastResults">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelAuthors }}</p>
|
||||
<template v-for="item in authorResults">
|
||||
<li :key="item.author" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.author)}`">
|
||||
<cards-author-search-card :author="item.author" />
|
||||
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`">
|
||||
<cards-author-search-card :author="item" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
|
||||
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelSeries }}</p>
|
||||
<template v-for="item in seriesResults">
|
||||
<li :key="item.series" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/series/${$encode(item.series)}`">
|
||||
<cards-series-search-card :series="item.series" :book-items="item.audiobooks" />
|
||||
<li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`">
|
||||
<cards-series-search-card :series="item.series" :book-items="item.books" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p>
|
||||
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelTags }}</p>
|
||||
<template v-for="item in tagResults">
|
||||
<li :key="item.tag" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.tag)}`">
|
||||
<cards-tag-search-card :tag="item.tag" />
|
||||
<li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
|
||||
<cards-tag-search-card :tag="item.name" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<p v-if="narratorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelNarrators }}</p>
|
||||
<template v-for="narrator in narratorResults">
|
||||
<li :key="narrator.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
|
||||
<cards-narrator-search-card :narrator="narrator.name" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
@@ -70,10 +88,12 @@ export default {
|
||||
isTyping: false,
|
||||
isFetching: false,
|
||||
search: null,
|
||||
audiobookResults: [],
|
||||
podcastResults: [],
|
||||
bookResults: [],
|
||||
authorResults: [],
|
||||
seriesResults: [],
|
||||
tagResults: [],
|
||||
narratorResults: [],
|
||||
searchTimeout: null,
|
||||
lastSearch: null
|
||||
}
|
||||
@@ -83,10 +103,13 @@ export default {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
totalResults() {
|
||||
return this.audiobookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length
|
||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickOption() {
|
||||
this.clearResults()
|
||||
},
|
||||
submitSearch() {
|
||||
if (!this.search) return
|
||||
var search = this.search
|
||||
@@ -96,10 +119,12 @@ export default {
|
||||
clearResults() {
|
||||
this.search = null
|
||||
this.lastSearch = null
|
||||
this.audiobookResults = []
|
||||
this.podcastResults = []
|
||||
this.bookResults = []
|
||||
this.authorResults = []
|
||||
this.seriesResults = []
|
||||
this.tagResults = []
|
||||
this.narratorResults = []
|
||||
this.showMenu = false
|
||||
this.isFetching = false
|
||||
this.isTyping = false
|
||||
@@ -128,7 +153,7 @@ export default {
|
||||
}
|
||||
this.isFetching = true
|
||||
|
||||
var searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => {
|
||||
const searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => {
|
||||
console.error('Search error', error)
|
||||
return []
|
||||
})
|
||||
@@ -136,10 +161,12 @@ export default {
|
||||
// Search was canceled
|
||||
if (!this.isFetching) return
|
||||
|
||||
this.audiobookResults = searchResults.audiobooks || []
|
||||
this.podcastResults = searchResults.podcast || []
|
||||
this.bookResults = searchResults.book || []
|
||||
this.authorResults = searchResults.authors || []
|
||||
this.seriesResults = searchResults.series || []
|
||||
this.tagResults = searchResults.tags || []
|
||||
this.narratorResults = searchResults.narrators || []
|
||||
|
||||
this.isFetching = false
|
||||
if (!this.showMenu) {
|
||||
@@ -170,8 +197,8 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style scoped>
|
||||
.globalSearchMenu {
|
||||
max-height: 80vh;
|
||||
max-height: calc(100vh - 75px);
|
||||
}
|
||||
</style>
|
||||
478
client/components/controls/LibraryFilterSelect.vue
Normal file
478
client/components/controls/LibraryFilterSelect.vue
Normal file
@@ -0,0 +1,478 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
</span>
|
||||
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
||||
<span class="material-icons" style="font-size: 1.1rem">close</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
|
||||
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in selectItems">
|
||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
|
||||
</div>
|
||||
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
|
||||
<span class="material-icons text-2xl">arrow_right</span>
|
||||
</div>
|
||||
<!-- selected checkmark icon -->
|
||||
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
||||
<span class="material-icons text-base text-yellow-400">check</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="option" @click="sublist = null">
|
||||
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
||||
<span class="material-icons text-2xl">arrow_left</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-normal block truncate">Back</span>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<template v-for="item in sublistItems">
|
||||
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedSublistOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
|
||||
</div>
|
||||
<!-- selected checkmark icon -->
|
||||
<div v-if="`${sublist}.${item.value}` === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
||||
<span class="material-icons text-base text-yellow-400">check</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: String,
|
||||
isSeries: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMenu: false,
|
||||
sublist: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
showMenu(newVal) {
|
||||
if (newVal) {
|
||||
this.sublist = this.selectedItemSublist
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selected: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
libraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isPodcast() {
|
||||
return this.libraryMediaType === 'podcast'
|
||||
},
|
||||
isMusic() {
|
||||
return this.libraryMediaType === 'music'
|
||||
},
|
||||
seriesItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelAll,
|
||||
value: 'all'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelGenre,
|
||||
value: 'genres',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelTag,
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAuthor,
|
||||
value: 'authors',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelNarrator,
|
||||
value: 'narrators',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLanguage,
|
||||
value: 'languages',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelSeriesProgress,
|
||||
value: 'progress',
|
||||
sublist: true
|
||||
}
|
||||
]
|
||||
},
|
||||
bookItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelAll,
|
||||
value: 'all'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelGenre,
|
||||
value: 'genres',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelTag,
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelSeries,
|
||||
value: 'series',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAuthor,
|
||||
value: 'authors',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelNarrator,
|
||||
value: 'narrators',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLanguage,
|
||||
value: 'languages',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelProgress,
|
||||
value: 'progress',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelMissing,
|
||||
value: 'missing',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelTracks,
|
||||
value: 'tracks',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelEbooks,
|
||||
value: 'ebooks',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAbridged,
|
||||
value: 'abridged',
|
||||
sublist: false
|
||||
},
|
||||
{
|
||||
text: this.$strings.ButtonIssues,
|
||||
value: 'issues',
|
||||
sublist: false
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelRSSFeedOpen,
|
||||
value: 'feed-open',
|
||||
sublist: false
|
||||
}
|
||||
]
|
||||
},
|
||||
podcastItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelAll,
|
||||
value: 'all'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelGenre,
|
||||
value: 'genres',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelTag,
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.ButtonIssues,
|
||||
value: 'issues',
|
||||
sublist: false
|
||||
}
|
||||
]
|
||||
},
|
||||
musicItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelAll,
|
||||
value: 'all'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelGenre,
|
||||
value: 'genres',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelTag,
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.ButtonIssues,
|
||||
value: 'issues',
|
||||
sublist: false
|
||||
}
|
||||
]
|
||||
},
|
||||
selectItems() {
|
||||
if (this.isSeries) return this.seriesItems
|
||||
if (this.isPodcast) return this.podcastItems
|
||||
if (this.isMusic) return this.musicItems
|
||||
return this.bookItems
|
||||
},
|
||||
selectedItemSublist() {
|
||||
return this.selected?.includes('.') ? this.selected.split('.')[0] : null
|
||||
},
|
||||
selectedText() {
|
||||
if (!this.selected) return ''
|
||||
const parts = this.selected.split('.')
|
||||
const filterName = this.selectItems.find((i) => i.value === parts[0])
|
||||
let filterValue = null
|
||||
if (parts.length > 1) {
|
||||
const decoded = this.$decode(parts[1])
|
||||
if (parts[0] === 'authors') {
|
||||
const author = this.authors.find((au) => au.id == decoded)
|
||||
if (author) filterValue = author.name
|
||||
} else if (parts[0] === 'series') {
|
||||
if (decoded === 'no-series') {
|
||||
filterValue = this.$strings.MessageNoSeries
|
||||
} else {
|
||||
const series = this.series.find((se) => se.id == decoded)
|
||||
if (series) filterValue = series.name
|
||||
}
|
||||
} else {
|
||||
filterValue = decoded
|
||||
}
|
||||
}
|
||||
if (filterName && filterValue) {
|
||||
return `${filterName.text}: ${filterValue}`
|
||||
} else if (filterName) {
|
||||
return filterName.text
|
||||
} else if (filterValue) {
|
||||
return filterValue
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
genres() {
|
||||
return this.filterData.genres || []
|
||||
},
|
||||
tags() {
|
||||
return this.filterData.tags || []
|
||||
},
|
||||
series() {
|
||||
return this.filterData.series || []
|
||||
},
|
||||
authors() {
|
||||
return this.filterData.authors || []
|
||||
},
|
||||
narrators() {
|
||||
return this.filterData.narrators || []
|
||||
},
|
||||
languages() {
|
||||
return this.filterData.languages || []
|
||||
},
|
||||
progress() {
|
||||
return [
|
||||
{
|
||||
id: 'finished',
|
||||
name: this.$strings.LabelFinished
|
||||
},
|
||||
{
|
||||
id: 'in-progress',
|
||||
name: this.$strings.LabelInProgress
|
||||
},
|
||||
{
|
||||
id: 'not-started',
|
||||
name: this.$strings.LabelNotStarted
|
||||
},
|
||||
{
|
||||
id: 'not-finished',
|
||||
name: this.$strings.LabelNotFinished
|
||||
}
|
||||
]
|
||||
},
|
||||
tracks() {
|
||||
return [
|
||||
{
|
||||
id: 'single',
|
||||
name: this.$strings.LabelTracksSingleTrack
|
||||
},
|
||||
{
|
||||
id: 'multi',
|
||||
name: this.$strings.LabelTracksMultiTrack
|
||||
}
|
||||
]
|
||||
},
|
||||
ebooks() {
|
||||
return [
|
||||
{
|
||||
id: 'ebook',
|
||||
name: this.$strings.LabelHasEbook
|
||||
},
|
||||
{
|
||||
id: 'supplementary',
|
||||
name: this.$strings.LabelHasSupplementaryEbook
|
||||
}
|
||||
]
|
||||
},
|
||||
missing() {
|
||||
return [
|
||||
{
|
||||
id: 'asin',
|
||||
name: 'ASIN'
|
||||
},
|
||||
{
|
||||
id: 'isbn',
|
||||
name: 'ISBN'
|
||||
},
|
||||
{
|
||||
id: 'subtitle',
|
||||
name: this.$strings.LabelSubtitle
|
||||
},
|
||||
{
|
||||
id: 'authors',
|
||||
name: this.$strings.LabelAuthor
|
||||
},
|
||||
{
|
||||
id: 'publishedYear',
|
||||
name: this.$strings.LabelPublishYear
|
||||
},
|
||||
{
|
||||
id: 'series',
|
||||
name: this.$strings.LabelSeries
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
name: this.$strings.LabelDescription
|
||||
},
|
||||
{
|
||||
id: 'genres',
|
||||
name: this.$strings.LabelGenres
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
name: this.$strings.LabelTags
|
||||
},
|
||||
{
|
||||
id: 'narrators',
|
||||
name: this.$strings.LabelNarrator
|
||||
},
|
||||
{
|
||||
id: 'publisher',
|
||||
name: this.$strings.LabelPublisher
|
||||
},
|
||||
{
|
||||
id: 'language',
|
||||
name: this.$strings.LabelLanguage
|
||||
},
|
||||
{
|
||||
id: 'cover',
|
||||
name: this.$strings.LabelCover
|
||||
}
|
||||
]
|
||||
},
|
||||
sublistItems() {
|
||||
const sublistItems = (this[this.sublist] || []).map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
text: item,
|
||||
value: this.$encode(item)
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
text: item.name,
|
||||
value: this.$encode(item.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
if (this.sublist === 'series') {
|
||||
sublistItems.unshift({
|
||||
text: this.$strings.MessageNoSeries,
|
||||
value: this.$encode('no-series')
|
||||
})
|
||||
}
|
||||
return sublistItems
|
||||
},
|
||||
filterData() {
|
||||
return this.$store.state.libraries.filterData || {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clearSelected() {
|
||||
this.selected = 'all'
|
||||
this.showMenu = false
|
||||
this.$nextTick(() => this.$emit('change', 'all'))
|
||||
},
|
||||
clickOutside() {
|
||||
if (!this.selectedItemSublist) this.sublist = null
|
||||
this.showMenu = false
|
||||
},
|
||||
clickedSublistOption(item) {
|
||||
this.clickedOption({ value: `${this.sublist}.${item}` })
|
||||
},
|
||||
clickedOption(option) {
|
||||
if (option.sublist) {
|
||||
this.sublist = option.value
|
||||
return
|
||||
}
|
||||
|
||||
const val = option.value
|
||||
if (this.selected === val) {
|
||||
this.showMenu = false
|
||||
return
|
||||
}
|
||||
this.selected = val
|
||||
this.showMenu = false
|
||||
this.$nextTick(() => this.$emit('change', val))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.libraryFilterMenu {
|
||||
max-height: calc(100vh - 125px);
|
||||
}
|
||||
</style>
|
||||
218
client/components/controls/LibrarySortSelect.vue
Normal file
218
client/components/controls/LibrarySortSelect.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in selectItems">
|
||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||
</div>
|
||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: String,
|
||||
descending: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMenu: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selected: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
selectedDesc: {
|
||||
get() {
|
||||
return this.descending
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:descending', val)
|
||||
}
|
||||
},
|
||||
libraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isPodcast() {
|
||||
return this.libraryMediaType === 'podcast'
|
||||
},
|
||||
isMusic() {
|
||||
return this.libraryMediaType === 'music'
|
||||
},
|
||||
podcastItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelTitle,
|
||||
value: 'media.metadata.title'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAuthor,
|
||||
value: 'media.metadata.author'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAddedAt,
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelSize,
|
||||
value: 'size'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelNumberOfEpisodes,
|
||||
value: 'media.numTracks'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelFileBirthtime,
|
||||
value: 'birthtimeMs'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelFileModified,
|
||||
value: 'mtimeMs'
|
||||
}
|
||||
]
|
||||
},
|
||||
bookItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelTitle,
|
||||
value: 'media.metadata.title'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAuthorFirstLast,
|
||||
value: 'media.metadata.authorName'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAuthorLastFirst,
|
||||
value: 'media.metadata.authorNameLF'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelPublishYear,
|
||||
value: 'media.metadata.publishedYear'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAddedAt,
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelSize,
|
||||
value: 'size'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelDuration,
|
||||
value: 'media.duration'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelFileBirthtime,
|
||||
value: 'birthtimeMs'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelFileModified,
|
||||
value: 'mtimeMs'
|
||||
}
|
||||
]
|
||||
},
|
||||
seriesItems() {
|
||||
return [
|
||||
...this.bookItems,
|
||||
{
|
||||
text: this.$strings.LabelSequence,
|
||||
value: 'sequence'
|
||||
}
|
||||
]
|
||||
},
|
||||
musicItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelTitle,
|
||||
value: 'media.metadata.title'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAddedAt,
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelSize,
|
||||
value: 'size'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelDuration,
|
||||
value: 'media.duration'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelFileBirthtime,
|
||||
value: 'birthtimeMs'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelFileModified,
|
||||
value: 'mtimeMs'
|
||||
}
|
||||
]
|
||||
},
|
||||
selectItems() {
|
||||
let items = null
|
||||
if (this.isPodcast) {
|
||||
items = this.podcastItems
|
||||
} else if (this.isMusic) {
|
||||
items = this.musicItems
|
||||
} else if (this.$store.getters['user/getUserSetting']('filterBy').startsWith('series.')) {
|
||||
items = this.seriesItems
|
||||
} else {
|
||||
items = this.bookItems
|
||||
}
|
||||
|
||||
if (!items.some((i) => i.value === this.selected)) {
|
||||
this.selected = items[0].value
|
||||
this.selectedDesc = !this.defaultsToAsc(items[0].value)
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
selectedText() {
|
||||
var _selected = this.selected
|
||||
if (!_selected) return ''
|
||||
if (this.selected.startsWith('book.')) _selected = _selected.replace('book.', 'media.metadata.')
|
||||
var _sel = this.selectItems.find((i) => i.value === _selected)
|
||||
if (!_sel) return ''
|
||||
return _sel.text
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickOutside() {
|
||||
this.showMenu = false
|
||||
},
|
||||
clickedOption(val) {
|
||||
if (this.selected === val) {
|
||||
this.selectedDesc = !this.selectedDesc
|
||||
} else {
|
||||
this.selected = val
|
||||
if (this.defaultsToAsc(val)) this.selectedDesc = false
|
||||
}
|
||||
this.showMenu = false
|
||||
this.$nextTick(() => this.$emit('change', val))
|
||||
},
|
||||
defaultsToAsc(val) {
|
||||
return val == 'media.metadata.title' || val == 'media.metadata.author' || val == 'media.metadata.authorName' || val == 'media.metadata.authorNameLF' || val == 'sequence'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div class="relative ml-8" v-click-outside="clickOutside">
|
||||
<div ref="wrapper" class="relative ml-4 sm:ml-8" v-click-outside="clickOutside">
|
||||
<div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)">
|
||||
<span class="font-mono uppercase text-gray-200">{{ playbackRate.toFixed(1) }}<span class="text-lg">⨯</span></span>
|
||||
<span class="font-mono uppercase text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base">x</span></span>
|
||||
</div>
|
||||
<div v-show="showMenu" class="absolute -top-20 left-0 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" style="left: -92px">
|
||||
<div class="absolute -bottom-2 left-0 right-0 w-full flex justify-center">
|
||||
<div v-show="showMenu" class="absolute -top-20 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" :style="{ left: menuLeft + 'px' }">
|
||||
<div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }">
|
||||
<div class="arrow-down" />
|
||||
</div>
|
||||
<div class="flex items-center h-9 relative overflow-hidden rounded-lg" style="width: 220px">
|
||||
<template v-for="rate in rates">
|
||||
<div :key="rate" class="h-full border-black-300 w-11 cursor-pointer border rounded-sm" :class="value === rate ? 'bg-black-100' : 'hover:bg-black hover:bg-opacity-10'" style="min-width: 44px; max-width: 44px" @click="set(rate)">
|
||||
<div class="w-full h-full flex justify-center items-center">
|
||||
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm">⨯</span></p>
|
||||
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm">x</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="w-full py-1 px-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" />
|
||||
<p class="px-2 text-3xl">{{ playbackRate }}<span class="text-2xl">⨯</span></p>
|
||||
<p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl">x</span></p>
|
||||
<ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,7 +40,9 @@ export default {
|
||||
showMenu: false,
|
||||
currentPlaybackRate: 0,
|
||||
MIN_SPEED: 0.5,
|
||||
MAX_SPEED: 3
|
||||
MAX_SPEED: 10,
|
||||
menuLeft: -92,
|
||||
arrowLeft: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -80,8 +82,22 @@ export default {
|
||||
var newPlaybackRate = this.playbackRate - 0.1
|
||||
this.playbackRate = Number(newPlaybackRate.toFixed(1))
|
||||
},
|
||||
updateMenuPositions() {
|
||||
if (!this.$refs.wrapper) return
|
||||
const boundingBox = this.$refs.wrapper.getBoundingClientRect()
|
||||
|
||||
if (boundingBox.left + 110 > window.innerWidth - 10) {
|
||||
this.menuLeft = window.innerWidth - 230 - boundingBox.left
|
||||
|
||||
this.arrowLeft = Math.abs(this.menuLeft) - 92
|
||||
} else {
|
||||
this.menuLeft = -92
|
||||
this.arrowLeft = 0
|
||||
}
|
||||
},
|
||||
setShowMenu(val) {
|
||||
if (val) {
|
||||
this.updateMenuPositions()
|
||||
this.currentPlaybackRate = this.playbackRate
|
||||
} else if (this.currentPlaybackRate !== this.playbackRate) {
|
||||
this.$emit('change', this.playbackRate)
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
|
||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||
</div>
|
||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
@@ -26,41 +26,15 @@
|
||||
export default {
|
||||
props: {
|
||||
value: String,
|
||||
descending: Boolean
|
||||
descending: Boolean,
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMenu: false,
|
||||
items: [
|
||||
{
|
||||
text: 'Title',
|
||||
value: 'book.title'
|
||||
},
|
||||
{
|
||||
text: 'Author (First Last)',
|
||||
value: 'book.authorFL'
|
||||
},
|
||||
{
|
||||
text: 'Author (Last, First)',
|
||||
value: 'book.authorLF'
|
||||
},
|
||||
{
|
||||
text: 'Added At',
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: 'Volume #',
|
||||
value: 'book.volumeNumber'
|
||||
},
|
||||
{
|
||||
text: 'Duration',
|
||||
value: 'duration'
|
||||
},
|
||||
{
|
||||
text: 'Size',
|
||||
value: 'size'
|
||||
}
|
||||
]
|
||||
showMenu: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -81,7 +55,8 @@ export default {
|
||||
}
|
||||
},
|
||||
selectedText() {
|
||||
var _selected = this.selected === 'book.author' ? 'book.authorFL' : this.selected
|
||||
var _selected = this.selected
|
||||
if (!_selected) return ''
|
||||
var _sel = this.items.find((i) => i.value === _selected)
|
||||
if (!_sel) return ''
|
||||
return _sel.text
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div class="cursor-pointer" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
||||
<span class="material-icons text-3xl">{{ volumeIcon }}</span>
|
||||
<div class="cursor-pointer text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
||||
<span class="material-icons text-2xl sm:text-3xl">{{ volumeIcon }}</span>
|
||||
</div>
|
||||
<transition name="menux">
|
||||
<div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px">
|
||||
@@ -37,6 +37,11 @@ export default {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
try {
|
||||
localStorage.setItem("volume", val);
|
||||
} catch(error) {
|
||||
console.error('Failed to store volume', err)
|
||||
}
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
@@ -141,6 +146,10 @@ export default {
|
||||
if (this.value === 0) {
|
||||
this.isMute = true
|
||||
}
|
||||
const storageVolume = localStorage.getItem("volume")
|
||||
if (storageVolume) {
|
||||
this.volume = parseFloat(storageVolume)
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('mousewheel', this.scroll)
|
||||
|
||||
87
client/components/covers/AuthorImage.vue
Normal file
87
client/components/covers/AuthorImage.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div ref="wrapper" :class="`rounded-${rounded}`" class="w-full h-full bg-primary overflow-hidden">
|
||||
<svg v-if="!imagePath" width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
|
||||
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
|
||||
<path
|
||||
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
|
||||
</svg>
|
||||
<div v-else class="w-full h-full relative">
|
||||
<div v-if="showCoverBg" class="cover-bg absolute" :style="{ backgroundImage: `url(${imgSrc})` }" />
|
||||
<img ref="img" :src="imgSrc" @load="imageLoaded" class="absolute top-0 left-0 h-full w-full" :class="coverContain ? 'object-contain' : 'object-cover'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
author: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
rounded: {
|
||||
type: String,
|
||||
default: 'lg'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showCoverBg: false,
|
||||
coverContain: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
_author() {
|
||||
return this.author || {}
|
||||
},
|
||||
authorId() {
|
||||
return this._author.id
|
||||
},
|
||||
imagePath() {
|
||||
return this._author.imagePath
|
||||
},
|
||||
updatedAt() {
|
||||
return this._author.updatedAt
|
||||
},
|
||||
imgSrc() {
|
||||
if (!this.imagePath) return null
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// Testing
|
||||
return `http://localhost:3333${this.$config.routerBasePath}/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||
}
|
||||
return `/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
imageLoaded() {
|
||||
var aspectRatio = 1.25
|
||||
if (this.$refs.wrapper) {
|
||||
aspectRatio = this.$refs.wrapper.clientHeight / this.$refs.wrapper.clientWidth
|
||||
}
|
||||
if (this.$refs.img) {
|
||||
var { naturalWidth, naturalHeight } = this.$refs.img
|
||||
var imgAr = naturalHeight / naturalWidth
|
||||
var arDiff = Math.abs(imgAr - aspectRatio)
|
||||
if (arDiff > 0.15) {
|
||||
this.showCoverBg = true
|
||||
} else {
|
||||
this.showCoverBg = false
|
||||
this.coverContain = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -5,20 +5,11 @@
|
||||
<div class="absolute cover-bg" ref="coverBg" />
|
||||
</div>
|
||||
|
||||
<img v-if="audiobook" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
||||
<div v-show="loading && audiobook" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
|
||||
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
<img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
||||
<div v-show="loading && 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">
|
||||
<div class="la-ball-spin-clockwise la-sm">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,17 +17,17 @@
|
||||
<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" loading="lazy" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
||||
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
|
||||
<p class="text-center text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div>
|
||||
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
|
||||
<p class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
|
||||
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -44,11 +35,10 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
authorOverride: String,
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
@@ -75,12 +65,15 @@ export default {
|
||||
height() {
|
||||
return this.width * this.bookCoverAspectRatio
|
||||
},
|
||||
book() {
|
||||
if (!this.audiobook) return {}
|
||||
return this.audiobook.book || {}
|
||||
media() {
|
||||
if (!this.libraryItem) return {}
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
title() {
|
||||
return this.book.title || 'No Title'
|
||||
return this.mediaMetadata.title || 'No Title'
|
||||
},
|
||||
titleCleaned() {
|
||||
if (this.title.length > 60) {
|
||||
@@ -88,9 +81,11 @@ export default {
|
||||
}
|
||||
return this.title
|
||||
},
|
||||
authors() {
|
||||
return this.mediaMetadata.authors || []
|
||||
},
|
||||
author() {
|
||||
if (this.authorOverride) return this.authorOverride
|
||||
return this.book.author || 'Unknown'
|
||||
return this.authors.map((au) => au.name).join(', ')
|
||||
},
|
||||
authorCleaned() {
|
||||
if (this.author.length > 30) {
|
||||
@@ -99,18 +94,19 @@ export default {
|
||||
return this.author
|
||||
},
|
||||
placeholderUrl() {
|
||||
return '/book_placeholder.jpg'
|
||||
const config = this.$config || this.$nuxt.$config
|
||||
return `${config.routerBasePath}/book_placeholder.jpg`
|
||||
},
|
||||
fullCoverUrl() {
|
||||
if (!this.audiobook) return null
|
||||
if (!this.libraryItem) return null
|
||||
var store = this.$store || this.$nuxt.$store
|
||||
return store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
|
||||
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl)
|
||||
},
|
||||
cover() {
|
||||
return this.book.cover || this.placeholderUrl
|
||||
return this.media.coverPath || this.placeholderUrl
|
||||
},
|
||||
hasCover() {
|
||||
return !!this.book.cover
|
||||
return !!this.media.coverPath
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.squareAspectRatio ? 192 : 120
|
||||
@@ -130,6 +126,9 @@ export default {
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
resolution() {
|
||||
return `${this.naturalWidth}x${this.naturalHeight}px`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -138,12 +137,12 @@ export default {
|
||||
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
|
||||
}
|
||||
},
|
||||
hideCoverBg() {},
|
||||
imageLoaded() {
|
||||
this.loading = false
|
||||
this.$nextTick(() => {
|
||||
this.imageReady = true
|
||||
})
|
||||
|
||||
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
|
||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||
var aspectRatio = naturalHeight / naturalWidth
|
||||
@@ -168,214 +167,3 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/*!
|
||||
* Load Awesome v1.1.0 (http://github.danielcardoso.net/load-awesome/)
|
||||
* Copyright 2015 Daniel Cardoso <@DanielCardoso>
|
||||
* Licensed under MIT
|
||||
*/
|
||||
.la-ball-spin-clockwise,
|
||||
.la-ball-spin-clockwise > div {
|
||||
position: relative;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.la-ball-spin-clockwise {
|
||||
display: block;
|
||||
font-size: 0;
|
||||
color: #fff;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-dark {
|
||||
color: #262626;
|
||||
}
|
||||
.la-ball-spin-clockwise > div {
|
||||
display: inline-block;
|
||||
float: none;
|
||||
background-color: currentColor;
|
||||
border: 0 solid currentColor;
|
||||
}
|
||||
.la-ball-spin-clockwise {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.la-ball-spin-clockwise > div {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: -4px;
|
||||
margin-left: -4px;
|
||||
border-radius: 100%;
|
||||
-webkit-animation: ball-spin-clockwise 1s infinite ease-in-out;
|
||||
-moz-animation: ball-spin-clockwise 1s infinite ease-in-out;
|
||||
-o-animation: ball-spin-clockwise 1s infinite ease-in-out;
|
||||
animation: ball-spin-clockwise 1s infinite ease-in-out;
|
||||
}
|
||||
.la-ball-spin-clockwise > div:nth-child(1) {
|
||||
top: 5%;
|
||||
left: 50%;
|
||||
-webkit-animation-delay: -0.875s;
|
||||
-moz-animation-delay: -0.875s;
|
||||
-o-animation-delay: -0.875s;
|
||||
animation-delay: -0.875s;
|
||||
}
|
||||
.la-ball-spin-clockwise > div:nth-child(2) {
|
||||
top: 18.1801948466%;
|
||||
left: 81.8198051534%;
|
||||
-webkit-animation-delay: -0.75s;
|
||||
-moz-animation-delay: -0.75s;
|
||||
-o-animation-delay: -0.75s;
|
||||
animation-delay: -0.75s;
|
||||
}
|
||||
.la-ball-spin-clockwise > div:nth-child(3) {
|
||||
top: 50%;
|
||||
left: 95%;
|
||||
-webkit-animation-delay: -0.625s;
|
||||
-moz-animation-delay: -0.625s;
|
||||
-o-animation-delay: -0.625s;
|
||||
animation-delay: -0.625s;
|
||||
}
|
||||
.la-ball-spin-clockwise > div:nth-child(4) {
|
||||
top: 81.8198051534%;
|
||||
left: 81.8198051534%;
|
||||
-webkit-animation-delay: -0.5s;
|
||||
-moz-animation-delay: -0.5s;
|
||||
-o-animation-delay: -0.5s;
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
.la-ball-spin-clockwise > div:nth-child(5) {
|
||||
top: 94.9999999966%;
|
||||
left: 50.0000000005%;
|
||||
-webkit-animation-delay: -0.375s;
|
||||
-moz-animation-delay: -0.375s;
|
||||
-o-animation-delay: -0.375s;
|
||||
animation-delay: -0.375s;
|
||||
}
|
||||
.la-ball-spin-clockwise > div:nth-child(6) {
|
||||
top: 81.8198046966%;
|
||||
left: 18.1801949248%;
|
||||
-webkit-animation-delay: -0.25s;
|
||||
-moz-animation-delay: -0.25s;
|
||||
-o-animation-delay: -0.25s;
|
||||
animation-delay: -0.25s;
|
||||
}
|
||||
.la-ball-spin-clockwise > div:nth-child(7) {
|
||||
top: 49.9999750815%;
|
||||
left: 5.0000051215%;
|
||||
-webkit-animation-delay: -0.125s;
|
||||
-moz-animation-delay: -0.125s;
|
||||
-o-animation-delay: -0.125s;
|
||||
animation-delay: -0.125s;
|
||||
}
|
||||
.la-ball-spin-clockwise > div:nth-child(8) {
|
||||
top: 18.179464974%;
|
||||
left: 18.1803700518%;
|
||||
-webkit-animation-delay: 0s;
|
||||
-moz-animation-delay: 0s;
|
||||
-o-animation-delay: 0s;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-sm {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-sm > div {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
margin-top: -2px;
|
||||
margin-left: -2px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-2x {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-2x > div {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: -8px;
|
||||
margin-left: -8px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-3x {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-3x > div {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-top: -12px;
|
||||
margin-left: -12px;
|
||||
}
|
||||
/*
|
||||
* Animation
|
||||
*/
|
||||
@-webkit-keyframes ball-spin-clockwise {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
opacity: 0;
|
||||
-webkit-transform: scale(0);
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
@-moz-keyframes ball-spin-clockwise {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
-moz-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
opacity: 0;
|
||||
-moz-transform: scale(0);
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
@-o-keyframes ball-spin-clockwise {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
-o-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
opacity: 0;
|
||||
-o-transform: scale(0);
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
@keyframes ball-spin-clockwise {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
-webkit-transform: scale(1);
|
||||
-moz-transform: scale(1);
|
||||
-o-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
opacity: 0;
|
||||
-webkit-transform: scale(0);
|
||||
-moz-transform: scale(0);
|
||||
-o-transform: scale(0);
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -13,13 +13,13 @@
|
||||
<div v-else-if="books.length" class="flex justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||
|
||||
<covers-book-cover :audiobook="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-book-cover v-if="books.length > 1" :audiobook="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-book-cover :library-item="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-book-cover v-if="books.length > 1" :library-item="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||
|
||||
<p class="font-book text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
|
||||
<p class="text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -17,7 +17,6 @@ export default {
|
||||
},
|
||||
width: Number,
|
||||
height: Number,
|
||||
groupTo: String,
|
||||
bookCoverAspectRatio: Number
|
||||
},
|
||||
data() {
|
||||
@@ -44,6 +43,14 @@ export default {
|
||||
this.$nextTick(this.init)
|
||||
}
|
||||
}
|
||||
},
|
||||
width: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.isInit = false
|
||||
this.$nextTick(this.init)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -51,9 +58,6 @@ export default {
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||
return this.width / 240
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.store.state.showExperimentalFeatures
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
@@ -63,7 +67,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
getCoverUrl(book) {
|
||||
return this.store.getters['audiobooks/getBookCoverSrc'](book, '')
|
||||
return this.store.getters['globals/getLibraryItemCoverSrc'](book, '')
|
||||
},
|
||||
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
|
||||
var src = coverData.coverUrl
|
||||
@@ -134,7 +138,7 @@ export default {
|
||||
|
||||
var innerP = document.createElement('p')
|
||||
innerP.textContent = this.name
|
||||
innerP.className = 'text-sm font-book text-white'
|
||||
innerP.className = 'text-sm text-white'
|
||||
imgdiv.appendChild(innerP)
|
||||
|
||||
return imgdiv
|
||||
@@ -151,7 +155,6 @@ export default {
|
||||
.map((bookItem) => {
|
||||
return {
|
||||
id: bookItem.id,
|
||||
volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null,
|
||||
coverUrl: this.getCoverUrl(bookItem)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<template>
|
||||
<div ref="container" @mouseover="mouseover" @mouseleave="mouseleave" class="relative">
|
||||
<covers-book-cover :width="24" :audiobook="audiobook" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
placeholderUrl() {
|
||||
return '/book_placeholder.jpg'
|
||||
},
|
||||
fullCoverUrl() {
|
||||
return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
|
||||
},
|
||||
hasCover() {
|
||||
return !!this.audiobook.book.cover
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
51
client/components/covers/PlaylistCover.vue
Normal file
51
client/components/covers/PlaylistCover.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="relative rounded-sm overflow-hidden" :style="{ width: width + 'px', height: height + 'px' }">
|
||||
<div v-if="items.length" class="flex flex-wrap justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||
<covers-book-cover v-for="(li, index) in libraryItemCovers" :key="index" :library-item="li" :width="itemCoverWidth" :book-cover-aspect-ratio="1" />
|
||||
</div>
|
||||
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
width: Number,
|
||||
height: Number
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
sizeMultiplier() {
|
||||
return this.width / (120 * 1.6 * 2)
|
||||
},
|
||||
itemCoverWidth() {
|
||||
if (this.libraryItemCovers.length === 1) return this.width
|
||||
return this.width / 2
|
||||
},
|
||||
libraryItemCovers() {
|
||||
if (!this.items.length) return []
|
||||
if (this.items.length === 1) return [this.items[0].libraryItem]
|
||||
|
||||
const covers = []
|
||||
for (let i = 0; i < 4; i++) {
|
||||
let index = i % this.items.length
|
||||
if (this.items.length === 2 && i >= 2) index = (i + 1) % 2 // for playlists with 2 items show covers in checker pattern
|
||||
|
||||
covers.push(this.items[index].libraryItem)
|
||||
}
|
||||
return covers
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * bookCoverAspectRatio + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||
<div class="w-full h-full relative">
|
||||
<div class="relative rounded-sm" :style="{ height: width * bookCoverAspectRatio + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||
<div class="w-full h-full relative overflow-hidden">
|
||||
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
||||
<div class="absolute cover-bg" ref="coverBg" />
|
||||
</div>
|
||||
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
|
||||
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
||||
|
||||
<a v-if="!imageFailed && showOpenNewTab && isHovering" :href="cover" @click.stop target="_blank" class="absolute bg-primary flex items-center justify-center shadow-sm rounded-full hover:scale-110 transform duration-100" :style="{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
|
||||
@@ -14,9 +14,11 @@
|
||||
<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 font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
|
||||
<p class="text-center text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!imageFailed && showResolution" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -29,7 +31,11 @@ export default {
|
||||
default: 120
|
||||
},
|
||||
showOpenNewTab: Boolean,
|
||||
bookCoverAspectRatio: Number
|
||||
bookCoverAspectRatio: Number,
|
||||
showResolution: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -54,6 +60,13 @@ export default {
|
||||
},
|
||||
placeholderCoverPadding() {
|
||||
return 0.8 * this.sizeMultiplier
|
||||
},
|
||||
resolution() {
|
||||
return `${this.naturalWidth}x${this.naturalHeight}px`
|
||||
},
|
||||
placeholderUrl() {
|
||||
const config = this.$config || this.$nuxt.$config
|
||||
return `${config.routerBasePath}/book_placeholder.jpg`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -63,7 +76,7 @@ export default {
|
||||
}
|
||||
},
|
||||
imageLoaded() {
|
||||
if (this.$refs.cover) {
|
||||
if (this.$refs.cover && this.src !== this.placeholderUrl) {
|
||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||
this.naturalHeight = naturalHeight
|
||||
this.naturalWidth = naturalWidth
|
||||
|
||||
@@ -1,87 +1,116 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
|
||||
<modals-modal ref="modal" v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<div class="w-full p-8">
|
||||
<div class="flex py-2 -mx-2">
|
||||
<div class="flex py-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="newUser.username" label="Username" class="mx-2" />
|
||||
<ui-text-input-with-label v-model="newUser.username" :label="$strings.LabelUsername" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? 'Password' : 'Change Password'" type="password" class="mx-2" />
|
||||
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? $strings.LabelPassword : $strings.LabelChangePassword" type="password" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-2">
|
||||
<div class="px-2">
|
||||
<ui-input-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :editable="false" :items="accountTypes" @input="userTypeUpdated" />
|
||||
<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>
|
||||
<div class="flex-grow" />
|
||||
<div v-show="!isEditingRoot" class="flex items-center pt-4 px-2">
|
||||
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">Is Active</p>
|
||||
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isEditingRoot && newUser.permissions" class="w-full border-t border-b border-black-200 py-2 px-3 mt-4">
|
||||
<p class="text-lg mb-2 font-semibold">Permissions</p>
|
||||
<p class="text-lg mb-2 font-semibold">{{ $strings.HeaderPermissions }}</p>
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>Can Download</p>
|
||||
<p id="download-permissions-toggle">{{ $strings.LabelPermissionsDownload }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.download" />
|
||||
<ui-toggle-switch labeledBy="download-permissions-toggle" v-model="newUser.permissions.download" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>Can Update</p>
|
||||
<p id="update-permissions-toggle">{{ $strings.LabelPermissionsUpdate }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.update" />
|
||||
<ui-toggle-switch labeledBy="update-permissions-toggle" v-model="newUser.permissions.update" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>Can Delete</p>
|
||||
<p id="delete-permissions-toggle">{{ $strings.LabelPermissionsDelete }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.delete" />
|
||||
<ui-toggle-switch labeledBy="delete-permissions-toggle" v-model="newUser.permissions.delete" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>Can Upload</p>
|
||||
<p id="upload-permissions-toggle">{{ $strings.LabelPermissionsUpload }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.upload" />
|
||||
<ui-toggle-switch labeledBy="upload-permissions-toggle" v-model="newUser.permissions.upload" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>Can Access All Libraries</p>
|
||||
<p id="explicit-content-permissions-toggle">{{ $strings.LabelPermissionsAccessExplicitContent }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
|
||||
<ui-toggle-switch labeledBy="explicit-content-permissions-toggle" v-model="newUser.permissions.accessExplicitContent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p id="access-all-libs--permissions-toggle">{{ $strings.LabelPermissionsAccessAllLibraries }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch labeledBy="access-all-libs--permissions-toggle" v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!newUser.permissions.accessAllLibraries" class="my-4">
|
||||
<ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" label="Libraries Accessible to User" />
|
||||
<ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" :label="$strings.LabelLibrariesAccessibleToUser" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-cen~ter my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>{{ $strings.LabelPermissionsAccessAllTags }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.accessAllTags" @input="accessAllTagsToggled" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
||||
<div class="flex items-center">
|
||||
<ui-multi-select-dropdown v-model="newUser.itemTagsSelected" :items="itemTags" :label="tagsSelectionText" />
|
||||
<div class="flex items-center pt-4 px-2">
|
||||
<p class="px-3 font-semibold" id="selected-tags-not-accessible--permissions-toggle">{{ $strings.LabelInvert }}</p>
|
||||
<ui-toggle-switch labeledBy="selected-tags-not-accessible--permissions-toggle" v-model="newUser.permissions.selectedTagsNotAccessible" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex pt-4">
|
||||
<div class="flex pt-4 px-2">
|
||||
<ui-btn v-if="isEditingRoot" to="/account">{{ $strings.ButtonChangeRootPassword }}</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">Submit</ui-btn>
|
||||
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,7 +132,8 @@ export default {
|
||||
processing: false,
|
||||
newUser: {},
|
||||
isNew: true,
|
||||
accountTypes: ['guest', 'user', 'admin']
|
||||
tags: [],
|
||||
loadingTags: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -124,8 +154,27 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
accountTypes() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelAccountTypeGuest,
|
||||
value: 'guest'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAccountTypeUser,
|
||||
value: 'user'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAccountTypeAdmin,
|
||||
value: 'admin'
|
||||
}
|
||||
]
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
title() {
|
||||
return this.isNew ? 'Add New Account' : `Update Account: ${(this.account || {}).username}`
|
||||
return this.isNew ? this.$strings.HeaderNewAccount : this.$strings.HeaderUpdateAccount
|
||||
},
|
||||
isEditingRoot() {
|
||||
return this.account && this.account.type === 'root'
|
||||
@@ -135,9 +184,45 @@ export default {
|
||||
},
|
||||
libraryItems() {
|
||||
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
|
||||
},
|
||||
itemTags() {
|
||||
return this.tags.map((t) => {
|
||||
return {
|
||||
text: t,
|
||||
value: t
|
||||
}
|
||||
})
|
||||
},
|
||||
tagsSelectionText() {
|
||||
return this.newUser.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
close() {
|
||||
// Force close when navigating - used in UsersTable
|
||||
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||
},
|
||||
accessAllTagsToggled(val) {
|
||||
if (val) {
|
||||
if (this.newUser.itemTagsSelected?.length) {
|
||||
this.newUser.itemTagsSelected = []
|
||||
}
|
||||
this.newUser.permissions.selectedTagsNotAccessible = false
|
||||
}
|
||||
},
|
||||
fetchAllTags() {
|
||||
this.loadingTags = true
|
||||
this.$axios
|
||||
.$get(`/api/tags`)
|
||||
.then((res) => {
|
||||
this.tags = res.tags
|
||||
this.loadingTags = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load tags', error)
|
||||
this.loadingTags = false
|
||||
})
|
||||
},
|
||||
accessAllLibrariesToggled(val) {
|
||||
if (!val && !this.newUser.librariesAccessible.length) {
|
||||
this.newUser.librariesAccessible = this.libraries.map((l) => l.id)
|
||||
@@ -154,6 +239,10 @@ export default {
|
||||
this.$toast.error('Must select at least one library')
|
||||
return
|
||||
}
|
||||
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsSelected.length) {
|
||||
this.$toast.error('Must select at least one tag')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isNew) {
|
||||
this.submitCreateAccount()
|
||||
@@ -175,10 +264,16 @@ export default {
|
||||
.then((data) => {
|
||||
this.processing = false
|
||||
if (data.error) {
|
||||
this.$toast.error(`Failed to update account: ${data.error}`)
|
||||
this.$toast.error(`${this.$strings.ToastAccountUpdateFailed}: ${data.error}`)
|
||||
} else {
|
||||
console.log('Account updated', data.user)
|
||||
this.$toast.success('Account updated')
|
||||
|
||||
if (data.user.id === this.user.id && data.user.token !== this.user.token) {
|
||||
console.log('Current user token was updated')
|
||||
this.$store.commit('user/setUserToken', data.user.token)
|
||||
}
|
||||
|
||||
this.$toast.success(this.$strings.ToastAccountUpdateSuccess)
|
||||
this.show = false
|
||||
}
|
||||
})
|
||||
@@ -223,22 +318,27 @@ export default {
|
||||
download: type !== 'guest',
|
||||
update: type === 'admin',
|
||||
delete: type === 'admin',
|
||||
upload: type === 'admin'
|
||||
upload: type === 'admin',
|
||||
accessAllLibraries: true,
|
||||
accessAllTags: true,
|
||||
selectedTagsNotAccessible: false
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.fetchAllTags()
|
||||
this.isNew = !this.account
|
||||
if (this.account) {
|
||||
var librariesAccessible = this.account.librariesAccessible || []
|
||||
this.newUser = {
|
||||
username: this.account.username,
|
||||
password: this.account.password,
|
||||
type: this.account.type,
|
||||
isActive: this.account.isActive,
|
||||
permissions: { ...this.account.permissions },
|
||||
librariesAccessible: [...librariesAccessible]
|
||||
librariesAccessible: [...(this.account.librariesAccessible || [])],
|
||||
itemTagsSelected: [...(this.account.itemTagsSelected || [])]
|
||||
}
|
||||
} else {
|
||||
this.fetchAllTags()
|
||||
this.newUser = {
|
||||
username: null,
|
||||
password: null,
|
||||
@@ -249,7 +349,9 @@ export default {
|
||||
update: false,
|
||||
delete: false,
|
||||
upload: false,
|
||||
accessAllLibraries: true
|
||||
accessAllLibraries: true,
|
||||
accessAllTags: true,
|
||||
selectedTagsNotAccessible: false
|
||||
},
|
||||
librariesAccessible: []
|
||||
}
|
||||
|
||||
174
client/components/modals/AudioFileDataModal.vue
Normal file
174
client/components/modals/AudioFileDataModal.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="audiofile-data-modal" :width="700" :height="'unset'">
|
||||
<div v-if="audioFile" ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-base text-gray-200 truncate">{{ metadata.filename }}</p>
|
||||
<ui-btn v-if="ffprobeData" small class="ml-2" @click="ffprobeData = null">{{ $strings.ButtonReset }}</ui-btn>
|
||||
<ui-btn v-else-if="userIsAdminOrUp" small :loading="probingFile" class="ml-2" @click="getFFProbeData">Probe Audio File</ui-btn>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<template v-if="!ffprobeData">
|
||||
<ui-text-input-with-label :value="metadata.path" readonly :label="$strings.LabelPath" class="mb-4 text-sm" />
|
||||
|
||||
<div class="flex flex-col sm:flex-row text-sm">
|
||||
<div class="w-full sm:w-1/2">
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelSize }}
|
||||
</p>
|
||||
<p>{{ $bytesPretty(metadata.size) }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelDuration }}
|
||||
</p>
|
||||
<p>{{ $secondsToTimestamp(audioFile.duration) }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">{{ $strings.LabelFormat }}</p>
|
||||
<p>{{ audioFile.format }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelChapters }}
|
||||
</p>
|
||||
<p>{{ audioFile.chapters?.length || 0 }}</p>
|
||||
</div>
|
||||
<div v-if="audioFile.embeddedCoverArt" class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelEmbeddedCover }}
|
||||
</p>
|
||||
<p>{{ audioFile.embeddedCoverArt || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-1/2">
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelCodec }}
|
||||
</p>
|
||||
<p>{{ audioFile.codec }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelChannels }}
|
||||
</p>
|
||||
<p>{{ audioFile.channels }} ({{ audioFile.channelLayout }})</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelBitrate }}
|
||||
</p>
|
||||
<p>{{ $bytesPretty(audioFile.bitRate || 0, 0) }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">{{ $strings.LabelTimeBase }}</p>
|
||||
<p>{{ audioFile.timeBase }}</p>
|
||||
</div>
|
||||
<div v-if="audioFile.language" class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelLanguage }}
|
||||
</p>
|
||||
<p>{{ audioFile.language || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<p class="font-bold mb-2">{{ $strings.LabelMetaTags }}</p>
|
||||
|
||||
<div v-for="(value, key) in metaTags" :key="key" class="flex mb-1 text-sm">
|
||||
<p class="w-32 min-w-32 text-black-50 mb-1">
|
||||
{{ key.replace('tag', '') }}
|
||||
</p>
|
||||
<p>{{ value }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="w-full">
|
||||
<div class="relative">
|
||||
<ui-textarea-with-label :value="prettyFfprobeData" readonly :rows="30" class="text-xs" />
|
||||
|
||||
<button class="absolute top-4 right-4" :class="copiedToClipboard ? 'text-success' : 'text-white/50 hover:text-white/80'" @click.stop="copyFfprobeData">
|
||||
<span class="material-icons">{{ copiedToClipboard ? 'check' : 'content_copy' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
audioFile: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
libraryItemId: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
probingFile: false,
|
||||
ffprobeData: null,
|
||||
copiedToClipboard: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.ffprobeData = null
|
||||
this.copiedToClipboard = false
|
||||
this.probingFile = false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
metadata() {
|
||||
return this.audioFile?.metadata || {}
|
||||
},
|
||||
metaTags() {
|
||||
return this.audioFile?.metaTags || {}
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
prettyFfprobeData() {
|
||||
if (!this.ffprobeData) return ''
|
||||
return JSON.stringify(this.ffprobeData, null, 2)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getFFProbeData() {
|
||||
this.probingFile = true
|
||||
this.$axios
|
||||
.$get(`/api/items/${this.libraryItemId}/ffprobe/${this.audioFile.ino}`)
|
||||
.then((data) => {
|
||||
console.log('Got ffprobe data', data)
|
||||
this.ffprobeData = data
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to get ffprobe data', error)
|
||||
this.$toast.error('FFProbe failed')
|
||||
})
|
||||
.finally(() => {
|
||||
this.probingFile = false
|
||||
})
|
||||
},
|
||||
async copyFfprobeData() {
|
||||
this.copiedToClipboard = await this.$copyToClipboard(this.prettyFfprobeData)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
91
client/components/modals/BackupScheduleModal.vue
Normal file
91
client/components/modals/BackupScheduleModal.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="backup-scheduler" :width="700" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-3xl text-white truncate">{{ $strings.HeaderSetBackupSchedule }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="show && newCronExpression" 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">
|
||||
<widgets-cron-expression-builder ref="expressionBuilder" v-model="newCronExpression" @input="expressionUpdated" />
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<ui-btn :disabled="!isUpdated" @click="submit">{{ isUpdated ? $strings.ButtonSave : $strings.MessageNoUpdateNecessary }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
cronExpression: {
|
||||
type: String,
|
||||
default: '* * * * *'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newCronExpression: null,
|
||||
isUpdated: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
expressionUpdated() {
|
||||
this.isUpdated = this.newCronExpression !== this.cronExpression
|
||||
},
|
||||
init() {
|
||||
this.newCronExpression = this.cronExpression
|
||||
this.isUpdated = false
|
||||
},
|
||||
submit() {
|
||||
// If custom expression input is focused then unfocus it instead of submitting
|
||||
if (this.$refs.expressionBuilder && this.$refs.expressionBuilder.checkBlurExpressionInput) {
|
||||
if (this.$refs.expressionBuilder.checkBlurExpressionInput()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
|
||||
var updatePayload = {
|
||||
backupSchedule: this.newCronExpression
|
||||
}
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', updatePayload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
this.processing = false
|
||||
this.show = false
|
||||
this.$emit('update:cronExpression', this.newCronExpression)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
135
client/components/modals/BatchQuickMatchModel.vue
Normal file
135
client/components/modals/BatchQuickMatchModel.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="batchQuickMatch" :processing="processing" :width="500" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div v-if="show" class="w-full h-full py-4">
|
||||
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
|
||||
<div class="flex px-8 items-center py-2">
|
||||
<p class="pr-4">{{ $strings.LabelProvider }}</p>
|
||||
<ui-dropdown v-model="options.provider" :items="providers" small />
|
||||
</div>
|
||||
<p class="text-base px-8 py-2">{{ $strings.MessageBatchQuickMatchDescription }}</p>
|
||||
<div class="flex px-8 items-end py-2">
|
||||
<ui-toggle-switch v-model="options.overrideCover" />
|
||||
<ui-tooltip :text="$strings.LabelUpdateCoverHelp">
|
||||
<p class="pl-4">
|
||||
{{ $strings.LabelUpdateCover }}
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex px-8 items-end py-2">
|
||||
<ui-toggle-switch v-model="options.overrideDetails" />
|
||||
<ui-tooltip :text="$strings.LabelUpdateDetailsHelp">
|
||||
<p class="pl-4">
|
||||
{{ $strings.LabelUpdateDetails }}
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 text-white text-opacity-80 border-t border-white border-opacity-5">
|
||||
<div class="flex items-center px-4">
|
||||
<ui-btn type="button" @click="show = false">{{ $strings.ButtonCancel }}</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" @click="doBatchQuickMatch">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
lastUsedLibrary: undefined,
|
||||
options: {
|
||||
provider: undefined,
|
||||
overrideDetails: true,
|
||||
overrideCover: true,
|
||||
overrideDefaults: true
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.$store.state.globals.showBatchQuickMatchModal
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('globals/setShowBatchQuickMatchModal', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.$getString('MessageItemsSelected', [this.selectedBookIds.length])
|
||||
},
|
||||
showBatchQuickMatchModal() {
|
||||
return this.$store.state.globals.showBatchQuickMatchModal
|
||||
},
|
||||
selectedBookIds() {
|
||||
return (this.$store.state.globals.selectedMediaItems || []).map((i) => i.id)
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
// If we don't have a set provider (first open of dialog) or we've switched library, set
|
||||
// the selected provider to the current library default provider
|
||||
if (!this.options.provider || this.options.lastUsedLibrary != this.currentLibraryId) {
|
||||
this.options.lastUsedLibrary = this.currentLibraryId
|
||||
this.options.provider = this.libraryProvider
|
||||
}
|
||||
},
|
||||
doBatchQuickMatch() {
|
||||
if (!this.selectedBookIds.length) return
|
||||
if (this.processing) return
|
||||
|
||||
this.processing = true
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
this.$axios
|
||||
.$post(`/api/items/batch/quickmatch`, {
|
||||
options: this.options,
|
||||
libraryItemIds: this.selectedBookIds
|
||||
})
|
||||
.then(() => {
|
||||
this.$toast.info('Batch quick match of ' + this.selectedBookIds.length + ' books started!')
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error('Batch quick match failed')
|
||||
console.error('Failed to batch quick match', error)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.show = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="bookmarks" :width="500" :height="'unset'">
|
||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div v-if="show" class="w-full h-full">
|
||||
<template v-for="bookmark in bookmarks">
|
||||
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @update="submitUpdateBookmark" @delete="deleteBookmark" />
|
||||
</template>
|
||||
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
|
||||
<p class="text-xl">No Bookmarks</p>
|
||||
<p class="text-xl">{{ $strings.MessageNoBookmarks }}</p>
|
||||
</div>
|
||||
<div class="w-full h-px bg-white bg-opacity-10" />
|
||||
<form @submit.prevent="submitCreateBookmark">
|
||||
<div v-if="!hideCreate" class="w-full h-px bg-white bg-opacity-10" />
|
||||
<form v-if="!hideCreate" @submit.prevent="submitCreateBookmark">
|
||||
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
|
||||
<div class="w-16 max-w-16 text-center">
|
||||
<p class="text-sm font-mono text-gray-400">
|
||||
@@ -19,7 +24,7 @@
|
||||
<div class="flex-grow px-2">
|
||||
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
|
||||
</div>
|
||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons -mt-px">add</span></ui-btn>
|
||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons text-2xl -mt-px">add</span></ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -39,7 +44,8 @@ export default {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
audiobookId: String
|
||||
libraryItemId: String,
|
||||
hideCreate: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -67,6 +73,12 @@ export default {
|
||||
},
|
||||
canCreateBookmark() {
|
||||
return !this.bookmarks.find((bm) => bm.time === this.currentTime)
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -76,8 +88,15 @@ export default {
|
||||
this.showBookmarkTitleInput = true
|
||||
},
|
||||
deleteBookmark(bm) {
|
||||
var bookmark = { ...bm, audiobookId: this.audiobookId }
|
||||
this.$root.socket.emit('delete_bookmark', bookmark)
|
||||
this.$axios
|
||||
.$delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastBookmarkRemoveSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error(this.$strings.ToastBookmarkRemoveFailed)
|
||||
console.error(error)
|
||||
})
|
||||
this.show = false
|
||||
},
|
||||
clickBookmark(bm) {
|
||||
@@ -85,21 +104,34 @@ export default {
|
||||
},
|
||||
submitUpdateBookmark(updatedBookmark) {
|
||||
var bookmark = { ...updatedBookmark }
|
||||
bookmark.audiobookId = this.audiobookId
|
||||
|
||||
this.$root.socket.emit('update_bookmark', bookmark)
|
||||
this.$axios
|
||||
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastBookmarkUpdateSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error(this.$strings.ToastBookmarkUpdateFailed)
|
||||
console.error(error)
|
||||
})
|
||||
this.show = false
|
||||
},
|
||||
submitCreateBookmark() {
|
||||
if (!this.newBookmarkTitle) {
|
||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||
this.newBookmarkTitle = this.$formatDatetime(Date.now(), this.dateFormat, this.timeFormat)
|
||||
}
|
||||
var bookmark = {
|
||||
audiobookId: this.audiobookId,
|
||||
title: this.newBookmarkTitle,
|
||||
time: this.currentTime
|
||||
time: Math.floor(this.currentTime)
|
||||
}
|
||||
this.$root.socket.emit('create_bookmark', bookmark)
|
||||
this.$axios
|
||||
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastBookmarkCreateSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error(this.$strings.ToastBookmarkCreateFailed)
|
||||
console.error(error)
|
||||
})
|
||||
|
||||
this.newBookmarkTitle = ''
|
||||
this.showBookmarkTitleInput = false
|
||||
@@ -108,4 +140,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="textures" :width="'40vw'" :height="'unset'" :bg-opacity="10" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">Bookshelf Texture</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="px-4 w-full max-w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300" @mousedown.prevent @mouseup.prevent @mousemove.prevent>
|
||||
<h1 class="text-2xl mb-2">Select a bookshelf texture (For testing only)</h1>
|
||||
<div class="overflow-y-hidden overflow-x-auto">
|
||||
<div class="flex -mx-1">
|
||||
<template v-for="texture in textures">
|
||||
<div :key="texture" class="relative mx-1" style="height: 180px; width: 180px; min-width: 180px" @mousedown.prevent @mouseup.prevent>
|
||||
<img :src="texture" class="h-full object-cover cursor-pointer" @click="setTexture(texture)" />
|
||||
<div v-if="texture === selectedBookshelfTexture" class="absolute top-0 left-0 flex items-center justify-center w-full h-full bg-black bg-opacity-10">
|
||||
<span class="material-icons text-4xl text-success">check</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="flex pt-4">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">Submit</ui-btn>
|
||||
</div> -->
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
textures: ['/textures/wood_default.jpg', '/textures/wood1.png', '/textures/wood2.png', '/textures/wood3.png', '/textures/wood4.png', '/textures/leather1.jpg'],
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.$store.state.globals.showBookshelfTextureModal
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('globals/setShowBookshelfTextureModal', val)
|
||||
}
|
||||
},
|
||||
selectedBookshelfTexture() {
|
||||
return this.$store.state.selectedBookshelfTexture
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {},
|
||||
setTexture(img) {
|
||||
this.$store.dispatch('setBookshelfTexture', img)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,11 +1,14 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="chapters" :width="500" :height="'unset'">
|
||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
|
||||
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<template v-for="chap in chapters">
|
||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
||||
{{ chap.title }}
|
||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end / _playbackRate <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
||||
<p class="chapter-title truncate text-sm md:text-base">
|
||||
{{ chap.title }}
|
||||
</p>
|
||||
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended((chap.end - chap.start) / _playbackRate) }}</span>
|
||||
<span class="flex-grow" />
|
||||
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
|
||||
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start / _playbackRate) }}</span>
|
||||
|
||||
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||
</div>
|
||||
@@ -25,7 +28,8 @@ export default {
|
||||
currentChapter: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
playbackRate: Number
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -44,11 +48,15 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
_playbackRate() {
|
||||
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
||||
return this.playbackRate
|
||||
},
|
||||
currentChapterId() {
|
||||
return this.currentChapter ? this.currentChapter.id : null
|
||||
},
|
||||
currentChapterStart() {
|
||||
return this.currentChapter ? this.currentChapter.start : 0
|
||||
return (this.currentChapter?.start || 0) / this._playbackRate
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -58,16 +66,25 @@ export default {
|
||||
scrollToChapter() {
|
||||
if (!this.currentChapterId) return
|
||||
|
||||
var container = this.$refs.container
|
||||
if (container) {
|
||||
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||
if (this.$refs.container) {
|
||||
const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||
if (currChapterEl) {
|
||||
var offsetTop = currChapterEl.offsetTop
|
||||
var containerHeight = container.clientHeight
|
||||
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
||||
const containerHeight = this.$refs.container.clientHeight
|
||||
this.$refs.container.scrollTo({ top: currChapterEl.offsetTop - containerHeight / 2 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#chapter-modal-wrapper .chapter-title {
|
||||
max-width: calc(100% - 120px);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
#chapter-modal-wrapper .chapter-title {
|
||||
max-width: calc(100% - 150px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
56
client/components/modals/Dialog.vue
Normal file
56
client/components/modals/Dialog.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="300" height="100%">
|
||||
<template #outer>
|
||||
<div v-if="title" class="absolute top-7 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<div class="relative flex items-center px-3">
|
||||
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
title: String,
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selected: String // optional
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickedOption(action) {
|
||||
this.$emit('action', { action })
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<modals-libraries-edit-library v-if="show" :library="library" :processing.sync="processing" @close="show = false" />
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
library: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.library ? 'Update Library' : 'New Library'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
137
client/components/modals/EditSeriesInputInnerModal.vue
Normal file
137
client/components/modals/EditSeriesInputInnerModal.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 61" @click="clickClose">
|
||||
<div class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
|
||||
<span class="material-icons text-2xl md:text-4xl">close</span>
|
||||
</div>
|
||||
<div ref="content" class="text-white">
|
||||
<form v-if="selectedSeries" @submit.prevent="submitSeriesForm">
|
||||
<div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
|
||||
<div class="flex">
|
||||
<div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80">
|
||||
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!isNewSeries" :label="$strings.LabelSeriesName" />
|
||||
</div>
|
||||
<div class="w-24 sm:w-28 md:w-40 p-1">
|
||||
<ui-text-input-with-label ref="sequenceInput" v-model="selectedSeries.sequence" :label="$strings.LabelSequence" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-2 p-1">
|
||||
<ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
selectedSeries: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
existingSeriesNames: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
el: null,
|
||||
content: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.$nextTick(this.setShow)
|
||||
} else {
|
||||
this.setHide()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
isNewSeries() {
|
||||
if (!this.selectedSeries || !this.selectedSeries.id) return false
|
||||
return this.selectedSeries.id.startsWith('new')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setInputFocus() {
|
||||
if (this.isNewSeries) {
|
||||
// Focus on series input if new series
|
||||
if (this.$refs.newSeriesSelect) {
|
||||
this.$refs.newSeriesSelect.setFocus()
|
||||
}
|
||||
} else {
|
||||
// Focus on sequence input if existing series
|
||||
if (this.$refs.sequenceInput) {
|
||||
this.$refs.sequenceInput.setFocus()
|
||||
}
|
||||
}
|
||||
},
|
||||
submitSeriesForm() {
|
||||
if (this.$refs.newSeriesSelect) {
|
||||
this.$refs.newSeriesSelect.blur()
|
||||
}
|
||||
|
||||
this.$emit('submit')
|
||||
},
|
||||
clickClose() {
|
||||
this.show = false
|
||||
},
|
||||
hotkey(action) {
|
||||
if (action === this.$hotkeys.Modal.CLOSE) {
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
setShow() {
|
||||
if (!this.el || !this.content) {
|
||||
this.init()
|
||||
}
|
||||
if (!this.el || !this.content) {
|
||||
return
|
||||
}
|
||||
|
||||
document.body.appendChild(this.el)
|
||||
setTimeout(() => {
|
||||
this.content.style.transform = 'scale(1)'
|
||||
}, 10)
|
||||
|
||||
this.$store.commit('setInnerModalOpen', true)
|
||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||
|
||||
this.setInputFocus()
|
||||
},
|
||||
setHide() {
|
||||
if (this.content) this.content.style.transform = 'scale(0)'
|
||||
if (this.el) this.el.remove()
|
||||
|
||||
this.$store.commit('setInnerModalOpen', false)
|
||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||
},
|
||||
init() {
|
||||
this.el = this.$refs.wrapper
|
||||
this.content = this.$refs.content
|
||||
if (this.content && this.el) {
|
||||
this.el.classList.remove('hidden')
|
||||
this.el.classList.add('flex')
|
||||
this.content.style.transform = 'scale(0)'
|
||||
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
|
||||
this.el.style.opacity = 1
|
||||
this.el.remove()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
217
client/components/modals/ListeningSessionModal.vue
Normal file
217
client/components/modals/ListeningSessionModal.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-lg md:text-2xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||
<div class="flex items-center">
|
||||
<p class="text-base text-gray-200">{{ _session.displayTitle }}</p>
|
||||
<p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">by {{ _session.displayAuthor }}</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<div class="flex flex-wrap mb-4">
|
||||
<div class="w-full md:w-2/3">
|
||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2">{{ $strings.HeaderDetails }}</p>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelStartedAt }}</div>
|
||||
<div class="px-1">
|
||||
{{ $formatDatetime(_session.startedAt, dateFormat, timeFormat) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelUpdatedAt }}</div>
|
||||
<div class="px-1">
|
||||
{{ $formatDatetime(_session.updatedAt, dateFormat, timeFormat) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelTimeListened }}</div>
|
||||
<div class="px-1">
|
||||
{{ $elapsedPrettyExtended(_session.timeListening) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelStartTime }}</div>
|
||||
<div class="px-1">
|
||||
{{ $secondsToTimestamp(_session.startTime) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLastTime }}</div>
|
||||
<div class="px-1">
|
||||
{{ $secondsToTimestamp(_session.currentTime) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelItem }}</p>
|
||||
<div v-if="_session.libraryId" class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibrary }} Id</div>
|
||||
<div class="px-1 text-xs">
|
||||
{{ _session.libraryId }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibraryItem }} Id</div>
|
||||
<div class="px-1 text-xs">
|
||||
{{ _session.libraryItemId }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="_session.episodeId" class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelEpisode }} Id</div>
|
||||
<div class="px-1 text-xs">
|
||||
{{ _session.episodeId }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelMediaType }}</div>
|
||||
<div class="px-1">
|
||||
{{ _session.mediaType }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelDuration }}</div>
|
||||
<div class="px-1">
|
||||
{{ $elapsedPretty(_session.duration) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-1/3">
|
||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
|
||||
<p class="mb-1 text-xs">{{ _session.userId }}</p>
|
||||
|
||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
|
||||
<p class="mb-1">{{ playMethodName }}</p>
|
||||
<p class="mb-1">{{ _session.mediaPlayer }}</p>
|
||||
|
||||
<p v-if="hasDeviceInfo" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelDevice }}</p>
|
||||
<p v-if="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p>
|
||||
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
|
||||
<p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p>
|
||||
<p v-if="clientDisplayName" class="mb-1">{{ clientDisplayName }}</p>
|
||||
<p v-if="deviceInfo.sdkVersion" class="mb-1">SDK {{ $strings.LabelVersion }}: {{ deviceInfo.sdkVersion }}</p>
|
||||
<p v-if="deviceInfo.deviceType" class="mb-1">{{ $strings.LabelType }}: {{ deviceInfo.deviceType }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<ui-btn v-if="!isOpenSession" small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
|
||||
<ui-btn v-else small color="error" @click.stop="closeSessionClick">Close Open Session</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
session: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
_session() {
|
||||
return this.session || {}
|
||||
},
|
||||
deviceInfo() {
|
||||
return this._session.deviceInfo || {}
|
||||
},
|
||||
hasDeviceInfo() {
|
||||
return Object.keys(this.deviceInfo).length
|
||||
},
|
||||
osDisplayName() {
|
||||
if (!this.deviceInfo.osName) return null
|
||||
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
|
||||
},
|
||||
clientDisplayName() {
|
||||
if (!this.deviceInfo.manufacturer || !this.deviceInfo.model) return null
|
||||
return `${this.deviceInfo.manufacturer} ${this.deviceInfo.model}`
|
||||
},
|
||||
playMethodName() {
|
||||
const playMethod = this._session.playMethod
|
||||
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
|
||||
else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode'
|
||||
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
|
||||
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
|
||||
return 'Unknown'
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
},
|
||||
isOpenSession() {
|
||||
return !!this._session.open
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
deleteSessionClick() {
|
||||
const payload = {
|
||||
message: this.$strings.MessageConfirmDeleteSession,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.deleteSession()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
deleteSession() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/sessions/${this._session.id}`)
|
||||
.then(() => {
|
||||
this.processing = false
|
||||
this.$toast.success(this.$strings.ToastSessionDeleteSuccess)
|
||||
this.$emit('removedSession')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
this.processing = false
|
||||
console.error('Failed to delete session', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(errMsg || this.$strings.ToastSessionDeleteFailed)
|
||||
})
|
||||
},
|
||||
closeSessionClick() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/session/${this._session.id}/close`)
|
||||
.then(() => {
|
||||
this.$toast.success('Session closed')
|
||||
this.show = false
|
||||
this.$emit('closedSession')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to close session', error)
|
||||
const errMsg = error.response?.data || ''
|
||||
this.$toast.error(errMsg || 'Failed to close open session')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -2,11 +2,11 @@
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
|
||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||
|
||||
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
|
||||
<span class="material-icons text-4xl">close</span>
|
||||
</div>
|
||||
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
|
||||
<span class="material-icons text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
||||
</button>
|
||||
<slot name="outer" />
|
||||
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
||||
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" aria-modal="true" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
||||
<slot />
|
||||
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
@@ -39,7 +39,7 @@ export default {
|
||||
},
|
||||
zIndex: {
|
||||
type: Number,
|
||||
default: 50
|
||||
default: 60
|
||||
},
|
||||
bgOpacity: {
|
||||
type: Number,
|
||||
@@ -50,7 +50,8 @@ export default {
|
||||
return {
|
||||
el: null,
|
||||
content: null,
|
||||
preventClickoutside: false
|
||||
preventClickoutside: false,
|
||||
isShowingPrompt: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -93,16 +94,18 @@ export default {
|
||||
this.show = false
|
||||
},
|
||||
clickBg(ev) {
|
||||
if (!this.show || this.isShowingPrompt) return
|
||||
if (this.preventClickoutside) {
|
||||
this.preventClickoutside = false
|
||||
return
|
||||
}
|
||||
if (this.processing && this.persistent) return
|
||||
if (ev.srcElement.classList.contains('modal-bg')) {
|
||||
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
hotkey(action) {
|
||||
if (this.$store.state.innerModalOpen) return
|
||||
if (action === this.$hotkeys.Modal.CLOSE) {
|
||||
this.show = false
|
||||
}
|
||||
@@ -145,8 +148,16 @@ export default {
|
||||
} else {
|
||||
console.warn('Invalid modal init', this.name)
|
||||
}
|
||||
},
|
||||
showingPrompt(isShowing) {
|
||||
this.isShowingPrompt = isShowing
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
this.$eventBus.$on('showing-prompt', this.showingPrompt)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('showing-prompt', this.showingPrompt)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
133
client/components/modals/SleepTimerModal.vue
Normal file
133
client/components/modals/SleepTimerModal.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="sleep-timer" :width="350" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||
<p class="text-3xl text-white truncate pointer-events-none">{{ $strings.HeaderSleepTimer }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div v-if="!timerSet" class="w-full">
|
||||
<template v-for="time in sleepTimes">
|
||||
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time.seconds)">
|
||||
<p class="text-xl text-center">{{ time.text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime">
|
||||
<ui-text-input v-model="customTime" type="number" step="any" min="0.1" placeholder="Time in minutes" class="w-48" />
|
||||
<ui-btn color="success" type="submit" :padding-x="0" class="h-9 w-12 flex items-center justify-center ml-1">Set</ui-btn>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else class="w-full p-4">
|
||||
<div class="mb-4 flex items-center justify-center">
|
||||
<ui-btn :padding-x="2" small :disabled="remaining < 30 * 60" class="flex items-center mr-4" @click="decrement(30 * 60)">
|
||||
<span class="material-icons text-lg">remove</span>
|
||||
<span class="pl-1 text-base font-mono">30m</span>
|
||||
</ui-btn>
|
||||
|
||||
<ui-icon-btn icon="remove" @click="decrement(60 * 5)" />
|
||||
|
||||
<p class="mx-6 text-2xl font-mono">{{ $secondsToTimestamp(remaining) }}</p>
|
||||
|
||||
<ui-icon-btn icon="add" @click="increment(60 * 5)" />
|
||||
|
||||
<ui-btn :padding-x="2" small class="flex items-center ml-4" @click="increment(30 * 60)">
|
||||
<span class="material-icons text-lg">add</span>
|
||||
<span class="pl-1 text-base font-mono">30m</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
<ui-btn class="w-full" @click="$emit('cancel')">{{ $strings.ButtonCancel }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
timerSet: Boolean,
|
||||
timerTime: Number,
|
||||
remaining: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
customTime: null,
|
||||
sleepTimes: [
|
||||
{
|
||||
seconds: 60 * 5,
|
||||
text: '5 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 15,
|
||||
text: '15 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 20,
|
||||
text: '20 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 30,
|
||||
text: '30 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 45,
|
||||
text: '45 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 60,
|
||||
text: '60 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 90,
|
||||
text: '90 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 120,
|
||||
text: '2 hours'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitCustomTime() {
|
||||
if (!this.customTime || isNaN(this.customTime) || Number(this.customTime) <= 0) {
|
||||
this.customTime = null
|
||||
return
|
||||
}
|
||||
|
||||
const timeInSeconds = Math.round(Number(this.customTime) * 60)
|
||||
this.setTime(timeInSeconds)
|
||||
},
|
||||
setTime(seconds) {
|
||||
this.$emit('set', seconds)
|
||||
},
|
||||
increment(amount) {
|
||||
this.$emit('increment', amount)
|
||||
},
|
||||
decrement(amount) {
|
||||
if (amount > this.remaining) {
|
||||
if (this.remaining > 60) amount = 60
|
||||
else amount = 5
|
||||
}
|
||||
this.$emit('decrement', amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
185
client/components/modals/authors/EditModal.vue
Normal file
185
client/components/modals/authors/EditModal.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="edit-author" :width="800" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="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>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<div class="flex">
|
||||
<div class="w-3/4 p-2">
|
||||
<ui-text-input-with-label v-model="authorCopy.name" :disabled="processing" :label="$strings.LabelName" />
|
||||
</div>
|
||||
<div class="flex-grow p-2">
|
||||
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" :label="$strings.LabelPhotoPathURL" />
|
||||
</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>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
authorCopy: {
|
||||
name: '',
|
||||
asin: '',
|
||||
description: '',
|
||||
imagePath: ''
|
||||
},
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
author: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.$store.state.globals.showEditAuthorModal
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('globals/setShowEditAuthorModal', val)
|
||||
}
|
||||
},
|
||||
author() {
|
||||
return this.$store.state.globals.selectedAuthor
|
||||
},
|
||||
authorId() {
|
||||
if (!this.author) return ''
|
||||
return this.author.id
|
||||
},
|
||||
title() {
|
||||
return this.$strings.HeaderUpdateAuthor
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.authorCopy.name = this.author.name
|
||||
this.authorCopy.asin = this.author.asin
|
||||
this.authorCopy.description = this.author.description
|
||||
this.authorCopy.imagePath = this.author.imagePath
|
||||
},
|
||||
async submitForm() {
|
||||
var keysToCheck = ['name', 'asin', 'description', 'imagePath']
|
||||
var updatePayload = {}
|
||||
keysToCheck.forEach((key) => {
|
||||
if (this.authorCopy[key] !== this.author[key]) {
|
||||
updatePayload[key] = this.authorCopy[key]
|
||||
}
|
||||
})
|
||||
if (!Object.keys(updatePayload).length) {
|
||||
this.$toast.info(this.$strings.MessageNoUpdateNecessary)
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
const errorMsg = error.response ? error.response.data : null
|
||||
this.$toast.error(errorMsg || this.$strings.ToastAuthorUpdateFailed)
|
||||
return null
|
||||
})
|
||||
if (result) {
|
||||
if (result.updated) {
|
||||
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
|
||||
this.show = false
|
||||
} else if (result.merged) {
|
||||
this.$toast.success(this.$strings.ToastAuthorUpdateMerged)
|
||||
this.show = false
|
||||
} else this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||
}
|
||||
this.processing = false
|
||||
},
|
||||
async removeCover() {
|
||||
var updatePayload = {
|
||||
imagePath: null
|
||||
}
|
||||
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.processing = false
|
||||
},
|
||||
async searchAuthor() {
|
||||
if (!this.authorCopy.name && !this.authorCopy.asin) {
|
||||
this.$toast.error('Must enter an author name')
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
|
||||
const payload = {}
|
||||
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
|
||||
else payload.q = this.authorCopy.name
|
||||
|
||||
payload.region = 'us'
|
||||
if (this.libraryProvider.startsWith('audible.')) {
|
||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
||||
}
|
||||
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
})
|
||||
if (!response) {
|
||||
this.$toast.error('Author not found')
|
||||
} else if (response.updated) {
|
||||
if (response.author.imagePath) {
|
||||
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
|
||||
this.$store.commit('globals/showEditAuthorModal', response.author)
|
||||
} else this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
|
||||
} else {
|
||||
this.$toast.info('No updates were made for Author')
|
||||
}
|
||||
this.processing = false
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div class="flex items-center px-4 py-4 justify-start relative hover:bg-bg" :class="wrapperClass" @click="click" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<!-- <span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-80'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> -->
|
||||
<div class="flex items-center px-4 py-4 justify-start relative bg-primary hover:bg-opacity-25" :class="wrapperClass" @click.stop="click" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div class="w-16 max-w-16 text-center">
|
||||
<p class="text-sm font-mono text-gray-400">
|
||||
{{ this.$secondsToTimestamp(bookmark.time) }}
|
||||
@@ -13,7 +12,7 @@
|
||||
<div class="flex-grow pr-2">
|
||||
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
|
||||
</div>
|
||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons -mt-px">forward</span></ui-btn>
|
||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons text-2xl -mt-px">forward</span></ui-btn>
|
||||
<div class="pl-2 flex items-center">
|
||||
<span class="material-icons text-3xl text-white text-opacity-70 hover:text-opacity-95 cursor-pointer" @click.stop.prevent="cancelEditing">close</span>
|
||||
</div>
|
||||
|
||||
73
client/components/modals/changelog/ViewModal.vue
Normal file
73
client/components/modals/changelog/ViewModal.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-3xl text-white truncate">Changelog</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
|
||||
<p class="text-xl font-bold pb-4">Changelog v{{ currentVersionNumber }}</p>
|
||||
<div class="custom-text" v-html="compiledMarkedown" />
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { marked } from '@/static/libs/marked/index.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
changelog: String,
|
||||
currentVersion: String
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
compiledMarkedown() {
|
||||
return marked.parse(this.changelog, { gfm: true, breaks: true })
|
||||
},
|
||||
currentVersionNumber() {
|
||||
return this.currentVersion
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
1. we need to manually define styles to apply to the parsed markdown elements,
|
||||
since we don't have access to the actual elements in this component
|
||||
|
||||
2. v-deep allows these to take effect on the content passed in to the v-html in the div above
|
||||
*/
|
||||
.custom-text ::v-deep > h2 {
|
||||
@apply text-lg font-bold;
|
||||
}
|
||||
.custom-text ::v-deep > h3 {
|
||||
@apply text-lg font-bold;
|
||||
}
|
||||
.custom-text ::v-deep > ul {
|
||||
@apply list-disc list-inside pb-4;
|
||||
}
|
||||
</style>
|
||||
@@ -1,34 +1,34 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="collections" :processing="processing" :width="500" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div v-if="show" class="w-full h-full">
|
||||
<div class="py-4 px-4">
|
||||
<h1 v-if="!showBatchUserCollectionModal" class="text-2xl">Add to Collection</h1>
|
||||
<h1 v-else class="text-2xl">Add {{ selectedBookIds.length }} Books to Collection</h1>
|
||||
<h1 v-if="!showBatchCollectionModal" class="text-2xl">{{ $strings.LabelAddToCollection }}</h1>
|
||||
<h1 v-else class="text-2xl">{{ $getString('LabelAddToCollectionBatch', [selectedBookIds.length]) }}</h1>
|
||||
</div>
|
||||
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
|
||||
<transition-group name="list-complete" tag="div">
|
||||
<template v-for="collection in sortedCollections">
|
||||
<modals-collections-user-collection-item :key="collection.id" :collection="collection" class="list-complete-item" @add="addToCollection" @remove="removeFromCollection" @close="show = false" />
|
||||
<modals-collections-collection-item :key="collection.id" :collection="collection" :book-cover-aspect-ratio="bookCoverAspectRatio" class="list-complete-item" @add="addToCollection" @remove="removeFromCollection" @close="show = false" />
|
||||
</template>
|
||||
</transition-group>
|
||||
</div>
|
||||
<div v-if="!collections.length" class="flex h-32 items-center justify-center">
|
||||
<p class="text-xl">No Collections</p>
|
||||
<p class="text-xl">{{ $strings.MessageNoCollections }}</p>
|
||||
</div>
|
||||
<div class="w-full h-px bg-white bg-opacity-10" />
|
||||
<form @submit.prevent="submitCreateCollection">
|
||||
<div class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
|
||||
<div class="flex-grow px-2">
|
||||
<ui-text-input v-model="newCollectionName" placeholder="New Collection" class="w-full" />
|
||||
<ui-text-input v-model="newCollectionName" :placeholder="$strings.PlaceholderNewCollection" class="w-full" />
|
||||
</div>
|
||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10">Create</ui-btn>
|
||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10">{{ $strings.ButtonCreate }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -50,44 +50,47 @@ export default {
|
||||
this.loadCollections()
|
||||
this.newCollectionName = ''
|
||||
} else {
|
||||
this.$store.commit('setSelectedAudiobook', null)
|
||||
this.$store.commit('setSelectedLibraryItem', null)
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.$store.state.globals.showUserCollectionsModal
|
||||
return this.$store.state.globals.showCollectionsModal
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('globals/setShowUserCollectionsModal', val)
|
||||
this.$store.commit('globals/setShowCollectionsModal', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
if (this.showBatchUserCollectionModal) {
|
||||
return `${this.selectedBookIds.length} Books Selected`
|
||||
if (this.showBatchCollectionModal) {
|
||||
return this.$getString('MessageItemsSelected', [this.selectedBookIds.length])
|
||||
}
|
||||
return this.selectedAudiobook ? this.selectedAudiobook.book.title : ''
|
||||
},
|
||||
selectedAudiobook() {
|
||||
return this.$store.state.selectedAudiobook
|
||||
},
|
||||
selectedAudiobookId() {
|
||||
return this.selectedAudiobook ? this.selectedAudiobook.id : null
|
||||
return this.selectedLibraryItem ? this.selectedLibraryItem.media.metadata.title : ''
|
||||
},
|
||||
collections() {
|
||||
return this.$store.state.user.collections || []
|
||||
return this.$store.state.libraries.collections || []
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
selectedLibraryItem() {
|
||||
return this.$store.state.selectedLibraryItem
|
||||
},
|
||||
selectedLibraryItemId() {
|
||||
return this.selectedLibraryItem ? this.selectedLibraryItem.id : null
|
||||
},
|
||||
sortedCollections() {
|
||||
return this.collections
|
||||
.map((c) => {
|
||||
var includesBook = false
|
||||
if (this.showBatchUserCollectionModal) {
|
||||
if (this.showBatchCollectionModal) {
|
||||
// Only show collection added if all books are in the collection
|
||||
var collectionBookIds = c.books.map((b) => b.id)
|
||||
includesBook = !this.selectedBookIds.find((id) => !collectionBookIds.includes(id))
|
||||
} else {
|
||||
includesBook = !!c.books.find((b) => b.id === this.selectedAudiobookId)
|
||||
includesBook = !!c.books.find((b) => b.id === this.selectedLibraryItemId)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -97,11 +100,11 @@ export default {
|
||||
})
|
||||
.sort((a, b) => (a.isBookIncluded ? -1 : 1))
|
||||
},
|
||||
showBatchUserCollectionModal() {
|
||||
return this.$store.state.globals.showBatchUserCollectionModal
|
||||
showBatchCollectionModal() {
|
||||
return this.$store.state.globals.showBatchCollectionModal
|
||||
},
|
||||
selectedBookIds() {
|
||||
return this.$store.state.selectedAudiobooks || []
|
||||
return (this.$store.state.globals.selectedMediaItems || []).map((i) => i.id)
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
@@ -109,47 +112,61 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
loadCollections() {
|
||||
this.$store.dispatch('user/loadUserCollections')
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/collections`)
|
||||
.then((data) => {
|
||||
if (data.results) {
|
||||
this.$store.commit('libraries/setCollections', data.results || [])
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to get collections', error)
|
||||
this.$toast.error('Failed to load collections')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
removeFromCollection(collection) {
|
||||
if (!this.selectedAudiobookId && !this.selectedBookIds.length) return
|
||||
if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return
|
||||
this.processing = true
|
||||
|
||||
if (this.showBatchUserCollectionModal) {
|
||||
if (this.showBatchCollectionModal) {
|
||||
// BATCH Remove books
|
||||
this.$axios
|
||||
.$post(`/api/collections/${collection.id}/batch/remove`, { books: this.selectedBookIds })
|
||||
.then((updatedCollection) => {
|
||||
console.log(`Books removed from collection`, updatedCollection)
|
||||
this.$toast.success('Books removed from collection')
|
||||
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove books from collection', error)
|
||||
this.$toast.error('Failed to remove books from collection')
|
||||
this.$toast.error(this.$strings.ToastCollectionItemsRemoveFailed)
|
||||
this.processing = false
|
||||
})
|
||||
} else {
|
||||
// Remove single book
|
||||
this.$axios
|
||||
.$delete(`/api/collections/${collection.id}/book/${this.selectedAudiobookId}`)
|
||||
.$delete(`/api/collections/${collection.id}/book/${this.selectedLibraryItemId}`)
|
||||
.then((updatedCollection) => {
|
||||
console.log(`Book removed from collection`, updatedCollection)
|
||||
this.$toast.success('Book removed from collection')
|
||||
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove book from collection', error)
|
||||
this.$toast.error('Failed to remove book from collection')
|
||||
this.$toast.error(this.$strings.ToastCollectionItemsRemoveFailed)
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
addToCollection(collection) {
|
||||
if (!this.selectedAudiobookId && !this.selectedBookIds.length) return
|
||||
if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return
|
||||
this.processing = true
|
||||
|
||||
if (this.showBatchUserCollectionModal) {
|
||||
if (this.showBatchCollectionModal) {
|
||||
// BATCH Remove books
|
||||
this.$axios
|
||||
.$post(`/api/collections/${collection.id}/batch/add`, { books: this.selectedBookIds })
|
||||
@@ -164,10 +181,10 @@ export default {
|
||||
this.processing = false
|
||||
})
|
||||
} else {
|
||||
if (!this.selectedAudiobookId) return
|
||||
if (!this.selectedLibraryItemId) return
|
||||
|
||||
this.$axios
|
||||
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedAudiobookId })
|
||||
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId })
|
||||
.then((updatedCollection) => {
|
||||
console.log(`Book added to collection`, updatedCollection)
|
||||
this.$toast.success('Book added to collection')
|
||||
@@ -181,12 +198,12 @@ export default {
|
||||
}
|
||||
},
|
||||
submitCreateCollection() {
|
||||
if (!this.newCollectionName || (!this.selectedAudiobookId && !this.selectedBookIds.length)) {
|
||||
if (!this.newCollectionName || (!this.selectedLibraryItemId && !this.selectedBookIds.length)) {
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
|
||||
var books = this.showBatchUserCollectionModal ? this.selectedBookIds : [this.selectedAudiobookId]
|
||||
var books = this.showBatchCollectionModal ? this.selectedBookIds : [this.selectedLibraryItemId]
|
||||
var newCollection = {
|
||||
books: books,
|
||||
libraryId: this.currentLibraryId,
|
||||
@@ -212,19 +229,3 @@ export default {
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.list-complete-item {
|
||||
transition: all 0.8s ease;
|
||||
}
|
||||
|
||||
.list-complete-enter-from,
|
||||
.list-complete-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
.list-complete-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
58
client/components/modals/collections/CollectionItem.vue
Normal file
58
client/components/modals/collections/CollectionItem.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="flex items-center px-4 py-2 justify-start relative hover:bg-bg" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div v-if="isBookIncluded" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
|
||||
<div class="w-20 max-w-20 text-center">
|
||||
<covers-collection-cover :book-items="books" :width="80" :height="40 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div class="flex-grow overflow-hidden px-2">
|
||||
<nuxt-link :to="`/collection/${collection.id}`" class="pl-2 pr-2 truncate hover:underline cursor-pointer" @click.native="clickNuxtLink">{{ collection.name }}</nuxt-link>
|
||||
</div>
|
||||
<div class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
|
||||
<ui-btn v-if="!isBookIncluded" color="success" :padding-x="3" small class="h-9" @click.stop="clickAdd"><span class="material-icons text-2xl pt-px">add</span></ui-btn>
|
||||
<ui-btn v-else color="error" :padding-x="3" class="h-9" small @click.stop="clickRem"><span class="material-icons text-2xl pt-px">remove</span></ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
collection: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
bookCoverAspectRatio: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isBookIncluded() {
|
||||
return !!this.collection.isBookIncluded
|
||||
},
|
||||
books() {
|
||||
return this.collection.books || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickNuxtLink() {
|
||||
this.$emit('close')
|
||||
},
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
},
|
||||
clickAdd() {
|
||||
this.$emit('add', this.collection)
|
||||
},
|
||||
clickRem() {
|
||||
this.$emit('remove', this.collection)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -2,27 +2,26 @@
|
||||
<modals-modal v-model="show" name="edit-collection" :width="700" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">Collection</p>
|
||||
<p class="text-3xl text-white truncate">{{ $strings.HeaderCollection }}</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">
|
||||
<template v-if="!showImageUploader">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="flex">
|
||||
<div>
|
||||
<div class="flex flex-wrap">
|
||||
<div class="w-full flex justify-center mb-2 md:w-auto md:mb-0 md:block">
|
||||
<covers-collection-cover :book-items="books" :width="200" :height="100 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<!-- <ui-btn type="button" @click="showImageUploader = true">Upload</ui-btn> -->
|
||||
</div>
|
||||
<div class="flex-grow px-4">
|
||||
<ui-text-input-with-label v-model="newCollectionName" label="Name" class="mb-2" />
|
||||
<ui-text-input-with-label v-model="newCollectionName" :label="$strings.LabelName" class="mb-2" />
|
||||
|
||||
<ui-textarea-with-label v-model="newCollectionDescription" label="Description" />
|
||||
<ui-textarea-with-label v-model="newCollectionDescription" :label="$strings.LabelDescription" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 right-0 w-full py-2 px-4 flex">
|
||||
<ui-btn small color="error" type="button" @click.stop="removeClick">Remove</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 color="success" type="submit">Save</ui-btn>
|
||||
<ui-btn color="success" type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -41,7 +40,6 @@
|
||||
<ui-btn color="success">Upload</ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
<!-- <modals-upload-image-modal v-model="showUploadImageModal" entity="collection" :entity-id="collection.id" /> -->
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
@@ -75,7 +73,7 @@ export default {
|
||||
}
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
collection() {
|
||||
return this.$store.state.globals.selectedCollection || {}
|
||||
@@ -85,6 +83,9 @@ export default {
|
||||
},
|
||||
books() {
|
||||
return this.collection.books || []
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -93,20 +94,19 @@ export default {
|
||||
this.newCollectionDescription = this.collection.description || ''
|
||||
},
|
||||
removeClick() {
|
||||
if (confirm(`Are you sure you want to remove collection "${this.collectionName}"?`)) {
|
||||
if (confirm(this.$getString('MessageConfirmRemoveCollection', [this.collectionName]))) {
|
||||
this.processing = true
|
||||
var collectionName = this.collectionName
|
||||
this.$axios
|
||||
.$delete(`/api/collections/${this.collection.id}`)
|
||||
.then(() => {
|
||||
this.processing = false
|
||||
this.show = false
|
||||
this.$toast.success(`Collection "${collectionName}" Removed`)
|
||||
this.$toast.success(this.$strings.ToastCollectionRemoveSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove collection', error)
|
||||
this.processing = false
|
||||
this.$toast.error(`Failed to remove collection`)
|
||||
this.$toast.error(this.$strings.ToastCollectionRemoveFailed)
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -130,12 +130,12 @@ export default {
|
||||
console.log('Collection Updated', collection)
|
||||
this.processing = false
|
||||
this.show = false
|
||||
this.$toast.success(`Collection "${collection.name}" Updated`)
|
||||
this.$toast.success(this.$strings.ToastCollectionUpdateSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update collection', error)
|
||||
this.processing = false
|
||||
this.$toast.error(`Failed to update collection`)
|
||||
this.$toast.error(this.$strings.ToastCollectionUpdateFailed)
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -1,94 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center px-4 py-2 justify-start relative hover:bg-bg" :class="wrapperClass" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div v-if="isBookIncluded" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
|
||||
<!-- <span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-80'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> -->
|
||||
<div class="w-20 max-w-20 text-center">
|
||||
<!-- <img src="/Logo.png" /> -->
|
||||
<covers-collection-cover :book-items="books" :width="80" :height="40 * 1.6" />
|
||||
</div>
|
||||
<div class="flex-grow overflow-hidden px-2">
|
||||
<!-- <template v-if="isEditing">
|
||||
<form @submit.prevent="submitUpdate">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-grow pr-2">
|
||||
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
|
||||
</div>
|
||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons -mt-px">forward</span></ui-btn>
|
||||
<div class="pl-2 flex items-center">
|
||||
<span class="material-icons text-3xl text-white text-opacity-70 hover:text-opacity-95 cursor-pointer" @click.stop.prevent="cancelEditing">close</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template> -->
|
||||
<nuxt-link :to="`/collection/${collection.id}`" class="pl-2 pr-2 truncate hover:underline cursor-pointer" @click.native="clickNuxtLink">{{ collection.name }}</nuxt-link>
|
||||
</div>
|
||||
<div v-if="!isEditing" class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
|
||||
<ui-btn v-if="!isBookIncluded" color="success" :padding-x="3" small class="h-9" @click.stop="clickAdd"><span class="material-icons pt-px">add</span></ui-btn>
|
||||
<ui-btn v-else color="error" :padding-x="3" class="h-9" small @click.stop="clickRem"><span class="material-icons pt-px">remove</span></ui-btn>
|
||||
<!-- <span class="material-icons text-xl mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
|
||||
<span class="material-icons text-xl text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span> -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
collection: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
highlight: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isHovering: false,
|
||||
isEditing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isBookIncluded() {
|
||||
return !!this.collection.isBookIncluded
|
||||
},
|
||||
wrapperClass() {
|
||||
var classes = []
|
||||
if (this.highlight) classes.push('bg-bg bg-opacity-60')
|
||||
if (!this.isEditing) classes.push('cursor-pointer')
|
||||
return classes.join(' ')
|
||||
},
|
||||
books() {
|
||||
return this.collection.books || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickNuxtLink() {
|
||||
this.$emit('close')
|
||||
},
|
||||
mouseover() {
|
||||
if (this.isEditing) return
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
},
|
||||
clickAdd() {
|
||||
this.$emit('add', this.collection)
|
||||
},
|
||||
clickRem() {
|
||||
this.$emit('remove', this.collection)
|
||||
},
|
||||
deleteClick() {
|
||||
if (this.isEditing) return
|
||||
this.$emit('delete', this.collection)
|
||||
},
|
||||
editClick() {
|
||||
this.isEditing = true
|
||||
this.isHovering = false
|
||||
},
|
||||
cancelEditing() {
|
||||
this.isEditing = false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,201 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
|
||||
<template v-for="(authorName, index) in searchAuthors">
|
||||
<cards-search-author-card :key="index" :author-name="authorName" @match="setSelectedMatch" />
|
||||
</template>
|
||||
|
||||
<div v-show="processing" class="flex h-full items-center justify-center">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="flex mb-2">
|
||||
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="text-xl pl-3">Update Author Details</p>
|
||||
</div>
|
||||
<form @submit.prevent="submitMatchUpdate">
|
||||
<div v-if="selectedMatch.image" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.image" />
|
||||
<img :src="selectedMatch.image" class="w-24 object-contain ml-4" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.image" :disabled="!selectedMatchUsage.image" label="Image" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.name" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.name" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.name" :disabled="!selectedMatchUsage.name" label="Name" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.description" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.description" />
|
||||
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end py-2">
|
||||
<ui-btn color="success" type="submit">Update</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchAuthors: [],
|
||||
audiobookId: null,
|
||||
searchAuthor: null,
|
||||
lastSearch: null,
|
||||
hasSearched: false,
|
||||
selectedMatch: null,
|
||||
|
||||
selectedMatchUsage: {
|
||||
image: true,
|
||||
name: true,
|
||||
description: true
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
audiobook: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
return this.processing
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// getSearchQuery() {
|
||||
// return `q=${this.searchAuthor}`
|
||||
// },
|
||||
// submitSearch() {
|
||||
// if (!this.searchTitle) {
|
||||
// this.$toast.warning('Search title is required')
|
||||
// return
|
||||
// }
|
||||
// this.runSearch()
|
||||
// },
|
||||
// async runSearch() {
|
||||
// var searchQuery = this.getSearchQuery()
|
||||
// if (this.lastSearch === searchQuery) return
|
||||
// this.selectedMatch = null
|
||||
// this.isProcessing = true
|
||||
// this.lastSearch = searchQuery
|
||||
// var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => {
|
||||
// console.error('Failed', error)
|
||||
// return []
|
||||
// })
|
||||
// if (result) {
|
||||
// this.selectedMatch = result
|
||||
// }
|
||||
// this.isProcessing = false
|
||||
// this.hasSearched = true
|
||||
// },
|
||||
init() {
|
||||
this.selectedMatch = null
|
||||
// this.selectedMatchUsage = {
|
||||
// title: true,
|
||||
// subtitle: true,
|
||||
// cover: true,
|
||||
// author: true,
|
||||
// description: true,
|
||||
// isbn: true,
|
||||
// publisher: true,
|
||||
// publishYear: true
|
||||
// }
|
||||
|
||||
if (this.audiobook.id !== this.audiobookId) {
|
||||
this.selectedMatch = null
|
||||
this.hasSearched = false
|
||||
this.audiobookId = this.audiobook.id
|
||||
}
|
||||
|
||||
if (!this.audiobook.book || !this.audiobook.book.authorFL) {
|
||||
this.searchAuthors = []
|
||||
return
|
||||
}
|
||||
this.searchAuthors = (this.audiobook.book.authorFL || '').split(', ')
|
||||
},
|
||||
selectMatch(match) {
|
||||
this.selectedMatch = match
|
||||
},
|
||||
buildMatchUpdatePayload() {
|
||||
var updatePayload = {}
|
||||
for (const key in this.selectedMatchUsage) {
|
||||
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
||||
updatePayload[key] = this.selectedMatch[key]
|
||||
}
|
||||
}
|
||||
return updatePayload
|
||||
},
|
||||
async submitMatchUpdate() {
|
||||
var updatePayload = this.buildMatchUpdatePayload()
|
||||
if (!Object.keys(updatePayload).length) {
|
||||
return
|
||||
}
|
||||
this.isProcessing = true
|
||||
|
||||
if (updatePayload.cover) {
|
||||
var coverPayload = {
|
||||
url: updatePayload.cover
|
||||
}
|
||||
var success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, coverPayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
if (success) {
|
||||
this.$toast.success('Book Cover Updated')
|
||||
} else {
|
||||
this.$toast.error('Book Cover Failed to Update')
|
||||
}
|
||||
console.log('Updated cover')
|
||||
delete updatePayload.cover
|
||||
}
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
var bookUpdatePayload = {
|
||||
book: updatePayload
|
||||
}
|
||||
var success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, bookUpdatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
if (success) {
|
||||
this.$toast.success('Book Details Updated')
|
||||
this.selectedMatch = null
|
||||
this.$emit('selectTab', 'details')
|
||||
} else {
|
||||
this.$toast.error('Book Details Failed to Update')
|
||||
}
|
||||
} else {
|
||||
this.selectedMatch = null
|
||||
}
|
||||
this.isProcessing = false
|
||||
},
|
||||
setSelectedMatch(authorMatchObj) {
|
||||
this.selectedMatch = authorMatchObj
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.matchListWrapper {
|
||||
height: calc(100% - 80px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,59 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
|
||||
<table v-else class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
||||
<th class="text-left">Title</th>
|
||||
<th class="text-center">Start</th>
|
||||
<th class="text-center">End</th>
|
||||
</tr>
|
||||
<template v-for="chapter in chapters">
|
||||
<tr :key="chapter.id">
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ chapter.id }}</p>
|
||||
</td>
|
||||
<td class="font-book">
|
||||
{{ chapter.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.end) }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
chapters: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
audiobook: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
init() {
|
||||
this.chapters = this.audiobook.chapters || []
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,342 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<form class="w-full h-full" @submit.prevent="submitForm">
|
||||
<div ref="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
|
||||
<div class="flex -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label v-model="details.title" label="Title" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="details.subtitle" label="Subtitle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<ui-text-input-with-label v-model="details.author" label="Author" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<ui-input-dropdown ref="seriesDropdown" v-model="details.series" label="Series" :items="series" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/3 px-1">
|
||||
<ui-text-input-with-label v-model="details.narrator" label="Narrator" />
|
||||
</div>
|
||||
<div class="w-1/3 px-1">
|
||||
<ui-text-input-with-label v-model="details.publisher" label="Publisher" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="details.language" label="Language" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/3 px-1">
|
||||
<ui-text-input-with-label v-model="details.isbn" label="ISBN" />
|
||||
</div>
|
||||
<div class="w-1/3 px-1">
|
||||
<ui-text-input-with-label v-model="details.asin" label="ASIN" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
|
||||
<div class="flex items-center px-4">
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-tooltip v-if="!isMissing" text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="mr-4 hidden sm:block">
|
||||
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
|
||||
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
|
||||
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-btn type="submit">Submit</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
details: {
|
||||
title: null,
|
||||
subtitle: null,
|
||||
description: null,
|
||||
author: null,
|
||||
narrator: null,
|
||||
series: null,
|
||||
volumeNumber: null,
|
||||
publishYear: null,
|
||||
publisher: null,
|
||||
language: null,
|
||||
isbn: null,
|
||||
asin: null,
|
||||
genres: []
|
||||
},
|
||||
newTags: [],
|
||||
resettingProgress: false,
|
||||
isScrollable: false,
|
||||
savingMetadata: false,
|
||||
rescanning: false,
|
||||
quickMatching: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
audiobook: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
return this.processing
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
isMissing() {
|
||||
return !!this.audiobook && !!this.audiobook.isMissing
|
||||
},
|
||||
audiobookId() {
|
||||
return this.audiobook ? this.audiobook.id : null
|
||||
},
|
||||
book() {
|
||||
return this.audiobook ? this.audiobook.book || {} : {}
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
genres() {
|
||||
return this.filterData.genres || []
|
||||
},
|
||||
tags() {
|
||||
return this.filterData.tags || []
|
||||
},
|
||||
series() {
|
||||
return this.filterData.series || []
|
||||
},
|
||||
filterData() {
|
||||
return this.$store.state.libraries.filterData || {}
|
||||
},
|
||||
libraryId() {
|
||||
return this.audiobook ? this.audiobook.libraryId : null
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
|
||||
},
|
||||
libraryScan() {
|
||||
if (!this.libraryId) return null
|
||||
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
quickMatch() {
|
||||
this.quickMatching = true
|
||||
var matchOptions = {
|
||||
provider: this.libraryProvider,
|
||||
title: this.details.title,
|
||||
author: this.details.author !== this.book.author ? this.details.author : null
|
||||
}
|
||||
this.$axios
|
||||
.$post(`/api/books/${this.audiobookId}/match`, matchOptions)
|
||||
.then((res) => {
|
||||
this.quickMatching = false
|
||||
if (res.warning) {
|
||||
this.$toast.warning(res.warning)
|
||||
} else if (res.updated) {
|
||||
this.$toast.success('Audiobook details updated')
|
||||
} else {
|
||||
this.$toast.info('No updates were made')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
console.error('Failed to match', error)
|
||||
this.$toast.error(errMsg || 'Failed to match')
|
||||
this.quickMatching = false
|
||||
})
|
||||
},
|
||||
audiobookScanComplete(result) {
|
||||
this.rescanning = false
|
||||
if (!result) {
|
||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||
} else if (result === 'UPDATED') {
|
||||
this.$toast.success(`Re-Scan complete audiobook was updated`)
|
||||
} else if (result === 'UPTODATE') {
|
||||
this.$toast.success(`Re-Scan complete audiobook was up to date`)
|
||||
} else if (result === 'REMOVED') {
|
||||
this.$toast.error(`Re-Scan complete audiobook was removed`)
|
||||
}
|
||||
},
|
||||
rescan() {
|
||||
this.rescanning = true
|
||||
this.$root.socket.once('audiobook_scan_complete', this.audiobookScanComplete)
|
||||
this.$root.socket.emit('scan_audiobook', this.audiobookId)
|
||||
},
|
||||
saveMetadataComplete(result) {
|
||||
this.savingMetadata = false
|
||||
if (result.error) {
|
||||
this.$toast.error(result.error)
|
||||
} else if (result.audiobookId) {
|
||||
var { savedPath } = result
|
||||
if (!savedPath) {
|
||||
this.$toast.error(`Failed to save metadata file (${result.audiobookId})`)
|
||||
} else {
|
||||
this.$toast.success(`Metadata file saved "${result.audiobookTitle}"`)
|
||||
}
|
||||
}
|
||||
},
|
||||
saveMetadata() {
|
||||
this.savingMetadata = true
|
||||
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
|
||||
this.$root.socket.emit('save_metadata', this.audiobookId)
|
||||
},
|
||||
submitForm() {
|
||||
if (this.isProcessing) {
|
||||
return
|
||||
}
|
||||
this.isProcessing = true
|
||||
if (this.$refs.seriesDropdown && this.$refs.seriesDropdown.isFocused) {
|
||||
this.$refs.seriesDropdown.blur()
|
||||
}
|
||||
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
|
||||
this.$refs.genresSelect.forceBlur()
|
||||
}
|
||||
if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {
|
||||
this.$refs.tagsSelect.forceBlur()
|
||||
}
|
||||
this.$nextTick(this.handleForm)
|
||||
},
|
||||
async handleForm() {
|
||||
const updatePayload = {
|
||||
book: this.details,
|
||||
tags: this.newTags
|
||||
}
|
||||
|
||||
var updatedAudiobook = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (updatedAudiobook) {
|
||||
this.$toast.success('Update Successful')
|
||||
this.$emit('close')
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.details.title = this.book.title
|
||||
this.details.subtitle = this.book.subtitle
|
||||
this.details.description = this.book.description
|
||||
this.details.author = this.book.author
|
||||
this.details.narrator = this.book.narrator
|
||||
this.details.genres = this.book.genres || []
|
||||
this.details.series = this.book.series
|
||||
this.details.volumeNumber = this.book.volumeNumber
|
||||
this.details.publishYear = this.book.publishYear
|
||||
this.details.publisher = this.book.publisher || null
|
||||
this.details.language = this.book.language || null
|
||||
this.details.isbn = this.book.isbn || null
|
||||
this.details.asin = this.book.asin || null
|
||||
|
||||
this.newTags = this.audiobook.tags || []
|
||||
},
|
||||
deleteAudiobook() {
|
||||
if (confirm(`Are you sure you want to remove this audiobook?\n\n*Does not delete your files, only removes the audiobook from AudioBookshelf`)) {
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$delete(`/api/books/${this.audiobookId}`)
|
||||
.then(() => {
|
||||
console.log('Audiobook removed')
|
||||
this.$toast.success('Audiobook Removed')
|
||||
this.$emit('close')
|
||||
this.isProcessing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Remove Audiobook failed', error)
|
||||
this.isProcessing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
checkIsScrollable() {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.formWrapper) {
|
||||
if (this.$refs.formWrapper.scrollHeight > this.$refs.formWrapper.clientHeight) {
|
||||
this.isScrollable = true
|
||||
} else {
|
||||
this.isScrollable = false
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
setResizeObserver() {
|
||||
try {
|
||||
this.$nextTick(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
this.checkIsScrollable()
|
||||
})
|
||||
resizeObserver.observe(this.$refs.formWrapper)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to set resize observer')
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setResizeObserver()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.details-form-wrapper {
|
||||
height: calc(100% - 70px);
|
||||
max-height: calc(100% - 70px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,215 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||
<p class="text-center text-lg mb-4 py-8">Preparing downloads can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted.<br />Download will timeout after 15 minutes.</p>
|
||||
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-4">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
|
||||
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||
|
||||
<ui-btn v-if="singleDownloadStatus !== $constants.DownloadStatus.READY" :loading="singleDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
|
||||
<div v-else>
|
||||
<ui-btn @click="downloadWithProgress(singleAudioDownload)">Download</ui-btn>
|
||||
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(singleAudioDownload.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full border border-black-200 p-4 my-4">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p v-if="totalFiles > 1" class="text-lg">Zip {{ totalFiles }} Files</p>
|
||||
<p v-else>Zip 1 File</p>
|
||||
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .ZIP file from the contents of the audiobook directory.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<p v-if="zipDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
||||
<p v-if="zipDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||
<p v-if="zipDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||
|
||||
<ui-btn v-if="zipDownloadStatus !== $constants.DownloadStatus.READY" :loading="zipDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startZipDownload">Start Download</ui-btn>
|
||||
<div v-else>
|
||||
<ui-btn @click="downloadWithProgress(zipDownload)">Download</ui-btn>
|
||||
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(zipDownload.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showM4bDownload" class="w-full flex items-center justify-center absolute bottom-4 left-0 right-0 text-center">
|
||||
<p class="text-error text-lg">* <strong>Experimental:</strong> Merging multiple .m4b files may have issues. <a href="https://github.com/advplyr/audiobookshelf/issues" class="underline text-blue-600" target="_blank">Report issues here.</a></p>
|
||||
</div>
|
||||
|
||||
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<p class="text-lg">Download.... {{ downloadPercent }}%</p>
|
||||
<p class="w-24 font-mono pl-8 text-right">
|
||||
{{ downloadAmount }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tempDisable: false,
|
||||
isDownloading: false,
|
||||
downloadPercent: '0',
|
||||
downloadAmount: '0 KB'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
singleDownloadStatus(newVal) {
|
||||
if (newVal) {
|
||||
this.tempDisable = false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
audiobookId() {
|
||||
return this.audiobook ? this.audiobook.id : null
|
||||
},
|
||||
_audiobook() {
|
||||
return this.audiobook || {}
|
||||
},
|
||||
downloads() {
|
||||
return this.$store.getters['downloads/getDownloads'](this.audiobookId)
|
||||
},
|
||||
singleAudioDownload() {
|
||||
return this.downloads.find((d) => d.type === 'singleAudio')
|
||||
},
|
||||
singleDownloadStatus() {
|
||||
return this.singleAudioDownload ? this.singleAudioDownload.status : false
|
||||
},
|
||||
zipDownload() {
|
||||
return this.downloads.find((d) => d.type === 'zip')
|
||||
},
|
||||
zipDownloadStatus() {
|
||||
return this.zipDownload ? this.zipDownload.status : false
|
||||
},
|
||||
isSingleTrack() {
|
||||
if (!this.audiobook.tracks) return false
|
||||
return this.audiobook.tracks.length === 1
|
||||
},
|
||||
singleTrackPath() {
|
||||
if (!this.isSingleTrack) return null
|
||||
return this.audiobook.tracks[0].path
|
||||
},
|
||||
audioFiles() {
|
||||
return this.audiobook ? this.audiobook.audioFiles || [] : []
|
||||
},
|
||||
otherFiles() {
|
||||
return this.audiobook ? this.audiobook.otherFiles || [] : []
|
||||
},
|
||||
totalFiles() {
|
||||
return this.audioFiles.length + this.otherFiles.length
|
||||
},
|
||||
showM4bDownload() {
|
||||
return !this._audiobook.isMissing && !this._audiobook.isInvalid && this._audiobook.tracks.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
startZipDownload() {
|
||||
// console.log('Download request received', this.audiobook)
|
||||
|
||||
this.tempDisable = true
|
||||
setTimeout(() => {
|
||||
this.tempDisable = false
|
||||
}, 1000)
|
||||
|
||||
var downloadPayload = {
|
||||
audiobookId: this.audiobook.id,
|
||||
type: 'zip'
|
||||
}
|
||||
this.$root.socket.emit('download', downloadPayload)
|
||||
},
|
||||
startSingleAudioDownload() {
|
||||
// console.log('Download request received', this.audiobook)
|
||||
|
||||
this.tempDisable = true
|
||||
setTimeout(() => {
|
||||
this.tempDisable = false
|
||||
}, 1000)
|
||||
|
||||
var downloadPayload = {
|
||||
audiobookId: this.audiobook.id,
|
||||
type: 'singleAudio',
|
||||
includeMetadata: true,
|
||||
includeCover: true
|
||||
}
|
||||
this.$root.socket.emit('download', downloadPayload)
|
||||
},
|
||||
downloadWithProgress(download) {
|
||||
var downloadId = download.id
|
||||
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
|
||||
var filename = download.filename
|
||||
|
||||
this.isDownloading = true
|
||||
|
||||
var request = new XMLHttpRequest()
|
||||
request.responseType = 'blob'
|
||||
request.open('get', downloadUrl, true)
|
||||
request.setRequestHeader('Authorization', `Bearer ${this.$store.getters['user/getToken']}`)
|
||||
request.send()
|
||||
|
||||
request.onreadystatechange = () => {
|
||||
if (request.readyState === 4) {
|
||||
this.isDownloading = false
|
||||
}
|
||||
if (request.readyState == 4 && request.status == 200) {
|
||||
const url = window.URL.createObjectURL(request.response)
|
||||
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
setTimeout(() => {
|
||||
if (anchor) anchor.remove()
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = (err) => {
|
||||
console.error('Download error', err)
|
||||
this.isDownloading = false
|
||||
}
|
||||
|
||||
request.onprogress = (e) => {
|
||||
const percent_complete = Math.floor((e.loaded / e.total) * 100)
|
||||
this.downloadAmount = this.$bytesPretty(e.loaded)
|
||||
this.downloadPercent = percent_complete
|
||||
|
||||
// const duration = (new Date().getTime() - startTime) / 1000
|
||||
// const bps = e.loaded / duration
|
||||
// const kbps = Math.floor(bps / 1024)
|
||||
// const time = (e.total - e.loaded) / bps
|
||||
// const seconds = Math.floor(time % 60)
|
||||
// const minutes = Math.floor(time / 60)
|
||||
// console.log(`${percent_complete}% - ${kbps} Kbps - ${minutes} min ${seconds} sec remaining`)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user