mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-31 11:38:47 -05:00
Compare commits
912 Commits
v2.13.3
...
stringify_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ca12eee19 | ||
|
|
ebdf377fc1 | ||
|
|
808d23561c | ||
|
|
a34813b3ab | ||
|
|
725192fbc0 | ||
|
|
2915c072b5 | ||
|
|
03a1d7da32 | ||
|
|
1be1ce6f87 | ||
|
|
21b27c432c | ||
|
|
cbe5e3db8a | ||
|
|
08b4d4d7a2 | ||
|
|
ac8324e595 | ||
|
|
a14c6a3a8b | ||
|
|
74b35ea9d1 | ||
|
|
78d8c83e6d | ||
|
|
bf795d3662 | ||
|
|
1fbd090441 | ||
|
|
70621e72e8 | ||
|
|
d30a09f503 | ||
|
|
39567c6c22 | ||
|
|
ed3af5bdcd | ||
|
|
9e54b4f7ca | ||
|
|
ec65376569 | ||
|
|
4e8cd6fba0 | ||
|
|
1a3d70d041 | ||
|
|
14e92435ec | ||
|
|
0ccb88904a | ||
|
|
4cc300d6e9 | ||
|
|
068ba84a8c | ||
|
|
36ef675556 | ||
|
|
0dd57a8912 | ||
|
|
ef45f844e5 | ||
|
|
9a261195b7 | ||
|
|
3d08a35aa0 | ||
|
|
a13143245b | ||
|
|
52bb28669a | ||
|
|
25ae6dd59a | ||
|
|
a37fe3c3d2 | ||
|
|
59bcbe0dfa | ||
|
|
b5e69630de | ||
|
|
0bba709124 | ||
|
|
e93bb5cb07 | ||
|
|
3f7af8acfb | ||
|
|
5e5a604d03 | ||
|
|
201e12ecc3 | ||
|
|
24d6e390f0 | ||
|
|
0cf7a6abec | ||
|
|
76ac0d001b | ||
|
|
00343a953b | ||
|
|
82ab95ab02 | ||
|
|
a1d8ebc01b | ||
|
|
eeaae5f934 | ||
|
|
4464276a6e | ||
|
|
3465790fe9 | ||
|
|
5fa4c5a2c3 | ||
|
|
13f353596b | ||
|
|
3d9100e5b8 | ||
|
|
b62309ead2 | ||
|
|
1fce94ad4a | ||
|
|
9abd6698ae | ||
|
|
88c10ad619 | ||
|
|
c62a6fbffd | ||
|
|
989388d3ed | ||
|
|
4cc97a22f6 | ||
|
|
8bd336a4ba | ||
|
|
437c8dd09c | ||
|
|
f82697cbbf | ||
|
|
74c87a0bbd | ||
|
|
35eb5bcfc0 | ||
|
|
0a29b549df | ||
|
|
a38a92b948 | ||
|
|
d245c93da4 | ||
|
|
bcf8f6b732 | ||
|
|
40e11db5e5 | ||
|
|
aebb3ff413 | ||
|
|
a58d486c44 | ||
|
|
4a76ba0226 | ||
|
|
7afff57b5e | ||
|
|
2e13c5bd50 | ||
|
|
344de941ff | ||
|
|
c3aad9486c | ||
|
|
5c0cd98cb3 | ||
|
|
8974c582fc | ||
|
|
5ee6005112 | ||
|
|
6a7469851d | ||
|
|
1d57daa9f9 | ||
|
|
caf2b664f1 | ||
|
|
b3b2bd7772 | ||
|
|
95864705dc | ||
|
|
0fbba3efbd | ||
|
|
575927c101 | ||
|
|
45aaaf9f0b | ||
|
|
51704f41aa | ||
|
|
e701a0a32e | ||
|
|
fbe186a925 | ||
|
|
6ed2b575b0 | ||
|
|
558173e086 | ||
|
|
23067e1818 | ||
|
|
9b4732c207 | ||
|
|
e096da1b4d | ||
|
|
a4d0f95ecc | ||
|
|
922a5039ce | ||
|
|
f258782e2e | ||
|
|
1ea1e60d4b | ||
|
|
7c4bcfb4f9 | ||
|
|
3eefe937d9 | ||
|
|
d4ba8b9d9f | ||
|
|
c735fea8ba | ||
|
|
9e3010681e | ||
|
|
c6f724edff | ||
|
|
358c3a15b5 | ||
|
|
32819860aa | ||
|
|
7dff571fd5 | ||
|
|
36dd96fd87 | ||
|
|
e6244b8676 | ||
|
|
9b561e4367 | ||
|
|
d25b46e9fa | ||
|
|
7a89836c3e | ||
|
|
a9dd67cf75 | ||
|
|
6f2384e4f2 | ||
|
|
254558f7a6 | ||
|
|
a4a7cddcff | ||
|
|
fc116ce1ed | ||
|
|
f77dd6b1ad | ||
|
|
647a722b06 | ||
|
|
6ec33f4bfa | ||
|
|
bb0cc1bb6f | ||
|
|
abb5bd3a2d | ||
|
|
79acc41d16 | ||
|
|
9fbf57bbef | ||
|
|
598a93d224 | ||
|
|
286185329d | ||
|
|
c3c846f82d | ||
|
|
66b90e0841 | ||
|
|
9b21812feb | ||
|
|
e9d8b62858 | ||
|
|
6d5aeaa42f | ||
|
|
3fd9721da6 | ||
|
|
63b2c6a3ea | ||
|
|
1506589ec8 | ||
|
|
035590236b | ||
|
|
eea446e217 | ||
|
|
63dc819728 | ||
|
|
ff537de132 | ||
|
|
56550157d1 | ||
|
|
28681d3783 | ||
|
|
24ce4a208d | ||
|
|
b816c0e7c4 | ||
|
|
a8b92819d1 | ||
|
|
54a4b09592 | ||
|
|
f13283b950 | ||
|
|
78994b3589 | ||
|
|
6745efc4d6 | ||
|
|
bdd8e5bb58 | ||
|
|
6c540ad789 | ||
|
|
64992b3308 | ||
|
|
ea9552e9a9 | ||
|
|
60add37ba0 | ||
|
|
6182764340 | ||
|
|
d8de61437c | ||
|
|
ca5c8a4d41 | ||
|
|
152683ff9c | ||
|
|
0ac92b6dc1 | ||
|
|
831f9ab9e7 | ||
|
|
3a33553aec | ||
|
|
94df14f0cb | ||
|
|
1d1bdb2f00 | ||
|
|
3aa6b358b3 | ||
|
|
6052bb9fda | ||
|
|
76b270ddf6 | ||
|
|
318e57170d | ||
|
|
5294335bca | ||
|
|
68af5933e5 | ||
|
|
bc2d7ff14d | ||
|
|
7d278ebc56 | ||
|
|
47247323cf | ||
|
|
77ad9c8a16 | ||
|
|
58ca26436d | ||
|
|
4a3254d338 | ||
|
|
ebaae98a12 | ||
|
|
4701b3ed0c | ||
|
|
4843be89e7 | ||
|
|
9a2fb49950 | ||
|
|
ecbcc8470b | ||
|
|
32b886a0c3 | ||
|
|
2463c62bbf | ||
|
|
d55faabb6d | ||
|
|
222ce6ca00 | ||
|
|
be5dc6d2ec | ||
|
|
804b446dae | ||
|
|
5897aee3b7 | ||
|
|
1e5e507eb0 | ||
|
|
760af51c5d | ||
|
|
24705ca06a | ||
|
|
56cba44154 | ||
|
|
9360165f6b | ||
|
|
adef6ede12 | ||
|
|
b8afcd1664 | ||
|
|
d8da793bca | ||
|
|
1856d68299 | ||
|
|
89247f1786 | ||
|
|
5995c52ab7 | ||
|
|
07264544ef | ||
|
|
6057930507 | ||
|
|
9bbb23b853 | ||
|
|
e865241258 | ||
|
|
1a67f57551 | ||
|
|
9b5bdc1fdb | ||
|
|
acda776e3e | ||
|
|
8c4a9280ab | ||
|
|
1812282946 | ||
|
|
64e9ac9d8f | ||
|
|
0da9a04d8e | ||
|
|
11178f58bd | ||
|
|
08b2d07f65 | ||
|
|
3c210170b2 | ||
|
|
03d35421b4 | ||
|
|
a176ba53e0 | ||
|
|
e34dff8f30 | ||
|
|
0881ab4bfb | ||
|
|
20c32efd62 | ||
|
|
e2b8127a5b | ||
|
|
90f32cefca | ||
|
|
ab2e661e22 | ||
|
|
a073aedca2 | ||
|
|
b440a22ec9 | ||
|
|
ec695e5f48 | ||
|
|
69ad0bf113 | ||
|
|
88f464398a | ||
|
|
6fce501389 | ||
|
|
559fab0d90 | ||
|
|
69c428802b | ||
|
|
6da631fa4f | ||
|
|
f83b081791 | ||
|
|
a6ce5fdd98 | ||
|
|
0a2e725bd3 | ||
|
|
c07c4a3341 | ||
|
|
422773e745 | ||
|
|
7a298aa6f5 | ||
|
|
41daf557aa | ||
|
|
de5bc63d88 | ||
|
|
5e2282ef76 | ||
|
|
c819afc53b | ||
|
|
37221a0446 | ||
|
|
0f20ed101e | ||
|
|
b0dbccd283 | ||
|
|
7001adb4dd | ||
|
|
9668b49df9 | ||
|
|
02ecf7ccfe | ||
|
|
05ff5f1956 | ||
|
|
1649fb40db | ||
|
|
052e0059ff | ||
|
|
5edd799b3e | ||
|
|
1632d8edee | ||
|
|
e6181196a7 | ||
|
|
bea9d6aff4 | ||
|
|
d410b13c9b | ||
|
|
8286aad7a4 | ||
|
|
ed5960825b | ||
|
|
7fd8178dde | ||
|
|
db17a5c88b | ||
|
|
2ec84edb5e | ||
|
|
0eed38b771 | ||
|
|
977bdbf0bb | ||
|
|
a1ec10bd0d | ||
|
|
57d742b862 | ||
|
|
108eaba022 | ||
|
|
ac159bea72 | ||
|
|
d5ce7b4939 | ||
|
|
e64302f1d4 | ||
|
|
fdbca4feb6 | ||
|
|
f366dfa909 | ||
|
|
5d1a17ffa8 | ||
|
|
0ed4ea9138 | ||
|
|
1e9470b840 | ||
|
|
726a9eaea5 | ||
|
|
6d52f88a96 | ||
|
|
7fae25a726 | ||
|
|
d8823c8b1c | ||
|
|
43d8d9b286 | ||
|
|
4a398f6113 | ||
|
|
69d1744496 | ||
|
|
0357dc90d4 | ||
|
|
6cd874dffc | ||
|
|
6467a92de6 | ||
|
|
63466ec48b | ||
|
|
de7296eaab | ||
|
|
c251f1899d | ||
|
|
f70f21455f | ||
|
|
a6fd0c95b2 | ||
|
|
d205c6f734 | ||
|
|
5e8678f1cc | ||
|
|
12c6f2e9a5 | ||
|
|
5cd14108f9 | ||
|
|
eb853d9f09 | ||
|
|
4787e7fdb5 | ||
|
|
dd0ebdf2d8 | ||
|
|
18dfbdd983 | ||
|
|
fe2ba083be | ||
|
|
de8b0abc3a | ||
|
|
08bbe1ba02 | ||
|
|
87bac1e33b | ||
|
|
e9eeab6fb5 | ||
|
|
235d05eff3 | ||
|
|
f9f8c6d751 | ||
|
|
e175a9c533 | ||
|
|
f9130a138e | ||
|
|
ed17dd9b51 | ||
|
|
0d8d0a650b | ||
|
|
eb505a0be7 | ||
|
|
f3918a47e1 | ||
|
|
c8a05920dd | ||
|
|
e7f7d1a573 | ||
|
|
5201625d38 | ||
|
|
8c4d0c503b | ||
|
|
d3bda898d4 | ||
|
|
86809dcc62 | ||
|
|
9fa00a1904 | ||
|
|
46247ecf78 | ||
|
|
0444829a9f | ||
|
|
754c121168 | ||
|
|
1c2ee09f18 | ||
|
|
ee310d967e | ||
|
|
25b7f005c6 | ||
|
|
777c59458d | ||
|
|
9785bc02ea | ||
|
|
6780ef9b37 | ||
|
|
88a0e75576 | ||
|
|
476933a144 | ||
|
|
2464aac2bf | ||
|
|
b6b786e3a6 | ||
|
|
bacb8aeac7 | ||
|
|
ba9277cc44 | ||
|
|
3cc5fae586 | ||
|
|
da7d9c10ad | ||
|
|
aa82439125 | ||
|
|
2e0156d9fa | ||
|
|
20e0172fa3 | ||
|
|
6928f6eeb6 | ||
|
|
4cdc2a8c28 | ||
|
|
e0c674d9a9 | ||
|
|
d7830f4bfc | ||
|
|
727310ab75 | ||
|
|
f46b5a533c | ||
|
|
f3e9cfbe45 | ||
|
|
4d8501c347 | ||
|
|
b4e8f16174 | ||
|
|
7073f17cca | ||
|
|
e1c41e4e58 | ||
|
|
13f73cc79d | ||
|
|
d811ec3806 | ||
|
|
e8505cb637 | ||
|
|
94fdd99ab5 | ||
|
|
331c7c011c | ||
|
|
5fa263023f | ||
|
|
7eb315a371 | ||
|
|
780c0dcb99 | ||
|
|
004210ee02 | ||
|
|
921880445a | ||
|
|
0099ae633a | ||
|
|
91d99deba1 | ||
|
|
e21cbc9ff4 | ||
|
|
600c1e4668 | ||
|
|
aea2951b89 | ||
|
|
71b943f434 | ||
|
|
4d2241769e | ||
|
|
ed0484a8e1 | ||
|
|
5302f3225b | ||
|
|
a94a7b7940 | ||
|
|
4318f64d60 | ||
|
|
26a6618e8f | ||
|
|
c242e9d3d6 | ||
|
|
4ecb22f70d | ||
|
|
547a49e95b | ||
|
|
b6875af148 | ||
|
|
c652b5bf74 | ||
|
|
eb0b92a547 | ||
|
|
b56bcbb802 | ||
|
|
3b8af95211 | ||
|
|
a3332f0478 | ||
|
|
46421d5f2c | ||
|
|
7db28d0e98 | ||
|
|
31d26929af | ||
|
|
086da5f6a1 | ||
|
|
09421a44e2 | ||
|
|
fde51da479 | ||
|
|
f3536dc3a3 | ||
|
|
a0c93e5dec | ||
|
|
63aa6aa950 | ||
|
|
680099cab4 | ||
|
|
66f3f3eddf | ||
|
|
a400c149a6 | ||
|
|
244b5ab36d | ||
|
|
f26747627e | ||
|
|
f57a07c483 | ||
|
|
080b879d8a | ||
|
|
63b3f22504 | ||
|
|
91f17efd5f | ||
|
|
858d697d0f | ||
|
|
ba55413e63 | ||
|
|
6cef1e3f12 | ||
|
|
b39268ccb0 | ||
|
|
de8a9304d2 | ||
|
|
f8fbd3ac8c | ||
|
|
369c05936b | ||
|
|
837a180dc1 | ||
|
|
302b651e7b | ||
|
|
4c68ad46f4 | ||
|
|
e50bd93958 | ||
|
|
d576625cb7 | ||
|
|
ca2327aba3 | ||
|
|
9bd1f9e3d5 | ||
|
|
c4610e6102 | ||
|
|
329bbea043 | ||
|
|
e616b53877 | ||
|
|
eab86f90a8 | ||
|
|
f97389cb2b | ||
|
|
c5c3aab130 | ||
|
|
4610e58337 | ||
|
|
190a1000d9 | ||
|
|
455b96d1ab | ||
|
|
8aaf62f243 | ||
|
|
e6d754113e | ||
|
|
5f72e30e63 | ||
|
|
57906540fe | ||
|
|
726adbb3bf | ||
|
|
f7b7b85673 | ||
|
|
5646466aa3 | ||
|
|
b38ce41731 | ||
|
|
a8ab8badd5 | ||
|
|
61729881cb | ||
|
|
5eca43082e | ||
|
|
6fa11934be | ||
|
|
ff7edc32a1 | ||
|
|
9b8e059efe | ||
|
|
7486d6345d | ||
|
|
835490a9fc | ||
|
|
3b4a5b8785 | ||
|
|
9a1c773b7a | ||
|
|
890b0b949e | ||
|
|
b19e360bbb | ||
|
|
1ff7952074 | ||
|
|
259d93d882 | ||
|
|
14f60a593b | ||
|
|
7334580c8c | ||
|
|
f467c44543 | ||
|
|
867354e59d | ||
|
|
67952cc577 | ||
|
|
079a15541c | ||
|
|
658ac04268 | ||
|
|
cbee6d8f5e | ||
|
|
68413ae2f6 | ||
|
|
252a233282 | ||
|
|
c35185fff7 | ||
|
|
9774b2cfa5 | ||
|
|
344890fb45 | ||
|
|
5fa0897ad7 | ||
|
|
95c80a5b18 | ||
|
|
0f1b64b883 | ||
|
|
615ed26f0f | ||
|
|
84803cef82 | ||
|
|
605bd73c11 | ||
|
|
cc89db059b | ||
|
|
a03146e09c | ||
|
|
33aa4f1952 | ||
|
|
c03f18b90a | ||
|
|
0dedb09a07 | ||
|
|
2b5484243b | ||
|
|
c496db7c95 | ||
|
|
ea4d5ff665 | ||
|
|
468a547864 | ||
|
|
cd9999d192 | ||
|
|
31e302ea59 | ||
|
|
1ff1ba66fd | ||
|
|
a5457d7e22 | ||
|
|
ddcbfd4500 | ||
|
|
293e530297 | ||
|
|
7278ad4ee7 | ||
|
|
0449fb5ef9 | ||
|
|
d2c28fc69c | ||
|
|
60ba0163af | ||
|
|
02ca926d88 | ||
|
|
4b52f31d58 | ||
|
|
9917f2d358 | ||
|
|
8c3ba67583 | ||
|
|
6d8720b404 | ||
|
|
843dd0b1b2 | ||
|
|
70f466d03c | ||
|
|
ef82e8b0d0 | ||
|
|
c643d4cec8 | ||
|
|
718d8b5999 | ||
|
|
2ba0f9157d | ||
|
|
53fdb5273c | ||
|
|
fabdfd5517 | ||
|
|
950993f652 | ||
|
|
5a968b002a | ||
|
|
3acd29fab3 | ||
|
|
315b21db00 | ||
|
|
f9aaeb3a34 | ||
|
|
d19bb909b3 | ||
|
|
f850db23fe | ||
|
|
5f81010f6a | ||
|
|
daf2493f50 | ||
|
|
57222f3611 | ||
|
|
62b185979e | ||
|
|
ebcc85acc4 | ||
|
|
33a7ba4acd | ||
|
|
1d4e6993fc | ||
|
|
784b761629 | ||
|
|
268fb2ce9a | ||
|
|
fc5f35b388 | ||
|
|
ff026a06bb | ||
|
|
b148a57c98 | ||
|
|
ee6e2d2983 | ||
|
|
ea3a6fd75e | ||
|
|
22f85d3af9 | ||
|
|
75f4c2ee99 | ||
|
|
dd3467efa2 | ||
|
|
4adb15c11b | ||
|
|
a5e38d1473 | ||
|
|
778256ca16 | ||
|
|
2b0ba7d1e2 | ||
|
|
772f3fedb3 | ||
|
|
fe25d1dccd | ||
|
|
10a7cd0987 | ||
|
|
6786df6965 | ||
|
|
4cfd18c81a | ||
|
|
d25a21cd32 | ||
|
|
b5f0a6f4a6 | ||
|
|
cf19dd23cf | ||
|
|
3e6a2d670e | ||
|
|
26ef33a4b6 | ||
|
|
9940f1d6db | ||
|
|
75eef8d722 | ||
|
|
46a3c3de33 | ||
|
|
2b7e3f0efe | ||
|
|
d5fbc1d455 | ||
|
|
bbe59499ad | ||
|
|
4c88e9c8d2 | ||
|
|
5ccf5d7150 | ||
|
|
27c9381e1d | ||
|
|
45f8b06d56 | ||
|
|
2a62992c75 | ||
|
|
997afc1b2f | ||
|
|
f941ea6500 | ||
|
|
92d083164f | ||
|
|
2dd30c7a26 | ||
|
|
3f0347253e | ||
|
|
bb6377fb22 | ||
|
|
12c2071358 | ||
|
|
ec4c4a4d5a | ||
|
|
876fcf3296 | ||
|
|
023ceed286 | ||
|
|
cc42aa32ef | ||
|
|
7cbb1c60a2 | ||
|
|
4ad130a11a | ||
|
|
9bf46b6367 | ||
|
|
4be2909b24 | ||
|
|
f161158d83 | ||
|
|
3a5f6ab6f1 | ||
|
|
c1b626da14 | ||
|
|
48e0a3c450 | ||
|
|
8626fa3e00 | ||
|
|
b50d7f0927 | ||
|
|
0d54b57151 | ||
|
|
5a2bdc58da | ||
|
|
01446c02aa | ||
|
|
a382482173 | ||
|
|
2e970cbb39 | ||
|
|
161a3f4da9 | ||
|
|
713bdcbc41 | ||
|
|
1fa67535f9 | ||
|
|
e8d8b67c0a | ||
|
|
e57d4cc544 | ||
|
|
435b7fda7e | ||
|
|
d7e810fc2f | ||
|
|
850ed48955 | ||
|
|
a5ebd89817 | ||
|
|
a8ec07cfc9 | ||
|
|
41fe5373a7 | ||
|
|
0812e189f7 | ||
|
|
588def6d33 | ||
|
|
0c244cbf95 | ||
|
|
7ef14aabed | ||
|
|
978c2b05f2 | ||
|
|
68fd1d67cb | ||
|
|
bf8407274e | ||
|
|
3bc2941445 | ||
|
|
654b1d6b34 | ||
|
|
7a49681dd2 | ||
|
|
7a1623e6a1 | ||
|
|
c25acb41fa | ||
|
|
4224b8a486 | ||
|
|
9e990d7927 | ||
|
|
431ae97593 | ||
|
|
633ff810cf | ||
|
|
f3d2b781ab | ||
|
|
a0b3960ee4 | ||
|
|
e55db0afdc | ||
|
|
ae9efe6359 | ||
|
|
32105665c1 | ||
|
|
2b18efdfdc | ||
|
|
e0c66ea6df | ||
|
|
667c7361d7 | ||
|
|
63fdf0d18e | ||
|
|
e05cb0ef4d | ||
|
|
925c7f7dc7 | ||
|
|
c69e97ea24 | ||
|
|
5e2aebc724 | ||
|
|
6eba467b91 | ||
|
|
524cf5ec5b | ||
|
|
50fd659749 | ||
|
|
8169afb59b | ||
|
|
d40086fea1 | ||
|
|
399c40debd | ||
|
|
d986673dfd | ||
|
|
f83f4d41f1 | ||
|
|
7ed711730e | ||
|
|
94e2ea9df3 | ||
|
|
8c8c4a15c3 | ||
|
|
2a9159f106 | ||
|
|
8f113d17c2 | ||
|
|
9084055b95 | ||
|
|
fba9cce82e | ||
|
|
92cfb46c14 | ||
|
|
449dc1a0e2 | ||
|
|
d9c345b0f3 | ||
|
|
69a639f76c | ||
|
|
d576efe759 | ||
|
|
9ba2ecbc21 | ||
|
|
84003cd67e | ||
|
|
be8c447216 | ||
|
|
e534daf5d4 | ||
|
|
1fefc1af92 | ||
|
|
e76c4ed2a4 | ||
|
|
e1caf13233 | ||
|
|
a7a2fbbca8 | ||
|
|
28d93d9160 | ||
|
|
4e90f90c28 | ||
|
|
2243fdddd3 | ||
|
|
39be3a2ef9 | ||
|
|
ecc30b85bc | ||
|
|
6905b288d2 | ||
|
|
0782146682 | ||
|
|
91aea4f754 | ||
|
|
6ca277a21d | ||
|
|
c47c75aefe | ||
|
|
9896e4381b | ||
|
|
953ffe889e | ||
|
|
72e59e77a7 | ||
|
|
35e2681ea9 | ||
|
|
84012d9090 | ||
|
|
e8a1ea3b54 | ||
|
|
ea6882d9ab | ||
|
|
1fa80e31d1 | ||
|
|
d80752cc9d | ||
|
|
b764e848c7 | ||
|
|
b037c4e8a3 | ||
|
|
6ba2360790 | ||
|
|
ca4eb507f0 | ||
|
|
965b094470 | ||
|
|
0fe313ecfd | ||
|
|
35a2f8d44f | ||
|
|
50797879d5 | ||
|
|
9327331ee9 | ||
|
|
1c15007e32 | ||
|
|
2151ffa114 | ||
|
|
49ed208a54 | ||
|
|
d668462529 | ||
|
|
f2102a0a23 | ||
|
|
5efc6b82c1 | ||
|
|
1e4e9768da | ||
|
|
cc5109c305 | ||
|
|
e858d6a1d5 | ||
|
|
b4cd5d2862 | ||
|
|
0633a44cfb | ||
|
|
5748126b83 | ||
|
|
06375743a3 | ||
|
|
2a41c186aa | ||
|
|
af51b7254c | ||
|
|
f63dfd769f | ||
|
|
a1512f3174 | ||
|
|
245751e2ce | ||
|
|
37001d9425 | ||
|
|
9d1f51c6ba | ||
|
|
cb234fe1fc | ||
|
|
cb85e0255b | ||
|
|
61b4cfdab7 | ||
|
|
d2c405c126 | ||
|
|
cbca560f92 | ||
|
|
2d7b63b4cf | ||
|
|
217038b085 | ||
|
|
13dd4edd6a | ||
|
|
a7288b4fbf | ||
|
|
3020e8104e | ||
|
|
8fdeeaaf38 | ||
|
|
42616b59de | ||
|
|
bf16681bea | ||
|
|
027190b5a4 | ||
|
|
241c02be30 | ||
|
|
dd87268848 | ||
|
|
f2ac24e623 | ||
|
|
80e0cac474 | ||
|
|
37273dd51c | ||
|
|
926a85fff0 | ||
|
|
70273ba2ba | ||
|
|
158cdeed57 | ||
|
|
ba9595a1be | ||
|
|
347e3ff674 | ||
|
|
2b6fb46cdb | ||
|
|
465775bd55 | ||
|
|
44e82fc454 | ||
|
|
c4963d0de8 | ||
|
|
ff81d70cb1 | ||
|
|
d7a543e143 | ||
|
|
cba547083d | ||
|
|
47b1d2a2c2 | ||
|
|
abc378954c | ||
|
|
fdf871af17 | ||
|
|
83fcb0efdc | ||
|
|
0c43f3d15a | ||
|
|
88e087d50f | ||
|
|
a9fb6eb8bc | ||
|
|
08acfdcd24 | ||
|
|
576eb9106f | ||
|
|
ddd2c0ae4e | ||
|
|
e58d7db03b | ||
|
|
1cac42aec5 | ||
|
|
f94449a659 | ||
|
|
df6afc957f | ||
|
|
99ffd3050c | ||
|
|
69dd82d329 | ||
|
|
076f71d490 | ||
|
|
33eae1e03a | ||
|
|
8a20510cde | ||
|
|
c33b470fca | ||
|
|
29db5f1990 | ||
|
|
f98f78a5bd | ||
|
|
d258b42e01 | ||
|
|
a6da32430f | ||
|
|
cfae607310 | ||
|
|
7653e72e88 | ||
|
|
f38b6636e3 | ||
|
|
e42db121ea | ||
|
|
0adceaa3f0 | ||
|
|
e6db1495ab | ||
|
|
e6e494a92c | ||
|
|
549f95b259 | ||
|
|
d92626071e | ||
|
|
a7ac82b023 | ||
|
|
64b78b5822 | ||
|
|
8ba17db877 | ||
|
|
6820d9ae4e | ||
|
|
0bdc2fb05e | ||
|
|
cf5598aeb9 | ||
|
|
8cf3d648ea | ||
|
|
212311a980 | ||
|
|
c9522dc25d | ||
|
|
37af753402 | ||
|
|
d8c5627cf8 | ||
|
|
4f926b37db | ||
|
|
fefc16bd13 | ||
|
|
1b1b71a9b6 | ||
|
|
086532652e | ||
|
|
4e8b4720a1 | ||
|
|
4a7ada28fb | ||
|
|
1710285674 | ||
|
|
a6bb61d998 | ||
|
|
5ec05dfa84 | ||
|
|
83e854aa13 | ||
|
|
634f809159 | ||
|
|
e5cf141834 | ||
|
|
8610b68d3f | ||
|
|
f3e3bddc94 | ||
|
|
7ef3284cc5 | ||
|
|
3494586f77 | ||
|
|
faaf99e6bb | ||
|
|
1078ba2111 | ||
|
|
2ad69300f5 | ||
|
|
d2f3fa7fdf | ||
|
|
64fcb6270b | ||
|
|
562c30cff4 | ||
|
|
7108501d24 | ||
|
|
37eae3406c | ||
|
|
501dc938e6 | ||
|
|
c5ecd35fe9 | ||
|
|
7cd8d7f44d | ||
|
|
567a9a4e58 | ||
|
|
58f4a0cfbb | ||
|
|
e6c0b697aa | ||
|
|
35f60d699d | ||
|
|
c219be0970 | ||
|
|
c72ce843fa | ||
|
|
c606059a3a | ||
|
|
049a8bdc6d | ||
|
|
9752f744ca | ||
|
|
4be6fb789c | ||
|
|
afc56e5259 | ||
|
|
d47f8521d5 | ||
|
|
7f853d426a | ||
|
|
e9008c615d | ||
|
|
01f081ef5a | ||
|
|
7ee174e0d5 | ||
|
|
24439f86e0 | ||
|
|
fbd3ce3b72 | ||
|
|
96f8b54b51 | ||
|
|
9c94a78e29 | ||
|
|
a14e3dd137 | ||
|
|
e37673bd67 | ||
|
|
6aa10d20a1 | ||
|
|
68a92acb7a | ||
|
|
8aa7cc9ca5 | ||
|
|
e6c087c3bb | ||
|
|
39a2097152 | ||
|
|
6a8003917e | ||
|
|
d5a17ddc8c | ||
|
|
48bbf0d649 | ||
|
|
0bc58c254f | ||
|
|
b2d41f0583 | ||
|
|
0d31d20f0f | ||
|
|
5154e31c1c | ||
|
|
c67b5e950e | ||
|
|
8a7b5cc87d | ||
|
|
bb7938f66d | ||
|
|
5b22e945da | ||
|
|
decde230aa | ||
|
|
1dec8ae122 | ||
|
|
8512d5e693 | ||
|
|
bb481ccfb4 | ||
|
|
12bce48ef5 | ||
|
|
013c7c776e | ||
|
|
8f96d20a23 | ||
|
|
1a8811b69a | ||
|
|
d796849d74 | ||
|
|
942bd0859f | ||
|
|
072028c740 | ||
|
|
0d08aecd56 | ||
|
|
66b290577c | ||
|
|
22ad16e11b | ||
|
|
2f49a08c7d | ||
|
|
fcacda74cb | ||
|
|
fa0c90de70 | ||
|
|
c1197314ac | ||
|
|
0b31792660 | ||
|
|
8b95dd65d9 | ||
|
|
691ed88096 | ||
|
|
836d772cd4 | ||
|
|
999ada03d1 | ||
|
|
b35fabbe55 | ||
|
|
8cd8a157a6 | ||
|
|
86aece6828 | ||
|
|
f9edadbafd | ||
|
|
6a388cd4fe | ||
|
|
9d17e9ff48 | ||
|
|
662b7d01b8 | ||
|
|
a19bc4b4e4 | ||
|
|
a545aa5c39 | ||
|
|
fa451f362b | ||
|
|
868659a2f1 | ||
|
|
8ae62da138 | ||
|
|
bedba39af9 | ||
|
|
8493e56b11 | ||
|
|
21c77dccce | ||
|
|
55164803b0 | ||
|
|
c163f84aec | ||
|
|
2711b989e1 | ||
|
|
5c49a8ce6a | ||
|
|
854f308eae | ||
|
|
16ba6b53ba | ||
|
|
0af29a378a | ||
|
|
def34a860b | ||
|
|
f8034e1b78 | ||
|
|
01fbea02f1 | ||
|
|
3d9af89e24 | ||
|
|
d430d9f3ed | ||
|
|
0c24a1e626 | ||
|
|
1099dbe642 | ||
|
|
2df3277dcd | ||
|
|
6ae14213f5 | ||
|
|
61bd029303 | ||
|
|
5b09bd8242 | ||
|
|
703477b157 | ||
|
|
03ff5d8ae1 | ||
|
|
220f7ef7cd | ||
|
|
682a99dd43 | ||
|
|
fac5de582d | ||
|
|
7cbf9de8ca | ||
|
|
ce213c3d89 | ||
|
|
32cd0360e6 | ||
|
|
1ec23a5699 | ||
|
|
48330f6432 | ||
|
|
28358debbc | ||
|
|
54b7ed6117 | ||
|
|
0cfd2ee63b | ||
|
|
37a0990741 | ||
|
|
7a0cd1eb34 | ||
|
|
ac3277da09 | ||
|
|
65d1e7be56 | ||
|
|
80685afa7e | ||
|
|
f892453892 | ||
|
|
422bb8c31c | ||
|
|
6fb1202c1c | ||
|
|
4ddd2788f0 | ||
|
|
8a28029809 | ||
|
|
423a2129d1 | ||
|
|
a338097514 | ||
|
|
84b67abb03 | ||
|
|
5ec8406653 | ||
|
|
b3ce300d32 | ||
|
|
3f93b93d9e | ||
|
|
e32c83db63 | ||
|
|
0344a63b48 |
33
.github/pull_request_template.md
vendored
Normal file
33
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
<!--
|
||||
For Work In Progress Pull Requests, please use the Draft PR feature,
|
||||
see https://github.blog/2019-02-14-introducing-draft-pull-requests/ for further details.
|
||||
|
||||
If you do not follow this template, the PR may be closed without review.
|
||||
|
||||
Please ensure all checks pass.
|
||||
If you are a new contributor, the workflows will need to be manually approved before they run.
|
||||
-->
|
||||
|
||||
## Brief summary
|
||||
|
||||
<!-- Please provide a brief summary of what your PR attempts to achieve. -->
|
||||
|
||||
## Which issue is fixed?
|
||||
|
||||
<!-- Which issue number does this PR fix? Ex: "Fixes #1234" -->
|
||||
|
||||
## In-depth Description
|
||||
|
||||
<!--
|
||||
Describe your solution in more depth.
|
||||
How does it work? Why is this the best solution?
|
||||
Does it solve a problem that affects multiple users or is this an edge case for your setup?
|
||||
-->
|
||||
|
||||
## How have you tested this?
|
||||
|
||||
<!-- Please describe in detail with reproducible steps how you tested your changes. -->
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- If your PR includes any changes to the web client, please include screenshots or a short video from before and after your changes. -->
|
||||
42
.github/workflows/close_blank_issues.yaml
vendored
Normal file
42
.github/workflows/close_blank_issues.yaml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: Close Issues not using a template
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
close_issue:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check issue headings
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const issueBody = context.payload.issue.body || "";
|
||||
|
||||
// Match Markdown headings (e.g., # Heading, ## Heading)
|
||||
const headingRegex = /^(#{1,6})\s.+/gm;
|
||||
const headings = [...issueBody.matchAll(headingRegex)];
|
||||
|
||||
if (headings.length < 3) {
|
||||
// Post a comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
body: "Thank you for opening an issue! To help us review your request efficiently, please use one of the provided issue templates. If you're seeking information or have a general question, consider opening a Discussion or joining the conversation on our Discord. Thanks!"
|
||||
});
|
||||
|
||||
// Close the issue
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
state: "closed"
|
||||
});
|
||||
}
|
||||
77
.github/workflows/codeql.yml
vendored
77
.github/workflows/codeql.yml
vendored
@@ -1,11 +1,25 @@
|
||||
name: "CodeQL"
|
||||
name: 'CodeQL'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ 'master' ]
|
||||
branches: ['master']
|
||||
# Only build when files in these directories have been changed
|
||||
paths:
|
||||
- client/**
|
||||
- server/**
|
||||
- test/**
|
||||
- index.js
|
||||
- package.json
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ 'master' ]
|
||||
branches: ['master']
|
||||
# Only build when files in these directories have been changed
|
||||
paths:
|
||||
- client/**
|
||||
- server/**
|
||||
- test/**
|
||||
- index.js
|
||||
- package.json
|
||||
schedule:
|
||||
- cron: '16 5 * * 4'
|
||||
|
||||
@@ -21,45 +35,44 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript' ]
|
||||
language: ['javascript']
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Use only 'java' to analyze code written in Java, Kotlin or both
|
||||
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
1
.github/workflows/docker-build.yml
vendored
1
.github/workflows/docker-build.yml
vendored
@@ -70,6 +70,7 @@ jobs:
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
|
||||
7
.github/workflows/integration-test.yml
vendored
7
.github/workflows/integration-test.yml
vendored
@@ -5,6 +5,13 @@ on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests
|
||||
# Only build when files in these directories have been changed
|
||||
paths:
|
||||
- client/**
|
||||
- server/**
|
||||
- test/**
|
||||
- index.js
|
||||
- package.json
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,6 +7,7 @@
|
||||
/podcasts/
|
||||
/media/
|
||||
/metadata/
|
||||
/plugins/
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
/dist/
|
||||
@@ -16,6 +17,7 @@
|
||||
/ffmpeg*
|
||||
/ffprobe*
|
||||
/unicode*
|
||||
/libnusqlite3*
|
||||
|
||||
sw.*
|
||||
.DS_STORE
|
||||
|
||||
38
Dockerfile
38
Dockerfile
@@ -11,25 +11,45 @@ FROM node:20-alpine
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN apk update && \
|
||||
apk add --no-cache --update \
|
||||
curl \
|
||||
tzdata \
|
||||
ffmpeg \
|
||||
make \
|
||||
gcompat \
|
||||
python3 \
|
||||
g++ \
|
||||
tini
|
||||
apk add --no-cache --update \
|
||||
curl \
|
||||
tzdata \
|
||||
ffmpeg \
|
||||
make \
|
||||
python3 \
|
||||
g++ \
|
||||
tini \
|
||||
unzip
|
||||
|
||||
COPY --from=build /client/dist /client/dist
|
||||
COPY index.js package* /
|
||||
COPY server server
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
ENV NUSQLITE3_DIR="/usr/local/lib/nusqlite3"
|
||||
ENV NUSQLITE3_PATH="${NUSQLITE3_DIR}/libnusqlite3.so"
|
||||
|
||||
RUN case "$TARGETPLATFORM" in \
|
||||
"linux/amd64") \
|
||||
curl -L -o /tmp/library.zip "https://github.com/mikiher/nunicode-sqlite/releases/download/v1.2/libnusqlite3-linux-musl-x64.zip" ;; \
|
||||
"linux/arm64") \
|
||||
curl -L -o /tmp/library.zip "https://github.com/mikiher/nunicode-sqlite/releases/download/v1.2/libnusqlite3-linux-musl-arm64.zip" ;; \
|
||||
*) echo "Unsupported platform: $TARGETPLATFORM" && exit 1 ;; \
|
||||
esac && \
|
||||
unzip /tmp/library.zip -d $NUSQLITE3_DIR && \
|
||||
rm /tmp/library.zip
|
||||
|
||||
RUN npm ci --only=production
|
||||
|
||||
RUN apk del make python3 g++
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
ENV PORT=80
|
||||
ENV CONFIG_PATH="/config"
|
||||
ENV METADATA_PATH="/metadata"
|
||||
ENV SOURCE="docker"
|
||||
|
||||
ENTRYPOINT ["tini", "--"]
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
@import './absicons.css';
|
||||
|
||||
:root {
|
||||
--bookshelf-texture-img: url(/textures/wood_default.jpg);
|
||||
--bookshelf-texture-img: url(~static/textures/wood_default.jpg);
|
||||
--bookshelf-divider-bg: 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%);
|
||||
}
|
||||
|
||||
@@ -92,11 +92,10 @@
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
input[type=number] {
|
||||
input[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
|
||||
.tracksTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
@@ -177,6 +176,10 @@ input[type=number] {
|
||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||
}
|
||||
|
||||
.box-shadow-progressbar {
|
||||
box-shadow: 0px -1px 4px rgb(62, 50, 2, 0.5);
|
||||
}
|
||||
|
||||
.shadow-height {
|
||||
height: calc(100% - 4px);
|
||||
}
|
||||
@@ -204,7 +207,6 @@ Bookshelf Label
|
||||
color: #fce3a6;
|
||||
}
|
||||
|
||||
|
||||
.cover-bg {
|
||||
width: calc(100% + 40px);
|
||||
height: calc(100% + 40px);
|
||||
@@ -247,4 +249,4 @@ Bookshelf Label
|
||||
|
||||
.abs-btn:disabled::before {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,4 +52,17 @@
|
||||
text-indent: 0px !important;
|
||||
text-align: start !important;
|
||||
text-align-last: start !important;
|
||||
}
|
||||
}
|
||||
|
||||
.default-style.less-spacing p {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
.default-style.less-spacing ul {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
.default-style.less-spacing ol {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -446,7 +446,7 @@ trix-editor .attachment__metadata .attachment__size {
|
||||
}
|
||||
|
||||
.trix-content {
|
||||
line-height: 1.5;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.trix-content * {
|
||||
@@ -455,6 +455,13 @@ trix-editor .attachment__metadata .attachment__size {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.trix-content p {
|
||||
box-sizing: border-box;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5em;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.trix-content h1 {
|
||||
font-size: 1.2em;
|
||||
line-height: 1.2;
|
||||
@@ -560,4 +567,4 @@ trix-editor .attachment__metadata .attachment__size {
|
||||
.trix-content .attachment-gallery.attachment-gallery--4 .attachment {
|
||||
flex-basis: 50%;
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<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-60">
|
||||
<div id="appbar" role="toolbar" aria-label="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">
|
||||
<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" />
|
||||
@@ -264,7 +264,6 @@ export default {
|
||||
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,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e">
|
||||
<template v-for="(shelf, index) in supportedShelves">
|
||||
<widgets-item-slider :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :type="shelf.type" class="bookshelf-row pl-8e my-6e" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||
<p class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
<h2 class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</h2>
|
||||
</widgets-item-slider>
|
||||
</template>
|
||||
</div>
|
||||
@@ -347,6 +347,13 @@ export default {
|
||||
libraryItemsAdded(libraryItems) {
|
||||
console.log('libraryItems added', libraryItems)
|
||||
|
||||
// First items added to library
|
||||
const isThisLibrary = libraryItems.some((li) => li.libraryId === this.currentLibraryId)
|
||||
if (!this.shelves.length && !this.search && isThisLibrary) {
|
||||
this.fetchCategories()
|
||||
return
|
||||
}
|
||||
|
||||
const recentlyAddedShelf = this.shelves.find((shelf) => shelf.id === 'recently-added')
|
||||
if (!recentlyAddedShelf) return
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
<div v-if="shelf.type === 'authors'" class="flex items-center">
|
||||
<template v-for="entity in shelf.entities">
|
||||
<cards-author-card :key="entity.id" :author="entity" @hook:updated="updatedBookCard" class="mx-2e" @edit="editAuthor" />
|
||||
<cards-author-card :key="entity.id" :authorMount="entity" @hook:updated="updatedBookCard" class="mx-2e" @edit="editAuthor" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'narrators'" class="flex items-center">
|
||||
@@ -37,18 +37,18 @@
|
||||
<div class="relative">
|
||||
<div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
|
||||
<p :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
<h2 :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></div>
|
||||
</div>
|
||||
<div v-show="canScrollLeft && !isScrolling" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft">
|
||||
<button v-show="canScrollLeft && !isScrolling" :aria-label="$strings.ButtonScrollLeft" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft">
|
||||
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_left</span>
|
||||
</div>
|
||||
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight">
|
||||
</button>
|
||||
<button v-show="canScrollRight && !isScrolling" :aria-label="$strings.ButtonScrollRight" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight">
|
||||
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_right</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -99,6 +99,7 @@ export default {
|
||||
this.$store.commit('showEditModal', libraryItem)
|
||||
},
|
||||
editEpisode({ libraryItem, episode }) {
|
||||
this.$store.commit('setEpisodeTableEpisodeIds', [episode.id])
|
||||
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<p v-if="isCollectionsPage" class="text-sm">{{ $strings.ButtonCollections }}</p>
|
||||
<span v-else class="material-symbols text-lg"></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'">
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/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
|
||||
@@ -42,15 +42,18 @@
|
||||
<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.ButtonAdd }}</p>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p class="text-sm">{{ $strings.ButtonDownloadQueue }}</p>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||
<div id="toolbar" role="toolbar" aria-label="Library 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>
|
||||
<span class="font-mono">{{ $formatNumber(numShowing) }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
|
||||
@@ -62,8 +65,8 @@
|
||||
<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>
|
||||
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome && !isAuthorsPage">
|
||||
<p class="hidden md:block">{{ $formatNumber(numShowing) }} {{ entityName }}</p>
|
||||
|
||||
<div class="flex-grow hidden sm:inline-block" />
|
||||
|
||||
@@ -80,7 +83,7 @@
|
||||
<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-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ $formatNumber(numShowing) }} {{ entityName }}</ui-btn>
|
||||
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
||||
</template>
|
||||
@@ -92,12 +95,14 @@
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
||||
</template>
|
||||
<!-- authors page -->
|
||||
<template v-else-if="page === 'authors'">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="userCanUpdate && authors?.length && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
|
||||
<template v-else-if="isAuthorsPage">
|
||||
<p class="hidden md:block">{{ $formatNumber(numShowing) }} {{ entityName }}</p>
|
||||
|
||||
<div class="flex-grow hidden sm:inline-block" />
|
||||
<ui-btn v-if="userCanUpdate && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
|
||||
|
||||
<!-- author sort select -->
|
||||
<controls-sort-select v-if="authors?.length" v-model="settings.authorSortBy" :descending.sync="settings.authorSortDesc" :items="authorSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateAuthorSort" />
|
||||
<controls-sort-select v-model="settings.authorSortBy" :descending.sync="settings.authorSortDesc" :items="authorSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateAuthorSort" />
|
||||
</template>
|
||||
<!-- home page -->
|
||||
<template v-else-if="isHome">
|
||||
@@ -117,11 +122,7 @@ export default {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
searchQuery: String,
|
||||
authors: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
searchQuery: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -246,9 +247,6 @@ export default {
|
||||
isPodcastLibrary() {
|
||||
return this.currentLibraryMediaType === 'podcast'
|
||||
},
|
||||
isMusicLibrary() {
|
||||
return this.currentLibraryMediaType === 'music'
|
||||
},
|
||||
isLibraryPage() {
|
||||
return this.page === ''
|
||||
},
|
||||
@@ -270,8 +268,11 @@ export default {
|
||||
isPodcastLatestPage() {
|
||||
return this.$route.name === 'library-library-podcast-latest'
|
||||
},
|
||||
isPodcastDownloadQueuePage() {
|
||||
return this.$route.name === 'library-library-podcast-download-queue'
|
||||
},
|
||||
isAuthorsPage() {
|
||||
return this.$route.name === 'library-library-authors'
|
||||
return this.page === 'authors'
|
||||
},
|
||||
isAlbumsPage() {
|
||||
return this.page === 'albums'
|
||||
@@ -281,13 +282,13 @@ export default {
|
||||
},
|
||||
entityName() {
|
||||
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
|
||||
if (this.isAuthorsPage) return this.$strings.LabelAuthors
|
||||
return ''
|
||||
},
|
||||
seriesId() {
|
||||
@@ -477,42 +478,54 @@ export default {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to re-add series to continue listening', error)
|
||||
this.$toast.error(this.$strings.ToastItemUpdateFailed)
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processingSeries = false
|
||||
})
|
||||
},
|
||||
async fetchAllAuthors() {
|
||||
// fetch all authors from the server, in the order that they are currently displayed
|
||||
const response = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/authors?sort=${this.settings.authorSortBy}&desc=${this.settings.authorSortDesc}`)
|
||||
return response.authors
|
||||
},
|
||||
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
|
||||
try {
|
||||
const authors = await this.fetchAllAuthors()
|
||||
|
||||
payload.region = 'us'
|
||||
if (this.libraryProvider.startsWith('audible.')) {
|
||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
||||
for (const author of 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(this.$getString('ToastAuthorNotFound', [author.name]))
|
||||
} 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.$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(this.$getString('ToastAuthorNotFound', [author.name]))
|
||||
} 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)
|
||||
} catch (error) {
|
||||
console.error('Failed to match all authors', error)
|
||||
this.$toast.error(this.$strings.ToastMatchAllAuthorsFailed)
|
||||
}
|
||||
this.processingAuthors = false
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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 role="toolbar" aria-orientation="vertical" aria-label="Config Sidebar">
|
||||
<div role="navigation" aria-label="Config Navigation" 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-symbols text-2xl">arrow_back</span>
|
||||
</div>
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<p class="text-xs text-gray-300 italic">{{ Source }}</p>
|
||||
</div>
|
||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ $config.version }}</a>
|
||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ versionData.latestVersion }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<div id="bookshelf" ref="bookshelf" class="w-full overflow-y-auto" :style="{ fontSize: sizeMultiplier + 'rem' }">
|
||||
<template v-for="shelf in totalShelves">
|
||||
<div :key="shelf" :id="`shelf-${shelf - 1}`" class="w-full px-4e sm:px-8e relative" :class="{ bookshelfRow: !isAlternativeBookshelfView }" :style="{ height: shelfHeight + 'px' }">
|
||||
<!-- Card skeletons -->
|
||||
<template v-for="entityIndex in entitiesInShelf(shelf)">
|
||||
<div :key="entityIndex" class="w-full h-full absolute rounded z-5 top-0 left-0 bg-primary box-shadow-book" :style="{ transform: entityTransform(entityIndex), width: cardWidth + 'px', height: coverHeight + 'px' }" />
|
||||
</template>
|
||||
<div v-if="!isAlternativeBookshelfView" class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20 h-6e" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -15,6 +19,14 @@
|
||||
</div>
|
||||
<div v-else-if="!totalShelves && initialized" class="w-full py-16">
|
||||
<p class="text-xl text-center">{{ emptyMessage }}</p>
|
||||
<div v-if="entityName === 'collections' || entityName === 'playlists'" class="flex justify-center mt-4">
|
||||
{{ emptyMessageHelp }}
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<!-- 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>
|
||||
@@ -65,7 +77,13 @@ export default {
|
||||
tempIsScanning: false,
|
||||
cardWidth: 0,
|
||||
cardHeight: 0,
|
||||
resizeObserver: null
|
||||
coverHeight: 0,
|
||||
resizeObserver: null,
|
||||
lastScrollTop: 0,
|
||||
lastTimestamp: 0,
|
||||
postScrollTimeout: null,
|
||||
currFirstEntityIndex: -1,
|
||||
currLastEntityIndex: -1
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -91,6 +109,7 @@ export default {
|
||||
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.page === 'authors') return this.$strings.MessageNoAuthors
|
||||
if (this.hasFilter) {
|
||||
if (this.filterName === 'Issues') return this.$strings.MessageNoIssues
|
||||
else if (this.filterName === 'Feed-open') return this.$strings.MessageBookshelfNoRSSFeeds
|
||||
@@ -98,6 +117,11 @@ export default {
|
||||
}
|
||||
return this.$strings.MessageNoResults
|
||||
},
|
||||
emptyMessageHelp() {
|
||||
if (this.page === 'collections') return this.$strings.MessageBookshelfNoCollectionsHelp
|
||||
if (this.page === 'playlists') return this.$strings.MessageNoUserPlaylistsHelp
|
||||
return ''
|
||||
},
|
||||
entityName() {
|
||||
if (!this.page) return 'items'
|
||||
return this.page
|
||||
@@ -111,6 +135,12 @@ export default {
|
||||
seriesFilterBy() {
|
||||
return this.$store.getters['user/getUserSetting']('seriesFilterBy')
|
||||
},
|
||||
authorSortBy() {
|
||||
return this.$store.getters['user/getUserSetting']('authorSortBy')
|
||||
},
|
||||
authorSortDesc() {
|
||||
return !!this.$store.getters['user/getUserSetting']('authorSortDesc')
|
||||
},
|
||||
orderBy() {
|
||||
return this.$store.getters['user/getUserSetting']('orderBy')
|
||||
},
|
||||
@@ -164,9 +194,6 @@ export default {
|
||||
bookWidth() {
|
||||
return this.cardWidth
|
||||
},
|
||||
bookHeight() {
|
||||
return this.cardHeight
|
||||
},
|
||||
shelfPadding() {
|
||||
if (this.bookshelfWidth < 640) return 32 * this.sizeMultiplier
|
||||
return 64 * this.sizeMultiplier
|
||||
@@ -177,9 +204,6 @@ export default {
|
||||
entityWidth() {
|
||||
return this.cardWidth
|
||||
},
|
||||
entityHeight() {
|
||||
return this.cardHeight
|
||||
},
|
||||
shelfPaddingHeight() {
|
||||
return 16
|
||||
},
|
||||
@@ -217,6 +241,8 @@ export default {
|
||||
this.$store.commit('globals/setEditCollection', entity)
|
||||
} else if (this.entityName === 'playlists') {
|
||||
this.$store.commit('globals/setEditPlaylist', entity)
|
||||
} else if (this.entityName === 'authors') {
|
||||
this.$store.commit('globals/showEditAuthorModal', entity)
|
||||
}
|
||||
},
|
||||
clearSelectedEntities() {
|
||||
@@ -345,50 +371,53 @@ export default {
|
||||
}
|
||||
},
|
||||
loadPage(page) {
|
||||
this.pagesLoaded[page] = true
|
||||
this.fetchEntites(page)
|
||||
if (!this.pagesLoaded[page]) this.pagesLoaded[page] = this.fetchEntites(page)
|
||||
return this.pagesLoaded[page]
|
||||
},
|
||||
showHideBookPlaceholder(index, show) {
|
||||
var el = document.getElementById(`book-${index}-placeholder`)
|
||||
if (el) el.style.display = show ? 'flex' : 'none'
|
||||
},
|
||||
mountEntites(fromIndex, toIndex) {
|
||||
mountEntities(fromIndex, toIndex) {
|
||||
for (let i = fromIndex; i < toIndex; i++) {
|
||||
if (!this.entityIndexesMounted.includes(i)) {
|
||||
this.cardsHelpers.mountEntityCard(i)
|
||||
}
|
||||
}
|
||||
},
|
||||
handleScroll(scrollTop) {
|
||||
this.currScrollTop = scrollTop
|
||||
var firstShelfIndex = Math.floor(scrollTop / this.shelfHeight)
|
||||
var lastShelfIndex = Math.ceil((scrollTop + this.bookshelfHeight) / this.shelfHeight)
|
||||
lastShelfIndex = Math.min(this.totalShelves - 1, lastShelfIndex)
|
||||
|
||||
var firstBookIndex = firstShelfIndex * this.entitiesPerShelf
|
||||
var lastBookIndex = lastShelfIndex * this.entitiesPerShelf + this.entitiesPerShelf
|
||||
lastBookIndex = Math.min(this.totalEntities, lastBookIndex)
|
||||
|
||||
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)
|
||||
this.loadPage(firstBookPage)
|
||||
}
|
||||
if (!this.pagesLoaded[lastBookPage]) {
|
||||
// console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
|
||||
this.loadPage(lastBookPage)
|
||||
}
|
||||
|
||||
getVisibleIndices(scrollTop) {
|
||||
const firstShelfIndex = Math.floor(scrollTop / this.shelfHeight)
|
||||
const lastShelfIndex = Math.min(Math.ceil((scrollTop + this.bookshelfHeight) / this.shelfHeight), this.totalShelves - 1)
|
||||
const firstEntityIndex = firstShelfIndex * this.entitiesPerShelf
|
||||
const lastEntityIndex = Math.min(lastShelfIndex * this.entitiesPerShelf + this.entitiesPerShelf, this.totalEntities)
|
||||
return { firstEntityIndex, lastEntityIndex }
|
||||
},
|
||||
postScroll() {
|
||||
const { firstEntityIndex, lastEntityIndex } = this.getVisibleIndices(this.currScrollTop)
|
||||
this.entityIndexesMounted = this.entityIndexesMounted.filter((_index) => {
|
||||
if (_index < firstBookIndex || _index >= lastBookIndex) {
|
||||
var el = document.getElementById(`book-card-${_index}`)
|
||||
if (el) el.remove()
|
||||
if (_index < firstEntityIndex || _index >= lastEntityIndex) {
|
||||
var el = this.entityComponentRefs[_index]
|
||||
if (el && el.$el) el.$el.remove()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
this.mountEntites(firstBookIndex, lastBookIndex)
|
||||
},
|
||||
handleScroll(scrollTop) {
|
||||
this.currScrollTop = scrollTop
|
||||
const { firstEntityIndex, lastEntityIndex } = this.getVisibleIndices(scrollTop)
|
||||
if (firstEntityIndex === this.currFirstEntityIndex && lastEntityIndex === this.currLastEntityIndex) return
|
||||
this.currFirstEntityIndex = firstEntityIndex
|
||||
this.currLastEntityIndex = lastEntityIndex
|
||||
|
||||
clearTimeout(this.postScrollTimeout)
|
||||
const firstPage = Math.floor(firstEntityIndex / this.booksPerFetch)
|
||||
const lastPage = Math.floor(lastEntityIndex / this.booksPerFetch)
|
||||
Promise.all([this.loadPage(firstPage), this.loadPage(lastPage)])
|
||||
.then(() => this.mountEntities(firstEntityIndex, lastEntityIndex))
|
||||
.catch((error) => console.error('Failed to load page', error))
|
||||
|
||||
this.postScrollTimeout = setTimeout(this.postScroll, 500)
|
||||
},
|
||||
async resetEntities() {
|
||||
if (this.isFetchingEntities) {
|
||||
@@ -396,8 +425,6 @@ export default {
|
||||
return
|
||||
}
|
||||
this.destroyEntityComponents()
|
||||
this.entityIndexesMounted = []
|
||||
this.entityComponentRefs = {}
|
||||
this.pagesLoaded = {}
|
||||
this.entities = []
|
||||
this.totalShelves = 0
|
||||
@@ -407,40 +434,21 @@ export default {
|
||||
this.initialized = false
|
||||
|
||||
this.initSizeData()
|
||||
this.pagesLoaded[0] = true
|
||||
await this.fetchEntites(0)
|
||||
await this.loadPage(0)
|
||||
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
||||
this.mountEntites(0, lastBookIndex)
|
||||
this.mountEntities(0, lastBookIndex)
|
||||
},
|
||||
remountEntities() {
|
||||
for (const key in this.entityComponentRefs) {
|
||||
if (this.entityComponentRefs[key]) {
|
||||
this.entityComponentRefs[key].destroy()
|
||||
}
|
||||
}
|
||||
this.entityComponentRefs = {}
|
||||
this.entityIndexesMounted.forEach((i) => {
|
||||
this.cardsHelpers.mountEntityCard(i)
|
||||
})
|
||||
},
|
||||
rebuild() {
|
||||
async rebuild() {
|
||||
this.initSizeData()
|
||||
|
||||
var lastBookIndex = Math.min(this.totalEntities, this.booksPerFetch)
|
||||
this.entityIndexesMounted = []
|
||||
for (let i = 0; i < lastBookIndex; i++) {
|
||||
this.entityIndexesMounted.push(i)
|
||||
if (!this.entities[i]) {
|
||||
const page = Math.floor(i / this.booksPerFetch)
|
||||
this.loadPage(page)
|
||||
}
|
||||
}
|
||||
this.destroyEntityComponents()
|
||||
await this.loadPage(0)
|
||||
var bookshelfEl = document.getElementById('bookshelf')
|
||||
if (bookshelfEl) {
|
||||
bookshelfEl.scrollTop = 0
|
||||
}
|
||||
|
||||
this.$nextTick(this.remountEntities)
|
||||
this.mountEntities(0, lastBookIndex)
|
||||
},
|
||||
buildSearchParams() {
|
||||
if (this.page === 'search' || this.page === 'collections') {
|
||||
@@ -457,6 +465,9 @@ export default {
|
||||
if (this.collapseBookSeries) {
|
||||
searchParams.set('collapseseries', 1)
|
||||
}
|
||||
} else if (this.page === 'authors') {
|
||||
searchParams.set('sort', this.authorSortBy)
|
||||
searchParams.set('desc', this.authorSortDesc ? 1 : 0)
|
||||
} else {
|
||||
if (this.filterBy && this.filterBy !== 'all') {
|
||||
searchParams.set('filter', this.filterBy)
|
||||
@@ -501,12 +512,29 @@ export default {
|
||||
if (wasUpdated) {
|
||||
this.resetEntities()
|
||||
} else if (settings.bookshelfCoverSize !== this.currentBookWidth) {
|
||||
this.executeRebuild()
|
||||
this.rebuild()
|
||||
}
|
||||
},
|
||||
getScrollRate() {
|
||||
const currentTimestamp = Date.now()
|
||||
const timeDelta = currentTimestamp - this.lastTimestamp
|
||||
const scrollDelta = this.currScrollTop - this.lastScrollTop
|
||||
const scrollRate = Math.abs(scrollDelta) / (timeDelta || 1)
|
||||
this.lastScrollTop = this.currScrollTop
|
||||
this.lastTimestamp = currentTimestamp
|
||||
return scrollRate
|
||||
},
|
||||
scroll(e) {
|
||||
if (!e || !e.target) return
|
||||
var { scrollTop } = e.target
|
||||
clearTimeout(this.scrollTimeout)
|
||||
const { scrollTop } = e.target
|
||||
const scrollRate = this.getScrollRate()
|
||||
if (scrollRate > 5) {
|
||||
this.scrollTimeout = setTimeout(() => {
|
||||
this.handleScroll(scrollTop)
|
||||
}, 25)
|
||||
return
|
||||
}
|
||||
this.handleScroll(scrollTop)
|
||||
},
|
||||
libraryItemAdded(libraryItem) {
|
||||
@@ -601,6 +629,34 @@ export default {
|
||||
this.executeRebuild()
|
||||
}
|
||||
},
|
||||
authorAdded(author) {
|
||||
if (this.entityName !== 'authors') return
|
||||
console.log(`[LazyBookshelf] authorAdded ${author.id}`, author)
|
||||
this.resetEntities()
|
||||
},
|
||||
authorUpdated(author) {
|
||||
if (this.entityName !== 'authors') return
|
||||
console.log(`[LazyBookshelf] authorUpdated ${author.id}`, author)
|
||||
const indexOf = this.entities.findIndex((ent) => ent && ent.id === author.id)
|
||||
if (indexOf >= 0) {
|
||||
this.entities[indexOf] = author
|
||||
if (this.entityComponentRefs[indexOf]) {
|
||||
this.entityComponentRefs[indexOf].setEntity(author)
|
||||
}
|
||||
}
|
||||
},
|
||||
authorRemoved(author) {
|
||||
if (this.entityName !== 'authors') return
|
||||
console.log(`[LazyBookshelf] authorRemoved ${author.id}`, author)
|
||||
const indexOf = this.entities.findIndex((ent) => ent && ent.id === author.id)
|
||||
if (indexOf >= 0) {
|
||||
this.entities = this.entities.filter((ent) => ent.id !== author.id)
|
||||
this.totalEntities--
|
||||
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
||||
this.executeRebuild()
|
||||
}
|
||||
},
|
||||
|
||||
shareOpen(mediaItemShare) {
|
||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||
var indexOf = this.entities.findIndex((ent) => ent?.media?.id === mediaItemShare.mediaItemId)
|
||||
@@ -627,13 +683,14 @@ export default {
|
||||
},
|
||||
updatePagesLoaded() {
|
||||
let numPages = Math.ceil(this.totalEntities / this.booksPerFetch)
|
||||
this.pagesLoaded = {}
|
||||
for (let page = 0; page < numPages; page++) {
|
||||
let numEntities = Math.min(this.totalEntities - page * this.booksPerFetch, this.booksPerFetch)
|
||||
this.pagesLoaded[page] = true
|
||||
this.pagesLoaded[page] = Promise.resolve()
|
||||
for (let i = 0; i < numEntities; i++) {
|
||||
const index = page * this.booksPerFetch + i
|
||||
if (!this.entities[index]) {
|
||||
this.pagesLoaded[page] = false
|
||||
if (this.pagesLoaded[page]) delete this.pagesLoaded[page]
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -648,7 +705,6 @@ export default {
|
||||
var entitiesPerShelfBefore = this.entitiesPerShelf
|
||||
|
||||
var { clientHeight, clientWidth } = bookshelf
|
||||
// console.log('Init bookshelf width', clientWidth, 'window width', window.innerWidth)
|
||||
this.mountWindowWidth = window.innerWidth
|
||||
this.bookshelfHeight = clientHeight
|
||||
this.bookshelfWidth = clientWidth
|
||||
@@ -673,10 +729,9 @@ export default {
|
||||
this.initSizeData(bookshelf)
|
||||
this.checkUpdateSearchParams()
|
||||
|
||||
this.pagesLoaded[0] = true
|
||||
await this.fetchEntites(0)
|
||||
await this.loadPage(0)
|
||||
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
||||
this.mountEntites(0, lastBookIndex)
|
||||
this.mountEntities(0, lastBookIndex)
|
||||
|
||||
// Set last scroll position for this bookshelf page
|
||||
if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {
|
||||
@@ -707,7 +762,7 @@ export default {
|
||||
var bookshelf = document.getElementById('bookshelf')
|
||||
if (bookshelf) {
|
||||
this.init(bookshelf)
|
||||
bookshelf.addEventListener('scroll', this.scroll)
|
||||
bookshelf.addEventListener('scroll', this.scroll, { passive: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -727,6 +782,9 @@ export default {
|
||||
this.$root.socket.on('playlist_added', this.playlistAdded)
|
||||
this.$root.socket.on('playlist_updated', this.playlistUpdated)
|
||||
this.$root.socket.on('playlist_removed', this.playlistRemoved)
|
||||
this.$root.socket.on('author_added', this.authorAdded)
|
||||
this.$root.socket.on('author_updated', this.authorUpdated)
|
||||
this.$root.socket.on('author_removed', this.authorRemoved)
|
||||
this.$root.socket.on('share_open', this.shareOpen)
|
||||
this.$root.socket.on('share_closed', this.shareClosed)
|
||||
} else {
|
||||
@@ -756,6 +814,9 @@ export default {
|
||||
this.$root.socket.off('playlist_added', this.playlistAdded)
|
||||
this.$root.socket.off('playlist_updated', this.playlistUpdated)
|
||||
this.$root.socket.off('playlist_removed', this.playlistRemoved)
|
||||
this.$root.socket.off('author_added', this.authorAdded)
|
||||
this.$root.socket.off('author_updated', this.authorUpdated)
|
||||
this.$root.socket.off('author_removed', this.authorRemoved)
|
||||
this.$root.socket.off('share_open', this.shareOpen)
|
||||
this.$root.socket.off('share_closed', this.shareClosed)
|
||||
} else {
|
||||
@@ -764,10 +825,14 @@ export default {
|
||||
},
|
||||
destroyEntityComponents() {
|
||||
for (const key in this.entityComponentRefs) {
|
||||
if (this.entityComponentRefs[key] && this.entityComponentRefs[key].destroy) {
|
||||
this.entityComponentRefs[key].destroy()
|
||||
const ref = this.entityComponentRefs[key]
|
||||
if (ref && ref.destroy) {
|
||||
if (ref.$el) ref.$el.remove()
|
||||
ref.destroy()
|
||||
}
|
||||
}
|
||||
this.entityComponentRefs = {}
|
||||
this.entityIndexesMounted = []
|
||||
},
|
||||
scan() {
|
||||
this.tempIsScanning = true
|
||||
@@ -780,6 +845,14 @@ export default {
|
||||
.finally(() => {
|
||||
this.tempIsScanning = false
|
||||
})
|
||||
},
|
||||
entitiesInShelf(shelf) {
|
||||
return shelf == this.totalShelves ? this.totalEntities % this.entitiesPerShelf || this.entitiesPerShelf : this.entitiesPerShelf
|
||||
},
|
||||
entityTransform(entityIndex) {
|
||||
const shelfOffsetY = this.shelfPaddingHeight * this.sizeMultiplier
|
||||
const shelfOffsetX = (entityIndex - 1) * this.totalEntityCardWidth + this.bookshelfMarginLeft
|
||||
return `translate3d(${shelfOffsetX}px, ${shelfOffsetY}px, 0px)`
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<template>
|
||||
<div v-if="streamLibraryItem" id="mediaPlayerContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 lg:h-40 z-50 bg-primary px-2 lg:px-4 pb-1 lg:pb-4 pt-2">
|
||||
<div id="videoDock" />
|
||||
<div class="absolute left-2 top-2 lg:left-4 cursor-pointer">
|
||||
<covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||
</div>
|
||||
<div class="flex items-start mb-6 lg:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
||||
<div class="flex items-start mb-6 lg:mb-0" :class="isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
||||
<div class="min-w-0 w-full">
|
||||
<div class="flex items-center">
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
|
||||
@@ -12,10 +11,9 @@
|
||||
</nuxt-link>
|
||||
<widgets-explicit-indicator v-if="isExplicit" />
|
||||
</div>
|
||||
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center w-1/2 sm:w-4/5 lg:w-2/5">
|
||||
<div class="text-gray-400 flex items-center w-1/2 sm:w-4/5 lg:w-2/5">
|
||||
<span class="material-symbols text-sm">person</span>
|
||||
<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 truncate">
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</div>
|
||||
@@ -55,16 +53,13 @@
|
||||
@showBookmarks="showBookmarks"
|
||||
@showSleepTimer="showSleepTimerModal = true"
|
||||
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
||||
@showPlayerSettings="showPlayerSettingsModal = true"
|
||||
/>
|
||||
|
||||
<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" :current-time="bookmarkCurrentTime" :playback-rate="currentPlaybackRate" :library-item-id="libraryItemId" @select="selectBookmark" />
|
||||
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||
|
||||
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" />
|
||||
|
||||
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -83,7 +78,6 @@ export default {
|
||||
currentTime: 0,
|
||||
showSleepTimerModal: false,
|
||||
showPlayerQueueItemsModal: false,
|
||||
showPlayerSettingsModal: false,
|
||||
sleepTimerSet: false,
|
||||
sleepTimerRemaining: 0,
|
||||
sleepTimerType: null,
|
||||
@@ -140,9 +134,6 @@ export default {
|
||||
isPodcast() {
|
||||
return this.streamLibraryItem?.mediaType === 'podcast'
|
||||
},
|
||||
isMusic() {
|
||||
return this.streamLibraryItem?.mediaType === 'music'
|
||||
},
|
||||
isExplicit() {
|
||||
return !!this.mediaMetadata.explicit
|
||||
},
|
||||
@@ -172,11 +163,7 @@ export default {
|
||||
},
|
||||
podcastAuthor() {
|
||||
if (!this.isPodcast) return null
|
||||
return this.mediaMetadata.author || 'Unknown'
|
||||
},
|
||||
musicArtists() {
|
||||
if (!this.isMusic) return null
|
||||
return this.mediaMetadata.artists.join(', ')
|
||||
return this.mediaMetadata.author || this.$strings.LabelUnknown
|
||||
},
|
||||
hasNextItemInQueue() {
|
||||
return this.currentPlayerQueueIndex < this.playerQueueItems.length - 1
|
||||
@@ -260,7 +247,7 @@ export default {
|
||||
sleepTimerEnd() {
|
||||
this.clearSleepTimer()
|
||||
this.playerHandler.pause()
|
||||
this.$toast.info('Sleep Timer Done.. zZzzZz')
|
||||
this.$toast.info(this.$strings.ToastSleepTimerDone)
|
||||
},
|
||||
cancelSleepTimer() {
|
||||
this.showSleepTimerModal = false
|
||||
@@ -387,19 +374,28 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API
|
||||
if ('mediaSession' in navigator) {
|
||||
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true)
|
||||
const artwork = [
|
||||
{
|
||||
src: coverImageSrc
|
||||
}
|
||||
]
|
||||
const chapterInfo = []
|
||||
if (this.chapters.length) {
|
||||
this.chapters.forEach((chapter) => {
|
||||
chapterInfo.push({
|
||||
title: chapter.title,
|
||||
startTime: chapter.start
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: this.title,
|
||||
artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown',
|
||||
album: this.mediaMetadata.seriesName || '',
|
||||
artwork
|
||||
artwork: [
|
||||
{
|
||||
src: this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true)
|
||||
}
|
||||
],
|
||||
chapterInfo
|
||||
})
|
||||
console.log('Set media session metadata', navigator.mediaSession.metadata)
|
||||
|
||||
@@ -534,7 +530,7 @@ export default {
|
||||
},
|
||||
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' })
|
||||
this.syncFailedToast = this.$toast(this.$strings.ToastProgressIsNotBeingSynced, { timeout: false, type: 'error' })
|
||||
},
|
||||
sessionClosedEvent(sessionId) {
|
||||
if (this.playerHandler.currentSessionId === sessionId) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
|
||||
<div role="toolbar" aria-orientation="vertical" aria-label="Library Sidebar" 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" />
|
||||
|
||||
<div id="siderail-buttons-container" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden">
|
||||
<div id="siderail-buttons-container" role="navigation" aria-label="Library Navigation" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
@@ -58,7 +58,7 @@
|
||||
<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'">
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/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"
|
||||
@@ -95,14 +95,6 @@
|
||||
<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-symbols 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-symbols text-2xl"></span>
|
||||
|
||||
@@ -172,9 +164,6 @@ export default {
|
||||
isPodcastLibrary() {
|
||||
return this.currentLibraryMediaType === 'podcast'
|
||||
},
|
||||
isMusicLibrary() {
|
||||
return this.currentLibraryMediaType === 'music'
|
||||
},
|
||||
isPodcastDownloadQueuePage() {
|
||||
return this.$route.name === 'library-library-podcast-download-queue'
|
||||
},
|
||||
@@ -184,9 +173,6 @@ export default {
|
||||
isPodcastLatestPage() {
|
||||
return this.$route.name === 'library-library-podcast-latest'
|
||||
},
|
||||
isMusicAlbumsPage() {
|
||||
return this.paramId === 'albums'
|
||||
},
|
||||
homePage() {
|
||||
return this.$route.name === 'library-library'
|
||||
},
|
||||
@@ -194,7 +180,7 @@ export default {
|
||||
return this.$route.name === 'library-library-series-id' || this.paramId === 'series'
|
||||
},
|
||||
isAuthorsPage() {
|
||||
return this.$route.name === 'library-library-authors'
|
||||
return this.libraryBookshelfPage && this.paramId === 'authors'
|
||||
},
|
||||
isNarratorsPage() {
|
||||
return this.$route.name === 'library-library-narrators'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
|
||||
<nuxt-link :to="`/author/${author.id}`">
|
||||
<div class="pb-3e" :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
|
||||
<nuxt-link :to="`/author/${author?.id}`">
|
||||
<div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div cy-id="imageArea" :style="{ height: cardHeight + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
<!-- Image or placeholder -->
|
||||
@@ -40,7 +40,7 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
author: {
|
||||
authorMount: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
@@ -57,7 +57,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
searching: false,
|
||||
isHovering: false
|
||||
isHovering: false,
|
||||
author: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -67,35 +68,41 @@ export default {
|
||||
cardHeight() {
|
||||
return this.height * this.sizeMultiplier
|
||||
},
|
||||
coverHeight() {
|
||||
return this.cardHeight
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
return this.store.getters['user/getToken']
|
||||
},
|
||||
_author() {
|
||||
return this.author || {}
|
||||
},
|
||||
authorId() {
|
||||
return this._author.id
|
||||
return this._author?.id || ''
|
||||
},
|
||||
name() {
|
||||
return this._author.name || ''
|
||||
return this._author?.name || ''
|
||||
},
|
||||
asin() {
|
||||
return this._author.asin || ''
|
||||
return this._author?.asin || ''
|
||||
},
|
||||
numBooks() {
|
||||
return this._author.numBooks || 0
|
||||
return this._author?.numBooks || 0
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
return this.store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
return this.store.state.libraries.currentLibraryId
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
return this.store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.$store.getters['user/getSizeMultiplier']
|
||||
return this.store.getters['user/getSizeMultiplier']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -121,24 +128,54 @@ export default {
|
||||
return null
|
||||
})
|
||||
if (!response) {
|
||||
this.$toast.error(`Author ${this.name} not found`)
|
||||
this.$toast.error(this.$getString('ToastAuthorNotFound', [this.name]))
|
||||
} 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)`)
|
||||
if (response.author.imagePath) {
|
||||
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
|
||||
} else {
|
||||
this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
|
||||
}
|
||||
} else {
|
||||
this.$toast.info(`No updates were made for Author ${response.author.name}`)
|
||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
||||
}
|
||||
this.searching = false
|
||||
},
|
||||
setSearching(isSearching) {
|
||||
this.searching = isSearching
|
||||
}
|
||||
},
|
||||
setEntity(author) {
|
||||
this.removeListeners()
|
||||
this.author = author
|
||||
this.addListeners()
|
||||
},
|
||||
addListeners() {
|
||||
if (this.author) {
|
||||
this.$eventBus.$on(`searching-author-${this.authorId}`, this.setSearching)
|
||||
}
|
||||
},
|
||||
removeListeners() {
|
||||
if (this.author) {
|
||||
this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching)
|
||||
}
|
||||
},
|
||||
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()
|
||||
}
|
||||
},
|
||||
setSelectionMode(val) {}
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on(`searching-author-${this.authorId}`, this.setSearching)
|
||||
if (this.authorMount) this.setEntity(this.authorMount)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching)
|
||||
this.removeListeners()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full max-h-12 overflow-hidden">
|
||||
<p class="text-gray-500 text-xs">{{ book.description }}</p>
|
||||
<p class="text-gray-500 text-xs">{{ book.descriptionPlain }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="px-4 flex-grow">
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<p class="truncate text-sm">{{ title }}</p>
|
||||
|
||||
<p class="truncate text-xs text-gray-300">{{ description }}</p>
|
||||
<p v-if="specialMessage" class="truncate text-xs text-gray-300">{{ specialMessage }}</p>
|
||||
|
||||
<p v-if="isFailed && failedMessage" class="text-xs truncate text-red-500">{{ failedMessage }}</p>
|
||||
<p v-else-if="!isFinished && cancelingScan" class="text-xs truncate">Canceling...</p>
|
||||
@@ -26,7 +27,16 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
cancelingScan: false
|
||||
cancelingScan: false,
|
||||
specialMessage: ''
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
task: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.initTask()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -34,14 +44,17 @@ export default {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
title() {
|
||||
if (this.task.titleKey && this.$strings[this.task.titleKey]) {
|
||||
return this.$getString(this.task.titleKey, this.task.titleSubs)
|
||||
}
|
||||
return this.task.title || 'No Title'
|
||||
},
|
||||
description() {
|
||||
if (this.task.descriptionKey && this.$strings[this.task.descriptionKey]) {
|
||||
return this.$getString(this.task.descriptionKey, this.task.descriptionSubs)
|
||||
}
|
||||
return this.task.description || ''
|
||||
},
|
||||
details() {
|
||||
return this.task.details || 'Unknown'
|
||||
},
|
||||
isFinished() {
|
||||
return !!this.task.isFinished
|
||||
},
|
||||
@@ -52,6 +65,9 @@ export default {
|
||||
return this.isFinished && !this.isFailed
|
||||
},
|
||||
failedMessage() {
|
||||
if (this.task.errorKey && this.$strings[this.task.errorKey]) {
|
||||
return this.$getString(this.task.errorKey, this.task.errorSubs)
|
||||
}
|
||||
return this.task.error || ''
|
||||
},
|
||||
action() {
|
||||
@@ -87,6 +103,21 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initTask() {
|
||||
// special message for library scan tasks
|
||||
if (this.task?.data?.scanResults) {
|
||||
const scanResults = this.task.data.scanResults
|
||||
const strs = []
|
||||
if (scanResults.added) strs.push(this.$getString('MessageTaskScanItemsAdded', [scanResults.added]))
|
||||
if (scanResults.updated) strs.push(this.$getString('MessageTaskScanItemsUpdated', [scanResults.updated]))
|
||||
if (scanResults.missing) strs.push(this.$getString('MessageTaskScanItemsMissing', [scanResults.missing]))
|
||||
const changesDetected = strs.length > 0 ? strs.join(', ') : this.$strings.MessageTaskScanNoChangesNeeded
|
||||
const timeElapsed = scanResults.elapsed ? ` (${this.$elapsedPretty(scanResults.elapsed / 1000, false, true)})` : ''
|
||||
this.specialMessage = `${changesDetected}${timeElapsed}`
|
||||
} else {
|
||||
this.specialMessage = ''
|
||||
}
|
||||
},
|
||||
cancelScan() {
|
||||
const libraryId = this.task?.data?.libraryId
|
||||
if (!libraryId) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div ref="card" :id="`book-card-${index}`" tabindex="0" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded overflow-hidden z-10 bg-primary box-shadow-book" :style="{ height: coverHeight + 'px ' }">
|
||||
<!-- When cover image does not fill -->
|
||||
<div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
||||
@@ -14,32 +14,25 @@
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
||||
<div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }">
|
||||
<div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" aria-hidden="true" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }" class="text-gray-300 text-center">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Cover Image -->
|
||||
<img cy-id="coverImage" v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||
<img cy-id="coverImage" v-if="libraryItem" :alt="`${displayTitle}, ${$strings.LabelCover}`" ref="cover" aria-hidden="true" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||
|
||||
<!-- Placeholder Cover Title & Author -->
|
||||
<div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }">
|
||||
<div>
|
||||
<p cy-id="placeholderTitleText" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
|
||||
<p cy-id="placeholderTitleText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }">
|
||||
<p cy-id="placeholderAuthorText" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #78350f">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }">#{{ seriesSequenceList }}</p>
|
||||
</div>
|
||||
<div v-else-if="booksInSeries" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #cd9d49dd">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }">{{ booksInSeries }}</p>
|
||||
<p cy-id="placeholderAuthorText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
|
||||
</div>
|
||||
|
||||
<!-- No progress shown for podcasts (unless showing podcast episode) -->
|
||||
<div cy-id="progressBar" v-if="!isPodcast || episodeProgress" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * userProgressPercent + 'px' }"></div>
|
||||
<div cy-id="progressBar" v-if="!isPodcast || episodeProgress" class="absolute bottom-0 left-0 h-1e max-w-full z-20 rounded-b box-shadow-progressbar" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<!-- Overlay is not shown if collapsing series in library -->
|
||||
<div cy-id="overlay" v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen) && !processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded md:block" :class="overlayWrapperClasslist">
|
||||
@@ -93,11 +86,11 @@
|
||||
|
||||
<!-- rss feed icon -->
|
||||
<div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 + 'em' }">
|
||||
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span>
|
||||
<span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span>
|
||||
</div>
|
||||
<!-- media item shared icon -->
|
||||
<div cy-id="mediaItemShare" v-if="mediaItemShare && !isSelectionMode && !isHovering" class="absolute text-success left-0 z-10" :style="{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }">
|
||||
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">public</span>
|
||||
<span class="material-symbols" aria-hidden="true" :style="{ fontSize: 1.5 + 'em' }">public</span>
|
||||
</div>
|
||||
|
||||
<!-- Series sequence -->
|
||||
@@ -114,7 +107,7 @@
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
<div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }">{{ numEpisodes }}</p>
|
||||
<p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfEpisodes">{{ numEpisodes }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
@@ -226,9 +219,6 @@ export default {
|
||||
isPodcast() {
|
||||
return this.mediaType === 'podcast' || this.store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
|
||||
},
|
||||
isMusic() {
|
||||
return this.mediaType === 'music'
|
||||
},
|
||||
isExplicit() {
|
||||
return this.mediaMetadata.explicit || false
|
||||
},
|
||||
@@ -247,6 +237,7 @@ export default {
|
||||
return this.mediaMetadata.series
|
||||
},
|
||||
seriesName() {
|
||||
if (this.collapsedSeries?.name) return this.collapsedSeries.name
|
||||
return this.series?.name || null
|
||||
},
|
||||
seriesSequence() {
|
||||
@@ -328,7 +319,7 @@ export default {
|
||||
},
|
||||
displaySubtitle() {
|
||||
if (!this.libraryItem) return '\u00A0'
|
||||
if (this.collapsedSeries) return this.collapsedSeries.numBooks === 1 ? '1 book' : `${this.collapsedSeries.numBooks} books`
|
||||
if (this.collapsedSeries) return `${this.collapsedSeries.numBooks} ${this.$strings.LabelBooks}`
|
||||
if (this.mediaMetadata.subtitle) return this.mediaMetadata.subtitle
|
||||
if (this.mediaMetadata.seriesName) return this.mediaMetadata.seriesName
|
||||
return ''
|
||||
@@ -336,7 +327,6 @@ export default {
|
||||
displayLineTwo() {
|
||||
if (this.recentEpisode) return this.title
|
||||
if (this.isPodcast) return this.author
|
||||
if (this.isMusic) return this.artist
|
||||
if (this.collapsedSeries) return ''
|
||||
if (this.isAuthorBookshelfView) {
|
||||
return this.mediaMetadata.publishedYear || ''
|
||||
@@ -364,7 +354,6 @@ export default {
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
|
||||
},
|
||||
userProgress() {
|
||||
if (this.isMusic) return null
|
||||
if (this.episodeProgress) return this.episodeProgress
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
@@ -420,7 +409,7 @@ export default {
|
||||
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
|
||||
},
|
||||
showPlayButton() {
|
||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
|
||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
||||
},
|
||||
showSmallEBookIcon() {
|
||||
return !this.isSelectionMode && this.ebookFormat
|
||||
@@ -464,8 +453,6 @@ export default {
|
||||
return this.store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
moreMenuItems() {
|
||||
if (this.isMusic) return []
|
||||
|
||||
if (this.recentEpisode) {
|
||||
const items = [
|
||||
{
|
||||
@@ -823,7 +810,7 @@ export default {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove series from home', error)
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdateUser)
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
@@ -841,7 +828,7 @@ export default {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to hide item from home', error)
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdateUser)
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="card" :id="`collection-card-${index}`" :style="{ width: cardWidth + '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 ref="card" :id="`collection-card-${index}`" role="button" :style="{ width: cardWidth + '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="relative" :style="{ height: coverHeight + 'px' }">
|
||||
<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">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="card" :id="`playlist-card-${index}`" :style="{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" 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 ref="card" :id="`playlist-card-${index}`" role="button" :style="{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" 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="relative" :style="{ height: coverHeight + 'px' }">
|
||||
<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">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div cy-id="card" ref="card" :id="`series-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div cy-id="card" ref="card" :id="`series-card-${index}`" tabindex="0" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div cy-id="covers-area" class="relative" :style="{ height: coverHeight + 'px' }">
|
||||
<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">
|
||||
@@ -7,12 +7,12 @@
|
||||
</div>
|
||||
|
||||
<div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }">{{ books.length }}</p>
|
||||
<p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfBooks">{{ books.length }}</p>
|
||||
</div>
|
||||
|
||||
<div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||
<div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b w-full box-shadow-progressbar" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||
|
||||
<div cy-id="hoveringDisplayTitle" 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: '1em' }">
|
||||
<div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" aria-hidden="true" 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: '1em' }">
|
||||
<p :style="{ fontSize: 1.2 + 'em' }">{{ displayTitle }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ export default {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update notification', error)
|
||||
this.$toast.error(this.$strings.ToastNotificationUpdateFailed)
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
.finally(() => {
|
||||
this.enabling = false
|
||||
|
||||
@@ -27,38 +27,6 @@
|
||||
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=publishers.${$encode(publisher)}`" class="hover:underline">{{ publisher }}</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicAlbum" class="flex py-0.5">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-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-24 min-w-24 sm:w-32 sm:min-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-24 min-w-24 sm:w-32 sm:min-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-24 min-w-24 sm:w-32 sm:min-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-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPodcastType }}</span>
|
||||
@@ -97,7 +65,7 @@
|
||||
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=languages.${$encode(language)}`" class="hover:underline">{{ language }}</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
|
||||
<div v-if="tracks.length || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
|
||||
</div>
|
||||
@@ -134,10 +102,6 @@ export default {
|
||||
isPodcast() {
|
||||
return this.libraryItem.mediaType === 'podcast'
|
||||
},
|
||||
audioFile() {
|
||||
// Music track
|
||||
return this.media.audioFile
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
@@ -168,25 +132,6 @@ export default {
|
||||
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 || []
|
||||
},
|
||||
@@ -220,4 +165,4 @@ export default {
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<div class="w-full relative sm:w-80">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<form role="search" @submit.prevent="submitSearch">
|
||||
<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">
|
||||
<button :aria-hidden="!search" 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-symbols" style="font-size: 1.2rem"></span>
|
||||
<span v-else class="material-symbols" style="font-size: 1.2rem">close</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 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-full max-w-64 sm:max-w-80 sm:w-80 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" @mousedown.stop.prevent>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<li v-if="isTyping" class="py-2 px-2">
|
||||
<p>{{ $strings.MessageThinking }}</p>
|
||||
@@ -157,7 +157,7 @@ export default {
|
||||
clearTimeout(this.focusTimeout)
|
||||
this.focusTimeout = setTimeout(() => {
|
||||
this.showMenu = false
|
||||
}, 200)
|
||||
}, 100)
|
||||
},
|
||||
async runSearch(value) {
|
||||
this.lastSearch = value
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
<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>
|
||||
<div class="relative h-7">
|
||||
<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="menu" :aria-expanded="showMenu" @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>
|
||||
</button>
|
||||
<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">
|
||||
<button v-else :aria-label="$strings.ButtonClearFilter" 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-symbols" style="font-size: 1.1rem">close</span>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<ul v-show="!sublist" class="h-full w-full" role="menu">
|
||||
<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)">
|
||||
<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="menuitem" :aria-haspopup="item.sublist ? '' : 'menu'" @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-symbols text-2xl">arrow_right</span>
|
||||
<span class="material-symbols text-2xl" :aria-label="$strings.LabelMore">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">
|
||||
@@ -31,8 +33,8 @@
|
||||
</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">
|
||||
<ul v-show="sublist" class="h-full w-full" role="menu">
|
||||
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="menuitem" @click="sublist = null">
|
||||
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
||||
<span class="material-symbols text-2xl">arrow_left</span>
|
||||
</div>
|
||||
@@ -40,13 +42,13 @@
|
||||
<span class="font-normal block truncate">{{ $strings.ButtonBack }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
|
||||
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="menuitem">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate py-2">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</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)">
|
||||
<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="menuitem" @click="clickedSublistOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
|
||||
</div>
|
||||
@@ -98,9 +100,6 @@ export default {
|
||||
isPodcast() {
|
||||
return this.libraryMediaType === 'podcast'
|
||||
},
|
||||
isMusic() {
|
||||
return this.libraryMediaType === 'music'
|
||||
},
|
||||
seriesItems() {
|
||||
return [
|
||||
{
|
||||
@@ -192,6 +191,12 @@ export default {
|
||||
value: 'publishers',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelPublishedDecade,
|
||||
textPlural: this.$strings.LabelPublishedDecades,
|
||||
value: 'publishedDecades',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLanguage,
|
||||
textPlural: this.$strings.LabelLanguages,
|
||||
@@ -274,35 +279,9 @@ export default {
|
||||
}
|
||||
]
|
||||
},
|
||||
musicItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelAll,
|
||||
value: 'all'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelGenre,
|
||||
textPlural: this.$strings.LabelGenres,
|
||||
value: 'genres',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelTag,
|
||||
textPlural: this.$strings.LabelTags,
|
||||
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() {
|
||||
@@ -367,6 +346,9 @@ export default {
|
||||
publishers() {
|
||||
return this.filterData.publishers || []
|
||||
},
|
||||
publishedDecades() {
|
||||
return this.filterData.publishedDecades || []
|
||||
},
|
||||
progress() {
|
||||
return [
|
||||
{
|
||||
@@ -433,21 +415,17 @@ export default {
|
||||
id: 'isbn',
|
||||
name: 'ISBN'
|
||||
},
|
||||
{
|
||||
id: 'subtitle',
|
||||
name: this.$strings.LabelSubtitle
|
||||
},
|
||||
{
|
||||
id: 'authors',
|
||||
name: this.$strings.LabelAuthor
|
||||
},
|
||||
{
|
||||
id: 'publishedYear',
|
||||
name: this.$strings.LabelPublishYear
|
||||
id: 'chapters',
|
||||
name: this.$strings.LabelChapters
|
||||
},
|
||||
{
|
||||
id: 'series',
|
||||
name: this.$strings.LabelSeries
|
||||
id: 'cover',
|
||||
name: this.$strings.LabelCover
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
@@ -458,24 +436,32 @@ export default {
|
||||
name: this.$strings.LabelGenres
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
name: this.$strings.LabelTags
|
||||
id: 'language',
|
||||
name: this.$strings.LabelLanguage
|
||||
},
|
||||
{
|
||||
id: 'narrators',
|
||||
name: this.$strings.LabelNarrator
|
||||
},
|
||||
{
|
||||
id: 'publishedYear',
|
||||
name: this.$strings.LabelPublishYear
|
||||
},
|
||||
{
|
||||
id: 'publisher',
|
||||
name: this.$strings.LabelPublisher
|
||||
},
|
||||
{
|
||||
id: 'language',
|
||||
name: this.$strings.LabelLanguage
|
||||
id: 'series',
|
||||
name: this.$strings.LabelSeries
|
||||
},
|
||||
{
|
||||
id: 'cover',
|
||||
name: this.$strings.LabelCover
|
||||
id: 'subtitle',
|
||||
name: this.$strings.LabelSubtitle
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
name: this.$strings.LabelTags
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<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 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="menu" :aria-expanded="showMenu" @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-symbols text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
<span class="material-symbols text-lg text-yellow-400" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ 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">
|
||||
<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="menu">
|
||||
<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)">
|
||||
<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="menuitem" @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-symbols text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
<span class="material-symbols text-xl" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
@@ -56,9 +56,6 @@ export default {
|
||||
isPodcast() {
|
||||
return this.libraryMediaType === 'podcast'
|
||||
},
|
||||
isMusic() {
|
||||
return this.libraryMediaType === 'music'
|
||||
},
|
||||
podcastItems() {
|
||||
return [
|
||||
{
|
||||
@@ -148,40 +145,10 @@ export default {
|
||||
}
|
||||
]
|
||||
},
|
||||
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 {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<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 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base">x</span></span>
|
||||
<span class="text-gray-200 text-sm sm:text-base">{{ playbackRateDisplay }}<span class="text-base">x</span></span>
|
||||
</div>
|
||||
<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 v-show="showMenu" class="absolute -top-[5.5rem] 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>
|
||||
@@ -11,15 +11,15 @@
|
||||
<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">x</span></p>
|
||||
<p class="text-xs text-center">{{ rate }}<span class="text-sm">x</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="w-full py-1 px-4">
|
||||
<div class="w-full py-1 px-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" />
|
||||
<p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl">x</span></p>
|
||||
<p class="px-2 text-2xl sm:text-3xl">{{ playbackRateDisplay }}<span class="text-2xl">x</span></p>
|
||||
<ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,6 +33,10 @@ export default {
|
||||
value: {
|
||||
type: [String, Number],
|
||||
default: 1
|
||||
},
|
||||
playbackRateIncrementDecrement: {
|
||||
type: Number,
|
||||
default: 0.1
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -41,7 +45,7 @@ export default {
|
||||
currentPlaybackRate: 0,
|
||||
MIN_SPEED: 0.5,
|
||||
MAX_SPEED: 10,
|
||||
menuLeft: -92,
|
||||
menuLeft: -96,
|
||||
arrowLeft: 0
|
||||
}
|
||||
},
|
||||
@@ -58,10 +62,17 @@ export default {
|
||||
return [0.5, 1, 1.2, 1.5, 2]
|
||||
},
|
||||
canIncrement() {
|
||||
return this.playbackRate + 0.1 <= this.MAX_SPEED
|
||||
return this.playbackRate + this.playbackRateIncrementDecrement <= this.MAX_SPEED
|
||||
},
|
||||
canDecrement() {
|
||||
return this.playbackRate - 0.1 >= this.MIN_SPEED
|
||||
return this.playbackRate - this.playbackRateIncrementDecrement >= this.MIN_SPEED
|
||||
},
|
||||
playbackRateDisplay() {
|
||||
if (this.playbackRateIncrementDecrement == 0.05) return this.playbackRate.toFixed(2)
|
||||
// For 0.1 increment: Only show 2 decimal places if the playback rate is 2 decimals
|
||||
const numDecimals = String(this.playbackRate).split('.')[1]?.length || 0
|
||||
if (numDecimals <= 1) return this.playbackRate.toFixed(1)
|
||||
return this.playbackRate.toFixed(2)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -73,14 +84,14 @@ export default {
|
||||
this.$nextTick(() => this.setShowMenu(false))
|
||||
},
|
||||
increment() {
|
||||
if (this.playbackRate + 0.1 > this.MAX_SPEED) return
|
||||
var newPlaybackRate = this.playbackRate + 0.1
|
||||
this.playbackRate = Number(newPlaybackRate.toFixed(1))
|
||||
if (this.playbackRate + this.playbackRateIncrementDecrement > this.MAX_SPEED) return
|
||||
var newPlaybackRate = this.playbackRate + this.playbackRateIncrementDecrement
|
||||
this.playbackRate = Number(newPlaybackRate.toFixed(2))
|
||||
},
|
||||
decrement() {
|
||||
if (this.playbackRate - 0.1 < this.MIN_SPEED) return
|
||||
var newPlaybackRate = this.playbackRate - 0.1
|
||||
this.playbackRate = Number(newPlaybackRate.toFixed(1))
|
||||
if (this.playbackRate - this.playbackRateIncrementDecrement < this.MIN_SPEED) return
|
||||
var newPlaybackRate = this.playbackRate - this.playbackRateIncrementDecrement
|
||||
this.playbackRate = Number(newPlaybackRate.toFixed(2))
|
||||
},
|
||||
updateMenuPositions() {
|
||||
if (!this.$refs.wrapper) return
|
||||
@@ -89,9 +100,9 @@ export default {
|
||||
if (boundingBox.left + 110 > window.innerWidth - 10) {
|
||||
this.menuLeft = window.innerWidth - 230 - boundingBox.left
|
||||
|
||||
this.arrowLeft = Math.abs(this.menuLeft) - 92
|
||||
this.arrowLeft = Math.abs(this.menuLeft) - 96
|
||||
} else {
|
||||
this.menuLeft = -92
|
||||
this.menuLeft = -96
|
||||
this.arrowLeft = 0
|
||||
}
|
||||
},
|
||||
@@ -109,4 +120,4 @@ export default {
|
||||
this.currentPlaybackRate = this.playbackRate
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||
<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">
|
||||
<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="menu" :aria-expanded="showMenu" @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-symbols text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
<span class="material-symbols text-lg text-yellow-400" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ 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 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none 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="menu">
|
||||
<template v-for="item in items">
|
||||
<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)">
|
||||
<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="menuitem" @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-symbols text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
<span class="material-symbols text-xl" :aria-label="descending ? $strings.LabelSortDescending : $strings.LabelSortAscending">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
<span class="material-symbols text-2xl sm:text-3xl">{{ volumeIcon }}</span>
|
||||
</button>
|
||||
<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">
|
||||
<div ref="volumeTrack" class="h-1 w-full bg-gray-500 my-2.5 relative cursor-pointer rounded-full" @mousedown="mousedownTrack" @click="clickVolumeTrack">
|
||||
<div class="bg-gray-100 h-full absolute left-0 top-0 pointer-events-none rounded-full" :style="{ width: volume * trackWidth + 'px' }" />
|
||||
<div class="w-2.5 h-2.5 bg-white shadow-sm rounded-full absolute pointer-events-none" :class="isDragging ? 'transform scale-125 origin-center' : ''" :style="{ left: cursorLeft + 'px', top: '-3px' }" />
|
||||
<div v-show="isOpen" class="volumeMenu h-28 absolute bottom-2 w-6 py-2 bg-bg shadow-sm rounded-lg" style="top: -116px">
|
||||
<div ref="volumeTrack" class="w-1 h-full bg-gray-500 mx-2.5 relative cursor-pointer rounded-full" @mousedown="mousedownTrack" @click="clickVolumeTrack">
|
||||
<div class="bg-gray-100 w-full absolute left-0 bottom-0 pointer-events-none rounded-full" :style="{ height: volume * trackHeight + 'px' }" />
|
||||
<div class="w-2.5 h-2.5 bg-white shadow-sm rounded-full absolute pointer-events-none" :class="isDragging ? 'transform scale-125 origin-center' : ''" :style="{ bottom: cursorBottom + 'px', left: '-3px' }" />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
@@ -24,10 +24,10 @@ export default {
|
||||
isOpen: false,
|
||||
isDragging: false,
|
||||
isHovering: false,
|
||||
posX: 0,
|
||||
posY: 0,
|
||||
lastValue: 0.5,
|
||||
isMute: false,
|
||||
trackWidth: 112 - 20,
|
||||
trackHeight: 112 - 20,
|
||||
openTimeout: null
|
||||
}
|
||||
},
|
||||
@@ -45,9 +45,9 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
cursorLeft() {
|
||||
var left = this.trackWidth * this.volume
|
||||
return left - 3
|
||||
cursorBottom() {
|
||||
var bottom = this.trackHeight * this.volume
|
||||
return bottom - 3
|
||||
},
|
||||
volumeIcon() {
|
||||
if (this.volume <= 0) return 'volume_mute'
|
||||
@@ -89,17 +89,10 @@ export default {
|
||||
}, 600)
|
||||
},
|
||||
mousemove(e) {
|
||||
var diff = this.posX - e.x
|
||||
this.posX = e.x
|
||||
var volShift = 0
|
||||
if (diff < 0) {
|
||||
// Volume up
|
||||
volShift = diff / this.trackWidth
|
||||
} else {
|
||||
// volume down
|
||||
volShift = diff / this.trackWidth
|
||||
}
|
||||
var newVol = this.volume - volShift
|
||||
var diff = this.posY - e.y
|
||||
this.posY = e.y
|
||||
var volShift = diff / this.trackHeight
|
||||
var newVol = this.volume + volShift
|
||||
newVol = Math.min(Math.max(0, newVol), 1)
|
||||
this.volume = newVol
|
||||
e.preventDefault()
|
||||
@@ -113,8 +106,8 @@ export default {
|
||||
},
|
||||
mousedownTrack(e) {
|
||||
this.isDragging = true
|
||||
this.posX = e.x
|
||||
var vol = e.offsetX / this.trackWidth
|
||||
this.posY = e.y
|
||||
var vol = 1 - e.offsetY / this.trackHeight
|
||||
vol = Math.min(Math.max(vol, 0), 1)
|
||||
this.volume = vol
|
||||
document.body.addEventListener('mousemove', this.mousemove)
|
||||
@@ -137,7 +130,7 @@ export default {
|
||||
this.clickVolumeIcon()
|
||||
},
|
||||
clickVolumeTrack(e) {
|
||||
var vol = e.offsetX / this.trackWidth
|
||||
var vol = 1 - e.offsetY / this.trackHeight
|
||||
vol = Math.min(Math.max(vol, 0), 1)
|
||||
this.volume = vol
|
||||
}
|
||||
@@ -147,7 +140,7 @@ export default {
|
||||
this.isMute = true
|
||||
}
|
||||
const storageVolume = localStorage.getItem('volume')
|
||||
if (storageVolume) {
|
||||
if (storageVolume && !isNaN(storageVolume)) {
|
||||
this.volume = parseFloat(storageVolume)
|
||||
}
|
||||
},
|
||||
@@ -157,4 +150,4 @@ export default {
|
||||
document.body.removeEventListener('mouseup', this.mouseup)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -56,24 +56,15 @@ export default {
|
||||
},
|
||||
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}`
|
||||
return `${this.$config.routerBasePath}/api/authors/${this.authorId}/image?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) {
|
||||
if (imgAr < 0.5 || imgAr > 2) {
|
||||
this.showCoverBg = true
|
||||
} else {
|
||||
this.showCoverBg = false
|
||||
|
||||
@@ -121,6 +121,8 @@ export default {
|
||||
|
||||
var img = document.createElement('img')
|
||||
img.src = src
|
||||
img.alt = `${this.name}, ${this.$strings.LabelCover}`
|
||||
img.ariaHidden = true
|
||||
img.className = 'absolute top-0 left-0 w-full h-full'
|
||||
img.style.objectFit = showCoverBg ? 'contain' : 'cover'
|
||||
|
||||
|
||||
@@ -69,6 +69,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p id="ereader-permissions-toggle">{{ $strings.LabelPermissionsCreateEreader }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch labeledBy="ereader-permissions-toggle" v-model="newUser.permissions.createEreader" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p id="explicit-content-permissions-toggle">{{ $strings.LabelPermissionsAccessExplicitContent }}</p>
|
||||
@@ -296,7 +305,7 @@ export default {
|
||||
.then((data) => {
|
||||
this.processing = false
|
||||
if (data.error) {
|
||||
this.$toast.error(`${this.$strings.ToastAccountUpdateFailed}: ${data.error}`)
|
||||
this.$toast.error(`${this.$strings.ToastFailedToUpdate}: ${data.error}`)
|
||||
} else {
|
||||
console.log('Account updated', data.user)
|
||||
|
||||
@@ -313,7 +322,7 @@ export default {
|
||||
this.processing = false
|
||||
console.error('Failed to update account', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(errMsg || this.$strings.ToastFailedToUpdateAccount)
|
||||
this.$toast.error(errMsg || this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
},
|
||||
submitCreateAccount() {
|
||||
@@ -351,10 +360,11 @@ export default {
|
||||
update: type === 'admin',
|
||||
delete: type === 'admin',
|
||||
upload: type === 'admin',
|
||||
accessExplicitContent: true,
|
||||
accessExplicitContent: type === 'admin',
|
||||
accessAllLibraries: true,
|
||||
accessAllTags: true,
|
||||
selectedTagsNotAccessible: false
|
||||
selectedTagsNotAccessible: false,
|
||||
createEreader: type === 'admin'
|
||||
}
|
||||
},
|
||||
init() {
|
||||
@@ -386,8 +396,9 @@ export default {
|
||||
upload: false,
|
||||
accessAllLibraries: true,
|
||||
accessAllTags: true,
|
||||
accessExplicitContent: true,
|
||||
selectedTagsNotAccessible: false
|
||||
accessExplicitContent: false,
|
||||
selectedTagsNotAccessible: false,
|
||||
createEreader: false
|
||||
},
|
||||
librariesAccessible: [],
|
||||
itemTagsSelected: []
|
||||
|
||||
@@ -90,8 +90,8 @@
|
||||
<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-symbols">{{ copiedToClipboard ? 'check' : 'content_copy' }}</span>
|
||||
<button class="absolute top-4 right-4" :class="hasCopied ? 'text-success' : 'text-gray-400 hover:text-white'" @click.stop="copyToClipboard">
|
||||
<span class="material-symbols">{{ hasCopied ? 'done' : 'content_copy' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,14 +113,13 @@ export default {
|
||||
return {
|
||||
probingFile: false,
|
||||
ffprobeData: null,
|
||||
copiedToClipboard: false
|
||||
hasCopied: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.ffprobeData = null
|
||||
this.copiedToClipboard = false
|
||||
this.probingFile = false
|
||||
}
|
||||
}
|
||||
@@ -165,8 +164,13 @@ export default {
|
||||
this.probingFile = false
|
||||
})
|
||||
},
|
||||
async copyFfprobeData() {
|
||||
this.copiedToClipboard = await this.$copyToClipboard(this.prettyFfprobeData)
|
||||
copyToClipboard() {
|
||||
clearTimeout(this.hasCopied)
|
||||
this.$copyToClipboard(this.prettyFfprobeData).then((success) => {
|
||||
this.hasCopied = setTimeout(() => {
|
||||
this.hasCopied = null
|
||||
}, 2000)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</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 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 py-4">
|
||||
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
|
||||
<div class="flex px-8 items-center py-2">
|
||||
@@ -54,8 +54,7 @@ export default {
|
||||
options: {
|
||||
provider: undefined,
|
||||
overrideDetails: true,
|
||||
overrideCover: true,
|
||||
overrideDefaults: true
|
||||
overrideCover: true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -99,8 +98,8 @@ export default {
|
||||
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
|
||||
if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) {
|
||||
this.lastUsedLibrary = this.currentLibraryId
|
||||
this.options.provider = this.libraryProvider
|
||||
}
|
||||
},
|
||||
@@ -116,10 +115,10 @@ export default {
|
||||
libraryItemIds: this.selectedBookIds
|
||||
})
|
||||
.then(() => {
|
||||
this.$toast.info('Batch quick match of ' + this.selectedBookIds.length + ' books started!')
|
||||
this.$toast.info(this.$getString('ToastBatchQuickMatchStarted', [this.selectedBookIds.length]))
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error('Batch quick match failed')
|
||||
this.$toast.error(this.$strings.ToastBatchQuickMatchFailed)
|
||||
console.error('Failed to batch quick match', error)
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -5,24 +5,26 @@
|
||||
<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">
|
||||
<div v-if="show" class="w-full rounded-lg bg-bg box-shadow-md relative" style="max-height: 80vh">
|
||||
<div v-if="bookmarks.length" class="h-full max-h-[calc(80vh-60px)] w-full relative overflow-y-auto overflow-x-hidden">
|
||||
<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" />
|
||||
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" :playback-rate="playbackRate" @click="clickBookmark" @delete="deleteBookmark" />
|
||||
</template>
|
||||
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
|
||||
<p class="text-xl">{{ $strings.MessageNoBookmarks }}</p>
|
||||
</div>
|
||||
<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>
|
||||
<div v-else class="flex h-32 items-center justify-center">
|
||||
<p class="text-xl">{{ $strings.MessageNoBookmarks }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="canCreateBookmark && !hideCreate" class="w-full border-t border-white/10">
|
||||
<form @submit.prevent="submitCreateBookmark">
|
||||
<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="w-16 max-w-16 text-center">
|
||||
<p class="text-sm font-mono text-gray-400">
|
||||
{{ this.$secondsToTimestamp(currentTime) }}
|
||||
{{ this.$secondsToTimestamp(currentTime / playbackRate) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
|
||||
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full h-10" />
|
||||
</div>
|
||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-symbols text-2xl -mt-px">add</span></ui-btn>
|
||||
</div>
|
||||
@@ -45,6 +47,7 @@ export default {
|
||||
default: 0
|
||||
},
|
||||
libraryItemId: String,
|
||||
playbackRate: Number,
|
||||
hideCreate: Boolean
|
||||
},
|
||||
data() {
|
||||
@@ -57,6 +60,7 @@ export default {
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.selectedBookmark = null
|
||||
this.showBookmarkTitleInput = false
|
||||
this.newBookmarkTitle = ''
|
||||
}
|
||||
@@ -72,7 +76,7 @@ export default {
|
||||
}
|
||||
},
|
||||
canCreateBookmark() {
|
||||
return !this.bookmarks.find((bm) => bm.time === this.currentTime)
|
||||
return !this.bookmarks.find((bm) => Math.abs(this.currentTime - bm.time) < 1)
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
@@ -102,19 +106,6 @@ export default {
|
||||
clickBookmark(bm) {
|
||||
this.$emit('select', bm)
|
||||
},
|
||||
submitUpdateBookmark(updatedBookmark) {
|
||||
var bookmark = { ...updatedBookmark }
|
||||
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.$formatDatetime(Date.now(), this.dateFormat, this.timeFormat)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<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">
|
||||
<div ref="wrapper" role="dialog" aria-modal="true" 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">
|
||||
<button type="button" 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" aria-label="Close modal">
|
||||
<span class="material-symbols text-2xl md:text-4xl">close</span>
|
||||
</div>
|
||||
</button>
|
||||
<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>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<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 ref="wrapper" role="dialog" aria-modal="true" 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" />
|
||||
|
||||
<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-symbols text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
||||
</button>
|
||||
<slot name="outer" />
|
||||
<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">
|
||||
<div ref="content" tabindex="0" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white outline-none" :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 />
|
||||
@@ -126,6 +126,9 @@ export default {
|
||||
|
||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||
this.$store.commit('setOpenModal', this.name)
|
||||
|
||||
// Set focus to the modal content
|
||||
this.content.focus()
|
||||
},
|
||||
setHide() {
|
||||
if (this.content) this.content.style.transform = 'scale(0)'
|
||||
|
||||
@@ -11,9 +11,12 @@
|
||||
<div class="flex items-center mb-4">
|
||||
<ui-select-input v-model="jumpForwardAmount" :label="$strings.LabelJumpForwardAmount" menuMaxHeight="250px" :items="jumpValues" @input="setJumpForwardAmount" />
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center mb-4">
|
||||
<ui-select-input v-model="jumpBackwardAmount" :label="$strings.LabelJumpBackwardAmount" menuMaxHeight="250px" :items="jumpValues" @input="setJumpBackwardAmount" />
|
||||
</div>
|
||||
<div class="flex items-center mb-4">
|
||||
<ui-select-input v-model="playbackRateIncrementDecrement" :label="$strings.LabelPlaybackRateIncrementDecrement" menuMaxHeight="250px" :items="playbackRateIncrementDecrementValues" @input="setPlaybackRateIncrementDecrementAmount" />
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
@@ -35,7 +38,9 @@ export default {
|
||||
{ text: this.$getString('LabelTimeDurationXMinutes', ['5']), value: 300 }
|
||||
],
|
||||
jumpForwardAmount: 10,
|
||||
jumpBackwardAmount: 10
|
||||
jumpBackwardAmount: 10,
|
||||
playbackRateIncrementDecrementValues: [0.1, 0.05],
|
||||
playbackRateIncrementDecrement: 0.1
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -59,12 +64,24 @@ export default {
|
||||
setJumpBackwardAmount(val) {
|
||||
this.jumpBackwardAmount = val
|
||||
this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val })
|
||||
},
|
||||
setPlaybackRateIncrementDecrementAmount(val) {
|
||||
this.playbackRateIncrementDecrement = val
|
||||
this.$store.dispatch('user/updateUserSettings', { playbackRateIncrementDecrement: val })
|
||||
},
|
||||
settingsUpdated() {
|
||||
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
|
||||
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
|
||||
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
|
||||
this.playbackRateIncrementDecrement = this.$store.getters['user/getUserSetting']('playbackRateIncrementDecrement')
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
|
||||
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
|
||||
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
|
||||
this.settingsUpdated()
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -16,15 +16,16 @@
|
||||
<template v-if="currentShare">
|
||||
<div class="w-full py-2">
|
||||
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelShareURL }}</label>
|
||||
<ui-text-input v-model="currentShareUrl" show-copy readonly class="text-base h-10" />
|
||||
<ui-text-input v-model="currentShareUrl" show-copy readonly />
|
||||
</div>
|
||||
<div class="w-full py-2 px-1">
|
||||
<p v-if="currentShare.expiresAt" class="text-base">{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}</p>
|
||||
<p v-if="currentShare.isDownloadable" class="text-sm mb-2">{{ $strings.LabelDownloadable }}</p>
|
||||
<p v-if="currentShare.expiresAt">{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}</p>
|
||||
<p v-else>{{ $strings.LabelPermanent }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-col sm:flex-row items-center justify-between space-y-4 sm:space-y-0 sm:space-x-4 mb-4">
|
||||
<div class="flex flex-col sm:flex-row items-center justify-between space-y-4 sm:space-y-0 sm:space-x-4 mb-2">
|
||||
<div class="w-full sm:w-48">
|
||||
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelSlug }}</label>
|
||||
<ui-text-input v-model="newShareSlug" class="text-base h-10" />
|
||||
@@ -46,6 +47,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center w-full md:w-1/2 mb-4">
|
||||
<p class="text-sm text-gray-300 py-1 px-1">{{ $strings.LabelDownloadable }}</p>
|
||||
<ui-toggle-switch size="sm" v-model="isDownloadable" />
|
||||
<ui-tooltip :text="$strings.LabelShareDownloadableHelp">
|
||||
<p class="pl-4 text-sm">
|
||||
<span class="material-symbols icon-text text-sm">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareURLWillBe', [demoShareUrl])" />
|
||||
<p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareExpirationWillBe', [expirationDateString])" />
|
||||
</template>
|
||||
@@ -81,7 +91,8 @@ export default {
|
||||
text: this.$strings.LabelDays,
|
||||
value: 'days'
|
||||
}
|
||||
]
|
||||
],
|
||||
isDownloadable: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -112,11 +123,11 @@ export default {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
demoShareUrl() {
|
||||
return `${window.origin}/share/${this.newShareSlug}`
|
||||
return `${window.origin}${this.$config.routerBasePath}/share/${this.newShareSlug}`
|
||||
},
|
||||
currentShareUrl() {
|
||||
if (!this.currentShare) return ''
|
||||
return `${window.origin}/share/${this.currentShare.slug}`
|
||||
return `${window.origin}${this.$config.routerBasePath}/share/${this.currentShare.slug}`
|
||||
},
|
||||
currentShareTimeRemaining() {
|
||||
if (!this.currentShare) return 'Error'
|
||||
@@ -172,7 +183,8 @@ export default {
|
||||
slug: this.newShareSlug,
|
||||
mediaItemType: 'book',
|
||||
mediaItemId: this.libraryItem.media.id,
|
||||
expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0
|
||||
expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0,
|
||||
isDownloadable: this.isDownloadable
|
||||
}
|
||||
this.processing = true
|
||||
this.$axios
|
||||
|
||||
@@ -148,7 +148,7 @@ export default {
|
||||
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)
|
||||
this.$toast.error(errorMsg || this.$strings.ToastFailedToUpdate)
|
||||
return null
|
||||
})
|
||||
if (result) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<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="flex items-center px-4 py-4 justify-start relative hover:bg-primary/10" :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) }}
|
||||
{{ this.$secondsToTimestamp(bookmark.time / playbackRate) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-grow overflow-hidden px-2">
|
||||
@@ -10,7 +10,7 @@
|
||||
<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" />
|
||||
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full h-10" />
|
||||
</div>
|
||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-symbols text-2xl -mt-px">forward</span></ui-btn>
|
||||
<div class="pl-2 flex items-center">
|
||||
@@ -35,7 +35,8 @@ export default {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
highlight: Boolean
|
||||
highlight: Boolean,
|
||||
playbackRate: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -83,11 +84,19 @@ export default {
|
||||
if (this.newBookmarkTitle === this.bookmark.title) {
|
||||
return this.cancelEditing()
|
||||
}
|
||||
var bookmark = { ...this.bookmark }
|
||||
const bookmark = { ...this.bookmark }
|
||||
bookmark.title = this.newBookmarkTitle
|
||||
this.$emit('update', bookmark)
|
||||
|
||||
this.$axios
|
||||
.$patch(`/api/me/item/${bookmark.libraryItemId}/bookmark`, bookmark)
|
||||
.then(() => {
|
||||
this.isEditing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
console.error(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<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>
|
||||
<h1 class="text-3xl text-white truncate">Changelog</h1>
|
||||
</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">
|
||||
@@ -13,7 +13,7 @@
|
||||
</p>
|
||||
<div class="custom-text" v-html="getChangelog(release)" />
|
||||
</div>
|
||||
<div v-if="release !== releasesToShow[releasesToShow.length - 1]" class="border-b border-black-300 my-8" />
|
||||
<div v-if="release !== releasesToShow[releasesToShow.length - 1]" :key="`${release.name}-divider`" class="border-b border-black-300 my-8" />
|
||||
</template>
|
||||
</div>
|
||||
</modals-modal>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</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 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">
|
||||
<div class="py-4 px-4">
|
||||
<h1 v-if="!showBatchCollectionModal" class="text-2xl">{{ $strings.LabelAddToCollection }}</h1>
|
||||
@@ -19,9 +19,20 @@
|
||||
</template>
|
||||
</transition-group>
|
||||
</div>
|
||||
<div v-if="!collections.length" class="flex h-32 items-center justify-center">
|
||||
<p class="text-xl">{{ $strings.MessageNoCollections }}</p>
|
||||
<div v-if="!collections.length" class="flex h-32 items-center justify-center text-center px-2">
|
||||
<div>
|
||||
<p class="text-xl mb-2">{{ $strings.MessageNoCollections }}</p>
|
||||
<div class="text-sm flex items-center justify-center text-gray-200">
|
||||
<p>{{ $strings.MessageBookshelfNoCollectionsHelp }}</p>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
@@ -138,7 +149,6 @@ export default {
|
||||
.$post(`/api/collections/${collection.id}/batch/remove`, { books: this.selectedBookIds })
|
||||
.then((updatedCollection) => {
|
||||
console.log(`Books removed from collection`, updatedCollection)
|
||||
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -152,7 +162,6 @@ export default {
|
||||
.$delete(`/api/collections/${collection.id}/book/${this.selectedLibraryItemId}`)
|
||||
.then((updatedCollection) => {
|
||||
console.log(`Book removed from collection`, updatedCollection)
|
||||
this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -167,12 +176,11 @@ export default {
|
||||
this.processing = true
|
||||
|
||||
if (this.showBatchCollectionModal) {
|
||||
// BATCH Remove books
|
||||
// BATCH Add books
|
||||
this.$axios
|
||||
.$post(`/api/collections/${collection.id}/batch/add`, { books: this.selectedBookIds })
|
||||
.then((updatedCollection) => {
|
||||
console.log(`Books added to collection`, updatedCollection)
|
||||
this.$toast.success(this.$strings.ToastCollectionItemsAddSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -187,7 +195,6 @@ export default {
|
||||
.$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId })
|
||||
.then((updatedCollection) => {
|
||||
console.log(`Book added to collection`, updatedCollection)
|
||||
this.$toast.success(this.$strings.ToastCollectionItemsAddSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -214,7 +221,6 @@ export default {
|
||||
.$post('/api/collections', newCollection)
|
||||
.then((data) => {
|
||||
console.log('New Collection Created', data)
|
||||
this.$toast.success(`Collection "${data.name}" created`)
|
||||
this.processing = false
|
||||
this.newCollectionName = ''
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex items-center px-4 py-2 justify-start relative hover:bg-bg" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div class="flex items-center px-4 py-2 justify-start relative hover:bg-black-400" @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" />
|
||||
|
||||
@@ -135,7 +135,7 @@ export default {
|
||||
.catch((error) => {
|
||||
console.error('Failed to update collection', error)
|
||||
this.processing = false
|
||||
this.$toast.error(this.$strings.ToastCollectionUpdateFailed)
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -178,7 +178,7 @@ export default {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update device', error)
|
||||
this.$toast.error(this.$strings.ToastDeviceUpdateFailed)
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
|
||||
188
client/components/modals/emails/UserEReaderDeviceModal.vue
Normal file
188
client/components/modals/emails/UserEReaderDeviceModal.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<modals-modal ref="modal" v-model="show" name="ereader-device-edit" :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>
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<div class="w-full px-3 py-5 md:p-12">
|
||||
<div class="flex items-center -mx-1 mb-4">
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="ereaderNameInput" v-model="newDevice.name" :disabled="processing" :label="$strings.LabelName" />
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="ereaderEmailInput" v-model="newDevice.email" :disabled="processing" :label="$strings.LabelEmail" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-4">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
existingDevices: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
ereaderDevice: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newDevice: {
|
||||
name: '',
|
||||
email: '',
|
||||
availabilityOption: 'adminAndUp',
|
||||
users: []
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
title() {
|
||||
return !this.ereaderDevice ? 'Create Device' : 'Update Device'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitForm() {
|
||||
this.$refs.ereaderNameInput.blur()
|
||||
this.$refs.ereaderEmailInput.blur()
|
||||
|
||||
if (!this.newDevice.name?.trim() || !this.newDevice.email?.trim()) {
|
||||
this.$toast.error(this.$strings.ToastNameEmailRequired)
|
||||
return
|
||||
}
|
||||
|
||||
this.newDevice.name = this.newDevice.name.trim()
|
||||
this.newDevice.email = this.newDevice.email.trim()
|
||||
|
||||
// Only catches duplicate names for the current user
|
||||
// Duplicates with other users caught on server side
|
||||
if (!this.ereaderDevice) {
|
||||
if (this.existingDevices.some((d) => d.name === this.newDevice.name)) {
|
||||
this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)
|
||||
return
|
||||
}
|
||||
|
||||
this.submitCreate()
|
||||
} else {
|
||||
if (this.ereaderDevice.name !== this.newDevice.name && this.existingDevices.some((d) => d.name === this.newDevice.name)) {
|
||||
this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)
|
||||
return
|
||||
}
|
||||
|
||||
this.submitUpdate()
|
||||
}
|
||||
},
|
||||
submitUpdate() {
|
||||
this.processing = true
|
||||
|
||||
const existingDevicesWithoutThisOne = this.existingDevices.filter((d) => d.name !== this.ereaderDevice.name)
|
||||
|
||||
const payload = {
|
||||
ereaderDevices: [
|
||||
...existingDevicesWithoutThisOne,
|
||||
{
|
||||
...this.newDevice
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
this.$axios
|
||||
.$post(`/api/me/ereader-devices`, payload)
|
||||
.then((data) => {
|
||||
this.$emit('update', data.ereaderDevices)
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update device', error)
|
||||
if (error.response?.data?.toLowerCase().includes('duplicate')) {
|
||||
this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)
|
||||
} else {
|
||||
this.$toast.error(this.$strings.ToastDeviceAddFailed)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
submitCreate() {
|
||||
this.processing = true
|
||||
|
||||
const payload = {
|
||||
ereaderDevices: [
|
||||
...this.existingDevices,
|
||||
{
|
||||
...this.newDevice
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
this.$axios
|
||||
.$post('/api/me/ereader-devices', payload)
|
||||
.then((data) => {
|
||||
this.$emit('update', data.ereaderDevices || [])
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to add device', error)
|
||||
if (error.response?.data?.toLowerCase().includes('duplicate')) {
|
||||
this.$toast.error(this.$strings.ToastDeviceNameAlreadyExists)
|
||||
} else {
|
||||
this.$toast.error(this.$strings.ToastDeviceAddFailed)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
init() {
|
||||
if (this.ereaderDevice) {
|
||||
this.newDevice.name = this.ereaderDevice.name
|
||||
this.newDevice.email = this.ereaderDevice.email
|
||||
this.newDevice.availabilityOption = this.ereaderDevice.availabilityOption || 'specificUsers'
|
||||
this.newDevice.users = this.ereaderDevice.users || [this.user.id]
|
||||
} else {
|
||||
this.newDevice.name = ''
|
||||
this.newDevice.email = ''
|
||||
this.newDevice.availabilityOption = 'specificUsers'
|
||||
this.newDevice.users = [this.user.id]
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -2,24 +2,24 @@
|
||||
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="marginTop">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-4 landscape:px-4 landscape:py-2 md:portrait:p-5 lg:p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||
<p class="text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</p>
|
||||
<h1 class="text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</h1>
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute -top-10 left-0 z-10 w-full flex">
|
||||
<div role="tablist" class="absolute -top-10 left-0 z-10 w-full flex">
|
||||
<template v-for="tab in availableTabs">
|
||||
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||
<button :key="tab.id" role="tab" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
||||
<div class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goPrevBook" @mousedown.prevent>arrow_back_ios</div>
|
||||
</div>
|
||||
<div v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
||||
<div class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
|
||||
<div role="tabpanel" class="w-full h-full max-h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
|
||||
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full max-h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
|
||||
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
||||
<div v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
||||
<button class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" :aria-label="$strings.ButtonNext" @click.stop.prevent="goPrevBook" @mousedown.prevent>arrow_back_ios</button>
|
||||
</div>
|
||||
<div v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
||||
<button class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" :aria-label="$strings.ButtonPrevious" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</button>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
@@ -196,6 +196,9 @@ export default {
|
||||
methods: {
|
||||
async goPrevBook() {
|
||||
if (this.currentBookshelfIndex - 1 < 0) return
|
||||
// Remove focus from active input
|
||||
document.activeElement?.blur?.()
|
||||
|
||||
var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]
|
||||
this.processing = true
|
||||
var prevBook = await this.$axios.$get(`/api/items/${prevBookId}?expanded=1`).catch((error) => {
|
||||
@@ -215,6 +218,9 @@ export default {
|
||||
},
|
||||
async goNextBook() {
|
||||
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
|
||||
// Remove focus from active input
|
||||
document.activeElement?.blur?.()
|
||||
|
||||
this.processing = true
|
||||
var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1]
|
||||
var nextBook = await this.$axios.$get(`/api/items/${nextBookId}?expanded=1`).catch((error) => {
|
||||
@@ -300,4 +306,4 @@ export default {
|
||||
.tab.tab-selected {
|
||||
height: 41px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<ui-text-input-with-label ref="maxEpisodesInput" v-model="maxEpisodesToDownload" :disabled="checkingNewEpisodes" type="number" :label="$strings.LabelLimit" class="w-16 mr-2" input-class="h-10">
|
||||
<div class="flex -mb-0.5">
|
||||
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': checkingNewEpisodes }">{{ $strings.LabelLimit }}</p>
|
||||
<ui-tooltip direction="top" text="Max # of episodes to download. Use 0 for unlimited.">
|
||||
<ui-tooltip direction="top" :text="$strings.LabelMaxEpisodesToDownload">
|
||||
<span class="material-symbols text-base">info</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
@@ -99,7 +99,7 @@ export default {
|
||||
|
||||
if (this.maxEpisodesToDownload < 0) {
|
||||
this.maxEpisodesToDownload = 3
|
||||
this.$toast.error('Invalid max episodes to download')
|
||||
this.$toast.error(this.$strings.ToastInvalidMaxEpisodesToDownload)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -113,6 +113,10 @@ export default {
|
||||
return false
|
||||
})
|
||||
console.log('updateResult', updateResult)
|
||||
} else if (!lastEpisodeCheck) {
|
||||
this.$toast.error(this.$strings.ToastDateTimeInvalidOrIncomplete)
|
||||
this.checkingNewEpisodes = false
|
||||
return false
|
||||
}
|
||||
|
||||
this.$axios
|
||||
@@ -120,9 +124,9 @@ export default {
|
||||
.then((response) => {
|
||||
if (response.episodes && response.episodes.length) {
|
||||
console.log('New episodes', response.episodes.length)
|
||||
this.$toast.success(`${response.episodes.length} new episodes found!`)
|
||||
this.$toast.success(this.$getString('ToastNewEpisodesFound', [response.episodes.length]))
|
||||
} else {
|
||||
this.$toast.info('No new episodes found')
|
||||
this.$toast.info(this.$strings.ToastNoNewEpisodesFound)
|
||||
}
|
||||
this.checkingNewEpisodes = false
|
||||
})
|
||||
@@ -141,4 +145,4 @@ export default {
|
||||
this.setLastEpisodeCheckInput()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" :label="$strings.LabelTitle" />
|
||||
<p v-if="mediaMetadata.title" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('title', mediaMetadata.title)">{{ mediaMetadata.title || '' }}</a>
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('title', mediaMetadata.title)">{{ mediaMetadata.title || '' }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,7 +69,7 @@
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" :label="$strings.LabelSubtitle" />
|
||||
<p v-if="mediaMetadata.subtitle" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('subtitle', mediaMetadata.subtitle)">{{ mediaMetadata.subtitle }}</a>
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('subtitle', mediaMetadata.subtitle)">{{ mediaMetadata.subtitle }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,7 +78,7 @@
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" :label="$strings.LabelAuthor" />
|
||||
<p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('author', mediaMetadata.authorName)">{{ mediaMetadata.authorName }}</a>
|
||||
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('author', mediaMetadata.authorName)">{{ mediaMetadata.authorName }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,16 +87,16 @@
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-multi-select v-model="selectedMatch.narrator" :items="narrators" :disabled="!selectedMatchUsage.narrator" :label="$strings.LabelNarrators" />
|
||||
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('narrator', mediaMetadata.narrators)">{{ mediaMetadata.narratorName }}</a>
|
||||
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('narrator', mediaMetadata.narrators)">{{ mediaMetadata.narratorName }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.description" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.description" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" :label="$strings.LabelDescription" />
|
||||
<ui-rich-text-editor v-model="selectedMatch.description" :disabled="!selectedMatchUsage.description" :label="$strings.LabelDescription" />
|
||||
<p v-if="mediaMetadata.description" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('description', mediaMetadata.description)">{{ mediaMetadata.description.substr(0, 100) + (mediaMetadata.description.length > 100 ? '...' : '') }}</a>
|
||||
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('description', mediaMetadata.description)">{{ mediaMetadata.descriptionPlain.substr(0, 100) + (mediaMetadata.descriptionPlain.length > 100 ? '...' : '') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,7 +105,7 @@
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" :label="$strings.LabelPublisher" />
|
||||
<p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publisher', mediaMetadata.publisher)">{{ mediaMetadata.publisher }}</a>
|
||||
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publisher', mediaMetadata.publisher)">{{ mediaMetadata.publisher }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,7 +114,7 @@
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" :label="$strings.LabelPublishYear" />
|
||||
<p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publishedYear', mediaMetadata.publishedYear)">{{ mediaMetadata.publishedYear }}</a>
|
||||
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publishedYear', mediaMetadata.publishedYear)">{{ mediaMetadata.publishedYear }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,7 +124,7 @@
|
||||
<div class="flex-grow ml-4">
|
||||
<widgets-series-input-widget v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" />
|
||||
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('series', mediaMetadata.series)">{{ mediaMetadata.seriesName }}</a>
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('series', mediaMetadata.series)">{{ mediaMetadata.seriesName }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-multi-select v-model="selectedMatch.genres" :items="genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
|
||||
<p v-if="mediaMetadata.genres?.length" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('genres', mediaMetadata.genres)">{{ mediaMetadata.genres.join(', ') }}</a>
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('genres', mediaMetadata.genres)">{{ mediaMetadata.genres.join(', ') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,7 +142,7 @@
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-multi-select v-model="selectedMatch.tags" :items="tags" :disabled="!selectedMatchUsage.tags" :label="$strings.LabelTags" />
|
||||
<p v-if="media.tags?.length" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('tags', media.tags)">{{ media.tags.join(', ') }}</a>
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('tags', media.tags)">{{ media.tags.join(', ') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,7 +151,7 @@
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.language" :disabled="!selectedMatchUsage.language" :label="$strings.LabelLanguage" />
|
||||
<p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('language', mediaMetadata.language)">{{ mediaMetadata.language }}</a>
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('language', mediaMetadata.language)">{{ mediaMetadata.language }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,7 +160,7 @@
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" />
|
||||
<p v-if="mediaMetadata.isbn" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('isbn', mediaMetadata.isbn)">{{ mediaMetadata.isbn }}</a>
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('isbn', mediaMetadata.isbn)">{{ mediaMetadata.isbn }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,7 +169,7 @@
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" />
|
||||
<p v-if="mediaMetadata.asin" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('asin', mediaMetadata.asin)">{{ mediaMetadata.asin }}</a>
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('asin', mediaMetadata.asin)">{{ mediaMetadata.asin }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,7 +179,7 @@
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.itunesId" type="number" :disabled="!selectedMatchUsage.itunesId" label="iTunes ID" />
|
||||
<p v-if="mediaMetadata.itunesId" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('itunesId', mediaMetadata.itunesId)">{{ mediaMetadata.itunesId }}</a>
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('itunesId', mediaMetadata.itunesId)">{{ mediaMetadata.itunesId }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,7 +188,7 @@
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.feedUrl" :disabled="!selectedMatchUsage.feedUrl" label="RSS Feed URL" />
|
||||
<p v-if="mediaMetadata.feedUrl" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('feedUrl', mediaMetadata.feedUrl)">{{ mediaMetadata.feedUrl }}</a>
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('feedUrl', mediaMetadata.feedUrl)">{{ mediaMetadata.feedUrl }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,7 +197,7 @@
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.itunesPageUrl" :disabled="!selectedMatchUsage.itunesPageUrl" label="iTunes Page URL" />
|
||||
<p v-if="mediaMetadata.itunesPageUrl" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('itunesPageUrl', mediaMetadata.itunesPageUrl)">{{ mediaMetadata.itunesPageUrl }}</a>
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('itunesPageUrl', mediaMetadata.itunesPageUrl)">{{ mediaMetadata.itunesPageUrl }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,7 +206,7 @@
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.releaseDate" :disabled="!selectedMatchUsage.releaseDate" :label="$strings.LabelReleaseDate" />
|
||||
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('releaseDate', mediaMetadata.releaseDate)">{{ mediaMetadata.releaseDate }}</a>
|
||||
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('releaseDate', mediaMetadata.releaseDate)">{{ mediaMetadata.releaseDate }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -623,7 +623,7 @@ export default {
|
||||
this.clearSelectedMatch()
|
||||
this.$emit('selectTab', 'details')
|
||||
} else {
|
||||
this.$toast.error(this.$strings.ToastItemDetailsUpdateFailed)
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
}
|
||||
} else {
|
||||
this.clearSelectedMatch()
|
||||
|
||||
@@ -2,28 +2,28 @@
|
||||
<div class="w-full h-full relative">
|
||||
<div id="scheduleWrapper" class="w-full overflow-y-auto px-2 py-4 md:px-6 md:py-6">
|
||||
<template v-if="!feedUrl">
|
||||
<widgets-alert type="warning" class="text-base mb-4">No RSS feed URL is set for this podcast</widgets-alert>
|
||||
<widgets-alert type="warning" class="text-base mb-4">{{ $strings.ToastPodcastNoRssFeed }}</widgets-alert>
|
||||
</template>
|
||||
<template v-if="feedUrl || autoDownloadEpisodes">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-base md:text-xl font-semibold">Schedule Automatic Episode Downloads</p>
|
||||
<ui-checkbox v-model="enableAutoDownloadEpisodes" label="Enable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
|
||||
<p class="text-base md:text-xl font-semibold">{{ $strings.HeaderScheduleEpisodeDownloads }}</p>
|
||||
<ui-checkbox v-model="enableAutoDownloadEpisodes" :label="$strings.LabelEnable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
|
||||
</div>
|
||||
|
||||
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
|
||||
<ui-text-input ref="maxEpisodesInput" type="number" v-model="newMaxEpisodesToKeep" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updatedMaxEpisodesToKeep" />
|
||||
<ui-tooltip text="Value of 0 sets no max limit. After a new episode is auto-downloaded this will delete the oldest episode if you have more than X episodes. <br>This will only delete 1 episode per new download.">
|
||||
<ui-tooltip :text="$strings.LabelMaxEpisodesToKeepHelp">
|
||||
<p class="pl-4 text-base">
|
||||
Max episodes to keep
|
||||
{{ $strings.LabelMaxEpisodesToKeep }}
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
|
||||
<ui-text-input ref="maxEpisodesToDownloadInput" type="number" v-model="newMaxNewEpisodesToDownload" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updateMaxNewEpisodesToDownload" />
|
||||
<ui-tooltip text="Value of 0 sets no max limit. When checking for new episodes this is the max number of episodes that will be downloaded.">
|
||||
<ui-tooltip :text="$strings.LabelUseZeroForUnlimited">
|
||||
<p class="pl-4 text-base">
|
||||
Max new episodes to download per check
|
||||
{{ $strings.LabelMaxEpisodesToDownloadPerCheck }}
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
@@ -36,7 +36,7 @@
|
||||
<div v-if="feedUrl || autoDownloadEpisodes" class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg border-t border-white border-opacity-5">
|
||||
<div class="flex items-center px-2 md:px-4">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn @click="save" :disabled="!isUpdated" :color="isUpdated ? 'success' : 'primary'" class="mx-2">{{ isUpdated ? 'Save' : 'No update necessary' }}</ui-btn>
|
||||
<ui-btn @click="save" :disabled="!isUpdated" :color="isUpdated ? 'success' : 'primary'" class="mx-2">{{ isUpdated ? $strings.ButtonSave : $strings.MessageNoUpdatesWereNecessary }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,18 +33,18 @@
|
||||
<span class="material-symbols text-lg ml-2">launch</span>
|
||||
</ui-btn>
|
||||
|
||||
<ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">Quick Embed</ui-btn>
|
||||
<ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">{{ $strings.ButtonQuickEmbed }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- queued alert -->
|
||||
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mt-4">
|
||||
<p class="text-lg">Queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
|
||||
<p class="text-lg">{{ $getString('MessageQuickEmbedQueue', [queuedEmbedLIds.length]) }}</p>
|
||||
</widgets-alert>
|
||||
|
||||
<!-- processing alert -->
|
||||
<widgets-alert v-if="isEmbedTaskRunning" type="warning" class="mt-4">
|
||||
<p class="text-lg">Currently embedding metadata</p>
|
||||
<p class="text-lg">{{ $strings.MessageQuickEmbedInProgress }}</p>
|
||||
</widgets-alert>
|
||||
</div>
|
||||
|
||||
@@ -113,7 +113,7 @@ export default {
|
||||
methods: {
|
||||
quickEmbed() {
|
||||
const payload = {
|
||||
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
|
||||
message: this.$strings.MessageConfirmQuickEmbed,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
|
||||
@@ -111,7 +111,6 @@ export default {
|
||||
},
|
||||
updateLibrary(library) {
|
||||
this.mapLibraryToCopy(library)
|
||||
console.log('Updated library', this.libraryCopy)
|
||||
},
|
||||
getNewLibraryData() {
|
||||
return {
|
||||
@@ -128,7 +127,9 @@ export default {
|
||||
autoScanCronExpression: null,
|
||||
hideSingleBookSeries: false,
|
||||
onlyShowLaterBooksInContinueSeries: false,
|
||||
metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
|
||||
metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'],
|
||||
markAsFinishedPercentComplete: null,
|
||||
markAsFinishedTimeRemaining: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -160,7 +161,7 @@ export default {
|
||||
return false
|
||||
}
|
||||
if (!this.libraryCopy.folders.length) {
|
||||
this.$toast.error('Library must have at least 1 path')
|
||||
this.$toast.error(this.$strings.ToastMustHaveAtLeastOnePath)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -222,7 +223,7 @@ export default {
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
} else {
|
||||
this.$toast.error(this.$strings.ToastLibraryUpdateFailed)
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
}
|
||||
this.processing = false
|
||||
})
|
||||
@@ -236,7 +237,6 @@ export default {
|
||||
this.show = false
|
||||
this.$toast.success(this.$getString('ToastLibraryCreateSuccess', [res.name]))
|
||||
if (!this.$store.state.libraries.currentLibraryId) {
|
||||
console.log('Setting initially library id', res.id)
|
||||
// First library added
|
||||
this.$store.dispatch('libraries/fetch', res.id)
|
||||
}
|
||||
|
||||
@@ -1,78 +1,94 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
|
||||
<div class="flex items-center py-3">
|
||||
<ui-toggle-switch v-model="useSquareBookCovers" @input="formUpdated" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsSquareBookCoversHelp">
|
||||
<p class="pl-4 text-base">
|
||||
{{ $strings.LabelSettingsSquareBookCovers }}
|
||||
<span class="material-symbols icon-text text-sm">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="enableWatcher" @input="formUpdated" />
|
||||
<ui-toggle-switch v-else disabled :value="false" />
|
||||
<p class="pl-4 text-base">{{ $strings.LabelSettingsEnableWatcherForLibrary }}</p>
|
||||
</div>
|
||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p>
|
||||
</div>
|
||||
<div v-if="isBookLibrary" class="flex items-center py-3">
|
||||
<ui-toggle-switch v-model="audiobooksOnly" @input="formUpdated" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsAudiobooksOnlyHelp">
|
||||
<p class="pl-4 text-base">
|
||||
{{ $strings.LabelSettingsAudiobooksOnly }}
|
||||
<span class="material-symbols icon-text text-sm">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-if="isBookLibrary" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
|
||||
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithASIN }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isBookLibrary" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
|
||||
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithISBN }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isBookLibrary" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="hideSingleBookSeries" @input="formUpdated" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsHideSingleBookSeriesHelp">
|
||||
<p class="pl-4 text-base">
|
||||
{{ $strings.LabelSettingsHideSingleBookSeries }}
|
||||
<div class="flex flex-wrap">
|
||||
<div class="flex items-center p-2 w-full md:w-1/2">
|
||||
<ui-toggle-switch v-model="useSquareBookCovers" size="sm" @input="formUpdated" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsSquareBookCoversHelp">
|
||||
<p class="pl-4 text-sm">
|
||||
{{ $strings.LabelSettingsSquareBookCovers }}
|
||||
<span class="material-symbols icon-text text-sm">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isBookLibrary" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="onlyShowLaterBooksInContinueSeries" @input="formUpdated" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp">
|
||||
<p class="pl-4 text-base">
|
||||
{{ $strings.LabelSettingsOnlyShowLaterBooksInContinueSeries }}
|
||||
<div class="p-2 w-full md:w-1/2">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="enableWatcher" size="sm" @input="formUpdated" />
|
||||
<ui-toggle-switch v-else disabled size="sm" :value="false" />
|
||||
<p class="pl-4 text-sm">{{ $strings.LabelSettingsEnableWatcherForLibrary }}</p>
|
||||
</div>
|
||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p>
|
||||
</div>
|
||||
<div v-if="isBookLibrary" class="flex items-center p-2 w-full md:w-1/2">
|
||||
<ui-toggle-switch v-model="audiobooksOnly" size="sm" @input="formUpdated" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsAudiobooksOnlyHelp">
|
||||
<p class="pl-4 text-sm">
|
||||
{{ $strings.LabelSettingsAudiobooksOnly }}
|
||||
<span class="material-symbols icon-text text-sm">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isBookLibrary" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="epubsAllowScriptedContent" @input="formUpdated" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsEpubsAllowScriptedContentHelp">
|
||||
<p class="pl-4 text-base">
|
||||
{{ $strings.LabelSettingsEpubsAllowScriptedContent }}
|
||||
<span class="material-symbols icon-text text-sm">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
<div v-if="isBookLibrary" class="p-2 w-full md:w-1/2">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" size="sm" @input="formUpdated" />
|
||||
<p class="pl-4 text-sm">{{ $strings.LabelSettingsSkipMatchingBooksWithASIN }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isBookLibrary" class="p-2 w-full md:w-1/2">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" size="sm" @input="formUpdated" />
|
||||
<p class="pl-4 text-sm">{{ $strings.LabelSettingsSkipMatchingBooksWithISBN }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isBookLibrary" class="p-2 w-full md:w-1/2">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="hideSingleBookSeries" size="sm" @input="formUpdated" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsHideSingleBookSeriesHelp">
|
||||
<p class="pl-4 text-sm">
|
||||
{{ $strings.LabelSettingsHideSingleBookSeries }}
|
||||
<span class="material-symbols icon-text text-sm">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isBookLibrary" class="p-2 w-full md:w-1/2">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="onlyShowLaterBooksInContinueSeries" size="sm" @input="formUpdated" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp">
|
||||
<p class="pl-4 text-sm">
|
||||
{{ $strings.LabelSettingsOnlyShowLaterBooksInContinueSeries }}
|
||||
<span class="material-symbols icon-text text-sm">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isBookLibrary" class="p-2 w-full md:w-1/2">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="epubsAllowScriptedContent" size="sm" @input="formUpdated" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsEpubsAllowScriptedContentHelp">
|
||||
<p class="pl-4 text-sm">
|
||||
{{ $strings.LabelSettingsEpubsAllowScriptedContent }}
|
||||
<span class="material-symbols icon-text text-sm">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isPodcastLibrary" class="p-2 w-full md:w-1/2">
|
||||
<ui-dropdown :label="$strings.LabelPodcastSearchRegion" v-model="podcastSearchRegion" :items="$podcastSearchRegionOptions" small class="max-w-72" menu-max-height="200px" @input="formUpdated" />
|
||||
</div>
|
||||
<div class="p-2 w-full flex items-center space-x-2 flex-wrap">
|
||||
<div>
|
||||
<ui-dropdown v-model="markAsFinishedWhen" :items="maskAsFinishedWhenItems" :label="$strings.LabelSettingsLibraryMarkAsFinishedWhen" small class="w-72 min-w-72 text-sm" menu-max-height="200px" @input="markAsFinishedWhenChanged" />
|
||||
</div>
|
||||
<div class="w-16">
|
||||
<div>
|
||||
<label class="px-1 text-sm font-semibold"></label>
|
||||
<div class="relative">
|
||||
<ui-text-input v-model="markAsFinishedValue" type="number" label="" no-spinner custom-input-class="pr-5" @input="markAsFinishedChanged" />
|
||||
<div class="absolute top-0 bottom-0 right-4 flex items-center">{{ markAsFinishedWhen === 'timeRemaining' ? '' : '%' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isPodcastLibrary" class="py-3">
|
||||
<ui-dropdown :label="$strings.LabelPodcastSearchRegion" v-model="podcastSearchRegion" :items="$podcastSearchRegionOptions" small class="max-w-72" menu-max-height="200px" @input="formUpdated" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -97,7 +113,9 @@ export default {
|
||||
epubsAllowScriptedContent: false,
|
||||
hideSingleBookSeries: false,
|
||||
onlyShowLaterBooksInContinueSeries: false,
|
||||
podcastSearchRegion: 'us'
|
||||
podcastSearchRegion: 'us',
|
||||
markAsFinishedWhen: 'timeRemaining',
|
||||
markAsFinishedValue: 10
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -119,10 +137,34 @@ export default {
|
||||
providers() {
|
||||
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
},
|
||||
maskAsFinishedWhenItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelSettingsLibraryMarkAsFinishedTimeRemaining,
|
||||
value: 'timeRemaining'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelSettingsLibraryMarkAsFinishedPercentComplete,
|
||||
value: 'percentComplete'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
markAsFinishedWhenChanged(val) {
|
||||
if (val === 'percentComplete' && this.markAsFinishedValue > 100) {
|
||||
this.markAsFinishedValue = 100
|
||||
}
|
||||
this.formUpdated()
|
||||
},
|
||||
markAsFinishedChanged(val) {
|
||||
this.formUpdated()
|
||||
},
|
||||
getLibraryData() {
|
||||
let markAsFinishedTimeRemaining = this.markAsFinishedWhen === 'timeRemaining' ? Number(this.markAsFinishedValue) : null
|
||||
let markAsFinishedPercentComplete = this.markAsFinishedWhen === 'percentComplete' ? Number(this.markAsFinishedValue) : null
|
||||
|
||||
return {
|
||||
settings: {
|
||||
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
|
||||
@@ -133,7 +175,9 @@ export default {
|
||||
epubsAllowScriptedContent: !!this.epubsAllowScriptedContent,
|
||||
hideSingleBookSeries: !!this.hideSingleBookSeries,
|
||||
onlyShowLaterBooksInContinueSeries: !!this.onlyShowLaterBooksInContinueSeries,
|
||||
podcastSearchRegion: this.podcastSearchRegion
|
||||
podcastSearchRegion: this.podcastSearchRegion,
|
||||
markAsFinishedTimeRemaining: markAsFinishedTimeRemaining,
|
||||
markAsFinishedPercentComplete: markAsFinishedPercentComplete
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -150,6 +194,11 @@ export default {
|
||||
this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries
|
||||
this.onlyShowLaterBooksInContinueSeries = !!this.librarySettings.onlyShowLaterBooksInContinueSeries
|
||||
this.podcastSearchRegion = this.librarySettings.podcastSearchRegion || 'us'
|
||||
this.markAsFinishedWhen = this.librarySettings.markAsFinishedTimeRemaining ? 'timeRemaining' : 'percentComplete'
|
||||
if (!this.librarySettings.markAsFinishedTimeRemaining && !this.librarySettings.markAsFinishedPercentComplete) {
|
||||
this.markAsFinishedWhen = 'timeRemaining'
|
||||
}
|
||||
this.markAsFinishedValue = this.librarySettings.markAsFinishedTimeRemaining || this.librarySettings.markAsFinishedPercentComplete || 10
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
<div class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex flex-wrap items-center">
|
||||
<div>
|
||||
<p class="text-lg">Remove metadata files in library item folders</p>
|
||||
<p class="max-w-sm text-sm pt-2 text-gray-300">Remove all metadata.json or metadata.abs files in your {{ mediaType }} folders</p>
|
||||
<p class="text-lg">{{ $strings.LabelRemoveMetadataFile }}</p>
|
||||
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $getString('LabelRemoveMetadataFileHelp', [mediaType]) }}</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<ui-btn class="mb-4 block" @click.stop="removeAllMetadataClick('json')">Remove all metadata.json</ui-btn>
|
||||
<ui-btn @click.stop="removeAllMetadataClick('abs')">Remove all metadata.abs</ui-btn>
|
||||
<ui-btn class="mb-4 block" @click.stop="removeAllMetadataClick('json')">{{ $strings.LabelRemoveAllMetadataJson }}</ui-btn>
|
||||
<ui-btn @click.stop="removeAllMetadataClick('abs')">{{ $strings.LabelRemoveAllMetadataAbs }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,7 +43,7 @@ export default {
|
||||
methods: {
|
||||
removeAllMetadataClick(ext) {
|
||||
const payload = {
|
||||
message: `Are you sure you want to remove all metadata.${ext} files in your library item folders?`,
|
||||
message: this.$getString('MessageConfirmRemoveMetadataFiles', [ext]),
|
||||
persistent: true,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
@@ -60,16 +60,16 @@ export default {
|
||||
.$post(`/api/libraries/${this.libraryId}/remove-metadata?ext=${ext}`)
|
||||
.then((data) => {
|
||||
if (!data.found) {
|
||||
this.$toast.info(`No metadata.${ext} files were found in library`)
|
||||
this.$toast.info(this.$getString('ToastMetadataFilesRemovedNoneFound', [ext]))
|
||||
} else if (!data.removed) {
|
||||
this.$toast.success(`No metadata.${ext} files removed`)
|
||||
this.$toast.success(this.$getString('ToastMetadataFilesRemovedNoneRemoved', [ext]))
|
||||
} else {
|
||||
this.$toast.success(`Successfully removed ${data.removed} metadata.${ext} files`)
|
||||
this.$toast.success(this.$getString('ToastMetadataFilesRemovedSuccess', [data.removed, ext]))
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove metadata files', error)
|
||||
this.$toast.error('Failed to remove metadata files')
|
||||
this.$toast.error(this.$getString('ToastMetadataFilesRemovedError', [ext]))
|
||||
})
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<ui-checkbox v-model="enableAutoScan" @input="toggleEnableAutoScan" :label="$strings.LabelEnable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
|
||||
</div>
|
||||
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoScan" v-model="cronExpression" @input="updatedCron" />
|
||||
<div v-else>
|
||||
<p class="text-yellow-400 text-base">{{ $strings.MessageScheduleLibraryScanNote }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -77,7 +77,13 @@ export default {
|
||||
return this.notificationData.events || []
|
||||
},
|
||||
eventOptions() {
|
||||
return this.notificationEvents.map((e) => ({ value: e.name, text: e.name, subtext: e.description }))
|
||||
return this.notificationEvents.map((e) => {
|
||||
return {
|
||||
value: e.name,
|
||||
text: e.name,
|
||||
subtext: this.$strings[e.descriptionKey] || e.description
|
||||
}
|
||||
})
|
||||
},
|
||||
selectedEventData() {
|
||||
return this.notificationEvents.find((e) => e.name === this.newNotification.eventName)
|
||||
@@ -132,7 +138,7 @@ export default {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update notification', error)
|
||||
this.$toast.error(this.$strings.ToastNotificationUpdateFailed)
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</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 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">
|
||||
<div class="py-4 px-4">
|
||||
<h1 v-if="!isBatch" class="text-2xl">{{ $strings.LabelAddToPlaylist }}</h1>
|
||||
@@ -19,8 +19,18 @@
|
||||
</template>
|
||||
</transition-group>
|
||||
</div>
|
||||
<div v-if="!playlists.length" class="flex h-32 items-center justify-center">
|
||||
<p class="text-xl">{{ $strings.MessageNoUserPlaylists }}</p>
|
||||
<div v-if="!playlists.length" class="flex h-32 items-center justify-center text-center px-2">
|
||||
<div>
|
||||
<p class="text-xl mb-2">{{ $strings.MessageNoUserPlaylists }}</p>
|
||||
<div class="text-sm flex items-center justify-center text-gray-200">
|
||||
<p>{{ $strings.MessageNoUserPlaylistsHelp }}</p>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-px bg-white bg-opacity-10" />
|
||||
<form @submit.prevent="submitCreatePlaylist">
|
||||
@@ -130,12 +140,11 @@ export default {
|
||||
.$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
|
||||
.then((updatedPlaylist) => {
|
||||
console.log(`Items removed from playlist`, updatedPlaylist)
|
||||
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove items from playlist', error)
|
||||
this.$toast.error(this.$strings.ToastPlaylistUpdateFailed)
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
@@ -148,12 +157,11 @@ export default {
|
||||
.$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })
|
||||
.then((updatedPlaylist) => {
|
||||
console.log(`Items added to playlist`, updatedPlaylist)
|
||||
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to add items to playlist', error)
|
||||
this.$toast.error(this.$strings.ToastPlaylistUpdateFailed)
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
@@ -174,7 +182,6 @@ export default {
|
||||
.$post('/api/playlists', newPlaylist)
|
||||
.then((data) => {
|
||||
console.log('New playlist created', data)
|
||||
this.$toast.success(this.$strings.ToastPlaylistCreateSuccess + ': ' + data.name)
|
||||
this.processing = false
|
||||
this.newPlaylistName = ''
|
||||
})
|
||||
|
||||
@@ -115,7 +115,7 @@ export default {
|
||||
.catch((error) => {
|
||||
console.error('Failed to update playlist', error)
|
||||
this.processing = false
|
||||
this.$toast.error(this.$strings.ToastPlaylistUpdateFailed)
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex items-center px-4 py-2 justify-start relative hover:bg-bg" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div class="flex items-center px-4 py-2 justify-start relative hover:bg-black-400" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div v-if="isItemIncluded" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
|
||||
<div class="w-16 max-w-16 text-center">
|
||||
<covers-playlist-cover :items="items" :width="64" :height="64" />
|
||||
|
||||
@@ -117,8 +117,12 @@ export default {
|
||||
methods: {
|
||||
async goPrevEpisode() {
|
||||
if (this.currentEpisodeIndex - 1 < 0) return
|
||||
// Remove focus from active input
|
||||
document.activeElement?.blur?.()
|
||||
|
||||
const prevEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex - 1]
|
||||
this.processing = true
|
||||
|
||||
const prevEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${prevEpisodeId}`).catch((error) => {
|
||||
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch episode'
|
||||
this.$toast.error(errorMsg)
|
||||
@@ -134,8 +138,12 @@ export default {
|
||||
},
|
||||
async goNextEpisode() {
|
||||
if (this.currentEpisodeIndex >= this.episodeTableEpisodeIds.length - 1) return
|
||||
// Remove focus from active input
|
||||
document.activeElement?.blur?.()
|
||||
|
||||
this.processing = true
|
||||
const nextEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex + 1]
|
||||
|
||||
const nextEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${nextEpisodeId}`).catch((error) => {
|
||||
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
|
||||
this.$toast.error(errorMsg)
|
||||
@@ -170,6 +178,12 @@ export default {
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
libraryItemUpdated(libraryItem) {
|
||||
const episode = libraryItem.media.episodes.find((e) => e.id === this.selectedEpisodeId)
|
||||
if (episode) {
|
||||
this.episodeItem = episode
|
||||
}
|
||||
},
|
||||
hotkey(action) {
|
||||
if (action === this.$hotkeys.Modal.NEXT_PAGE) {
|
||||
this.goNextEpisode()
|
||||
@@ -178,9 +192,15 @@ export default {
|
||||
}
|
||||
},
|
||||
registerListeners() {
|
||||
if (this.libraryItem) {
|
||||
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||
}
|
||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||
},
|
||||
unregisterListeners() {
|
||||
if (this.libraryItem) {
|
||||
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||
}
|
||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -156,7 +156,12 @@ export default {
|
||||
return this.selectedFolder.fullPath
|
||||
},
|
||||
podcastTypes() {
|
||||
return this.$store.state.globals.podcastTypes || []
|
||||
return this.$store.state.globals.podcastTypes.map((e) => {
|
||||
return {
|
||||
text: this.$strings[e.descriptionKey] || e.text,
|
||||
value: e.value
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -16,8 +16,25 @@
|
||||
</div>
|
||||
</div>
|
||||
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
|
||||
<div v-if="description" dir="auto" class="default-style" v-html="description" />
|
||||
<div v-if="description" dir="auto" class="default-style less-spacing" v-html="description" />
|
||||
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
|
||||
|
||||
<div class="w-full h-px bg-white/5 my-4" />
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-grow">
|
||||
<p class="font-semibold text-xs mb-1">{{ $strings.LabelFilename }}</p>
|
||||
<p class="mb-2 text-xs">
|
||||
{{ audioFileFilename }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<p class="font-semibold text-xs mb-1">{{ $strings.LabelSize }}</p>
|
||||
<p class="mb-2 text-xs">
|
||||
{{ audioFileSize }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
@@ -54,7 +71,7 @@ export default {
|
||||
return this.episode.description || ''
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
return this.libraryItem?.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
@@ -65,6 +82,14 @@ export default {
|
||||
podcastAuthor() {
|
||||
return this.mediaMetadata.author
|
||||
},
|
||||
audioFileFilename() {
|
||||
return this.episode.audioFile?.metadata?.filename || ''
|
||||
},
|
||||
audioFileSize() {
|
||||
const size = this.episode.audioFile?.metadata?.size || 0
|
||||
|
||||
return this.$bytesPretty(size)
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
}
|
||||
|
||||
@@ -2,22 +2,22 @@
|
||||
<div>
|
||||
<div class="flex flex-wrap">
|
||||
<div class="w-1/5 p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.season" :label="$strings.LabelSeason" />
|
||||
<ui-text-input-with-label v-model="newEpisode.season" trim-whitespace :label="$strings.LabelSeason" />
|
||||
</div>
|
||||
<div class="w-1/5 p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.episode" :label="$strings.LabelEpisode" />
|
||||
<ui-text-input-with-label v-model="newEpisode.episode" trim-whitespace :label="$strings.LabelEpisode" />
|
||||
</div>
|
||||
<div class="w-1/5 p-1">
|
||||
<ui-dropdown v-model="newEpisode.episodeType" :label="$strings.LabelEpisodeType" :items="episodeTypes" small />
|
||||
</div>
|
||||
<div class="w-2/5 p-1">
|
||||
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" :label="$strings.LabelPubDate" />
|
||||
<ui-text-input-with-label v-model="pubDateInput" ref="pubdate" type="datetime-local" :label="$strings.LabelPubDate" @input="updatePubDate" />
|
||||
</div>
|
||||
<div class="w-full p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.title" :label="$strings.LabelTitle" />
|
||||
<ui-text-input-with-label v-model="newEpisode.title" :label="$strings.LabelTitle" trim-whitespace />
|
||||
</div>
|
||||
<div class="w-full p-1">
|
||||
<ui-textarea-with-label v-model="newEpisode.subtitle" :label="$strings.LabelSubtitle" :rows="3" />
|
||||
<ui-textarea-with-label v-model="newEpisode.subtitle" :label="$strings.LabelSubtitle" :rows="3" trim-whitespace />
|
||||
</div>
|
||||
<div class="w-full p-1">
|
||||
<ui-rich-text-editor :label="$strings.LabelDescription" v-model="newEpisode.description" />
|
||||
@@ -33,11 +33,11 @@
|
||||
</div>
|
||||
<div v-if="enclosureUrl" class="pb-4 pt-6">
|
||||
<ui-text-input-with-label :value="enclosureUrl" readonly class="text-xs">
|
||||
<label class="px-1 text-xs text-gray-200 font-semibold">Episode URL from RSS feed</label>
|
||||
<label class="px-1 text-xs text-gray-200 font-semibold">{{ $strings.LabelEpisodeUrlFromRssFeed }}</label>
|
||||
</ui-text-input-with-label>
|
||||
</div>
|
||||
<div v-else class="py-4">
|
||||
<p class="text-xs text-gray-300 font-semibold">Episode not linked to RSS feed episode</p>
|
||||
<p class="text-xs text-gray-300 font-semibold">{{ $strings.LabelEpisodeNotLinkedToRssFeed }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -97,7 +97,12 @@ export default {
|
||||
return this.enclosure.url
|
||||
},
|
||||
episodeTypes() {
|
||||
return this.$store.state.globals.episodeTypes || []
|
||||
return this.$store.state.globals.episodeTypes.map((e) => {
|
||||
return {
|
||||
text: this.$strings[e.descriptionKey] || e.text,
|
||||
value: e.value
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -140,11 +145,18 @@ export default {
|
||||
return null
|
||||
}
|
||||
|
||||
// Check pubdate is valid if it is being updated. Cannot be set to null in the web client
|
||||
if (this.newEpisode.pubDate === null && this.$refs.pubdate?.$refs?.input?.isInvalidDate) {
|
||||
this.$toast.error(this.$strings.ToastDateTimeInvalidOrIncomplete)
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedDetails = this.getUpdatePayload()
|
||||
if (!Object.keys(updatedDetails).length) {
|
||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
||||
return false
|
||||
}
|
||||
|
||||
return this.updateDetails(updatedDetails)
|
||||
},
|
||||
async updateDetails(updatedDetails) {
|
||||
@@ -152,19 +164,16 @@ export default {
|
||||
const updateResult = await this.$axios.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, updatedDetails).catch((error) => {
|
||||
console.error('Failed update episode', error)
|
||||
this.isProcessing = false
|
||||
this.$toast.error(error?.response?.data || 'Failed to update episode')
|
||||
this.$toast.error(error?.response?.data || this.$strings.ToastFailedToUpdate)
|
||||
return false
|
||||
})
|
||||
|
||||
this.isProcessing = false
|
||||
if (updateResult) {
|
||||
if (updateResult) {
|
||||
this.$toast.success('Podcast episode updated')
|
||||
return true
|
||||
} else {
|
||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||
}
|
||||
this.$toast.success(this.$strings.ToastItemUpdateSuccess)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10,9 +10,7 @@
|
||||
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p>
|
||||
|
||||
<div class="w-full relative">
|
||||
<ui-text-input v-model="currentFeed.feedUrl" readonly />
|
||||
|
||||
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeed.feedUrl)">content_copy</span>
|
||||
<ui-text-input :value="feedUrl" readonly show-copy />
|
||||
</div>
|
||||
|
||||
<div v-if="currentFeed.meta" class="mt-5">
|
||||
@@ -111,8 +109,11 @@ export default {
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
feedUrl() {
|
||||
return this.currentFeed ? `${window.origin}${this.$config.routerBasePath}${this.currentFeed.feedUrl}` : ''
|
||||
},
|
||||
demoFeedUrl() {
|
||||
return `${window.origin}/feed/${this.newFeedSlug}`
|
||||
return `${window.origin}${this.$config.routerBasePath}/feed/${this.newFeedSlug}`
|
||||
},
|
||||
isHttp() {
|
||||
return window.origin.startsWith('http://')
|
||||
@@ -139,7 +140,7 @@ export default {
|
||||
slug: this.newFeedSlug,
|
||||
metadataDetails: this.metadataDetails
|
||||
}
|
||||
if (this.$isDev) payload.serverAddress = `http://localhost:3333${this.$config.routerBasePath}`
|
||||
if (this.$isDev) payload.serverAddress = process.env.serverUrl
|
||||
|
||||
console.log('Payload', payload)
|
||||
this.$axios
|
||||
@@ -157,9 +158,6 @@ export default {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
copyToClipboard(str) {
|
||||
this.$copyToClipboard(str, this)
|
||||
},
|
||||
closeFeed() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p>
|
||||
|
||||
<div class="w-full relative">
|
||||
<ui-text-input v-model="feed.feedUrl" readonly />
|
||||
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feed.feedUrl)">content_copy</span>
|
||||
<ui-text-input :value="feedUrl" readonly show-copy />
|
||||
</div>
|
||||
|
||||
<div v-if="feed.meta" class="mt-5">
|
||||
@@ -70,14 +69,11 @@ export default {
|
||||
},
|
||||
_feed() {
|
||||
return this.feed || {}
|
||||
},
|
||||
feedUrl() {
|
||||
return this.feed ? `${window.origin}${this.$config.routerBasePath}${this.feed.feedUrl}` : ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
copyToClipboard(str) {
|
||||
this.$copyToClipboard(str, this)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,38 +1,37 @@
|
||||
<template>
|
||||
<div class="flex items-center pt-4 pb-2 lg:pt-0 lg:pb-2">
|
||||
<div class="flex-grow" />
|
||||
<template v-if="!loading">
|
||||
<ui-tooltip direction="top" :text="$strings.ButtonPreviousChapter" class="mr-4 lg:mr-8">
|
||||
<button :aria-label="$strings.ButtonPreviousChapter" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter">
|
||||
<span class="material-symbols text-2xl sm:text-3xl">first_page</span>
|
||||
<div class="flex justify-center pt-4 pb-2 lg:pt-0 lg:pb-2">
|
||||
<div class="flex items-center justify-center flex-grow">
|
||||
<template v-if="!loading">
|
||||
<ui-tooltip direction="top" :text="$strings.ButtonPreviousChapter" class="mr-4 lg:mr-8">
|
||||
<button :aria-label="$strings.ButtonPreviousChapter" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter">
|
||||
<span class="material-symbols text-2xl sm:text-3xl">first_page</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<ui-tooltip direction="top" :text="jumpBackwardText">
|
||||
<button :aria-label="jumpForwardText" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||
<span class="material-symbols text-2xl sm:text-3xl">replay</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<button :aria-label="paused ? $strings.ButtonPlay : $strings.ButtonPause" class="p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 lg:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
||||
<span class="material-symbols fill text-2xl">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<ui-tooltip direction="top" :text="jumpBackwardText">
|
||||
<button :aria-label="jumpForwardText" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||
<span class="material-symbols text-2xl sm:text-3xl">replay</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<button :aria-label="paused ? $strings.ButtonPlay : $strings.ButtonPause" class="p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 lg:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
||||
<span class="material-symbols fill text-2xl">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
||||
</button>
|
||||
<ui-tooltip direction="top" :text="jumpForwardText">
|
||||
<button :aria-label="jumpForwardText" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||
<span class="material-symbols text-2xl sm:text-3xl">forward_media</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<ui-tooltip direction="top" :text="hasNextLabel" class="ml-4 lg:ml-8">
|
||||
<button :aria-label="hasNextLabel" :disabled="!hasNext" class="text-gray-300 disabled:text-gray-500" @mousedown.prevent @mouseup.prevent @click.stop="next">
|
||||
<span class="material-symbols text-2xl sm:text-3xl">last_page</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<controls-playback-speed-control v-model="playbackRateInput" @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-symbols text-2xl">autorenew</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex-grow" />
|
||||
<ui-tooltip direction="top" :text="jumpForwardText">
|
||||
<button :aria-label="jumpForwardText" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||
<span class="material-symbols text-2xl sm:text-3xl">forward_media</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<ui-tooltip direction="top" :text="hasNextLabel" class="ml-4 lg:ml-8">
|
||||
<button :aria-label="hasNextLabel" :disabled="!hasNext" class="text-gray-300 disabled:text-gray-500" @mousedown.prevent @mouseup.prevent @click.stop="next">
|
||||
<span class="material-symbols text-2xl sm:text-3xl">last_page</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
</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-symbols text-2xl">autorenew</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -41,7 +40,6 @@ export default {
|
||||
props: {
|
||||
loading: Boolean,
|
||||
seekLoading: Boolean,
|
||||
playbackRate: Number,
|
||||
paused: Boolean,
|
||||
hasNextChapter: Boolean,
|
||||
hasNextItemInQueue: Boolean
|
||||
@@ -50,14 +48,6 @@ export default {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
playbackRateInput: {
|
||||
get() {
|
||||
return this.playbackRate
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:playbackRate', val)
|
||||
}
|
||||
},
|
||||
jumpForwardText() {
|
||||
return this.getJumpText('jumpForwardAmount', this.$strings.ButtonJumpForward)
|
||||
},
|
||||
@@ -89,15 +79,6 @@ export default {
|
||||
jumpForward() {
|
||||
this.$emit('jumpForward')
|
||||
},
|
||||
playbackRateUpdated(playbackRate) {
|
||||
this.$emit('setPlaybackRate', playbackRate)
|
||||
},
|
||||
playbackRateChanged(playbackRate) {
|
||||
this.$emit('setPlaybackRate', playbackRate)
|
||||
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
|
||||
console.error('Failed to update settings', err)
|
||||
})
|
||||
},
|
||||
getJumpText(setting, prefix) {
|
||||
const amount = this.$store.getters['user/getUserSetting'](setting)
|
||||
if (!amount) return prefix
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<div class="w-full -mt-6">
|
||||
<div class="w-full relative mb-1">
|
||||
<div class="absolute -top-10 lg:top-0 right-0 lg:right-2 flex items-center h-full">
|
||||
<!-- <span class="material-symbols text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span> -->
|
||||
<controls-playback-speed-control v-model="playbackRate" @input="setPlaybackRate" @change="playbackRateChanged" :playbackRateIncrementDecrement="playbackRateIncrementDecrement" class="mx-2 block" />
|
||||
|
||||
<ui-tooltip direction="top" :text="$strings.LabelVolume">
|
||||
<ui-tooltip direction="bottom" :text="$strings.LabelVolume">
|
||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden sm:block" />
|
||||
</ui-tooltip>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<span v-if="!sleepTimerSet" class="material-symbols text-2xl">snooze</span>
|
||||
<div v-else class="flex items-center">
|
||||
<span class="material-symbols text-lg text-warning">snooze</span>
|
||||
<p class="text-sm sm:text-lg text-warning font-mono font-semibold text-center px-0.5 sm:pb-0.5 sm:min-w-8">{{ sleepTimerRemainingString }}</p>
|
||||
<p class="text-sm sm:text-lg text-warning font-semibold text-center px-0.5 sm:pb-0.5 sm:min-w-8">{{ sleepTimerRemainingString }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
@@ -37,7 +37,7 @@
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip direction="top" :text="$strings.LabelViewPlayerSettings">
|
||||
<button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerSettings')">
|
||||
<button :aria-label="$strings.LabelViewPlayerSettings" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="showPlayerSettings">
|
||||
<span class="material-symbols text-2xl sm:text-2.5xl">settings_slow_motion</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
@@ -48,18 +48,24 @@
|
||||
|
||||
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" :playback-rate="playbackRate" @seek="seek" />
|
||||
|
||||
<div class="flex">
|
||||
<p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
||||
<p class="font-mono text-sm hidden sm:block text-gray-100 pointer-events-auto"> / {{ progressPercent }}%</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-xs sm:text-sm text-gray-300 pt-0.5 px-2 truncate">
|
||||
{{ currentChapterName }} <span v-if="useChapterTrack" class="text-xs text-gray-400"> ({{ $getString('LabelPlayerChapterNumberMarker', [currentChapterIndex + 1, chapters.length]) }})</span>
|
||||
</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||
<div class="relative flex items-center justify-between">
|
||||
<div class="flex-grow flex items-center">
|
||||
<p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
||||
<p class="font-mono text-sm hidden sm:block text-gray-100 pointer-events-auto"> / {{ progressPercent }}%</p>
|
||||
</div>
|
||||
<div class="absolute left-1/2 transform -translate-x-1/2">
|
||||
<p class="text-xs sm:text-sm text-gray-300 pt-0.5 px-2 truncate">
|
||||
{{ currentChapterName }} <span v-if="useChapterTrack" class="text-xs text-gray-400"> ({{ $getString('LabelPlayerChapterNumberMarker', [currentChapterIndex + 1, chapters.length]) }})</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-grow flex items-center justify-end">
|
||||
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
|
||||
|
||||
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -92,6 +98,7 @@ export default {
|
||||
audioEl: null,
|
||||
seekLoading: false,
|
||||
showChaptersModal: false,
|
||||
showPlayerSettingsModal: false,
|
||||
currentTime: 0,
|
||||
duration: 0
|
||||
}
|
||||
@@ -173,27 +180,14 @@ export default {
|
||||
useChapterTrack() {
|
||||
const _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
|
||||
return this.chapters.length ? _useChapterTrack : false
|
||||
},
|
||||
playbackRateIncrementDecrement() {
|
||||
return this.$store.getters['user/getUserSetting']('playbackRateIncrementDecrement')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleFullscreen(isFullscreen) {
|
||||
this.$store.commit('setPlayerIsFullscreen', isFullscreen)
|
||||
|
||||
var videoPlayerEl = document.getElementById('video-player')
|
||||
if (videoPlayerEl) {
|
||||
if (isFullscreen) {
|
||||
videoPlayerEl.style.width = '100vw'
|
||||
videoPlayerEl.style.height = '100vh'
|
||||
videoPlayerEl.style.top = '0px'
|
||||
videoPlayerEl.style.left = '0px'
|
||||
} else {
|
||||
videoPlayerEl.style.width = '384px'
|
||||
videoPlayerEl.style.height = '216px'
|
||||
videoPlayerEl.style.top = 'unset'
|
||||
videoPlayerEl.style.bottom = '80px'
|
||||
videoPlayerEl.style.left = '16px'
|
||||
}
|
||||
}
|
||||
},
|
||||
setDuration(duration) {
|
||||
this.duration = duration
|
||||
@@ -232,14 +226,20 @@ export default {
|
||||
},
|
||||
increasePlaybackRate() {
|
||||
if (this.playbackRate >= 10) return
|
||||
this.playbackRate = Number((this.playbackRate + 0.1).toFixed(1))
|
||||
this.playbackRate = Number((this.playbackRate + this.playbackRateIncrementDecrement || 0.1).toFixed(2))
|
||||
this.setPlaybackRate(this.playbackRate)
|
||||
},
|
||||
decreasePlaybackRate() {
|
||||
if (this.playbackRate <= 0.5) return
|
||||
this.playbackRate = Number((this.playbackRate - 0.1).toFixed(1))
|
||||
this.playbackRate = Number((this.playbackRate - this.playbackRateIncrementDecrement || 0.1).toFixed(2))
|
||||
this.setPlaybackRate(this.playbackRate)
|
||||
},
|
||||
playbackRateChanged(playbackRate) {
|
||||
this.setPlaybackRate(playbackRate)
|
||||
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
|
||||
console.error('Failed to update settings', err)
|
||||
})
|
||||
},
|
||||
setPlaybackRate(playbackRate) {
|
||||
this.$emit('setPlaybackRate', playbackRate)
|
||||
},
|
||||
@@ -321,6 +321,9 @@ export default {
|
||||
if (!this.chapters.length) return
|
||||
this.showChaptersModal = !this.showChaptersModal
|
||||
},
|
||||
showPlayerSettings() {
|
||||
this.showPlayerSettingsModal = !this.showPlayerSettingsModal
|
||||
},
|
||||
init() {
|
||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||
|
||||
|
||||
@@ -97,9 +97,9 @@ export default {
|
||||
},
|
||||
ebookUrl() {
|
||||
if (this.fileId) {
|
||||
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
|
||||
return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook/${this.fileId}`
|
||||
}
|
||||
return `/api/items/${this.libraryItemId}/ebook`
|
||||
return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook`
|
||||
},
|
||||
themeRules() {
|
||||
const isDark = this.ereaderSettings.theme === 'dark'
|
||||
|
||||
@@ -35,22 +35,22 @@
|
||||
<div class="flex justify-between pt-12">
|
||||
<div>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsWeekListening }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ totalMinutesListeningThisWeek }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ $formatNumber(totalMinutesListeningThisWeek) }}</p>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsMinutes }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsDailyAverage }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ averageMinutesPerDay }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ $formatNumber(averageMinutesPerDay) }}</p>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsMinutes }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsBestDay }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ mostListenedDay }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ $formatNumber(mostListenedDay) }}</p>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsMinutes }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsDays }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ daysInARow }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ $formatNumber(daysInARow) }}</p>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsInARow }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div id="heatmap" class="w-full">
|
||||
<div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)">
|
||||
<p class="mb-2 px-1 text-sm text-gray-200">{{ $getString('MessageListeningSessionsInTheLastYear', [Object.values(daysListening).length]) }}</p>
|
||||
<p class="mb-2 px-1 text-sm text-gray-200">{{ $getString('MessageDaysListenedInTheLastYear', [daysListenedInTheLastYear]) }}</p>
|
||||
<div class="border border-white border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
|
||||
<div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout">
|
||||
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
|
||||
@@ -37,6 +37,7 @@ export default {
|
||||
innerHeight: 13 * 7,
|
||||
blockWidth: 13,
|
||||
data: [],
|
||||
daysListenedInTheLastYear: 0,
|
||||
monthLabels: [],
|
||||
tooltipEl: null,
|
||||
tooltipTextEl: null,
|
||||
@@ -62,9 +63,6 @@ export default {
|
||||
dayOfWeekToday() {
|
||||
return new Date().getDay()
|
||||
},
|
||||
firstWeekStart() {
|
||||
return this.$addDaysToToday(-this.daysToShow)
|
||||
},
|
||||
dayLabels() {
|
||||
return [
|
||||
{
|
||||
@@ -193,46 +191,59 @@ export default {
|
||||
buildData() {
|
||||
this.data = []
|
||||
|
||||
var maxValue = 0
|
||||
var minValue = 0
|
||||
Object.values(this.daysListening).forEach((val) => {
|
||||
if (val > maxValue) maxValue = val
|
||||
if (!minValue || val < minValue) minValue = val
|
||||
})
|
||||
let maxValue = 0
|
||||
let minValue = 0
|
||||
|
||||
const dates = []
|
||||
|
||||
const numDaysInTheLastYear = 52 * 7 + this.dayOfWeekToday
|
||||
const firstDay = this.$addDaysToToday(-numDaysInTheLastYear)
|
||||
for (let i = 0; i < numDaysInTheLastYear + 1; i++) {
|
||||
const date = i === 0 ? firstDay : this.$addDaysToDate(firstDay, i)
|
||||
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
|
||||
|
||||
if (this.daysListening[dateString] > 0) {
|
||||
this.daysListenedInTheLastYear++
|
||||
}
|
||||
|
||||
const visibleDayIndex = i - (numDaysInTheLastYear - this.daysToShow)
|
||||
if (visibleDayIndex < 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const dateObj = {
|
||||
col: Math.floor(visibleDayIndex / 7),
|
||||
row: visibleDayIndex % 7,
|
||||
date,
|
||||
dateString,
|
||||
datePretty: this.$formatJsDate(date, 'MMM d, yyyy'),
|
||||
monthString: this.$formatJsDate(date, 'MMM'),
|
||||
dayOfMonth: Number(dateString.split('-').pop()),
|
||||
yearString: dateString.split('-').shift(),
|
||||
value: this.daysListening[dateString] || 0
|
||||
}
|
||||
dates.push(dateObj)
|
||||
|
||||
if (dateObj.value > 0) {
|
||||
if (dateObj.value > maxValue) maxValue = dateObj.value
|
||||
if (!minValue || dateObj.value < minValue) minValue = dateObj.value
|
||||
}
|
||||
}
|
||||
const range = maxValue - minValue + 0.01
|
||||
|
||||
for (let i = 0; i < this.daysToShow + 1; i++) {
|
||||
const col = Math.floor(i / 7)
|
||||
const row = i % 7
|
||||
|
||||
const date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i)
|
||||
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
|
||||
const datePretty = this.$formatJsDate(date, 'MMM d, yyyy')
|
||||
const monthString = this.$formatJsDate(date, 'MMM')
|
||||
const value = this.daysListening[dateString] || 0
|
||||
const x = col * 13
|
||||
const y = row * 13
|
||||
|
||||
var bgColor = this.bgColors[0]
|
||||
var outlineColor = this.outlineColors[0]
|
||||
if (value) {
|
||||
for (const dateObj of dates) {
|
||||
let bgColor = this.bgColors[0]
|
||||
let outlineColor = this.outlineColors[0]
|
||||
if (dateObj.value) {
|
||||
outlineColor = this.outlineColors[1]
|
||||
var percentOfAvg = (value - minValue) / range
|
||||
var bgIndex = Math.floor(percentOfAvg * 4) + 1
|
||||
const percentOfAvg = (dateObj.value - minValue) / range
|
||||
const bgIndex = Math.floor(percentOfAvg * 4) + 1
|
||||
bgColor = this.bgColors[bgIndex] || 'red'
|
||||
}
|
||||
|
||||
this.data.push({
|
||||
date,
|
||||
dateString,
|
||||
datePretty,
|
||||
monthString,
|
||||
dayOfMonth: Number(dateString.split('-').pop()),
|
||||
yearString: dateString.split('-').shift(),
|
||||
value,
|
||||
col,
|
||||
row,
|
||||
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
|
||||
...dateObj,
|
||||
style: `transform:translate(${dateObj.col * 13}px,${dateObj.row * 13}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -260,6 +271,7 @@ export default {
|
||||
const heatmapEl = document.getElementById('heatmap')
|
||||
this.contentWidth = heatmapEl.clientWidth
|
||||
this.maxInnerWidth = this.contentWidth - 52
|
||||
this.daysListenedInTheLastYear = 0
|
||||
this.buildData()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||
<div v-if="processing" role="img" :aria-label="$strings.MessageLoading" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
|
||||
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" :aria-label="$getString('LabelPersonalYearReview', [variant + 1])" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<p class="hidden md:block text-xl font-semibold">{{ $getString('HeaderYearReview', [yearInReviewYear]) }}</p>
|
||||
<h1 class="hidden md:block text-xl font-semibold">{{ $getString('HeaderYearReview', [yearInReviewYear]) }}</h1>
|
||||
<div class="hidden md:block flex-grow" />
|
||||
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? $strings.LabelYearReviewHide : $strings.LabelYearReviewShow }}</ui-btn>
|
||||
</div>
|
||||
@@ -16,17 +16,22 @@
|
||||
<div v-if="showYearInReview">
|
||||
<div class="w-full h-px bg-slate-200/10 my-4" />
|
||||
|
||||
<div class="flex items-center justify-center mb-2 max-w-[800px] mx-auto">
|
||||
<div v-if="availableYears.length > 1" class="mb-2 py-2 max-w-[800px] mx-auto">
|
||||
<!-- year selector -->
|
||||
<ui-dropdown v-model="yearInReviewYear" small :items="availableYears" :disabled="processingYearInReview" class="max-w-24" @input="yearInReviewYearChanged" />
|
||||
</div>
|
||||
|
||||
<div role="toolbar" class="flex items-center justify-center mb-2 max-w-[800px] mx-auto">
|
||||
<!-- previous button -->
|
||||
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
|
||||
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" :aria-label="$strings.ButtonPrevious" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
|
||||
<span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
|
||||
</ui-btn>
|
||||
<!-- share button -->
|
||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview">{{ $strings.ButtonShare }} </ui-btn>
|
||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview">{{ $strings.ButtonShare }} </ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<p class="hidden sm:block text-lg font-semibold">{{ $getString('LabelPersonalYearReview', [yearInReviewVariant + 1]) }}</p>
|
||||
<h2 class="hidden sm:block text-lg font-semibold">{{ $getString('LabelPersonalYearReview', [yearInReviewVariant + 1]) }}</h2>
|
||||
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
@@ -36,7 +41,7 @@
|
||||
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
|
||||
</ui-btn>
|
||||
<!-- next button -->
|
||||
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
|
||||
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" :aria-label="$strings.ButtonNext" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
|
||||
<span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span>
|
||||
<span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
@@ -46,23 +51,23 @@
|
||||
<!-- your year in review short -->
|
||||
<div class="w-full max-w-[800px] mx-auto my-4">
|
||||
<!-- share button -->
|
||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewShort" class="inline-flex sm:hidden items-center font-semibold mb-1" @click="shareYearInReviewShort">{{ $strings.ButtonShare }}</ui-btn>
|
||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewShort" class="inline-flex items-center font-semibold mb-1" @click="shareYearInReviewShort">{{ $strings.ButtonShare }}</ui-btn>
|
||||
<stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" />
|
||||
</div>
|
||||
|
||||
<!-- your server in review -->
|
||||
<div v-if="isAdminOrUp" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10">
|
||||
<div v-if="isAdminOrUp" role="toolbar" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10">
|
||||
<div class="flex items-center justify-center mb-2">
|
||||
<!-- previous button -->
|
||||
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
|
||||
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" :aria-label="$strings.ButtonPrevious" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
|
||||
<span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
|
||||
</ui-btn>
|
||||
<!-- share button -->
|
||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer">{{ $strings.ButtonShare }} </ui-btn>
|
||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer">{{ $strings.ButtonShare }} </ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<p class="hidden sm:block text-lg font-semibold">{{ $getString('LabelServerYearReview', [yearInReviewServerVariant + 1]) }}</p>
|
||||
<h2 class="hidden sm:block text-lg font-semibold">{{ $getString('LabelServerYearReview', [yearInReviewServerVariant + 1]) }}</h2>
|
||||
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
@@ -72,7 +77,7 @@
|
||||
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
|
||||
</ui-btn>
|
||||
<!-- next button -->
|
||||
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
|
||||
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" :aria-label="$strings.ButtonNext" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
|
||||
<span class="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span>
|
||||
<span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
@@ -88,6 +93,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
showYearInReview: false,
|
||||
availableYears: [],
|
||||
yearInReviewYear: 0,
|
||||
yearInReviewVariant: 0,
|
||||
yearInReviewServerVariant: 0,
|
||||
@@ -100,6 +106,9 @@ export default {
|
||||
computed: {
|
||||
isAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -112,25 +121,57 @@ export default {
|
||||
shareYearInReviewShort() {
|
||||
this.$refs.yearInReviewShort.share()
|
||||
},
|
||||
yearInReviewYearChanged() {
|
||||
this.$nextTick(() => {
|
||||
this.refreshYearInReview()
|
||||
this.refreshYearInReviewServer()
|
||||
})
|
||||
},
|
||||
refreshYearInReviewServer() {
|
||||
this.$refs.yearInReviewServer.refresh()
|
||||
if (this.$refs.yearInReviewServer != null) {
|
||||
this.$refs.yearInReviewServer.refresh()
|
||||
}
|
||||
},
|
||||
refreshYearInReview() {
|
||||
this.$refs.yearInReview.refresh()
|
||||
this.$refs.yearInReviewShort.refresh()
|
||||
if (this.$refs.yearInReview != null && this.$refs.yearInReviewShort != null) {
|
||||
this.$refs.yearInReview.refresh()
|
||||
this.$refs.yearInReviewShort.refresh()
|
||||
}
|
||||
},
|
||||
clickShowYearInReview() {
|
||||
this.showYearInReview = !this.showYearInReview
|
||||
},
|
||||
getAvailableYears() {
|
||||
if (this.user) {
|
||||
const oldestDate = this.user.createdAt
|
||||
if (oldestDate) {
|
||||
const date = new Date(oldestDate)
|
||||
const oldestYear = date.getFullYear()
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
const years = []
|
||||
for (let year = currentYear; year >= oldestYear; year--) {
|
||||
years.push({ value: year, text: year.toString() })
|
||||
}
|
||||
|
||||
return years
|
||||
}
|
||||
}
|
||||
// Fallback on error
|
||||
return [{ value: this.yearInReviewYear, text: this.yearInReviewYear.toString() }]
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
this.yearInReviewYear = new Date().getFullYear()
|
||||
|
||||
// When not December show previous year
|
||||
if (new Date().getMonth() < 11) {
|
||||
this.yearInReviewYear--
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.availableYears = this.getAvailableYears()
|
||||
|
||||
if (typeof navigator.share !== 'undefined' && navigator.share) {
|
||||
this.showShareButton = true
|
||||
} else {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||
<div v-if="processing" role="img" :aria-label="$strings.MessageLoading" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
|
||||
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" :aria-label="$getString('LabelServerYearReview', [variant + 1])" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export default {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update collection', error)
|
||||
this.$toast.error(this.$strings.ToastCollectionUpdateFailed)
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
},
|
||||
editBook(book) {
|
||||
|
||||
@@ -92,7 +92,7 @@ export default {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update playlist', error)
|
||||
this.$toast.error(this.$strings.ToastPlaylistUpdateFailed)
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
},
|
||||
init() {
|
||||
|
||||
@@ -120,6 +120,7 @@ export default {
|
||||
this.users = res.users.sort((a, b) => {
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
this.$emit('numUsers', this.users.length)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
|
||||
@@ -218,12 +218,11 @@ export default {
|
||||
this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess)
|
||||
} else {
|
||||
console.log(`Item removed from playlist`, updatedPlaylist)
|
||||
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove item from playlist', error)
|
||||
this.$toast.error(this.$strings.ToastPlaylistUpdateFailed)
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processingRemove = false
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
</div>
|
||||
<div class="h-8 flex items-center">
|
||||
<div class="w-full inline-flex justify-between max-w-xl">
|
||||
<p v-if="episode?.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
|
||||
<p v-if="episode?.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
||||
<p v-if="episode?.chapters?.length" class="text-sm text-gray-300">{{ episode.chapters.length }} Chapters</p>
|
||||
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p>
|
||||
<p v-if="episode?.season" class="text-sm text-gray-300">{{ $getString('LabelSeasonNumber', [episode.season]) }}</p>
|
||||
<p v-if="episode?.episode" class="text-sm text-gray-300">{{ $getString('LabelEpisodeNumber', [episode.episode]) }}</p>
|
||||
<p v-if="episode?.chapters?.length" class="text-sm text-gray-300">{{ $getString('LabelChapterCount', [episode.chapters.length]) }}</p>
|
||||
<p v-if="publishedAt" class="text-sm text-gray-300">{{ $getString('LabelPublishedDate', [$formatDate(publishedAt, dateFormat)]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,7 @@ export default {
|
||||
return this.episode?.title || ''
|
||||
},
|
||||
episodeSubtitle() {
|
||||
return this.episode?.subtitle || ''
|
||||
return this.episode?.subtitle || this.episode?.description || ''
|
||||
},
|
||||
episodeType() {
|
||||
return this.episode?.episodeType || ''
|
||||
@@ -132,13 +132,13 @@ export default {
|
||||
return this.store.state.streamIsPlaying && this.isStreaming
|
||||
},
|
||||
timeRemaining() {
|
||||
if (this.streamIsPlaying) return 'Playing'
|
||||
if (this.streamIsPlaying) return this.$strings.ButtonPlaying
|
||||
if (!this.itemProgress) return this.$elapsedPretty(this.episode?.duration || 0)
|
||||
if (this.userIsFinished) return 'Finished'
|
||||
if (this.userIsFinished) return this.$strings.LabelFinished
|
||||
|
||||
const duration = this.itemProgress.duration || this.episode?.duration || 0
|
||||
const remaining = Math.floor(duration - this.itemProgress.currentTime)
|
||||
return `${this.$elapsedPretty(remaining)} left`
|
||||
return this.$getString('LabelTimeLeft', [this.$elapsedPretty(remaining)])
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -182,7 +182,7 @@ export default {
|
||||
toggleFinished(confirmed = false) {
|
||||
if (!this.userIsFinished && this.itemProgressPercent > 0 && !confirmed) {
|
||||
const payload = {
|
||||
message: `Are you sure you want to mark "${this.episodeTitle}" as finished?`,
|
||||
message: this.$getString('MessageConfirmMarkItemFinished', [this.episodeTitle]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.toggleFinished(true)
|
||||
|
||||
@@ -25,13 +25,12 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p> -->
|
||||
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
|
||||
<form @submit.prevent="submit" class="flex flex-grow">
|
||||
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
|
||||
</form>
|
||||
</div>
|
||||
<div class="relative min-h-[176px]">
|
||||
<div class="relative min-h-44">
|
||||
<template v-for="episode in totalEpisodes">
|
||||
<div :key="episode" :id="`episode-${episode - 1}`" class="w-full h-44 px-2 py-3 overflow-hidden relative border-b border-white/10">
|
||||
<!-- episode is mounted here -->
|
||||
@@ -40,7 +39,7 @@
|
||||
<div v-if="isSearching" class="w-full h-full absolute inset-0 flex justify-center py-12" :class="{ 'bg-black/50': totalEpisodes }">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
<div v-else-if="!totalEpisodes" class="h-44 flex items-center justify-center">
|
||||
<div v-else-if="!totalEpisodes" id="no-episodes" class="h-44 flex items-center justify-center">
|
||||
<p class="text-lg">{{ $strings.MessageNoEpisodes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,7 +80,8 @@ export default {
|
||||
episodeComponentRefs: {},
|
||||
windowHeight: 0,
|
||||
episodesTableOffsetTop: 0,
|
||||
episodeRowHeight: 176
|
||||
episodeRowHeight: 44 * 4, // h-44,
|
||||
currScrollTop: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -93,17 +93,18 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
contextMenuItems() {
|
||||
if (!this.userIsAdminOrUp) return []
|
||||
return [
|
||||
{
|
||||
text: 'Quick match all episodes',
|
||||
const menuItems = []
|
||||
if (this.userIsAdminOrUp) {
|
||||
menuItems.push({
|
||||
text: this.$strings.MessageQuickMatchAllEpisodes,
|
||||
action: 'quick-match-episodes'
|
||||
},
|
||||
{
|
||||
text: this.allEpisodesFinished ? this.$strings.MessageMarkAllEpisodesNotFinished : this.$strings.MessageMarkAllEpisodesFinished,
|
||||
action: 'batch-mark-as-finished'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
menuItems.push({
|
||||
text: this.allEpisodesFinished ? this.$strings.MessageMarkAllEpisodesNotFinished : this.$strings.MessageMarkAllEpisodesFinished,
|
||||
action: 'batch-mark-as-finished'
|
||||
})
|
||||
return menuItems
|
||||
},
|
||||
sortItems() {
|
||||
return [
|
||||
@@ -261,21 +262,21 @@ export default {
|
||||
this.processing = true
|
||||
|
||||
const payload = {
|
||||
message: 'Quick matching episodes will overwrite details if a match is found. Only unmatched episodes will be updated. Are you sure?',
|
||||
message: this.$strings.MessageConfirmQuickMatchEpisodes,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
.$post(`/api/podcasts/${this.libraryItem.id}/match-episodes?override=1`)
|
||||
.then((data) => {
|
||||
if (data.numEpisodesUpdated) {
|
||||
this.$toast.success(`${data.numEpisodesUpdated} episodes updated`)
|
||||
this.$toast.success(this.$getString('ToastEpisodeUpdateSuccess', [data.numEpisodesUpdated]))
|
||||
} else {
|
||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to request match episodes', error)
|
||||
this.$toast.error('Failed to match episodes')
|
||||
this.$toast.error(this.$strings.ToastFailedToMatch)
|
||||
})
|
||||
}
|
||||
this.processing = false
|
||||
@@ -484,9 +485,8 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
scroll(evt) {
|
||||
if (!evt?.target?.scrollTop) return
|
||||
const scrollTop = Math.max(evt.target.scrollTop - this.episodesTableOffsetTop, 0)
|
||||
handleScroll() {
|
||||
const scrollTop = this.currScrollTop
|
||||
let firstEpisodeIndex = Math.floor(scrollTop / this.episodeRowHeight)
|
||||
let lastEpisodeIndex = Math.ceil((scrollTop + this.windowHeight) / this.episodeRowHeight)
|
||||
lastEpisodeIndex = Math.min(this.totalEpisodes - 1, lastEpisodeIndex)
|
||||
@@ -501,6 +501,12 @@ export default {
|
||||
})
|
||||
this.mountEpisodes(firstEpisodeIndex, lastEpisodeIndex + 1)
|
||||
},
|
||||
scroll(evt) {
|
||||
if (!evt?.target?.scrollTop) return
|
||||
const scrollTop = Math.max(evt.target.scrollTop - this.episodesTableOffsetTop, 0)
|
||||
this.currScrollTop = scrollTop
|
||||
this.handleScroll()
|
||||
},
|
||||
initListeners() {
|
||||
const itemPageWrapper = document.getElementById('item-page-wrapper')
|
||||
if (itemPageWrapper) {
|
||||
@@ -514,6 +520,10 @@ export default {
|
||||
}
|
||||
},
|
||||
filterSortChanged() {
|
||||
// Save filterKey and sortKey to local storage
|
||||
localStorage.setItem('podcastEpisodesFilter', this.filterKey)
|
||||
localStorage.setItem('podcastEpisodesSortBy', this.sortKey + (this.sortDesc ? '-desc' : ''))
|
||||
|
||||
this.init()
|
||||
},
|
||||
refresh() {
|
||||
@@ -528,14 +538,32 @@ export default {
|
||||
this.episodesTableOffsetTop = (lazyEpisodesTableEl?.offsetTop || 0) + 64
|
||||
|
||||
this.windowHeight = window.innerHeight
|
||||
this.episodesPerPage = Math.ceil(this.windowHeight / this.episodeRowHeight)
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.mountEpisodes(0, Math.min(this.episodesPerPage, this.totalEpisodes))
|
||||
this.recalcEpisodeRowHeight()
|
||||
this.episodesPerPage = Math.ceil(this.windowHeight / this.episodeRowHeight)
|
||||
// Maybe update currScrollTop if items were removed
|
||||
const itemPageWrapper = document.getElementById('item-page-wrapper')
|
||||
const { scrollHeight, clientHeight } = itemPageWrapper
|
||||
const maxScrollTop = scrollHeight - clientHeight
|
||||
this.currScrollTop = Math.min(this.currScrollTop, maxScrollTop)
|
||||
this.handleScroll()
|
||||
})
|
||||
},
|
||||
recalcEpisodeRowHeight() {
|
||||
const episodeRowEl = document.getElementById('episode-0') || document.getElementById('no-episodes')
|
||||
if (episodeRowEl) {
|
||||
const height = getComputedStyle(episodeRowEl).height
|
||||
this.episodeRowHeight = parseInt(height) || this.episodeRowHeight
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.filterKey = localStorage.getItem('podcastEpisodesFilter') || 'incomplete'
|
||||
const sortBy = localStorage.getItem('podcastEpisodesSortBy') || 'publishedAt-desc'
|
||||
this.sortKey = sortBy.split('-')[0]
|
||||
this.sortDesc = sortBy.split('-')[1] === 'desc'
|
||||
|
||||
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
|
||||
this.initListeners()
|
||||
this.init()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
|
||||
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing">
|
||||
<button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" :aria-label="$strings.LabelMore" aria-haspopup="menu" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<span class="material-symbols text-2xl" :class="iconClass"></span>
|
||||
</button>
|
||||
<div v-else class="h-full w-full flex items-center justify-center">
|
||||
@@ -10,12 +10,12 @@
|
||||
</slot>
|
||||
|
||||
<transition name="menu">
|
||||
<div v-show="showMenu" ref="menuWrapper" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }">
|
||||
<div v-show="showMenu" ref="menuWrapper" role="menu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<template v-if="item.subitems">
|
||||
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
|
||||
<button :key="index" role="menuitem" aria-haspopup="menu" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default w-full" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
|
||||
<p>{{ item.text }}</p>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
v-if="mouseoverItemIndex === index"
|
||||
:key="`subitems-${index}`"
|
||||
@@ -25,14 +25,14 @@
|
||||
:class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'"
|
||||
:style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }"
|
||||
>
|
||||
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.action, subitem.data)">
|
||||
<button v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(subitem.action, subitem.data)">
|
||||
<p>{{ subitem.text }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
|
||||
<button v-else :key="index" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(item.action)">
|
||||
<p class="text-left">{{ item.text }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
||||
<p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<button type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<button type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="menu" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
|
||||
<span v-if="selectedSubtext">: </span>
|
||||
@@ -13,9 +13,9 @@
|
||||
</button>
|
||||
|
||||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="listbox" :style="{ maxHeight: menuMaxHeight }">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="menu" :style="{ maxHeight: menuMaxHeight }">
|
||||
<template v-for="item in itemsToShow">
|
||||
<li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" :id="'listbox-option-' + item.value" role="option" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)">
|
||||
<li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" role="menuitem" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="ml-3 block truncate font-sans text-sm" :class="{ 'font-semibold': item.subtext }">{{ item.text }}</span>
|
||||
<span v-if="item.subtext">: </span>
|
||||
@@ -119,4 +119,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<button class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
|
||||
<button :aria-label="ariaLabel" class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
|
||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
@@ -28,7 +28,8 @@ export default {
|
||||
size: {
|
||||
type: Number,
|
||||
default: 9
|
||||
}
|
||||
},
|
||||
ariaLabel: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200"
|
||||
aria-haspopup="listbox"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="showMenu"
|
||||
:aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name"
|
||||
@click.stop.prevent="clickShowMenu"
|
||||
@@ -16,9 +16,9 @@
|
||||
</button>
|
||||
|
||||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="listbox">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="menu">
|
||||
<template v-for="library in librariesFiltered">
|
||||
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="option" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
|
||||
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="menuitem" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
|
||||
<div class="flex items-center px-2">
|
||||
<ui-library-icon :icon="library.icon" class="mr-1.5" />
|
||||
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||
<label :for="identifier" class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</label>
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative">
|
||||
<div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 px-1 bg-bg bg-opacity-75 flex items-center justify-end opacity-0 hover:opacity-100">
|
||||
<span v-if="showEdit" class="material-symbols text-white hover:text-warning cursor-pointer" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
|
||||
<span class="material-symbols text-white hover:text-error cursor-pointer" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
|
||||
<div ref="inputWrapper" role="list" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div v-for="item in selected" :key="item" role="listitem" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative">
|
||||
<div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 px-1 bg-bg bg-opacity-75 flex items-center justify-end opacity-0 hover:opacity-100" :class="{ 'opacity-100': inputFocused }">
|
||||
<button v-if="showEdit" type="button" :aria-label="$strings.ButtonEdit" class="material-symbols text-white hover:text-warning cursor-pointer" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</button>
|
||||
<button type="button" :aria-label="$strings.ButtonRemove" class="material-symbols text-white hover:text-error focus:text-error cursor-pointer" style="font-size: 1.1rem" @click.stop="removeItem(item)" @keydown.enter.stop.prevent="removeItem(item)" @focus="setInputFocused(true)" @blur="setInputFocused(false)" tabindex="0">close</button>
|
||||
</div>
|
||||
{{ item }}
|
||||
</div>
|
||||
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" class="h-full bg-primary focus:outline-none px-1 w-6" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
|
||||
<input v-show="!readonly" v-model="textInput" ref="input" :id="identifier" :disabled="disabled" class="h-full bg-primary focus:outline-none px-1 w-6" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -66,7 +66,8 @@ export default {
|
||||
typingTimeout: null,
|
||||
isFocused: false,
|
||||
menu: null,
|
||||
filteredItems: null
|
||||
filteredItems: null,
|
||||
inputFocused: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -100,6 +101,9 @@ export default {
|
||||
}
|
||||
|
||||
return this.filteredItems
|
||||
},
|
||||
identifier() {
|
||||
return Math.random().toString(36).substring(2)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -129,6 +133,9 @@ export default {
|
||||
}, 100)
|
||||
this.setInputWidth()
|
||||
},
|
||||
setInputFocused(focused) {
|
||||
this.inputFocused = focused
|
||||
},
|
||||
setInputWidth() {
|
||||
setTimeout(() => {
|
||||
var value = this.$refs.input.value
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||
<label :for="identifier" class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</label>
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-0.5" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div v-for="item in selected" :key="item.id" class="rounded-full px-2 py-0.5 m-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center justify-center relative min-w-12">
|
||||
<div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
|
||||
<span v-if="showEdit" class="material-symbols text-base text-white hover:text-warning mr-1" @click.stop="editItem(item)">edit</span>
|
||||
<span class="material-symbols text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.id)">close</span>
|
||||
<div ref="inputWrapper" role="list" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-0.5" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div v-for="item in selected" :key="item.id" role="listitem" class="rounded-full px-2 py-0.5 m-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center justify-center relative min-w-12">
|
||||
<div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer" :class="{ 'opacity-100': inputFocused }">
|
||||
<button v-if="showEdit" type="button" :aria-label="$strings.ButtonEdit" class="material-symbols text-base text-white hover:text-warning focus:text-warning mr-1" @click.stop="editItem(item)" @keydown.enter.stop.prevent="editItem(item)" @focus="setInputFocused(true)" @blur="setInputFocused(false)" tabindex="0">edit</button>
|
||||
<button type="button" :aria-label="$strings.ButtonRemove" class="material-symbols text-white hover:text-error focus:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.id)" @keydown.enter.stop="removeItem(item.id)" @focus="setInputFocused(true)" @blur="setInputFocused(false)" tabindex="0">close</button>
|
||||
</div>
|
||||
{{ item[textKey] }}
|
||||
</div>
|
||||
<div v-if="showEdit && !disabled" class="rounded-full cursor-pointer w-6 h-6 mx-0.5 bg-bg flex items-center justify-center">
|
||||
<span class="material-symbols text-white hover:text-success pt-px pr-px" style="font-size: 1.1rem" @click.stop="addItem">add</span>
|
||||
<button type="button" :aria-label="$strings.ButtonAdd" class="material-symbols text-white hover:text-success focus:text-success pt-px pr-px" style="font-size: 1.1rem" @click.stop="addItem" @keydown.enter.stop="addItem" tabindex="0">add</button>
|
||||
</div>
|
||||
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" class="h-full bg-primary focus:outline-none px-1 w-6" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
|
||||
<input v-show="!readonly" v-model="textInput" ref="input" :id="identifier" :disabled="disabled" class="h-full bg-primary focus:outline-none px-1 w-6" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -65,6 +65,7 @@ export default {
|
||||
currentSearch: null,
|
||||
typingTimeout: null,
|
||||
isFocused: false,
|
||||
inputFocused: false,
|
||||
menu: null,
|
||||
items: []
|
||||
}
|
||||
@@ -102,6 +103,9 @@ export default {
|
||||
},
|
||||
filterData() {
|
||||
return this.$store.state.libraries.filterData || {}
|
||||
},
|
||||
identifier() {
|
||||
return Math.random().toString(36).substring(2)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -114,6 +118,9 @@ export default {
|
||||
getIsSelected(itemValue) {
|
||||
return !!this.selected.find((i) => i.id === itemValue)
|
||||
},
|
||||
setInputFocused(focused) {
|
||||
this.inputFocused = focused
|
||||
},
|
||||
search() {
|
||||
if (!this.textInput) return
|
||||
this.currentSearch = this.textInput
|
||||
@@ -208,6 +215,10 @@ export default {
|
||||
inputBlur() {
|
||||
if (!this.isFocused) return
|
||||
|
||||
if (typeof this.textInput === 'string') {
|
||||
this.textInput = this.textInput.trim()
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (document.activeElement === this.$refs.input) {
|
||||
return
|
||||
@@ -224,6 +235,11 @@ export default {
|
||||
},
|
||||
forceBlur() {
|
||||
this.isFocused = false
|
||||
|
||||
if (typeof this.textInput === 'string') {
|
||||
this.textInput = this.textInput.trim()
|
||||
}
|
||||
|
||||
if (this.textInput) this.submitForm()
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
},
|
||||
@@ -282,11 +298,12 @@ export default {
|
||||
this.selectedMenuItemIndex = null
|
||||
},
|
||||
submitForm() {
|
||||
if (!this.textInput) return
|
||||
if (!this.textInput || !this.textInput.trim?.()) return
|
||||
|
||||
this.textInput = this.textInput.trim()
|
||||
|
||||
const cleaned = this.textInput.trim()
|
||||
const matchesItem = this.items.find((i) => {
|
||||
return i.name === cleaned
|
||||
return i.name === this.textInput
|
||||
})
|
||||
|
||||
if (matchesItem) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
|
||||
<button :aria-label="isRead ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :class="borderless ? '' : 'bg-primary border border-gray-600'" @click="clickBtn">
|
||||
<div class="w-5 h-5 text-white relative">
|
||||
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
|
||||
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="default-style">
|
||||
<p v-if="label" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
|
||||
<p v-if="label" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }" style="margin-top: 0; margin-bottom: 0.125em">
|
||||
{{ label }}
|
||||
</p>
|
||||
<ui-vue-trix v-model="content" :config="config" :disabled-editor="disabled" @trix-file-accept="trixFileAccept" />
|
||||
<ui-vue-trix ref="input" v-model="content" :disabled-editor="disabled" @trix-file-accept="trixFileAccept" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -12,7 +12,10 @@ export default {
|
||||
props: {
|
||||
value: String,
|
||||
label: String,
|
||||
disabled: Boolean
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -25,49 +28,19 @@ export default {
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
config() {
|
||||
return {
|
||||
toolbar: {
|
||||
getDefaultHTML: () => `<div class="trix-button-row">
|
||||
<span class="trix-button-group trix-button-group--text-tools" data-trix-button-group="text-tools">
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-bold" data-trix-attribute="bold" data-trix-key="b" title="${this.$strings.LabelFontBold}" tabindex="-1">${this.$strings.LabelFontBold}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-italic" data-trix-attribute="italic" data-trix-key="i" title="${this.$strings.LabelFontItalic}" tabindex="-1">${this.$strings.LabelFontItalic}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-strike" data-trix-attribute="strike" title="${this.$strings.LabelFontStrikethrough}" tabindex="-1">${this.$strings.LabelFontStrikethrough}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-link" data-trix-attribute="href" data-trix-action="link" data-trix-key="k" title="${this.$strings.LabelTextEditorLink}" tabindex="-1">${this.$strings.LabelTextEditorLink}</button>
|
||||
</span>
|
||||
<span class="trix-button-group trix-button-group--block-tools" data-trix-button-group="block-tools">
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-bullet-list" data-trix-attribute="bullet" title="${this.$strings.LabelTextEditorBulletedList}" tabindex="-1">${this.$strings.LabelTextEditorBulletedList}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-number-list" data-trix-attribute="number" title="${this.$strings.LabelTextEditorNumberedList}" tabindex="-1">${this.$strings.LabelTextEditorNumberedList}</button>
|
||||
</span>
|
||||
|
||||
<span class="trix-button-group-spacer"></span>
|
||||
<span class="trix-button-group trix-button-group--history-tools" data-trix-button-group="history-tools">
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-undo" data-trix-action="undo" data-trix-key="z" title="${this.$strings.LabelUndo}" tabindex="-1">${this.$strings.LabelUndo}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-redo" data-trix-action="redo" data-trix-key="shift+z" title="${this.$strings.LabelRedo}" tabindex="-1">${this.$strings.LabelRedo}</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="trix-dialogs" data-trix-dialogs>
|
||||
<div class="trix-dialog trix-dialog--link" data-trix-dialog="href" data-trix-dialog-attribute="href">
|
||||
<div class="trix-dialog__link-fields">
|
||||
<input type="url" name="href" class="trix-input trix-input--dialog" placeholder="" aria-label="URL" required data-trix-input>
|
||||
<div class="trix-button-group">
|
||||
<input type="button" class="trix-button trix-button--dialog" value="${this.$strings.LabelTextEditorLink}" data-trix-method="setAttribute">
|
||||
<input type="button" class="trix-button trix-button--dialog" value="${this.$strings.LabelTextEditorUnlink}" data-trix-method="removeAttribute">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
trixFileAccept(e) {
|
||||
e.preventDefault()
|
||||
},
|
||||
blur() {
|
||||
if (this.$refs.input && this.$refs.input.blur) {
|
||||
this.$refs.input.blur()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,32 +1,14 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative">
|
||||
<input
|
||||
:id="inputId"
|
||||
:name="inputName"
|
||||
ref="input"
|
||||
v-model="inputValue"
|
||||
:type="actualType"
|
||||
:step="step"
|
||||
:min="min"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder"
|
||||
dir="auto"
|
||||
class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full"
|
||||
:class="classList"
|
||||
@keyup="keyup"
|
||||
@change="change"
|
||||
@focus="focused"
|
||||
@blur="blurred"
|
||||
/>
|
||||
<input :id="inputId" :name="inputName" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" dir="auto" class="rounded bg-primary text-gray-200 focus:bg-bg focus:outline-none border h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
||||
<span class="material-symbols text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
||||
</div>
|
||||
<div v-if="type === 'password' && isHovering" class="absolute top-0 right-0 h-full px-4 flex items-center justify-center">
|
||||
<span class="material-symbols text-gray-400 cursor-pointer text-lg" @click.stop.prevent="showPassword = !showPassword">{{ !showPassword ? 'visibility' : 'visibility_off' }}</span>
|
||||
</div>
|
||||
<div v-else-if="showCopy" class="absolute top-0 right-0 h-full px-4 flex items-center justify-center">
|
||||
<span class="material-symbols text-gray-400 cursor-pointer text-lg" @click.stop.prevent="copyToClipboard">{{ !hasCopied ? 'content_copy' : 'done' }}</span>
|
||||
<div v-else-if="showCopy" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
||||
<span class="material-symbols cursor-pointer text-lg" :class="hasCopied ? 'text-success' : 'text-gray-400 hover:text-white'" @click.stop.prevent="copyToClipboard">{{ !hasCopied ? 'content_copy' : 'done' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -57,14 +39,17 @@ export default {
|
||||
inputName: String,
|
||||
showCopy: Boolean,
|
||||
step: [String, Number],
|
||||
min: [String, Number]
|
||||
min: [String, Number],
|
||||
customInputClass: String,
|
||||
trimWhitespace: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showPassword: false,
|
||||
isHovering: false,
|
||||
isFocused: false,
|
||||
hasCopied: false
|
||||
hasCopied: null,
|
||||
isInvalidDate: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -78,10 +63,20 @@ export default {
|
||||
},
|
||||
classList() {
|
||||
var _list = []
|
||||
_list.push(`px-${this.paddingX}`)
|
||||
if (this.showCopy) {
|
||||
_list.push('pl-3', 'pr-8')
|
||||
} else {
|
||||
_list.push(`px-${this.paddingX}`)
|
||||
}
|
||||
|
||||
_list.push(`py-${this.paddingY}`)
|
||||
if (this.noSpinner) _list.push('no-spinner')
|
||||
if (this.textCenter) _list.push('text-center')
|
||||
if (this.customInputClass) _list.push(this.customInputClass)
|
||||
|
||||
if (this.isInvalidDate) _list.push('border-error')
|
||||
else _list.push('focus:border-gray-300 border-gray-600')
|
||||
|
||||
return _list.join(' ')
|
||||
},
|
||||
actualType() {
|
||||
@@ -91,11 +86,10 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
copyToClipboard() {
|
||||
if (this.hasCopied) return
|
||||
clearTimeout(this.hasCopied)
|
||||
this.$copyToClipboard(this.inputValue).then((success) => {
|
||||
this.hasCopied = success
|
||||
setTimeout(() => {
|
||||
this.hasCopied = false
|
||||
this.hasCopied = setTimeout(() => {
|
||||
this.hasCopied = null
|
||||
}, 2000)
|
||||
})
|
||||
},
|
||||
@@ -108,14 +102,26 @@ export default {
|
||||
this.$emit('focus')
|
||||
},
|
||||
blurred() {
|
||||
if (this.trimWhitespace && typeof this.inputValue === 'string') {
|
||||
this.inputValue = this.inputValue.trim()
|
||||
}
|
||||
this.isFocused = false
|
||||
this.$emit('blur')
|
||||
},
|
||||
|
||||
change(e) {
|
||||
this.$emit('change', e.target.value)
|
||||
},
|
||||
keyup(e) {
|
||||
this.$emit('keyup', e)
|
||||
|
||||
if (this.type === 'datetime-local') {
|
||||
if (e.target.validity?.badInput) {
|
||||
this.isInvalidDate = true
|
||||
} else {
|
||||
this.isInvalidDate = false
|
||||
}
|
||||
}
|
||||
},
|
||||
blur() {
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<slot>
|
||||
<label :for="identifier" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }"
|
||||
>{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em></label
|
||||
>
|
||||
<label :for="identifier" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
|
||||
{{ label }}
|
||||
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</label>
|
||||
</slot>
|
||||
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
|
||||
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :show-copy="showCopy" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -22,7 +23,9 @@ export default {
|
||||
},
|
||||
readonly: Boolean,
|
||||
disabled: Boolean,
|
||||
inputClass: String
|
||||
inputClass: String,
|
||||
showCopy: Boolean,
|
||||
trimWhitespace: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -57,4 +60,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<button :aria-labelledby="labeledBy" role="checkbox" type="button" class="border rounded-full border-black-100 flex items-center cursor-pointer w-10 justify-start" :aria-checked="toggleValue" :class="className" @click="clickToggle">
|
||||
<span class="rounded-full border w-5 h-5 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span>
|
||||
<button :aria-labelledby="labeledBy" :aria-label="label" role="checkbox" type="button" class="border rounded-full border-black-100 flex items-center cursor-pointer justify-start" :style="{ width: buttonWidth + 'px' }" :aria-checked="toggleValue" :class="className" @click="clickToggle">
|
||||
<span class="rounded-full border border-black-50 shadow transform transition-transform duration-100" :style="{ width: cursorHeightWidth + 'px', height: cursorHeightWidth + 'px' }" :class="switchClassName"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -19,7 +19,12 @@ export default {
|
||||
default: 'primary'
|
||||
},
|
||||
disabled: Boolean,
|
||||
labeledBy: String
|
||||
labeledBy: String,
|
||||
label: String,
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
toggleValue: {
|
||||
@@ -37,6 +42,13 @@ export default {
|
||||
switchClassName() {
|
||||
var bgColor = this.disabled ? 'bg-gray-300' : 'bg-white'
|
||||
return this.toggleValue ? 'translate-x-5 ' + bgColor : bgColor
|
||||
},
|
||||
cursorHeightWidth() {
|
||||
if (this.size === 'sm') return 16
|
||||
return 20
|
||||
},
|
||||
buttonWidth() {
|
||||
return this.cursorHeightWidth * 2
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
<template>
|
||||
<div>
|
||||
<trix-editor :contenteditable="!disabledEditor" :class="['trix-content']" ref="trix" :input="computedId" :placeholder="placeholder" @trix-change="handleContentChange" @trix-initialize="handleInitialize" @trix-focus="processTrixFocus" @trix-blur="processTrixBlur" />
|
||||
<trix-toolbar :id="toolbarId">
|
||||
<div v-show="!disabledEditor" class="trix-button-row">
|
||||
<span class="trix-button-group trix-button-group--text-tools" data-trix-button-group="text-tools">
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-bold" data-trix-attribute="bold" data-trix-key="b" :title="$strings.LabelFontBold" tabindex="-1">{{ $strings.LabelFontBold }}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-italic" data-trix-attribute="italic" data-trix-key="i" :title="$strings.LabelFontItalic" tabindex="-1">{{ $strings.LabelFontItalic }}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-strike" data-trix-attribute="strike" :title="$strings.LabelFontStrikethrough" tabindex="-1">{{ $strings.LabelFontStrikethrough }}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-link" data-trix-attribute="href" data-trix-action="link" data-trix-key="k" :title="$strings.LabelTextEditorLink" tabindex="-1">{{ $strings.LabelTextEditorLink }}</button>
|
||||
</span>
|
||||
<span class="trix-button-group trix-button-group--block-tools" data-trix-button-group="block-tools">
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-bullet-list" data-trix-attribute="bullet" :title="$strings.LabelTextEditorBulletedList" tabindex="-1">{{ $strings.LabelTextEditorBulletedList }}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-number-list" data-trix-attribute="number" :title="$strings.LabelTextEditorNumberedList" tabindex="-1">{{ $strings.LabelTextEditorNumberedList }}</button>
|
||||
</span>
|
||||
|
||||
<span class="trix-button-group-spacer"></span>
|
||||
<span class="trix-button-group trix-button-group--history-tools" data-trix-button-group="history-tools">
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-undo" data-trix-action="undo" data-trix-key="z" :title="$strings.LabelUndo" tabindex="-1">{{ $strings.LabelUndo }}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-redo" data-trix-action="redo" data-trix-key="shift+z" :title="$strings.LabelRedo" tabindex="-1">{{ $strings.LabelRedo }}</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="trix-dialogs" data-trix-dialogs>
|
||||
<div class="trix-dialog trix-dialog--link" data-trix-dialog="href" data-trix-dialog-attribute="href">
|
||||
<div class="trix-dialog__link-fields">
|
||||
<input type="url" name="href" class="trix-input trix-input--dialog" placeholder="" aria-label="URL" required data-trix-input />
|
||||
<div class="trix-button-group">
|
||||
<input type="button" class="trix-button trix-button--dialog" :value="$strings.LabelTextEditorLink" data-trix-method="setAttribute" />
|
||||
<input type="button" class="trix-button trix-button--dialog" :value="$strings.LabelTextEditorUnlink" data-trix-method="removeAttribute" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</trix-toolbar>
|
||||
<trix-editor :toolbar="toolbarId" :contenteditable="!disabledEditor" :class="['trix-content']" ref="trix" :input="computedId" :placeholder="placeholder" @trix-change="handleContentChange" @trix-initialize="handleInitialize" @trix-focus="processTrixFocus" @trix-blur="processTrixBlur" />
|
||||
<input type="hidden" :name="inputName" :id="computedId" :value="editorContent" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -14,6 +45,30 @@
|
||||
import Trix from 'trix'
|
||||
import '@/assets/trix.css'
|
||||
|
||||
function enableBreakParagraphOnReturn() {
|
||||
// Trix works with divs by default, we want paragraphs instead
|
||||
Trix.config.blockAttributes.default.tagName = 'p'
|
||||
// Enable break paragraph on Enter (Shift + Enter will still create a line break)
|
||||
Trix.config.blockAttributes.default.breakOnReturn = true
|
||||
|
||||
// Hack to fix buggy paragraph breaks
|
||||
// Copied from https://github.com/basecamp/trix/issues/680#issuecomment-735742942
|
||||
Trix.Block.prototype.breaksOnReturn = function () {
|
||||
const attr = this.getLastAttribute()
|
||||
const config = Trix.getBlockConfig(attr ? attr : 'default')
|
||||
return config ? config.breakOnReturn : false
|
||||
}
|
||||
Trix.LineBreakInsertion.prototype.shouldInsertBlockBreak = function () {
|
||||
if (this.block.hasAttributes() && this.block.isListItem() && !this.block.isEmpty()) {
|
||||
return this.startLocation.offset > 0
|
||||
} else {
|
||||
return !this.shouldBreakFormattedBlock() ? this.breaksOnReturn : false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enableBreakParagraphOnReturn()
|
||||
|
||||
export default {
|
||||
name: 'vue-trix',
|
||||
model: {
|
||||
@@ -134,6 +189,9 @@ export default {
|
||||
* Compute a random id of hidden input
|
||||
* when it haven't been specified.
|
||||
*/
|
||||
toolbarId() {
|
||||
return `trix-toolbar-${this.generateId}`
|
||||
},
|
||||
generateId() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
var r = (Math.random() * 16) | 0
|
||||
@@ -223,13 +281,17 @@ export default {
|
||||
decorateDisabledEditor(editorState) {
|
||||
/** Disable toolbar and editor by pointer events styling */
|
||||
if (editorState) {
|
||||
this.$refs.trix.toolbarElement.style['pointer-events'] = 'none'
|
||||
this.$refs.trix.disabled = true
|
||||
this.$refs.trix.contentEditable = false
|
||||
this.$refs.trix.style['background'] = '#e9ecef'
|
||||
this.$refs.trix.style['pointer-events'] = 'none'
|
||||
this.$refs.trix.style['background-color'] = '#444'
|
||||
this.$refs.trix.style['color'] = '#bbb'
|
||||
} else {
|
||||
this.$refs.trix.toolbarElement.style['pointer-events'] = 'unset'
|
||||
this.$refs.trix.disabled = false
|
||||
this.$refs.trix.contentEditable = true
|
||||
this.$refs.trix.style['pointer-events'] = 'unset'
|
||||
this.$refs.trix.style['background'] = 'transparent'
|
||||
this.$refs.trix.style['background-color'] = ''
|
||||
this.$refs.trix.style['color'] = ''
|
||||
}
|
||||
},
|
||||
overrideConfig(config) {
|
||||
@@ -249,6 +311,11 @@ export default {
|
||||
}
|
||||
}
|
||||
return target
|
||||
},
|
||||
blur() {
|
||||
if (this.$refs.trix && this.$refs.trix.blur) {
|
||||
this.$refs.trix.blur()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -283,4 +350,14 @@ export default {
|
||||
.trix_container .trix-content {
|
||||
background-color: white;
|
||||
}
|
||||
</style>
|
||||
trix-editor {
|
||||
height: calc(4 * 1lh);
|
||||
min-height: calc(4 * 1lh);
|
||||
overflow-y: auto;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
trix-editor * {
|
||||
pointer-events: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<template>
|
||||
<ui-tooltip v-if="alreadyInLibrary" :text="$strings.LabelAlreadyInYourLibrary" direction="top">
|
||||
<span class="material-symbols ml-1 text-success" style="font-size: 0.8rem">check_circle</span>
|
||||
<ui-tooltip :text="$strings.LabelAlreadyInYourLibrary" direction="top" class="inline-flex">
|
||||
<span class="material-symbols ml-1 text-sm text-success">check_circle</span>
|
||||
</ui-tooltip>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
alreadyInLibrary: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<form class="w-full h-full px-2 md:px-4 py-6" @submit.prevent="submitForm">
|
||||
<div class="flex flex-wrap -mx-1">
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="titleInput" v-model="details.title" :label="$strings.LabelTitle" @input="handleInputChange" />
|
||||
<ui-text-input-with-label ref="titleInput" v-model="details.title" :label="$strings.LabelTitle" trim-whitespace @input="handleInputChange" />
|
||||
</div>
|
||||
<div class="flex-grow px-1 mt-2 md:mt-0">
|
||||
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" :label="$strings.LabelSubtitle" @input="handleInputChange" />
|
||||
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" :label="$strings.LabelSubtitle" trim-whitespace @input="handleInputChange" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" :label="$strings.LabelDescription" class="mt-2" @input="handleInputChange" />
|
||||
<ui-rich-text-editor ref="descriptionInput" v-model="details.description" :label="$strings.LabelDescription" class="mt-2" @input="handleInputChange" />
|
||||
|
||||
<div class="flex flex-wrap mt-2 -mx-1">
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
@@ -42,19 +42,19 @@
|
||||
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" :label="$strings.LabelNarrators" :items="narrators" @input="handleInputChange" />
|
||||
</div>
|
||||
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
|
||||
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" @input="handleInputChange" />
|
||||
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" trim-whitespace @input="handleInputChange" />
|
||||
</div>
|
||||
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
|
||||
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" @input="handleInputChange" />
|
||||
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" trim-whitespace @input="handleInputChange" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap mt-2 -mx-1">
|
||||
<div class="w-full md:w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" :label="$strings.LabelPublisher" @input="handleInputChange" />
|
||||
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" :label="$strings.LabelPublisher" trim-whitespace @input="handleInputChange" />
|
||||
</div>
|
||||
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
|
||||
<ui-text-input-with-label ref="languageInput" v-model="details.language" :label="$strings.LabelLanguage" @input="handleInputChange" />
|
||||
<ui-text-input-with-label ref="languageInput" v-model="details.language" :label="$strings.LabelLanguage" trim-whitespace @input="handleInputChange" />
|
||||
</div>
|
||||
<div class="flex-grow px-1 pt-6 mt-2 md:mt-0">
|
||||
<div class="flex justify-center">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
|
||||
<div aria-hidden="true" class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
|
||||
<span class="material-symbols" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize" aria-label="Decrease Cover Size" role="button"></span>
|
||||
<p class="px-2 font-mono" style="font-size: 1rem">{{ bookCoverWidth }}</p>
|
||||
<span class="material-symbols" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize" aria-label="Increase Cover Size" role="button"></span>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<div class="flex items-center py-3e">
|
||||
<slot />
|
||||
<div class="flex-grow" />
|
||||
<button cy-id="leftScrollButton" v-if="isScrollable" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
|
||||
<button cy-id="leftScrollButton" v-if="isScrollable" :aria-label="$strings.ButtonScrollLeft" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
|
||||
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_left</span>
|
||||
</button>
|
||||
<button cy-id="rightScrollButton" v-if="isScrollable" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
|
||||
<button cy-id="rightScrollButton" v-if="isScrollable" :aria-label="$strings.ButtonScrollRight" class="w-8e h-8e mx-1e flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
|
||||
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -65,7 +65,7 @@ export default {
|
||||
},
|
||||
authors: {
|
||||
component: 'cards-author-card',
|
||||
itemPropName: 'author',
|
||||
itemPropName: 'author-mount',
|
||||
itemIdFunc: (item) => item.id
|
||||
},
|
||||
narrators: {
|
||||
@@ -124,6 +124,7 @@ export default {
|
||||
this.updateSelectionMode(false)
|
||||
},
|
||||
editEpisode({ libraryItem, episode }) {
|
||||
this.$store.commit('setEpisodeTableEpisodeIds', [episode.id])
|
||||
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user