Compare commits
751 Commits
dependabot
...
renovate/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbdc38baf5 | ||
|
|
73c1274011 | ||
|
|
6a70ed2ba7 | ||
|
|
0ecdde9f26 | ||
|
|
2b18fc3636 | ||
|
|
e7f6930ca3 | ||
|
|
42d1a0563f | ||
|
|
e0b27bd1a6 | ||
|
|
40ad1fc595 | ||
|
|
f4544a676f | ||
|
|
e9d0832cb2 | ||
|
|
b80b971231 | ||
|
|
977bfbfe73 | ||
|
|
97bfdddacc | ||
|
|
32cee8ac28 | ||
|
|
2b2672be70 | ||
|
|
ed4417b7de | ||
|
|
6be1cb58a5 | ||
|
|
a326654542 | ||
|
|
948d792677 | ||
|
|
b7f1a46841 | ||
|
|
ff42033d77 | ||
|
|
a1bc62d158 | ||
|
|
742918f39f | ||
|
|
08f8b2d2f7 | ||
|
|
4781f5e99f | ||
|
|
d4599dc02c | ||
|
|
522b7455f4 | ||
|
|
9766d77fd4 | ||
|
|
f39c9a3f57 | ||
|
|
1376d435b8 | ||
|
|
66013fe785 | ||
|
|
890d0a6d41 | ||
|
|
63d9b03106 | ||
|
|
dd20b323eb | ||
|
|
20dcfb4398 | ||
|
|
5bf657c57a | ||
|
|
5cf6b3e902 | ||
|
|
10625a0360 | ||
|
|
b2703d0c59 | ||
|
|
d3a2115db5 | ||
|
|
01f1e97f3b | ||
|
|
3909785249 | ||
|
|
3ca6229023 | ||
|
|
c7ec17bf09 | ||
|
|
f583c72e13 | ||
|
|
d0e37f6373 | ||
|
|
4b8f6d14a0 | ||
|
|
7ccc98ec7a | ||
|
|
e41ea5a293 | ||
|
|
193d610d0d | ||
|
|
1985c7deb7 | ||
|
|
3b1509afc0 | ||
|
|
0e97d3a7f8 | ||
|
|
42e6180700 | ||
|
|
bf72cd699a | ||
|
|
6ba590b59d | ||
|
|
dc7226eeea | ||
|
|
46ddff668b | ||
|
|
ca3cf922f9 | ||
|
|
122e6aadeb | ||
|
|
2abce18d1a | ||
|
|
0769eec314 | ||
|
|
3e93be0292 | ||
|
|
364841c24c | ||
|
|
15644cb097 | ||
|
|
19ba529a70 | ||
|
|
6d8dce739b | ||
|
|
da336b75be | ||
|
|
c9e04a33f5 | ||
|
|
e2c38ae3f1 | ||
|
|
32de578f50 | ||
|
|
768dba60bd | ||
|
|
0a6309de44 | ||
|
|
225ca1fd41 | ||
|
|
46223f4af3 | ||
|
|
d3793f02eb | ||
|
|
7bbfa02c4a | ||
|
|
3b5b1345fa | ||
|
|
eafc65c508 | ||
|
|
23b4ed4fee | ||
|
|
6cb53c7c13 | ||
|
|
21f708d3f4 | ||
|
|
56275a3a7b | ||
|
|
2a10d11253 | ||
|
|
5df49ca297 | ||
|
|
b527141177 | ||
|
|
269590adb5 | ||
|
|
2f16a16375 | ||
|
|
762f95cb72 | ||
|
|
ecb7a35425 | ||
|
|
f7cebe7381 | ||
|
|
024ea6b1f7 | ||
|
|
ae1f025557 | ||
|
|
068f2e691b | ||
|
|
86db4bd0e1 | ||
|
|
4008ec04b9 | ||
|
|
c32a3c8386 | ||
|
|
a8090af035 | ||
|
|
7e4eb2f43f | ||
|
|
3d721d9658 | ||
|
|
6c38e30e31 | ||
|
|
3a59ab4c32 | ||
|
|
c24b7376f8 | ||
|
|
a12dc57099 | ||
|
|
bef60736b0 | ||
|
|
a2fef21af2 | ||
|
|
c2df080ad8 | ||
|
|
56d23e13eb | ||
|
|
cb8b2836c2 | ||
|
|
7e663d57f1 | ||
|
|
cd8f3c4831 | ||
|
|
7dc51349de | ||
|
|
2ec16d73dc | ||
|
|
6c372f61f1 | ||
|
|
d9e4818c70 | ||
|
|
6c12efe202 | ||
|
|
3894236c46 | ||
|
|
f11ca9584a | ||
|
|
e1c761031c | ||
|
|
46616a1d25 | ||
|
|
e7b7938841 | ||
|
|
498efbe493 | ||
|
|
1b54ae6cac | ||
|
|
0efb74e0b7 | ||
|
|
468f3085d2 | ||
|
|
b575915f24 | ||
|
|
5bbcf2dd96 | ||
|
|
1f7ee737c3 | ||
|
|
96467b7c51 | ||
|
|
e1d9abc3f9 | ||
|
|
c272eba4f5 | ||
|
|
41bde8ecd8 | ||
|
|
a9ea556b15 | ||
|
|
20fbb8f24e | ||
|
|
d9adbd6bc8 | ||
|
|
c23b722426 | ||
|
|
fa122fc15a | ||
|
|
d9d44c49c1 | ||
|
|
4e4d211117 | ||
|
|
11860af730 | ||
|
|
761bd31519 | ||
|
|
652682bbe1 | ||
|
|
3ec4b58ff8 | ||
|
|
0a9e08cdab | ||
|
|
74240bd265 | ||
|
|
abcc625b60 | ||
|
|
13c49d5144 | ||
|
|
9673982c79 | ||
|
|
a2516723d4 | ||
|
|
1a7fe7ece0 | ||
|
|
699e749a49 | ||
|
|
571e699b7e | ||
|
|
6e06787a0a | ||
|
|
2d7a38c9cc | ||
|
|
a2e9231983 | ||
|
|
573c31032d | ||
|
|
2a9be36c7b | ||
|
|
c796529544 | ||
|
|
73d66d5612 | ||
|
|
3dd636d520 | ||
|
|
0c9e61fdc2 | ||
|
|
463b3559b1 | ||
|
|
d744639c22 | ||
|
|
bf8b002142 | ||
|
|
e64e1f6535 | ||
|
|
23184b3e18 | ||
|
|
611f9eb962 | ||
|
|
0bec3f2577 | ||
|
|
f1e6d8726c | ||
|
|
1a8e6d4add | ||
|
|
026349babb | ||
|
|
e1724f5ec1 | ||
|
|
7e89b5f564 | ||
|
|
77df7d5d85 | ||
|
|
afb4c4c830 | ||
|
|
1295d5592e | ||
|
|
c70fd69315 | ||
|
|
d4ff5a27de | ||
|
|
acf7b6889d | ||
|
|
1d1542c446 | ||
|
|
c5c5fc6743 | ||
|
|
20ce3da458 | ||
|
|
d1df6ae42b | ||
|
|
220ff70d30 | ||
|
|
0427ae6bb1 | ||
|
|
702412a425 | ||
|
|
f4883e19a8 | ||
|
|
2fae712ea9 | ||
|
|
0a545e1c7c | ||
|
|
530df76316 | ||
|
|
5b85e0e0ac | ||
|
|
b517c27bf6 | ||
|
|
7be11bc9d9 | ||
|
|
af10633a7d | ||
|
|
91cfc15e1c | ||
|
|
2687b3daf1 | ||
|
|
de2fe3f52d | ||
|
|
9b2ce2886e | ||
|
|
0a9ddb490d | ||
|
|
99fea57a95 | ||
|
|
ac2d059219 | ||
|
|
134848d082 | ||
|
|
95da2b3868 | ||
|
|
d1c1f74763 | ||
|
|
ca6204518a | ||
|
|
4c88d6b28b | ||
|
|
7edcf9501e | ||
|
|
741babbbc3 | ||
|
|
3616f5b81e | ||
|
|
5857c02921 | ||
|
|
1aa990f1bf | ||
|
|
bd8d0e786d | ||
|
|
d46c34a901 | ||
|
|
c02c9690a6 | ||
|
|
04df0b9106 | ||
|
|
90744a57ba | ||
|
|
78926c2bea | ||
|
|
76b704d897 | ||
|
|
5648423c12 | ||
|
|
f1cb49ec38 | ||
|
|
2454034d3f | ||
|
|
8a6a97a437 | ||
|
|
81663eec15 | ||
|
|
d74a148db4 | ||
|
|
a75f1124a6 | ||
|
|
4da2be5038 | ||
|
|
cbac146558 | ||
|
|
837f4e7479 | ||
|
|
625ab3fede | ||
|
|
290f8285c7 | ||
|
|
13f35d0e4b | ||
|
|
918af8fa65 | ||
|
|
d168815ba4 | ||
|
|
8026ae3137 | ||
|
|
bef489cba4 | ||
|
|
1d02d2e9d2 | ||
|
|
8c1958db46 | ||
|
|
c778f9cc2c | ||
|
|
ebf2e85af8 | ||
|
|
e30d2a324e | ||
|
|
f69e509898 | ||
|
|
a9955fcb35 | ||
|
|
430e8fb98c | ||
|
|
1e77ecffcc | ||
|
|
788475b7b8 | ||
|
|
c72f093f88 | ||
|
|
b5fb51bfa9 | ||
|
|
ce2958351b | ||
|
|
54a828c123 | ||
|
|
0cd8dcf946 | ||
|
|
c466497733 | ||
|
|
813f0a6399 | ||
|
|
9d17ef0dce | ||
|
|
b94f7021dc | ||
|
|
29bd1a2dc8 | ||
|
|
95f910cc6a | ||
|
|
531ceedcbb | ||
|
|
d2afde2e01 | ||
|
|
860fbbf371 | ||
|
|
37432ff513 | ||
|
|
0a14b8212d | ||
|
|
068a42c5bf | ||
|
|
7f2bd12e98 | ||
|
|
6ae937eab4 | ||
|
|
535104ac80 | ||
|
|
1915ad08e2 | ||
|
|
01e1345a89 | ||
|
|
258ee7bacf | ||
|
|
48eee02ead | ||
|
|
64870f9247 | ||
|
|
7ff6490028 | ||
|
|
a7d5b09bbd | ||
|
|
cc933c6678 | ||
|
|
751985cab3 | ||
|
|
e956a0a635 | ||
|
|
57a139d80c | ||
|
|
f701a1fcbd | ||
|
|
d0341fb3d8 | ||
|
|
c8fd928167 | ||
|
|
a8c93b3394 | ||
|
|
b5db940fc3 | ||
|
|
505cc64ef6 | ||
|
|
24477e8025 | ||
|
|
23097a4502 | ||
|
|
54bfe07a01 | ||
|
|
5464ee5ba4 | ||
|
|
9e284cc93e | ||
|
|
9741d0c603 | ||
|
|
6441aa0269 | ||
|
|
4373c8b058 | ||
|
|
4c14a8b529 | ||
|
|
c09237f4ce | ||
|
|
ad342a0b1e | ||
|
|
f1a77af8d3 | ||
|
|
c68dd09ebe | ||
|
|
daee19c4ac | ||
|
|
edb196c6b0 | ||
|
|
d0eabd3116 | ||
|
|
1189b6b84b | ||
|
|
e31e646b7b | ||
|
|
9b837ff89e | ||
|
|
5b0c88bd6b | ||
|
|
921d13517f | ||
|
|
22f0706789 | ||
|
|
b2951f0282 | ||
|
|
dfba17fdbc | ||
|
|
39777707b0 | ||
|
|
7606dfaf4b | ||
|
|
dae70c60e4 | ||
|
|
4f9a105921 | ||
|
|
1dc435986c | ||
|
|
fbbf879006 | ||
|
|
6dab926437 | ||
|
|
b183690db6 | ||
|
|
c2d94327d0 | ||
|
|
bf32030b23 | ||
|
|
91f210f378 | ||
|
|
8a7148164b | ||
|
|
780905e670 | ||
|
|
b0c9e11404 | ||
|
|
fee9b12f1b | ||
|
|
f510ad5874 | ||
|
|
7a9b8fe7ae | ||
|
|
e58063f457 | ||
|
|
62f61fa167 | ||
|
|
96024d3025 | ||
|
|
7281ce480d | ||
|
|
3bcaf84ecb | ||
|
|
4fa5176982 | ||
|
|
1ed047df3d | ||
|
|
d48e2c4cd7 | ||
|
|
7dc276ab51 | ||
|
|
a95599b60f | ||
|
|
dfd461cf4c | ||
|
|
3215be4cd8 | ||
|
|
e91a7556cf | ||
|
|
e6d57d8e89 | ||
|
|
2c2311415f | ||
|
|
16fd2a01aa | ||
|
|
0682ca3b99 | ||
|
|
d39c58675d | ||
|
|
5292162fdd | ||
|
|
f44b642514 | ||
|
|
d37b6304fa | ||
|
|
40fb2ddc93 | ||
|
|
184cc7e9d1 | ||
|
|
7d20728ae3 | ||
|
|
ac94190e0c | ||
|
|
cdf9613e08 | ||
|
|
d188880e7e | ||
|
|
0f6fcd8daf | ||
|
|
0e3384e7a4 | ||
|
|
b9769d9547 | ||
|
|
24860e373a | ||
|
|
b89a90ebf5 | ||
|
|
a98f740ad8 | ||
|
|
c6c951a377 | ||
|
|
a8af5c31cd | ||
|
|
b847506c1b | ||
|
|
776755a81c | ||
|
|
f936c9366f | ||
|
|
9b7d921845 | ||
|
|
b08df1ed80 | ||
|
|
9ebe4b7f57 | ||
|
|
9f6964fb51 | ||
|
|
3787889b41 | ||
|
|
91da2edae5 | ||
|
|
d5e54157ed | ||
|
|
ed8dbf1bd9 | ||
|
|
a19ccf5439 | ||
|
|
ae99ac8b03 | ||
|
|
e83279b69f | ||
|
|
5bf0b0314c | ||
|
|
5d1a19a65d | ||
|
|
ce24556dad | ||
|
|
2b92a87006 | ||
|
|
2a58eb8194 | ||
|
|
2b96e9d6c7 | ||
|
|
fd4c897642 | ||
|
|
76fbfbbe84 | ||
|
|
d26cc473a9 | ||
|
|
715b026b0f | ||
|
|
a3baf9a257 | ||
|
|
fbd480cd55 | ||
|
|
1435ea1560 | ||
|
|
4523b9f790 | ||
|
|
b691f62fc7 | ||
|
|
1d07721de8 | ||
|
|
5a1ca91bab | ||
|
|
70530a562c | ||
|
|
5b622a547d | ||
|
|
e2e9a5523d | ||
|
|
41e5b7b6bc | ||
|
|
1194cff68d | ||
|
|
35507a8303 | ||
|
|
4d59c20550 | ||
|
|
f974a39938 | ||
|
|
e9e56af092 | ||
|
|
3efd339d91 | ||
|
|
403d116338 | ||
|
|
0a9db2bda9 | ||
|
|
2ee0caab6a | ||
|
|
39ab3a52d8 | ||
|
|
49988dbd35 | ||
|
|
39278b1e4e | ||
|
|
d061871955 | ||
|
|
ec73f0e0fc | ||
|
|
58c43e72c0 | ||
|
|
3d75ba4a7e | ||
|
|
ec80e82625 | ||
|
|
f09ada7f87 | ||
|
|
b6be3c3866 | ||
|
|
527c25388e | ||
|
|
2d041661ce | ||
|
|
68d69351ea | ||
|
|
72e20c95ae | ||
|
|
8dab9a6f12 | ||
|
|
fc3ac97e75 | ||
|
|
73b23092ed | ||
|
|
3cf3a345db | ||
|
|
72392ec2ed | ||
|
|
e602b50e5b | ||
|
|
eaa0ca4b79 | ||
|
|
c8ca4f3bb4 | ||
|
|
36fa0fb9be | ||
|
|
3e30c04941 | ||
|
|
874a3cc727 | ||
|
|
8a0176eba2 | ||
|
|
f20aaa3195 | ||
|
|
abce5b1bea | ||
|
|
ad00b16069 | ||
|
|
cc16d73fac | ||
|
|
9c4bb658f6 | ||
|
|
77c2366dbe | ||
|
|
a32b2613ac | ||
|
|
429170bb65 | ||
|
|
b1e083f9c7 | ||
|
|
c93c25481d | ||
|
|
f10573ff46 | ||
|
|
c3d1f78e15 | ||
|
|
3667493bc2 | ||
|
|
5660931dd1 | ||
|
|
ab62a00574 | ||
|
|
af6b205781 | ||
|
|
a55eea3e62 | ||
|
|
9b2f036296 | ||
|
|
26c065c52d | ||
|
|
46a683a56b | ||
|
|
1586880776 | ||
|
|
3a747addbf | ||
|
|
1702604e32 | ||
|
|
ae56c9ee64 | ||
|
|
f87421bde8 | ||
|
|
5411a0a0e7 | ||
|
|
47889a5789 | ||
|
|
6c03684db5 | ||
|
|
98c1dfa597 | ||
|
|
ff42b28520 | ||
|
|
a516de5fc7 | ||
|
|
d5423d2d56 | ||
|
|
4b36146b34 | ||
|
|
37aa7b8b08 | ||
|
|
5346444689 | ||
|
|
473b8cb428 | ||
|
|
929c8b3cc7 | ||
|
|
b39360bf61 | ||
|
|
13f3f61b39 | ||
|
|
e225dce119 | ||
|
|
a238b5ef8a | ||
|
|
713bb551cf | ||
|
|
35082b8712 | ||
|
|
68eb5b9e36 | ||
|
|
966e69354a | ||
|
|
89c5119aed | ||
|
|
3a95e751d0 | ||
|
|
cc7799cf49 | ||
|
|
139ecd8146 | ||
|
|
eff386ffd8 | ||
|
|
b58ee4c1ba | ||
|
|
38fc5db9c2 | ||
|
|
2a59c296da | ||
|
|
fb58a759ac | ||
|
|
2e4dde35f4 | ||
|
|
952a83d282 | ||
|
|
a8f06c4fa8 | ||
|
|
2729f77aa8 | ||
|
|
ee717bab07 | ||
|
|
0606493bd9 | ||
|
|
4f0f1635be | ||
|
|
b78b5fc4f0 | ||
|
|
f97cbe0fc5 | ||
|
|
646773b30a | ||
|
|
6f615b7cd9 | ||
|
|
6c3a3a7205 | ||
|
|
df1626e95b | ||
|
|
e9cc027340 | ||
|
|
e8846f71a1 | ||
|
|
8c099c87fe | ||
|
|
79d2c178e9 | ||
|
|
a25295194f | ||
|
|
9562a188c4 | ||
|
|
d581dd9c68 | ||
|
|
19a28b441e | ||
|
|
ddae83d2ed | ||
|
|
b13942fbd5 | ||
|
|
e04c867424 | ||
|
|
eaf4b16abb | ||
|
|
82d9e465a3 | ||
|
|
b2db3370b4 | ||
|
|
2d35d763b9 | ||
|
|
5f6b7138e0 | ||
|
|
ac6b24b3eb | ||
|
|
db94421f5f | ||
|
|
edeb5d6f0c | ||
|
|
94b007544a | ||
|
|
5969f7b600 | ||
|
|
60d810b3a1 | ||
|
|
a2222e4272 | ||
|
|
2e90aa6c54 | ||
|
|
44f4d9c537 | ||
|
|
808ece5db4 | ||
|
|
69b7c5216e | ||
|
|
a5fce23949 | ||
|
|
5a17f34fe4 | ||
|
|
77272cb35c | ||
|
|
87db7e61e4 | ||
|
|
89a59608c9 | ||
|
|
107bdd276c | ||
|
|
f0c3c98b6f | ||
|
|
b620fcaf96 | ||
|
|
122379306c | ||
|
|
2fa8079bc6 | ||
|
|
24954abee7 | ||
|
|
ca2d669924 | ||
|
|
18f3083e69 | ||
|
|
db3ce49e9e | ||
|
|
b006b48772 | ||
|
|
018bfa2c8c | ||
|
|
a2f8d43970 | ||
|
|
d8363144e3 | ||
|
|
b2e634bc95 | ||
|
|
f8c12e7c17 | ||
|
|
bc7bfcfb8a | ||
|
|
cf234ccb98 | ||
|
|
392cbff73b | ||
|
|
6bab9cd7b9 | ||
|
|
8644db92b0 | ||
|
|
66a3a6ffb7 | ||
|
|
c6ce01eaa6 | ||
|
|
d7b9ea641a | ||
|
|
89879edf92 | ||
|
|
992289c8cd | ||
|
|
796301ca9b | ||
|
|
ca2e4523f9 | ||
|
|
b49de1b9e0 | ||
|
|
4c5a025e95 | ||
|
|
221d678899 | ||
|
|
a7d041ae96 | ||
|
|
118a0c375f | ||
|
|
6d11d4ab40 | ||
|
|
c965b6169d | ||
|
|
25481cae7e | ||
|
|
be68305bd7 | ||
|
|
09ca934c0d | ||
|
|
c4c009b795 | ||
|
|
c4f8cfc589 | ||
|
|
af8c65a4c2 | ||
|
|
fb622b15f9 | ||
|
|
a988a97d75 | ||
|
|
95e0e43417 | ||
|
|
efd1609049 | ||
|
|
723369acec | ||
|
|
ebca102eaa | ||
|
|
7694ee695b | ||
|
|
6ea2a7b220 | ||
|
|
481ba04d4e | ||
|
|
d75d84484d | ||
|
|
48a5a6eef7 | ||
|
|
bbae91088f | ||
|
|
8a25ff534b | ||
|
|
065a97cf9f | ||
|
|
c6518a8e2f | ||
|
|
bba17cc2fa | ||
|
|
872d7fde93 | ||
|
|
9b9ee3c258 | ||
|
|
693b4c1383 | ||
|
|
c64fa5a612 | ||
|
|
df99a37356 | ||
|
|
5d33900607 | ||
|
|
49ae7017e5 | ||
|
|
b55899587f | ||
|
|
5e26465fe1 | ||
|
|
8b39997166 | ||
|
|
6d24d7ccf5 | ||
|
|
9cbcd891ef | ||
|
|
ea760e6eb0 | ||
|
|
838d83d214 | ||
|
|
75f18a2853 | ||
|
|
261536a671 | ||
|
|
a7f676aa3a | ||
|
|
8205df5fc5 | ||
|
|
185223d2fc | ||
|
|
ac7b5e6231 | ||
|
|
2be0186750 | ||
|
|
9a6b54aa9a | ||
|
|
d2485b5426 | ||
|
|
343f73bf0c | ||
|
|
761df06ef3 | ||
|
|
07726e2311 | ||
|
|
9a82ba5cef | ||
|
|
e483dfcc89 | ||
|
|
b0e42be494 | ||
|
|
4ef907f89e | ||
|
|
110c2052d3 | ||
|
|
0fbaa49a1b | ||
|
|
97420e5213 | ||
|
|
68e338c113 | ||
|
|
7740c5d61a | ||
|
|
13412e3eea | ||
|
|
cc81d133c8 | ||
|
|
f7e580196c | ||
|
|
a4084a0610 | ||
|
|
ccd9448c49 | ||
|
|
8d09be6664 | ||
|
|
7e26935a41 | ||
|
|
e9f943cc99 | ||
|
|
fd876f4def | ||
|
|
e782fbd7b0 | ||
|
|
c62bcfdb87 | ||
|
|
45495caa55 | ||
|
|
b386d349a4 | ||
|
|
3aea9bc7ae | ||
|
|
c92e06a4c2 | ||
|
|
f62081c277 | ||
|
|
b6a8159238 | ||
|
|
9f947ceac8 | ||
|
|
15f5c5df64 | ||
|
|
9082f53c89 | ||
|
|
b0cfdab7c3 | ||
|
|
187f1368c2 | ||
|
|
50bfabff70 | ||
|
|
dae7cfc8a8 | ||
|
|
761a367637 | ||
|
|
cbb6dd2466 | ||
|
|
4cbcc47f57 | ||
|
|
e2e592a722 | ||
|
|
71102e3d55 | ||
|
|
279ecdd62d | ||
|
|
452479b7b2 | ||
|
|
acada92e7d | ||
|
|
e0409ced8f | ||
|
|
4debb10c49 | ||
|
|
2e026b5790 | ||
|
|
837bbe98e1 | ||
|
|
8d0517b00a | ||
|
|
6dbbe0e78b | ||
|
|
7a8e934c7e | ||
|
|
9fc58b1d5c | ||
|
|
47a3ecbea5 | ||
|
|
efae432f9a | ||
|
|
e607c45162 | ||
|
|
7142d41a74 | ||
|
|
274228a095 | ||
|
|
d39bbc69e2 | ||
|
|
faf6b14cf4 | ||
|
|
5fc9fb3084 | ||
|
|
e9cb48d7ef | ||
|
|
0c6f582f28 | ||
|
|
b66d846324 | ||
|
|
6724c72ce5 | ||
|
|
b4efb6f5de | ||
|
|
3d2f060373 | ||
|
|
b1c69890d8 | ||
|
|
29fa59d8b3 | ||
|
|
aa3f1e8969 | ||
|
|
bf1b7cae80 | ||
|
|
7d87f8c5b2 | ||
|
|
a75de89d43 | ||
|
|
6bc6831c13 | ||
|
|
287e14a83c | ||
|
|
1b281ef299 | ||
|
|
0a532a96f3 | ||
|
|
9903b9052c | ||
|
|
c23f1ba231 | ||
|
|
6c4cb665bf | ||
|
|
88d44bfe8f | ||
|
|
1098ca4447 | ||
|
|
357ce7c9b8 | ||
|
|
38ac335544 | ||
|
|
a7eb42c439 | ||
|
|
1245d89224 | ||
|
|
f8f71a8b1f | ||
|
|
4c828845d4 | ||
|
|
c3df42c6cc | ||
|
|
fb0f4ee284 | ||
|
|
b7f330e01c | ||
|
|
2912bf50c5 | ||
|
|
c9d7e20b44 | ||
|
|
d1460f2d3c | ||
|
|
de7176af1b | ||
|
|
4b658123c1 | ||
|
|
fa59e0c2b9 | ||
|
|
0e447a6eb4 | ||
|
|
88a065a80d | ||
|
|
5e3aa28d57 | ||
|
|
4ffbaed5ac | ||
|
|
5170125228 | ||
|
|
b193e454a4 | ||
|
|
9afc6e6bf3 | ||
|
|
9089f3d450 | ||
|
|
a643738e2f | ||
|
|
5fec647f11 | ||
|
|
86f18bfa08 | ||
|
|
4fd2a4041f | ||
|
|
2ce3f72c0a | ||
|
|
949e8684c1 | ||
|
|
998e991264 | ||
|
|
069d9f62bd | ||
|
|
5e1af7e40c | ||
|
|
ba8d4f9c2b | ||
|
|
9424d8d79c | ||
|
|
f38afeb06f | ||
|
|
cb97eb834d | ||
|
|
bfa516664d | ||
|
|
a8577f363e | ||
|
|
671c0aa7a0 | ||
|
|
801b96413b | ||
|
|
c9412241d8 | ||
|
|
2b0499c341 | ||
|
|
bc7f5547e1 | ||
|
|
93821aed8c | ||
|
|
fcd1a65522 | ||
|
|
6bea19f54a | ||
|
|
e0e9853d49 | ||
|
|
325ff3b105 | ||
|
|
89e07f2f2b | ||
|
|
9fd0fcc175 | ||
|
|
0eeed43d85 | ||
|
|
a9106642bd | ||
|
|
d5bdd3cd5a | ||
|
|
d96bb5a61e | ||
|
|
4fd3d72c08 | ||
|
|
5b953440a3 | ||
|
|
6b7ac54d06 | ||
|
|
a36908b4c4 | ||
|
|
2346943348 | ||
|
|
2cdbbd3f2d | ||
|
|
5c8c86b766 | ||
|
|
4cccd63831 | ||
|
|
c1161c7c5a |
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@@ -1 +1,5 @@
|
||||
* @jellyfin/web
|
||||
# Joshua must review all changes to bump_version
|
||||
bump_version @joshuaboniface
|
||||
# Core must approve all changes within the repo config
|
||||
.github/ @jellyfin/core
|
||||
|
||||
8
.github/workflows/__codeql.yml
vendored
8
.github/workflows/__codeql.yml
vendored
@@ -20,21 +20,21 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository ⬇️
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ inputs.commit }}
|
||||
show-progress: false
|
||||
|
||||
- name: Initialize CodeQL 🛠️
|
||||
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
uses: github/codeql-action/init@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3.31.4
|
||||
with:
|
||||
queries: security-and-quality
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild 📦
|
||||
uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
uses: github/codeql-action/autobuild@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3.31.4
|
||||
|
||||
- name: Perform CodeQL Analysis 🧪
|
||||
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
uses: github/codeql-action/analyze@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3.31.4
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
2
.github/workflows/__package.yml
vendored
2
.github/workflows/__package.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ inputs.commit || github.sha }}
|
||||
|
||||
|
||||
6
.github/workflows/__quality_checks.yml
vendored
6
.github/workflows/__quality_checks.yml
vendored
@@ -14,13 +14,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ inputs.commit }}
|
||||
show-progress: false
|
||||
|
||||
- name: Scan
|
||||
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||
with:
|
||||
## Workaround from https://github.com/actions/dependency-review-action/issues/456
|
||||
## TODO: Remove when necessary
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout ⬇️
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ inputs.commit }}
|
||||
show-progress: false
|
||||
|
||||
4
.github/workflows/pull_request.yml
vendored
4
.github/workflows/pull_request.yml
vendored
@@ -80,7 +80,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
@@ -95,6 +95,6 @@ jobs:
|
||||
run: npm ci --no-audit
|
||||
|
||||
- name: Run eslint
|
||||
uses: CatChen/eslint-suggestion-action@4dda35decf912ab18ea3e071acec2c6c2eda00b6 # v4.1.18
|
||||
uses: CatChen/eslint-suggestion-action@4ee415529307a8ca0260b4a3775484802523e5af # v4.1.19
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -101,6 +101,8 @@
|
||||
- [diegoeche](https://github.com/diegoeche)
|
||||
- [Free O'Toole](https://github.com/freeotoole)
|
||||
- [TheBosZ](https://github.com/thebosz)
|
||||
- [qm3jp](https://github.com/qm3jp)
|
||||
- [johnnyg](https://github.com/johnnyg)
|
||||
|
||||
## Emby Contributors
|
||||
|
||||
|
||||
1023
package-lock.json
generated
1023
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellyfin-web",
|
||||
"version": "10.11.0",
|
||||
"version": "10.12.0",
|
||||
"description": "Web interface for Jellyfin",
|
||||
"repository": "https://github.com/jellyfin/jellyfin-web",
|
||||
"license": "GPL-2.0-or-later",
|
||||
@@ -18,8 +18,8 @@
|
||||
"@types/loadable__component": "5.13.9",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/markdown-it": "14.1.2",
|
||||
"@types/react": "18.3.23",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react-lazy-load-image-component": "1.6.4",
|
||||
"@types/sortablejs": "1.15.8",
|
||||
"@typescript-eslint/parser": "8.35.1",
|
||||
@@ -66,6 +66,7 @@
|
||||
"ts-loader": "9.5.2",
|
||||
"typescript": "5.8.3",
|
||||
"typescript-eslint": "8.35.1",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "3.2.4",
|
||||
"webpack": "5.99.9",
|
||||
"webpack-bundle-analyzer": "4.10.2",
|
||||
@@ -84,7 +85,7 @@
|
||||
"@fontsource/noto-sans-sc": "5.2.6",
|
||||
"@fontsource/noto-sans-tc": "5.2.6",
|
||||
"@jellyfin/libass-wasm": "4.2.3",
|
||||
"@jellyfin/sdk": "0.0.0-unstable.202507090504",
|
||||
"@jellyfin/sdk": "0.0.0-unstable.202512091852",
|
||||
"@jellyfin/ux-web": "1.0.0",
|
||||
"@mui/icons-material": "6.4.12",
|
||||
"@mui/material": "6.4.12",
|
||||
@@ -106,7 +107,7 @@
|
||||
"flv.js": "1.6.2",
|
||||
"headroom.js": "0.12.0",
|
||||
"history": "5.3.0",
|
||||
"hls.js": "1.6.5",
|
||||
"hls.js": "1.6.13",
|
||||
"intersection-observer": "0.12.2",
|
||||
"jellyfin-apiclient": "1.11.0",
|
||||
"jquery": "3.7.1",
|
||||
@@ -120,11 +121,11 @@
|
||||
"native-promise-only": "0.8.1",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
"proxy-polyfill": "0.3.2",
|
||||
"react": "18.3.1",
|
||||
"react": "19.2.3",
|
||||
"react-blurhash": "0.3.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-dom": "19.2.3",
|
||||
"react-lazy-load-image-component": "1.6.3",
|
||||
"react-router-dom": "6.30.1",
|
||||
"react-router-dom": "7.11.0",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"screenfull": "6.0.2",
|
||||
"sortablejs": "1.15.6",
|
||||
@@ -161,14 +162,14 @@
|
||||
"build:check": "tsc --noEmit",
|
||||
"build:es-check": "npm run build:production && npm run escheck",
|
||||
"escheck": "es-check",
|
||||
"lint": "eslint \"./\"",
|
||||
"lint": "eslint",
|
||||
"test": "vitest --watch=false --config vite.config.ts",
|
||||
"test:watch": "vitest --config vite.config.ts",
|
||||
"stylelint": "stylelint \"src/**/*.{css,scss}\""
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"npm": ">=9.6.4",
|
||||
"npm": ">=9.6.4 <11.0.0",
|
||||
"yarn": "YARN NO LONGER USED - use npm instead."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import {
|
||||
RouterProvider,
|
||||
@@ -13,12 +13,16 @@ import { STABLE_APP_ROUTES } from 'apps/stable/routes/routes';
|
||||
import { WIZARD_APP_ROUTES } from 'apps/wizard/routes/routes';
|
||||
import AppHeader from 'components/AppHeader';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import { SETTING_KEY as LAYOUT_SETTING_KEY } from 'components/layoutManager';
|
||||
import BangRedirect from 'components/router/BangRedirect';
|
||||
import { createRouterHistory } from 'components/router/routerHistory';
|
||||
import UserThemeProvider from 'themes/UserThemeProvider';
|
||||
import { LayoutMode } from 'constants/layoutMode';
|
||||
import browser from 'scripts/browser';
|
||||
import appTheme from 'themes';
|
||||
import { ThemeStorageManager } from 'themes/themeStorageManager';
|
||||
|
||||
const layoutMode = localStorage.getItem('layout');
|
||||
const isExperimentalLayout = layoutMode === 'experimental';
|
||||
const layoutMode = browser.tv ? LayoutMode.Tv : localStorage.getItem(LAYOUT_SETTING_KEY);
|
||||
const isExperimentalLayout = !layoutMode || layoutMode === LayoutMode.Experimental;
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
@@ -51,11 +55,15 @@ function RootAppLayout() {
|
||||
.some(path => location.pathname.startsWith(`/${path}`));
|
||||
|
||||
return (
|
||||
<UserThemeProvider>
|
||||
<ThemeProvider
|
||||
theme={appTheme}
|
||||
defaultMode='dark'
|
||||
storageManager={ThemeStorageManager}
|
||||
>
|
||||
<Backdrop />
|
||||
<AppHeader isHidden={isExperimentalLayout || isNewLayoutPath} />
|
||||
|
||||
<Outlet />
|
||||
</UserThemeProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
1
src/apiclient.d.ts
vendored
1
src/apiclient.d.ts
vendored
@@ -136,6 +136,7 @@ declare module 'jellyfin-apiclient' {
|
||||
getInstantMixFromItem(itemId: string, options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
getIntros(itemId: string): Promise<BaseItemDtoQueryResult>;
|
||||
getItemCounts(userId?: string): Promise<ItemCounts>;
|
||||
/** @deprecated This function returns a URL with a legacy auth parameter.*/
|
||||
getItemDownloadUrl(itemId: string): string;
|
||||
getItemImageInfos(itemId: string): Promise<ImageInfo[]>;
|
||||
getItems(userId: string, options?: any): Promise<BaseItemDtoQueryResult>;
|
||||
|
||||
@@ -7,7 +7,7 @@ import isEqual from 'lodash-es/isEqual';
|
||||
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { EventType } from 'types/eventType';
|
||||
import { EventType } from 'constants/eventType';
|
||||
import Events, { type Event } from 'utils/events';
|
||||
|
||||
interface AppTabsParams {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardHeader from '@mui/material/CardHeader';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
@@ -11,9 +11,8 @@ import CardActionArea from '@mui/material/CardActionArea';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { Link, To } from 'react-router-dom';
|
||||
|
||||
interface IProps {
|
||||
interface BaseCardProps {
|
||||
title?: string;
|
||||
secondaryTitle?: string;
|
||||
text?: string;
|
||||
image?: string | null;
|
||||
icon?: React.ReactNode;
|
||||
@@ -22,15 +21,30 @@ interface IProps {
|
||||
action?: boolean;
|
||||
actionRef?: React.MutableRefObject<HTMLButtonElement | null>;
|
||||
onActionClick?: () => void;
|
||||
height?: number;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
const BaseCard = ({ title, secondaryTitle, text, image, icon, to, onClick, action, actionRef, onActionClick }: IProps) => {
|
||||
const BaseCard = ({
|
||||
title,
|
||||
text,
|
||||
image,
|
||||
icon,
|
||||
to,
|
||||
onClick,
|
||||
action,
|
||||
actionRef,
|
||||
onActionClick,
|
||||
height,
|
||||
width
|
||||
}: BaseCardProps) => {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 240
|
||||
height: height || 240,
|
||||
width: width
|
||||
}}
|
||||
>
|
||||
<CardActionArea
|
||||
@@ -62,30 +76,44 @@ const BaseCard = ({ title, secondaryTitle, text, image, icon, to, onClick, actio
|
||||
</Box>
|
||||
)}
|
||||
</CardActionArea>
|
||||
<CardHeader
|
||||
title={
|
||||
<Stack direction='row' gap={1} alignItems='center'>
|
||||
<Typography sx={{
|
||||
<CardContent
|
||||
sx={{
|
||||
minHeight: 50,
|
||||
'&:last-child': {
|
||||
paddingBottom: 2,
|
||||
paddingRight: 1
|
||||
}
|
||||
}}>
|
||||
<Stack flexGrow={1} direction='row'>
|
||||
<Stack flexGrow={1}>
|
||||
<Typography gutterBottom sx={{
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
{title}
|
||||
</Typography>
|
||||
{secondaryTitle && (
|
||||
<Typography variant='body2' color='text.secondary'>{secondaryTitle}</Typography>
|
||||
{text && (
|
||||
<Typography
|
||||
variant='body2'
|
||||
color='text.secondary'
|
||||
sx={{
|
||||
lineBreak: 'anywhere'
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
subheader={text}
|
||||
action={
|
||||
action ? (
|
||||
<IconButton ref={actionRef} onClick={onActionClick}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Box>
|
||||
{action ? (
|
||||
<IconButton ref={actionRef} onClick={onActionClick}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
70
src/apps/dashboard/components/SearchInput.tsx
Normal file
70
src/apps/dashboard/components/SearchInput.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import Search from '@mui/icons-material/Search';
|
||||
import InputBase, { type InputBaseProps } from '@mui/material/InputBase';
|
||||
import { alpha, styled } from '@mui/material/styles';
|
||||
import React, { type FC } from 'react';
|
||||
|
||||
const SearchContainer = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: alpha(theme.palette.common.white, 0.15),
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(theme.palette.common.white, 0.25)
|
||||
},
|
||||
width: '100%',
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
width: 'auto'
|
||||
}
|
||||
}));
|
||||
|
||||
const SearchIconWrapper = styled('div')(({ theme }) => ({
|
||||
padding: theme.spacing(0, 2),
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}));
|
||||
|
||||
const StyledInputBase = styled(InputBase)(({ theme }) => ({
|
||||
color: 'inherit',
|
||||
flexGrow: 1,
|
||||
'& .MuiInputBase-input': {
|
||||
padding: theme.spacing(1, 1, 1, 0),
|
||||
// vertical padding + font size from searchIcon
|
||||
paddingLeft: `calc(1em + ${theme.spacing(4)})`,
|
||||
transition: theme.transitions.create('width'),
|
||||
width: '100%',
|
||||
[theme.breakpoints.up('md')]: {
|
||||
width: '20ch'
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
interface SearchInputProps extends InputBaseProps {
|
||||
label?: string
|
||||
}
|
||||
|
||||
const SearchInput: FC<SearchInputProps> = ({
|
||||
label,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<SearchContainer>
|
||||
<SearchIconWrapper>
|
||||
<Search />
|
||||
</SearchIconWrapper>
|
||||
<StyledInputBase
|
||||
placeholder={label}
|
||||
inputProps={{
|
||||
'aria-label': label,
|
||||
...props.inputProps
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</SearchContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchInput;
|
||||
30
src/apps/dashboard/components/Toast.tsx
Normal file
30
src/apps/dashboard/components/Toast.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import Snackbar, { SnackbarProps } from '@mui/material/Snackbar';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
const Toast = (props: SnackbarProps) => {
|
||||
const onCloseClick = useCallback((e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
props.onClose?.(e, 'clickaway');
|
||||
}, [ props ]);
|
||||
|
||||
const action = (
|
||||
<IconButton
|
||||
size='small'
|
||||
color='inherit'
|
||||
onClick={onCloseClick}
|
||||
>
|
||||
<CloseIcon fontSize='small' />
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
autoHideDuration={3300}
|
||||
action={action}
|
||||
{ ...props }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
@@ -1,6 +1,5 @@
|
||||
import Extension from '@mui/icons-material/Extension';
|
||||
import Folder from '@mui/icons-material/Folder';
|
||||
import Public from '@mui/icons-material/Public';
|
||||
import List from '@mui/material/List';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
@@ -33,23 +32,16 @@ const PluginDrawerSection = () => {
|
||||
>
|
||||
<ListItemLink
|
||||
to='/dashboard/plugins'
|
||||
includePaths={[ '/configurationpage' ]}
|
||||
includePaths={[
|
||||
'/configurationpage',
|
||||
'/dashboard/plugins/repositories'
|
||||
]}
|
||||
excludePaths={pagesInfo?.map(p => `/${Dashboard.getPluginUrl(p.Name)}`)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Extension />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabMyPlugins')} />
|
||||
</ListItemLink>
|
||||
|
||||
<ListItemLink
|
||||
to='/dashboard/plugins/catalog'
|
||||
includePaths={[ '/dashboard/plugins/repositories' ]}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Public />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabCatalog')} />
|
||||
<ListItemText primary={globalize.translate('TabPlugins')} />
|
||||
</ListItemLink>
|
||||
|
||||
{pagesInfo?.map(pageInfo => (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Box from '@mui/material/Box/Box';
|
||||
import Stack from '@mui/material/Stack/Stack';
|
||||
import type {} from '@mui/material/themeCssVarsAugmentation';
|
||||
import Typography from '@mui/material/Typography/Typography';
|
||||
import { type MRT_RowData, type MRT_TableInstance, MaterialReactTable } from 'material-react-table';
|
||||
import { type MRT_RowData, type MRT_TableInstance, type MRT_TableOptions, MaterialReactTable } from 'material-react-table';
|
||||
import React from 'react';
|
||||
|
||||
import Page, { type PageProps } from 'components/Page';
|
||||
@@ -12,7 +13,7 @@ interface TablePageProps<T extends MRT_RowData> extends PageProps {
|
||||
table: MRT_TableInstance<T>
|
||||
}
|
||||
|
||||
export const DEFAULT_TABLE_OPTIONS = {
|
||||
export const DEFAULT_TABLE_OPTIONS: Partial<MRT_TableOptions<MRT_RowData>> = {
|
||||
// Enable custom features
|
||||
enableColumnPinning: true,
|
||||
enableColumnResizing: true,
|
||||
|
||||
@@ -39,6 +39,7 @@ const ActivityLogWidget = () => {
|
||||
key={entry.Id}
|
||||
item={entry}
|
||||
displayShortOverview={true}
|
||||
to='/dashboard/activity?useractivity=true'
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
|
||||
@@ -31,6 +31,7 @@ const AlertsLogWidget = () => {
|
||||
key={entry.Id}
|
||||
item={entry}
|
||||
displayShortOverview={false}
|
||||
to='/dashboard/activity?useractivity=false'
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
|
||||
@@ -5,13 +5,14 @@ import MusicNote from '@mui/icons-material/MusicNote';
|
||||
import MusicVideo from '@mui/icons-material/MusicVideo';
|
||||
import Tv from '@mui/icons-material/Tv';
|
||||
import VideoLibrary from '@mui/icons-material/VideoLibrary';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import SvgIcon from '@mui/material/SvgIcon';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useItemCounts } from 'apps/dashboard/features/metrics/api/useItemCounts';
|
||||
import MetricCard, { type MetricCardProps } from 'apps/dashboard/features/metrics/components/MetricCard';
|
||||
import globalize from 'lib/globalize';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
interface MetricDefinition {
|
||||
key: keyof ItemCounts
|
||||
@@ -75,23 +76,27 @@ const ItemCountsWidget = () => {
|
||||
}, [ counts, isPending ]);
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: 'stretch',
|
||||
marginTop: 2
|
||||
}}
|
||||
>
|
||||
{cards.map(card => (
|
||||
<Grid
|
||||
key={card.metrics.map(metric => metric.label).join('-')}
|
||||
size={{ xs: 12, sm: 6, lg: 4 }}
|
||||
>
|
||||
<MetricCard {...card} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
<Box>
|
||||
<Grid
|
||||
container
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: 'stretch'
|
||||
}}
|
||||
>
|
||||
{cards.map(card => (
|
||||
<Grid
|
||||
key={card.metrics.map(metric => metric.label).join('-')}
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
lg={4}
|
||||
>
|
||||
<MetricCard {...card} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,15 +6,24 @@ import Typography from '@mui/material/Typography';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Button from '@mui/material/Button';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||
import { useSystemInfo } from 'hooks/useSystemInfo';
|
||||
|
||||
type ServerInfoWidgetProps = {
|
||||
onScanLibrariesClick?: () => void;
|
||||
onRestartClick?: () => void;
|
||||
onShutdownClick?: () => void;
|
||||
isScanning?: boolean;
|
||||
};
|
||||
|
||||
const ServerInfoWidget = ({ onScanLibrariesClick, onRestartClick, onShutdownClick }: ServerInfoWidgetProps) => {
|
||||
const ServerInfoWidget = ({
|
||||
onScanLibrariesClick,
|
||||
onRestartClick,
|
||||
onShutdownClick,
|
||||
isScanning
|
||||
}: ServerInfoWidgetProps) => {
|
||||
const { data: systemInfo, isPending } = useSystemInfo();
|
||||
|
||||
return (
|
||||
@@ -27,13 +36,13 @@ const ServerInfoWidget = ({ onScanLibrariesClick, onRestartClick, onShutdownClic
|
||||
padding: 2
|
||||
}}>
|
||||
<Stack direction='row'>
|
||||
<Stack flexGrow={1} gap={1}>
|
||||
<Stack flexGrow={1} spacing={1}>
|
||||
<Typography fontWeight='bold'>{globalize.translate('LabelServerName')}</Typography>
|
||||
<Typography fontWeight='bold'>{globalize.translate('LabelServerVersion')}</Typography>
|
||||
<Typography fontWeight='bold'>{globalize.translate('LabelWebVersion')}</Typography>
|
||||
<Typography fontWeight='bold'>{globalize.translate('LabelBuildVersion')}</Typography>
|
||||
</Stack>
|
||||
<Stack flexGrow={5} gap={1}>
|
||||
<Stack flexGrow={5} spacing={1}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Skeleton />
|
||||
@@ -53,18 +62,21 @@ const ServerInfoWidget = ({ onScanLibrariesClick, onRestartClick, onShutdownClic
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Stack direction='row' gap={1.5} flexWrap={'wrap'}>
|
||||
<Stack direction='row' spacing={1.5}>
|
||||
<Button
|
||||
onClick={onScanLibrariesClick}
|
||||
startIcon={<RefreshIcon />}
|
||||
sx={{
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
disabled={isScanning}
|
||||
>
|
||||
{globalize.translate('ButtonScanAllLibraries')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onRestartClick}
|
||||
startIcon={<RestartAltIcon />}
|
||||
color='error'
|
||||
sx={{
|
||||
fontWeight: 'bold'
|
||||
@@ -75,6 +87,7 @@ const ServerInfoWidget = ({ onScanLibrariesClick, onRestartClick, onShutdownClic
|
||||
|
||||
<Button
|
||||
onClick={onShutdownClick}
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
color='error'
|
||||
sx={{
|
||||
fontWeight: 'bold'
|
||||
|
||||
@@ -22,10 +22,7 @@ export const HelpLinks = [
|
||||
paths: ['/dashboard/playback/transcoding'],
|
||||
url: 'https://jellyfin.org/docs/general/server/transcoding'
|
||||
}, {
|
||||
paths: [
|
||||
'/dashboard/plugins',
|
||||
'/dashboard/plugins/catalog'
|
||||
],
|
||||
paths: ['/dashboard/plugins'],
|
||||
url: 'https://jellyfin.org/docs/general/server/plugins/'
|
||||
}, {
|
||||
paths: ['/dashboard/plugins/repositories'],
|
||||
@@ -50,5 +47,8 @@ export const HelpLinks = [
|
||||
'/dashboard/users/profile'
|
||||
],
|
||||
url: 'https://jellyfin.org/docs/general/server/users/'
|
||||
}, {
|
||||
paths: ['/dashboard/backups'],
|
||||
url: 'https://jellyfin.org/docs/general/administration/backup-and-restore/'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<div id="mediaLibraryPage" data-role="page" class="page type-interior mediaLibraryPage librarySectionPage fullWidthContent" data-title="${HeaderLibraries}">
|
||||
<div>
|
||||
<div class="content-primary">
|
||||
<div class="padded-top padded-bottom">
|
||||
<button is="emby-button" type="button" class="raised btnRefresh">
|
||||
<span>${ButtonScanAllLibraries}</span>
|
||||
</button>
|
||||
<progress max="100" min="0" style="display: inline-block; vertical-align: middle;" class="refreshProgress"></progress>
|
||||
</div>
|
||||
|
||||
<div id="divVirtualFolders"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,383 +0,0 @@
|
||||
import escapeHtml from 'escape-html';
|
||||
|
||||
import taskButton from 'scripts/taskbutton';
|
||||
import loading from 'components/loading/loading';
|
||||
import globalize from 'lib/globalize';
|
||||
import dom from 'scripts/dom';
|
||||
import imageHelper from 'utils/image';
|
||||
import 'components/cardbuilder/card.scss';
|
||||
import 'elements/emby-itemrefreshindicator/emby-itemrefreshindicator';
|
||||
import { pageClassOn, pageIdOn } from 'utils/dashboard';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
|
||||
|
||||
function addVirtualFolder(page) {
|
||||
import('components/mediaLibraryCreator/mediaLibraryCreator').then(({ default: MediaLibraryCreator }) => {
|
||||
new MediaLibraryCreator({
|
||||
collectionTypeOptions: getCollectionTypeOptions().filter(function (f) {
|
||||
return !f.hidden;
|
||||
}),
|
||||
refresh: shouldRefreshLibraryAfterChanges(page)
|
||||
}).then(function (hasChanges) {
|
||||
if (hasChanges) {
|
||||
reloadLibrary(page);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function editVirtualFolder(page, virtualFolder) {
|
||||
import('components/mediaLibraryEditor/mediaLibraryEditor').then(({ default: MediaLibraryEditor }) => {
|
||||
new MediaLibraryEditor({
|
||||
refresh: shouldRefreshLibraryAfterChanges(page),
|
||||
library: virtualFolder
|
||||
}).then(function (hasChanges) {
|
||||
if (hasChanges) {
|
||||
reloadLibrary(page);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deleteVirtualFolder(page, virtualFolder) {
|
||||
let msg = globalize.translate('MessageAreYouSureYouWishToRemoveMediaFolder');
|
||||
|
||||
if (virtualFolder.Locations.length) {
|
||||
msg += '<br/><br/>' + globalize.translate('MessageTheFollowingLocationWillBeRemovedFromLibrary') + '<br/><br/>';
|
||||
msg += virtualFolder.Locations.join('<br/>');
|
||||
}
|
||||
|
||||
confirm({
|
||||
text: msg,
|
||||
title: globalize.translate('HeaderRemoveMediaFolder'),
|
||||
confirmText: globalize.translate('Delete'),
|
||||
primary: 'delete'
|
||||
}).then(function () {
|
||||
const refreshAfterChange = shouldRefreshLibraryAfterChanges(page);
|
||||
ApiClient.removeVirtualFolder(virtualFolder.Name, refreshAfterChange).then(function () {
|
||||
reloadLibrary(page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function refreshVirtualFolder(page, virtualFolder) {
|
||||
import('components/refreshdialog/refreshdialog').then(({ default: RefreshDialog }) => {
|
||||
new RefreshDialog({
|
||||
itemIds: [virtualFolder.ItemId],
|
||||
serverId: ApiClient.serverId(),
|
||||
mode: 'scan'
|
||||
}).show();
|
||||
});
|
||||
}
|
||||
|
||||
function renameVirtualFolder(page, virtualFolder) {
|
||||
import('components/prompt/prompt').then(({ default: prompt }) => {
|
||||
prompt({
|
||||
label: globalize.translate('LabelNewName'),
|
||||
description: globalize.translate('MessageRenameMediaFolder'),
|
||||
confirmText: globalize.translate('ButtonRename')
|
||||
}).then(function (newName) {
|
||||
if (newName && newName != virtualFolder.Name) {
|
||||
const refreshAfterChange = shouldRefreshLibraryAfterChanges(page);
|
||||
ApiClient.renameVirtualFolder(virtualFolder.Name, newName, refreshAfterChange).then(function () {
|
||||
reloadLibrary(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showCardMenu(page, elem, virtualFolders) {
|
||||
const card = dom.parentWithClass(elem, 'card');
|
||||
const index = parseInt(card.getAttribute('data-index'), 10);
|
||||
const virtualFolder = virtualFolders[index];
|
||||
const menuItems = [];
|
||||
menuItems.push({
|
||||
name: globalize.translate('EditImages'),
|
||||
id: 'editimages',
|
||||
icon: 'photo'
|
||||
});
|
||||
menuItems.push({
|
||||
name: globalize.translate('ManageLibrary'),
|
||||
id: 'edit',
|
||||
icon: 'folder'
|
||||
});
|
||||
menuItems.push({
|
||||
name: globalize.translate('ButtonRename'),
|
||||
id: 'rename',
|
||||
icon: 'mode_edit'
|
||||
});
|
||||
menuItems.push({
|
||||
name: globalize.translate('ScanLibrary'),
|
||||
id: 'refresh',
|
||||
icon: 'refresh'
|
||||
});
|
||||
menuItems.push({
|
||||
name: globalize.translate('ButtonRemove'),
|
||||
id: 'delete',
|
||||
icon: 'delete'
|
||||
});
|
||||
|
||||
import('components/actionSheet/actionSheet').then((actionsheet) => {
|
||||
actionsheet.show({
|
||||
items: menuItems,
|
||||
positionTo: elem,
|
||||
callback: function (resultId) {
|
||||
switch (resultId) {
|
||||
case 'edit':
|
||||
editVirtualFolder(page, virtualFolder);
|
||||
break;
|
||||
|
||||
case 'editimages':
|
||||
editImages(page, virtualFolder);
|
||||
break;
|
||||
|
||||
case 'rename':
|
||||
renameVirtualFolder(page, virtualFolder);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
deleteVirtualFolder(page, virtualFolder);
|
||||
break;
|
||||
|
||||
case 'refresh':
|
||||
refreshVirtualFolder(page, virtualFolder);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function reloadLibrary(page) {
|
||||
loading.show();
|
||||
ApiClient.getVirtualFolders().then(function (result) {
|
||||
reloadVirtualFolders(page, result);
|
||||
});
|
||||
}
|
||||
|
||||
function shouldRefreshLibraryAfterChanges(page) {
|
||||
return page.id === 'mediaLibraryPage';
|
||||
}
|
||||
|
||||
function reloadVirtualFolders(page, virtualFolders) {
|
||||
let html = '';
|
||||
virtualFolders.push({
|
||||
Name: globalize.translate('ButtonAddMediaLibrary'),
|
||||
icon: 'add_circle',
|
||||
Locations: [],
|
||||
showType: false,
|
||||
showLocations: false,
|
||||
showMenu: false,
|
||||
showNameWithIcon: false,
|
||||
elementId: 'addLibrary'
|
||||
});
|
||||
|
||||
for (let i = 0; i < virtualFolders.length; i++) {
|
||||
const virtualFolder = virtualFolders[i];
|
||||
html += getVirtualFolderHtml(page, virtualFolder, i);
|
||||
}
|
||||
|
||||
const divVirtualFolders = page.querySelector('#divVirtualFolders');
|
||||
divVirtualFolders.innerHTML = html;
|
||||
divVirtualFolders.classList.add('itemsContainer');
|
||||
divVirtualFolders.classList.add('vertical-wrap');
|
||||
const btnCardMenuElements = divVirtualFolders.querySelectorAll('.btnCardMenu');
|
||||
btnCardMenuElements.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
showCardMenu(page, btn, virtualFolders);
|
||||
});
|
||||
});
|
||||
divVirtualFolders.querySelector('#addLibrary').addEventListener('click', function () {
|
||||
addVirtualFolder(page);
|
||||
});
|
||||
|
||||
const libraryEditElements = divVirtualFolders.querySelectorAll('.editLibrary');
|
||||
libraryEditElements.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const card = dom.parentWithClass(btn, 'card');
|
||||
const index = parseInt(card.getAttribute('data-index'), 10);
|
||||
const virtualFolder = virtualFolders[index];
|
||||
|
||||
if (virtualFolder.ItemId) {
|
||||
editVirtualFolder(page, virtualFolder);
|
||||
}
|
||||
});
|
||||
});
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
function editImages(page, virtualFolder) {
|
||||
import('components/imageeditor/imageeditor').then((imageEditor) => {
|
||||
imageEditor.show({
|
||||
itemId: virtualFolder.ItemId,
|
||||
serverId: ApiClient.serverId()
|
||||
}).then(function () {
|
||||
reloadLibrary(page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getLink(text, url) {
|
||||
return globalize.translate(text, '<a is="emby-linkbutton" class="button-link" href="' + url + '" target="_blank" data-autohide="true">', '</a>');
|
||||
}
|
||||
|
||||
function getCollectionTypeOptions() {
|
||||
return [{
|
||||
name: '',
|
||||
value: ''
|
||||
}, {
|
||||
name: globalize.translate('Movies'),
|
||||
value: 'movies',
|
||||
message: getLink('MovieLibraryHelp', 'https://jellyfin.org/docs/general/server/media/movies')
|
||||
}, {
|
||||
name: globalize.translate('TabMusic'),
|
||||
value: 'music',
|
||||
message: getLink('MusicLibraryHelp', 'https://jellyfin.org/docs/general/server/media/music')
|
||||
}, {
|
||||
name: globalize.translate('Shows'),
|
||||
value: 'tvshows',
|
||||
message: getLink('TvLibraryHelp', 'https://jellyfin.org/docs/general/server/media/shows')
|
||||
}, {
|
||||
name: globalize.translate('Books'),
|
||||
value: 'books',
|
||||
message: getLink('BookLibraryHelp', 'https://jellyfin.org/docs/general/server/media/books')
|
||||
}, {
|
||||
name: globalize.translate('HomeVideosPhotos'),
|
||||
value: 'homevideos'
|
||||
}, {
|
||||
name: globalize.translate('MusicVideos'),
|
||||
value: 'musicvideos'
|
||||
}, {
|
||||
name: globalize.translate('MixedMoviesShows'),
|
||||
value: 'mixed',
|
||||
message: globalize.translate('MessageUnsetContentHelp')
|
||||
}];
|
||||
}
|
||||
|
||||
function getVirtualFolderHtml(page, virtualFolder, index) {
|
||||
let html = '';
|
||||
|
||||
const elementId = virtualFolder.elementId ? `id="${virtualFolder.elementId}" ` : '';
|
||||
html += '<div ' + elementId + 'class="card backdropCard scalableCard backdropCard-scalable" data-index="' + index + '" data-id="' + virtualFolder.ItemId + '">';
|
||||
|
||||
html += '<div class="cardBox visualCardBox">';
|
||||
html += '<div class="cardScalable visualCardBox-cardScalable">';
|
||||
html += '<div class="cardPadder cardPadder-backdrop"></div>';
|
||||
html += '<div class="cardContent">';
|
||||
let imgUrl = '';
|
||||
|
||||
if (virtualFolder.PrimaryImageItemId) {
|
||||
imgUrl = ApiClient.getScaledImageUrl(virtualFolder.PrimaryImageItemId, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.40),
|
||||
type: 'Primary'
|
||||
});
|
||||
}
|
||||
|
||||
let hasCardImageContainer;
|
||||
|
||||
if (imgUrl) {
|
||||
html += `<div class="cardImageContainer editLibrary ${imgUrl ? '' : getDefaultBackgroundClass()}" style="cursor:pointer">`;
|
||||
html += `<img src="${imgUrl}" style="width:100%" />`;
|
||||
hasCardImageContainer = true;
|
||||
} else if (!virtualFolder.showNameWithIcon) {
|
||||
html += `<div class="cardImageContainer editLibrary ${getDefaultBackgroundClass()}" style="cursor:pointer;">`;
|
||||
html += '<span class="cardImageIcon material-icons ' + (virtualFolder.icon || imageHelper.getLibraryIcon(virtualFolder.CollectionType)) + '" aria-hidden="true"></span>';
|
||||
hasCardImageContainer = true;
|
||||
}
|
||||
|
||||
if (hasCardImageContainer) {
|
||||
html += '<div class="cardIndicators backdropCardIndicators">';
|
||||
html += '<div is="emby-itemrefreshindicator"' + (virtualFolder.RefreshProgress || virtualFolder.RefreshStatus && virtualFolder.RefreshStatus !== 'Idle' ? '' : ' class="hide"') + ' data-progress="' + (virtualFolder.RefreshProgress || 0) + '" data-status="' + virtualFolder.RefreshStatus + '"></div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
if (!imgUrl && virtualFolder.showNameWithIcon) {
|
||||
html += '<h3 class="cardImageContainer addLibrary" style="position:absolute;top:0;left:0;right:0;bottom:0;cursor:pointer;flex-direction:column;">';
|
||||
html += '<span class="cardImageIcon material-icons ' + (virtualFolder.icon || imageHelper.getLibraryIcon(virtualFolder.CollectionType)) + '" aria-hidden="true"></span>';
|
||||
|
||||
if (virtualFolder.showNameWithIcon) {
|
||||
html += '<div style="margin:1em 0;position:width:100%;">';
|
||||
html += escapeHtml(virtualFolder.Name);
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</h3>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '<div class="cardFooter visualCardBox-cardFooter">'; // always show menu unless explicitly hidden
|
||||
|
||||
if (virtualFolder.showMenu !== false) {
|
||||
const dirTextAlign = globalize.getIsRTL() ? 'left' : 'right';
|
||||
html += '<div style="text-align:' + dirTextAlign + '; float:' + dirTextAlign + ';padding-top:5px;">';
|
||||
html += '<button type="button" is="paper-icon-button-light" class="btnCardMenu autoSize"><span class="material-icons more_vert" aria-hidden="true"></span></button>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += "<div class='cardText'>";
|
||||
|
||||
if (virtualFolder.showNameWithIcon) {
|
||||
html += ' ';
|
||||
} else {
|
||||
html += escapeHtml(virtualFolder.Name);
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
let typeName = getCollectionTypeOptions().filter(function (t) {
|
||||
return t.value == virtualFolder.CollectionType;
|
||||
})[0];
|
||||
typeName = typeName ? typeName.name : globalize.translate('Other');
|
||||
html += "<div class='cardText cardText-secondary'>";
|
||||
|
||||
if (virtualFolder.showType === false) {
|
||||
html += ' ';
|
||||
} else {
|
||||
html += typeName;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
if (virtualFolder.showLocations === false) {
|
||||
html += "<div class='cardText cardText-secondary'>";
|
||||
html += ' ';
|
||||
html += '</div>';
|
||||
} else if (virtualFolder.Locations.length && virtualFolder.Locations.length === 1) {
|
||||
html += "<div class='cardText cardText-secondary' dir='ltr' style='text-align:left;'>";
|
||||
html += virtualFolder.Locations[0];
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += "<div class='cardText cardText-secondary'>";
|
||||
html += globalize.translate('NumLocationsValue', virtualFolder.Locations.length);
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
pageClassOn('pageshow', 'mediaLibraryPage', function () {
|
||||
reloadLibrary(this);
|
||||
});
|
||||
pageIdOn('pageshow', 'mediaLibraryPage', function () {
|
||||
const page = this;
|
||||
taskButton({
|
||||
mode: 'on',
|
||||
progressElem: page.querySelector('.refreshProgress'),
|
||||
taskKey: 'RefreshLibrary',
|
||||
button: page.querySelector('.btnRefresh')
|
||||
});
|
||||
});
|
||||
pageIdOn('pagebeforehide', 'mediaLibraryPage', function () {
|
||||
const page = this;
|
||||
taskButton({
|
||||
mode: 'off',
|
||||
progressElem: page.querySelector('.refreshProgress'),
|
||||
taskKey: 'RefreshLibrary',
|
||||
button: page.querySelector('.btnRefresh')
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<div id="liveTvStatusPage" data-role="page" class="page type-interior liveTvSettingsPage" data-title="${LiveTV}">
|
||||
<div>
|
||||
<div class="content-primary">
|
||||
<div class="verticalSection verticalSection-extrabottompadding">
|
||||
<div class="verticalSection verticalSection-extrabottompadding">
|
||||
<div class="sectionTitleContainer sectionTitleContainer-cards">
|
||||
<h2 class="sectionTitle sectionTitle-cards">
|
||||
<span>${HeaderTunerDevices}</span>
|
||||
</h2>
|
||||
<button is="emby-button" type="button" class="fab btnAddDevice submit sectionTitleButton" style="margin-left:1em;" title="${Add}">
|
||||
<span class="material-icons add" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="devicesList itemsContainer vertical-wrap" data-hovermenu="false" data-multiselect="false" style="margin-top: .5em;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="readOnlyContent">
|
||||
<div class="verticalSection">
|
||||
<div class="sectionTitleContainer">
|
||||
<h2 class="sectionTitle">${HeaderGuideProviders}</h2>
|
||||
<button is="emby-button" type="button" class="fab btnAddProvider submit" style="margin-left:1em;" title="${Add}">
|
||||
<span class="material-icons add" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="providerList">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="button" class="raised btnRefresh block button-cancel">
|
||||
<span>${ButtonRefreshGuideData}</span>
|
||||
</button>
|
||||
<progress max="100" min="0" style="width: 100%;" class="refreshGuideProgress"></progress>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,338 +0,0 @@
|
||||
import 'jquery';
|
||||
|
||||
import globalize from 'lib/globalize';
|
||||
import taskButton from 'scripts/taskbutton';
|
||||
import dom from 'scripts/dom';
|
||||
import layoutManager from 'components/layoutManager';
|
||||
import loading from 'components/loading/loading';
|
||||
import browser from 'scripts/browser';
|
||||
import 'components/listview/listview.scss';
|
||||
import 'styles/flexstyles.scss';
|
||||
import 'elements/emby-itemscontainer/emby-itemscontainer';
|
||||
import 'components/cardbuilder/card.scss';
|
||||
import 'material-design-icons-iconfont';
|
||||
import 'elements/emby-button/emby-button';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
|
||||
|
||||
const enableFocusTransform = !browser.slow && !browser.edge;
|
||||
|
||||
function getDeviceHtml(device) {
|
||||
const padderClass = 'cardPadder-backdrop';
|
||||
let cssClass = 'card scalableCard backdropCard backdropCard-scalable';
|
||||
const cardBoxCssClass = 'cardBox visualCardBox';
|
||||
let html = '';
|
||||
|
||||
// TODO move card creation code to Card component
|
||||
|
||||
if (layoutManager.tv) {
|
||||
cssClass += ' show-focus';
|
||||
|
||||
if (enableFocusTransform) {
|
||||
cssClass += ' show-animation';
|
||||
}
|
||||
}
|
||||
|
||||
html += '<div type="button" class="' + cssClass + '" data-id="' + device.Id + '">';
|
||||
html += '<div class="' + cardBoxCssClass + '">';
|
||||
html += '<div class="cardScalable visualCardBox-cardScalable">';
|
||||
html += '<div class="' + padderClass + '"></div>';
|
||||
html += '<div class="cardContent searchImage">';
|
||||
html += `<div class="cardImageContainer coveredImage ${getDefaultBackgroundClass()}"><span class="cardImageIcon material-icons dvr" aria-hidden="true"></span></div>`;
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '<div class="cardFooter visualCardBox-cardFooter">';
|
||||
html += '<button is="paper-icon-button-light" class="itemAction btnCardOptions autoSize" data-action="menu"><span class="material-icons more_vert" aria-hidden="true"></span></button>';
|
||||
html += '<div class="cardText">' + (device.FriendlyName || getTunerName(device.Type)) + '</div>';
|
||||
html += '<div class="cardText cardText-secondary">';
|
||||
html += device.Url || ' ';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderDevices(page, devices) {
|
||||
page.querySelector('.devicesList').innerHTML = devices.map(getDeviceHtml).join('');
|
||||
}
|
||||
|
||||
function deleteDevice(page, id) {
|
||||
const message = globalize.translate('MessageConfirmDeleteTunerDevice');
|
||||
|
||||
confirm(message, globalize.translate('HeaderDeleteDevice')).then(function () {
|
||||
loading.show();
|
||||
ApiClient.ajax({
|
||||
type: 'DELETE',
|
||||
url: ApiClient.getUrl('LiveTv/TunerHosts', {
|
||||
Id: id
|
||||
})
|
||||
}).then(function () {
|
||||
reload(page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function reload(page) {
|
||||
loading.show();
|
||||
ApiClient.getNamedConfiguration('livetv').then(function (config) {
|
||||
renderDevices(page, config.TunerHosts);
|
||||
renderProviders(page, config.ListingProviders);
|
||||
});
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
function submitAddDeviceForm(page) {
|
||||
page.querySelector('.dlgAddDevice').close();
|
||||
loading.show();
|
||||
ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: ApiClient.getUrl('LiveTv/TunerHosts'),
|
||||
data: JSON.stringify({
|
||||
Type: page.querySelector('#selectTunerDeviceType').value,
|
||||
Url: page.querySelector('#txtDevicePath').value
|
||||
}),
|
||||
contentType: 'application/json'
|
||||
}).then(function () {
|
||||
reload(page);
|
||||
}, function () {
|
||||
Dashboard.alert({
|
||||
message: globalize.translate('ErrorAddingTunerDevice')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderProviders(page, providers) {
|
||||
let html = '';
|
||||
|
||||
if (providers.length) {
|
||||
html += '<div class="paperList">';
|
||||
|
||||
for (let i = 0, length = providers.length; i < length; i++) {
|
||||
const provider = providers[i];
|
||||
html += '<div class="listItem">';
|
||||
html += '<span class="listItemIcon material-icons dvr" aria-hidden="true"></span>';
|
||||
html += '<div class="listItemBody two-line">';
|
||||
html += '<a is="emby-linkbutton" style="display:block;padding:0;margin:0;text-align:left;" class="clearLink" href="' + getProviderConfigurationUrl(provider.Type) + '&id=' + provider.Id + '">';
|
||||
html += '<h3 class="listItemBodyText">';
|
||||
html += getProviderName(provider.Type);
|
||||
html += '</h3>';
|
||||
html += '<div class="listItemBodyText secondary">';
|
||||
html += provider.Path || provider.ListingsId || '';
|
||||
html += '</div>';
|
||||
html += '</a>';
|
||||
html += '</div>';
|
||||
html += '<button type="button" is="paper-icon-button-light" class="btnOptions" data-id="' + provider.Id + '"><span class="material-icons listItemAside more_vert" aria-hidden="true"></span></button>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
const elem = page.querySelector('.providerList');
|
||||
elem.innerHTML = html;
|
||||
if (elem.querySelector('.btnOptions')) {
|
||||
const btnOptionElements = elem.querySelectorAll('.btnOptions');
|
||||
btnOptionElements.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const id = this.getAttribute('data-id');
|
||||
showProviderOptions(page, id, btn);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function showProviderOptions(page, providerId, button) {
|
||||
const items = [];
|
||||
items.push({
|
||||
name: globalize.translate('Delete'),
|
||||
id: 'delete'
|
||||
});
|
||||
items.push({
|
||||
name: globalize.translate('MapChannels'),
|
||||
id: 'map'
|
||||
});
|
||||
|
||||
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
|
||||
actionsheet.show({
|
||||
items: items,
|
||||
positionTo: button
|
||||
}).then(function (id) {
|
||||
switch (id) {
|
||||
case 'delete':
|
||||
deleteProvider(page, providerId);
|
||||
break;
|
||||
|
||||
case 'map':
|
||||
mapChannels(page, providerId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mapChannels(page, providerId) {
|
||||
import('components/channelMapper/channelMapper').then(({ default: ChannelMapper }) => {
|
||||
new ChannelMapper({
|
||||
serverId: ApiClient.serverInfo().Id,
|
||||
providerId: providerId
|
||||
}).show();
|
||||
});
|
||||
}
|
||||
|
||||
function deleteProvider(page, id) {
|
||||
const message = globalize.translate('MessageConfirmDeleteGuideProvider');
|
||||
|
||||
confirm(message, globalize.translate('HeaderDeleteProvider')).then(function () {
|
||||
loading.show();
|
||||
ApiClient.ajax({
|
||||
type: 'DELETE',
|
||||
url: ApiClient.getUrl('LiveTv/ListingProviders', {
|
||||
Id: id
|
||||
})
|
||||
}).then(function () {
|
||||
reload(page);
|
||||
}, function () {
|
||||
reload(page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getTunerName(providerId) {
|
||||
switch (providerId.toLowerCase()) {
|
||||
case 'm3u':
|
||||
return 'M3U';
|
||||
case 'hdhomerun':
|
||||
return 'HDHomeRun';
|
||||
case 'hauppauge':
|
||||
return 'Hauppauge';
|
||||
case 'satip':
|
||||
return 'DVB';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderName(providerId) {
|
||||
switch (providerId.toLowerCase()) {
|
||||
case 'schedulesdirect':
|
||||
return 'Schedules Direct';
|
||||
case 'xmltv':
|
||||
return 'XMLTV';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderConfigurationUrl(providerId) {
|
||||
switch (providerId.toLowerCase()) {
|
||||
case 'xmltv':
|
||||
return '#/dashboard/livetv/guide?type=xmltv';
|
||||
case 'schedulesdirect':
|
||||
return '#/dashboard/livetv/guide?type=schedulesdirect';
|
||||
}
|
||||
}
|
||||
|
||||
function addProvider(button) {
|
||||
const menuItems = [];
|
||||
menuItems.push({
|
||||
name: 'Schedules Direct',
|
||||
id: 'SchedulesDirect'
|
||||
});
|
||||
menuItems.push({
|
||||
name: 'XMLTV',
|
||||
id: 'xmltv'
|
||||
});
|
||||
|
||||
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
|
||||
actionsheet.show({
|
||||
items: menuItems,
|
||||
positionTo: button,
|
||||
callback: function (id) {
|
||||
Dashboard.navigate(getProviderConfigurationUrl(id));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addDevice() {
|
||||
Dashboard.navigate('dashboard/livetv/tuner');
|
||||
}
|
||||
|
||||
function showDeviceMenu(button, tunerDeviceId) {
|
||||
const items = [];
|
||||
items.push({
|
||||
name: globalize.translate('Delete'),
|
||||
id: 'delete'
|
||||
});
|
||||
items.push({
|
||||
name: globalize.translate('Edit'),
|
||||
id: 'edit'
|
||||
});
|
||||
|
||||
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
|
||||
actionsheet.show({
|
||||
items: items,
|
||||
positionTo: button
|
||||
}).then(function (id) {
|
||||
switch (id) {
|
||||
case 'delete':
|
||||
deleteDevice(dom.parentWithClass(button, 'page'), tunerDeviceId);
|
||||
break;
|
||||
|
||||
case 'edit':
|
||||
Dashboard.navigate('dashboard/livetv/tuner?id=' + tunerDeviceId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onDevicesListClick(e) {
|
||||
const card = dom.parentWithClass(e.target, 'card');
|
||||
|
||||
if (card) {
|
||||
const id = card.getAttribute('data-id');
|
||||
const btnCardOptions = dom.parentWithClass(e.target, 'btnCardOptions');
|
||||
|
||||
if (btnCardOptions) {
|
||||
showDeviceMenu(btnCardOptions, id);
|
||||
} else {
|
||||
Dashboard.navigate('dashboard/livetv/tuner?id=' + id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$(document).on('pageinit', '#liveTvStatusPage', function () {
|
||||
const page = this;
|
||||
page.querySelector('.btnAddDevice').addEventListener('click', function () {
|
||||
addDevice();
|
||||
});
|
||||
if (page.querySelector('.formAddDevice')) {
|
||||
// NOTE: unused?
|
||||
page.querySelector('.formAddDevice').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
submitAddDeviceForm(page);
|
||||
});
|
||||
}
|
||||
page.querySelector('.btnAddProvider').addEventListener('click', function () {
|
||||
addProvider(this);
|
||||
});
|
||||
page.querySelector('.devicesList').addEventListener('click', onDevicesListClick);
|
||||
}).on('pageshow', '#liveTvStatusPage', function () {
|
||||
const page = this;
|
||||
reload(page);
|
||||
taskButton({
|
||||
mode: 'on',
|
||||
progressElem: page.querySelector('.refreshGuideProgress'),
|
||||
taskKey: 'RefreshGuide',
|
||||
button: page.querySelector('.btnRefresh')
|
||||
});
|
||||
}).on('pagehide', '#liveTvStatusPage', function () {
|
||||
const page = this;
|
||||
taskButton({
|
||||
mode: 'off',
|
||||
progressElem: page.querySelector('.refreshGuideProgress'),
|
||||
taskKey: 'RefreshGuide',
|
||||
button: page.querySelector('.btnRefresh')
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import globalize from 'lib/globalize';
|
||||
import loading from 'components/loading/loading';
|
||||
import dom from 'scripts/dom';
|
||||
import dom from 'utils/dom';
|
||||
import 'elements/emby-input/emby-input';
|
||||
import 'elements/emby-button/emby-button';
|
||||
import 'elements/emby-checkbox/emby-checkbox';
|
||||
|
||||
@@ -4,7 +4,6 @@ import Notifications from '@mui/icons-material/Notifications';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import formatRelative from 'date-fns/formatRelative';
|
||||
@@ -12,13 +11,15 @@ import { getLocale } from 'utils/dateFnsLocale';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import getLogLevelColor from '../utils/getLogLevelColor';
|
||||
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
|
||||
import ListItemLink from 'components/ListItemLink';
|
||||
|
||||
type ActivityListItemProps = {
|
||||
item: ActivityLogEntry;
|
||||
displayShortOverview: boolean;
|
||||
to: string;
|
||||
};
|
||||
|
||||
const ActivityListItem = ({ item, displayShortOverview }: ActivityListItemProps) => {
|
||||
const ActivityListItem = ({ item, displayShortOverview, to }: ActivityListItemProps) => {
|
||||
const relativeDate = useMemo(() => {
|
||||
if (item.Date) {
|
||||
return formatRelative(Date.parse(item.Date), Date.now(), { locale: getLocale() });
|
||||
@@ -29,7 +30,7 @@ const ActivityListItem = ({ item, displayShortOverview }: ActivityListItemProps)
|
||||
|
||||
return (
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton>
|
||||
<ListItemLink to={to}>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: getLogLevelColor(item.Severity || LogLevel.Information) + '.main' }}>
|
||||
<Notifications sx={{ color: '#fff' }} />
|
||||
@@ -37,14 +38,28 @@ const ActivityListItem = ({ item, displayShortOverview }: ActivityListItemProps)
|
||||
</ListItemAvatar>
|
||||
|
||||
<ListItemText
|
||||
primary={<Typography>{item.Name}</Typography>}
|
||||
primary={<Typography sx={{ whiteSpace: 'pre-wrap' }}>{item.Name}</Typography>}
|
||||
secondary={(
|
||||
<Stack>
|
||||
<Typography variant='body1' color='text.secondary'>
|
||||
<Typography
|
||||
sx={{
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
variant='body1'
|
||||
color='text.secondary'
|
||||
>
|
||||
{relativeDate}
|
||||
</Typography>
|
||||
{displayShortOverview && (
|
||||
<Typography variant='body1' color='text.secondary'>
|
||||
<Typography
|
||||
sx={{
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
variant='body1'
|
||||
color='text.secondary'
|
||||
>
|
||||
{item.ShortOverview}
|
||||
</Typography>
|
||||
)}
|
||||
@@ -52,7 +67,7 @@ const ActivityListItem = ({ item, displayShortOverview }: ActivityListItemProps)
|
||||
)}
|
||||
disableTypography
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import Box from '@mui/material/Box';
|
||||
import globalize from 'lib/globalize';
|
||||
import React, { FunctionComponent, useCallback } from 'react';
|
||||
import React, { FunctionComponent, useCallback, useState } from 'react';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import FormGroup from '@mui/material/FormGroup';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
@@ -16,7 +16,7 @@ import Checkbox from '@mui/material/Checkbox';
|
||||
import ContentCopy from '@mui/icons-material/ContentCopy';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { copy } from 'scripts/clipboard';
|
||||
import toast from 'components/toast/toast';
|
||||
import Toast from 'apps/dashboard/components/Toast';
|
||||
|
||||
type IProps = {
|
||||
backup: BackupManifestDto;
|
||||
@@ -25,10 +25,16 @@ type IProps = {
|
||||
};
|
||||
|
||||
const BackupInfoDialog: FunctionComponent<IProps> = ({ backup, open, onClose }: IProps) => {
|
||||
const [ isCopiedToastOpen, setIsCopiedToastOpen ] = useState(false);
|
||||
|
||||
const handleToastClose = useCallback(() => {
|
||||
setIsCopiedToastOpen(false);
|
||||
}, []);
|
||||
|
||||
const copyPath = useCallback(async () => {
|
||||
if (backup.Path) {
|
||||
await copy(backup.Path);
|
||||
toast({ text: globalize.translate('Copied') });
|
||||
setIsCopiedToastOpen(true);
|
||||
}
|
||||
}, [ backup.Path ]);
|
||||
|
||||
@@ -39,16 +45,21 @@ const BackupInfoDialog: FunctionComponent<IProps> = ({ backup, open, onClose }:
|
||||
maxWidth={'sm'}
|
||||
fullWidth
|
||||
>
|
||||
<Toast
|
||||
open={isCopiedToastOpen}
|
||||
onClose={handleToastClose}
|
||||
message={globalize.translate('Copied')}
|
||||
/>
|
||||
<DialogTitle>
|
||||
{backup.DateCreated}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Stack gap={2}>
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Stack
|
||||
direction='row'
|
||||
gap={2}
|
||||
spacing={2}
|
||||
>
|
||||
<Typography fontWeight='bold'>{globalize.translate('LabelPath')}</Typography>
|
||||
<Stack direction='row'>
|
||||
@@ -60,7 +71,7 @@ const BackupInfoDialog: FunctionComponent<IProps> = ({ backup, open, onClose }:
|
||||
</Stack>
|
||||
<Stack
|
||||
direction='row'
|
||||
gap={2}
|
||||
spacing={2}
|
||||
>
|
||||
<Typography fontWeight='bold'>{globalize.translate('LabelVersion')}</Typography>
|
||||
<Typography color='text.secondary'>{backup.ServerVersion}</Typography>
|
||||
|
||||
@@ -32,7 +32,7 @@ const RestoreConfirmationDialog: FunctionComponent<IProps> = ({ open, onClose, o
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} color='error'>
|
||||
<Button onClick={onClose} variant='text'>
|
||||
{globalize.translate('ButtonCancel')}
|
||||
</Button>
|
||||
<Button onClick={onConfirm}>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getLibraryStructureApi } from '@jellyfin/sdk/lib/utils/api/library-structure-api';
|
||||
import { LibraryStructureApiRemoveVirtualFolderRequest } from '@jellyfin/sdk/lib/generated-client/api/library-structure-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
export const useRemoveVirtualFolder = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: LibraryStructureApiRemoveVirtualFolderRequest) => (
|
||||
getLibraryStructureApi(api!)
|
||||
.removeVirtualFolder(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ 'VirtualFolders' ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getLibraryStructureApi } from '@jellyfin/sdk/lib/utils/api/library-structure-api';
|
||||
import { LibraryStructureApiRenameVirtualFolderRequest } from '@jellyfin/sdk/lib/generated-client/api/library-structure-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
export const useRenameVirtualFolder = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: LibraryStructureApiRenameVirtualFolderRequest) => (
|
||||
getLibraryStructureApi(api!)
|
||||
.renameVirtualFolder(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ 'VirtualFolders' ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import { getLibraryStructureApi } from '@jellyfin/sdk/lib/utils/api/library-structure-api';
|
||||
|
||||
const fetchVirtualFolders = async (api: Api, options?: AxiosRequestConfig) => {
|
||||
const response = await getLibraryStructureApi(api).getVirtualFolders(options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useVirtualFolders = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [ 'VirtualFolders' ],
|
||||
queryFn: ({ signal }) => fetchVirtualFolders(api!, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
||||
237
src/apps/dashboard/features/libraries/components/LibraryCard.tsx
Normal file
237
src/apps/dashboard/features/libraries/components/LibraryCard.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import type { VirtualFolderInfo } from '@jellyfin/sdk/lib/generated-client/models/virtual-folder-info';
|
||||
import BaseCard from 'apps/dashboard/components/BaseCard';
|
||||
import getCollectionTypeOptions from '../utils/collectionTypeOptions';
|
||||
import globalize from 'lib/globalize';
|
||||
import Icon from '@mui/material/Icon';
|
||||
import { getLibraryIcon } from 'utils/image';
|
||||
import MediaLibraryEditor from 'components/mediaLibraryEditor/mediaLibraryEditor';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import Folder from '@mui/icons-material/Folder';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import imageeditor from 'components/imageeditor/imageeditor';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import InputDialog from 'components/InputDialog';
|
||||
import { useRenameVirtualFolder } from '../api/useRenameVirtualFolder';
|
||||
import RefreshDialog from 'components/refreshdialog/refreshdialog';
|
||||
import ConfirmDialog from 'components/ConfirmDialog';
|
||||
import { useRemoveVirtualFolder } from '../api/useRemoveVirtualFolder';
|
||||
import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
|
||||
import dom from 'utils/dom';
|
||||
|
||||
type LibraryCardProps = {
|
||||
virtualFolder: VirtualFolderInfo;
|
||||
};
|
||||
|
||||
const LibraryCard = ({ virtualFolder }: LibraryCardProps) => {
|
||||
const { api } = useApi();
|
||||
const actionRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [ anchorEl, setAnchorEl ] = useState<HTMLElement | null>(null);
|
||||
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
|
||||
const [ isRenameLibraryDialogOpen, setIsRenameLibraryDialogOpen ] = useState(false);
|
||||
const [ isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen ] = useState(false);
|
||||
const renameVirtualFolder = useRenameVirtualFolder();
|
||||
const removeVirtualFolder = useRemoveVirtualFolder();
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
if (virtualFolder.PrimaryImageItemId && virtualFolder.ItemId && api) {
|
||||
return getImageApi(api)
|
||||
.getItemImageUrlById(virtualFolder.ItemId, ImageType.Primary, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.40)
|
||||
});
|
||||
}
|
||||
}, [ api, virtualFolder ]);
|
||||
|
||||
const typeName = getCollectionTypeOptions().filter(function (t) {
|
||||
return t.value == virtualFolder.CollectionType;
|
||||
})[0]?.name || globalize.translate('Other');
|
||||
|
||||
const openRenameDialog = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
setIsRenameLibraryDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const hideRenameLibraryDialog = useCallback(() => {
|
||||
setIsRenameLibraryDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const onMenuClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const onActionClick = useCallback(() => {
|
||||
setAnchorEl(actionRef.current);
|
||||
setIsMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const renameLibrary = useCallback((newName: string) => {
|
||||
if (virtualFolder.Name) {
|
||||
renameVirtualFolder.mutate({
|
||||
refreshLibrary: true,
|
||||
newName: newName,
|
||||
name: virtualFolder.Name
|
||||
}, {
|
||||
onSettled: () => {
|
||||
hideRenameLibraryDialog();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [ renameVirtualFolder, virtualFolder, hideRenameLibraryDialog ]);
|
||||
|
||||
const showRefreshDialog = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
|
||||
void new RefreshDialog({
|
||||
itemIds: [ virtualFolder.ItemId ],
|
||||
serverId: ServerConnections.currentApiClient()?.serverId(),
|
||||
mode: 'scan'
|
||||
}).show();
|
||||
}, [ virtualFolder ]);
|
||||
|
||||
const showMediaLibraryEditor = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
|
||||
const mediaLibraryEditor = new MediaLibraryEditor({
|
||||
library: virtualFolder
|
||||
}) as Promise<boolean>;
|
||||
|
||||
void mediaLibraryEditor.then((hasChanges: boolean) => {
|
||||
if (hasChanges) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['VirtualFolders']
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [ virtualFolder ]);
|
||||
|
||||
const showImageEditor = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
|
||||
void imageeditor.show({
|
||||
itemId: virtualFolder.ItemId,
|
||||
serverId: ServerConnections.currentApiClient()?.serverId()
|
||||
}).then(() => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['VirtualFolders']
|
||||
});
|
||||
}).catch(() => {
|
||||
/* pop up closed */
|
||||
});
|
||||
}, [ virtualFolder ]);
|
||||
|
||||
const showDeleteLibraryDialog = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
setIsConfirmDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const onCancelDeleteLibrary = useCallback(() => {
|
||||
setIsConfirmDeleteDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const onConfirmDeleteLibrary = useCallback(() => {
|
||||
if (virtualFolder.Name) {
|
||||
removeVirtualFolder.mutate({
|
||||
name: virtualFolder.Name,
|
||||
refreshLibrary: true
|
||||
}, {
|
||||
onSettled: () => {
|
||||
setIsConfirmDeleteDialogOpen(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [ virtualFolder, removeVirtualFolder ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<InputDialog
|
||||
title={globalize.translate('ButtonRename')}
|
||||
open={isRenameLibraryDialogOpen}
|
||||
onClose={hideRenameLibraryDialog}
|
||||
label={globalize.translate('LabelNewName')}
|
||||
helperText={globalize.translate('MessageRenameMediaFolder')}
|
||||
initialText={virtualFolder.Name || ''}
|
||||
confirmButtonText={globalize.translate('ButtonRename')}
|
||||
onConfirm={renameLibrary}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={isConfirmDeleteDialogOpen}
|
||||
title={globalize.translate('HeaderRemoveMediaFolder')}
|
||||
text={
|
||||
globalize.translate('MessageAreYouSureYouWishToRemoveMediaFolder') + '\n\n'
|
||||
+ globalize.translate('MessageTheFollowingLocationWillBeRemovedFromLibrary') + '\n\n'
|
||||
+ virtualFolder.Locations?.join('\n')
|
||||
}
|
||||
confirmButtonText={globalize.translate('Delete')}
|
||||
confirmButtonColor='error'
|
||||
onConfirm={onConfirmDeleteLibrary}
|
||||
onCancel={onCancelDeleteLibrary}
|
||||
/>
|
||||
|
||||
<BaseCard
|
||||
title={virtualFolder.Name || ''}
|
||||
text={typeName}
|
||||
image={imageUrl}
|
||||
icon={<Icon sx={{ fontSize: 70 }}>{getLibraryIcon(virtualFolder.CollectionType)}</Icon>}
|
||||
action={true}
|
||||
actionRef={actionRef}
|
||||
onActionClick={onActionClick}
|
||||
onClick={showMediaLibraryEditor}
|
||||
height={260}
|
||||
/>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={onMenuClose}
|
||||
>
|
||||
<MenuItem onClick={showImageEditor}>
|
||||
<ListItemIcon>
|
||||
<ImageIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('EditImages')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={showMediaLibraryEditor}>
|
||||
<ListItemIcon>
|
||||
<Folder />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('ManageLibrary')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={openRenameDialog}>
|
||||
<ListItemIcon>
|
||||
<EditIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('ButtonRename')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={showRefreshDialog}>
|
||||
<ListItemIcon>
|
||||
<RefreshIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('ScanLibrary')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={showDeleteLibraryDialog}>
|
||||
<ListItemIcon>
|
||||
<DeleteIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('ButtonRemove')}</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryCard;
|
||||
@@ -0,0 +1,31 @@
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
const getCollectionTypeOptions = () => {
|
||||
return [{
|
||||
name: '',
|
||||
value: ''
|
||||
}, {
|
||||
name: globalize.translate('Movies'),
|
||||
value: 'movies'
|
||||
}, {
|
||||
name: globalize.translate('TabMusic'),
|
||||
value: 'music'
|
||||
}, {
|
||||
name: globalize.translate('Shows'),
|
||||
value: 'tvshows'
|
||||
}, {
|
||||
name: globalize.translate('Books'),
|
||||
value: 'books'
|
||||
}, {
|
||||
name: globalize.translate('HomeVideosPhotos'),
|
||||
value: 'homevideos'
|
||||
}, {
|
||||
name: globalize.translate('MusicVideos'),
|
||||
value: 'musicvideos'
|
||||
}, {
|
||||
name: globalize.translate('MixedMoviesShows'),
|
||||
value: 'mixed'
|
||||
}];
|
||||
};
|
||||
|
||||
export default getCollectionTypeOptions;
|
||||
22
src/apps/dashboard/features/livetv/api/useDeleteProvider.ts
Normal file
22
src/apps/dashboard/features/livetv/api/useDeleteProvider.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { getLiveTvApi } from '@jellyfin/sdk/lib/utils/api/live-tv-api';
|
||||
import { LiveTvApiDeleteListingProviderRequest } from '@jellyfin/sdk/lib/generated-client/api/live-tv-api';
|
||||
|
||||
export const useDeleteProvider = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: LiveTvApiDeleteListingProviderRequest) => (
|
||||
getLiveTvApi(api!)
|
||||
.deleteListingProvider(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ 'NamedConfiguration', 'livetv' ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
22
src/apps/dashboard/features/livetv/api/useDeleteTuner.ts
Normal file
22
src/apps/dashboard/features/livetv/api/useDeleteTuner.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { getLiveTvApi } from '@jellyfin/sdk/lib/utils/api/live-tv-api';
|
||||
import { LiveTvApiDeleteTunerHostRequest } from '@jellyfin/sdk/lib/generated-client/api/live-tv-api';
|
||||
|
||||
export const useDeleteTuner = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: LiveTvApiDeleteTunerHostRequest) => (
|
||||
getLiveTvApi(api!)
|
||||
.deleteTunerHost(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ 'NamedConfiguration', 'livetv' ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
138
src/apps/dashboard/features/livetv/components/Provider.tsx
Normal file
138
src/apps/dashboard/features/livetv/components/Provider.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import type { ListingsProviderInfo } from '@jellyfin/sdk/lib/generated-client/models/listings-provider-info';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||||
import ListItemLink from 'components/ListItemLink';
|
||||
import DvrIcon from '@mui/icons-material/Dvr';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import getProviderConfigurationUrl from '../utils/getProviderConfigurationUrl';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import getProviderName from '../utils/getProviderName';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ConfirmDialog from 'components/ConfirmDialog';
|
||||
import globalize from 'lib/globalize';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import LocationSearchingIcon from '@mui/icons-material/LocationSearching';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ChannelMapper from 'components/channelMapper/channelMapper';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import { useDeleteProvider } from '../api/useDeleteProvider';
|
||||
|
||||
interface ProviderProps {
|
||||
provider: ListingsProviderInfo
|
||||
}
|
||||
|
||||
const Provider = ({ provider }: ProviderProps) => {
|
||||
const [ isDeleteProviderDialogOpen, setIsDeleteProviderDialogOpen ] = useState(false);
|
||||
const actionsRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [ anchorEl, setAnchorEl ] = useState<HTMLButtonElement | null>(null);
|
||||
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
|
||||
const deleteProvider = useDeleteProvider();
|
||||
|
||||
const showChannelMapper = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
void new ChannelMapper({
|
||||
serverId: ServerConnections.currentApiClient()?.serverId(),
|
||||
providerId: provider.Id
|
||||
}).show();
|
||||
}, [ provider ]);
|
||||
|
||||
const showContextMenu = useCallback(() => {
|
||||
setAnchorEl(actionsRef.current);
|
||||
setIsMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const showDeleteDialog = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
setIsDeleteProviderDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const onDeleteProviderDialogCancel = useCallback(() => {
|
||||
setIsDeleteProviderDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const onMenuClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const onConfirmDelete = useCallback(() => {
|
||||
if (provider.Id) {
|
||||
deleteProvider.mutate({
|
||||
id: provider.Id
|
||||
}, {
|
||||
onSettled: () => {
|
||||
setIsDeleteProviderDialogOpen(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [ deleteProvider, provider ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
open={isDeleteProviderDialogOpen}
|
||||
title={globalize.translate('HeaderDeleteProvider')}
|
||||
text={globalize.translate('MessageConfirmDeleteGuideProvider')}
|
||||
onCancel={onDeleteProviderDialogCancel}
|
||||
onConfirm={onConfirmDelete}
|
||||
confirmButtonText={globalize.translate('Delete')}
|
||||
confirmButtonColor='error'
|
||||
/>
|
||||
<ListItem
|
||||
disablePadding key={provider.Id}
|
||||
secondaryAction={
|
||||
<IconButton ref={actionsRef} onClick={showContextMenu}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemLink to={getProviderConfigurationUrl(provider.Type || '') + '&id=' + provider.Id}>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||
<DvrIcon sx={{ color: '#fff' }} />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={getProviderName(provider.Type)}
|
||||
secondary={provider.Path || provider.ListingsId}
|
||||
slotProps={{
|
||||
primary: {
|
||||
variant: 'h3'
|
||||
},
|
||||
secondary: {
|
||||
variant: 'body1'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ListItemLink>
|
||||
</ListItem>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={onMenuClose}
|
||||
>
|
||||
<MenuItem onClick={showChannelMapper}>
|
||||
<ListItemIcon>
|
||||
<LocationSearchingIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('MapChannels')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={showDeleteDialog}>
|
||||
<ListItemIcon>
|
||||
<DeleteIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('Delete')}</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Provider;
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import type { TunerHostInfo } from '@jellyfin/sdk/lib/generated-client/models/tuner-host-info';
|
||||
import BaseCard from 'apps/dashboard/components/BaseCard';
|
||||
import DvrIcon from '@mui/icons-material/Dvr';
|
||||
import getTunerName from '../utils/getTunerName';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import globalize from 'lib/globalize';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ConfirmDialog from 'components/ConfirmDialog';
|
||||
import { useDeleteTuner } from '../api/useDeleteTuner';
|
||||
|
||||
interface TunerDeviceCardProps {
|
||||
tunerHost: TunerHostInfo;
|
||||
}
|
||||
|
||||
const TunerDeviceCard = ({ tunerHost }: TunerDeviceCardProps) => {
|
||||
const navigate = useNavigate();
|
||||
const actionRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [ anchorEl, setAnchorEl ] = useState<HTMLElement | null>(null);
|
||||
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
|
||||
const [ isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen ] = useState(false);
|
||||
const deleteTuner = useDeleteTuner();
|
||||
|
||||
const navigateToEditPage = useCallback(() => {
|
||||
navigate(`/dashboard/livetv/tuner?id=${tunerHost.Id}`);
|
||||
}, [ navigate, tunerHost ]);
|
||||
|
||||
const onDelete = useCallback(() => {
|
||||
if (tunerHost.Id) {
|
||||
deleteTuner.mutate({
|
||||
id: tunerHost.Id
|
||||
}, {
|
||||
onSettled: () => {
|
||||
setIsConfirmDeleteDialogOpen(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [ deleteTuner, tunerHost ]);
|
||||
|
||||
const showDeleteDialog = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
setIsConfirmDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const onDeleteDialogClose = useCallback(() => {
|
||||
setIsConfirmDeleteDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const onActionClick = useCallback(() => {
|
||||
setAnchorEl(actionRef.current);
|
||||
setIsMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const onMenuClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
open={isConfirmDeleteDialogOpen}
|
||||
title={globalize.translate('HeaderDeleteDevice')}
|
||||
text={globalize.translate('MessageConfirmDeleteTunerDevice')}
|
||||
onCancel={onDeleteDialogClose}
|
||||
onConfirm={onDelete}
|
||||
confirmButtonColor='error'
|
||||
confirmButtonText={globalize.translate('Delete')}
|
||||
/>
|
||||
|
||||
<BaseCard
|
||||
title={tunerHost.FriendlyName || getTunerName(tunerHost.Type) || ''}
|
||||
text={tunerHost.Url || ''}
|
||||
icon={<DvrIcon sx={{ fontSize: 70 }} />}
|
||||
action={true}
|
||||
actionRef={actionRef}
|
||||
onActionClick={onActionClick}
|
||||
onClick={navigateToEditPage}
|
||||
/>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={onMenuClose}
|
||||
>
|
||||
<MenuItem onClick={navigateToEditPage}>
|
||||
<ListItemIcon>
|
||||
<EditIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('Edit')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={showDeleteDialog}>
|
||||
<ListItemIcon>
|
||||
<DeleteIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('Delete')}</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TunerDeviceCard;
|
||||
@@ -0,0 +1,10 @@
|
||||
const getProviderConfigurationUrl = (providerId: string) => {
|
||||
switch (providerId?.toLowerCase()) {
|
||||
case 'xmltv':
|
||||
return '/dashboard/livetv/guide?type=xmltv';
|
||||
case 'schedulesdirect':
|
||||
return '/dashboard/livetv/guide?type=schedulesdirect';
|
||||
}
|
||||
};
|
||||
|
||||
export default getProviderConfigurationUrl;
|
||||
12
src/apps/dashboard/features/livetv/utils/getProviderName.ts
Normal file
12
src/apps/dashboard/features/livetv/utils/getProviderName.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
const getProviderName = (providerId: string | null | undefined) => {
|
||||
switch (providerId?.toLowerCase()) {
|
||||
case 'schedulesdirect':
|
||||
return 'Schedules Direct';
|
||||
case 'xmltv':
|
||||
return 'XMLTV';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
export default getProviderName;
|
||||
16
src/apps/dashboard/features/livetv/utils/getTunerName.ts
Normal file
16
src/apps/dashboard/features/livetv/utils/getTunerName.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
const getTunerName = (providerId: string | null | undefined) => {
|
||||
switch (providerId?.toLowerCase()) {
|
||||
case 'm3u':
|
||||
return 'M3U';
|
||||
case 'hdhomerun':
|
||||
return 'HDHomeRun';
|
||||
case 'hauppauge':
|
||||
return 'Hauppauge';
|
||||
case 'satip':
|
||||
return 'DVB';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
export default getTunerName;
|
||||
@@ -12,7 +12,13 @@ const fetchServerLog = async (
|
||||
const response = await getSystemApi(api).getLogFile({ name }, options);
|
||||
|
||||
// FIXME: TypeScript SDK thinks it is returning a File but in reality it is a string
|
||||
return response.data as never as string;
|
||||
const data = response.data as never as string | object;
|
||||
|
||||
if (typeof data === 'object') {
|
||||
return JSON.stringify(data, null, 2);
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
};
|
||||
export const useServerLog = (name: string) => {
|
||||
const { api } = useApi();
|
||||
|
||||
119
src/apps/dashboard/features/plugins/api/usePluginDetails.ts
Normal file
119
src/apps/dashboard/features/plugins/api/usePluginDetails.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import { PluginCategory } from '../constants/pluginCategory';
|
||||
import type { PluginDetails } from '../types/PluginDetails';
|
||||
|
||||
import { findBestConfigurationPage } from './configurationPage';
|
||||
import { findBestPluginInfo } from './pluginInfo';
|
||||
import { useConfigurationPages } from './useConfigurationPages';
|
||||
import { usePackages } from './usePackages';
|
||||
import { usePlugins } from './usePlugins';
|
||||
|
||||
export const usePluginDetails = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
const {
|
||||
data: configurationPages,
|
||||
isError: isConfigurationPagesError,
|
||||
isPending: isConfigurationPagesPending
|
||||
} = useConfigurationPages();
|
||||
|
||||
const {
|
||||
data: packages,
|
||||
isError: isPackagesError,
|
||||
isPending: isPackagesPending
|
||||
} = usePackages();
|
||||
|
||||
const {
|
||||
data: plugins,
|
||||
isError: isPluginsError,
|
||||
isPending: isPluginsPending
|
||||
} = usePlugins();
|
||||
|
||||
const pluginDetails = useMemo<PluginDetails[]>(() => {
|
||||
if (!isPackagesPending && !isPluginsPending) {
|
||||
const pluginIds = new Set<string>();
|
||||
packages?.forEach(({ guid }) => {
|
||||
if (guid) pluginIds.add(guid);
|
||||
});
|
||||
plugins?.forEach(({ Id }) => {
|
||||
if (Id) pluginIds.add(Id);
|
||||
});
|
||||
|
||||
return Array.from(pluginIds)
|
||||
.map(id => {
|
||||
const packageInfo = packages?.find(pkg => pkg.guid === id);
|
||||
const pluginInfo = findBestPluginInfo(id, plugins);
|
||||
|
||||
let version;
|
||||
if (pluginInfo) {
|
||||
// Find the installed version
|
||||
const repoVersion = packageInfo?.versions?.find(v => v.version === pluginInfo.Version);
|
||||
version = repoVersion || {
|
||||
version: pluginInfo.Version,
|
||||
VersionNumber: pluginInfo.Version
|
||||
};
|
||||
} else {
|
||||
// Use the latest version
|
||||
version = packageInfo?.versions?.[0];
|
||||
}
|
||||
|
||||
let imageUrl;
|
||||
if (pluginInfo?.HasImage) {
|
||||
imageUrl = api?.getUri(`/Plugins/${pluginInfo.Id}/${pluginInfo.Version}/Image`);
|
||||
}
|
||||
|
||||
let category = packageInfo?.category;
|
||||
if (!packageInfo) {
|
||||
switch (id) {
|
||||
case 'a629c0dafac54c7e931a7174223f14c8': // AudioDB
|
||||
case '8c95c4d2e50c4fb0a4f36c06ff0f9a1a': // MusicBrainz
|
||||
category = PluginCategory.Music;
|
||||
break;
|
||||
case 'a628c0dafac54c7e9d1a7134223f14c8': // OMDb
|
||||
case 'b8715ed16c4745289ad3f72deb539cd4': // TMDb
|
||||
category = PluginCategory.MoviesAndShows;
|
||||
break;
|
||||
case '872a78491171458da6fb3de3d442ad30': // Studio Images
|
||||
category = PluginCategory.General;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canUninstall: !!pluginInfo?.CanUninstall,
|
||||
category,
|
||||
description: pluginInfo?.Description || packageInfo?.description || packageInfo?.overview,
|
||||
id,
|
||||
imageUrl: imageUrl || packageInfo?.imageUrl || undefined,
|
||||
isEnabled: pluginInfo?.Status !== PluginStatus.Disabled,
|
||||
name: pluginInfo?.Name || packageInfo?.name,
|
||||
owner: packageInfo?.owner,
|
||||
status: pluginInfo?.Status,
|
||||
configurationPage: findBestConfigurationPage(configurationPages || [], id),
|
||||
version,
|
||||
versions: packageInfo?.versions || []
|
||||
};
|
||||
})
|
||||
.sort(({ name: nameA }, { name: nameB }) => (
|
||||
(nameA || '').localeCompare(nameB || '')
|
||||
));
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [
|
||||
api,
|
||||
configurationPages,
|
||||
isPluginsPending,
|
||||
packages,
|
||||
plugins
|
||||
]);
|
||||
|
||||
return {
|
||||
data: pluginDetails,
|
||||
isError: isConfigurationPagesError || isPackagesError || isPluginsError,
|
||||
isPending: isConfigurationPagesPending || isPackagesPending || isPluginsPending
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { type FC } from 'react';
|
||||
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
interface NoPluginResultsProps {
|
||||
isFiltered: boolean
|
||||
onViewAll: () => void
|
||||
query: string
|
||||
}
|
||||
|
||||
const NoPluginResults: FC<NoPluginResultsProps> = ({
|
||||
isFiltered,
|
||||
onViewAll,
|
||||
query
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
component='div'
|
||||
sx={{
|
||||
marginTop: 2,
|
||||
marginBottom: 1
|
||||
}}
|
||||
>
|
||||
{
|
||||
query ?
|
||||
globalize.translate('SearchResultsEmpty', query) :
|
||||
globalize.translate('NoSubtitleSearchResultsFound')
|
||||
}
|
||||
</Typography>
|
||||
|
||||
{isFiltered && (
|
||||
<Button
|
||||
variant='text'
|
||||
onClick={onViewAll}
|
||||
>
|
||||
{globalize.translate('ViewAllPlugins')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoPluginResults;
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from 'react';
|
||||
import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info';
|
||||
import ExtensionIcon from '@mui/icons-material/Extension';
|
||||
import BaseCard from 'apps/dashboard/components/BaseCard';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
type IProps = {
|
||||
pkg: PackageInfo;
|
||||
};
|
||||
|
||||
const PackageCard = ({ pkg }: IProps) => {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
title={pkg.name}
|
||||
image={pkg.imageUrl}
|
||||
icon={<ExtensionIcon sx={{ width: 80, height: 80 }} />}
|
||||
to={{
|
||||
pathname: `/dashboard/plugins/${pkg.guid}`,
|
||||
search: `?name=${encodeURIComponent(pkg.name || '')}`,
|
||||
hash: location.hash
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PackageCard;
|
||||
@@ -1,171 +1,34 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import type { PluginInfo } from '@jellyfin/sdk/lib/generated-client/models/plugin-info';
|
||||
import globalize from 'lib/globalize';
|
||||
import BaseCard from 'apps/dashboard/components/BaseCard';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import Settings from '@mui/icons-material/Settings';
|
||||
import Delete from '@mui/icons-material/Delete';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
import BlockIcon from '@mui/icons-material/Block';
|
||||
import ExtensionIcon from '@mui/icons-material/Extension';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
|
||||
import type { ConfigurationPageInfo } from '@jellyfin/sdk/lib/generated-client/models/configuration-page-info';
|
||||
import { useEnablePlugin } from '../api/useEnablePlugin';
|
||||
import { useDisablePlugin } from '../api/useDisablePlugin';
|
||||
import { useUninstallPlugin } from '../api/useUninstallPlugin';
|
||||
import ConfirmDialog from 'components/ConfirmDialog';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
interface IProps {
|
||||
plugin: PluginInfo;
|
||||
configurationPage?: ConfigurationPageInfo;
|
||||
import BaseCard from 'apps/dashboard/components/BaseCard';
|
||||
|
||||
import { PluginDetails } from '../types/PluginDetails';
|
||||
|
||||
interface PluginCardProps {
|
||||
plugin: PluginDetails;
|
||||
};
|
||||
|
||||
const PluginCard = ({ plugin, configurationPage }: IProps) => {
|
||||
const PluginCard = ({ plugin }: PluginCardProps) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const actionRef = useRef<HTMLButtonElement | null>(null);
|
||||
const enablePlugin = useEnablePlugin();
|
||||
const disablePlugin = useDisablePlugin();
|
||||
const uninstallPlugin = useUninstallPlugin();
|
||||
const [ anchorEl, setAnchorEl ] = useState<HTMLElement | null>(null);
|
||||
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
|
||||
const [ isUninstallConfirmOpen, setIsUninstallConfirmOpen ] = useState(false);
|
||||
const { api } = useApi();
|
||||
|
||||
const pluginPage = useMemo(() => (
|
||||
{
|
||||
pathname: '/configurationpage',
|
||||
search: `?name=${encodeURIComponent(configurationPage?.Name || '')}`,
|
||||
pathname: `/dashboard/plugins/${plugin.id}`,
|
||||
search: `?name=${encodeURIComponent(plugin.name || '')}`,
|
||||
hash: location.hash
|
||||
}
|
||||
), [ location, configurationPage ]);
|
||||
|
||||
const navigateToPluginSettings = useCallback(() => {
|
||||
navigate(pluginPage);
|
||||
}, [ navigate, pluginPage ]);
|
||||
|
||||
const onEnablePlugin = useCallback(() => {
|
||||
if (plugin.Id && plugin.Version) {
|
||||
enablePlugin.mutate({
|
||||
pluginId: plugin.Id,
|
||||
version: plugin.Version
|
||||
});
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}, [ plugin, enablePlugin ]);
|
||||
|
||||
const onDisablePlugin = useCallback(() => {
|
||||
if (plugin.Id && plugin.Version) {
|
||||
disablePlugin.mutate({
|
||||
pluginId: plugin.Id,
|
||||
version: plugin.Version
|
||||
});
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}, [ plugin, disablePlugin ]);
|
||||
|
||||
const onCloseUninstallConfirmDialog = useCallback(() => {
|
||||
setIsUninstallConfirmOpen(false);
|
||||
}, []);
|
||||
|
||||
const showUninstallConfirmDialog = useCallback(() => {
|
||||
setIsUninstallConfirmOpen(true);
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const onUninstall = useCallback(() => {
|
||||
if (plugin.Id && plugin.Version) {
|
||||
uninstallPlugin.mutate({
|
||||
pluginId: plugin.Id,
|
||||
version: plugin.Version
|
||||
});
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}, [ plugin, uninstallPlugin ]);
|
||||
|
||||
const onMenuClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const onActionClick = useCallback(() => {
|
||||
setAnchorEl(actionRef.current);
|
||||
setIsMenuOpen(true);
|
||||
}, []);
|
||||
), [ location, plugin ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseCard
|
||||
title={plugin.Name}
|
||||
secondaryTitle={plugin.Version}
|
||||
to={pluginPage}
|
||||
text={`${globalize.translate('LabelStatus')} ${plugin.Status}`}
|
||||
image={plugin.HasImage ? api?.getUri(`/Plugins/${plugin.Id}/${plugin.Version}/Image`) : null}
|
||||
icon={<ExtensionIcon sx={{ width: 80, height: 80 }} />}
|
||||
action={true}
|
||||
actionRef={actionRef}
|
||||
onActionClick={onActionClick}
|
||||
/>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={onMenuClose}
|
||||
>
|
||||
{configurationPage && (
|
||||
<MenuItem onClick={navigateToPluginSettings}>
|
||||
<ListItemIcon>
|
||||
<Settings />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('Settings')}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{(plugin.CanUninstall && plugin.Status === PluginStatus.Active) && (
|
||||
<MenuItem onClick={onDisablePlugin}>
|
||||
<ListItemIcon>
|
||||
<BlockIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('DisablePlugin')}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{(plugin.CanUninstall && plugin.Status === PluginStatus.Disabled) && (
|
||||
<MenuItem onClick={onEnablePlugin}>
|
||||
<ListItemIcon>
|
||||
<CheckCircleOutlineIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('EnablePlugin')}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{plugin.CanUninstall && (
|
||||
<MenuItem onClick={showUninstallConfirmDialog}>
|
||||
<ListItemIcon>
|
||||
<Delete />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('ButtonUninstall')}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
<ConfirmDialog
|
||||
open={isUninstallConfirmOpen}
|
||||
title={globalize.translate('HeaderUninstallPlugin')}
|
||||
text={globalize.translate('UninstallPluginConfirmation', plugin.Name || '')}
|
||||
onCancel={onCloseUninstallConfirmDialog}
|
||||
onConfirm={onUninstall}
|
||||
confirmButtonColor='error'
|
||||
confirmButtonText={globalize.translate('ButtonUninstall')}
|
||||
/>
|
||||
</>
|
||||
<BaseCard
|
||||
title={plugin.name}
|
||||
to={pluginPage}
|
||||
text={[plugin.version?.VersionNumber, plugin.status].filter(t => t).join(' ')}
|
||||
image={plugin.imageUrl}
|
||||
icon={<ExtensionIcon sx={{ width: 80, height: 80 }} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ const PluginDetailsTable: FC<PluginDetailsTableProps> = ({
|
||||
<TableCell>
|
||||
{
|
||||
(isRepositoryLoading && <Skeleton />)
|
||||
|| (pluginDetails?.status && pluginDetails?.canUninstall === false
|
||||
&& globalize.translate('LabelBundled')
|
||||
)
|
||||
|| (pluginDetails?.version?.repositoryUrl && (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { PluginCategory } from './pluginCategory';
|
||||
|
||||
/** A mapping of category names used by the plugin repository to translation keys. */
|
||||
export const CATEGORY_LABELS: Record<string, string> = {
|
||||
Administration: 'HeaderAdmin',
|
||||
Anime: 'Anime',
|
||||
Authentication: 'LabelAuthProvider', // Legacy
|
||||
Books: 'Books',
|
||||
Channel: 'Channels', // Unused?
|
||||
General: 'General',
|
||||
LiveTV: 'LiveTV',
|
||||
Metadata: 'LabelMetadata', // Legacy
|
||||
MoviesAndShows: 'MoviesAndShows',
|
||||
Music: 'TabMusic',
|
||||
Subtitles: 'Subtitles',
|
||||
Other: 'Other'
|
||||
export const CATEGORY_LABELS: Record<PluginCategory, string> = {
|
||||
[PluginCategory.Administration]: 'HeaderAdmin',
|
||||
[PluginCategory.General]: 'General',
|
||||
[PluginCategory.Anime]: 'Anime',
|
||||
[PluginCategory.Books]: 'Books',
|
||||
[PluginCategory.LiveTV]: 'LiveTV',
|
||||
[PluginCategory.MoviesAndShows]: 'MoviesAndShows',
|
||||
[PluginCategory.Music]: 'TabMusic',
|
||||
[PluginCategory.Subtitles]: 'Subtitles',
|
||||
[PluginCategory.Other]: 'Other'
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Supported plugin category values. */
|
||||
export enum PluginCategory {
|
||||
Administration = 'Administration',
|
||||
General = 'General',
|
||||
Anime = 'Anime',
|
||||
Books = 'Books',
|
||||
LiveTV = 'LiveTV',
|
||||
MoviesAndShows = 'MoviesAndShows',
|
||||
Music = 'Music',
|
||||
Subtitles = 'Subtitles',
|
||||
Other = 'Other'
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Options for filtering plugins based on the installation status. */
|
||||
export enum PluginStatusOption {
|
||||
All = 'All',
|
||||
Available = 'Available',
|
||||
Installed = 'Installed'
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { ConfigurationPageInfo, PluginStatus, VersionInfo } from '@jellyfin
|
||||
|
||||
export interface PluginDetails {
|
||||
canUninstall: boolean
|
||||
category?: string
|
||||
description?: string
|
||||
id: string
|
||||
imageUrl?: string
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info';
|
||||
|
||||
const getPackageCategories = (packages?: PackageInfo[]) => {
|
||||
if (!packages) return [];
|
||||
|
||||
const categories: string[] = [];
|
||||
|
||||
for (const pkg of packages) {
|
||||
if (pkg.category && !categories.includes(pkg.category)) {
|
||||
categories.push(pkg.category);
|
||||
}
|
||||
}
|
||||
|
||||
return categories.sort((a, b) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
export default getPackageCategories;
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info';
|
||||
|
||||
const getPackagesByCategory = (packages: PackageInfo[] | undefined, category: string) => {
|
||||
if (!packages) return [];
|
||||
|
||||
return packages
|
||||
.filter(pkg => pkg.category === category)
|
||||
.sort((a, b) => {
|
||||
if (a.name && b.name) {
|
||||
return a.name.localeCompare(b.name);
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default getPackagesByCategory;
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
|
||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import dom from 'scripts/dom';
|
||||
import dom from 'utils/dom';
|
||||
|
||||
const getNowPlayingImageUrl = (item: BaseItemDto) => {
|
||||
if (!item.ServerId) return null;
|
||||
|
||||
@@ -164,7 +164,7 @@ const NewTriggerForm: FunctionComponent<IProps> = ({ open, title, onClose, onAdd
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
color='error'
|
||||
variant='text'
|
||||
>{globalize.translate('ButtonCancel')}</Button>
|
||||
<Button type='submit'>{globalize.translate('Add')}</Button>
|
||||
</DialogActions>
|
||||
|
||||
@@ -8,7 +8,15 @@ const TaskProgress: FunctionComponent<TaskProps> = ({ task }: TaskProps) => {
|
||||
const progress = task.CurrentProgressPercentage;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', height: '1.2rem', mr: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '1.2rem',
|
||||
mr: 2,
|
||||
minWidth: '170px'
|
||||
}}
|
||||
>
|
||||
{progress != null ? (
|
||||
<>
|
||||
<Box sx={{ width: '100%', mr: 1 }}>
|
||||
|
||||
21
src/apps/dashboard/features/users/api/useAuthProviders.ts
Normal file
21
src/apps/dashboard/features/users/api/useAuthProviders.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
const fetchAuthProviders = async (api: Api, options?: AxiosRequestConfig) => {
|
||||
const response = await getSessionApi(api).getAuthProviders(options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useAuthProviders = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [ 'AuthProviders' ],
|
||||
queryFn: ({ signal }) => fetchAuthProviders(api!, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
||||
22
src/apps/dashboard/features/users/api/useChannels.ts
Normal file
22
src/apps/dashboard/features/users/api/useChannels.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { getChannelsApi } from '@jellyfin/sdk/lib/utils/api/channels-api';
|
||||
import { ChannelsApiGetChannelsRequest } from '@jellyfin/sdk/lib/generated-client/api/channels-api';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
const fetchChannels = async (api: Api, params?: ChannelsApiGetChannelsRequest, options?: AxiosRequestConfig) => {
|
||||
const response = await getChannelsApi(api).getChannels(params, options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useChannels = (params?: ChannelsApiGetChannelsRequest) => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [ 'Channels' ],
|
||||
queryFn: ({ signal }) => fetchChannels(api!, params, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
||||
15
src/apps/dashboard/features/users/api/useCreateUser.ts
Normal file
15
src/apps/dashboard/features/users/api/useCreateUser.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { UserApiCreateUserByNameRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
|
||||
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
export const useCreateUser = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: UserApiCreateUserByNameRequest) => (
|
||||
getUserApi(api!)
|
||||
.createUserByName(params)
|
||||
)
|
||||
});
|
||||
};
|
||||
22
src/apps/dashboard/features/users/api/useDeleteUser.ts
Normal file
22
src/apps/dashboard/features/users/api/useDeleteUser.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { UserApiDeleteUserRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
|
||||
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { QUERY_KEY } from 'hooks/useUsers';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
export const useDeleteUser = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: UserApiDeleteUserRequest) => (
|
||||
getUserApi(api!)
|
||||
.deleteUser(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { LibraryApiGetMediaFoldersRequest } from '@jellyfin/sdk/lib/generated-client/api/library-api';
|
||||
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
const fetchLibraryMediaFolders = async (api: Api, params?: LibraryApiGetMediaFoldersRequest, options?: AxiosRequestConfig) => {
|
||||
const response = await getLibraryApi(api).getMediaFolders(params, options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useLibraryMediaFolders = (params?: LibraryApiGetMediaFoldersRequest) => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['LibraryMediaFolders'],
|
||||
queryFn: ({ signal }) => fetchLibraryMediaFolders(api!, params, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
||||
22
src/apps/dashboard/features/users/api/useNetworkConfig.ts
Normal file
22
src/apps/dashboard/features/users/api/useNetworkConfig.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { getConfigurationApi } from '@jellyfin/sdk/lib/utils/api/configuration-api';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import type { NetworkConfiguration } from '@jellyfin/sdk/lib/generated-client/models/network-configuration';
|
||||
|
||||
const fetchNetworkConfig = async (api: Api, options?: AxiosRequestConfig) => {
|
||||
const response = await getConfigurationApi(api).getNamedConfiguration({ key: 'network' }, options);
|
||||
|
||||
return response.data as NetworkConfiguration;
|
||||
};
|
||||
|
||||
export const useNetworkConfig = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [ 'NetConfig' ],
|
||||
queryFn: ({ signal }) => fetchNetworkConfig(api!, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
||||
21
src/apps/dashboard/features/users/api/useParentalRatings.ts
Normal file
21
src/apps/dashboard/features/users/api/useParentalRatings.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { getLocalizationApi } from '@jellyfin/sdk/lib/utils/api/localization-api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
const fetchParentalRatings = async (api: Api, options?: AxiosRequestConfig) => {
|
||||
const response = await getLocalizationApi(api).getParentalRatings(options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useParentalRatings = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['ParentalRatings'],
|
||||
queryFn: ({ signal }) => fetchParentalRatings(api!, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
const fetchPasswordResetProviders = async (api: Api, options?: AxiosRequestConfig) => {
|
||||
const response = await getSessionApi(api).getPasswordResetProviders(options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const usePasswordResetProviders = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [ 'PasswordResetProviders' ],
|
||||
queryFn: ({ signal }) => fetchPasswordResetProviders(api!, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
};
|
||||
22
src/apps/dashboard/features/users/api/useUpdateUser.ts
Normal file
22
src/apps/dashboard/features/users/api/useUpdateUser.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { UserApiUpdateUserRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
|
||||
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { QUERY_KEY } from './useUser';
|
||||
|
||||
export const useUpdateUser = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: UserApiUpdateUserRequest) => (
|
||||
getUserApi(api!)
|
||||
.updateUser(params)
|
||||
),
|
||||
onSuccess: (_, params) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [QUERY_KEY, params.userId]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
23
src/apps/dashboard/features/users/api/useUpdateUserPolicy.ts
Normal file
23
src/apps/dashboard/features/users/api/useUpdateUserPolicy.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { UserApiUpdateUserPolicyRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
|
||||
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { QUERY_KEY } from './useUser';
|
||||
|
||||
export const useUpdateUserPolicy = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: UserApiUpdateUserPolicyRequest) => (
|
||||
|
||||
getUserApi(api!)
|
||||
.updateUserPolicy(params)
|
||||
),
|
||||
onSuccess: (_, params) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [QUERY_KEY, params.userId]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
24
src/apps/dashboard/features/users/api/useUser.ts
Normal file
24
src/apps/dashboard/features/users/api/useUser.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Api } from '@jellyfin/sdk';
|
||||
import { UserApiGetUserByIdRequest } from '@jellyfin/sdk/lib/generated-client/api/user-api';
|
||||
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
export const QUERY_KEY = 'User';
|
||||
|
||||
const fetchUser = async (api: Api, params: UserApiGetUserByIdRequest, options?: AxiosRequestConfig) => {
|
||||
const response = await getUserApi(api).getUserById(params, options);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useUser = (params?: UserApiGetUserByIdRequest) => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [ QUERY_KEY, params?.userId ],
|
||||
queryFn: ({ signal }) => fetchUser(api!, params!, { signal }),
|
||||
enabled: !!api && !!params
|
||||
});
|
||||
};
|
||||
@@ -9,9 +9,11 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
||||
{ path: 'devices', type: AppType.Dashboard },
|
||||
{ path: 'settings', type: AppType.Dashboard },
|
||||
{ path: 'keys', type: AppType.Dashboard },
|
||||
{ path: 'libraries', type: AppType.Dashboard },
|
||||
{ path: 'libraries/display', type: AppType.Dashboard },
|
||||
{ path: 'libraries/metadata', type: AppType.Dashboard },
|
||||
{ path: 'libraries/nfo', type: AppType.Dashboard },
|
||||
{ path: 'livetv', type: AppType.Dashboard },
|
||||
{ path: 'livetv/recordings', type: AppType.Dashboard },
|
||||
{ path: 'logs', type: AppType.Dashboard },
|
||||
{ path: 'logs/:file', page: 'logs/file', type: AppType.Dashboard },
|
||||
@@ -21,7 +23,6 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
||||
{ path: 'playback/trickplay', type: AppType.Dashboard },
|
||||
{ path: 'plugins', type: AppType.Dashboard },
|
||||
{ path: 'plugins/:pluginId', page: 'plugins/plugin', type: AppType.Dashboard },
|
||||
{ path: 'plugins/catalog', type: AppType.Dashboard },
|
||||
{ path: 'plugins/repositories', type: AppType.Dashboard },
|
||||
{ path: 'tasks', type: AppType.Dashboard },
|
||||
{ path: 'tasks/:id', page: 'tasks/task', type: AppType.Dashboard },
|
||||
|
||||
@@ -9,13 +9,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
||||
controller: 'networking',
|
||||
view: 'networking.html'
|
||||
}
|
||||
}, {
|
||||
path: 'libraries',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'library',
|
||||
view: 'library.html'
|
||||
}
|
||||
}, {
|
||||
path: 'livetv/guide',
|
||||
pageProps: {
|
||||
@@ -23,13 +16,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
||||
controller: 'livetvguideprovider',
|
||||
view: 'livetvguideprovider.html'
|
||||
}
|
||||
}, {
|
||||
path: 'livetv',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'livetvstatus',
|
||||
view: 'livetvstatus.html'
|
||||
}
|
||||
}, {
|
||||
path: 'livetv/tuner',
|
||||
pageProps: {
|
||||
|
||||
@@ -2,9 +2,10 @@ import parseISO from 'date-fns/parseISO';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
|
||||
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
|
||||
import { type MRT_ColumnDef, type MRT_Theme, useMaterialReactTable } from 'material-react-table';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
|
||||
@@ -53,6 +54,8 @@ export const Component = () => {
|
||||
|
||||
const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails();
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const UserCell = getUserCell(users);
|
||||
|
||||
const activityParams = useMemo(() => ({
|
||||
@@ -156,8 +159,15 @@ export const Component = () => {
|
||||
}
|
||||
}, [ activityView, searchParams, setSearchParams ]);
|
||||
|
||||
// NOTE: We need to provide a custom theme due to a MRT bug causing the initial theme to always be used
|
||||
// https://github.com/KevinVandy/material-react-table/issues/1429
|
||||
const mrtTheme = useMemo<Partial<MRT_Theme>>(() => ({
|
||||
baseBackgroundColor: theme.palette.background.paper
|
||||
}), [ theme ]);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
...DEFAULT_TABLE_OPTIONS,
|
||||
mrtTheme,
|
||||
|
||||
columns,
|
||||
data: logEntries,
|
||||
|
||||
@@ -102,7 +102,7 @@ export const Component = () => {
|
||||
}).catch(() => {
|
||||
// Server is still down
|
||||
});
|
||||
}, 5000);
|
||||
}, 45000);
|
||||
|
||||
return () => {
|
||||
clearInterval(serverCheckInterval);
|
||||
|
||||
@@ -292,6 +292,7 @@ export const Component = () => {
|
||||
name={BrandingOption.CustomCss}
|
||||
label={globalize.translate('LabelCustomCss')}
|
||||
helperText={globalize.translate('LabelCustomCssHelp')}
|
||||
spellCheck={false}
|
||||
value={brandingOptions?.CustomCss}
|
||||
onChange={setBrandingOption}
|
||||
slotProps={{
|
||||
|
||||
@@ -4,9 +4,10 @@ import Edit from '@mui/icons-material/Edit';
|
||||
import Box from '@mui/material/Box/Box';
|
||||
import Button from '@mui/material/Button/Button';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import Tooltip from '@mui/material/Tooltip/Tooltip';
|
||||
import parseISO from 'date-fns/parseISO';
|
||||
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
|
||||
import { type MRT_ColumnDef, type MRT_Theme, useMaterialReactTable } from 'material-react-table';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
|
||||
@@ -41,6 +42,7 @@ export const Component = () => {
|
||||
data?.Items || []
|
||||
), [ data ]);
|
||||
const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails();
|
||||
const theme = useTheme();
|
||||
|
||||
const [ isDeleteConfirmOpen, setIsDeleteConfirmOpen ] = useState(false);
|
||||
const [ isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen ] = useState(false);
|
||||
@@ -137,8 +139,15 @@ export const Component = () => {
|
||||
}
|
||||
], [ UserCell, userNames ]);
|
||||
|
||||
// NOTE: We need to provide a custom theme due to a MRT bug causing the initial theme to always be used
|
||||
// https://github.com/KevinVandy/material-react-table/issues/1429
|
||||
const mrtTheme = useMemo<Partial<MRT_Theme>>(() => ({
|
||||
baseBackgroundColor: theme.palette.background.paper
|
||||
}), [ theme ]);
|
||||
|
||||
const mrTable = useMaterialReactTable({
|
||||
...DEFAULT_TABLE_OPTIONS,
|
||||
mrtTheme,
|
||||
|
||||
columns,
|
||||
data: devices,
|
||||
@@ -184,16 +193,25 @@ export const Component = () => {
|
||||
positionActionsColumn: 'last',
|
||||
displayColumnDefOptions: {
|
||||
'mrt-row-actions': {
|
||||
header: ''
|
||||
header: '',
|
||||
size: 100
|
||||
}
|
||||
},
|
||||
renderRowActions: ({ row, table }) => {
|
||||
const isDeletable = api && row.original.Id && api.deviceInfo.id === row.original.Id;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
'&&': {
|
||||
backgroundColor: 'transparent !important'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip title={globalize.translate('Edit')}>
|
||||
<IconButton
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => table.setEditingRow(row)}
|
||||
>
|
||||
<Edit />
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import Box from '@mui/material/Box';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import ServerPathWidget from '../components/widgets/ServerPathWidget';
|
||||
import ServerInfoWidget from '../components/widgets/ServerInfoWidget';
|
||||
import ActivityLogWidget from '../components/widgets/ActivityLogWidget';
|
||||
import AlertsLogWidget from '../components/widgets/AlertsLogWidget';
|
||||
import useTheme from '@mui/material/styles/useTheme';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import useShutdownServer from '../features/system/api/useShutdownServer';
|
||||
import useRestartServer from '../features/system/api/useRestartServer';
|
||||
@@ -18,11 +16,9 @@ import RunningTasksWidget from '../components/widgets/RunningTasksWidget';
|
||||
import DevicesWidget from '../components/widgets/DevicesWidget';
|
||||
import { useStartTask } from '../features/tasks/api/useStartTask';
|
||||
import ItemCountsWidget from '../components/widgets/ItemCountsWidget';
|
||||
import { TaskState } from '@jellyfin/sdk/lib/generated-client/models/task-state';
|
||||
|
||||
export const Component = () => {
|
||||
const theme = useTheme();
|
||||
const isMedium = useMediaQuery(theme.breakpoints.only('md'));
|
||||
const isExtraLarge = useMediaQuery(theme.breakpoints.only('xl'));
|
||||
const [ isRestartConfirmDialogOpen, setIsRestartConfirmDialogOpen ] = useState(false);
|
||||
const [ isShutdownConfirmDialogOpen, setIsShutdownConfirmDialogOpen ] = useState(false);
|
||||
const startTask = useStartTask();
|
||||
@@ -31,6 +27,10 @@ export const Component = () => {
|
||||
|
||||
const { data: tasks } = useLiveTasks({ isHidden: false });
|
||||
|
||||
const librariesTask = useMemo(() => (
|
||||
tasks?.find((value) => value.Key === 'RefreshLibrary')
|
||||
), [ tasks ]);
|
||||
|
||||
const promptRestart = useCallback(() => {
|
||||
setIsRestartConfirmDialogOpen(true);
|
||||
}, []);
|
||||
@@ -93,36 +93,28 @@ export const Component = () => {
|
||||
/>
|
||||
<Box className='content-primary'>
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, md: 12, lg: 8, xl: 6 }}>
|
||||
<Grid item xs={12} md={7} lg={7} xl={6}>
|
||||
<Stack spacing={3}>
|
||||
<ServerInfoWidget
|
||||
onScanLibrariesClick={onScanLibraries}
|
||||
onRestartClick={promptRestart}
|
||||
onShutdownClick={promptShutdown}
|
||||
isScanning={librariesTask?.State !== TaskState.Idle}
|
||||
/>
|
||||
<ItemCountsWidget />
|
||||
<RunningTasksWidget tasks={tasks} />
|
||||
<DevicesWidget />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6, lg: 4, xl: 3 }}>
|
||||
<Grid item xs={12} md={5} lg={5} xl={3}>
|
||||
<ActivityLogWidget />
|
||||
</Grid>
|
||||
{isMedium || isExtraLarge ? (
|
||||
<Grid size={{ md: 6, xl: 3 }}>
|
||||
<Stack spacing={3}>
|
||||
<AlertsLogWidget />
|
||||
<ServerPathWidget />
|
||||
</Stack>
|
||||
</Grid>
|
||||
) : (
|
||||
<Grid size={12}>
|
||||
<Stack spacing={3}>
|
||||
<AlertsLogWidget />
|
||||
<ServerPathWidget />
|
||||
</Stack>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={12} md={6} lg={12} xl={3}>
|
||||
<Stack spacing={3}>
|
||||
<AlertsLogWidget />
|
||||
<ServerPathWidget />
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Page>
|
||||
|
||||
@@ -2,31 +2,34 @@ import type { AuthenticationInfo } from '@jellyfin/sdk/lib/generated-client/mode
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import parseISO from 'date-fns/parseISO';
|
||||
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { type MRT_ColumnDef, type MRT_Theme, useMaterialReactTable } from 'material-react-table';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
|
||||
import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/table/TablePage';
|
||||
import { useApiKeys } from 'apps/dashboard/features/keys/api/useApiKeys';
|
||||
import { useRevokeKey } from 'apps/dashboard/features/keys/api/useRevokeKey';
|
||||
import { useCreateKey } from 'apps/dashboard/features/keys/api/useCreateKey';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
import prompt from 'components/prompt/prompt';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import globalize from 'lib/globalize';
|
||||
import InputDialog from 'components/InputDialog';
|
||||
import ConfirmDialog from 'components/ConfirmDialog';
|
||||
|
||||
export const Component = () => {
|
||||
const { api } = useApi();
|
||||
const [ isCreateApiKeyPromptOpen, setIsCreateApiKeyPromptOpen ] = useState(false);
|
||||
const [ isConfirmDeleteOpen, setIsConfirmDeleteOpen ] = useState(false);
|
||||
const [ apiKeyToDelete, setApiKeyToDelete ] = useState('');
|
||||
const { data, isLoading } = useApiKeys();
|
||||
const keys = useMemo(() => (
|
||||
data?.Items || []
|
||||
), [ data ]);
|
||||
const revokeKey = useRevokeKey();
|
||||
const createKey = useCreateKey();
|
||||
const theme = useTheme();
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<AuthenticationInfo>[]>(() => [
|
||||
{
|
||||
@@ -49,8 +52,15 @@ export const Component = () => {
|
||||
}
|
||||
], []);
|
||||
|
||||
// NOTE: We need to provide a custom theme due to a MRT bug causing the initial theme to always be used
|
||||
// https://github.com/KevinVandy/material-react-table/issues/1429
|
||||
const mrtTheme = useMemo<Partial<MRT_Theme>>(() => ({
|
||||
baseBackgroundColor: theme.palette.background.paper
|
||||
}), [ theme ]);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
...DEFAULT_TABLE_OPTIONS,
|
||||
mrtTheme,
|
||||
|
||||
columns,
|
||||
data: keys,
|
||||
@@ -96,41 +106,72 @@ export const Component = () => {
|
||||
});
|
||||
|
||||
const onRevokeKey = useCallback((accessToken: string) => {
|
||||
if (!api) return;
|
||||
|
||||
confirm(globalize.translate('MessageConfirmRevokeApiKey'), globalize.translate('HeaderConfirmRevokeApiKey')).then(function () {
|
||||
revokeKey.mutate({
|
||||
key: accessToken
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('[apikeys] failed to show confirmation dialog', err);
|
||||
});
|
||||
}, [api, revokeKey]);
|
||||
setApiKeyToDelete(accessToken);
|
||||
setIsConfirmDeleteOpen(true);
|
||||
}, []);
|
||||
|
||||
const showNewKeyPopup = useCallback(() => {
|
||||
if (!api) return;
|
||||
setIsCreateApiKeyPromptOpen(true);
|
||||
}, []);
|
||||
|
||||
prompt({
|
||||
title: globalize.translate('HeaderNewApiKey'),
|
||||
label: globalize.translate('LabelAppName'),
|
||||
description: globalize.translate('LabelAppNameExample')
|
||||
}).then((value) => {
|
||||
createKey.mutate({
|
||||
app: value
|
||||
});
|
||||
}).catch(() => {
|
||||
// popup closed
|
||||
const onCreateApiKeyPromptClose = useCallback(() => {
|
||||
setIsCreateApiKeyPromptOpen(false);
|
||||
}, []);
|
||||
|
||||
const onConfirmDelete = useCallback(() => {
|
||||
revokeKey.mutate({
|
||||
key: apiKeyToDelete
|
||||
}, {
|
||||
onSettled: () => {
|
||||
setApiKeyToDelete('');
|
||||
setIsConfirmDeleteOpen(false);
|
||||
}
|
||||
});
|
||||
}, [api, createKey]);
|
||||
}, [ revokeKey, apiKeyToDelete ]);
|
||||
|
||||
const onConfirmDeleteCancel = useCallback(() => {
|
||||
setApiKeyToDelete('');
|
||||
setIsConfirmDeleteOpen(false);
|
||||
}, []);
|
||||
|
||||
const onConfirmCreate = useCallback((name: string) => {
|
||||
createKey.mutate({
|
||||
app: name
|
||||
}, {
|
||||
onSettled: () => {
|
||||
setIsCreateApiKeyPromptOpen(false);
|
||||
}
|
||||
});
|
||||
}, [ createKey ]);
|
||||
|
||||
return (
|
||||
<TablePage
|
||||
id='apiKeysPage'
|
||||
title={globalize.translate('HeaderApiKeys')}
|
||||
subtitle={globalize.translate('HeaderApiKeysHelp')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
table={table}
|
||||
/>
|
||||
<>
|
||||
<ConfirmDialog
|
||||
open={isConfirmDeleteOpen}
|
||||
title={globalize.translate('HeaderConfirmRevokeApiKey')}
|
||||
text={globalize.translate('MessageConfirmRevokeApiKey')}
|
||||
confirmButtonColor='error'
|
||||
confirmButtonText={globalize.translate('Delete')}
|
||||
onConfirm={onConfirmDelete}
|
||||
onCancel={onConfirmDeleteCancel}
|
||||
/>
|
||||
<InputDialog
|
||||
open={isCreateApiKeyPromptOpen}
|
||||
title={globalize.translate('HeaderNewApiKey')}
|
||||
label={globalize.translate('LabelAppName')}
|
||||
helperText={globalize.translate('LabelAppNameExample')}
|
||||
confirmButtonText={globalize.translate('Create')}
|
||||
onConfirm={onConfirmCreate}
|
||||
onClose={onCreateApiKeyPromptClose}
|
||||
/>
|
||||
<TablePage
|
||||
id='apiKeysPage'
|
||||
title={globalize.translate('HeaderApiKeys')}
|
||||
subtitle={globalize.translate('HeaderApiKeysHelp')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
table={table}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
108
src/apps/dashboard/routes/libraries/index.tsx
Normal file
108
src/apps/dashboard/routes/libraries/index.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { useVirtualFolders } from 'apps/dashboard/features/libraries/api/useVirtualFolders';
|
||||
import useLiveTasks from 'apps/dashboard/features/tasks/hooks/useLiveTasks';
|
||||
import { useStartTask } from 'apps/dashboard/features/tasks/api/useStartTask';
|
||||
import TaskProgress from 'apps/dashboard/features/tasks/components/TaskProgress';
|
||||
import { TaskState } from '@jellyfin/sdk/lib/generated-client/models/task-state';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import LibraryCard from 'apps/dashboard/features/libraries/components/LibraryCard';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import MediaLibraryCreator from 'components/mediaLibraryCreator/mediaLibraryCreator';
|
||||
import getCollectionTypeOptions from 'apps/dashboard/features/libraries/utils/collectionTypeOptions';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import Add from '@mui/icons-material/Add';
|
||||
|
||||
export const Component = () => {
|
||||
const { data: virtualFolders, isPending: isVirtualFoldersPending } = useVirtualFolders();
|
||||
const startTask = useStartTask();
|
||||
const { data: tasks, isPending: isLiveTasksPending } = useLiveTasks({ isHidden: false });
|
||||
|
||||
const librariesTask = useMemo(() => (
|
||||
tasks?.find((value) => value.Key === 'RefreshLibrary')
|
||||
), [ tasks ]);
|
||||
|
||||
const showMediaLibraryCreator = useCallback(() => {
|
||||
const mediaLibraryCreator = new MediaLibraryCreator({
|
||||
collectionTypeOptions: getCollectionTypeOptions(),
|
||||
refresh: true
|
||||
}) as Promise<boolean>;
|
||||
|
||||
void mediaLibraryCreator.then((hasChanges: boolean) => {
|
||||
if (hasChanges) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['VirtualFolders']
|
||||
});
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onScanLibraries = useCallback(() => {
|
||||
if (librariesTask?.Id) {
|
||||
startTask.mutate({
|
||||
taskId: librariesTask.Id
|
||||
});
|
||||
}
|
||||
}, [ startTask, librariesTask ]);
|
||||
|
||||
if (isVirtualFoldersPending || isLiveTasksPending) return <Loading />;
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='mediaLibraryPage'
|
||||
title={globalize.translate('HeaderLibraries')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
<Stack spacing={3} mt={2}>
|
||||
<Stack direction='row' alignItems={'center'} spacing={1.5}>
|
||||
<Button
|
||||
startIcon={<Add />}
|
||||
onClick={showMediaLibraryCreator}
|
||||
>
|
||||
{globalize.translate('ButtonAddMediaLibrary')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onScanLibraries}
|
||||
startIcon={<RefreshIcon />}
|
||||
loading={librariesTask && librariesTask.State !== TaskState.Idle}
|
||||
loadingPosition='start'
|
||||
variant='outlined'
|
||||
>
|
||||
{globalize.translate('ButtonScanAllLibraries')}
|
||||
</Button>
|
||||
{(librariesTask && librariesTask.State == TaskState.Running) && (
|
||||
<TaskProgress task={librariesTask} />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Grid container spacing={2}>
|
||||
{virtualFolders?.map(virtualFolder => (
|
||||
<Grid
|
||||
key={virtualFolder?.ItemId}
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={3}
|
||||
lg={2.4}
|
||||
>
|
||||
<LibraryCard
|
||||
virtualFolder={virtualFolder}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'LibrariesPage';
|
||||
179
src/apps/dashboard/routes/livetv/index.tsx
Normal file
179
src/apps/dashboard/routes/livetv/index.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Page from 'components/Page';
|
||||
import { useNamedConfiguration } from 'hooks/useNamedConfiguration';
|
||||
import type { LiveTvOptions } from '@jellyfin/sdk/lib/generated-client/models/live-tv-options';
|
||||
import globalize from 'lib/globalize';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import TunerDeviceCard from 'apps/dashboard/features/livetv/components/TunerDeviceCard';
|
||||
import useLiveTasks from 'apps/dashboard/features/tasks/hooks/useLiveTasks';
|
||||
import Button from '@mui/material/Button';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useStartTask } from 'apps/dashboard/features/tasks/api/useStartTask';
|
||||
import { TaskState } from '@jellyfin/sdk/lib/generated-client/models/task-state';
|
||||
import TaskProgress from 'apps/dashboard/features/tasks/components/TaskProgress';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import List from '@mui/material/List';
|
||||
import Provider from 'apps/dashboard/features/livetv/components/Provider';
|
||||
import Grid from '@mui/material/Grid';
|
||||
|
||||
const CONFIG_KEY = 'livetv';
|
||||
|
||||
export const Component = () => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
data: config,
|
||||
isPending: isConfigPending,
|
||||
isError: isConfigError
|
||||
} = useNamedConfiguration<LiveTvOptions>(CONFIG_KEY);
|
||||
const {
|
||||
data: tasks,
|
||||
isPending: isTasksPending,
|
||||
isError: isTasksError
|
||||
} = useLiveTasks({ isHidden: false });
|
||||
const providerButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [ anchorEl, setAnchorEl ] = useState<HTMLButtonElement | null>(null);
|
||||
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
|
||||
const startTask = useStartTask();
|
||||
|
||||
const navigateToSchedulesDirect = useCallback(() => {
|
||||
navigate('/dashboard/livetv/guide?type=schedulesdirect');
|
||||
}, [ navigate ]);
|
||||
|
||||
const navigateToXMLTV = useCallback(() => {
|
||||
navigate('/dashboard/livetv/guide?type=xmltv');
|
||||
}, [ navigate ]);
|
||||
|
||||
const showProviderMenu = useCallback(() => {
|
||||
setAnchorEl(providerButtonRef.current);
|
||||
setIsMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const onMenuClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
setIsMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const refreshGuideTask = useMemo(() => (
|
||||
tasks?.find((value) => value.Key === 'RefreshGuide')
|
||||
), [ tasks ]);
|
||||
|
||||
const refreshGuideData = useCallback(() => {
|
||||
if (refreshGuideTask?.Id) {
|
||||
startTask.mutate({
|
||||
taskId: refreshGuideTask.Id
|
||||
});
|
||||
}
|
||||
}, [ startTask, refreshGuideTask ]);
|
||||
|
||||
if (isConfigPending || isTasksPending) return <Loading />;
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='liveTvStatusPage'
|
||||
title={globalize.translate('LiveTV')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
{(isConfigError || isTasksError) ? (
|
||||
<Alert severity='error'>{globalize.translate('HeaderError')}</Alert>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h2'>{globalize.translate('HeaderTunerDevices')}</Typography>
|
||||
|
||||
<Button
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
startIcon={<AddIcon />}
|
||||
component={Link}
|
||||
to='/dashboard/livetv/tuner'
|
||||
>
|
||||
{globalize.translate('ButtonAddTunerDevice')}
|
||||
</Button>
|
||||
|
||||
<Box>
|
||||
<Grid container spacing={2}>
|
||||
{config.TunerHosts?.map(tunerHost => (
|
||||
<Grid
|
||||
key={tunerHost.Id}
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={3}
|
||||
lg={2.4}
|
||||
>
|
||||
<TunerDeviceCard
|
||||
key={tunerHost.Id}
|
||||
tunerHost={tunerHost}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Typography variant='h2'>{globalize.translate('HeaderGuideProviders')}</Typography>
|
||||
|
||||
<Stack sx={{ alignSelf: 'flex-start' }} spacing={2}>
|
||||
<Stack direction='row' spacing={1.5}>
|
||||
<Button
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
startIcon={<AddIcon />}
|
||||
onClick={showProviderMenu}
|
||||
ref={providerButtonRef}
|
||||
>
|
||||
{globalize.translate('ButtonAddProvider')}
|
||||
</Button>
|
||||
<Button
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
startIcon={<RefreshIcon />}
|
||||
variant='outlined'
|
||||
onClick={refreshGuideData}
|
||||
loading={refreshGuideTask && refreshGuideTask.State === TaskState.Running}
|
||||
loadingPosition='start'
|
||||
>
|
||||
{globalize.translate('ButtonRefreshGuideData')}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{(refreshGuideTask && refreshGuideTask.State === TaskState.Running) && (
|
||||
<TaskProgress task={refreshGuideTask} />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={onMenuClose}
|
||||
>
|
||||
<MenuItem onClick={navigateToSchedulesDirect}>
|
||||
<ListItemText>Schedules Direct</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={navigateToXMLTV}>
|
||||
<ListItemText>XMLTV</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{(config.ListingProviders && config.ListingProviders?.length > 0) && (
|
||||
<List sx={{ backgroundColor: 'background.paper' }}>
|
||||
{config.ListingProviders?.map(provider => (
|
||||
<Provider
|
||||
key={provider.Id}
|
||||
provider={provider}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'LiveTvPage';
|
||||
@@ -1,6 +1,6 @@
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Page from 'components/Page';
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useServerLog } from 'apps/dashboard/features/logs/api/useServerLog';
|
||||
import Alert from '@mui/material/Alert';
|
||||
@@ -13,8 +13,8 @@ import Typography from '@mui/material/Typography';
|
||||
import ContentCopy from '@mui/icons-material/ContentCopy';
|
||||
import FileDownload from '@mui/icons-material/FileDownload';
|
||||
import globalize from 'lib/globalize';
|
||||
import toast from 'components/toast/toast';
|
||||
import { copy } from 'scripts/clipboard';
|
||||
import Toast from 'apps/dashboard/components/Toast';
|
||||
|
||||
export const Component = () => {
|
||||
const { file: fileName } = useParams();
|
||||
@@ -24,13 +24,18 @@ export const Component = () => {
|
||||
data: log,
|
||||
refetch
|
||||
} = useServerLog(fileName ?? '');
|
||||
const [ isCopiedToastOpen, setIsCopiedToastOpen ] = useState(false);
|
||||
|
||||
const retry = useCallback(() => refetch(), [refetch]);
|
||||
|
||||
const handleToastClose = useCallback(() => {
|
||||
setIsCopiedToastOpen(false);
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (log) {
|
||||
await copy(log);
|
||||
toast({ text: globalize.translate('CopyLogSuccess') });
|
||||
setIsCopiedToastOpen(true);
|
||||
}
|
||||
}, [log]);
|
||||
|
||||
@@ -52,7 +57,12 @@ export const Component = () => {
|
||||
title={fileName}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Container className='content-primary'>
|
||||
<Toast
|
||||
open={isCopiedToastOpen}
|
||||
onClose={handleToastClose}
|
||||
message={globalize.translate('CopyLogSuccess')}
|
||||
/>
|
||||
<Container className='content-primary' maxWidth={false}>
|
||||
<Box>
|
||||
<Typography variant='h1'>{fileName}</Typography>
|
||||
|
||||
@@ -96,7 +106,14 @@ export const Component = () => {
|
||||
|
||||
<Paper sx={{ mt: 2 }}>
|
||||
<code>
|
||||
<pre style={{ overflow:'auto', margin: 0, padding: '16px' }}>{log}</pre>
|
||||
<pre style={{
|
||||
overflow:'auto',
|
||||
margin: 0,
|
||||
padding: '16px',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}>
|
||||
{log}
|
||||
</pre>
|
||||
</code>
|
||||
</Paper>
|
||||
</>
|
||||
|
||||
@@ -471,19 +471,21 @@ export const Component = () => {
|
||||
|
||||
{(hardwareAccelType === 'none' || isHwaSelected) && (
|
||||
<>
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('EnableTonemapping')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='EnableTonemapping'
|
||||
checked={config.EnableTonemapping}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate(isHwaSelected ? 'AllowTonemappingHelp' : 'AllowTonemappingSoftwareHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
{isHwaSelected && (
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
label={globalize.translate('EnableTonemapping')}
|
||||
control={
|
||||
<Checkbox
|
||||
name='EnableTonemapping'
|
||||
checked={config.EnableTonemapping}
|
||||
onChange={onCheckboxChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormHelperText>{globalize.translate('AllowTonemappingHelp')}</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
name='TonemappingAlgorithm'
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { usePackages } from 'apps/dashboard/features/plugins/api/usePackages';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import getPackageCategories from 'apps/dashboard/features/plugins/utils/getPackageCategories';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import getPackagesByCategory from 'apps/dashboard/features/plugins/utils/getPackagesByCategory';
|
||||
import PackageCard from 'apps/dashboard/features/plugins/components/PackageCard';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Settings from '@mui/icons-material/Settings';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CATEGORY_LABELS } from 'apps/dashboard/features/plugins/constants/categoryLabels';
|
||||
|
||||
export const Component = () => {
|
||||
const { data: packages, isPending: isPackagesPending } = usePackages();
|
||||
const [ searchQuery, setSearchQuery ] = useState('');
|
||||
|
||||
const filteredPackages = useMemo(() => {
|
||||
return packages?.filter(i => i.name?.toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase()));
|
||||
}, [ packages, searchQuery ]);
|
||||
|
||||
const packageCategories = getPackageCategories(filteredPackages);
|
||||
|
||||
const updateSearchQuery = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
}, []);
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const categoryKey = category.replace(/\s/g, '');
|
||||
|
||||
if (CATEGORY_LABELS[categoryKey]) {
|
||||
return globalize.translate(CATEGORY_LABELS[categoryKey]);
|
||||
}
|
||||
|
||||
console.warn('[AvailablePlugins] unmapped category label', category);
|
||||
return category;
|
||||
};
|
||||
|
||||
if (isPackagesPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='pluginCatalogPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
title={globalize.translate('TabCatalog')}
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction='row' gap={1}>
|
||||
<Typography variant='h1'>{globalize.translate('TabCatalog')}</Typography>
|
||||
<IconButton
|
||||
component={Link}
|
||||
to='/dashboard/plugins/repositories'
|
||||
sx={{
|
||||
backgroundColor: 'background.paper'
|
||||
}}
|
||||
>
|
||||
<Settings />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
label={globalize.translate('Search')}
|
||||
value={searchQuery}
|
||||
onChange={updateSearchQuery}
|
||||
/>
|
||||
|
||||
{packageCategories.map(category => (
|
||||
<Stack key={category} spacing={2}>
|
||||
<Typography variant='h2'>{getCategoryLabel(category)}</Typography>
|
||||
|
||||
<Grid container spacing={2} columns={{ xs: 1, sm: 4, md: 9, lg: 8, xl: 10 }}>
|
||||
{getPackagesByCategory(filteredPackages, category).map(pkg => (
|
||||
<Grid key={pkg.guid} size={{ xs: 1, sm: 2, md: 3, lg: 2 }}>
|
||||
<PackageCard
|
||||
pkg={pkg}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'PluginsCatalogPage';
|
||||
@@ -1,44 +1,93 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { usePlugins } from 'apps/dashboard/features/plugins/api/usePlugins';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import SearchInput from 'apps/dashboard/components/SearchInput';
|
||||
import { usePluginDetails } from 'apps/dashboard/features/plugins/api/usePluginDetails';
|
||||
import NoPluginResults from 'apps/dashboard/features/plugins/components/NoPluginResults';
|
||||
import PluginCard from 'apps/dashboard/features/plugins/components/PluginCard';
|
||||
import { useConfigurationPages } from 'apps/dashboard/features/plugins/api/useConfigurationPages';
|
||||
import { findBestConfigurationPage } from 'apps/dashboard/features/plugins/api/configurationPage';
|
||||
import { CATEGORY_LABELS } from 'apps/dashboard/features/plugins/constants/categoryLabels';
|
||||
import { PluginCategory } from 'apps/dashboard/features/plugins/constants/pluginCategory';
|
||||
import { PluginStatusOption } from 'apps/dashboard/features/plugins/constants/pluginStatusOption';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Page from 'components/Page';
|
||||
import useSearchParam from 'hooks/useSearchParam';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
/**
|
||||
* The list of primary/main categories.
|
||||
* Any category not in this list will be added to the "other" category.
|
||||
*/
|
||||
const MAIN_CATEGORIES = [
|
||||
PluginCategory.Administration.toLowerCase(),
|
||||
PluginCategory.General.toLowerCase(),
|
||||
PluginCategory.Anime.toLowerCase(),
|
||||
PluginCategory.Books.toLowerCase(),
|
||||
PluginCategory.LiveTV.toLowerCase(),
|
||||
PluginCategory.MoviesAndShows.toLowerCase(),
|
||||
PluginCategory.Music.toLowerCase(),
|
||||
PluginCategory.Subtitles.toLowerCase()
|
||||
];
|
||||
|
||||
const CATEGORY_PARAM = 'category';
|
||||
const QUERY_PARAM = 'query';
|
||||
const STATUS_PARAM = 'status';
|
||||
|
||||
export const Component = () => {
|
||||
const {
|
||||
data: plugins,
|
||||
isPending,
|
||||
isError
|
||||
} = usePlugins();
|
||||
const {
|
||||
data: configurationPages,
|
||||
isError: isConfigurationPagesError,
|
||||
isPending: isConfigurationPagesPending
|
||||
} = useConfigurationPages();
|
||||
const [ searchQuery, setSearchQuery ] = useState('');
|
||||
data: pluginDetails,
|
||||
isError,
|
||||
isPending
|
||||
} = usePluginDetails();
|
||||
const [ category, setCategory ] = useSearchParam(CATEGORY_PARAM);
|
||||
const [ searchQuery, setSearchQuery ] = useSearchParam(QUERY_PARAM);
|
||||
const [ status, setStatus ] = useSearchParam(STATUS_PARAM, PluginStatusOption.Installed);
|
||||
|
||||
const onSearchChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||
setSearchQuery(event.target.value);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onViewAll = useCallback(() => {
|
||||
if (category) setCategory('');
|
||||
else setStatus(PluginStatusOption.All);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ category ]);
|
||||
|
||||
const filteredPlugins = useMemo(() => {
|
||||
if (plugins) {
|
||||
return plugins.filter(i => i.Name?.toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase()));
|
||||
if (pluginDetails) {
|
||||
let filtered = pluginDetails;
|
||||
|
||||
if (status === PluginStatusOption.Installed) {
|
||||
filtered = filtered.filter(p => p.status);
|
||||
} else if (status === PluginStatusOption.Available) {
|
||||
filtered = filtered.filter(p => !p.status);
|
||||
}
|
||||
|
||||
if (category) {
|
||||
if (category === PluginCategory.Other.toLowerCase()) {
|
||||
filtered = filtered.filter(p => (
|
||||
p.category && !MAIN_CATEGORIES.includes(p.category.toLowerCase())
|
||||
));
|
||||
} else {
|
||||
filtered = filtered.filter(p => p.category?.toLowerCase() === category);
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
.filter(i => i.name?.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}, [ plugins, searchQuery ]);
|
||||
}, [ category, pluginDetails, searchQuery, status ]);
|
||||
|
||||
if (isPending || isConfigurationPagesPending) {
|
||||
if (isPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
@@ -49,31 +98,161 @@ export const Component = () => {
|
||||
className='type-interior mainAnimatedPage'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
{isError || isConfigurationPagesError ? (
|
||||
<Alert severity='error'>{globalize.translate('PluginsLoadError')}</Alert>
|
||||
{isError ? (
|
||||
<Alert
|
||||
severity='error'
|
||||
sx={{ marginBottom: 2 }}
|
||||
>
|
||||
{globalize.translate('PluginsLoadError')}
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h1'>
|
||||
{globalize.translate('TabMyPlugins')}
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction='row'
|
||||
sx={{
|
||||
flexWrap: {
|
||||
xs: 'wrap',
|
||||
sm: 'nowrap'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant='h1'
|
||||
component='span'
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
>
|
||||
{globalize.translate('TabPlugins')}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label={globalize.translate('Search')}
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
<Button
|
||||
component={Link}
|
||||
to='/dashboard/plugins/repositories'
|
||||
variant='outlined'
|
||||
sx={{
|
||||
marginLeft: 2
|
||||
}}
|
||||
>
|
||||
{globalize.translate('ManageRepositories')}
|
||||
</Button>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'end',
|
||||
marginTop: {
|
||||
xs: 2,
|
||||
sm: 0
|
||||
},
|
||||
marginLeft: {
|
||||
xs: 0,
|
||||
sm: 2
|
||||
},
|
||||
width: {
|
||||
xs: '100%',
|
||||
sm: 'auto'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SearchInput
|
||||
label={globalize.translate('Search')}
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Grid container spacing={2} columns={{ xs: 1, sm: 4, md: 9, lg: 8, xl: 10 }}>
|
||||
{filteredPlugins.map(plugin => (
|
||||
<Grid key={plugin.Id} size={{ xs: 1, sm: 2, md: 3, lg: 2 }}>
|
||||
<PluginCard
|
||||
plugin={plugin}
|
||||
configurationPage={findBestConfigurationPage(configurationPages, plugin.Id || '')}
|
||||
/>
|
||||
</Grid>
|
||||
<Stack
|
||||
direction='row'
|
||||
spacing={1}
|
||||
sx={{
|
||||
marginLeft: '-1rem',
|
||||
marginRight: '-1rem',
|
||||
paddingLeft: '1rem',
|
||||
paddingRight: '1rem',
|
||||
paddingBottom: {
|
||||
xs: 1,
|
||||
md: 0.5
|
||||
},
|
||||
overflowX: 'auto'
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
color={status === PluginStatusOption.All ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.All)}
|
||||
label={globalize.translate('All')}
|
||||
/>
|
||||
|
||||
<Chip
|
||||
color={status === PluginStatusOption.Available ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.Available)}
|
||||
label={globalize.translate('LabelAvailable')}
|
||||
/>
|
||||
|
||||
<Chip
|
||||
color={status === PluginStatusOption.Installed ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.Installed)}
|
||||
label={globalize.translate('LabelInstalled')}
|
||||
/>
|
||||
|
||||
<Divider orientation='vertical' flexItem />
|
||||
|
||||
<Chip
|
||||
color={!category ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setCategory('')}
|
||||
label={globalize.translate('All')}
|
||||
/>
|
||||
|
||||
{Object.values(PluginCategory).map(c => (
|
||||
<Chip
|
||||
key={c}
|
||||
color={category === c.toLowerCase() ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setCategory(c.toLowerCase())}
|
||||
label={globalize.translate(CATEGORY_LABELS[c as PluginCategory])}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
<Divider />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
{filteredPlugins.length > 0 ? (
|
||||
// NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
<Grid container spacing={2}>
|
||||
{filteredPlugins.map(plugin => (
|
||||
// NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
<Grid
|
||||
key={plugin.id}
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={4}
|
||||
lg={3}
|
||||
xl={2}
|
||||
>
|
||||
<PluginCard
|
||||
plugin={plugin}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<NoPluginResults
|
||||
isFiltered={!!category || status !== PluginStatusOption.All}
|
||||
onViewAll={onViewAll}
|
||||
query={searchQuery}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
|
||||
import type { VersionInfo } from '@jellyfin/sdk/lib/generated-client/models/version-info';
|
||||
import Alert from '@mui/material/Alert/Alert';
|
||||
import Button from '@mui/material/Button/Button';
|
||||
import Container from '@mui/material/Container/Container';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel/FormControlLabel';
|
||||
import FormGroup from '@mui/material/FormGroup/FormGroup';
|
||||
import Grid from '@mui/material/Grid2/Grid2';
|
||||
import Skeleton from '@mui/material/Skeleton/Skeleton';
|
||||
import Stack from '@mui/material/Stack/Stack';
|
||||
import Switch from '@mui/material/Switch/Switch';
|
||||
import Typography from '@mui/material/Typography/Typography';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Button from '@mui/material/Button';
|
||||
import Container from '@mui/material/Container';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormGroup from '@mui/material/FormGroup';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Delete from '@mui/icons-material/Delete';
|
||||
import Download from '@mui/icons-material/Download';
|
||||
import Extension from '@mui/icons-material/Extension';
|
||||
@@ -56,6 +56,7 @@ const PluginPage: FC = () => {
|
||||
|
||||
const [ isEnabledOverride, setIsEnabledOverride ] = useState<boolean>();
|
||||
const [ isInstallConfirmOpen, setIsInstallConfirmOpen ] = useState(false);
|
||||
const [ isInstalling, setIsInstalling ] = useState(false);
|
||||
const [ isUninstallConfirmOpen, setIsUninstallConfirmOpen ] = useState(false);
|
||||
const [ pendingInstallVersion, setPendingInstallVersion ] = useState<VersionInfo>();
|
||||
|
||||
@@ -115,7 +116,7 @@ const PluginPage: FC = () => {
|
||||
isEnabled: (isEnabledOverride && pluginInfo?.Status === PluginStatus.Restart)
|
||||
?? pluginInfo?.Status !== PluginStatus.Disabled,
|
||||
name: pluginName || pluginInfo?.Name || packageInfo?.name,
|
||||
owner: packageInfo?.owner,
|
||||
owner: pluginInfo?.CanUninstall === false ? 'jellyfin' : packageInfo?.owner,
|
||||
status: pluginInfo?.Status,
|
||||
configurationPage: findBestConfigurationPage(configurationPages || [], pluginId),
|
||||
version,
|
||||
@@ -168,7 +169,8 @@ const PluginPage: FC = () => {
|
||||
alerts.push({ messageKey: 'PluginLoadConfigError' });
|
||||
}
|
||||
|
||||
if (isPackageInfoError) {
|
||||
// Don't show package load error for built-in plugins
|
||||
if (!isPluginsLoading && pluginDetails?.canUninstall && isPackageInfoError) {
|
||||
alerts.push({
|
||||
severity: 'warning',
|
||||
messageKey: 'PluginLoadRepoError'
|
||||
@@ -188,6 +190,8 @@ const PluginPage: FC = () => {
|
||||
isConfigurationPagesError,
|
||||
isPackageInfoError,
|
||||
isPluginsError,
|
||||
isPluginsLoading,
|
||||
pluginDetails?.canUninstall,
|
||||
uninstallPlugin.isError
|
||||
]);
|
||||
|
||||
@@ -243,6 +247,7 @@ const PluginPage: FC = () => {
|
||||
|
||||
console.debug('[PluginPage] installing plugin', installVersion);
|
||||
|
||||
setIsInstalling(true);
|
||||
installPlugin.mutate({
|
||||
name: pluginDetails.name,
|
||||
assemblyGuid: pluginDetails.id,
|
||||
@@ -250,6 +255,7 @@ const PluginPage: FC = () => {
|
||||
repositoryUrl: installVersion.repositoryUrl
|
||||
}, {
|
||||
onSettled: () => {
|
||||
setIsInstalling(false);
|
||||
setPendingInstallVersion(undefined);
|
||||
disablePlugin.reset();
|
||||
enablePlugin.reset();
|
||||
@@ -310,13 +316,17 @@ const PluginPage: FC = () => {
|
||||
<Container className='content-primary'>
|
||||
|
||||
{alertMessages.map(({ severity = 'error', messageKey }) => (
|
||||
<Alert key={messageKey} severity={severity}>
|
||||
<Alert
|
||||
key={messageKey}
|
||||
severity={severity}
|
||||
sx={{ marginBottom: 2 }}
|
||||
>
|
||||
{globalize.translate(messageKey)}
|
||||
</Alert>
|
||||
))}
|
||||
|
||||
<Grid container spacing={2} sx={{ marginTop: 0 }}>
|
||||
<Grid size={{ xs: 12, lg: 8 }}>
|
||||
<Grid item xs={12} lg={8}>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant='h1'>
|
||||
{pluginDetails?.name || pluginName}
|
||||
@@ -332,7 +342,7 @@ const PluginPage: FC = () => {
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ lg: 4 }} sx={{ display: { xs: 'none', lg: 'initial' } }}>
|
||||
<Grid item lg={4} sx={{ display: { xs: 'none', lg: 'initial' } }}>
|
||||
<Image
|
||||
isLoading={isLoading}
|
||||
alt={pluginDetails?.name}
|
||||
@@ -341,7 +351,7 @@ const PluginPage: FC = () => {
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, lg: 8 }} sx={{ order: { xs: 1, lg: 'initial' } }}>
|
||||
<Grid item xs={12} lg={8} sx={{ order: { xs: 1, lg: 'initial' } }}>
|
||||
{!!pluginDetails?.versions.length && (
|
||||
<>
|
||||
<Typography variant='h3' sx={{ marginBottom: 2 }}>
|
||||
@@ -355,7 +365,7 @@ const PluginPage: FC = () => {
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, lg: 4 }}>
|
||||
<Grid item xs={12} lg={4}>
|
||||
<Stack spacing={2} direction={{ xs: 'column', sm: 'row-reverse', lg: 'column' }}>
|
||||
<Stack spacing={1} sx={{ flexBasis: '50%' }}>
|
||||
{!isLoading && !pluginDetails?.status && (
|
||||
@@ -367,6 +377,7 @@ const PluginPage: FC = () => {
|
||||
<Button
|
||||
startIcon={<Download />}
|
||||
onClick={onInstall()}
|
||||
loading={isInstalling}
|
||||
>
|
||||
{globalize.translate('HeaderInstall')}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { RouteObject } from 'react-router-dom';
|
||||
import { Navigate, RouteObject } from 'react-router-dom';
|
||||
|
||||
import ConnectionRequired from 'components/ConnectionRequired';
|
||||
import { ASYNC_ADMIN_ROUTES } from './_asyncRoutes';
|
||||
@@ -26,7 +26,11 @@ export const DASHBOARD_APP_ROUTES: RouteObject[] = [
|
||||
path: DASHBOARD_APP_PATHS.Dashboard,
|
||||
children: [
|
||||
...ASYNC_ADMIN_ROUTES.map(toAsyncPageRoute),
|
||||
...LEGACY_ADMIN_ROUTES.map(toViewManagerPageRoute)
|
||||
...LEGACY_ADMIN_ROUTES.map(toViewManagerPageRoute),
|
||||
{
|
||||
path: 'plugins/catalog',
|
||||
element: <Navigate replace to='/dashboard/plugins' />
|
||||
}
|
||||
],
|
||||
errorElement: <ErrorBoundary pageClasses={[ 'type-interior' ]} />
|
||||
},
|
||||
|
||||
@@ -7,10 +7,11 @@ import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import RemoveCircleIcon from '@mui/icons-material/RemoveCircle';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import { MRT_ColumnDef, MRT_Table, useMaterialReactTable } from 'material-react-table';
|
||||
import { type MRT_ColumnDef, MRT_Table, type MRT_Theme, useMaterialReactTable } from 'material-react-table';
|
||||
import type { TaskTriggerInfo } from '@jellyfin/sdk/lib/generated-client/models/task-trigger-info';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import { useTask } from 'apps/dashboard/features/tasks/api/useTask';
|
||||
@@ -26,6 +27,7 @@ export const Component = () => {
|
||||
const [ isAddTriggerDialogOpen, setIsAddTriggerDialogOpen ] = useState(false);
|
||||
const [ isRemoveConfirmOpen, setIsRemoveConfirmOpen ] = useState(false);
|
||||
const [ pendingDeleteTrigger, setPendingDeleteTrigger ] = useState<TaskTriggerInfo | null>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
const onCloseRemoveConfirmDialog = useCallback(() => {
|
||||
setPendingDeleteTrigger(null);
|
||||
@@ -80,7 +82,15 @@ export const Component = () => {
|
||||
}
|
||||
], []);
|
||||
|
||||
// NOTE: We need to provide a custom theme due to a MRT bug causing the initial theme to always be used
|
||||
// https://github.com/KevinVandy/material-react-table/issues/1429
|
||||
const mrtTheme = useMemo<Partial<MRT_Theme>>(() => ({
|
||||
baseBackgroundColor: theme.palette.background.paper
|
||||
}), [ theme ]);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
mrtTheme,
|
||||
|
||||
columns,
|
||||
data: task?.Triggers || [],
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import toast from '../../../../components/toast/toast';
|
||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||
import Button from '../../../../elements/emby-button/Button';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import AccessContainer from '../../../../components/dashboard/users/AccessContainer';
|
||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
import Page from '../../../../components/Page';
|
||||
import Toast from 'apps/dashboard/components/Toast';
|
||||
|
||||
type ItemsArr = {
|
||||
Name?: string | null;
|
||||
@@ -23,6 +23,7 @@ type ItemsArr = {
|
||||
const UserLibraryAccess = () => {
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const [ isSettingsSavedToastOpen, setIsSettingsSavedToastOpen ] = useState(false);
|
||||
const [ userName, setUserName ] = useState('');
|
||||
const [channelsItems, setChannelsItems] = useState<ItemsArr[]>([]);
|
||||
const [mediaFoldersItems, setMediaFoldersItems] = useState<ItemsArr[]>([]);
|
||||
@@ -31,6 +32,10 @@ const UserLibraryAccess = () => {
|
||||
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleToastClose = useCallback(() => {
|
||||
setIsSettingsSavedToastOpen(false);
|
||||
}, []);
|
||||
|
||||
const triggerChange = (select: HTMLInputElement) => {
|
||||
const evt = new Event('change', { bubbles: false, cancelable: true });
|
||||
select.dispatchEvent(evt);
|
||||
@@ -220,7 +225,7 @@ const UserLibraryAccess = () => {
|
||||
|
||||
const onSaveComplete = () => {
|
||||
loading.hide();
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
setIsSettingsSavedToastOpen(true);
|
||||
};
|
||||
|
||||
(page.querySelector('.chkEnableAllDevices') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
|
||||
@@ -243,6 +248,11 @@ const UserLibraryAccess = () => {
|
||||
id='userLibraryAccessPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Toast
|
||||
open={isSettingsSavedToastOpen}
|
||||
onClose={handleToastClose}
|
||||
message={globalize.translate('SettingsSaved')}
|
||||
/>
|
||||
<div ref={element} className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import type { BaseItemDto, CreateUserByName } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||
|
||||
import Dashboard from '../../../../utils/dashboard';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import toast from '../../../../components/toast/toast';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import Input from '../../../../elements/emby-input/Input';
|
||||
import Button from '../../../../elements/emby-button/Button';
|
||||
import AccessContainer from '../../../../components/dashboard/users/AccessContainer';
|
||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
import Page from '../../../../components/Page';
|
||||
import Toast from 'apps/dashboard/components/Toast';
|
||||
|
||||
type UserInput = {
|
||||
Name?: string;
|
||||
Password?: string;
|
||||
};
|
||||
import { useLibraryMediaFolders } from 'apps/dashboard/features/users/api/useLibraryMediaFolders';
|
||||
import { useChannels } from 'apps/dashboard/features/users/api/useChannels';
|
||||
import { useUpdateUserPolicy } from 'apps/dashboard/features/users/api/useUpdateUserPolicy';
|
||||
import { useCreateUser } from 'apps/dashboard/features/users/api/useCreateUser';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
type ItemsArr = {
|
||||
Name?: string | null;
|
||||
@@ -23,10 +23,21 @@ type ItemsArr = {
|
||||
};
|
||||
|
||||
const UserNew = () => {
|
||||
const navigate = useNavigate();
|
||||
const [ channelsItems, setChannelsItems ] = useState<ItemsArr[]>([]);
|
||||
const [ mediaFoldersItems, setMediaFoldersItems ] = useState<ItemsArr[]>([]);
|
||||
const [ isErrorToastOpen, setIsErrorToastOpen ] = useState(false);
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleToastClose = useCallback(() => {
|
||||
setIsErrorToastOpen(false);
|
||||
}, []);
|
||||
const { data: mediaFolders, isSuccess: isMediaFoldersSuccess } = useLibraryMediaFolders();
|
||||
const { data: channels, isSuccess: isChannelsSuccess } = useChannels();
|
||||
|
||||
const createUser = useCreateUser();
|
||||
const updateUserPolicy = useUpdateUserPolicy();
|
||||
|
||||
const getItemsResult = (items: BaseItemDto[]) => {
|
||||
return items.map(item =>
|
||||
({
|
||||
@@ -44,9 +55,7 @@ const UserNew = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaFolders = getItemsResult(result);
|
||||
|
||||
setMediaFoldersItems(mediaFolders);
|
||||
setMediaFoldersItems(getItemsResult(result));
|
||||
|
||||
const folderAccess = page.querySelector('.folderAccess') as HTMLDivElement;
|
||||
folderAccess.dispatchEvent(new CustomEvent('create'));
|
||||
@@ -62,15 +71,15 @@ const UserNew = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const channels = getItemsResult(result);
|
||||
const channelItems = getItemsResult(result);
|
||||
|
||||
setChannelsItems(channels);
|
||||
setChannelsItems(channelItems);
|
||||
|
||||
const channelAccess = page.querySelector('.channelAccess') as HTMLDivElement;
|
||||
channelAccess.dispatchEvent(new CustomEvent('create'));
|
||||
|
||||
const channelAccessContainer = page.querySelector('.channelAccessContainer') as HTMLDivElement;
|
||||
channels.length ? channelAccessContainer.classList.remove('hide') : channelAccessContainer.classList.add('hide');
|
||||
channelItems.length ? channelAccessContainer.classList.remove('hide') : channelAccessContainer.classList.add('hide');
|
||||
|
||||
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).checked = false;
|
||||
}, []);
|
||||
@@ -82,22 +91,26 @@ const UserNew = () => {
|
||||
console.error('Unexpected null reference');
|
||||
return;
|
||||
}
|
||||
if (!mediaFolders?.Items) {
|
||||
console.error('[add] mediaFolders not available');
|
||||
return;
|
||||
}
|
||||
if (!channels?.Items) {
|
||||
console.error('[add] channels not available');
|
||||
return;
|
||||
}
|
||||
|
||||
(page.querySelector('#txtUsername') as HTMLInputElement).value = '';
|
||||
(page.querySelector('#txtPassword') as HTMLInputElement).value = '';
|
||||
loadMediaFolders(mediaFolders?.Items);
|
||||
loadChannels(channels?.Items);
|
||||
loading.hide();
|
||||
}, [loadChannels, loadMediaFolders, mediaFolders, channels]);
|
||||
|
||||
useEffect(() => {
|
||||
loading.show();
|
||||
const promiseFolders = window.ApiClient.getJSON(window.ApiClient.getUrl('Library/MediaFolders', {
|
||||
IsHidden: false
|
||||
}));
|
||||
const promiseChannels = window.ApiClient.getJSON(window.ApiClient.getUrl('Channels'));
|
||||
Promise.all([promiseFolders, promiseChannels]).then(function (responses) {
|
||||
loadMediaFolders(responses[0].Items);
|
||||
loadChannels(responses[1].Items);
|
||||
loading.hide();
|
||||
}).catch(err => {
|
||||
console.error('[usernew] failed to load data', err);
|
||||
});
|
||||
}, [loadChannels, loadMediaFolders]);
|
||||
if (isMediaFoldersSuccess && isChannelsSuccess) {
|
||||
loadUser();
|
||||
}
|
||||
}, [loadUser, isMediaFoldersSuccess, isChannelsSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
@@ -107,51 +120,54 @@ const UserNew = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
loadUser();
|
||||
|
||||
const saveUser = () => {
|
||||
const userInput: UserInput = {};
|
||||
userInput.Name = (page.querySelector('#txtUsername') as HTMLInputElement).value.trim();
|
||||
userInput.Password = (page.querySelector('#txtPassword') as HTMLInputElement).value;
|
||||
const userInput: CreateUserByName = {
|
||||
Name: (page.querySelector('#txtUsername') as HTMLInputElement).value,
|
||||
Password: (page.querySelector('#txtPassword') as HTMLInputElement).value
|
||||
};
|
||||
createUser.mutate({ createUserByName: userInput }, {
|
||||
onSuccess: (response) => {
|
||||
const user = response.data;
|
||||
|
||||
window.ApiClient.createUser(userInput).then(function (user) {
|
||||
if (!user.Id || !user.Policy) {
|
||||
throw new Error('Unexpected null user id or policy');
|
||||
}
|
||||
if (!user.Id || !user.Policy) {
|
||||
throw new Error('Unexpected null user id or policy');
|
||||
}
|
||||
|
||||
user.Policy.EnableAllFolders = (page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked;
|
||||
user.Policy.EnabledFolders = [];
|
||||
user.Policy.EnableAllFolders = (page.querySelector('.chkEnableAllFolders') as HTMLInputElement).checked;
|
||||
user.Policy.EnabledFolders = [];
|
||||
|
||||
if (!user.Policy.EnableAllFolders) {
|
||||
user.Policy.EnabledFolders = Array.prototype.filter.call(page.querySelectorAll('.chkFolder'), function (i) {
|
||||
return i.checked;
|
||||
}).map(function (i) {
|
||||
return i.getAttribute('data-id');
|
||||
});
|
||||
}
|
||||
|
||||
user.Policy.EnableAllChannels = (page.querySelector('.chkEnableAllChannels') as HTMLInputElement).checked;
|
||||
user.Policy.EnabledChannels = [];
|
||||
|
||||
if (!user.Policy.EnableAllChannels) {
|
||||
user.Policy.EnabledChannels = Array.prototype.filter.call(page.querySelectorAll('.chkChannel'), function (i) {
|
||||
return i.checked;
|
||||
}).map(function (i) {
|
||||
return i.getAttribute('data-id');
|
||||
});
|
||||
}
|
||||
|
||||
window.ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () {
|
||||
Dashboard.navigate('/dashboard/users/profile?userId=' + user.Id)
|
||||
.catch(err => {
|
||||
console.error('[usernew] failed to navigate to edit user page', err);
|
||||
if (!user.Policy.EnableAllFolders) {
|
||||
user.Policy.EnabledFolders = Array.prototype.filter.call(page.querySelectorAll('.chkFolder'), function (i) {
|
||||
return i.checked;
|
||||
}).map(function (i) {
|
||||
return i.getAttribute('data-id');
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('[usernew] failed to update user policy', err);
|
||||
});
|
||||
}, function () {
|
||||
toast(globalize.translate('ErrorDefault'));
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
user.Policy.EnableAllChannels = (page.querySelector('.chkEnableAllChannels') as HTMLInputElement).checked;
|
||||
user.Policy.EnabledChannels = [];
|
||||
|
||||
if (!user.Policy.EnableAllChannels) {
|
||||
user.Policy.EnabledChannels = Array.prototype.filter.call(page.querySelectorAll('.chkChannel'), function (i) {
|
||||
return i.checked;
|
||||
}).map(function (i) {
|
||||
return i.getAttribute('data-id');
|
||||
});
|
||||
}
|
||||
|
||||
updateUserPolicy.mutate({
|
||||
userId: user.Id,
|
||||
userPolicy: user.Policy
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
navigate(`/dashboard/users/profile?userId=${user.Id}`);
|
||||
},
|
||||
onError: () => {
|
||||
console.error('[usernew] failed to update user policy');
|
||||
setIsErrorToastOpen(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -163,28 +179,43 @@ const UserNew = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
|
||||
const enableAllChannelsChange = function (this: HTMLInputElement) {
|
||||
const channelAccessListContainer = page.querySelector('.channelAccessListContainer') as HTMLDivElement;
|
||||
this.checked ? channelAccessListContainer.classList.add('hide') : channelAccessListContainer.classList.remove('hide');
|
||||
});
|
||||
};
|
||||
|
||||
(page.querySelector('.chkEnableAllFolders') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
|
||||
const enableAllFoldersChange = function (this: HTMLInputElement) {
|
||||
const folderAccessListContainer = page.querySelector('.folderAccessListContainer') as HTMLDivElement;
|
||||
this.checked ? folderAccessListContainer.classList.add('hide') : folderAccessListContainer.classList.remove('hide');
|
||||
});
|
||||
};
|
||||
|
||||
(page.querySelector('.newUserProfileForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||
|
||||
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', function() {
|
||||
const onCancelClick = () => {
|
||||
window.history.back();
|
||||
});
|
||||
}, [loadUser]);
|
||||
};
|
||||
|
||||
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).addEventListener('change', enableAllChannelsChange);
|
||||
(page.querySelector('.chkEnableAllFolders') as HTMLInputElement).addEventListener('change', enableAllFoldersChange);
|
||||
(page.querySelector('.newUserProfileForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', onCancelClick);
|
||||
|
||||
return () => {
|
||||
(page.querySelector('.chkEnableAllChannels') as HTMLInputElement).removeEventListener('change', enableAllChannelsChange);
|
||||
(page.querySelector('.chkEnableAllFolders') as HTMLInputElement).removeEventListener('change', enableAllFoldersChange);
|
||||
(page.querySelector('.newUserProfileForm') as HTMLFormElement).removeEventListener('submit', onSubmit);
|
||||
(page.querySelector('#btnCancel') as HTMLButtonElement).removeEventListener('click', onCancelClick);
|
||||
};
|
||||
}, [loadUser, createUser, updateUserPolicy, navigate]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='newUserPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Toast
|
||||
open={isErrorToastOpen}
|
||||
onClose={handleToastClose}
|
||||
message={globalize.translate('ErrorDefault')}
|
||||
/>
|
||||
<div ref={element} className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
|
||||
import Dashboard from '../../../../utils/dashboard';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import dom from '../../../../scripts/dom';
|
||||
import confirm from '../../../../components/confirm/confirm';
|
||||
import UserCardBox from '../../../../components/dashboard/users/UserCardBox';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
@@ -14,6 +9,12 @@ import '../../../../components/cardbuilder/card.scss';
|
||||
import '../../../../components/indicators/indicators.scss';
|
||||
import '../../../../styles/flexstyles.scss';
|
||||
import Page from '../../../../components/Page';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import Toast from 'apps/dashboard/components/Toast';
|
||||
import { useUsers } from 'hooks/useUsers';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import { useDeleteUser } from 'apps/dashboard/features/users/api/useDeleteUser';
|
||||
import dom from 'utils/dom';
|
||||
|
||||
type MenuEntry = {
|
||||
name?: string;
|
||||
@@ -22,30 +23,30 @@ type MenuEntry = {
|
||||
};
|
||||
|
||||
const UserProfiles = () => {
|
||||
const [ users, setUsers ] = useState<UserDto[]>([]);
|
||||
|
||||
const location = useLocation();
|
||||
const [ isSettingsSavedToastOpen, setIsSettingsSavedToastOpen ] = useState(false);
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const { data: users, isPending } = useUsers();
|
||||
const deleteUser = useDeleteUser();
|
||||
|
||||
const loadData = () => {
|
||||
loading.show();
|
||||
window.ApiClient.getUsers().then(function (result) {
|
||||
setUsers(result);
|
||||
loading.hide();
|
||||
}).catch(err => {
|
||||
console.error('[userprofiles] failed to fetch users', err);
|
||||
});
|
||||
};
|
||||
const handleToastClose = useCallback(() => {
|
||||
setIsSettingsSavedToastOpen(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
|
||||
if (location.state?.openSavedToast) {
|
||||
setIsSettingsSavedToastOpen(true);
|
||||
window.history.replaceState({}, '');
|
||||
}
|
||||
|
||||
if (!page) {
|
||||
console.error('Unexpected null reference');
|
||||
return;
|
||||
}
|
||||
|
||||
loadData();
|
||||
|
||||
const showUserMenu = (elem: HTMLElement) => {
|
||||
const card = dom.parentWithClass(elem, 'card');
|
||||
const userId = card?.getAttribute('data-userid');
|
||||
@@ -86,28 +87,19 @@ const UserProfiles = () => {
|
||||
callback: function (id: string) {
|
||||
switch (id) {
|
||||
case 'open':
|
||||
Dashboard.navigate('/dashboard/users/profile?userId=' + userId)
|
||||
.catch(err => {
|
||||
console.error('[userprofiles] failed to navigate to user edit page', err);
|
||||
});
|
||||
navigate(`/dashboard/users/profile?userId=${userId}`);
|
||||
break;
|
||||
|
||||
case 'access':
|
||||
Dashboard.navigate('/dashboard/users/access?userId=' + userId)
|
||||
.catch(err => {
|
||||
console.error('[userprofiles] failed to navigate to user library page', err);
|
||||
});
|
||||
navigate(`/dashboard/users/access?userId=${userId}`);
|
||||
break;
|
||||
|
||||
case 'parentalcontrol':
|
||||
Dashboard.navigate('/dashboard/users/parentalcontrol?userId=' + userId)
|
||||
.catch(err => {
|
||||
console.error('[userprofiles] failed to navigate to parental control page', err);
|
||||
});
|
||||
navigate(`/dashboard/users/parentalcontrol?userId=${userId}`);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
deleteUser(userId, username);
|
||||
confirmDeleteUser(userId, username);
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
@@ -118,7 +110,7 @@ const UserProfiles = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const deleteUser = (id: string, username?: string | null) => {
|
||||
const confirmDeleteUser = (id: string, username?: string | null) => {
|
||||
const title = username ? globalize.translate('DeleteName', username) : globalize.translate('DeleteUser');
|
||||
const text = globalize.translate('DeleteUserConfirmation');
|
||||
|
||||
@@ -128,32 +120,38 @@ const UserProfiles = () => {
|
||||
confirmText: globalize.translate('Delete'),
|
||||
primary: 'delete'
|
||||
}).then(function () {
|
||||
loading.show();
|
||||
window.ApiClient.deleteUser(id).then(function () {
|
||||
loadData();
|
||||
}).catch(err => {
|
||||
console.error('[userprofiles] failed to delete user', err);
|
||||
deleteUser.mutate({
|
||||
userId: id
|
||||
});
|
||||
}).catch(() => {
|
||||
// confirm dialog closed
|
||||
});
|
||||
};
|
||||
|
||||
page.addEventListener('click', function (e) {
|
||||
const onPageClick = function (e: MouseEvent) {
|
||||
const btnUserMenu = dom.parentWithClass(e.target as HTMLElement, 'btnUserMenu');
|
||||
|
||||
if (btnUserMenu) {
|
||||
showUserMenu(btnUserMenu);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
(page.querySelector('#btnAddUser') as HTMLButtonElement).addEventListener('click', function() {
|
||||
Dashboard.navigate('/dashboard/users/add')
|
||||
.catch(err => {
|
||||
console.error('[userprofiles] failed to navigate to new user page', err);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
const onAddUserClick = function() {
|
||||
navigate('/dashboard/users/add');
|
||||
};
|
||||
|
||||
page.addEventListener('click', onPageClick);
|
||||
(page.querySelector('#btnAddUser') as HTMLButtonElement).addEventListener('click', onAddUserClick);
|
||||
|
||||
return () => {
|
||||
page.removeEventListener('click', onPageClick);
|
||||
(page.querySelector('#btnAddUser') as HTMLButtonElement).removeEventListener('click', onAddUserClick);
|
||||
};
|
||||
}, [navigate, deleteUser, location.state?.openSavedToast]);
|
||||
|
||||
if (isPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
@@ -161,6 +159,11 @@ const UserProfiles = () => {
|
||||
className='mainAnimatedPage type-interior userProfilesPage fullWidthContent'
|
||||
title={globalize.translate('HeaderUsers')}
|
||||
>
|
||||
<Toast
|
||||
open={isSettingsSavedToastOpen}
|
||||
onClose={handleToastClose}
|
||||
message={globalize.translate('SettingsSaved')}
|
||||
/>
|
||||
<div ref={element} className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
@@ -174,7 +177,7 @@ const UserProfiles = () => {
|
||||
</div>
|
||||
|
||||
<div className='localUsers itemsContainer vertical-wrap'>
|
||||
{users.map(user => {
|
||||
{users?.map(user => {
|
||||
return <UserCardBox key={user.Id} user={user} />;
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -12,12 +12,12 @@ import Button from '../../../../elements/emby-button/Button';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import toast from '../../../../components/toast/toast';
|
||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
import SelectElement from '../../../../elements/SelectElement';
|
||||
import Page from '../../../../components/Page';
|
||||
import prompt from '../../../../components/prompt/prompt';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import Toast from 'apps/dashboard/components/Toast';
|
||||
|
||||
type NamedItem = {
|
||||
name: string;
|
||||
@@ -30,6 +30,7 @@ type UnratedNamedItem = NamedItem & {
|
||||
|
||||
function handleSaveUser(
|
||||
page: HTMLDivElement,
|
||||
parentalRatingsRef: React.MutableRefObject<ParentalRating[]>,
|
||||
getSchedulesFromPage: () => AccessSchedule[],
|
||||
getAllowedTagsFromPage: () => string[],
|
||||
getBlockedTagsFromPage: () => string[],
|
||||
@@ -42,8 +43,12 @@ function handleSaveUser(
|
||||
throw new Error('Unexpected null user id or policy');
|
||||
}
|
||||
|
||||
const parentalRating = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value, 10);
|
||||
userPolicy.MaxParentalRating = Number.isNaN(parentalRating) ? null : parentalRating;
|
||||
const parentalRatingIndex = parseInt((page.querySelector('#selectMaxParentalRating') as HTMLSelectElement).value, 10);
|
||||
const parentalRating = parentalRatingsRef.current[parentalRatingIndex] as ParentalRating;
|
||||
const score = parentalRating?.RatingScore?.score;
|
||||
const subScore = parentalRating?.RatingScore?.subScore;
|
||||
userPolicy.MaxParentalRating = Number.isNaN(score) ? null : score;
|
||||
userPolicy.MaxParentalSubRating = Number.isNaN(subScore) ? null : subScore;
|
||||
userPolicy.BlockUnratedItems = Array.prototype.filter
|
||||
.call(page.querySelectorAll('.chkUnratedItem'), i => i.checked)
|
||||
.map(i => i.getAttribute('data-itemtype'));
|
||||
@@ -69,33 +74,14 @@ const UserParentalControl = () => {
|
||||
const [ accessSchedules, setAccessSchedules ] = useState<AccessSchedule[]>([]);
|
||||
const [ allowedTags, setAllowedTags ] = useState<string[]>([]);
|
||||
const [ blockedTags, setBlockedTags ] = useState<string[]>([]);
|
||||
const [ isSettingsSavedToastOpen, setIsSettingsSavedToastOpen ] = useState(false);
|
||||
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
|
||||
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
const parentalRatingsRef = useRef<ParentalRating[]>([]);
|
||||
|
||||
const populateRatings = useCallback((allParentalRatings: ParentalRating[]) => {
|
||||
let rating;
|
||||
const ratings: ParentalRating[] = [];
|
||||
|
||||
for (let i = 0, length = allParentalRatings.length; i < length; i++) {
|
||||
rating = allParentalRatings[i];
|
||||
|
||||
if (ratings.length) {
|
||||
const lastRating = ratings[ratings.length - 1];
|
||||
|
||||
if (lastRating.Value === rating.Value) {
|
||||
lastRating.Name += '/' + rating.Name;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
ratings.push({
|
||||
Name: rating.Name,
|
||||
Value: rating.Value
|
||||
});
|
||||
}
|
||||
|
||||
setParentalRatings(ratings);
|
||||
const handleToastClose = useCallback(() => {
|
||||
setIsSettingsSavedToastOpen(false);
|
||||
}, []);
|
||||
|
||||
const loadUnratedItems = useCallback((user: UserDto) => {
|
||||
@@ -161,16 +147,52 @@ const UserParentalControl = () => {
|
||||
|
||||
setAllowedTags(user.Policy?.AllowedTags || []);
|
||||
setBlockedTags(user.Policy?.BlockedTags || []);
|
||||
populateRatings(allParentalRatings);
|
||||
|
||||
let ratingValue = '';
|
||||
allParentalRatings.forEach(rating => {
|
||||
if (rating.Value != null && user.Policy?.MaxParentalRating != null && user.Policy.MaxParentalRating >= rating.Value) {
|
||||
ratingValue = `${rating.Value}`;
|
||||
// Build the grouped ratings array
|
||||
const ratings: ParentalRating[] = [];
|
||||
for (let i = 0, length = allParentalRatings.length; i < length; i++) {
|
||||
const rating = allParentalRatings[i];
|
||||
|
||||
if (ratings.length) {
|
||||
const lastRating = ratings[ratings.length - 1];
|
||||
|
||||
if (lastRating.RatingScore?.score === rating.RatingScore?.score && lastRating.RatingScore?.subScore == rating.RatingScore?.subScore) {
|
||||
lastRating.Name += '/' + rating.Name;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setMaxParentalRating(ratingValue);
|
||||
ratings.push(rating);
|
||||
}
|
||||
setParentalRatings(ratings);
|
||||
parentalRatingsRef.current = ratings;
|
||||
|
||||
// Find matching rating - first try exact match with score and subscore
|
||||
let ratingIndex = '';
|
||||
const userMaxRating = user.Policy?.MaxParentalRating;
|
||||
const userMaxSubRating = user.Policy?.MaxParentalSubRating;
|
||||
|
||||
if (userMaxRating != null) {
|
||||
// First try to find exact match with both score and subscore
|
||||
ratings.forEach((rating, index) => {
|
||||
if (rating.RatingScore?.score === userMaxRating
|
||||
&& rating.RatingScore?.subScore === userMaxSubRating) {
|
||||
ratingIndex = `${index}`;
|
||||
}
|
||||
});
|
||||
|
||||
// If no exact match found, fallback to score-only match
|
||||
if (!ratingIndex) {
|
||||
ratings.forEach((rating, index) => {
|
||||
if (rating.RatingScore?.score != null
|
||||
&& rating.RatingScore.score <= userMaxRating) {
|
||||
ratingIndex = `${index}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setMaxParentalRating(ratingIndex);
|
||||
|
||||
if (user.Policy?.IsAdministrator) {
|
||||
(page.querySelector('.accessScheduleSection') as HTMLDivElement).classList.add('hide');
|
||||
@@ -179,7 +201,7 @@ const UserParentalControl = () => {
|
||||
}
|
||||
setAccessSchedules(user.Policy?.AccessSchedules || []);
|
||||
loading.hide();
|
||||
}, [libraryMenu, setAllowedTags, setBlockedTags, loadUnratedItems, populateRatings]);
|
||||
}, [libraryMenu, setAllowedTags, setBlockedTags, loadUnratedItems]);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
if (!userId) {
|
||||
@@ -283,10 +305,10 @@ const UserParentalControl = () => {
|
||||
|
||||
const onSaveComplete = () => {
|
||||
loading.hide();
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
setIsSettingsSavedToastOpen(true);
|
||||
};
|
||||
|
||||
const saveUser = handleSaveUser(page, getSchedulesFromPage, getAllowedTagsFromPage, getBlockedTagsFromPage, onSaveComplete);
|
||||
const saveUser = handleSaveUser(page, parentalRatingsRef, getSchedulesFromPage, getAllowedTagsFromPage, getBlockedTagsFromPage, onSaveComplete);
|
||||
|
||||
const onSubmit = (e: Event) => {
|
||||
if (!userId) {
|
||||
@@ -342,11 +364,11 @@ const UserParentalControl = () => {
|
||||
const optionMaxParentalRating = () => {
|
||||
let content = '';
|
||||
content += '<option value=\'\'></option>';
|
||||
for (const rating of parentalRatings) {
|
||||
if (rating.Value != null) {
|
||||
content += `<option value='${rating.Value}'>${escapeHTML(rating.Name)}</option>`;
|
||||
parentalRatings.forEach((rating, index) => {
|
||||
if (rating.RatingScore != null) {
|
||||
content += `<option value='${index}'>${escapeHTML(rating.Name)}</option>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
return content;
|
||||
};
|
||||
|
||||
@@ -370,6 +392,11 @@ const UserParentalControl = () => {
|
||||
id='userParentalControlPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Toast
|
||||
open={isSettingsSavedToastOpen}
|
||||
onClose={handleToastClose}
|
||||
message={globalize.translate('SettingsSaved')}
|
||||
/>
|
||||
<div ref={element} className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
|
||||
@@ -1,37 +1,21 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||
import UserPasswordForm from '../../../../components/dashboard/users/UserPasswordForm';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import Page from '../../../../components/Page';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import { useUser } from 'apps/dashboard/features/users/api/useUser';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
|
||||
const UserPassword = () => {
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const [ userName, setUserName ] = useState('');
|
||||
const { data: user, isPending } = useUser(userId ? { userId: userId } : undefined);
|
||||
|
||||
const loadUser = useCallback(() => {
|
||||
if (!userId) {
|
||||
console.error('[userpassword] missing user id');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.show();
|
||||
window.ApiClient.getUser(userId).then(function (user) {
|
||||
if (!user.Name) {
|
||||
throw new Error('Unexpected null user.Name');
|
||||
}
|
||||
setUserName(user.Name);
|
||||
loading.hide();
|
||||
}).catch(err => {
|
||||
console.error('[userpassword] failed to fetch user', err);
|
||||
});
|
||||
}, [userId]);
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
}, [loadUser]);
|
||||
if (isPending || !user) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
@@ -41,13 +25,13 @@ const UserPassword = () => {
|
||||
<div className='content-primary'>
|
||||
<div className='verticalSection'>
|
||||
<SectionTitleContainer
|
||||
title={userName}
|
||||
title={user?.Name || undefined}
|
||||
/>
|
||||
</div>
|
||||
<SectionTabs activeTab='userpassword'/>
|
||||
<div className='readOnlyContent'>
|
||||
<UserPasswordForm
|
||||
userId={userId}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { BaseItemDto, NameIdPair, SyncPlayUserAccessType, UserDto } from '@jellyfin/sdk/lib/generated-client';
|
||||
import escapeHTML from 'escape-html';
|
||||
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import Dashboard from '../../../../utils/dashboard';
|
||||
import globalize from '../../../../lib/globalize';
|
||||
import Button from '../../../../elements/emby-button/Button';
|
||||
import CheckBoxElement from '../../../../elements/CheckBoxElement';
|
||||
@@ -12,9 +11,16 @@ import Input from '../../../../elements/emby-input/Input';
|
||||
import SectionTitleContainer from '../../../../elements/SectionTitleContainer';
|
||||
import SectionTabs from '../../../../components/dashboard/users/SectionTabs';
|
||||
import loading from '../../../../components/loading/loading';
|
||||
import toast from '../../../../components/toast/toast';
|
||||
import SelectElement from '../../../../elements/SelectElement';
|
||||
import Page from '../../../../components/Page';
|
||||
import { useUser } from 'apps/dashboard/features/users/api/useUser';
|
||||
import { useAuthProviders } from 'apps/dashboard/features/users/api/useAuthProviders';
|
||||
import { usePasswordResetProviders } from 'apps/dashboard/features/users/api/usePasswordResetProviders';
|
||||
import { useLibraryMediaFolders } from 'apps/dashboard/features/users/api/useLibraryMediaFolders';
|
||||
import { useChannels } from 'apps/dashboard/features/users/api/useChannels';
|
||||
import { useUpdateUser } from 'apps/dashboard/features/users/api/useUpdateUser';
|
||||
import { useUpdateUserPolicy } from 'apps/dashboard/features/users/api/useUpdateUserPolicy';
|
||||
import { useNetworkConfig } from 'apps/dashboard/features/users/api/useNetworkConfig';
|
||||
|
||||
type ResetProvider = BaseItemDto & {
|
||||
checkedAttribute: string
|
||||
@@ -25,27 +31,26 @@ const getCheckedElementDataIds = (elements: NodeListOf<Element>) => (
|
||||
.map(e => e.getAttribute('data-id'))
|
||||
);
|
||||
|
||||
function onSaveComplete() {
|
||||
Dashboard.navigate('/dashboard/users')
|
||||
.catch(err => {
|
||||
console.error('[useredit] failed to navigate to user profile', err);
|
||||
});
|
||||
loading.hide();
|
||||
toast(globalize.translate('SettingsSaved'));
|
||||
}
|
||||
|
||||
const UserEdit = () => {
|
||||
const navigate = useNavigate();
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const [ userDto, setUserDto ] = useState<UserDto>();
|
||||
const [ deleteFoldersAccess, setDeleteFoldersAccess ] = useState<ResetProvider[]>([]);
|
||||
const [ authProviders, setAuthProviders ] = useState<NameIdPair[]>([]);
|
||||
const [ passwordResetProviders, setPasswordResetProviders ] = useState<NameIdPair[]>([]);
|
||||
const libraryMenu = useMemo(async () => ((await import('../../../../scripts/libraryMenu')).default), []);
|
||||
|
||||
const [ authenticationProviderId, setAuthenticationProviderId ] = useState('');
|
||||
const [ passwordResetProviderId, setPasswordResetProviderId ] = useState('');
|
||||
|
||||
const { data: userDto, isSuccess: isUserSuccess } = useUser(userId ? { userId: userId } : undefined);
|
||||
const { data: authProviders, isSuccess: isAuthProvidersSuccess } = useAuthProviders();
|
||||
const { data: passwordResetProviders, isSuccess: isPasswordResetProvidersSuccess } = usePasswordResetProviders();
|
||||
const { data: mediaFolders, isSuccess: isMediaFoldersSuccess } = useLibraryMediaFolders({ isHidden: false });
|
||||
const { data: channels, isSuccess: isChannelsSuccess } = useChannels({ supportsMediaDeletion: true });
|
||||
const { data: netConfig, isSuccess: isNetConfigSuccess } = useNetworkConfig();
|
||||
|
||||
const updateUser = useUpdateUser();
|
||||
const updateUserPolicy = useUpdateUserPolicy();
|
||||
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
const triggerChange = (select: HTMLInputElement) => {
|
||||
@@ -53,17 +58,10 @@ const UserEdit = () => {
|
||||
select.dispatchEvent(evt);
|
||||
};
|
||||
|
||||
const getUser = () => {
|
||||
if (!userId) throw new Error('missing user id');
|
||||
return window.ApiClient.getUser(userId);
|
||||
};
|
||||
|
||||
const loadAuthProviders = useCallback((page: HTMLDivElement, user: UserDto, providers: NameIdPair[]) => {
|
||||
const fldSelectLoginProvider = page.querySelector('.fldSelectLoginProvider') as HTMLDivElement;
|
||||
fldSelectLoginProvider.classList.toggle('hide', providers.length <= 1);
|
||||
|
||||
setAuthProviders(providers);
|
||||
|
||||
const currentProviderId = user.Policy?.AuthenticationProviderId || '';
|
||||
setAuthenticationProviderId(currentProviderId);
|
||||
}, []);
|
||||
@@ -72,30 +70,26 @@ const UserEdit = () => {
|
||||
const fldSelectPasswordResetProvider = page.querySelector('.fldSelectPasswordResetProvider') as HTMLDivElement;
|
||||
fldSelectPasswordResetProvider.classList.toggle('hide', providers.length <= 1);
|
||||
|
||||
setPasswordResetProviders(providers);
|
||||
|
||||
const currentProviderId = user.Policy?.PasswordResetProviderId || '';
|
||||
setPasswordResetProviderId(currentProviderId);
|
||||
}, []);
|
||||
|
||||
const loadDeleteFolders = useCallback((page: HTMLDivElement, user: UserDto, mediaFolders: BaseItemDto[]) => {
|
||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Channels', {
|
||||
SupportsMediaDeletion: true
|
||||
})).then(function (channelsResult) {
|
||||
let isChecked;
|
||||
let checkedAttribute;
|
||||
const itemsArr: ResetProvider[] = [];
|
||||
const loadDeleteFolders = useCallback((page: HTMLDivElement, user: UserDto, folders: BaseItemDto[]) => {
|
||||
let isChecked;
|
||||
let checkedAttribute;
|
||||
const itemsArr: ResetProvider[] = [];
|
||||
|
||||
for (const mediaFolder of mediaFolders) {
|
||||
isChecked = user.Policy?.EnableContentDeletion || user.Policy?.EnableContentDeletionFromFolders?.indexOf(mediaFolder.Id || '') != -1;
|
||||
checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||
itemsArr.push({
|
||||
...mediaFolder,
|
||||
checkedAttribute: checkedAttribute
|
||||
});
|
||||
}
|
||||
for (const mediaFolder of folders) {
|
||||
isChecked = user.Policy?.EnableContentDeletion || user.Policy?.EnableContentDeletionFromFolders?.indexOf(mediaFolder.Id || '') != -1;
|
||||
checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||
itemsArr.push({
|
||||
...mediaFolder,
|
||||
checkedAttribute: checkedAttribute
|
||||
});
|
||||
}
|
||||
|
||||
for (const channel of channelsResult.Items) {
|
||||
if (channels?.Items) {
|
||||
for (const channel of channels.Items) {
|
||||
isChecked = user.Policy?.EnableContentDeletion || user.Policy?.EnableContentDeletionFromFolders?.indexOf(channel.Id || '') != -1;
|
||||
checkedAttribute = isChecked ? ' checked="checked"' : '';
|
||||
itemsArr.push({
|
||||
@@ -103,16 +97,66 @@ const UserEdit = () => {
|
||||
checkedAttribute: checkedAttribute
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setDeleteFoldersAccess(itemsArr);
|
||||
setDeleteFoldersAccess(itemsArr);
|
||||
|
||||
const chkEnableDeleteAllFolders = page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement;
|
||||
chkEnableDeleteAllFolders.checked = user.Policy?.EnableContentDeletion || false;
|
||||
triggerChange(chkEnableDeleteAllFolders);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch channels', err);
|
||||
});
|
||||
}, []);
|
||||
const chkEnableDeleteAllFolders = page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement;
|
||||
chkEnableDeleteAllFolders.checked = user.Policy?.EnableContentDeletion || false;
|
||||
triggerChange(chkEnableDeleteAllFolders);
|
||||
}, [channels]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[useredit] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
if (userDto && isAuthProvidersSuccess && authProviders != null) {
|
||||
loadAuthProviders(page, userDto, authProviders);
|
||||
}
|
||||
}, [authProviders, isAuthProvidersSuccess, userDto, loadAuthProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[useredit] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
if (userDto && isPasswordResetProvidersSuccess && passwordResetProviders != null) {
|
||||
loadPasswordResetProviders(page, userDto, passwordResetProviders);
|
||||
}
|
||||
}, [passwordResetProviders, isPasswordResetProvidersSuccess, userDto, loadPasswordResetProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[useredit] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
if (userDto && isMediaFoldersSuccess && isChannelsSuccess && mediaFolders?.Items != null) {
|
||||
loadDeleteFolders(page, userDto, mediaFolders.Items);
|
||||
}
|
||||
}, [userDto, mediaFolders, isMediaFoldersSuccess, isChannelsSuccess, channels, loadDeleteFolders]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
|
||||
if (!page) {
|
||||
console.error('[useredit] Unexpected null page reference');
|
||||
return;
|
||||
}
|
||||
|
||||
if (netConfig && isNetConfigSuccess) {
|
||||
(page.querySelector('.fldRemoteAccess') as HTMLDivElement).classList.toggle('hide', !netConfig.EnableRemoteAccess);
|
||||
}
|
||||
}, [netConfig, isNetConfigSuccess]);
|
||||
|
||||
const loadUser = useCallback((user: UserDto) => {
|
||||
const page = element.current;
|
||||
@@ -122,24 +166,6 @@ const UserEdit = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/Providers')).then(function (providers) {
|
||||
loadAuthProviders(page, user, providers);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch auth providers', err);
|
||||
});
|
||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Auth/PasswordResetProviders')).then(function (providers) {
|
||||
loadPasswordResetProviders(page, user, providers);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch password reset providers', err);
|
||||
});
|
||||
window.ApiClient.getJSON(window.ApiClient.getUrl('Library/MediaFolders', {
|
||||
IsHidden: false
|
||||
})).then(function (folders) {
|
||||
loadDeleteFolders(page, user, folders.Items);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch media folders', err);
|
||||
});
|
||||
|
||||
const disabledUserBanner = page.querySelector('.disabledUserBanner') as HTMLDivElement;
|
||||
disabledUserBanner.classList.toggle('hide', !user.Policy?.IsDisabled);
|
||||
|
||||
@@ -149,7 +175,6 @@ const UserEdit = () => {
|
||||
|
||||
void libraryMenu.then(menu => menu.setTitle(user.Name));
|
||||
|
||||
setUserDto(user);
|
||||
(page.querySelector('#txtUserName') as HTMLInputElement).value = user.Name || '';
|
||||
(page.querySelector('.chkIsAdmin') as HTMLInputElement).checked = !!user.Policy?.IsAdministrator;
|
||||
(page.querySelector('.chkDisabled') as HTMLInputElement).checked = !!user.Policy?.IsDisabled;
|
||||
@@ -173,16 +198,22 @@ const UserEdit = () => {
|
||||
(page.querySelector('#txtMaxActiveSessions') as HTMLInputElement).value = String(user.Policy?.MaxActiveSessions) || '0';
|
||||
(page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value = String(user.Policy?.SyncPlayAccess);
|
||||
loading.hide();
|
||||
}, [loadAuthProviders, loadPasswordResetProviders, loadDeleteFolders ]);
|
||||
}, [ libraryMenu ]);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
if (!userDto) {
|
||||
console.error('[profile] No user available');
|
||||
return;
|
||||
}
|
||||
loading.show();
|
||||
getUser().then(function (user) {
|
||||
loadUser(user);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to load data', err);
|
||||
});
|
||||
}, [loadUser]);
|
||||
loadUser(userDto);
|
||||
}, [userDto, loadUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isUserSuccess) {
|
||||
loadData();
|
||||
}
|
||||
}, [loadData, isUserSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
const page = element.current;
|
||||
@@ -192,8 +223,6 @@ const UserEdit = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
loadData();
|
||||
|
||||
const saveUser = (user: UserDto) => {
|
||||
if (!user.Id || !user.Policy) {
|
||||
throw new Error('Unexpected null user id or policy');
|
||||
@@ -225,50 +254,58 @@ const UserEdit = () => {
|
||||
user.Policy.EnableContentDeletionFromFolders = user.Policy.EnableContentDeletion ? [] : getCheckedElementDataIds(page.querySelectorAll('.chkFolder'));
|
||||
user.Policy.SyncPlayAccess = (page.querySelector('#selectSyncPlayAccess') as HTMLSelectElement).value as SyncPlayUserAccessType;
|
||||
|
||||
window.ApiClient.updateUser(user).then(() => (
|
||||
window.ApiClient.updateUserPolicy(user.Id || '', user.Policy || { PasswordResetProviderId: '', AuthenticationProviderId: '' })
|
||||
)).then(() => {
|
||||
onSaveComplete();
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to update user', err);
|
||||
updateUser.mutate({ userId: user.Id, userDto: user }, {
|
||||
onSuccess: () => {
|
||||
if (user.Id) {
|
||||
updateUserPolicy.mutate({
|
||||
userId: user.Id,
|
||||
userPolicy: user.Policy || { PasswordResetProviderId: '', AuthenticationProviderId: '' }
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
loading.hide();
|
||||
navigate('/dashboard/users', {
|
||||
state: { openSavedToast: true }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = (e: Event) => {
|
||||
loading.show();
|
||||
getUser().then(function (result) {
|
||||
saveUser(result);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to fetch user', err);
|
||||
});
|
||||
if (userDto) {
|
||||
saveUser(userDto);
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
};
|
||||
|
||||
const onBtnCancelClick = () => {
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
(page.querySelector('.chkEnableDeleteAllFolders') as HTMLInputElement).addEventListener('change', function (this: HTMLInputElement) {
|
||||
(page.querySelector('.deleteAccess') as HTMLDivElement).classList.toggle('hide', this.checked);
|
||||
});
|
||||
|
||||
window.ApiClient.getNamedConfiguration('network').then(function (config) {
|
||||
(page.querySelector('.fldRemoteAccess') as HTMLDivElement).classList.toggle('hide', !config.EnableRemoteAccess);
|
||||
}).catch(err => {
|
||||
console.error('[useredit] failed to load network config', err);
|
||||
});
|
||||
|
||||
(page.querySelector('.editUserProfileForm') as HTMLFormElement).addEventListener('submit', onSubmit);
|
||||
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', onBtnCancelClick);
|
||||
|
||||
(page.querySelector('#btnCancel') as HTMLButtonElement).addEventListener('click', function() {
|
||||
window.history.back();
|
||||
});
|
||||
}, [loadData]);
|
||||
return () => {
|
||||
(page.querySelector('.editUserProfileForm') as HTMLFormElement).removeEventListener('submit', onSubmit);
|
||||
(page.querySelector('#btnCancel') as HTMLButtonElement).removeEventListener('click', onBtnCancelClick);
|
||||
};
|
||||
}, [loadData, updateUser, userDto, updateUserPolicy, navigate]);
|
||||
|
||||
const optionLoginProvider = authProviders.map((provider) => {
|
||||
const optionLoginProvider = authProviders?.map((provider) => {
|
||||
const selected = provider.Id === authenticationProviderId || authProviders.length < 2 ? ' selected' : '';
|
||||
return `<option value="${provider.Id}"${selected}>${escapeHTML(provider.Name)}</option>`;
|
||||
});
|
||||
|
||||
const optionPasswordResetProvider = passwordResetProviders.map((provider) => {
|
||||
const optionPasswordResetProvider = passwordResetProviders?.map((provider) => {
|
||||
const selected = provider.Id === passwordResetProviderId || passwordResetProviders.length < 2 ? ' selected' : '';
|
||||
return `<option value="${provider.Id}"${selected}>${escapeHTML(provider.Name)}</option>`;
|
||||
});
|
||||
|
||||
@@ -27,7 +27,18 @@ $mui-bp-xl: 1536px;
|
||||
padding-top: 3.25rem !important;
|
||||
}
|
||||
|
||||
// Fix backdrop position on mobile item details page
|
||||
.layout-mobile .itemBackdrop {
|
||||
margin-top: 0 !important;
|
||||
.layout-mobile {
|
||||
.itemBackdrop {
|
||||
// Fix backdrop position on mobile item details page
|
||||
margin-top: 0 !important;
|
||||
|
||||
// Add a subtle gradient over the backdrop to ensure the app bar buttons are visible
|
||||
&::before {
|
||||
display: block;
|
||||
content: "";
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: linear-gradient(180deg, rgba(32, 32, 32, 0.6) 0%, rgba(32, 32, 32, 0.2) 4rem, rgba(0, 0, 0, 0) 50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Cast from '@mui/icons-material/Cast';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import type {} from '@mui/material/themeCssVarsAugmentation';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
|
||||
import { playbackManager } from 'components/playback/playbackmanager';
|
||||
@@ -15,7 +15,6 @@ import RemotePlayMenu, { ID } from './menus/RemotePlayMenu';
|
||||
import RemotePlayActiveMenu, { ID as ACTIVE_ID } from './menus/RemotePlayActiveMenu';
|
||||
|
||||
const RemotePlayButton = () => {
|
||||
const theme = useTheme();
|
||||
const [ playerInfo, setPlayerInfo ] = useState(playbackManager.getPlayerInfo());
|
||||
|
||||
const updatePlayerInfo = useCallback(() => {
|
||||
@@ -70,9 +69,10 @@ const RemotePlayButton = () => {
|
||||
aria-haspopup='true'
|
||||
onClick={onRemotePlayActiveButtonClick}
|
||||
color='inherit'
|
||||
sx={{
|
||||
color: theme.palette.primary.main
|
||||
}}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
sx={(theme) => ({
|
||||
color: theme.vars.palette.primary.main
|
||||
})}
|
||||
>
|
||||
{playerInfo.deviceName || playerInfo.name}
|
||||
</Button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collec
|
||||
import ArrowDropDown from '@mui/icons-material/ArrowDropDown';
|
||||
import Favorite from '@mui/icons-material/Favorite';
|
||||
import Button from '@mui/material/Button/Button';
|
||||
import Icon from '@mui/material/Icon';
|
||||
import { Theme } from '@mui/material/styles';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
@@ -15,6 +16,7 @@ import { appRouter } from 'components/router/appRouter';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import useCurrentTab from 'hooks/useCurrentTab';
|
||||
import { useUserViews } from 'hooks/useUserViews';
|
||||
import { useWebConfig } from 'hooks/useWebConfig';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
import UserViewsMenu from './UserViewsMenu';
|
||||
@@ -56,14 +58,19 @@ const UserViewNav = () => {
|
||||
const libraryId = searchParams.get('topParentId') || searchParams.get('parentId');
|
||||
const collectionType = searchParams.get('collectionType');
|
||||
const { activeTab } = useCurrentTab();
|
||||
const webConfig = useWebConfig();
|
||||
|
||||
const isExtraLargeScreen = useMediaQuery((t: Theme) => t.breakpoints.up('xl'));
|
||||
const isLargeScreen = useMediaQuery((t: Theme) => t.breakpoints.up('lg'));
|
||||
const maxViews = useMemo(() => {
|
||||
if (isExtraLargeScreen) return MAX_USER_VIEWS_XL;
|
||||
if (isLargeScreen) return MAX_USER_VIEWS_LG;
|
||||
return MAX_USER_VIEWS_MD;
|
||||
}, [ isExtraLargeScreen, isLargeScreen ]);
|
||||
let _maxViews = MAX_USER_VIEWS_MD;
|
||||
if (isExtraLargeScreen) _maxViews = MAX_USER_VIEWS_XL;
|
||||
else if (isLargeScreen) _maxViews = MAX_USER_VIEWS_LG;
|
||||
|
||||
const customLinks = (webConfig.menuLinks || []).length;
|
||||
|
||||
return _maxViews - customLinks;
|
||||
}, [ isExtraLargeScreen, isLargeScreen, webConfig.menuLinks ]);
|
||||
|
||||
const { user } = useApi();
|
||||
const {
|
||||
@@ -108,6 +115,21 @@ const UserViewNav = () => {
|
||||
{globalize.translate(MetaView.Favorites.Name)}
|
||||
</Button>
|
||||
|
||||
{webConfig.menuLinks?.map(link => (
|
||||
<Button
|
||||
key={link.name}
|
||||
variant='text'
|
||||
color='inherit'
|
||||
startIcon={<Icon>{link.icon || 'link'}</Icon>}
|
||||
component='a'
|
||||
href={link.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{link.name}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{primaryViews?.map(view => (
|
||||
<Button
|
||||
key={view.Id}
|
||||
|
||||
@@ -9,6 +9,7 @@ import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import classNames from 'classnames';
|
||||
import React, { type FC, useCallback } from 'react';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { useLocalStorage } from 'hooks/useLocalStorage';
|
||||
import { useGetItemsViewByType } from 'hooks/useFetchItems';
|
||||
@@ -99,7 +100,7 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||
|
||||
if (viewType === LibraryTab.Songs) {
|
||||
listOptions.showParentTitle = true;
|
||||
listOptions.action = 'playallfromhere';
|
||||
listOptions.action = ItemAction.PlayAllFromHere;
|
||||
listOptions.smallIcon = true;
|
||||
listOptions.showArtist = true;
|
||||
listOptions.addToListButton = true;
|
||||
@@ -300,7 +301,7 @@ const ItemsView: FC<ItemsViewProps> = ({
|
||||
xs: 1,
|
||||
sm: 0
|
||||
},
|
||||
justifyContent: 'end'
|
||||
justifyContent: 'flex-end'
|
||||
}}
|
||||
>
|
||||
{!isPending && (
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import React, { type FC } from 'react';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { useGetProgramsSectionsWithItems, useGetTimers } from 'hooks/useFetchItems';
|
||||
import { appRouter } from 'components/router/appRouter';
|
||||
import globalize from 'lib/globalize';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
|
||||
import NoItemsMessage from 'components/common/NoItemsMessage';
|
||||
import SectionContainer from 'components/common/SectionContainer';
|
||||
import { CardShape } from 'utils/card';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import { appRouter } from 'components/router/appRouter';
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { useGetProgramsSectionsWithItems, useGetTimers } from 'hooks/useFetchItems';
|
||||
import globalize from 'lib/globalize';
|
||||
import type { ParentId } from 'types/library';
|
||||
import type { Section, SectionType } from 'types/sections';
|
||||
import { CardShape } from 'utils/card';
|
||||
|
||||
interface ProgramsSectionViewProps {
|
||||
parentId: ParentId;
|
||||
@@ -92,7 +94,7 @@ const ProgramsSectionView: FC<ProgramsSectionViewProps> = ({
|
||||
showChannelName: false,
|
||||
cardLayout: true,
|
||||
centerText: false,
|
||||
action: 'edit',
|
||||
action: ItemAction.Edit,
|
||||
cardFooterAside: 'none',
|
||||
preferThumb: true,
|
||||
coverImage: true,
|
||||
|
||||
@@ -4,6 +4,7 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import ReplayIcon from '@mui/icons-material/Replay';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { ItemAction } from 'constants/itemAction';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { getChannelQuery } from 'hooks/api/liveTvHooks/useGetChannel';
|
||||
import globalize from 'lib/globalize';
|
||||
@@ -76,7 +77,7 @@ const PlayOrResumeButton: FC<PlayOrResumeButtonProps> = ({
|
||||
return (
|
||||
<IconButton
|
||||
className='button-flat btnPlayOrResume'
|
||||
data-action={isResumable ? 'resume' : 'play'}
|
||||
data-action={isResumable ? ItemAction.Resume : ItemAction.Play}
|
||||
title={
|
||||
isResumable ?
|
||||
globalize.translate('ButtonResume') :
|
||||
|
||||
@@ -12,6 +12,7 @@ import React, { Fragment } from 'react';
|
||||
|
||||
import { appHost } from 'components/apphost';
|
||||
import { AppFeature } from 'constants/appFeature';
|
||||
import { LayoutMode } from 'constants/layoutMode';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { useThemes } from 'hooks/useThemes';
|
||||
import globalize from 'lib/globalize';
|
||||
@@ -45,11 +46,10 @@ export function DisplayPreferences({ onChange, values }: Readonly<DisplayPrefere
|
||||
onChange={onChange}
|
||||
value={values.layout}
|
||||
>
|
||||
<MenuItem value='auto'>{globalize.translate('Auto')}</MenuItem>
|
||||
<MenuItem value='desktop'>{globalize.translate('Desktop')}</MenuItem>
|
||||
<MenuItem value='mobile'>{globalize.translate('Mobile')}</MenuItem>
|
||||
<MenuItem value='tv'>{globalize.translate('TV')}</MenuItem>
|
||||
<MenuItem value='experimental'>{globalize.translate('Experimental')}</MenuItem>
|
||||
<MenuItem value={LayoutMode.Auto}>{globalize.translate('Auto')}</MenuItem>
|
||||
<MenuItem value={LayoutMode.Desktop}>{globalize.translate('Desktop')}</MenuItem>
|
||||
<MenuItem value={LayoutMode.Mobile}>{globalize.translate('Mobile')}</MenuItem>
|
||||
<MenuItem value={LayoutMode.Tv}>{globalize.translate('TV')}</MenuItem>
|
||||
</Select>
|
||||
<FormHelperText component={Stack} id='display-settings-layout-description'>
|
||||
<span>{globalize.translate('DisplayModeHelp')}</span>
|
||||
@@ -169,6 +169,30 @@ export function DisplayPreferences({ onChange, values }: Readonly<DisplayPrefere
|
||||
</Fragment>
|
||||
) }
|
||||
|
||||
<FormControl fullWidth>
|
||||
<TextField
|
||||
aria-describedby='display-settings-slideshow-interval-description'
|
||||
value={values.slideshowInterval}
|
||||
label={globalize.translate('LabelSlideshowInterval')}
|
||||
name='slideshowInterval'
|
||||
onChange={onChange}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
inputMode: 'numeric',
|
||||
max: '3600',
|
||||
min: '1',
|
||||
pattern: '[0-9]',
|
||||
required: true,
|
||||
step: '1',
|
||||
type: 'number'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormHelperText id='display-settings-slideshow-interval-description'>
|
||||
{globalize.translate('LabelSlideshowIntervalHelp')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<FormControlLabel
|
||||
aria-describedby='display-settings-faster-animations-description'
|
||||
|
||||
@@ -10,6 +10,9 @@ import themeManager from 'scripts/themeManager';
|
||||
import { currentSettings, UserSettings } from 'scripts/settings/userSettings';
|
||||
|
||||
import type { DisplaySettingsValues } from '../types/displaySettingsValues';
|
||||
import { useThemes } from 'hooks/useThemes';
|
||||
import { Theme } from 'types/webConfig';
|
||||
import { FALLBACK_THEME_ID } from 'hooks/useUserTheme';
|
||||
|
||||
interface UseDisplaySettingsParams {
|
||||
userId?: string | null;
|
||||
@@ -20,6 +23,7 @@ export function useDisplaySettings({ userId }: UseDisplaySettingsParams) {
|
||||
const [userSettings, setUserSettings] = useState<UserSettings>();
|
||||
const [displaySettings, setDisplaySettings] = useState<DisplaySettingsValues>();
|
||||
const { __legacyApiClient__, user: currentUser } = useApi();
|
||||
const { defaultTheme } = useThemes();
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || !currentUser || !__legacyApiClient__) {
|
||||
@@ -29,7 +33,7 @@ export function useDisplaySettings({ userId }: UseDisplaySettingsParams) {
|
||||
setLoading(true);
|
||||
|
||||
void (async () => {
|
||||
const loadedSettings = await loadDisplaySettings({ api: __legacyApiClient__, currentUser, userId });
|
||||
const loadedSettings = await loadDisplaySettings({ api: __legacyApiClient__, currentUser, userId, defaultTheme });
|
||||
|
||||
setDisplaySettings(loadedSettings.displaySettings);
|
||||
setUserSettings(loadedSettings.userSettings);
|
||||
@@ -62,15 +66,17 @@ export function useDisplaySettings({ userId }: UseDisplaySettingsParams) {
|
||||
}
|
||||
|
||||
interface LoadDisplaySettingsParams {
|
||||
currentUser: UserDto;
|
||||
userId?: string;
|
||||
api: ApiClient;
|
||||
currentUser: UserDto
|
||||
userId?: string
|
||||
api: ApiClient
|
||||
defaultTheme?: Theme
|
||||
}
|
||||
|
||||
async function loadDisplaySettings({
|
||||
currentUser,
|
||||
userId,
|
||||
api
|
||||
api,
|
||||
defaultTheme
|
||||
}: LoadDisplaySettingsParams) {
|
||||
const settings = (!userId || userId === currentUser?.Id) ? currentSettings : new UserSettings();
|
||||
const user = (!userId || userId === currentUser?.Id) ? currentUser : await api.getUser(userId);
|
||||
@@ -78,8 +84,8 @@ async function loadDisplaySettings({
|
||||
await settings.setUserInfo(userId, api);
|
||||
|
||||
const displaySettings = {
|
||||
customCss: settings.customCss(),
|
||||
dashboardTheme: settings.dashboardTheme() || 'auto',
|
||||
customCss: settings.customCss() || '',
|
||||
dashboardTheme: settings.dashboardTheme() || defaultTheme?.id || FALLBACK_THEME_ID,
|
||||
dateTimeLocale: settings.dateTimeLocale() || 'auto',
|
||||
disableCustomCss: Boolean(settings.disableCustomCss()),
|
||||
displayMissingEpisodes: user?.Configuration?.DisplayMissingEpisodes ?? false,
|
||||
@@ -97,7 +103,8 @@ async function loadDisplaySettings({
|
||||
maxDaysForNextUp: settings.maxDaysForNextUp(),
|
||||
screensaver: settings.screensaver() || 'none',
|
||||
screensaverInterval: settings.backdropScreensaverInterval(),
|
||||
theme: settings.theme()
|
||||
slideshowInterval: settings.slideshowInterval(),
|
||||
theme: settings.theme() || defaultTheme?.id || FALLBACK_THEME_ID
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -125,7 +132,7 @@ async function saveDisplaySettings({
|
||||
userSettings.language(normalizeValue(newDisplaySettings.language));
|
||||
}
|
||||
userSettings.customCss(normalizeValue(newDisplaySettings.customCss));
|
||||
userSettings.dashboardTheme(normalizeValue(newDisplaySettings.dashboardTheme));
|
||||
userSettings.dashboardTheme(newDisplaySettings.dashboardTheme);
|
||||
userSettings.dateTimeLocale(normalizeValue(newDisplaySettings.dateTimeLocale));
|
||||
userSettings.disableCustomCss(newDisplaySettings.disableCustomCss);
|
||||
userSettings.enableBlurhash(newDisplaySettings.enableBlurHash);
|
||||
|
||||
@@ -18,5 +18,6 @@ export interface DisplaySettingsValues {
|
||||
maxDaysForNextUp: number;
|
||||
screensaver: string;
|
||||
screensaverInterval: number;
|
||||
slideshowInterval: number;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import globalize from '../../../lib/globalize';
|
||||
import { clearBackdrop } from '../../../components/backdrop/backdrop';
|
||||
import layoutManager from '../../../components/layoutManager';
|
||||
import Page from '../../../components/Page';
|
||||
import { EventType } from 'types/eventType';
|
||||
import { EventType } from 'constants/eventType';
|
||||
import Events from 'utils/events';
|
||||
|
||||
import '../../../elements/emby-tabs/emby-tabs';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user