mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-31 11:38:47 -05:00
Compare commits
529 Commits
v2.17.0
...
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 | ||
|
|
d5fbc1d455 | ||
|
|
27c9381e1d | ||
|
|
0d54b57151 | ||
|
|
0812e189f7 | ||
|
|
588def6d33 | ||
|
|
a0b3960ee4 | ||
|
|
e55db0afdc | ||
|
|
ae9efe6359 |
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"
|
||||
});
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@
|
||||
/podcasts/
|
||||
/media/
|
||||
/metadata/
|
||||
/plugins/
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
/dist/
|
||||
|
||||
@@ -46,5 +46,10 @@ 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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -42,8 +42,11 @@
|
||||
<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">
|
||||
@@ -265,6 +268,9 @@ export default {
|
||||
isPodcastLatestPage() {
|
||||
return this.$route.name === 'library-library-podcast-latest'
|
||||
},
|
||||
isPodcastDownloadQueuePage() {
|
||||
return this.$route.name === 'library-library-podcast-download-queue'
|
||||
},
|
||||
isAuthorsPage() {
|
||||
return this.page === 'authors'
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
@@ -99,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
|
||||
@@ -171,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
|
||||
@@ -184,9 +204,6 @@ export default {
|
||||
entityWidth() {
|
||||
return this.cardWidth
|
||||
},
|
||||
entityHeight() {
|
||||
return this.cardHeight
|
||||
},
|
||||
shelfPaddingHeight() {
|
||||
return 16
|
||||
},
|
||||
@@ -354,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) {
|
||||
@@ -405,8 +425,6 @@ export default {
|
||||
return
|
||||
}
|
||||
this.destroyEntityComponents()
|
||||
this.entityIndexesMounted = []
|
||||
this.entityComponentRefs = {}
|
||||
this.pagesLoaded = {}
|
||||
this.entities = []
|
||||
this.totalShelves = 0
|
||||
@@ -416,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') {
|
||||
@@ -513,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) {
|
||||
@@ -667,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
|
||||
}
|
||||
}
|
||||
@@ -688,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
|
||||
@@ -713,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) {
|
||||
@@ -747,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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -810,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
|
||||
@@ -826,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() {
|
||||
|
||||
@@ -53,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>
|
||||
|
||||
@@ -81,7 +78,6 @@ export default {
|
||||
currentTime: 0,
|
||||
showSleepTimerModal: false,
|
||||
showPlayerQueueItemsModal: false,
|
||||
showPlayerSettingsModal: false,
|
||||
sleepTimerSet: false,
|
||||
sleepTimerRemaining: 0,
|
||||
sleepTimerType: null,
|
||||
@@ -378,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)
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -68,6 +68,9 @@ export default {
|
||||
cardHeight() {
|
||||
return this.height * this.sizeMultiplier
|
||||
},
|
||||
coverHeight() {
|
||||
return this.cardHeight
|
||||
},
|
||||
userToken() {
|
||||
return this.store.getters['user/getToken']
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 -->
|
||||
@@ -244,6 +237,7 @@ export default {
|
||||
return this.mediaMetadata.series
|
||||
},
|
||||
seriesName() {
|
||||
if (this.collapsedSeries?.name) return this.collapsedSeries.name
|
||||
return this.series?.name || null
|
||||
},
|
||||
seriesSequence() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<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" @mousedown.stop.prevent>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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="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-[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' }">
|
||||
@@ -19,7 +19,7 @@
|
||||
<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() {
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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.ToastFailedToUpdate)
|
||||
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: {
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -94,9 +94,9 @@
|
||||
<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="$strings.LabelClickToUseCurrentValue" 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,7 +140,6 @@ 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) => {
|
||||
@@ -148,7 +157,6 @@ 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) => {
|
||||
@@ -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 = ''
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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" />
|
||||
@@ -145,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) {
|
||||
@@ -163,13 +170,10 @@ export default {
|
||||
|
||||
this.isProcessing = false
|
||||
if (updateResult) {
|
||||
if (updateResult) {
|
||||
this.$toast.success(this.$strings.ToastItemUpdateSuccess)
|
||||
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://')
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
<controls-playback-speed-control v-model="playbackRate" @input="setPlaybackRate" @change="playbackRateChanged" class="mx-2 block" />
|
||||
<controls-playback-speed-control v-model="playbackRate" @input="setPlaybackRate" @change="playbackRateChanged" :playbackRateIncrementDecrement="playbackRateIncrementDecrement" class="mx-2 block" />
|
||||
|
||||
<ui-tooltip direction="left" :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>
|
||||
|
||||
@@ -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>
|
||||
@@ -64,6 +64,8 @@
|
||||
</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>
|
||||
|
||||
@@ -96,6 +98,7 @@ export default {
|
||||
audioEl: null,
|
||||
seekLoading: false,
|
||||
showChaptersModal: false,
|
||||
showPlayerSettingsModal: false,
|
||||
currentTime: 0,
|
||||
duration: 0
|
||||
}
|
||||
@@ -177,6 +180,9 @@ 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: {
|
||||
@@ -220,12 +226,12 @@ 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) {
|
||||
@@ -315,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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,7 +218,6 @@ export default {
|
||||
this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess)
|
||||
} else {
|
||||
console.log(`Item removed from playlist`, updatedPlaylist)
|
||||
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -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 || ''
|
||||
|
||||
@@ -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: {
|
||||
@@ -485,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)
|
||||
@@ -502,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) {
|
||||
@@ -515,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() {
|
||||
@@ -529,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>
|
||||
@@ -58,14 +40,16 @@ export default {
|
||||
showCopy: Boolean,
|
||||
step: [String, Number],
|
||||
min: [String, Number],
|
||||
customInputClass: String
|
||||
customInputClass: String,
|
||||
trimWhitespace: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showPassword: false,
|
||||
isHovering: false,
|
||||
isFocused: false,
|
||||
hasCopied: false
|
||||
hasCopied: null,
|
||||
isInvalidDate: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -79,11 +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() {
|
||||
@@ -93,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)
|
||||
})
|
||||
},
|
||||
@@ -110,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,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<button :aria-labelledby="labeledBy" 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">
|
||||
<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>
|
||||
@@ -20,6 +20,7 @@ export default {
|
||||
},
|
||||
disabled: Boolean,
|
||||
labeledBy: String,
|
||||
label: String,
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
<form class="w-full h-full px-4 py-6" @submit.prevent="submitForm">
|
||||
<div class="flex -mx-1">
|
||||
<div class="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">
|
||||
<ui-text-input-with-label ref="authorInput" v-model="details.author" :label="$strings.LabelAuthor" @input="handleInputChange" />
|
||||
<ui-text-input-with-label ref="authorInput" v-model="details.author" :label="$strings.LabelAuthor" trim-whitespace @input="handleInputChange" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" :label="$strings.LabelRSSFeedURL" class="mt-2" @input="handleInputChange" />
|
||||
<ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" :label="$strings.LabelRSSFeedURL" trim-whitespace class="mt-2" @input="handleInputChange" />
|
||||
|
||||
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" :label="$strings.LabelDescription" class="mt-2" @input="handleInputChange" />
|
||||
|
||||
@@ -25,13 +25,13 @@
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" :label="$strings.LabelReleaseDate" @input="handleInputChange" />
|
||||
<ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" :label="$strings.LabelReleaseDate" trim-whitespace @input="handleInputChange" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" @input="handleInputChange" />
|
||||
<ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" trim-whitespace @input="handleInputChange" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<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">
|
||||
<div class="flex justify-center">
|
||||
|
||||
@@ -71,8 +71,6 @@ export default {
|
||||
this.showSeriesForm = true
|
||||
},
|
||||
submitSeriesForm() {
|
||||
console.log('submit series form', this.value, this.selectedSeries)
|
||||
|
||||
if (!this.selectedSeries.name) {
|
||||
this.$toast.error('Must enter a series')
|
||||
return
|
||||
|
||||
188
client/cypress/tests/utils/ElapsedPrettyExtended.cy.js
Normal file
188
client/cypress/tests/utils/ElapsedPrettyExtended.cy.js
Normal file
@@ -0,0 +1,188 @@
|
||||
import Vue from 'vue'
|
||||
import '@/plugins/utils'
|
||||
|
||||
// This is the actual function that is being tested
|
||||
const elapsedPrettyExtended = Vue.prototype.$elapsedPrettyExtended
|
||||
|
||||
// Helper function to convert days, hours, minutes, seconds to total seconds
|
||||
function DHMStoSeconds(days, hours, minutes, seconds) {
|
||||
return seconds + minutes * 60 + hours * 3600 + days * 86400
|
||||
}
|
||||
|
||||
describe('$elapsedPrettyExtended', () => {
|
||||
describe('function is on the Vue Prototype', () => {
|
||||
it('exists as a function on Vue.prototype', () => {
|
||||
expect(Vue.prototype.$elapsedPrettyExtended).to.exist
|
||||
expect(Vue.prototype.$elapsedPrettyExtended).to.be.a('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('param default values', () => {
|
||||
const testSeconds = DHMStoSeconds(0, 25, 1, 5) // 25h 1m 5s = 90065 seconds
|
||||
|
||||
it('uses useDays=true showSeconds=true by default', () => {
|
||||
expect(elapsedPrettyExtended(testSeconds)).to.equal('1d 1h 1m 5s')
|
||||
})
|
||||
|
||||
it('only useDays=false overrides useDays but keeps showSeconds=true', () => {
|
||||
expect(elapsedPrettyExtended(testSeconds, false)).to.equal('25h 1m 5s')
|
||||
})
|
||||
|
||||
it('explicit useDays=false showSeconds=false overrides both', () => {
|
||||
expect(elapsedPrettyExtended(testSeconds, false, false)).to.equal('25h 1m')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDays=false showSeconds=true', () => {
|
||||
const useDaysFalse = false
|
||||
const showSecondsTrue = true
|
||||
const testCases = [
|
||||
[[0, 0, 0, 0], '', '0s -> ""'],
|
||||
[[0, 1, 0, 1], '1h 1s', '1h 1s -> 1h 1s'],
|
||||
[[0, 25, 0, 1], '25h 1s', '25h 1s -> 25h 1s']
|
||||
]
|
||||
|
||||
testCases.forEach(([dhms, expected, description]) => {
|
||||
it(description, () => {
|
||||
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysFalse, showSecondsTrue)).to.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDays=true showSeconds=true', () => {
|
||||
const useDaysTrue = true
|
||||
const showSecondsTrue = true
|
||||
const testCases = [
|
||||
[[0, 0, 0, 0], '', '0s -> ""'],
|
||||
[[0, 1, 0, 1], '1h 1s', '1h 1s -> 1h 1s'],
|
||||
[[0, 25, 0, 1], '1d 1h 1s', '25h 1s -> 1d 1h 1s']
|
||||
]
|
||||
|
||||
testCases.forEach(([dhms, expected, description]) => {
|
||||
it(description, () => {
|
||||
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsTrue)).to.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDays=true showSeconds=false', () => {
|
||||
const useDaysTrue = true
|
||||
const showSecondsFalse = false
|
||||
const testCases = [
|
||||
[[0, 0, 0, 0], '', '0s -> ""'],
|
||||
[[0, 1, 0, 0], '1h', '1h -> 1h'],
|
||||
[[0, 1, 0, 1], '1h', '1h 1s -> 1h'],
|
||||
[[0, 1, 1, 0], '1h 1m', '1h 1m -> 1h 1m'],
|
||||
[[0, 25, 0, 0], '1d 1h', '25h -> 1d 1h'],
|
||||
[[0, 25, 0, 1], '1d 1h', '25h 1s -> 1d 1h'],
|
||||
[[2, 0, 0, 0], '2d', '2d -> 2d']
|
||||
]
|
||||
|
||||
testCases.forEach(([dhms, expected, description]) => {
|
||||
it(description, () => {
|
||||
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsFalse)).to.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('rounding useDays=true showSeconds=true', () => {
|
||||
const useDaysTrue = true
|
||||
const showSecondsTrue = true
|
||||
const testCases = [
|
||||
// Seconds rounding
|
||||
[[0, 0, 0, 1], '1s', '1s -> 1s'],
|
||||
[[0, 0, 0, 29.9], '30s', '29.9s -> 30s'],
|
||||
[[0, 0, 0, 30], '30s', '30s -> 30s'],
|
||||
[[0, 0, 0, 30.1], '30s', '30.1s -> 30s'],
|
||||
[[0, 0, 0, 59.4], '59s', '59.4s -> 59s'],
|
||||
[[0, 0, 0, 59.5], '1m', '59.5s -> 1m'],
|
||||
|
||||
// Minutes rounding
|
||||
[[0, 0, 59, 29], '59m 29s', '59m 29s -> 59m 29s'],
|
||||
[[0, 0, 59, 30], '59m 30s', '59m 30s -> 59m 30s'],
|
||||
[[0, 0, 59, 59.5], '1h', '59m 59.5s -> 1h'],
|
||||
|
||||
// Hours rounding
|
||||
[[0, 23, 59, 29], '23h 59m 29s', '23h 59m 29s -> 23h 59m 29s'],
|
||||
[[0, 23, 59, 30], '23h 59m 30s', '23h 59m 30s -> 23h 59m 30s'],
|
||||
[[0, 23, 59, 59.5], '1d', '23h 59m 59.5s -> 1d'],
|
||||
|
||||
// The actual bug case
|
||||
[[44, 23, 59, 30], '44d 23h 59m 30s', '44d 23h 59m 30s -> 44d 23h 59m 30s']
|
||||
]
|
||||
|
||||
testCases.forEach(([dhms, expected, description]) => {
|
||||
it(description, () => {
|
||||
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsTrue)).to.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('rounding useDays=true showSeconds=false', () => {
|
||||
const useDaysTrue = true
|
||||
const showSecondsFalse = false
|
||||
const testCases = [
|
||||
// Seconds rounding - these cases changed behavior from original
|
||||
[[0, 0, 0, 1], '', '1s -> ""'],
|
||||
[[0, 0, 0, 29.9], '', '29.9s -> ""'],
|
||||
[[0, 0, 0, 30], '', '30s -> ""'],
|
||||
[[0, 0, 0, 30.1], '', '30.1s -> ""'],
|
||||
[[0, 0, 0, 59.4], '', '59.4s -> ""'],
|
||||
[[0, 0, 0, 59.5], '1m', '59.5s -> 1m'],
|
||||
// This is unexpected behavior, but it's consistent with the original behavior
|
||||
// We preserved the test case, to document the current behavior
|
||||
// - with showSeconds=false,
|
||||
// one might expect: 1m 29.5s --round(1.4901m)-> 1m
|
||||
// actual implementation: 1h 29.5s --roundSeconds-> 1h 30s --roundMinutes-> 2m
|
||||
// So because of the separate rounding of seconds, and then minutes, it returns 2m
|
||||
[[0, 0, 1, 29.5], '2m', '1m 29.5s -> 2m'],
|
||||
|
||||
// Minutes carry - actual bug fixes below
|
||||
[[0, 0, 59, 29], '59m', '59m 29s -> 59m'],
|
||||
[[0, 0, 59, 30], '1h', '59m 30s -> 1h'], // This was an actual bug, used to return 60m
|
||||
[[0, 0, 59, 59.5], '1h', '59m 59.5s -> 1h'],
|
||||
|
||||
// Hours carry
|
||||
[[0, 23, 59, 29], '23h 59m', '23h 59m 29s -> 23h 59m'],
|
||||
[[0, 23, 59, 30], '1d', '23h 59m 30s -> 1d'], // This was an actual bug, used to return 23h 60m
|
||||
[[0, 23, 59, 59.5], '1d', '23h 59m 59.5s -> 1d'],
|
||||
|
||||
// The actual bug case
|
||||
[[44, 23, 59, 30], '45d', '44d 23h 59m 30s -> 45d'] // This was an actual bug, used to return 44d 23h 60m
|
||||
]
|
||||
|
||||
testCases.forEach(([dhms, expected, description]) => {
|
||||
it(description, () => {
|
||||
expect(elapsedPrettyExtended(DHMStoSeconds(...dhms), useDaysTrue, showSecondsFalse)).to.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty values', () => {
|
||||
const paramCombos = [
|
||||
// useDays, showSeconds, description
|
||||
[true, true, 'with days and seconds'],
|
||||
[true, false, 'with days, no seconds'],
|
||||
[false, true, 'no days, with seconds'],
|
||||
[false, false, 'no days, no seconds']
|
||||
]
|
||||
|
||||
const emptyInputs = [
|
||||
// input, description
|
||||
[null, 'null input'],
|
||||
[undefined, 'undefined input'],
|
||||
[0, 'zero'],
|
||||
[0.49, 'rounds to zero'] // Just under rounding threshold
|
||||
]
|
||||
|
||||
paramCombos.forEach(([useDays, showSeconds, paramDesc]) => {
|
||||
describe(paramDesc, () => {
|
||||
emptyInputs.forEach(([input, desc]) => {
|
||||
it(desc, () => {
|
||||
expect(elapsedPrettyExtended(input, useDays, showSeconds)).to.equal('')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -57,9 +57,10 @@ export default {
|
||||
for (let entry of entries) {
|
||||
this.cardWidth = entry.borderBoxSize[0].inlineSize
|
||||
this.cardHeight = entry.borderBoxSize[0].blockSize
|
||||
this.resizeObserver.disconnect()
|
||||
this.$refs.bookshelf.removeChild(instance.$el)
|
||||
}
|
||||
this.coverHeight = instance.coverHeight
|
||||
this.resizeObserver.disconnect()
|
||||
this.$refs.bookshelf.removeChild(instance.$el)
|
||||
})
|
||||
instance.$el.style.visibility = 'hidden'
|
||||
instance.$el.style.position = 'absolute'
|
||||
@@ -131,10 +132,7 @@ export default {
|
||||
this.entityComponentRefs[index] = instance
|
||||
|
||||
instance.$mount()
|
||||
const shelfOffsetY = this.shelfPaddingHeight * this.sizeMultiplier
|
||||
const row = index % this.entitiesPerShelf
|
||||
const shelfOffsetX = row * this.totalEntityCardWidth + this.bookshelfMarginLeft
|
||||
instance.$el.style.transform = `translate3d(${shelfOffsetX}px, ${shelfOffsetY}px, 0px)`
|
||||
instance.$el.style.transform = this.entityTransform((index % this.entitiesPerShelf) + 1)
|
||||
instance.$el.classList.add('absolute', 'top-0', 'left-0')
|
||||
shelfEl.appendChild(instance.$el)
|
||||
|
||||
|
||||
@@ -28,10 +28,8 @@ export default {
|
||||
var validOtherFiles = []
|
||||
var ignoredFiles = []
|
||||
files.forEach((file) => {
|
||||
// var filetype = this.checkFileType(file.name)
|
||||
if (!file.filetype) ignoredFiles.push(file)
|
||||
else {
|
||||
// file.filetype = filetype
|
||||
if (file.filetype === 'audio' || (file.filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)
|
||||
else validOtherFiles.push(file)
|
||||
}
|
||||
@@ -165,7 +163,7 @@ export default {
|
||||
|
||||
var firstBookPath = Path.dirname(firstBookFile.filepath)
|
||||
|
||||
var dirs = firstBookPath.split('/').filter(d => !!d && d !== '.')
|
||||
var dirs = firstBookPath.split('/').filter((d) => !!d && d !== '.')
|
||||
if (dirs.length) {
|
||||
audiobook.title = dirs.pop()
|
||||
if (dirs.length > 1) {
|
||||
@@ -189,7 +187,7 @@ export default {
|
||||
var firstAudioFile = podcast.itemFiles[0]
|
||||
if (!firstAudioFile.filepath) return podcast // No path
|
||||
var firstPath = Path.dirname(firstAudioFile.filepath)
|
||||
var dirs = firstPath.split('/').filter(d => !!d && d !== '.')
|
||||
var dirs = firstPath.split('/').filter((d) => !!d && d !== '.')
|
||||
if (dirs.length) {
|
||||
podcast.title = dirs.length > 1 ? dirs[1] : dirs[0]
|
||||
} else {
|
||||
@@ -212,13 +210,15 @@ export default {
|
||||
}
|
||||
var ignoredFiles = itemData.ignoredFiles
|
||||
var index = 1
|
||||
var items = itemData.items.filter((ab) => {
|
||||
if (!ab.itemFiles.length) {
|
||||
if (ab.otherFiles.length) ignoredFiles = ignoredFiles.concat(ab.otherFiles)
|
||||
if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles)
|
||||
}
|
||||
return ab.itemFiles.length
|
||||
}).map(ab => this.cleanItem(ab, mediaType, index++))
|
||||
var items = itemData.items
|
||||
.filter((ab) => {
|
||||
if (!ab.itemFiles.length) {
|
||||
if (ab.otherFiles.length) ignoredFiles = ignoredFiles.concat(ab.otherFiles)
|
||||
if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles)
|
||||
}
|
||||
return ab.itemFiles.length
|
||||
})
|
||||
.map((ab) => this.cleanItem(ab, mediaType, index++))
|
||||
return {
|
||||
items,
|
||||
ignoredFiles
|
||||
@@ -259,7 +259,7 @@ export default {
|
||||
|
||||
otherFiles.forEach((file) => {
|
||||
var dir = Path.dirname(file.filepath)
|
||||
var findItem = Object.values(itemMap).find(b => dir.startsWith(b.path))
|
||||
var findItem = Object.values(itemMap).find((b) => dir.startsWith(b.path))
|
||||
if (findItem) {
|
||||
findItem.otherFiles.push(file)
|
||||
} else {
|
||||
@@ -270,18 +270,18 @@ export default {
|
||||
var items = []
|
||||
var index = 1
|
||||
// If book media type and all files are audio files then treat each one as an audiobook
|
||||
if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some(f => f.filetype !== 'audio')) {
|
||||
if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some((f) => f.filetype !== 'audio')) {
|
||||
items = itemMap[''].itemFiles.map((audioFile) => {
|
||||
return this.cleanItem({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] }, mediaType, index++)
|
||||
})
|
||||
} else {
|
||||
items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++))
|
||||
items = Object.values(itemMap).map((i) => this.cleanItem(i, mediaType, index++))
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
ignoredFiles: ignoredFiles
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const pkg = require('./package.json')
|
||||
|
||||
const routerBasePath = process.env.ROUTER_BASE_PATH || ''
|
||||
const routerBasePath = process.env.ROUTER_BASE_PATH ?? '/audiobookshelf'
|
||||
const serverHostUrl = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333'
|
||||
const serverPaths = ['api/', 'public/', 'hls/', 'auth/', 'feed/', 'status', 'login', 'logout', 'init']
|
||||
const proxy = Object.fromEntries(serverPaths.map((path) => [`${routerBasePath}/${path}`, { target: process.env.NODE_ENV !== 'production' ? serverHostUrl : '/' }]))
|
||||
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.17.0",
|
||||
"version": "2.19.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.17.0",
|
||||
"version": "2.19.2",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.17.0",
|
||||
"version": "2.19.2",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -414,11 +414,8 @@ export default {
|
||||
|
||||
const audioEl = this.audioEl || document.createElement('audio')
|
||||
var src = audioTrack.contentUrl + `?token=${this.userToken}`
|
||||
if (this.$isDev) {
|
||||
src = `${process.env.serverUrl}${src}`
|
||||
}
|
||||
|
||||
audioEl.src = src
|
||||
audioEl.src = `${process.env.serverUrl}${src}`
|
||||
audioEl.id = 'chapter-audio'
|
||||
document.body.appendChild(audioEl)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div v-if="openMapOptions" class="flex flex-wrap">
|
||||
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.subtitle" />
|
||||
<ui-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" :label="$strings.LabelSubtitle" class="mb-5 ml-4" />
|
||||
<ui-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" :label="$strings.LabelSubtitle" trim-whitespace class="mb-5 ml-4" />
|
||||
</div>
|
||||
<div v-if="!isPodcastLibrary" class="flex items-center px-4 h-18 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.authors" />
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.publishedYear" />
|
||||
<ui-text-input-with-label ref="publishedYearInput" v-model="batchDetails.publishedYear" :disabled="!selectedBatchUsage.publishedYear" :label="$strings.LabelPublishYear" class="mb-5 ml-4" />
|
||||
<ui-text-input-with-label ref="publishedYearInput" v-model="batchDetails.publishedYear" :disabled="!selectedBatchUsage.publishedYear" :label="$strings.LabelPublishYear" trim-whitespace class="mb-5 ml-4" />
|
||||
</div>
|
||||
<div v-if="!isPodcastLibrary" class="flex items-center px-4 h-18 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.series" />
|
||||
@@ -51,11 +51,11 @@
|
||||
</div>
|
||||
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.publisher" />
|
||||
<ui-text-input-with-label ref="publisherInput" v-model="batchDetails.publisher" :disabled="!selectedBatchUsage.publisher" :label="$strings.LabelPublisher" class="mb-5 ml-4" />
|
||||
<ui-text-input-with-label ref="publisherInput" v-model="batchDetails.publisher" :disabled="!selectedBatchUsage.publisher" :label="$strings.LabelPublisher" trim-whitespace class="mb-5 ml-4" />
|
||||
</div>
|
||||
<div v-if="!isMapAppend" class="flex items-center px-4 h-18 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.language" />
|
||||
<ui-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" :label="$strings.LabelLanguage" class="mb-5 ml-4" />
|
||||
<ui-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" :label="$strings.LabelLanguage" trim-whitespace class="mb-5 ml-4" />
|
||||
</div>
|
||||
<div v-if="!isMapAppend" class="flex items-center px-4 h-18 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.explicit" />
|
||||
@@ -86,7 +86,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex items-center justify-end p-4">
|
||||
<div class="w-full flex items-center p-4 space-x-2">
|
||||
<ui-btn small @click.stop="resetMapDetails">{{ $strings.ButtonReset }}</ui-btn>
|
||||
<ui-tooltip direction="bottom" :text="$strings.MessageBatchEditPopulateMapDetailsAllHelp">
|
||||
<ui-btn small :disabled="!hasSelectedBatchUsage" @click.stop="populateFromExisting()">{{ $strings.ButtonBatchEditPopulateFromExisting }}</ui-btn>
|
||||
</ui-tooltip>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" :disabled="!hasSelectedBatchUsage" :padding-x="8" small class="text-base" :loading="isProcessing" @click="mapBatchDetails">{{ $strings.ButtonApply }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,6 +102,11 @@
|
||||
<div class="flex justify-center flex-wrap">
|
||||
<template v-for="libraryItem in libraryItemCopies">
|
||||
<div :key="libraryItem.id" class="w-full max-w-3xl border border-black-300 p-6 -ml-px -mt-px">
|
||||
<div class="flex items-center justify-end">
|
||||
<ui-tooltip direction="bottom" :text="$strings.MessageBatchEditPopulateMapDetailsItemHelp">
|
||||
<ui-btn small :disabled="!hasSelectedBatchUsage" @click="populateFromExisting(libraryItem.id)">{{ $strings.ButtonBatchEditPopulateMapDetails }}</ui-btn>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<widgets-book-details-edit v-if="libraryItem.mediaType === 'book'" :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" @change="handleItemChange" />
|
||||
<widgets-podcast-details-edit v-else :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" @change="handleItemChange" />
|
||||
</div>
|
||||
@@ -228,6 +238,88 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
resetMapDetails() {
|
||||
this.blurBatchForm()
|
||||
this.batchDetails = {
|
||||
subtitle: null,
|
||||
authors: null,
|
||||
publishedYear: null,
|
||||
series: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
narrators: [],
|
||||
publisher: null,
|
||||
language: null,
|
||||
explicit: false,
|
||||
abridged: false
|
||||
}
|
||||
this.selectedBatchUsage = {
|
||||
subtitle: false,
|
||||
authors: false,
|
||||
publishedYear: false,
|
||||
series: false,
|
||||
genres: false,
|
||||
tags: false,
|
||||
narrators: false,
|
||||
publisher: false,
|
||||
language: false,
|
||||
explicit: false,
|
||||
abridged: false
|
||||
}
|
||||
},
|
||||
populateFromExisting(libraryItemId) {
|
||||
this.blurBatchForm()
|
||||
|
||||
let libraryItemsToMap = this.libraryItemCopies
|
||||
if (libraryItemId) {
|
||||
libraryItemsToMap = this.libraryItemCopies.filter((li) => li.id === libraryItemId)
|
||||
}
|
||||
|
||||
for (const key in this.selectedBatchUsage) {
|
||||
if (!this.selectedBatchUsage[key]) continue
|
||||
if (this.isMapAppend && !this.appendableKeys.includes(key)) continue
|
||||
|
||||
let existingValues = undefined
|
||||
libraryItemsToMap.forEach((li) => {
|
||||
if (key === 'tags') {
|
||||
if (!existingValues) existingValues = []
|
||||
li.media.tags.forEach((tag) => {
|
||||
if (!existingValues.includes(tag)) {
|
||||
existingValues.push(tag)
|
||||
}
|
||||
})
|
||||
} else if (key === 'authors') {
|
||||
if (!existingValues) existingValues = []
|
||||
li.media.metadata[key].forEach((entity) => {
|
||||
if (!existingValues.some((au) => au.id === entity.id)) {
|
||||
existingValues.push({
|
||||
id: entity.id,
|
||||
name: entity.name
|
||||
})
|
||||
}
|
||||
})
|
||||
} else if (key === 'series') {
|
||||
if (!existingValues) existingValues = []
|
||||
li.media.metadata[key].forEach((entity) => {
|
||||
if (!existingValues.includes(entity.name)) {
|
||||
existingValues.push(entity.name)
|
||||
}
|
||||
})
|
||||
} else if (key === 'genres' || key === 'narrators') {
|
||||
if (!existingValues) existingValues = []
|
||||
li.media.metadata[key].forEach((item) => {
|
||||
if (!existingValues.includes(item)) {
|
||||
existingValues.push(item)
|
||||
}
|
||||
})
|
||||
} else if (existingValues === undefined) {
|
||||
existingValues = li.media.metadata[key]
|
||||
}
|
||||
})
|
||||
|
||||
this.batchDetails[key] = existingValues
|
||||
}
|
||||
},
|
||||
handleItemChange(itemChange) {
|
||||
if (!itemChange.hasChanges) {
|
||||
this.itemsWithChanges = this.itemsWithChanges.filter((id) => id !== itemChange.libraryItemId)
|
||||
|
||||
@@ -64,6 +64,20 @@
|
||||
<ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" />
|
||||
<p class="sm:pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" />
|
||||
|
||||
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
|
||||
<div class="w-44">
|
||||
<ui-dropdown v-model="newAuthSettings.authOpenIDSubfolderForRedirectURLs" small :items="subfolderOptions" :label="$strings.LabelWebRedirectURLsSubfolder" :disabled="savingSettings" />
|
||||
</div>
|
||||
<div class="mt-2 sm:mt-5">
|
||||
<p class="sm:pl-4 text-sm text-gray-300">{{ $strings.LabelWebRedirectURLsDescription }}</p>
|
||||
<p class="sm:pl-4 text-sm text-gray-300 mb-2">
|
||||
<code>{{ webCallbackURL }}</code>
|
||||
<br />
|
||||
<code>{{ mobileAppCallbackURL }}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" />
|
||||
|
||||
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
|
||||
@@ -164,6 +178,27 @@ export default {
|
||||
value: 'username'
|
||||
}
|
||||
]
|
||||
},
|
||||
subfolderOptions() {
|
||||
const options = [
|
||||
{
|
||||
text: 'None',
|
||||
value: ''
|
||||
}
|
||||
]
|
||||
if (this.$config.routerBasePath) {
|
||||
options.push({
|
||||
text: this.$config.routerBasePath,
|
||||
value: this.$config.routerBasePath
|
||||
})
|
||||
}
|
||||
return options
|
||||
},
|
||||
webCallbackURL() {
|
||||
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/callback`
|
||||
},
|
||||
mobileAppCallbackURL() {
|
||||
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/mobile-redirect`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -325,7 +360,8 @@ export default {
|
||||
},
|
||||
init() {
|
||||
this.newAuthSettings = {
|
||||
...this.authSettings
|
||||
...this.authSettings,
|
||||
authOpenIDSubfolderForRedirectURLs: this.authSettings.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : this.authSettings.authOpenIDSubfolderForRedirectURLs
|
||||
}
|
||||
this.enableLocalAuth = this.authMethods.includes('local')
|
||||
this.enableOpenIDAuth = this.authMethods.includes('openid')
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsGeneral }}</h2>
|
||||
</div>
|
||||
<div class="flex items-end py-2">
|
||||
<ui-toggle-switch labeledBy="settings-store-cover-with-items" v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsStoreCoversWithItemHelp">
|
||||
<div role="article" :aria-label="$strings.LabelSettingsStoreCoversWithItemHelp" class="flex items-end py-2">
|
||||
<ui-toggle-switch :label="$strings.LabelSettingsStoreCoversWithItem" v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
|
||||
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsStoreCoversWithItemHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-store-cover-with-items">{{ $strings.LabelSettingsStoreCoversWithItem }}</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
@@ -16,9 +16,9 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-store-metadata-with-items" v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsStoreMetadataWithItemHelp">
|
||||
<div role="article" :aria-label="$strings.LabelSettingsStoreMetadataWithItemHelp" class="flex items-center py-2">
|
||||
<ui-toggle-switch :label="$strings.LabelSettingsStoreMetadataWithItem" v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
|
||||
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsStoreMetadataWithItemHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-store-metadata-with-items">{{ $strings.LabelSettingsStoreMetadataWithItem }}</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
@@ -26,9 +26,9 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-sorting-ignore-prefixes" v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsSortingIgnorePrefixesHelp">
|
||||
<div role="article" :aria-label="$strings.LabelSettingsSortingIgnorePrefixesHelp" class="flex items-center py-2">
|
||||
<ui-toggle-switch :label="$strings.LabelSettingsSortingIgnorePrefixes" v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
|
||||
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsSortingIgnorePrefixesHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-sorting-ignore-prefixes">{{ $strings.LabelSettingsSortingIgnorePrefixes }}</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
@@ -42,18 +42,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2 mb-2">
|
||||
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
||||
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-parse-subtitles" v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsParseSubtitlesHelp">
|
||||
<div role="article" :aria-label="$strings.LabelSettingsParseSubtitlesHelp" class="flex items-center py-2">
|
||||
<ui-toggle-switch :label="$strings.LabelSettingsParseSubtitles" v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
|
||||
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsParseSubtitlesHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-parse-subtitles">{{ $strings.LabelSettingsParseSubtitles }}</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
@@ -61,9 +56,9 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-find-covers" v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsFindCoversHelp">
|
||||
<div role="article" :aria-label="$strings.LabelSettingsFindCoversHelp" class="flex items-center py-2">
|
||||
<ui-toggle-switch :label="$strings.LabelSettingsFindCovers" v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
|
||||
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsFindCoversHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-find-covers">{{ $strings.LabelSettingsFindCovers }}</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
@@ -75,9 +70,9 @@
|
||||
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-prefer-matched-metadata" v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
|
||||
<div role="article" :aria-label="$strings.LabelSettingsPreferMatchedMetadataHelp" class="flex items-center py-2">
|
||||
<ui-toggle-switch :label="$strings.LabelSettingsPreferMatchedMetadata" v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
|
||||
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsPreferMatchedMetadataHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-prefer-matched-metadata">{{ $strings.LabelSettingsPreferMatchedMetadata }}</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
@@ -85,15 +80,29 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-disable-watcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsEnableWatcherHelp">
|
||||
<div role="article" :aria-label="$strings.LabelSettingsEnableWatcherHelp" class="flex items-center py-2">
|
||||
<ui-toggle-switch :label="$strings.LabelSettingsEnableWatcher" v-model="scannerEnableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', !val)" />
|
||||
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsEnableWatcherHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-disable-watcher">{{ $strings.LabelSettingsEnableWatcher }}</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsWebClient }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :label="$strings.LabelSettingsChromecastSupport" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
||||
<p aria-hidden="true" class="pl-4">{{ $strings.LabelSettingsChromecastSupport }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2 mb-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.allowIframe" :label="$strings.LabelSettingsAllowIframe" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('allowIframe', val)" />
|
||||
<p aria-hidden="true" class="pl-4">{{ $strings.LabelSettingsAllowIframe }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
@@ -324,21 +333,21 @@ export default {
|
||||
},
|
||||
updateServerSettings(payload) {
|
||||
this.updatingServerSettings = true
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then(() => {
|
||||
this.updatingServerSettings = false
|
||||
this.$store.dispatch('updateServerSettings', payload).then((response) => {
|
||||
this.updatingServerSettings = false
|
||||
|
||||
if (payload.language) {
|
||||
// Updating language after save allows for re-rendering
|
||||
this.$setLanguageCode(payload.language)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.updatingServerSettings = false
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
if (response.error) {
|
||||
console.error('Failed to update server settins', response.error)
|
||||
this.$toast.error(response.error)
|
||||
this.initServerSettings()
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.language) {
|
||||
// Updating language after save allows for re-rendering
|
||||
this.$setLanguageCode(payload.language)
|
||||
}
|
||||
})
|
||||
},
|
||||
initServerSettings() {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<tr v-for="feed in feeds" :key="feed.id" class="cursor-pointer h-12" @click="showFeed(feed)">
|
||||
<!-- -->
|
||||
<td>
|
||||
<img :src="coverUrl(feed)" class="h-full w-full" />
|
||||
<img :src="coverUrl(feed)" class="h-auto w-full" />
|
||||
</td>
|
||||
<!-- -->
|
||||
<td class="w-48 max-w-64 min-w-24 text-left truncate">
|
||||
@@ -126,7 +126,7 @@ export default {
|
||||
},
|
||||
coverUrl(feed) {
|
||||
if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png`
|
||||
return `${feed.feedUrl}/cover`
|
||||
return `${this.$config.routerBasePath}${feed.feedUrl}/cover`
|
||||
},
|
||||
async loadFeeds() {
|
||||
const data = await this.$axios.$get(`/api/feeds`).catch((err) => {
|
||||
@@ -137,7 +137,16 @@ export default {
|
||||
this.$toast.error(this.$strings.ToastFailedToLoadData)
|
||||
return
|
||||
}
|
||||
this.feeds = data.feeds
|
||||
this.feeds = data.feeds.map((feed) => ({
|
||||
...feed,
|
||||
episodes: [...feed.episodes].sort((a, b) => {
|
||||
if (!a.pubDate) return 1 // null dates sort to end
|
||||
if (!b.pubDate) return -1
|
||||
const dateA = new Date(a.pubDate)
|
||||
const dateB = new Date(b.pubDate)
|
||||
return dateA - dateB
|
||||
})
|
||||
}))
|
||||
},
|
||||
init() {
|
||||
this.loadFeeds()
|
||||
|
||||
@@ -14,11 +14,7 @@
|
||||
<h1 class="text-xl pl-2">{{ username }}</h1>
|
||||
</div>
|
||||
<div v-if="userToken" class="flex text-xs mt-4">
|
||||
<ui-text-input-with-label :label="$strings.LabelApiToken" :value="userToken" readonly />
|
||||
|
||||
<div class="px-1 mt-8 cursor-pointer" @click="copyToClipboard(userToken)">
|
||||
<span class="material-symbols pl-2 text-base">content_copy</span>
|
||||
</div>
|
||||
<ui-text-input-with-label :label="$strings.LabelApiToken" :value="userToken" readonly show-copy />
|
||||
</div>
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||
<div class="py-2">
|
||||
@@ -140,9 +136,6 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
copyToClipboard(str) {
|
||||
this.$copyToClipboard(str, this)
|
||||
},
|
||||
async init() {
|
||||
this.listeningSessions = await this.$axios
|
||||
.$get(`/api/users/${this.user.id}/listening-sessions?page=0&itemsPerPage=10`)
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<div>
|
||||
<app-settings-content :header-text="$strings.HeaderUsers">
|
||||
<template #header-items>
|
||||
<div v-if="numUsers" class="mx-2 px-1.5 rounded-lg bg-primary/50 text-gray-300/90 text-sm inline-flex items-center justify-center">
|
||||
<span>{{ numUsers }}</span>
|
||||
</div>
|
||||
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
@@ -13,7 +17,7 @@
|
||||
<ui-btn color="primary" small @click="setShowUserModal()">{{ $strings.ButtonAddUser }}</ui-btn>
|
||||
</template>
|
||||
|
||||
<tables-users-table class="pt-2" @edit="setShowUserModal" />
|
||||
<tables-users-table class="pt-2" @edit="setShowUserModal" @numUsers="(count) => (numUsers = count)" />
|
||||
</app-settings-content>
|
||||
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
|
||||
</div>
|
||||
@@ -29,7 +33,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
selectedAccount: null,
|
||||
showAccountModal: false
|
||||
showAccountModal: false,
|
||||
numUsers: 0
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<!-- Item Cover Overlay -->
|
||||
<div class="absolute top-0 left-0 w-full h-full z-10 opacity-0 group-hover:opacity-100 pointer-events-none">
|
||||
<div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="playItem">
|
||||
<button class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" :aria-label="$strings.ButtonPlay" @click.stop.prevent="playItem">
|
||||
<span class="material-symbols fill text-4xl">play_arrow</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="absolute bottom-2.5 right-2.5 z-10 material-symbols text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" @click="showEditCover">edit</span>
|
||||
<button class="absolute bottom-2.5 right-2.5 z-10 material-symbols text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" :aria-label="$strings.ButtonEdit" @click="showEditCover">edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,7 +87,7 @@
|
||||
</ui-btn>
|
||||
|
||||
<ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
|
||||
<span v-show="!isStreaming" class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span>
|
||||
<span class="material-symbols text-2xl -ml-2 pr-1 text-white">error</span>
|
||||
{{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }}
|
||||
</ui-btn>
|
||||
|
||||
@@ -96,12 +96,12 @@
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
|
||||
<span class="material-symbols text-2xl -ml-2 pr-2 text-white">auto_stories</span>
|
||||
<span class="material-symbols text-2xl -ml-2 pr-2 text-white" aria-hidden="true">auto_stories</span>
|
||||
{{ $strings.ButtonRead }}
|
||||
</ui-btn>
|
||||
|
||||
<ui-tooltip v-if="userCanUpdate" :text="$strings.LabelEdit" direction="top">
|
||||
<ui-icon-btn icon="" outlined class="mx-0.5" @click="editClick" />
|
||||
<ui-icon-btn icon="" outlined class="mx-0.5" :aria-label="$strings.LabelEdit" @click="editClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="!isPodcast" :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
|
||||
@@ -110,12 +110,12 @@
|
||||
|
||||
<!-- Only admin or root user can download new episodes -->
|
||||
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top">
|
||||
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
|
||||
<ui-icon-btn icon="search" class="mx-0.5" :aria-label="$strings.LabelFindEpisodes" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
|
||||
<template #default="{ showMenu, clickShowMenu, disabled }">
|
||||
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" :aria-label="$strings.LabelMore" @click.stop.prevent="clickShowMenu">
|
||||
<span class="material-symbols text-2xl"></span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -123,7 +123,8 @@
|
||||
</div>
|
||||
|
||||
<div class="my-4 w-full">
|
||||
<p ref="description" id="item-description" dir="auto" class="text-base text-gray-100 whitespace-pre-line mb-1" :class="{ 'show-full': showFullDescription }">{{ description }}</p>
|
||||
<div ref="description" id="item-description" dir="auto" class="default-style less-spacing text-base text-gray-100 whitespace-pre-line mb-1" :class="{ 'show-full': showFullDescription }" v-html="description" />
|
||||
|
||||
<button v-if="isDescriptionClamped" class="py-0.5 flex items-center text-slate-300 hover:text-white" @click="showFullDescription = !showFullDescription">{{ showFullDescription ? $strings.ButtonReadLess : $strings.ButtonReadMore }} <span class="material-symbols text-xl pl-1" v-html="showFullDescription ? 'expand_less' : ''" /></button>
|
||||
</div>
|
||||
|
||||
@@ -141,7 +142,7 @@
|
||||
</div>
|
||||
|
||||
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :playback-rate="1" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -804,8 +805,7 @@ export default {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
max-height: 6.25rem;
|
||||
transition: all 0.3s ease-in-out;
|
||||
max-height: calc(6 * 1lh);
|
||||
}
|
||||
#item-description.show-full {
|
||||
-webkit-line-clamp: unset;
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
<div class="w-full pt-16">
|
||||
<player-ui ref="audioPlayer" :chapters="chapters" :current-chapter="currentChapter" :paused="isPaused" :loading="!hasLoaded" :is-podcast="false" hide-bookmarks hide-sleep-timer @playPause="playPause" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setVolume="setVolume" @setPlaybackRate="setPlaybackRate" @seek="seek" />
|
||||
</div>
|
||||
|
||||
<ui-tooltip v-if="mediaItemShare.isDownloadable" direction="bottom" :text="$strings.LabelDownload" class="absolute top-0 left-0 m-4">
|
||||
<button aria-label="Download" class="text-gray-300 hover:text-white" @click="downloadShareItem"><span class="material-symbols text-2xl sm:text-3xl">download</span></button>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,6 +67,9 @@ export default {
|
||||
if (!this.playbackSession.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg`
|
||||
return `${this.$config.routerBasePath}/public/share/${this.mediaItemShare.slug}/cover`
|
||||
},
|
||||
downloadUrl() {
|
||||
return `${process.env.serverUrl}/public/share/${this.mediaItemShare.slug}/download`
|
||||
},
|
||||
audioTracks() {
|
||||
return (this.playbackSession.audioTracks || []).map((track) => {
|
||||
track.relativeContentUrl = track.contentUrl
|
||||
@@ -103,6 +110,84 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mediaSessionPlay() {
|
||||
console.log('Media session play')
|
||||
this.play()
|
||||
},
|
||||
mediaSessionPause() {
|
||||
console.log('Media session pause')
|
||||
this.pause()
|
||||
},
|
||||
mediaSessionStop() {
|
||||
console.log('Media session stop')
|
||||
this.pause()
|
||||
},
|
||||
mediaSessionSeekBackward() {
|
||||
console.log('Media session seek backward')
|
||||
this.jumpBackward()
|
||||
},
|
||||
mediaSessionSeekForward() {
|
||||
console.log('Media session seek forward')
|
||||
this.jumpForward()
|
||||
},
|
||||
mediaSessionSeekTo(e) {
|
||||
console.log('Media session seek to', e)
|
||||
if (e.seekTime !== null && !isNaN(e.seekTime)) {
|
||||
this.seek(e.seekTime)
|
||||
}
|
||||
},
|
||||
mediaSessionPreviousTrack() {
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.prevChapter()
|
||||
}
|
||||
},
|
||||
mediaSessionNextTrack() {
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.nextChapter()
|
||||
}
|
||||
},
|
||||
updateMediaSessionPlaybackState() {
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'
|
||||
}
|
||||
},
|
||||
setMediaSession() {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API
|
||||
if ('mediaSession' in navigator) {
|
||||
const chapterInfo = []
|
||||
if (this.chapters.length > 0) {
|
||||
this.chapters.forEach((chapter) => {
|
||||
chapterInfo.push({
|
||||
title: chapter.title,
|
||||
startTime: chapter.start
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: this.mediaItemShare.playbackSession.displayTitle || 'No title',
|
||||
artist: this.mediaItemShare.playbackSession.displayAuthor || 'Unknown',
|
||||
artwork: [
|
||||
{
|
||||
src: this.coverUrl
|
||||
}
|
||||
],
|
||||
chapterInfo
|
||||
})
|
||||
console.log('Set media session metadata', navigator.mediaSession.metadata)
|
||||
|
||||
navigator.mediaSession.setActionHandler('play', this.mediaSessionPlay)
|
||||
navigator.mediaSession.setActionHandler('pause', this.mediaSessionPause)
|
||||
navigator.mediaSession.setActionHandler('stop', this.mediaSessionStop)
|
||||
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
|
||||
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
|
||||
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
|
||||
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
|
||||
navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
|
||||
} else {
|
||||
console.warn('Media session not available')
|
||||
}
|
||||
},
|
||||
async coverImageLoaded(e) {
|
||||
if (!this.playbackSession.coverPath) return
|
||||
const fac = new FastAverageColor()
|
||||
@@ -119,19 +204,32 @@ export default {
|
||||
})
|
||||
},
|
||||
playPause() {
|
||||
if (this.isPlaying) {
|
||||
this.pause()
|
||||
} else {
|
||||
this.play()
|
||||
}
|
||||
},
|
||||
play() {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
this.localAudioPlayer.playPause()
|
||||
this.localAudioPlayer.play()
|
||||
},
|
||||
pause() {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
this.localAudioPlayer.pause()
|
||||
},
|
||||
jumpForward() {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
const currentTime = this.localAudioPlayer.getCurrentTime()
|
||||
const duration = this.localAudioPlayer.getDuration()
|
||||
this.seek(Math.min(currentTime + 10, duration))
|
||||
const jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') || 10
|
||||
this.seek(Math.min(currentTime + jumpForwardAmount, duration))
|
||||
},
|
||||
jumpBackward() {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
const currentTime = this.localAudioPlayer.getCurrentTime()
|
||||
this.seek(Math.max(currentTime - 10, 0))
|
||||
const jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') || 10
|
||||
this.seek(Math.max(currentTime - jumpBackwardAmount, 0))
|
||||
},
|
||||
setVolume(volume) {
|
||||
if (!this.localAudioPlayer || !this.hasLoaded) return
|
||||
@@ -204,6 +302,7 @@ export default {
|
||||
} else {
|
||||
this.stopPlayInterval()
|
||||
}
|
||||
this.updateMediaSessionPlaybackState()
|
||||
},
|
||||
playerTimeUpdate(time) {
|
||||
this.setCurrentTime(time)
|
||||
@@ -245,9 +344,14 @@ export default {
|
||||
},
|
||||
playerFinished() {
|
||||
console.log('Player finished')
|
||||
},
|
||||
downloadShareItem() {
|
||||
this.$downloadFile(this.downloadUrl)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('user/loadUserSettings')
|
||||
|
||||
this.resize()
|
||||
window.addEventListener('resize', this.resize)
|
||||
window.addEventListener('keydown', this.keyDown)
|
||||
@@ -262,6 +366,8 @@ export default {
|
||||
this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this))
|
||||
this.localAudioPlayer.on('error', this.playerError.bind(this))
|
||||
this.localAudioPlayer.on('finished', this.playerFinished.bind(this))
|
||||
|
||||
this.setMediaSession()
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.resize)
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="page p-0 sm:p-6 overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div id="page-wrapper" class="page p-1 sm:p-6 overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="w-full max-w-6xl mx-auto">
|
||||
<!-- Library & folder picker -->
|
||||
<div class="flex my-6 -mx-2">
|
||||
<div class="w-1/5 px-2">
|
||||
<div class="flex flex-wrap my-6 md:-mx-2">
|
||||
<div class="w-full md:w-1/5 px-2">
|
||||
<ui-dropdown v-model="selectedLibraryId" :items="libraryItems" :label="$strings.LabelLibrary" :disabled="!!items.length" @input="libraryChanged" />
|
||||
</div>
|
||||
<div class="w-3/5 px-2">
|
||||
<div class="w-full md:w-3/5 px-2">
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="!selectedLibraryId || !!items.length" :label="$strings.LabelFolder" />
|
||||
</div>
|
||||
<div class="w-1/5 px-2">
|
||||
<div class="w-full md:w-1/5 px-2">
|
||||
<ui-text-input-with-label :value="selectedLibraryMediaType" readonly :label="$strings.LabelMediaType" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!selectedLibraryIsPodcast" class="flex items-center mb-6">
|
||||
<div v-if="!selectedLibraryIsPodcast" class="flex items-center mb-6 px-2 md:px-0">
|
||||
<label class="flex cursor-pointer pt-4">
|
||||
<ui-toggle-switch v-model="fetchMetadata.enabled" class="inline-flex" />
|
||||
<span class="pl-2 text-base">{{ $strings.LabelAutoFetchMetadata }}</span>
|
||||
@@ -33,13 +33,13 @@
|
||||
</widgets-alert>
|
||||
|
||||
<!-- Picker display -->
|
||||
<div v-if="!items.length && !ignoredFiles.length" class="w-full mx-auto border border-white border-opacity-20 px-12 pt-12 pb-4 my-12 relative" :class="isDragging ? 'bg-primary bg-opacity-40' : 'border-dashed'">
|
||||
<p class="text-2xl text-center">{{ isDragging ? $strings.LabelUploaderDropFiles : $strings.LabelUploaderDragAndDrop }}</p>
|
||||
<div v-if="!items.length && !ignoredFiles.length" class="w-full mx-auto border border-white border-opacity-20 px-4 md:px-12 pt-12 pb-4 my-12 relative" :class="isDragging ? 'bg-primary bg-opacity-40' : 'border-dashed'">
|
||||
<p class="text-2xl text-center">{{ isDragging ? $strings.LabelUploaderDropFiles : isIOS ? $strings.LabelUploaderDragAndDropFilesOnly : $strings.LabelUploaderDragAndDrop }}</p>
|
||||
<p class="text-center text-sm my-5">{{ $strings.MessageOr }}</p>
|
||||
<div class="w-full max-w-xl mx-auto">
|
||||
<div class="flex">
|
||||
<ui-btn class="w-full mx-1" @click="openFilePicker">{{ $strings.ButtonChooseFiles }}</ui-btn>
|
||||
<ui-btn class="w-full mx-1" @click="openFolderPicker">{{ $strings.ButtonChooseAFolder }}</ui-btn>
|
||||
<ui-btn v-if="!isIOS" class="w-full mx-1" @click="openFolderPicker">{{ $strings.ButtonChooseAFolder }} </ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-8 text-center">
|
||||
@@ -48,7 +48,7 @@
|
||||
</p>
|
||||
|
||||
<p class="text-sm text-white text-opacity-70">
|
||||
{{ $strings.NoteUploaderFoldersWithMediaFiles }} <span v-if="selectedLibraryMediaType === 'book'">{{ $strings.NoteUploaderOnlyAudioFiles }}</span>
|
||||
<span v-if="!isIOS">{{ $strings.NoteUploaderFoldersWithMediaFiles }}</span> <span v-if="selectedLibraryMediaType === 'book'">{{ $strings.NoteUploaderOnlyAudioFiles }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,8 +84,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input ref="fileInput" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||
<input ref="fileFolderInput" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||
<input ref="fileInput" type="file" multiple :accept="isIOS ? '' : inputAccept" class="hidden" @change="inputChanged" />
|
||||
<input ref="fileFolderInput" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" v-if="!isIOS" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -127,6 +127,10 @@ export default {
|
||||
})
|
||||
return extensions
|
||||
},
|
||||
isIOS() {
|
||||
const ua = window.navigator.userAgent
|
||||
return /iPad|iPhone|iPod/.test(ua) && !window.MSStream
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export default class AudioTrack {
|
||||
constructor(track, userToken) {
|
||||
constructor(track, userToken, routerBasePath) {
|
||||
this.index = track.index || 0
|
||||
this.startOffset = track.startOffset || 0 // Total time of all previous tracks
|
||||
this.duration = track.duration || 0
|
||||
@@ -9,20 +9,27 @@ export default class AudioTrack {
|
||||
this.metadata = track.metadata || {}
|
||||
|
||||
this.userToken = userToken
|
||||
this.routerBasePath = routerBasePath || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for CastPlayer
|
||||
*/
|
||||
get fullContentUrl() {
|
||||
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
|
||||
}
|
||||
return `${window.location.origin}${this.contentUrl}?token=${this.userToken}`
|
||||
return `${window.location.origin}${this.routerBasePath}${this.contentUrl}?token=${this.userToken}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for LocalPlayer
|
||||
*/
|
||||
get relativeContentUrl() {
|
||||
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||
|
||||
return this.contentUrl + `?token=${this.userToken}`
|
||||
return `${this.routerBasePath}${this.contentUrl}?token=${this.userToken}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ export default class PlayerHandler {
|
||||
|
||||
console.log('[PlayerHandler] Preparing Session', session)
|
||||
|
||||
var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, this.userToken))
|
||||
var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, this.userToken, this.ctx.$config.routerBasePath))
|
||||
|
||||
this.ctx.playerLoading = true
|
||||
this.isHlsTranscode = true
|
||||
|
||||
@@ -7,6 +7,7 @@ const defaultCode = 'en-us'
|
||||
const languageCodeMap = {
|
||||
bg: { label: 'Български', dateFnsLocale: 'bg' },
|
||||
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
|
||||
ca: { label: 'Català', dateFnsLocale: 'ca' },
|
||||
cs: { label: 'Čeština', dateFnsLocale: 'cs' },
|
||||
da: { label: 'Dansk', dateFnsLocale: 'da' },
|
||||
de: { label: 'Deutsch', dateFnsLocale: 'de' },
|
||||
@@ -41,6 +42,7 @@ Vue.prototype.$languageCodeOptions = Object.keys(languageCodeMap).map((code) =>
|
||||
|
||||
// iTunes search API uses ISO 3166 country codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
|
||||
const podcastSearchRegionMap = {
|
||||
au: { label: 'Australia' },
|
||||
br: { label: 'Brasil' },
|
||||
be: { label: 'België / Belgique / Belgien' },
|
||||
cz: { label: 'Česko' },
|
||||
@@ -56,6 +58,7 @@ const podcastSearchRegionMap = {
|
||||
hu: { label: 'Magyarország' },
|
||||
nl: { label: 'Nederland' },
|
||||
no: { label: 'Norge' },
|
||||
nz: { label: 'New Zealand' },
|
||||
at: { label: 'Österreich' },
|
||||
pl: { label: 'Polska' },
|
||||
pt: { label: 'Portugal' },
|
||||
|
||||
@@ -128,12 +128,11 @@ Vue.prototype.$sanitizeSlug = (str) => {
|
||||
return str
|
||||
}
|
||||
|
||||
Vue.prototype.$copyToClipboard = (str, ctx) => {
|
||||
Vue.prototype.$copyToClipboard = (str) => {
|
||||
return new Promise((resolve) => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(str).then(
|
||||
() => {
|
||||
if (ctx) ctx.$toast.success('Copied to clipboard')
|
||||
resolve(true)
|
||||
},
|
||||
(err) => {
|
||||
@@ -152,7 +151,6 @@ Vue.prototype.$copyToClipboard = (str, ctx) => {
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(el)
|
||||
|
||||
if (ctx) ctx.$toast.success('Copied to clipboard')
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -69,17 +69,22 @@ Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true, showSeconds = t
|
||||
let hours = Math.floor(minutes / 60)
|
||||
minutes -= hours * 60
|
||||
|
||||
// Handle rollovers before days calculation
|
||||
if (minutes && seconds && !showSeconds) {
|
||||
if (seconds >= 30) minutes++
|
||||
if (minutes >= 60) {
|
||||
hours++ // Increment hours if minutes roll over
|
||||
minutes -= 60 // adjust minutes
|
||||
}
|
||||
}
|
||||
|
||||
// Now calculate days with the final hours value
|
||||
let days = 0
|
||||
if (useDays || Math.floor(hours / 24) >= 100) {
|
||||
days = Math.floor(hours / 24)
|
||||
hours -= days * 24
|
||||
}
|
||||
|
||||
// If not showing seconds then round minutes up
|
||||
if (minutes && seconds && !showSeconds) {
|
||||
if (seconds >= 30) minutes++
|
||||
}
|
||||
|
||||
const strs = []
|
||||
if (days) strs.push(`${days}d`)
|
||||
if (hours) strs.push(`${hours}h`)
|
||||
|
||||
@@ -72,16 +72,17 @@ export const actions = {
|
||||
return this.$axios
|
||||
.$patch('/api/settings', updatePayload)
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
if (result.serverSettings) {
|
||||
commit('setServerSettings', result.serverSettings)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
return result
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
return false
|
||||
const errorMsg = error.response?.data || 'Unknown error'
|
||||
return {
|
||||
error: errorMsg
|
||||
}
|
||||
})
|
||||
},
|
||||
checkForUpdate({ commit }) {
|
||||
|
||||
@@ -5,6 +5,7 @@ export const state = () => ({
|
||||
orderDesc: false,
|
||||
filterBy: 'all',
|
||||
playbackRate: 1,
|
||||
playbackRateIncrementDecrement: 0.1,
|
||||
bookshelfCoverSize: 120,
|
||||
collapseSeries: false,
|
||||
collapseBookSeries: false,
|
||||
|
||||
@@ -127,5 +127,30 @@
|
||||
"HeaderCollectionItems": "عناصر المجموعة",
|
||||
"HeaderCover": "الغلاف",
|
||||
"HeaderCurrentDownloads": "التنزيلات الجارية",
|
||||
"HeaderCustomMessageOnLogin": "رسالة مخصصة عند تسجيل الدخول"
|
||||
"HeaderCustomMessageOnLogin": "رسالة مخصصة عند تسجيل الدخول",
|
||||
"HeaderCustomMetadataProviders": "مقدمو البيانات الوصفية المخصصة",
|
||||
"HeaderDetails": "التفاصيل",
|
||||
"HeaderDownloadQueue": "تنزيل قائمة الانتظار",
|
||||
"HeaderEbookFiles": "ملفات الكتب الإلكترونية",
|
||||
"HeaderEmail": "البريد الإلكتروني",
|
||||
"HeaderEmailSettings": "إعدادات البريد الإلكتروني",
|
||||
"HeaderEpisodes": "الحلقات",
|
||||
"HeaderEreaderDevices": "أجهزة قراءة الكتب الإلكترونية",
|
||||
"HeaderEreaderSettings": "إعدادات القارئ الإلكتروني",
|
||||
"HeaderFiles": "ملفات",
|
||||
"HeaderFindChapters": "البحث عن الفصول",
|
||||
"HeaderIgnoredFiles": "الملفات المتجاهلة",
|
||||
"HeaderItemFiles": "ملفات العنصر",
|
||||
"HeaderItemMetadataUtils": "بيانات تعريف العنصر",
|
||||
"HeaderLastListeningSession": "آخر جلسة استماع",
|
||||
"HeaderLatestEpisodes": "أحدث الحلقات",
|
||||
"HeaderLibraries": "المكتبات",
|
||||
"HeaderLibraryFiles": "ملفات المكتبة",
|
||||
"HeaderLibraryStats": "إحصائيات المكتبة",
|
||||
"HeaderListeningSessions": "جلسات الاستماع",
|
||||
"HeaderListeningStats": "جلسات الاستماع",
|
||||
"HeaderLogin": "تسجيل الدخول",
|
||||
"HeaderLogs": "السجلات",
|
||||
"HeaderManageGenres": "إدارة الانواع",
|
||||
"HeaderManageTags": "إدارة العلامات"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user