Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2ddb676fe | ||
|
|
f8c21f6000 | ||
|
|
3127182f5c | ||
|
|
0c41241bbd | ||
|
|
4985bd8f91 | ||
|
|
3a6e957710 | ||
|
|
041006c181 | ||
|
|
2bac28a50a | ||
|
|
f55b813aa1 | ||
|
|
c37a0e38d9 | ||
|
|
32b0059b10 | ||
|
|
b542bad939 | ||
|
|
cf9b0581a4 | ||
|
|
d4919dc908 | ||
|
|
30d4464067 | ||
|
|
44176b403e | ||
|
|
f756d17901 | ||
|
|
dea6d6c311 | ||
|
|
aea65eadcd | ||
|
|
1a2f0adfc8 | ||
|
|
c3b9d981f7 | ||
|
|
e062ea1507 | ||
|
|
a8c36f35c3 | ||
|
|
d599390512 | ||
|
|
4e41af51eb | ||
|
|
85a5ebd288 | ||
|
|
81372a81e2 | ||
|
|
a005ead158 | ||
|
|
2f1c800844 | ||
|
|
654880d7e4 | ||
|
|
9a7e845010 | ||
|
|
564096e463 | ||
|
|
278107a1a4 | ||
|
|
c5cc21a4b0 | ||
|
|
aa2d2f8c4b | ||
|
|
9b5b8e6db3 | ||
|
|
9c5972faf4 | ||
|
|
98f1c33318 | ||
|
|
6e24553e10 | ||
|
|
799d6c5d4a | ||
|
|
7572634780 | ||
|
|
7e219bc86f | ||
|
|
e46bf5d248 | ||
|
|
d40655134f | ||
|
|
fa1a2c4b31 | ||
|
|
ce67fcd57c | ||
|
|
c52fd972b7 | ||
|
|
b1393fa59b | ||
|
|
dac2b5063b | ||
|
|
c8b1bb8d62 | ||
|
|
601af7acd8 | ||
|
|
3781fe5cf8 | ||
|
|
a231a2da6c | ||
|
|
0df7cb5c25 | ||
|
|
a44c907745 | ||
|
|
84bb1deae5 | ||
|
|
3b5082c107 | ||
|
|
c57bf54bbb | ||
|
|
0c134dde6e | ||
|
|
727f10448f | ||
|
|
12105b3b29 | ||
|
|
cd77acdfab | ||
|
|
e99fd64882 | ||
|
|
a0cc31da70 | ||
|
|
a87c937b69 | ||
|
|
ea43ed0ca4 | ||
|
|
c4ec4f3c66 | ||
|
|
c053a373b4 | ||
|
|
9fa4bef2a4 | ||
|
|
da13d63969 | ||
|
|
f50f46d4b7 | ||
|
|
c6daccbdf0 | ||
|
|
f2f31b5ee0 | ||
|
|
476fda279d | ||
|
|
bb0026a9fd | ||
|
|
ba81e5e35a | ||
|
|
df8f6d25d4 | ||
|
|
c707b4b10f | ||
|
|
ed52b26f6d | ||
|
|
65b1ae26d1 | ||
|
|
85cf101043 | ||
|
|
bd31a4c3ad | ||
|
|
98b62a9f91 | ||
|
|
1bc470a39f | ||
|
|
0fadc28a12 | ||
|
|
6f97741862 | ||
|
|
348687670d | ||
|
|
0f8d9edb3e | ||
|
|
511c9a4dbb | ||
|
|
fe99421ec9 | ||
|
|
c051bf55b2 | ||
|
|
99ddd9214e | ||
|
|
0ca140f4ff | ||
|
|
98cf214aed | ||
|
|
efd06ea107 | ||
|
|
c7b226142f | ||
|
|
e3cd8a8b25 | ||
|
|
8bcba350d5 | ||
|
|
a44ead09a3 | ||
|
|
388717ec3c | ||
|
|
2bbe594cd2 | ||
|
|
6f2bc8e9be | ||
|
|
14d1ce842a | ||
|
|
ab1744ce20 | ||
|
|
eb21628ee4 | ||
|
|
4d51e3b344 | ||
|
|
23ee229906 | ||
|
|
635fadfce9 | ||
|
|
2d5a7a26c1 | ||
|
|
ea84b1c372 | ||
|
|
dea62547e0 | ||
|
|
1d281c0cc9 | ||
|
|
9c5ca6f906 | ||
|
|
d0d2a82a68 | ||
|
|
1421ccc917 | ||
|
|
770768eb21 | ||
|
|
b14f82b4bb | ||
|
|
ee8375756c | ||
|
|
20e6399d5e | ||
|
|
b5bfe90453 | ||
|
|
41538a7366 | ||
|
|
0aa760a786 | ||
|
|
6ad201f4a2 | ||
|
|
87b46deaf7 | ||
|
|
b5e0848436 | ||
|
|
4c9471e608 | ||
|
|
8ea8b2ded5 | ||
|
|
ac0750452c | ||
|
|
547287dba1 | ||
|
|
665df53a39 | ||
|
|
8dde6bb042 | ||
|
|
52b45d835d | ||
|
|
770a0d63f9 | ||
|
|
17572bc602 | ||
|
|
d3836648c2 | ||
|
|
9d20362cf1 | ||
|
|
59ab63c6d8 | ||
|
|
3277754290 | ||
|
|
bc4644609c | ||
|
|
8b5ea3c5d3 | ||
|
|
d63f6d270f | ||
|
|
2f3619b68d | ||
|
|
6f5c993510 | ||
|
|
c3d54b49a0 | ||
|
|
61333094df | ||
|
|
4a8ed13a87 | ||
|
|
f73dff5482 | ||
|
|
b3f6645783 | ||
|
|
1b0d927e88 | ||
|
|
f24c06852e | ||
|
|
233033f1ce | ||
|
|
7dd4d4ec7b | ||
|
|
0c24cc1a69 | ||
|
|
0f56dc1ecd | ||
|
|
6ce466d38e | ||
|
|
42a49e6461 | ||
|
|
08c6303fa6 | ||
|
|
8aaa4f5856 | ||
|
|
c3e1ce30fa | ||
|
|
9af01bb680 | ||
|
|
adb59b292b | ||
|
|
1994a5938d | ||
|
|
f2c3d109cc | ||
|
|
3ead13e479 | ||
|
|
2245e7aae8 | ||
|
|
f6c7271fb4 | ||
|
|
1bce7a300d | ||
|
|
8f7ed056c4 | ||
|
|
a30be7bc1e | ||
|
|
2dd21d6688 | ||
|
|
4795c8c557 | ||
|
|
0386449ec1 | ||
|
|
17d6229a0f | ||
|
|
65f26117fc | ||
|
|
80e896f658 | ||
|
|
d029f48f79 | ||
|
|
b2143eddeb | ||
|
|
f495383bcf | ||
|
|
78230b1fe9 | ||
|
|
4e431b4f89 | ||
|
|
16e7f938bd | ||
|
|
e2414f5e9f | ||
|
|
ea0c35fa6e | ||
|
|
36915d95fe | ||
|
|
07f72f7367 | ||
|
|
141c65149a | ||
|
|
f3858f0902 | ||
|
|
cc29cb8e43 | ||
|
|
6aa5adf5b0 | ||
|
|
df7ba1edaf | ||
|
|
da2c8fa00b | ||
|
|
bbd0ff775a | ||
|
|
9eea8d48e8 | ||
|
|
612a15810b | ||
|
|
6692c83fbf | ||
|
|
82bfbc6f23 | ||
|
|
ce1ef3cbe7 | ||
|
|
0c44af2f67 | ||
|
|
dd9080f781 | ||
|
|
a02825113a | ||
|
|
2258dc3ec6 | ||
|
|
aa2d440acb | ||
|
|
2bbde5ebd1 | ||
|
|
d0e9e0f09d | ||
|
|
6d4d6efe3f | ||
|
|
8f5d7f8387 | ||
|
|
3cb1221038 | ||
|
|
f1d5dd03cb | ||
|
|
fc317840a7 | ||
|
|
003cc5160c | ||
|
|
6f50f37462 | ||
|
|
657c2eef80 | ||
|
|
d01ddd61b6 | ||
|
|
6738296830 | ||
|
|
3ef478c53a | ||
|
|
5a73700f57 | ||
|
|
6c39612c07 | ||
|
|
5986160265 | ||
|
|
f9cc34c12f | ||
|
|
b9206d3628 | ||
|
|
cd4619845b | ||
|
|
9fb6e67ed3 | ||
|
|
929054d5bd | ||
|
|
414fb32c32 | ||
|
|
aca5c279b5 | ||
|
|
6685f4c806 | ||
|
|
7059a3d88f | ||
|
|
16a5b43695 | ||
|
|
c8dfbc8e2d | ||
|
|
93a3083c60 | ||
|
|
7a56006533 | ||
|
|
50bd4344bc | ||
|
|
2351617177 | ||
|
|
fb55f1272d | ||
|
|
cae9a800c4 | ||
|
|
2d4681d079 | ||
|
|
564fcea8d7 | ||
|
|
703ecece63 | ||
|
|
41b41a3c53 | ||
|
|
cd43253061 | ||
|
|
5e772215a9 | ||
|
|
2535192b2c | ||
|
|
3d4e607a9d | ||
|
|
8c9bb6e5d5 | ||
|
|
d2a30db3c1 | ||
|
|
9d5007c05c | ||
|
|
18c1da4a62 | ||
|
|
18347fef3b | ||
|
|
ed0c6136e6 | ||
|
|
40af17083a | ||
|
|
3f076fafcd | ||
|
|
ea4de527b2 | ||
|
|
c844eb2a22 | ||
|
|
8069d7acf0 | ||
|
|
1272976c1a | ||
|
|
36a7a18c35 | ||
|
|
7b6301e498 | ||
|
|
930bc2d7d2 | ||
|
|
6bf0a53d21 | ||
|
|
4f421433c2 | ||
|
|
08d4472d8b | ||
|
|
e88854c0e5 | ||
|
|
5404cebf5b | ||
|
|
fc1bff2110 | ||
|
|
19cd41a8e5 | ||
|
|
d20384dfe0 | ||
|
|
8c205535eb | ||
|
|
753d091dbb | ||
|
|
00d5020824 | ||
|
|
247de555ce | ||
|
|
ca83872151 | ||
|
|
049e1107e6 | ||
|
|
364d6f4555 | ||
|
|
bef5832470 | ||
|
|
3d1b626baa | ||
|
|
9e1faa03b1 | ||
|
|
1249fb9612 | ||
|
|
0f34bf85a8 | ||
|
|
6ea4a74675 | ||
|
|
4c52cc338e | ||
|
|
1d236678b3 | ||
|
|
8fe162e16c | ||
|
|
0c657276a5 | ||
|
|
d69fed6514 | ||
|
|
b080e0de71 | ||
|
|
824ec8a9ca | ||
|
|
6ff36405ba | ||
|
|
acb4cb9c82 | ||
|
|
fcf23c3872 | ||
|
|
51be02477b | ||
|
|
6d437bd2be | ||
|
|
775c3597dc | ||
|
|
0f14e378d7 | ||
|
|
afb6adf1e6 | ||
|
|
2738f40c5e | ||
|
|
94984ac11b | ||
|
|
c7a7cef1a2 | ||
|
|
69e69ee315 | ||
|
|
3771a61c49 | ||
|
|
f7f676ace9 | ||
|
|
961fca0f64 | ||
|
|
b507bf2ae6 | ||
|
|
48d01e3c6c | ||
|
|
b0e269209e | ||
|
|
09c88a1c36 | ||
|
|
2ea0d04c71 | ||
|
|
78b45c4a5b | ||
|
|
e60e4a49f8 | ||
|
|
6351bf78ff | ||
|
|
f0cd45c813 | ||
|
|
a3520e4dfd | ||
|
|
e193c1c6f5 | ||
|
|
89fe289fc5 | ||
|
|
e34af8659b | ||
|
|
022fad92a9 | ||
|
|
ec2de901aa | ||
|
|
b81f5a2ee3 | ||
|
|
acbb834889 | ||
|
|
7ec4adcb24 | ||
|
|
cbf2ecfb9d | ||
|
|
8779b7d607 | ||
|
|
cd0af7e8f1 | ||
|
|
1311e72ca0 | ||
|
|
7742d5f5b4 | ||
|
|
df08258ba1 | ||
|
|
30ee123072 | ||
|
|
051d57bb39 | ||
|
|
f10c8fcb48 | ||
|
|
d401c8fa0f | ||
|
|
4e6437a180 | ||
|
|
34374e9a18 | ||
|
|
432af79055 | ||
|
|
27d08baa2d | ||
|
|
72aed22125 | ||
|
|
2d155fa0c4 | ||
|
|
058518710e | ||
|
|
9666440e80 | ||
|
|
f5c380f75b | ||
|
|
33441d11ac | ||
|
|
2f77be8045 | ||
|
|
0883173d7b | ||
|
|
7521ef0519 | ||
|
|
3eb90968ac | ||
|
|
c5894136f6 | ||
|
|
17b29620fd | ||
|
|
5e7ee1b657 | ||
|
|
5bf4a2d95b | ||
|
|
1657ebc288 | ||
|
|
0ba647ad51 | ||
|
|
cc61e27fad | ||
|
|
b9bcc1d23a | ||
|
|
497363b491 | ||
|
|
f2629d9ac7 | ||
|
|
ced289095c | ||
|
|
38b011a767 | ||
|
|
ba3f1739f4 | ||
|
|
81a13b0e59 | ||
|
|
4a34ec91e5 | ||
|
|
04725942ee | ||
|
|
9d62fb42c4 | ||
|
|
b9f048c57d | ||
|
|
5b651a782a | ||
|
|
f8c9ae67f8 | ||
|
|
4b39c16d25 | ||
|
|
bc886d9476 | ||
|
|
9ce172e78a | ||
|
|
a9d87e4e6b | ||
|
|
f6e551807f | ||
|
|
3be110097b | ||
|
|
f327837d82 | ||
|
|
13e09968d9 | ||
|
|
67195c6051 | ||
|
|
fcb72e6716 | ||
|
|
349c49462f | ||
|
|
6dafbaae48 | ||
|
|
25452ff063 | ||
|
|
ab7d3f8e4c | ||
|
|
21c0e4b354 | ||
|
|
e18c773af9 | ||
|
|
c833f2cbf2 | ||
|
|
db9c200f89 | ||
|
|
017c378582 | ||
|
|
a80b49a03e | ||
|
|
013c0c0eeb | ||
|
|
9258c397e0 | ||
|
|
81946e50a9 | ||
|
|
365e267b8d | ||
|
|
f9e2df085b | ||
|
|
940a4890e2 | ||
|
|
c399d832e5 | ||
|
|
ac4b4ed9cf | ||
|
|
c2bf6779c0 | ||
|
|
44712608b7 | ||
|
|
d4c2bd2789 | ||
|
|
a2be781f81 | ||
|
|
c4c4d41e4d | ||
|
|
693e319e26 | ||
|
|
c6bb272318 | ||
|
|
c69483a12c | ||
|
|
137ebf6d22 | ||
|
|
3f35163481 | ||
|
|
d378ddb42b | ||
|
|
796261c5f5 | ||
|
|
7fa5e70d5b | ||
|
|
ca1ecdf848 | ||
|
|
b53a2b6b78 | ||
|
|
b8b084c188 | ||
|
|
0cec1dbb17 | ||
|
|
65c6854e44 | ||
|
|
9013d5dbf0 | ||
|
|
2a459dda20 | ||
|
|
afe2637f2d | ||
|
|
26615d80e7 | ||
|
|
5c73b54428 | ||
|
|
a9a46024e2 | ||
|
|
1811e1de79 | ||
|
|
25e732bf45 | ||
|
|
5256853351 | ||
|
|
3a5f818e7c | ||
|
|
15171dd5af | ||
|
|
79cabe6580 | ||
|
|
5f89d93db6 | ||
|
|
d98112eae4 | ||
|
|
e0566dfcfa | ||
|
|
1a24ba6ef7 | ||
|
|
74f1580cba | ||
|
|
bce73bc38f | ||
|
|
e40d2d776f | ||
|
|
67dad45214 | ||
|
|
406ed16ce4 | ||
|
|
9cb6200895 | ||
|
|
851f5e486d | ||
|
|
a0c55459d1 | ||
|
|
37309e0727 | ||
|
|
9181f11b13 | ||
|
|
8e10c48d2f | ||
|
|
55a000a7fc | ||
|
|
b0e10f0c62 | ||
|
|
ff847f0910 | ||
|
|
fa2c448bac | ||
|
|
4836801004 | ||
|
|
eab5c10fa6 | ||
|
|
1128110be3 | ||
|
|
bce085c028 | ||
|
|
15d9327d46 | ||
|
|
4380aa9b3e | ||
|
|
c973a3bfa5 | ||
|
|
6f7327b774 | ||
|
|
eba4052522 | ||
|
|
89ab1df2db | ||
|
|
63b3c1bff1 | ||
|
|
04a836e824 | ||
|
|
90ef982066 | ||
|
|
32a8488b7b | ||
|
|
69d9a79b05 | ||
|
|
d70e99c973 | ||
|
|
20d44121e6 | ||
|
|
d8c21f996c | ||
|
|
868b7b890e | ||
|
|
fc5f02b022 | ||
|
|
47834d4288 | ||
|
|
43e48010e5 | ||
|
|
508fcb9861 | ||
|
|
b2860ffbd3 | ||
|
|
0c7264bc52 | ||
|
|
c512ddba49 | ||
|
|
6a6f9595f6 | ||
|
|
1098725bd1 | ||
|
|
88e5ac2f71 | ||
|
|
e5e06baeb3 | ||
|
|
41e576039f | ||
|
|
56e7aed87f | ||
|
|
c33086d8b5 | ||
|
|
dc75ffb9e6 | ||
|
|
5da74ca7e0 | ||
|
|
14d19fcd8b | ||
|
|
37a8a0fe5a | ||
|
|
445c0c4d2e | ||
|
|
b5379c5352 | ||
|
|
c8f3eb69e5 | ||
|
|
5c92f6a3cc | ||
|
|
ee2703ca4b | ||
|
|
a4edda6932 | ||
|
|
002d011b18 | ||
|
|
baf552bf52 | ||
|
|
015b42aa61 | ||
|
|
4e03e7f49d | ||
|
|
86c729a83f | ||
|
|
86d526db28 | ||
|
|
b82128a90c | ||
|
|
05bbc19a15 | ||
|
|
54a4173e72 | ||
|
|
ed1ac628a1 | ||
|
|
30669d23e3 | ||
|
|
c8d57ac3fa | ||
|
|
636d3c670c | ||
|
|
56c6be523d | ||
|
|
08ba282528 | ||
|
|
ea47e4c0a0 | ||
|
|
7f341efdb3 | ||
|
|
fd642011ba | ||
|
|
fe5af9231e | ||
|
|
e438bac0f5 | ||
|
|
5c68ac9e2e | ||
|
|
d7a30cd0dd | ||
|
|
66036b08bc | ||
|
|
c84fb63b25 | ||
|
|
f0d6676917 | ||
|
|
f23831be5e | ||
|
|
0c7f479952 | ||
|
|
a8748fa439 | ||
|
|
82caec402a | ||
|
|
3df6dcfbe7 | ||
|
|
653f42bccb | ||
|
|
3fb6458edf | ||
|
|
8a0411341d | ||
|
|
7d61baad03 | ||
|
|
0169501676 | ||
|
|
01eb84654d | ||
|
|
f58c38a1aa | ||
|
|
0a4ff4b883 | ||
|
|
be4748b8fa | ||
|
|
ec4b3a6408 | ||
|
|
5382b1999f | ||
|
|
e54d9aaf44 | ||
|
|
99175feda4 | ||
|
|
3da5734571 | ||
|
|
f2c70a4d0b | ||
|
|
dcb9cfaf29 | ||
|
|
8085e72cf4 | ||
|
|
ea0a6a62a8 | ||
|
|
c3f988cc02 | ||
|
|
bf70143cca | ||
|
|
4cee3dfbe0 | ||
|
|
5e3dc1e44d | ||
|
|
74b017d9f4 | ||
|
|
60669999d1 | ||
|
|
ea17975406 | ||
|
|
2c197bc59b | ||
|
|
4f94e98140 | ||
|
|
46943b147a | ||
|
|
8d42ca8b5e | ||
|
|
0d98335354 | ||
|
|
0a3d1e9e5e | ||
|
|
3214d889fa | ||
|
|
408cdeb5c3 | ||
|
|
be711149f7 | ||
|
|
680cc284d6 | ||
|
|
95101e34ed | ||
|
|
998615fc11 | ||
|
|
61cbb74d14 | ||
|
|
228fc48e33 | ||
|
|
a67050e77b | ||
|
|
9375738e05 | ||
|
|
75e5a6c42f | ||
|
|
41dba7b516 | ||
|
|
f557fbbae1 | ||
|
|
1dd509a40f | ||
|
|
bb8640b162 | ||
|
|
1cf119c768 | ||
|
|
15f3493b5b | ||
|
|
d32075d410 | ||
|
|
e12531cccc | ||
|
|
6aecd1a713 | ||
|
|
60c0cfa911 | ||
|
|
dba97a3bd6 | ||
|
|
83ee03b192 | ||
|
|
0ac123b59f | ||
|
|
0990e4b552 | ||
|
|
08d571ad74 | ||
|
|
663eb0bf5b | ||
|
|
8ee0d67a36 | ||
|
|
4e1bbf2ae6 | ||
|
|
be100b610e | ||
|
|
531cd0de25 | ||
|
|
8fc28844bb | ||
|
|
4cf10b97c5 | ||
|
|
e057affddf | ||
|
|
e46bfd9146 | ||
|
|
c8d4461ab8 | ||
|
|
52e936f979 | ||
|
|
7a14c255be | ||
|
|
c4b54ed36c | ||
|
|
988ec6bebb | ||
|
|
6a3a8465e7 | ||
|
|
a76e941378 | ||
|
|
75748dfbc3 | ||
|
|
196ac340a1 | ||
|
|
fab69c1c07 | ||
|
|
a2140d0870 | ||
|
|
6029a5b592 | ||
|
|
f92aeba403 | ||
|
|
92afd82d22 | ||
|
|
ea12da991a | ||
|
|
af0b986b3e | ||
|
|
324a643fd1 | ||
|
|
05702bede8 | ||
|
|
9087f4bfdf | ||
|
|
8d7a7bc9b1 | ||
|
|
7b3143357e | ||
|
|
d5f4bd0aab | ||
|
|
21a2db6019 | ||
|
|
d78b581104 | ||
|
|
4291bde94f | ||
|
|
171fe40fd5 | ||
|
|
541d745cea | ||
|
|
508369cb0b | ||
|
|
bf607349e0 | ||
|
|
0bfc0c5d25 | ||
|
|
fdcf3a89c9 | ||
|
|
86b49414f0 | ||
|
|
64fe061fb0 | ||
|
|
f96e25c0a2 | ||
|
|
4526fa6fc4 | ||
|
|
05690cc5e3 | ||
|
|
e9d612051d | ||
|
|
727f97226c | ||
|
|
33e8cc7412 | ||
|
|
f358c33bd3 | ||
|
|
e957c6b601 | ||
|
|
0d14bad10b | ||
|
|
25f8564e97 | ||
|
|
8b4cc5b994 | ||
|
|
856cb2d005 | ||
|
|
10acb2bc76 | ||
|
|
e4c6864a94 | ||
|
|
4e9593b859 | ||
|
|
37ebcfe318 | ||
|
|
5547e52001 | ||
|
|
f5f6388361 | ||
|
|
476fa59079 | ||
|
|
8b3bc03b9b | ||
|
|
d69cda0313 | ||
|
|
9cab001451 | ||
|
|
c627384c4b | ||
|
|
8e4ba37f94 | ||
|
|
2e782ae442 | ||
|
|
19498854fc | ||
|
|
d985fbe69a | ||
|
|
6fc27dfce4 | ||
|
|
83b4631cde | ||
|
|
84851db333 | ||
|
|
0710425e78 | ||
|
|
28927cfa28 | ||
|
|
3a3a8251bd | ||
|
|
bac09d8be5 | ||
|
|
2c1b52aff9 | ||
|
|
e46ea71bc2 | ||
|
|
fb92c3e95c | ||
|
|
38c32cbece | ||
|
|
1bae7352dc | ||
|
|
d7ecdf5567 | ||
|
|
3dca787fa7 | ||
|
|
1f730ab786 | ||
|
|
a3b861e13c | ||
|
|
66ddb45975 | ||
|
|
4de5ab5d41 | ||
|
|
475f472cb0 | ||
|
|
efd958d6cb | ||
|
|
246272669e | ||
|
|
493c22f9da | ||
|
|
1d6745b142 | ||
|
|
309111c600 | ||
|
|
98078556c3 | ||
|
|
5bf1ddde4b | ||
|
|
afa96349c5 | ||
|
|
1aabc4dfb2 | ||
|
|
8f551aa485 | ||
|
|
dda34e7764 | ||
|
|
899060c234 | ||
|
|
f1dbc4420f | ||
|
|
251cedf111 | ||
|
|
cfd8b328b4 | ||
|
|
2623708c6d | ||
|
|
946ea8753f | ||
|
|
f5aaf89a3d | ||
|
|
710197b3a0 | ||
|
|
d993e8d811 | ||
|
|
381832685f | ||
|
|
812b8d8306 | ||
|
|
2b5f873c00 | ||
|
|
20874cb96d | ||
|
|
dc3b00736e | ||
|
|
869efbc638 | ||
|
|
2e044ac871 | ||
|
|
04c02d886d | ||
|
|
2be1ae8011 | ||
|
|
67d4899ed3 | ||
|
|
a6a6734097 | ||
|
|
4ff39b26e9 | ||
|
|
4b304c74bc | ||
|
|
d08bdf3e2d | ||
|
|
7cc2736d68 | ||
|
|
452308336a | ||
|
|
26146f4037 | ||
|
|
b1b839fcd8 | ||
|
|
fef070c42c | ||
|
|
e832bfbbdb | ||
|
|
777473ee36 | ||
|
|
96150f802d | ||
|
|
51fb7cb402 | ||
|
|
3b39e87212 | ||
|
|
d16281bea7 | ||
|
|
79d977c008 | ||
|
|
ff84c2bdb7 | ||
|
|
6d46e91a7c |
No files matched your search
@@ -5,4 +5,5 @@ npm-debug.log
|
|||||||
README.md
|
README.md
|
||||||
.next
|
.next
|
||||||
.git
|
.git
|
||||||
dev
|
dev
|
||||||
|
.build
|
||||||
@@ -20,8 +20,6 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
|||||||
# DB_PASSWORD='password'
|
# DB_PASSWORD='password'
|
||||||
# DB_NAME='name-of-database'
|
# DB_NAME='name-of-database'
|
||||||
|
|
||||||
# @see https://next-auth.js.org/configuration/options#nextauth_url
|
|
||||||
AUTH_URL='http://localhost:3000'
|
|
||||||
|
|
||||||
# You can generate the secret via 'openssl rand -base64 32' on Unix
|
# You can generate the secret via 'openssl rand -base64 32' on Unix
|
||||||
# @see https://next-auth.js.org/configuration/options#secret
|
# @see https://next-auth.js.org/configuration/options#secret
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
||||||
"extends": [
|
|
||||||
"config:recommended"
|
|
||||||
],
|
|
||||||
"packageRules": [
|
|
||||||
{
|
|
||||||
"matchPackagePatterns": [
|
|
||||||
"^@homarr/"
|
|
||||||
],
|
|
||||||
"enabled": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matchUpdateTypes": [
|
|
||||||
"minor",
|
|
||||||
"patch",
|
|
||||||
"pin",
|
|
||||||
"digest"
|
|
||||||
],
|
|
||||||
"automerge": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"updateInternalDeps": true,
|
|
||||||
"rangeStrategy": "bump",
|
|
||||||
"automerge": false,
|
|
||||||
"baseBranches": [
|
|
||||||
"dev"
|
|
||||||
],
|
|
||||||
"dependencyDashboard": false
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
$schema: "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
extends: ["config:recommended"],
|
||||||
|
packageRules: [
|
||||||
|
{
|
||||||
|
matchPackagePatterns: ["^@homarr/"],
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matchUpdateTypes: ["minor", "patch", "pin", "digest"],
|
||||||
|
automerge: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updateInternalDeps: true,
|
||||||
|
rangeStrategy: "bump",
|
||||||
|
automerge: false,
|
||||||
|
baseBranches: ["dev"],
|
||||||
|
dependencyDashboard: false,
|
||||||
|
}
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
name: Build apps and migration script
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: ["*"]
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
merge_group:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
|
||||||
|
|
||||||
env:
|
|
||||||
FORCE_COLOR: 3
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup
|
|
||||||
uses: ./tooling/github/setup
|
|
||||||
|
|
||||||
- name: Copy env
|
|
||||||
shell: bash
|
|
||||||
run: cp .env.example .env
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: pnpm build
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Code quality analysis
|
name: "[Quality] Code Analysis"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -8,7 +8,7 @@ on:
|
|||||||
merge_group:
|
merge_group:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: "${{ github.workflow }}-${{ github.ref }}"
|
||||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||||
|
|
||||||
# You can leverage Vercel Remote Caching with Turbo to speed up your builds
|
# You can leverage Vercel Remote Caching with Turbo to speed up your builds
|
||||||
@@ -72,3 +72,15 @@ jobs:
|
|||||||
# Only works if you set `reportOnFailure: true` in your vite config as specified above
|
# Only works if you set `reportOnFailure: true` in your vite config as specified above
|
||||||
if: always()
|
if: always()
|
||||||
uses: davelosert/vitest-coverage-report-action@v2
|
uses: davelosert/vitest-coverage-report-action@v2
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup
|
||||||
|
uses: ./tooling/github/setup
|
||||||
|
- name: Copy env
|
||||||
|
shell: bash
|
||||||
|
run: cp .env.example .env
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
|
|
||||||
# https://github.com/webiny/action-conventional-commits?tab=readme-ov-file
|
# https://github.com/webiny/action-conventional-commits?tab=readme-ov-file
|
||||||
|
name: "[Conventions] Semantic Commits"
|
||||||
name: Conventional Commits
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: "Lint PR"
|
name: "[Conventions] Semantic PRs"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
@@ -11,8 +11,7 @@ permissions:
|
|||||||
pull-requests: read
|
pull-requests: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
main:
|
validate-pull-request-title:
|
||||||
name: Validate PR title
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: amannn/action-semantic-pull-request@v5
|
- uses: amannn/action-semantic-pull-request@v5
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
name: "[Deployment] Release"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
send-notifications:
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
description: Send notifications
|
||||||
|
push-image:
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
description: Push Docker Image
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
SKIP_ENV_VALIDATION: true
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
TURBO_TELEMETRY_DISABLED: 1
|
||||||
|
|
||||||
|
concurrency: production
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy docker image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [20]
|
||||||
|
steps:
|
||||||
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications != false }}
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
uses: Ilshidur/action-discord@master
|
||||||
|
with:
|
||||||
|
args: "Deployment of an image has been triggered: [run ${{ github.run_number }}](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>)"
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Get Next Version
|
||||||
|
id: semver
|
||||||
|
uses: ietf-tools/semver-action@v1
|
||||||
|
with:
|
||||||
|
token: ${{ github.token }}
|
||||||
|
branch: dev
|
||||||
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications != false }}
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
uses: Ilshidur/action-discord@master
|
||||||
|
with:
|
||||||
|
args: "Semver computed next tag to be ${{ steps.semver.outputs.next }}. Current is ${{ steps.semver.outputs.current }}"
|
||||||
|
- uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications != false }}
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
uses: Ilshidur/action-discord@master
|
||||||
|
with:
|
||||||
|
args: "Built application artifacts. Building images..."
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||||
|
tags: |
|
||||||
|
type=raw,value=alpha
|
||||||
|
type=raw,value=early-adopters
|
||||||
|
# tags: |
|
||||||
|
# type=raw,value=latest
|
||||||
|
# type=raw,value=${{ steps.semver.outputs.next }}
|
||||||
|
- name: Build and push
|
||||||
|
id: buildPushAction
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
if: ${{ github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null }}
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
network: host
|
||||||
|
env:
|
||||||
|
SKIP_ENV_VALIDATION: true
|
||||||
|
- name: Build
|
||||||
|
id: buildPushDryAction
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
if: ${{ github.events.inputs.push-image == 'false' }}
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
network: host
|
||||||
|
env:
|
||||||
|
SKIP_ENV_VALIDATION: true
|
||||||
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications != false && (github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }}
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
uses: Ilshidur/action-discord@master
|
||||||
|
with:
|
||||||
|
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'."
|
||||||
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications != false && !(github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }}
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
uses: Ilshidur/action-discord@master
|
||||||
|
with:
|
||||||
|
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushDryAction.outputs.imageid }}'. This was a dry run."
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
name: "[Deployment] Automatic Weekly Release"
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
send-notifications:
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
description: Send notifications
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create-and-merge-pr:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications }}
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
uses: Ilshidur/action-discord@master
|
||||||
|
with:
|
||||||
|
args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Get Next Version
|
||||||
|
id: semver
|
||||||
|
uses: ietf-tools/semver-action@v1
|
||||||
|
with:
|
||||||
|
token: ${{ github.token }}
|
||||||
|
branch: dev
|
||||||
|
- name: Create pull request
|
||||||
|
run: "gh pr create --title \"chore(release): automatic release ${{ steps.semver.outputs.next }}\" --body \"**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``\" --base main --head dev --label automerge"
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications }}
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
uses: Ilshidur/action-discord@master
|
||||||
|
with:
|
||||||
|
args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})"
|
||||||
|
- name: Obtain token
|
||||||
|
id: obtainApprovalToken
|
||||||
|
uses: tibdex/github-app-token@v2
|
||||||
|
with:
|
||||||
|
private_key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||||
|
app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||||
|
- name: Approve PR
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }}
|
||||||
|
run: |
|
||||||
|
gh pr review --approve --body "Automatically approved by GitHub Action"
|
||||||
|
- name: Obtain token
|
||||||
|
id: obtainMergeToken
|
||||||
|
uses: tibdex/github-app-token@v2
|
||||||
|
with:
|
||||||
|
private_key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
|
||||||
|
app_id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
||||||
|
- id: automerge
|
||||||
|
if: ${{ steps.semver.outputs.bump != 'major' }}
|
||||||
|
name: automerge
|
||||||
|
uses: "pascalgn/automerge-action@v0.16.4"
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }}
|
||||||
|
MERGE_METHOD: merge # we prefer merge commits for merging to master
|
||||||
|
MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
|
||||||
|
MERGE_DELETE_BRANCH: false # never set to true!
|
||||||
|
PULL_REQUEST: "${{ steps.create-pull-request.outputs.pr_number }}"
|
||||||
|
MERGE_RETRIES: 20 # 20 retries * MERGE_RETRY_SLEEP until step fails
|
||||||
|
MERGE_RETRY_SLEEP: 10000 # 10 seconds * MERGE_RETRIES until step fails
|
||||||
|
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
|
||||||
|
|
||||||
|
- name: Merged Discord notification
|
||||||
|
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.events.inputs.send-notifications }}
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
uses: Ilshidur/action-discord@master
|
||||||
|
with:
|
||||||
|
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
|
||||||
|
- name: Major Bump Discord notification
|
||||||
|
if: ${{ steps.semver.outputs.bump == 'major' && github.events.inputs.send-notifications }}
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
uses: Ilshidur/action-discord@master
|
||||||
|
with:
|
||||||
|
args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
|
||||||
|
- name: Discord Fail Notification
|
||||||
|
if: failure() && github.events.inputs.send-notifications
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
uses: Ilshidur/action-discord@master
|
||||||
|
with:
|
||||||
|
args: "The automatic release workflow [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) has failed"
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
name: Docker image
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- closed
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch: {}
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
TURBO_TELEMETRY_DISABLED: 1
|
|
||||||
|
|
||||||
concurrency: production
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
name: Deploy docker image
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node-version: [20]
|
|
||||||
steps:
|
|
||||||
- name: Discord notification
|
|
||||||
env:
|
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
|
||||||
uses: Ilshidur/action-discord@master
|
|
||||||
with:
|
|
||||||
args: "Deployment of an image has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Get Next Version
|
|
||||||
id: semver
|
|
||||||
uses: ietf-tools/semver-action@v1
|
|
||||||
with:
|
|
||||||
token: ${{ github.token }}
|
|
||||||
branch: master
|
|
||||||
- name: Discord notification
|
|
||||||
env:
|
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
|
||||||
uses: Ilshidur/action-discord@master
|
|
||||||
with:
|
|
||||||
args: "Semver computed next tag to be ${{ steps.semver.outputs.next }}. Current is ${{ steps.semver.outputs.current }}"
|
|
||||||
- uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: 8
|
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ matrix.node-version }}
|
|
||||||
cache: "pnpm"
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
- name: Build artifacts
|
|
||||||
run: pnpm build
|
|
||||||
- name: Discord notification
|
|
||||||
env:
|
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
|
||||||
uses: Ilshidur/action-discord@master
|
|
||||||
with:
|
|
||||||
args: "Built application artifacts. Building images..."
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=raw,value=latest
|
|
||||||
type=raw,value=${{ steps.semver.outputs.next }}
|
|
||||||
- name: Build and push
|
|
||||||
id: buildPushAction
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/arm/v7,linux/arm/v6
|
|
||||||
context: .
|
|
||||||
push: false
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
network: host
|
|
||||||
- name: Discord notification
|
|
||||||
env:
|
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
|
||||||
uses: Ilshidur/action-discord@master
|
|
||||||
with:
|
|
||||||
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'. This was a dry run."
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Approve Renovate PRs
|
name: "[Dependency Updates] Auto Approve"
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize]
|
types: [opened, synchronize]
|
||||||
@@ -6,18 +6,20 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
approve-renovate-prs:
|
approve-renovate-prs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: github.actor_id == 158783068 # Id of renovate bot see https://api.github.com/users/homarr-renovate%5Bbot%5D
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
- name: Obtain token
|
||||||
|
id: obtainToken
|
||||||
|
uses: tibdex/github-app-token@v2
|
||||||
|
with:
|
||||||
|
private_key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
|
||||||
|
app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
|
||||||
- name: Install GitHub CLI
|
- name: Install GitHub CLI
|
||||||
run: sudo apt-get install -y gh
|
run: sudo apt-get install -y gh
|
||||||
|
|
||||||
- name: Approve Renovate PRs
|
- name: Approve Renovate PRs
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.RENOVATE_APPROVE_TOKEN }}
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
run: |
|
run: |
|
||||||
for pr in $(gh pr list --author homarr-renovate[bot] --json number --jq .[].number); do
|
gh pr review ${{github.event.pull_request.number}} --approve --body "Automatically approved by GitHub Action"
|
||||||
gh pr review $pr --approve --body "Automatically approved by GitHub Action"
|
|
||||||
done
|
|
||||||
@@ -14,8 +14,8 @@ coverage
|
|||||||
out/
|
out/
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# nest.js
|
# artifacts
|
||||||
apps/nestjs/dist
|
packages/db/migrations/*/migrate.cjs
|
||||||
|
|
||||||
# nitro
|
# nitro
|
||||||
.nitro/
|
.nitro/
|
||||||
@@ -48,10 +48,16 @@ yarn-error.log*
|
|||||||
.turbo
|
.turbo
|
||||||
|
|
||||||
# database
|
# database
|
||||||
db.sqlite
|
*.sqlite
|
||||||
|
|
||||||
# logs
|
# logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
apps/tasks/tasks.cjs
|
apps/tasks/tasks.cjs
|
||||||
apps/websocket/wssServer.cjs
|
apps/websocket/wssServer.cjs
|
||||||
|
apps/nextjs/.million/
|
||||||
|
packages/cli/cli.cjs
|
||||||
|
|
||||||
|
|
||||||
|
#personal backgrounds
|
||||||
|
apps/nextjs/public/images/background.png
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="db:migration:mysql:generate" type="js.build_tools.npm">
|
||||||
|
<package-json value="$PROJECT_DIR$/package.json" />
|
||||||
|
<command value="run" />
|
||||||
|
<scripts>
|
||||||
|
<script value="db:migration:mysql:generate" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<package-manager value="pnpm" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="db:migration:sqlite:generate" type="js.build_tools.npm">
|
||||||
|
<package-json value="$PROJECT_DIR$/package.json" />
|
||||||
|
<command value="run" />
|
||||||
|
<scripts>
|
||||||
|
<script value="db:migration:sqlite:generate" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<package-manager value="pnpm" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="db:push" type="js.build_tools.npm">
|
||||||
|
<package-json value="$PROJECT_DIR$/package.json" />
|
||||||
|
<command value="run" />
|
||||||
|
<scripts>
|
||||||
|
<script value="db:push" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<package-manager value="pnpm" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="db:studio" type="js.build_tools.npm">
|
||||||
|
<package-json value="$PROJECT_DIR$/package.json" />
|
||||||
|
<command value="run" />
|
||||||
|
<scripts>
|
||||||
|
<script value="db:studio" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<package-manager value="pnpm" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="dev" type="js.build_tools.npm" nameIsGenerated="true">
|
||||||
|
<package-json value="$PROJECT_DIR$/package.json" />
|
||||||
|
<command value="run" />
|
||||||
|
<scripts>
|
||||||
|
<script value="dev" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<package-manager value="pnpm" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="docker:dev" type="js.build_tools.npm">
|
||||||
|
<package-json value="$PROJECT_DIR$/package.json" />
|
||||||
|
<command value="run" />
|
||||||
|
<scripts>
|
||||||
|
<script value="docker:dev" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<package-manager value="pnpm" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="format" type="js.build_tools.npm">
|
||||||
|
<package-json value="$PROJECT_DIR$/package.json" />
|
||||||
|
<command value="run" />
|
||||||
|
<scripts>
|
||||||
|
<script value="format" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<package-manager value="pnpm" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="format:fix" type="js.build_tools.npm">
|
||||||
|
<package-json value="$PROJECT_DIR$/package.json" />
|
||||||
|
<command value="run" />
|
||||||
|
<scripts>
|
||||||
|
<script value="format:fix" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<package-manager value="pnpm" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="test" type="js.build_tools.npm">
|
||||||
|
<package-json value="$PROJECT_DIR$/package.json" />
|
||||||
|
<command value="run" />
|
||||||
|
<scripts>
|
||||||
|
<script value="test" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<package-manager value="pnpm" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="test:ui" type="js.build_tools.npm">
|
||||||
|
<package-json value="$PROJECT_DIR$/package.json" />
|
||||||
|
<command value="run" />
|
||||||
|
<scripts>
|
||||||
|
<script value="test:ui" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<package-manager value="pnpm" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"recommendations": [
|
"recommendations": [
|
||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
"esbenp.prettier-vscode",
|
"esbenp.prettier-vscode",
|
||||||
"yoavbls.pretty-ts-errors"
|
"yoavbls.pretty-ts-errors",
|
||||||
|
"million.million-lint"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -4,13 +4,32 @@
|
|||||||
"mode": "auto"
|
"mode": "auto"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"eslint.experimental.useFlatConfig": true,
|
||||||
"typescript.tsdk": "node_modules\\typescript\\lib",
|
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||||
"js/ts.implicitProjectConfig.experimentalDecorators": true,
|
"js/ts.implicitProjectConfig.experimentalDecorators": true,
|
||||||
"prettier.configPath": "./tooling/prettier/index.mjs",
|
"prettier.configPath": "./tooling/prettier/index.mjs",
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"superjson",
|
"ajnart",
|
||||||
|
"cqmin",
|
||||||
|
"gridstack",
|
||||||
"homarr",
|
"homarr",
|
||||||
|
"jellyfin",
|
||||||
|
"mantine",
|
||||||
|
"manuel-rw",
|
||||||
|
"Meierschlumpf",
|
||||||
|
"overseerr",
|
||||||
|
"Sabnzbd",
|
||||||
|
"SeDemal",
|
||||||
|
"Sonarr",
|
||||||
|
"superjson",
|
||||||
|
"tabler",
|
||||||
"trpc",
|
"trpc",
|
||||||
"Umami"
|
"Umami"
|
||||||
]
|
],
|
||||||
}
|
"i18n-ally.dirStructure": "auto",
|
||||||
|
"i18n-ally.enabledFrameworks": ["next-international"],
|
||||||
|
"i18n-ally.localesPaths": ["./packages/translation/src/lang/"],
|
||||||
|
"i18n-ally.enabledParsers": ["ts"],
|
||||||
|
"i18n-ally.extract.keyMaxLength": 0,
|
||||||
|
"i18n-ally.keystyle": "flat"
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
FROM node:20.14.0-alpine AS base
|
FROM node:20.18.0-alpine AS base
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
RUN apk update
|
RUN apk update
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -11,6 +12,7 @@ RUN turbo prune @homarr/nextjs --docker --out-dir ./next-out
|
|||||||
RUN turbo prune @homarr/tasks --docker --out-dir ./tasks-out
|
RUN turbo prune @homarr/tasks --docker --out-dir ./tasks-out
|
||||||
RUN turbo prune @homarr/websocket --docker --out-dir ./websocket-out
|
RUN turbo prune @homarr/websocket --docker --out-dir ./websocket-out
|
||||||
RUN turbo prune @homarr/db --docker --out-dir ./migration-out
|
RUN turbo prune @homarr/db --docker --out-dir ./migration-out
|
||||||
|
RUN turbo prune @homarr/cli --docker --out-dir ./cli-out
|
||||||
|
|
||||||
# Add lockfile and package.json's of isolated subworkspace
|
# Add lockfile and package.json's of isolated subworkspace
|
||||||
FROM base AS installer
|
FROM base AS installer
|
||||||
@@ -22,20 +24,19 @@ WORKDIR /app
|
|||||||
COPY .gitignore .gitignore
|
COPY .gitignore .gitignore
|
||||||
|
|
||||||
COPY --from=builder /app/tasks-out/json/ .
|
COPY --from=builder /app/tasks-out/json/ .
|
||||||
COPY --from=builder /app/tasks-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
|
||||||
RUN corepack enable pnpm && pnpm install
|
|
||||||
|
|
||||||
COPY --from=builder /app/websocket-out/json/ .
|
COPY --from=builder /app/websocket-out/json/ .
|
||||||
COPY --from=builder /app/websocket-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
|
||||||
RUN corepack enable pnpm && pnpm install
|
|
||||||
|
|
||||||
COPY --from=builder /app/migration-out/json/ .
|
COPY --from=builder /app/migration-out/json/ .
|
||||||
COPY --from=builder /app/migration-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
COPY --from=builder /app/cli-out/json/ .
|
||||||
RUN corepack enable pnpm && pnpm install
|
|
||||||
|
|
||||||
COPY --from=builder /app/next-out/json/ .
|
COPY --from=builder /app/next-out/json/ .
|
||||||
COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
|
|
||||||
|
# Is used for postinstall of docs definitions
|
||||||
|
COPY --from=builder /app/packages/definitions/src/docs ./packages/definitions/src/docs
|
||||||
|
|
||||||
|
# Uses the lockfile to install the dependencies
|
||||||
|
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
|
||||||
|
|
||||||
|
# Install sharp for image optimization
|
||||||
RUN corepack enable pnpm && pnpm install sharp -w
|
RUN corepack enable pnpm && pnpm install sharp -w
|
||||||
|
|
||||||
# Build the project
|
# Build the project
|
||||||
@@ -43,24 +44,45 @@ COPY --from=builder /app/tasks-out/full/ .
|
|||||||
COPY --from=builder /app/websocket-out/full/ .
|
COPY --from=builder /app/websocket-out/full/ .
|
||||||
COPY --from=builder /app/next-out/full/ .
|
COPY --from=builder /app/next-out/full/ .
|
||||||
COPY --from=builder /app/migration-out/full/ .
|
COPY --from=builder /app/migration-out/full/ .
|
||||||
|
COPY --from=builder /app/cli-out/full/ .
|
||||||
|
|
||||||
# Copy static data as it is not part of the build
|
# Copy static data as it is not part of the build
|
||||||
COPY static-data ./static-data
|
COPY static-data ./static-data
|
||||||
ARG SKIP_ENV_VALIDATION=true
|
ARG SKIP_ENV_VALIDATION='true'
|
||||||
RUN corepack enable pnpm && pnpm turbo run build
|
ARG CI='true'
|
||||||
|
ARG DISABLE_REDIS_LOGS='true'
|
||||||
|
RUN corepack enable pnpm && pnpm build
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache redis
|
# gettext is required for envsubst
|
||||||
|
RUN apk add --no-cache redis nginx bash gettext
|
||||||
RUN mkdir /appdata
|
RUN mkdir /appdata
|
||||||
RUN mkdir /appdata/db
|
RUN mkdir /appdata/db
|
||||||
RUN mkdir /appdata/redis
|
RUN mkdir /appdata/redis
|
||||||
VOLUME /appdata
|
VOLUME /appdata
|
||||||
|
RUN mkdir /secrets
|
||||||
|
VOLUME /secrets
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Don't run production as root
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Enable homarr cli
|
||||||
|
COPY --from=installer --chown=nextjs:nodejs /app/packages/cli/cli.cjs /app/apps/cli/cli.cjs
|
||||||
|
RUN echo $'#!/bin/bash\ncd /app/apps/cli && node ./cli.cjs "$@"' > /usr/bin/homarr
|
||||||
|
RUN chmod +x /usr/bin/homarr
|
||||||
|
|
||||||
|
# Don't run production as root
|
||||||
RUN chown -R nextjs:nodejs /appdata
|
RUN chown -R nextjs:nodejs /appdata
|
||||||
|
RUN chown -R nextjs:nodejs /secrets
|
||||||
|
RUN mkdir -p /var/cache/nginx && chown -R nextjs:nodejs /var/cache/nginx && \
|
||||||
|
mkdir -p /var/log/nginx && chown -R nextjs:nodejs /var/log/nginx && \
|
||||||
|
mkdir -p /var/lib/nginx && chown -R nextjs:nodejs /var/lib/nginx && \
|
||||||
|
touch /run/nginx/nginx.pid && chown -R nextjs:nodejs /run/nginx/nginx.pid && \
|
||||||
|
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs && chown -R nextjs:nodejs /etc/nginx
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
COPY --from=installer /app/apps/nextjs/next.config.mjs .
|
COPY --from=installer /app/apps/nextjs/next.config.mjs .
|
||||||
@@ -78,10 +100,14 @@ COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
|
|||||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
|
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
|
||||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
|
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
|
||||||
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
|
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
|
||||||
|
COPY --chown=nextjs:nodejs scripts/generateEncryptionKey.js ./generateEncryptionKey.js
|
||||||
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
|
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
|
||||||
|
COPY --chown=nextjs:nodejs nginx.conf /etc/nginx/templates/nginx.conf
|
||||||
|
|
||||||
|
|
||||||
ENV DB_URL='/appdata/db/db.sqlite'
|
ENV DB_URL='/appdata/db/db.sqlite'
|
||||||
ENV DB_DIALECT='sqlite'
|
ENV DB_DIALECT='sqlite'
|
||||||
ENV DB_DRIVER='better-sqlite3'
|
ENV DB_DRIVER='better-sqlite3'
|
||||||
|
ENV AUTH_PROVIDERS='credentials'
|
||||||
|
|
||||||
CMD ["sh", "run.sh"]
|
CMD ["sh", "run.sh"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import baseConfig from "@homarr/eslint-config/base";
|
||||||
|
import nextjsConfig from "@homarr/eslint-config/nextjs";
|
||||||
|
import reactConfig from "@homarr/eslint-config/react";
|
||||||
|
|
||||||
|
/** @type {import('typescript-eslint').Config} */
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: [".next/**"],
|
||||||
|
},
|
||||||
|
...baseConfig,
|
||||||
|
...reactConfig,
|
||||||
|
...nextjsConfig,
|
||||||
|
];
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
// Importing env files here to validate on build
|
// Importing env files here to validate on build
|
||||||
import "@homarr/auth/env.mjs";
|
import "@homarr/auth/env.mjs";
|
||||||
|
|
||||||
|
import MillionLint from "@million/lint";
|
||||||
|
|
||||||
import "./src/env.mjs";
|
import "./src/env.mjs";
|
||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
@@ -9,13 +12,14 @@ const config = {
|
|||||||
/** We already do linting and typechecking as separate tasks in CI */
|
/** We already do linting and typechecking as separate tasks in CI */
|
||||||
eslint: { ignoreDuringBuilds: true },
|
eslint: { ignoreDuringBuilds: true },
|
||||||
typescript: { ignoreBuildErrors: true },
|
typescript: { ignoreBuildErrors: true },
|
||||||
webpack: (config) => {
|
webpack: (config, { isServer }) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
if (isServer) {
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
test: /\.node$/,
|
test: /\.node$/,
|
||||||
loader: "node-loader",
|
loader: "node-loader",
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
@@ -27,4 +31,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error
|
||||||
|
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
@@ -7,61 +7,72 @@
|
|||||||
"build": "pnpm with-env next build",
|
"build": "pnpm with-env next build",
|
||||||
"clean": "git clean -xdf .next .turbo node_modules",
|
"clean": "git clean -xdf .next .turbo node_modules",
|
||||||
"dev": "pnpm with-env next dev",
|
"dev": "pnpm with-env next dev",
|
||||||
"lint": "dotenv -v SKIP_ENV_VALIDATION=1 next lint",
|
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
|
"lint": "eslint",
|
||||||
"start": "pnpm with-env next start",
|
"start": "pnpm with-env next start",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"with-env": "dotenv -e ../../.env --"
|
"with-env": "dotenv -e ../../.env --"
|
||||||
},
|
},
|
||||||
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/analytics": "workspace:^0.1.0",
|
"@homarr/analytics": "workspace:^0.1.0",
|
||||||
"@homarr/api": "workspace:^0.1.0",
|
"@homarr/api": "workspace:^0.1.0",
|
||||||
"@homarr/auth": "workspace:^0.1.0",
|
"@homarr/auth": "workspace:^0.1.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
|
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/form": "workspace:^0.1.0",
|
"@homarr/form": "workspace:^0.1.0",
|
||||||
"@homarr/gridstack": "^1.0.0",
|
"@homarr/gridstack": "^1.0.3",
|
||||||
"@homarr/integrations": "workspace:^0.1.0",
|
"@homarr/integrations": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^",
|
"@homarr/log": "workspace:^",
|
||||||
"@homarr/modals": "workspace:^0.1.0",
|
"@homarr/modals": "workspace:^0.1.0",
|
||||||
|
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||||
"@homarr/notifications": "workspace:^0.1.0",
|
"@homarr/notifications": "workspace:^0.1.0",
|
||||||
|
"@homarr/old-schema": "workspace:^0.1.0",
|
||||||
|
"@homarr/redis": "workspace:^0.1.0",
|
||||||
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
"@homarr/spotlight": "workspace:^0.1.0",
|
"@homarr/spotlight": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@homarr/widgets": "workspace:^0.1.0",
|
"@homarr/widgets": "workspace:^0.1.0",
|
||||||
"@mantine/colors-generator": "^7.10.1",
|
"@mantine/colors-generator": "^7.13.4",
|
||||||
"@mantine/hooks": "^7.10.1",
|
"@mantine/core": "^7.13.4",
|
||||||
"@mantine/modals": "^7.10.1",
|
"@mantine/hooks": "^7.13.4",
|
||||||
"@mantine/tiptap": "^7.10.1",
|
"@mantine/modals": "^7.13.4",
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@mantine/tiptap": "^7.13.4",
|
||||||
"@t3-oss/env-nextjs": "^0.10.1",
|
"@million/lint": "1.0.11",
|
||||||
"@tanstack/react-query": "^5.40.0",
|
"@t3-oss/env-nextjs": "^0.11.1",
|
||||||
"@tanstack/react-query-devtools": "^5.40.0",
|
"@tabler/icons-react": "^3.20.0",
|
||||||
"@tanstack/react-query-next-experimental": "5.40.0",
|
"@tanstack/react-query": "^5.59.16",
|
||||||
"@trpc/client": "11.0.0-rc.382",
|
"@tanstack/react-query-devtools": "^5.59.16",
|
||||||
|
"@tanstack/react-query-next-experimental": "5.59.16",
|
||||||
|
"@trpc/client": "next",
|
||||||
"@trpc/next": "next",
|
"@trpc/next": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
"@trpc/server": "next",
|
"@trpc/server": "next",
|
||||||
"@xterm/addon-canvas": "^0.7.0",
|
"@xterm/addon-canvas": "^0.7.0",
|
||||||
"@xterm/addon-fit": "0.10.0",
|
"@xterm/addon-fit": "0.10.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"chroma-js": "^2.4.2",
|
"chroma-js": "^3.1.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.13",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"flag-icons": "^7.2.3",
|
"flag-icons": "^7.2.3",
|
||||||
"glob": "^10.4.1",
|
"glob": "^11.0.0",
|
||||||
"jotai": "^2.8.2",
|
"jotai": "^2.10.1",
|
||||||
"mantine-react-table": "2.0.0-beta.3",
|
"mantine-react-table": "2.0.0-beta.7",
|
||||||
"next": "^14.2.3",
|
"next": "^14.2.16",
|
||||||
"postcss-preset-mantine": "^1.15.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"react": "18.3.1",
|
"prismjs": "^1.29.0",
|
||||||
"react-dom": "18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-error-boundary": "^4.0.13",
|
"react-dom": "^18.3.1",
|
||||||
"sass": "^1.77.3",
|
"react-error-boundary": "^4.1.2",
|
||||||
|
"react-simple-code-editor": "^0.14.1",
|
||||||
|
"sass": "^1.80.4",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
|
"swagger-ui-react": "^5.17.14",
|
||||||
"use-deep-compare-effect": "^1.8.1"
|
"use-deep-compare-effect": "^1.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -69,23 +80,15 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/chroma-js": "2.4.4",
|
"@types/chroma-js": "2.4.4",
|
||||||
"@types/node": "^20.12.13",
|
"@types/node": "^20.17.1",
|
||||||
"@types/react": "^18.3.3",
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react": "^18.3.12",
|
||||||
"concurrently": "^8.2.2",
|
"@types/react-dom": "^18.3.1",
|
||||||
"eslint": "^8.57.0",
|
"@types/swagger-ui-react": "^4.18.3",
|
||||||
|
"concurrently": "^9.0.1",
|
||||||
|
"eslint": "^9.13.0",
|
||||||
"node-loader": "^2.0.0",
|
"node-loader": "^2.0.0",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.3.3",
|
||||||
"tsx": "4.11.0",
|
"typescript": "^5.6.3"
|
||||||
"typescript": "^5.4.5"
|
}
|
||||||
},
|
|
||||||
"eslintConfig": {
|
|
||||||
"root": true,
|
|
||||||
"extends": [
|
|
||||||
"@homarr/eslint-config/base",
|
|
||||||
"@homarr/eslint-config/nextjs",
|
|
||||||
"@homarr/eslint-config/react"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"prettier": "@homarr/prettier-config"
|
|
||||||
}
|
}
|
||||||
|
After Width: | Height: | Size: 497 B |
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg width="1024" height="1024" version="1.1" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="a">
|
||||||
|
<circle cx="512" cy="512" r="410" fill="#989898" stroke-width="3.5906"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="b">
|
||||||
|
<path d="m512 636l0-565 -330 565 330 0z" fill="#000"/>
|
||||||
|
<path d="m512 388l0 565 330-565 -330 0z" fill="#000"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g id="lidarr-group-logo" transform="translate(5e-6 .26738)">
|
||||||
|
<circle id="lidarr-black-circle" cx="512" cy="512" r="410" stroke-width="1.8"/>
|
||||||
|
<path id="lidarr-honeycomb-a" d="m852.12 33.49l23.46 14.44 23.22-14.44 0-28.87 -23.22-14.44 -23.46 14.44 0 28.87zm-46.44-28.87l-23.22-14.44 -23.2 14.44 0 28.87 23.2 14.44 23.22-14.44 0-28.87 23.22-14.44 23.22 14.44m-46.44 28.87l23.22 14.44 23.22-14.44m-46.44 57.49l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.22 14.18 -23.22-14.18 -23.2 14.18m92.86 28.87l23.46 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.46 14.18m23.46-43.05l0 28.87m-46.68-28.87l0 28.87m69.9-72.18l23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.2-14.44 23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.18 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.45 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.18 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.2-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.18 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.45 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.18 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.46-14.44 23.22 14.44 23.22-14.44 23.2 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.46-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.2-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.2-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.46-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44m-46.44 0l0 28.87 23.22 14.44 23.22-14.44m-46.44 57.49l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.22 14.18 -23.22-14.18 -23.22 14.18m116.08-43.05l0 28.87m-46.42-28.87l0 28.87m23.22 100.79l0 28.87 23.2 14.44 23.22-14.44 0-28.87 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 0 28.87 23.22 14.44 23.22-14.44m46.42-86.61l-23.22 14.44 0 28.87m-23.2-43.31l23.2 14.44m-46.42 0l0 28.87m0 57.74l0 28.87 23.22 14.16 23.2-14.16 0-28.87m46.44-86.61l0 28.87 23.22 14.44 23.46-14.44 0-28.87m-46.68 28.87l-23.22 14.44m-23.22 72.18l23.22 14.16 23.22-14.16 0-28.87 -23.22-14.44m46.44-28.87l0 28.87 23.46 14.44 23.22-14.44 0-28.87 -23.22-14.44m0 57.74l0 28.87 23.22 14.16 23.22-14.16 0-28.87 -23.22-14.44m-69.9 14.44l23.22-14.44m-185.74-201.84l0 28.87 23.22 14.44 23.22-14.44m-139.56-28.87l0 28.87 23.22 14.44 23.22-14.44 0-28.87m0 28.87l23.46 14.44 23.22-14.44m-46.68 57.49l0 28.87 23.46 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.46 14.18 -23.22-14.18 -23.22 14.18 0 28.87 23.22 14.44 23.22-14.44m-23.22-71.92l0 28.87m93.12-28.87l0 28.87m-46.44-28.87l0 28.87m-116.32-72.18l0 28.87 23.2 14.44 23.22-14.44m-92.86-28.87l0 28.87 23.22 14.44 23.22-14.44m-23.22 43.31l-23.22 14.18 0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.18zm69.64 14.18l-23.22-14.18 -23.2 14.18m-23.22-43.05l0 28.87m46.42-28.87l0 28.87m23.22 43.05l-23.22 14.44 0 28.87 23.22 14.44 23.22-14.44 0-28.87m-92.86 0l0 28.87 23.22 14.44 23.2-14.44m-46.42 86.61l23.22 14.16 23.2-14.16 0-28.87 -23.2-14.44 -23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 23.22-14.44 0-28.87m23.2-43.03l23.22-14.44 0-28.87m-46.42-57.74l23.2 14.44m-23.2 72.18l0-28.87m116.32-43.31l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44m-93.12 86.61l23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44m46.68 0l-23.46 14.44m23.46 72.18l23.22 14.16 23.22-14.16 0-28.87 -23.22-14.44 -23.22 14.44 0 28.87 -23.46 14.16 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 23.46-14.44 0-28.87 -23.46-14.44m-69.66-43.03l23.22 14.16 23.22-14.16 0-28.87m23.22-14.44l23.46 14.44m23.22-14.44l0-28.87m0 86.34l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.16m-69.9 100.77l0 28.87 23.46 14.44 23.22-14.44 0-28.87 -23.22-14.44m23.22 43.31l23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44 -23.22 14.44m-23.22-43.31l23.22-14.44m23.22 14.44l0 28.87m-93.12-28.87l-23.22-14.44 -23.22 14.44 0 28.87 23.22 14.44 23.22-14.44m-23.22-43.31l0-28.87m-69.64 72.18l-23.22 14.44 0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44zm23.22 4Line truncated
|
||||||
|
<ellipse cx="512" cy="512" rx="460" ry="460" style="fill:none;stroke-width:99;stroke:#e5e5e5"/>
|
||||||
|
<ellipse cx="512" cy="512" rx="270" ry="270" style="fill:#e5e5e5;stroke-width:76;stroke:#e5e5e5"/>
|
||||||
|
<ellipse ry="410" rx="410" cy="512" cx="512" style="fill-opacity:null;fill:none;stroke-width:12;stroke:#009252"/>
|
||||||
|
<path d="m512 636l0-565 -330 565 330 0z" fill="#000"/>
|
||||||
|
<path d="m512 388l0 565 330-565 -330 0z" fill="#000"/>
|
||||||
|
<path id="lidarr-honeycomb-b" d="m852.12 33.49l23.46 14.44 23.22-14.44 0-28.87 -23.22-14.44 -23.46 14.44 0 28.87zm-46.44-28.87l-23.22-14.44 -23.2 14.44 0 28.87 23.2 14.44 23.22-14.44 0-28.87 23.22-14.44 23.22 14.44m-46.44 28.87l23.22 14.44 23.22-14.44m-46.44 57.49l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.22 14.18 -23.22-14.18 -23.2 14.18m92.86 28.87l23.46 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.46 14.18m23.46-43.05l0 28.87m-46.68-28.87l0 28.87m69.9-72.18l23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.2-14.44 23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.18 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.45 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 0 28.87 -23.22 14.18 0 28.87 23.22 14.44 0 28.87 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.2-14.44 -23.22 14.44 -23.22-14.44 -23.46 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 -23.22 14.44 -23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.18 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.45 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.16 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 0-28.87 -23.22-14.44 0-28.87 23.22-14.18 0-28.87 -23.22-14.44 0-28.87 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.46-14.44 23.22 14.44 23.22-14.44 23.2 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.46-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.2-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.2-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.46-14.44 23.22 14.44 23.22-14.44 23.22 14.44 23.22-14.44 23.22 14.44m-46.44 0l0 28.87 23.22 14.44 23.22-14.44m-46.44 57.49l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.22 14.18 -23.22-14.18 -23.22 14.18m116.08-43.05l0 28.87m-46.42-28.87l0 28.87m23.22 100.79l0 28.87 23.2 14.44 23.22-14.44 0-28.87 -23.22-14.44 -23.2 14.44 -23.22-14.44 -23.22 14.44 0 28.87 23.22 14.44 23.22-14.44m46.42-86.61l-23.22 14.44 0 28.87m-23.2-43.31l23.2 14.44m-46.42 0l0 28.87m0 57.74l0 28.87 23.22 14.16 23.2-14.16 0-28.87m46.44-86.61l0 28.87 23.22 14.44 23.46-14.44 0-28.87m-46.68 28.87l-23.22 14.44m-23.22 72.18l23.22 14.16 23.22-14.16 0-28.87 -23.22-14.44m46.44-28.87l0 28.87 23.46 14.44 23.22-14.44 0-28.87 -23.22-14.44m0 57.74l0 28.87 23.22 14.16 23.22-14.16 0-28.87 -23.22-14.44m-69.9 14.44l23.22-14.44m-185.74-201.84l0 28.87 23.22 14.44 23.22-14.44m-139.56-28.87l0 28.87 23.22 14.44 23.22-14.44 0-28.87m0 28.87l23.46 14.44 23.22-14.44m-46.68 57.49l0 28.87 23.46 14.44 23.22-14.44 0-28.87 -23.22-14.18 -23.46 14.18 -23.22-14.18 -23.22 14.18 0 28.87 23.22 14.44 23.22-14.44m-23.22-71.92l0 28.87m93.12-28.87l0 28.87m-46.44-28.87l0 28.87m-116.32-72.18l0 28.87 23.2 14.44 23.22-14.44m-92.86-28.87l0 28.87 23.22 14.44 23.22-14.44m-23.22 43.31l-23.22 14.18 0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.18zm69.64 14.18l-23.22-14.18 -23.2 14.18m-23.22-43.05l0 28.87m46.42-28.87l0 28.87m23.22 43.05l-23.22 14.44 0 28.87 23.22 14.44 23.22-14.44 0-28.87m-92.86 0l0 28.87 23.22 14.44 23.2-14.44m-46.42 86.61l23.22 14.16 23.2-14.16 0-28.87 -23.2-14.44 -23.22 14.44 0 28.87 -23.22 14.16 0 28.87 23.22 14.44 23.22-14.44 0-28.87m23.2-43.03l23.22-14.44 0-28.87m-46.42-57.74l23.2 14.44m-23.2 72.18l0-28.87m116.32-43.31l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44m-93.12 86.61l23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44m46.68 0l-23.46 14.44m23.46 72.18l23.22 14.16 23.22-14.16 0-28.87 -23.22-14.44 -23.22 14.44 0 28.87 -23.46 14.16 0 28.87 -23.22 14.44 0 28.87 23.22 14.44 23.46-14.44 0-28.87 -23.46-14.44m-69.66-43.03l23.22 14.16 23.22-14.16 0-28.87m23.22-14.44l23.46 14.44m23.22-14.44l0-28.87m0 86.34l0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.16m-69.9 100.77l0 28.87 23.46 14.44 23.22-14.44 0-28.87 -23.22-14.44m23.22 43.31l23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44 -23.22 14.44m-23.22-43.31l23.22-14.44m23.22 14.44l0 28.87m-93.12-28.87l-23.22-14.44 -23.22 14.44 0 28.87 23.22 14.44 23.22-14.44m-23.22-43.31l0-28.87m-69.64 72.18l-23.22 14.44 0 28.87 23.22 14.44 23.22-14.44 0-28.87 -23.22-14.44zm23.22 4Line truncated
|
||||||
|
<path d="m512 636l0-565 -330 565 330 0z" style="fill:none;stroke-width:16;stroke:#009252;"/>
|
||||||
|
<path d="m512 388l0 565 330-565 -330 0z" style="fill:none;stroke-width:16;stroke:#009252;"/>
|
||||||
|
<ellipse cx="512" cy="512" rx="150" ry="150" style="fill-opacity:null;fill:#009252;stroke-opacity:null;stroke-width:null;stroke:null"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1024" viewBox="0 0 1024 1024" width="1024" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="m0 0h1024v1024h-1024z"/><clipPath id="b"><use clip-rule="evenodd" xlink:href="#a"/></clipPath></defs><g clip-path="url(#b)"><use fill="none" xlink:href="#a"/><g transform="translate(70 21.00012)"><path d="m105.302 154.943 7.522 714.549c-60.173 7.522-105.30242-22.565-105.30242-82.737l-7.52158-594.205c0-188.03894 172.996-233.1684 278.298-157.9526l534.032 308.3846c75.216 52.651 90.259 150.431 52.651 218.125-7.521-52.651-30.086-82.737-75.216-112.823l-601.726-338.471c-45.129-30.0862-82.737-22.5646-82.737 45.13z" fill="#24292e"/><path d="m0 376.079c45.1295 15.043 90.259 7.521 127.867-15.043l616.769-361.036c37.608 52.651 30.087 105.302-15.043 135.388l-518.989 300.863c-75.216 37.608-172.9961 0-210.604-60.172z" fill="#24292e" transform="translate(60.17249 531.0214)"/><path d="m0 413.687 368.557-210.604-361.03543-203.083z" fill="#ffc230" transform="translate(240.6902 282.8092)"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg version="1.1" viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.97895 0 0 .97895 -2.2026 -2.2026)"><g stroke="#443c3c" stroke-width="1.5"><circle cx="513" cy="513" r="510" fill="#eee"/><circle cx="513" cy="513" r="440" fill="#443c3c"/><circle cx="513" cy="513" r="387" fill="#8e2222"/></g><g stroke-width="1.5"><circle cx="513" cy="513" r="378" fill="#eee" stroke="#8e2222"/><circle cx="511.67" cy="514.33" r="265" fill="#443c3c" stroke="#443c3c"/></g><g stroke="#8e2222"><path d="m176.71 682.24-5.71-356.67c0.634-53.106 17.5-47.829 30.454-49.405 198.58 10.83 270.91 71.252 275.35 73.499 13.323 5.018 20.937 31.782 20.302 31.123 0.634 0.658 4.441 420.6 3.807 419.94 3.172 22.455-13.323 21.002-13.958 20.343-124.99-98.152-297.56-122.85-298.19-123.51-12.055-0.795-12.055-15.326-12.055-15.326zm670.08 0.82 5.711-357.54c-0.635-53.236-17.501-47.946-30.456-49.526-198.6 10.857-270.93 71.426-275.38 73.679-13.325 5.03-20.939 31.859-20.304 31.199-0.635 0.66-4.442 421.63-3.807 420.97-3.173 22.51 13.325 21.053 13.959 20.393 125-98.392 297.58-123.15 298.22-123.81 12.056-0.797 12.056-15.363 12.056-15.363z" fill="#eee" stroke-width="10"/><path d="m174.14 739.57-5.802-356.67c0.645-53.106 17.782-47.829 30.945-49.405 201.79 10.83 275.28 71.252 279.8 73.499 13.539 5.018 21.275 31.782 20.63 31.123 0.645 0.658 4.513 420.6 3.868 419.94 3.224 22.455-13.539 21.002-14.183 20.343-127-98.152-302.36-122.85-303.01-123.51-12.249-0.795-12.249-15.326-12.249-15.326zm675.22 0.49 5.803-357.54c-0.645-53.236-17.784-47.946-30.948-49.526-201.81 10.857-275.31 71.426-279.82 73.679-13.54 5.03-21.277 31.859-20.632 31.199-0.645 0.66-4.513 421.63-3.869 420.97-3.224 22.51 13.54 21.053 14.184 20.393 127.02-98.392 302.39-123.15 303.03-123.81 12.25-0.797 12.25-15.363 12.25-15.363z" fill="#8e2222" stroke-width="5"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><g clip-rule="evenodd"><path fill="#eee" fill-rule="evenodd" d="M47.978 24c0 6.602-2.331 12.26-6.993 16.974a3.773 3.773 0 0 1-.52.509 20.53 20.53 0 0 1-2.435 2.047C33.988 46.51 29.318 48 24.022 48c-5.304 0-9.966-1.49-13.986-4.47a21.726 21.726 0 0 1-2.988-2.556c-3.622-3.6-5.846-7.783-6.672-12.548-.162-.93-.27-1.874-.32-2.833a38.27 38.27 0 0 1 0-3.197c0-.052.014-.104.044-.155.346-5.887 2.662-10.973 6.948-15.259C11.762 2.327 17.42 0 24.022 0c6.624 0 12.279 2.327 16.963 6.982 4.662 4.743 6.993 10.416 6.993 17.018z"/><path fill="#3a3f51" fill-rule="evenodd" d="m43.098 9.405-4.957 4.957c-2.899 2.899-3.153 5.422-3.153 9.87 0 3.97.63 7.602 3.585 10.556 2.156 2.157 4.204 4.194 4.204 4.194a27.962 27.962 0 0 1-1.792 1.992 3.773 3.773 0 0 1-.52.509 20.05 20.05 0 0 1-1.749 1.538l-3.883-3.884c-3.452-3.452-6.196-3.784-10.756-3.784-4.375 0-7.352.403-10.556 3.607a2715.831 2715.831 0 0 0-4.105 4.116 21.196 21.196 0 0 1-2.368-2.102 27.739 27.739 0 0 1-1.737-1.903s2.168-2.18 4.238-4.25c3.066-3.065 3.563-6.62 3.563-10.589 0-3.872-.636-7.485-3.452-10.301C7.705 11.975 5 9.284 5 9.284a25.954 25.954 0 0 1 2.047-2.302A29.761 29.761 0 0 1 9.04 5.201l4.504 4.503c2.877 2.877 6.565 3.618 10.533 3.618 4.087 0 7.763-.791 10.756-3.784 1.84-1.841 4.27-4.26 4.27-4.26a25.168 25.168 0 0 1 1.882 1.704c.767.782 1.471 1.59 2.113 2.423z"/><path fill="#0cf" fill-rule="evenodd" d="M17.438 25.228a6.986 6.986 0 0 1-.1-1.228c0-.155.005-.303.012-.443 0-.014.004-.029.011-.044.096-1.63.738-3.039 1.925-4.227 1.306-1.29 2.874-1.936 4.703-1.936 1.837 0 3.404.645 4.703 1.936 1.29 1.313 1.936 2.884 1.936 4.714s-.645 3.397-1.936 4.703c-.045.051-.093.1-.144.143a6.056 6.056 0 0 1-.675.565c-1.121.826-2.416 1.239-3.884 1.239s-2.759-.413-3.873-1.24a5.818 5.818 0 0 1-.83-.707c-1.003-.996-1.619-2.155-1.848-3.475z"/><path fill="none" stroke="#0cf" stroke-miterlimit="1" stroke-width=".4426" d="m34.943 13.223-3.32 3.242M6.834 7.198l9.044 9.012M34.6 34.855l6.154 6.369m.41-34.056-6.22 6.056M7.18 41.107l6.053-6.063"/><path fill="none" stroke="#0cf" stroke-miterlimit="1" stroke-width="1.5491" d="m34.943 13.223-3.75 3.806m-18.12-3.617 3.806 3.795m-3.662 17.854 3.706-3.851m13.705-.309 3.99 3.971"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="100px" height="54px" viewBox="0 0 100 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>Logo tvdb</title>
|
||||||
|
<g id="Logo-tvdb" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M0,5.09590006 C0,1.81024006 2.9636,-0.441498938 6.46228,0.0733078623 L6.46228,0.0733078623 L52.10124,6.03470006 C54.15254,6.33652006 55.78724,8.54666006 55.78724,10.9536001 L55.78724,10.9536001 L55.78654,17.1835001 C51.94104,19.7605001 49.42044,24.0737001 49.42044,28.9596001 C49.42044,33.8924001 51.87974,38.1680001 55.78724,40.7361001 L55.78724,40.7361001 L55.78724,43.4756001 C55.78724,45.8825001 54.15254,48.0927001 52.10124,48.3945001 L52.10124,48.3945001 L11.60314,53.9266001 C8.10444,54.4417001 5.14084,52.1897001 5.14084,48.9040001 L5.14084,48.9040001 Z M19.68044,10.8218001 L13.66114,10.8218001 L13.66114,18.7064001 L9.84244,18.7064001 L9.84244,23.2621001 L13.66114,23.2621001 L13.66114,32.0227001 C13.4846091,37.5274601 15.6467584,39.9923503 20.6149401,40.0386142 L25.25134,40.0387001 L25.25134,35.4830001 L22.87064,35.4830001 C20.17484,35.3516001 19.59134,34.5631001 19.68074,31.0149001 L19.68074,23.2617001 L27.08014,23.2617001 L33.93424,40.0384001 L40.40294,40.0384001 L49.83694,18.7061001 L43.45734,18.7061001 L37.34794,33.3806001 L31.77694,18.7064001 L19.68044,18.7064001 L19.68044,10.8218001 Z" id="Combined-Shape" fill="#6CD591" fill-rule="nonzero"></path>
|
||||||
|
<path d="M88.60974,18.2771001 C92.51784,18.2771001 95.12314,19.2407001 97.09994,21.4310001 C98.71734,23.1831001 99.57074,25.7677001 99.57074,28.6584001 C99.57074,32.8634001 97.86394,36.1487001 94.76414,38.0323001 C92.74234,39.2590001 90.99054,39.6094001 87.03734,39.6094001 L77.24404,39.6094001 L77.24404,10.3925001 L83.26404,10.3925001 L83.26404,18.2771001 L88.60974,18.2771001 Z M83.26404,35.0537001 L87.71094,35.0537001 C91.26004,35.0537001 93.41634,32.6884001 93.41634,28.8334001 C93.41634,24.8035001 91.52964,22.8324001 87.71094,22.8324001 L83.26404,22.8324001 L83.26404,35.0537001 Z" id="Shape" fill="#FFFFFF" fill-rule="nonzero"></path>
|
||||||
|
<path d="M68.01354,10.3925001 L74.03354,10.3925001 L74.03354,39.6094001 L63.65594,39.6094001 C59.43354,39.6094001 57.41174,38.9962001 55.25524,37.1126001 C53.05394,35.1416001 51.93124,32.3384001 51.93124,28.7898001 C51.93124,25.1102001 53.14404,22.3070001 55.70494,20.2481001 C57.32204,18.9342001 59.52364,18.2771001 62.35354,18.2771001 L68.01384,18.2771001 L68.01384,10.3925001 L68.01354,10.3925001 Z M68.01354,22.8327001 L63.65594,22.8327001 C60.15224,22.8327001 58.04064,25.0667001 58.04064,28.7898001 C58.04064,32.6884001 60.19654,35.0537001 63.65594,35.0537001 L68.01354,35.0537001 L68.01354,22.8327001 Z" id="Shape" fill="#FFFFFF" fill-rule="nonzero"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 90 90" xmlns="http://www.w3.org/2000/svg"><g fill="none"><path fill="#31BEEC" d="M90 38.197v19.137L48.942 80.999V61.864z"/><path d="M41.086 61.863V81L0 57.333V38.197l18.566 10.687c.02.016.043.03.067.04l22.453 12.94Z" fill="#0095D5"/><path fill="#AEADAE" d="m61.621 45.506-16.607 9.576-16.622-9.576 16.622-9.575z"/><path fill="#0095D5" d="M86.086 31.416 69.464 40.99 48.942 29.15V10z"/><path fill="#31BEEC" d="M41.086 10v19.15l-20.55 11.827-16.621-9.561z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 484 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><linearGradient id="a" x1="100%" x2="0" y1="0" y2="100%" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff8d30"/><stop offset="1" stop-color="#e32929"/></linearGradient></defs><circle cx="50%" cy="50%" r="50%" fill="url(#a)"/><path fill="#fff" d="M246.6 200.8h18.7v110.6h-18.7zm-182.3 0H83v110.7H64.3zm91.1 123.9h18.7V367h-18.7zm-45.7-47.5h18.7v68.5h-18.7zm91.2 0h18.6v68.4h-18.6zm228.2-76.5h18.7v110.7h-18.7zM338 145.5h18.7v42.3H338zm45.7 21.2h18.7v68.2h-18.7zm-91.5 0h18.7v68.1h-18.7z"/></svg>
|
||||||
|
After Width: | Height: | Size: 577 B |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
||||||
|
import { createTheme, DirectionProvider, MantineProvider } from "@mantine/core";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
|
import { parseCookies, setClientCookie } from "@homarr/common";
|
||||||
|
|
||||||
|
export const CustomMantineProvider = ({ children }: PropsWithChildren) => {
|
||||||
|
const manager = useColorSchemeManager();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DirectionProvider>
|
||||||
|
<MantineProvider
|
||||||
|
defaultColorScheme="dark"
|
||||||
|
colorSchemeManager={manager}
|
||||||
|
theme={createTheme({
|
||||||
|
primaryColor: "red",
|
||||||
|
autoContrast: true,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MantineProvider>
|
||||||
|
</DirectionProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function useColorSchemeManager(): MantineColorSchemeManager {
|
||||||
|
const key = "homarr-color-scheme";
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
|
||||||
|
setClientCookie(key, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutate: mutateColorScheme } = clientApi.user.changeColorScheme.useMutation({
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
updateCookieValue(variables.colorScheme);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: (defaultValue) => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cookies = parseCookies(document.cookie);
|
||||||
|
return (cookies[key] as MantineColorScheme | undefined) ?? defaultValue;
|
||||||
|
} catch {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
set: (value) => {
|
||||||
|
if (value === "auto") return;
|
||||||
|
try {
|
||||||
|
if (session) {
|
||||||
|
mutateColorScheme({ colorScheme: value });
|
||||||
|
}
|
||||||
|
updateCookieValue(value);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[@mantine/core] Color scheme manager was unable to save color scheme.", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
subscribe: () => undefined,
|
||||||
|
unsubscribe: () => undefined,
|
||||||
|
clear: () => undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,14 +1,46 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect } from "react";
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
import { SessionProvider } from "@homarr/auth/client";
|
import { SessionProvider, signIn } from "@homarr/auth/client";
|
||||||
|
|
||||||
interface AuthProviderProps {
|
interface AuthProviderProps extends AuthContextProps {
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthProvider = ({ children, session }: PropsWithChildren<AuthProviderProps>) => {
|
export const AuthProvider = ({ children, session, logoutUrl }: PropsWithChildren<AuthProviderProps>) => {
|
||||||
return <SessionProvider session={session}>{children}</SessionProvider>;
|
useLoginRedirectOnSessionExpiry(session);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionProvider session={session}>
|
||||||
|
<AuthContext.Provider value={{ logoutUrl }}>{children}</AuthContext.Provider>
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AuthContextProps {
|
||||||
|
logoutUrl: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextProps | null>(null);
|
||||||
|
|
||||||
|
export const useAuthContext = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
|
||||||
|
if (!context) throw new Error("useAuthContext must be used within an AuthProvider");
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useLoginRedirectOnSessionExpiry = (session: Session | null) => {
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
if (!session) return () => {};
|
||||||
|
//setTimeout doesn't allow for a number higher than 2147483647 (2³¹-1 , or roughly 24 days)
|
||||||
|
const timeout = setTimeout(() => void signIn(), Math.min(dayjs(session.expires).diff(), 2147483647));
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [session]);
|
||||||
};
|
};
|
||||||
@@ -5,14 +5,37 @@ import { useState } from "react";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||||
import { createWSClient, loggerLink, unstable_httpBatchStreamLink, wsLink } from "@trpc/client";
|
import {
|
||||||
|
createWSClient,
|
||||||
|
httpLink,
|
||||||
|
isNonJsonSerializable,
|
||||||
|
loggerLink,
|
||||||
|
splitLink,
|
||||||
|
unstable_httpBatchStreamLink,
|
||||||
|
wsLink,
|
||||||
|
} from "@trpc/client";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
import type { AppRouter } from "@homarr/api";
|
import type { AppRouter } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
|
||||||
|
import { env } from "~/env.mjs";
|
||||||
|
|
||||||
|
const constructWebsocketUrl = () => {
|
||||||
|
const fallback = "ws://localhost:3001/websockets";
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.NODE_ENV === "development") {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `ws://${window.location.hostname}:${window.location.port}/websockets`;
|
||||||
|
};
|
||||||
|
|
||||||
const wsClient = createWSClient({
|
const wsClient = createWSClient({
|
||||||
url: "ws://localhost:3001",
|
url: constructWebsocketUrl(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function TRPCReactProvider(props: PropsWithChildren) {
|
export function TRPCReactProvider(props: PropsWithChildren) {
|
||||||
@@ -34,18 +57,29 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
enabled: (opts) =>
|
enabled: (opts) =>
|
||||||
process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
||||||
}),
|
}),
|
||||||
(args) => {
|
splitLink({
|
||||||
return ({ op, next }) => {
|
condition: ({ type }) => type === "subscription",
|
||||||
console.log("op", op.type, op.input, op.path, op.id);
|
true: wsLink<AppRouter>({
|
||||||
if (op.type === "subscription") {
|
client: wsClient,
|
||||||
const link = wsLink<AppRouter>({
|
transformer: superjson,
|
||||||
client: wsClient,
|
}),
|
||||||
transformer: superjson,
|
false: splitLink({
|
||||||
});
|
condition: ({ input }) => isNonJsonSerializable(input),
|
||||||
return link(args)({ op, next });
|
true: httpLink({
|
||||||
}
|
/**
|
||||||
|
* We don't want to transform the data here as we want to use form data
|
||||||
return unstable_httpBatchStreamLink({
|
*/
|
||||||
|
transformer: {
|
||||||
|
serialize(object: unknown) {
|
||||||
|
return object;
|
||||||
|
},
|
||||||
|
deserialize(data: unknown) {
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
url: `${getBaseUrl()}/api/trpc`,
|
||||||
|
}),
|
||||||
|
false: unstable_httpBatchStreamLink({
|
||||||
transformer: superjson,
|
transformer: superjson,
|
||||||
url: `${getBaseUrl()}/api/trpc`,
|
url: `${getBaseUrl()}/api/trpc`,
|
||||||
headers() {
|
headers() {
|
||||||
@@ -53,9 +87,9 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
headers.set("x-trpc-source", "nextjs-react");
|
headers.set("x-trpc-source", "nextjs-react");
|
||||||
return headers;
|
return headers;
|
||||||
},
|
},
|
||||||
})(args)({ op, next });
|
}),
|
||||||
};
|
}),
|
||||||
},
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import { CustomPasswordInput } from "@homarr/ui";
|
||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
@@ -64,7 +65,8 @@ export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
|
|||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<TextInput label={t("field.username.label")} autoComplete="off" {...form.getInputProps("username")} />
|
<TextInput label={t("field.username.label")} autoComplete="off" {...form.getInputProps("username")} />
|
||||||
<PasswordInput
|
<CustomPasswordInput
|
||||||
|
withPasswordRequirements
|
||||||
label={t("field.password.label")}
|
label={t("field.password.label")}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
{...form.getInputProps("password")}
|
{...form.getInputProps("password")}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { and, db, eq } from "@homarr/db";
|
import { and, db, eq } from "@homarr/db";
|
||||||
import { invites } from "@homarr/db/schema/sqlite";
|
import { invites } from "@homarr/db/schema/sqlite";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
@@ -19,6 +20,8 @@ interface InviteUsagePageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) {
|
export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) {
|
||||||
|
if (!isProviderEnabled("credentials")) notFound();
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (session) notFound();
|
if (session) notFound();
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +1,185 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Alert, Button, PasswordInput, rem, Stack, TextInput } from "@mantine/core";
|
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
|
||||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
|
||||||
import { signIn } from "@homarr/auth/client";
|
import { signIn } from "@homarr/auth/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import type { useForm } from "@homarr/form";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { z } from "@homarr/validation";
|
import { validation, z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
|
||||||
|
|
||||||
export const LoginForm = () => {
|
interface LoginFormProps {
|
||||||
|
providers: string[];
|
||||||
|
oidcClientName: string;
|
||||||
|
isOidcAutoLoginEnabled: boolean;
|
||||||
|
callbackUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extendedValidation = validation.user.signIn.extend({ provider: z.enum(["credentials", "ldap"]) });
|
||||||
|
|
||||||
|
export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => {
|
||||||
const t = useScopedI18n("user");
|
const t = useScopedI18n("user");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isPending, setIsPending] = useState(false);
|
||||||
const [error, setError] = useState<string>();
|
const form = useZodForm(extendedValidation, {
|
||||||
const form = useZodForm(validation.user.signIn, {
|
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: "",
|
name: "",
|
||||||
password: "",
|
password: "",
|
||||||
|
provider: "credentials",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmitAsync = async (values: z.infer<typeof validation.user.signIn>) => {
|
const credentialInputsVisible = providers.includes("credentials") || providers.includes("ldap");
|
||||||
setIsLoading(true);
|
|
||||||
setError(undefined);
|
|
||||||
await signIn("credentials", {
|
|
||||||
...values,
|
|
||||||
redirect: false,
|
|
||||||
callbackUrl: "/",
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
if (!response?.ok || response.error) {
|
|
||||||
throw response?.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
showSuccessNotification({
|
const onSuccess = useCallback(
|
||||||
title: t("action.login.notification.success.title"),
|
async (response: Awaited<ReturnType<typeof signIn>>) => {
|
||||||
message: t("action.login.notification.success.message"),
|
if (response && (!response.ok || response.error)) {
|
||||||
});
|
// eslint-disable-next-line @typescript-eslint/only-throw-error
|
||||||
router.push("/");
|
throw response.error;
|
||||||
})
|
}
|
||||||
.catch((error: Error | string) => {
|
|
||||||
setIsLoading(false);
|
showSuccessNotification({
|
||||||
setError(error.toString());
|
title: t("action.login.notification.success.title"),
|
||||||
showErrorNotification({
|
message: t("action.login.notification.success.message"),
|
||||||
title: t("action.login.notification.error.title"),
|
|
||||||
message: t("action.login.notification.error.message"),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
// Redirect to the callback URL if the response is defined and comes from a credentials provider (ldap or credentials). oidc is redirected automatically.
|
||||||
|
if (response) {
|
||||||
|
await revalidatePathActionAsync("/");
|
||||||
|
router.push(callbackUrl);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t, router, callbackUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onError = useCallback(() => {
|
||||||
|
setIsPending(false);
|
||||||
|
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("action.login.notification.error.title"),
|
||||||
|
message: t("action.login.notification.error.message"),
|
||||||
|
autoClose: 10000,
|
||||||
|
});
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const signInAsync = useCallback(
|
||||||
|
async (provider: string, options?: Parameters<typeof signIn>[1]) => {
|
||||||
|
setIsPending(true);
|
||||||
|
await signIn(provider, {
|
||||||
|
...options,
|
||||||
|
redirect: false,
|
||||||
|
callbackUrl: new URL(callbackUrl, window.location.href).href,
|
||||||
|
})
|
||||||
|
.then(onSuccess)
|
||||||
|
.catch(onError);
|
||||||
|
},
|
||||||
|
[setIsPending, onSuccess, onError, callbackUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLoginInProgress = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOidcAutoLoginEnabled && !isPending && !isLoginInProgress.current) {
|
||||||
|
isLoginInProgress.current = true;
|
||||||
|
void signInAsync("oidc");
|
||||||
|
}
|
||||||
|
}, [signInAsync, isOidcAutoLoginEnabled, isPending]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
<Stack gap="lg">
|
||||||
<Stack gap="lg">
|
{credentialInputsVisible && (
|
||||||
<TextInput label={t("field.username.label")} {...form.getInputProps("name")} />
|
<>
|
||||||
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
|
<form onSubmit={form.onSubmit((credentials) => void signInAsync(credentials.provider, credentials))}>
|
||||||
<Button type="submit" fullWidth loading={isLoading}>
|
<Stack gap="lg">
|
||||||
{t("action.login.label")}
|
<TextInput label={t("field.username.label")} {...form.getInputProps("name")} />
|
||||||
</Button>
|
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{error && (
|
{providers.includes("credentials") && (
|
||||||
<Alert icon={<IconAlertTriangle size={rem(16)} />} color="red">
|
<Stack gap="sm">
|
||||||
{error}
|
<SubmitButton isPending={isPending} form={form} provider="credentials">
|
||||||
</Alert>
|
{t("action.login.label")}
|
||||||
)}
|
</SubmitButton>
|
||||||
|
<PasswordForgottenCollapse username={form.values.name} />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{providers.includes("ldap") && (
|
||||||
|
<SubmitButton isPending={isPending} form={form} provider="ldap">
|
||||||
|
{t("action.login.labelWith", { provider: "LDAP" })}
|
||||||
|
</SubmitButton>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
{providers.includes("oidc") && <Divider label="OIDC" labelPosition="center" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{providers.includes("oidc") && (
|
||||||
|
<Button fullWidth variant="light" onClick={async () => await signInAsync("oidc")}>
|
||||||
|
{t("action.login.labelWith", { provider: oidcClientName })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface SubmitButtonProps {
|
||||||
|
isPending: boolean;
|
||||||
|
form: ReturnType<typeof useForm<FormType, (values: FormType) => FormType>>;
|
||||||
|
provider: "credentials" | "ldap";
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
|
||||||
|
const isCurrentProviderActive = form.getValues().provider === provider;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
name={provider}
|
||||||
|
fullWidth
|
||||||
|
onClick={() => form.setFieldValue("provider", provider)}
|
||||||
|
loading={isPending && isCurrentProviderActive}
|
||||||
|
disabled={isPending && !isCurrentProviderActive}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PasswordForgottenCollapseProps {
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps) => {
|
||||||
|
const [visible, { toggle }] = useDisclosure(false);
|
||||||
|
const tForgotPassword = useScopedI18n("user.action.login.forgotPassword");
|
||||||
|
|
||||||
|
const commandUsername = username.trim().length >= 1 ? username.trim() : "<username>";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Anchor type="button" component="button" onClick={toggle}>
|
||||||
|
{tForgotPassword("label")}
|
||||||
|
</Anchor>
|
||||||
|
|
||||||
|
<Collapse in={visible}>
|
||||||
|
<Card>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="sm">{tForgotPassword("description")}</Text>
|
||||||
|
|
||||||
|
<Code>homarr reset-password -u {commandUsername}</Code>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Collapse>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormType = z.infer<typeof extendedValidation>;
|
||||||
@@ -1,11 +1,26 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { env } from "@homarr/auth/env.mjs";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||||
import { LoginForm } from "./_login-form";
|
import { LoginForm } from "./_login-form";
|
||||||
|
|
||||||
export default async function Login() {
|
interface LoginProps {
|
||||||
|
searchParams: {
|
||||||
|
callbackUrl?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Login({ searchParams }: LoginProps) {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
redirect(searchParams.callbackUrl ?? "/");
|
||||||
|
}
|
||||||
|
|
||||||
const t = await getScopedI18n("user.page.login");
|
const t = await getScopedI18n("user.page.login");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -21,7 +36,12 @@ export default async function Login() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Card bg="dark.8" w={64 * 6} maw="90vw">
|
<Card bg="dark.8" w={64 * 6} maw="90vw">
|
||||||
<LoginForm />
|
<LoginForm
|
||||||
|
providers={env.AUTH_PROVIDERS}
|
||||||
|
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
||||||
|
isOidcAutoLoginEnabled={env.AUTH_OIDC_AUTO_LOGIN}
|
||||||
|
callbackUrl={searchParams.callbackUrl ?? "/"}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ export const ClientBoard = () => {
|
|||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const isReady = useIsBoardReady();
|
const isReady = useIsBoardReady();
|
||||||
|
|
||||||
const sortedSections = board.sections.sort((sectionA, sectionB) => sectionA.position - sectionB.position);
|
const fullWidthSortedSections = board.sections
|
||||||
|
.filter((section) => section.kind === "empty" || section.kind === "category")
|
||||||
|
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -58,11 +60,11 @@ export const ClientBoard = () => {
|
|||||||
h={fullHeightWithoutHeaderAndFooter}
|
h={fullHeightWithoutHeaderAndFooter}
|
||||||
/>
|
/>
|
||||||
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
|
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
|
||||||
{sortedSections.map((section) =>
|
{fullWidthSortedSections.map((section) =>
|
||||||
section.kind === "empty" ? (
|
section.kind === "empty" ? (
|
||||||
<BoardEmptySection key={section.id} section={section} mainRef={ref} />
|
<BoardEmptySection key={section.id} section={section} />
|
||||||
) : (
|
) : (
|
||||||
<BoardCategorySection key={section.id} section={section} mainRef={ref} />
|
<BoardCategorySection key={section.id} section={section} />
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { TRPCError } from "@trpc/server";
|
|||||||
// Placed here because gridstack styles are used for board content
|
// Placed here because gridstack styles are used for board content
|
||||||
import "~/styles/gridstack.scss";
|
import "~/styles/gridstack.scss";
|
||||||
|
|
||||||
|
import { IntegrationProvider } from "@homarr/auth/client";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
@@ -25,10 +28,17 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
layout: createBoardLayout({
|
layout: createBoardLayout({
|
||||||
headerActions: <BoardContentHeaderActions />,
|
headerActions: <BoardContentHeaderActions />,
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
isBoardContentPage: true,
|
|
||||||
}),
|
}),
|
||||||
page: () => {
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
return <ClientBoard />;
|
page: async () => {
|
||||||
|
const session = await auth();
|
||||||
|
const integrations = await getIntegrationsWithPermissionsAsync(session);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IntegrationProvider integrations={integrations}>
|
||||||
|
<ClientBoard />
|
||||||
|
</IntegrationProvider>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
generateMetadataAsync: async ({ params }: { params: TParams }): Promise<Metadata> => {
|
generateMetadataAsync: async ({ params }: { params: TParams }): Promise<Metadata> => {
|
||||||
try {
|
try {
|
||||||
@@ -39,6 +49,10 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
|
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
|
||||||
icons: {
|
icons: {
|
||||||
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
||||||
|
apple: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
||||||
|
},
|
||||||
|
appleWebApp: {
|
||||||
|
startupImage: { url: board.faviconImageUrl ? board.faviconImageUrl : "/logo/logo.png" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,27 +2,29 @@
|
|||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Group, Menu } from "@mantine/core";
|
import { Group, Menu } from "@mantine/core";
|
||||||
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
IconBox,
|
IconBox,
|
||||||
IconBoxAlignTop,
|
IconBoxAlignTop,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconPackageImport,
|
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconPencilOff,
|
IconPencilOff,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
|
IconResize,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||||
|
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||||
import { HeaderButton } from "~/components/layout/header/button";
|
import { HeaderButton } from "~/components/layout/header/button";
|
||||||
import { useEditMode, useRequiredBoard } from "./_context";
|
import { useEditMode, useRequiredBoard } from "./_context";
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ const AddMenu = () => {
|
|||||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||||
const { addCategoryToEnd } = useCategoryActions();
|
const { addCategoryToEnd } = useCategoryActions();
|
||||||
|
const { addDynamicSection } = useDynamicSectionActions();
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
const handleAddCategory = useCallback(
|
const handleAddCategory = useCallback(
|
||||||
@@ -92,13 +95,16 @@ const AddMenu = () => {
|
|||||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectItem}>
|
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectItem}>
|
||||||
{t("item.action.create")}
|
{t("item.action.create")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item leftSection={<IconPackageImport size={20} />}>{t("item.action.import")}</Menu.Item>
|
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||||
{t("section.category.action.create")}
|
{t("section.category.action.create")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
|
||||||
|
{t("section.dynamic.action.create")}
|
||||||
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
@@ -132,6 +138,8 @@ const EditModeMenu = () => {
|
|||||||
setEditMode(true);
|
setEditMode(true);
|
||||||
}, [board, isEditMode, saveBoard, setEditMode]);
|
}, [board, isEditMode, saveBoard, setEditMode]);
|
||||||
|
|
||||||
|
useHotkeys([["mod+e", toggle]]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderButton onClick={toggle} loading={isPending}>
|
<HeaderButton onClick={toggle} loading={isPending}>
|
||||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const generateColors = (hex: string) => {
|
|||||||
return rgbaColors.map((color) => {
|
return rgbaColors.map((color) => {
|
||||||
return (
|
return (
|
||||||
"#" +
|
"#" +
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
color
|
color
|
||||||
.split("(")[1]!
|
.split("(")[1]!
|
||||||
.replaceAll(" ", "")
|
.replaceAll(" ", "")
|
||||||
|
|||||||
@@ -8,5 +8,4 @@ export default createBoardLayout<{ locale: string; name: string }>({
|
|||||||
async getInitialBoardAsync({ name }) {
|
async getInitialBoardAsync({ name }) {
|
||||||
return await api.board.getBoardByName({ name });
|
return await api.board.getBoardByName({ name });
|
||||||
},
|
},
|
||||||
isBoardContentPage: false,
|
|
||||||
});
|
});
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Group, Stack, Tabs } from "@mantine/core";
|
|
||||||
import { IconUser, IconUserDown, IconUsersGroup } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
|
||||||
import { CountBadge } from "@homarr/ui";
|
|
||||||
|
|
||||||
import type { Board } from "../../_types";
|
|
||||||
import { GroupsForm } from "./_access/group-access";
|
|
||||||
import { InheritTable } from "./_access/inherit-access";
|
|
||||||
import { UsersForm } from "./_access/user-access";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
board: Board;
|
|
||||||
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
|
|
||||||
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
|
|
||||||
{
|
|
||||||
id: board.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
initialData: initialPermissions,
|
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const [counts, setCounts] = useState({
|
|
||||||
user: initialPermissions.userPermissions.length + (board.creator ? 1 : 0),
|
|
||||||
group: initialPermissions.groupPermissions.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Tabs color="red" defaultValue="user">
|
|
||||||
<Tabs.List grow>
|
|
||||||
<TabItem value="user" count={counts.user} icon={IconUser} />
|
|
||||||
<TabItem value="group" count={counts.group} icon={IconUsersGroup} />
|
|
||||||
<TabItem value="inherited" count={initialPermissions.inherited.length} icon={IconUserDown} />
|
|
||||||
</Tabs.List>
|
|
||||||
|
|
||||||
<Tabs.Panel value="user">
|
|
||||||
<UsersForm
|
|
||||||
board={board}
|
|
||||||
initialPermissions={permissions}
|
|
||||||
onCountChange={(callback) =>
|
|
||||||
setCounts(({ user, ...others }) => ({
|
|
||||||
user: callback(user),
|
|
||||||
...others,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Tabs.Panel>
|
|
||||||
|
|
||||||
<Tabs.Panel value="group">
|
|
||||||
<GroupsForm
|
|
||||||
board={board}
|
|
||||||
initialPermissions={permissions}
|
|
||||||
onCountChange={(callback) =>
|
|
||||||
setCounts(({ group, ...others }) => ({
|
|
||||||
group: callback(group),
|
|
||||||
...others,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Tabs.Panel>
|
|
||||||
|
|
||||||
<Tabs.Panel value="inherited">
|
|
||||||
<InheritTable initialPermissions={permissions} />
|
|
||||||
</Tabs.Panel>
|
|
||||||
</Tabs>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TabItemProps {
|
|
||||||
value: "user" | "group" | "inherited";
|
|
||||||
count: number;
|
|
||||||
icon: TablerIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TabItem = ({ value, icon: Icon, count }: TabItemProps) => {
|
|
||||||
const t = useScopedI18n("board.setting.section.access.permission");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs.Tab value={value} leftSection={<Icon stroke={1.5} size={16} />}>
|
|
||||||
<Group gap="sm">
|
|
||||||
{t(`tab.${value}`)}
|
|
||||||
<CountBadge count={count} />
|
|
||||||
</Group>
|
|
||||||
</Tabs.Tab>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import type { BoardPermission } from "@homarr/definitions";
|
|
||||||
import { createFormContext } from "@homarr/form";
|
|
||||||
|
|
||||||
export interface BoardAccessFormType {
|
|
||||||
items: {
|
|
||||||
itemId: string;
|
|
||||||
permission: BoardPermission;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const [FormProvider, useFormContext, useForm] = createFormContext<BoardAccessFormType>();
|
|
||||||
|
|
||||||
export type OnCountChange = (callback: (prev: number) => number) => void;
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { getPermissionsWithChildren } from "@homarr/definitions";
|
|
||||||
import type { BoardPermission, GroupPermissionKey } from "@homarr/definitions";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { BoardAccessDisplayRow } from "./board-access-table-rows";
|
|
||||||
import { GroupItemContent } from "./group-access";
|
|
||||||
|
|
||||||
export interface InheritTableProps {
|
|
||||||
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapPermissions = {
|
|
||||||
"board-full-access": "board-full",
|
|
||||||
"board-modify-all": "board-change",
|
|
||||||
"board-view-all": "board-view",
|
|
||||||
} satisfies Partial<Record<GroupPermissionKey, BoardPermission | "board-full">>;
|
|
||||||
|
|
||||||
export const InheritTable = ({ initialPermissions }: InheritTableProps) => {
|
|
||||||
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
|
||||||
return (
|
|
||||||
<Stack pt="sm">
|
|
||||||
<Table>
|
|
||||||
<TableThead>
|
|
||||||
<TableTr>
|
|
||||||
<TableTh>{tPermissions("field.user.label")}</TableTh>
|
|
||||||
<TableTh>{tPermissions("field.permission.label")}</TableTh>
|
|
||||||
</TableTr>
|
|
||||||
</TableThead>
|
|
||||||
<TableTbody>
|
|
||||||
{initialPermissions.inherited.map(({ group, permission }) => {
|
|
||||||
const boardPermission =
|
|
||||||
permission in mapPermissions
|
|
||||||
? mapPermissions[permission as keyof typeof mapPermissions]
|
|
||||||
: getPermissionsWithChildren([permission]).includes("board-full-access")
|
|
||||||
? "board-full"
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!boardPermission) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BoardAccessDisplayRow
|
|
||||||
key={group.id}
|
|
||||||
itemContent={<GroupItemContent group={group} />}
|
|
||||||
permission={boardPermission}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableTbody>
|
|
||||||
</Table>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { useCallback, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
|
|
||||||
import { IconPlus } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
|
||||||
import { UserAvatar } from "@homarr/ui";
|
|
||||||
|
|
||||||
import type { Board } from "../../../_types";
|
|
||||||
import { BoardAccessDisplayRow, BoardAccessSelectRow } from "./board-access-table-rows";
|
|
||||||
import type { BoardAccessFormType, OnCountChange } from "./form";
|
|
||||||
import { FormProvider, useForm } from "./form";
|
|
||||||
import { UserSelectModal } from "./user-select-modal";
|
|
||||||
|
|
||||||
export interface FormProps {
|
|
||||||
board: Pick<Board, "id" | "creatorId" | "creator">;
|
|
||||||
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
|
|
||||||
onCountChange: OnCountChange;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UsersForm = ({ board, initialPermissions, onCountChange }: FormProps) => {
|
|
||||||
const { mutate, isPending } = clientApi.board.saveUserBoardPermissions.useMutation();
|
|
||||||
const utils = clientApi.useUtils();
|
|
||||||
const [users, setUsers] = useState<Map<string, User>>(
|
|
||||||
new Map(initialPermissions.userPermissions.map(({ user }) => [user.id, user])),
|
|
||||||
);
|
|
||||||
const { openModal } = useModalAction(UserSelectModal);
|
|
||||||
const t = useI18n();
|
|
||||||
const tPermissions = useScopedI18n("board.setting.section.access.permission");
|
|
||||||
const form = useForm({
|
|
||||||
initialValues: {
|
|
||||||
items: initialPermissions.userPermissions.map(({ user, permission }) => ({
|
|
||||||
itemId: user.id,
|
|
||||||
permission,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
(values: BoardAccessFormType) => {
|
|
||||||
mutate(
|
|
||||||
{
|
|
||||||
id: board.id,
|
|
||||||
permissions: values.items,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
void utils.board.getBoardPermissions.invalidate();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[board.id, mutate, utils.board.getBoardPermissions],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAddUser = useCallback(() => {
|
|
||||||
const presentUserIds = form.values.items.map(({ itemId: id }) => id);
|
|
||||||
|
|
||||||
openModal({
|
|
||||||
presentUserIds: board.creatorId ? presentUserIds.concat(board.creatorId) : presentUserIds,
|
|
||||||
onSelect: (user) => {
|
|
||||||
setUsers((prev) => new Map(prev).set(user.id, user));
|
|
||||||
form.setFieldValue("items", [
|
|
||||||
{
|
|
||||||
itemId: user.id,
|
|
||||||
permission: "board-view",
|
|
||||||
},
|
|
||||||
...form.values.items,
|
|
||||||
]);
|
|
||||||
onCountChange((prev) => prev + 1);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [form, openModal, board.creatorId, onCountChange]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<FormProvider form={form}>
|
|
||||||
<Stack pt="sm">
|
|
||||||
<Table>
|
|
||||||
<TableThead>
|
|
||||||
<TableTr>
|
|
||||||
<TableTh>{tPermissions("field.user.label")}</TableTh>
|
|
||||||
<TableTh>{tPermissions("field.permission.label")}</TableTh>
|
|
||||||
</TableTr>
|
|
||||||
</TableThead>
|
|
||||||
<TableTbody>
|
|
||||||
{board.creator && (
|
|
||||||
<BoardAccessDisplayRow itemContent={<UserItemContent user={board.creator} />} permission="board-full" />
|
|
||||||
)}
|
|
||||||
{form.values.items.map((row, index) => (
|
|
||||||
<BoardAccessSelectRow
|
|
||||||
key={row.itemId}
|
|
||||||
itemContent={<UserItemContent user={users.get(row.itemId)!} />}
|
|
||||||
permission={row.permission}
|
|
||||||
index={index}
|
|
||||||
onCountChange={onCountChange}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</TableTbody>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
|
|
||||||
{t("common.action.add")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={isPending} color="teal">
|
|
||||||
{t("common.action.saveChanges")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</FormProvider>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const UserItemContent = ({ user }: { user: User }) => {
|
|
||||||
return (
|
|
||||||
<Group wrap="nowrap">
|
|
||||||
<Box visibleFrom="xs">
|
|
||||||
<UserAvatar user={user} size="sm" />
|
|
||||||
</Box>
|
|
||||||
<Anchor component={Link} href={`/manage/users/${user.id}`} size="sm" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{user.name}
|
|
||||||
</Anchor>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface User {
|
|
||||||
id: string;
|
|
||||||
name: string | null;
|
|
||||||
image: string | null;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { IconEye, IconPencil, IconSettings } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { boardPermissions, boardPermissionsMap } from "@homarr/definitions";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { AccessSettings } from "~/components/access/access-settings";
|
||||||
|
import type { Board } from "../../_types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
board: Board;
|
||||||
|
initialPermissions: RouterOutputs["board"]["getBoardPermissions"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
|
||||||
|
const groupMutation = clientApi.board.saveGroupBoardPermissions.useMutation();
|
||||||
|
const userMutation = clientApi.board.saveUserBoardPermissions.useMutation();
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
|
||||||
|
{
|
||||||
|
id: board.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialData: initialPermissions,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccessSettings
|
||||||
|
entity={{
|
||||||
|
id: board.id,
|
||||||
|
ownerId: board.creatorId,
|
||||||
|
owner: board.creator,
|
||||||
|
}}
|
||||||
|
query={{
|
||||||
|
invalidate: () => utils.board.getBoardPermissions.invalidate(),
|
||||||
|
data: permissions,
|
||||||
|
}}
|
||||||
|
groupsMutation={{
|
||||||
|
mutate: groupMutation.mutate,
|
||||||
|
isPending: groupMutation.isPending,
|
||||||
|
}}
|
||||||
|
usersMutation={{
|
||||||
|
mutate: userMutation.mutate,
|
||||||
|
isPending: userMutation.isPending,
|
||||||
|
}}
|
||||||
|
translate={(key) => t(`board.setting.section.access.permission.item.${key}.label`)}
|
||||||
|
permission={{
|
||||||
|
items: boardPermissions,
|
||||||
|
default: "view",
|
||||||
|
fullAccessGroupPermission: "board-full-all",
|
||||||
|
groupPermissionMapping: boardPermissionsMap,
|
||||||
|
icons: {
|
||||||
|
modify: IconPencil,
|
||||||
|
view: IconEye,
|
||||||
|
full: IconSettings,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -20,8 +20,8 @@ import type { TablerIcon } from "@homarr/ui";
|
|||||||
|
|
||||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||||
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
||||||
import { AccessSettingsContent } from "./_access";
|
|
||||||
import { BackgroundSettingsContent } from "./_background";
|
import { BackgroundSettingsContent } from "./_background";
|
||||||
|
import { BoardAccessSettings } from "./_board-access";
|
||||||
import { ColorSettingsContent } from "./_colors";
|
import { ColorSettingsContent } from "./_colors";
|
||||||
import { CustomCssSettingsContent } from "./_customCss";
|
import { CustomCssSettingsContent } from "./_customCss";
|
||||||
import { DangerZoneSettingsContent } from "./_danger";
|
import { DangerZoneSettingsContent } from "./_danger";
|
||||||
@@ -44,8 +44,8 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
|
|||||||
const permissions = hasFullAccess
|
const permissions = hasFullAccess
|
||||||
? await api.board.getBoardPermissions({ id: board.id })
|
? await api.board.getBoardPermissions({ id: board.id })
|
||||||
: {
|
: {
|
||||||
userPermissions: [],
|
users: [],
|
||||||
groupPermissions: [],
|
groups: [],
|
||||||
inherited: [],
|
inherited: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ export default async function BoardSettingsPage({ params, searchParams }: Props)
|
|||||||
{hasFullAccess && (
|
{hasFullAccess && (
|
||||||
<>
|
<>
|
||||||
<AccordionItemFor value="access" icon={IconUser}>
|
<AccordionItemFor value="access" icon={IconUser}>
|
||||||
<AccessSettingsContent board={board} initialPermissions={permissions} />
|
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||||
</AccordionItemFor>
|
</AccordionItemFor>
|
||||||
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
||||||
<DangerZoneSettingsContent />
|
<DangerZoneSettingsContent />
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { AppShellMain } from "@mantine/core";
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
import { GlobalItemServerDataRunner } from "@homarr/widgets";
|
|
||||||
|
|
||||||
import { MainHeader } from "~/components/layout/header";
|
import { MainHeader } from "~/components/layout/header";
|
||||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||||
@@ -18,13 +17,11 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
|||||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||||
headerActions: JSX.Element;
|
headerActions: JSX.Element;
|
||||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||||
isBoardContentPage: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createBoardLayout = <TParams extends Params>({
|
export const createBoardLayout = <TParams extends Params>({
|
||||||
headerActions,
|
headerActions,
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
isBoardContentPage,
|
|
||||||
}: CreateBoardLayoutProps<TParams>) => {
|
}: CreateBoardLayoutProps<TParams>) => {
|
||||||
const Layout = async ({
|
const Layout = async ({
|
||||||
params,
|
params,
|
||||||
@@ -42,21 +39,19 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlobalItemServerDataRunner board={initialBoard} shouldRun={isBoardContentPage}>
|
<BoardProvider initialBoard={initialBoard}>
|
||||||
<BoardProvider initialBoard={initialBoard}>
|
<BoardMantineProvider>
|
||||||
<BoardMantineProvider>
|
<CustomCss />
|
||||||
<CustomCss />
|
<ClientShell hasNavigation={false}>
|
||||||
<ClientShell hasNavigation={false}>
|
<MainHeader
|
||||||
<MainHeader
|
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
actions={headerActions}
|
||||||
actions={headerActions}
|
hasNavigation={false}
|
||||||
hasNavigation={false}
|
/>
|
||||||
/>
|
<AppShellMain>{children}</AppShellMain>
|
||||||
<AppShellMain>{children}</AppShellMain>
|
</ClientShell>
|
||||||
</ClientShell>
|
</BoardMantineProvider>
|
||||||
</BoardMantineProvider>
|
</BoardProvider>
|
||||||
</BoardProvider>
|
|
||||||
</GlobalItemServerDataRunner>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ export type Item = Section["items"][number];
|
|||||||
|
|
||||||
export type CategorySection = Extract<Section, { kind: "category" }>;
|
export type CategorySection = Extract<Section, { kind: "category" }>;
|
||||||
export type EmptySection = Extract<Section, { kind: "empty" }>;
|
export type EmptySection = Extract<Section, { kind: "empty" }>;
|
||||||
|
export type DynamicSection = Extract<Section, { kind: "dynamic" }>;
|
||||||
|
|
||||||
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
|
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
|
||||||
@@ -6,7 +6,6 @@ export const composeWrappers = (
|
|||||||
wrappers: React.FunctionComponent<PropsWithChildren>[],
|
wrappers: React.FunctionComponent<PropsWithChildren>[],
|
||||||
): React.FunctionComponent<PropsWithChildren> => {
|
): React.FunctionComponent<PropsWithChildren> => {
|
||||||
return wrappers.reverse().reduce((Acc, Current): React.FunctionComponent<PropsWithChildren> => {
|
return wrappers.reverse().reduce((Acc, Current): React.FunctionComponent<PropsWithChildren> => {
|
||||||
// eslint-disable-next-line react/display-name
|
|
||||||
return (props) => (
|
return (props) => (
|
||||||
<Current>
|
<Current>
|
||||||
<Acc {...props} />
|
<Acc {...props} />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import { CustomPasswordInput } from "@homarr/ui";
|
||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
@@ -50,7 +51,11 @@ export const InitUserForm = () => {
|
|||||||
>
|
>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
|
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
|
||||||
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
|
<CustomPasswordInput
|
||||||
|
withPasswordRequirements
|
||||||
|
label={t("field.password.label")}
|
||||||
|
{...form.getInputProps("password")}
|
||||||
|
/>
|
||||||
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
|
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
|
||||||
<Button type="submit" fullWidth loading={isPending}>
|
<Button type="submit" fullWidth loading={isPending}>
|
||||||
{t("action.create")}
|
{t("action.create")}
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
|
||||||
import "@homarr/ui/styles.css";
|
|
||||||
import "@homarr/notifications/styles.css";
|
import "@homarr/notifications/styles.css";
|
||||||
import "@homarr/spotlight/styles.css";
|
import "@homarr/spotlight/styles.css";
|
||||||
|
import "@homarr/ui/styles.css";
|
||||||
|
import "~/styles/scroll-area.scss";
|
||||||
|
|
||||||
import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
import { env } from "@homarr/auth/env.mjs";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { ModalProvider } from "@homarr/modals";
|
import { ModalProvider } from "@homarr/modals";
|
||||||
import { Notifications } from "@homarr/notifications";
|
import { Notifications } from "@homarr/notifications";
|
||||||
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { Analytics } from "~/components/layout/analytics";
|
import { Analytics } from "~/components/layout/analytics";
|
||||||
|
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||||
import { JotaiProvider } from "./_client-providers/jotai";
|
import { JotaiProvider } from "./_client-providers/jotai";
|
||||||
|
import { CustomMantineProvider } from "./_client-providers/mantine";
|
||||||
import { NextInternationalProvider } from "./_client-providers/next-international";
|
import { NextInternationalProvider } from "./_client-providers/next-international";
|
||||||
import { AuthProvider } from "./_client-providers/session";
|
import { AuthProvider } from "./_client-providers/session";
|
||||||
import { TRPCReactProvider } from "./_client-providers/trpc";
|
import { TRPCReactProvider } from "./_client-providers/trpc";
|
||||||
@@ -23,8 +28,7 @@ const fontSans = Inter({
|
|||||||
variable: "--font-sans",
|
variable: "--font-sans",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const generateMetadata = (): Metadata => ({
|
||||||
metadataBase: new URL("http://localhost:3000"),
|
|
||||||
title: "Homarr",
|
title: "Homarr",
|
||||||
description:
|
description:
|
||||||
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
||||||
@@ -35,12 +39,17 @@ export const metadata: Metadata = {
|
|||||||
url: "https://homarr.dev",
|
url: "https://homarr.dev",
|
||||||
siteName: "Homarr Documentation",
|
siteName: "Homarr Documentation",
|
||||||
},
|
},
|
||||||
twitter: {
|
icons: {
|
||||||
card: "summary_large_image",
|
icon: "/logo/logo.png",
|
||||||
site: "@jullerino",
|
apple: "/logo/logo.png",
|
||||||
creator: "@jullerino",
|
|
||||||
},
|
},
|
||||||
};
|
appleWebApp: {
|
||||||
|
title: "Homarr",
|
||||||
|
capable: true,
|
||||||
|
startupImage: { url: "/logo/logo.png" },
|
||||||
|
statusBarStyle: getColorScheme() === "dark" ? "black-translucent" : "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: [
|
themeColor: [
|
||||||
@@ -49,35 +58,37 @@ export const viewport: Viewport = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
|
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
|
||||||
const colorScheme = "dark";
|
const session = await auth();
|
||||||
|
const colorScheme = getColorScheme();
|
||||||
|
const tCommon = await getScopedI18n("common");
|
||||||
|
const direction = tCommon("direction");
|
||||||
|
|
||||||
const StackedProvider = composeWrappers([
|
const StackedProvider = composeWrappers([
|
||||||
async (innerProps) => {
|
(innerProps) => {
|
||||||
const session = await auth();
|
return <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
|
||||||
return <AuthProvider session={session} {...innerProps} />;
|
|
||||||
},
|
},
|
||||||
(innerProps) => <JotaiProvider {...innerProps} />,
|
(innerProps) => <JotaiProvider {...innerProps} />,
|
||||||
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
||||||
(innerProps) => <NextInternationalProvider {...innerProps} locale={props.params.locale} />,
|
(innerProps) => <NextInternationalProvider {...innerProps} locale={props.params.locale} />,
|
||||||
(innerProps) => (
|
(innerProps) => <CustomMantineProvider {...innerProps} />,
|
||||||
<MantineProvider
|
|
||||||
{...innerProps}
|
|
||||||
defaultColorScheme="dark"
|
|
||||||
theme={createTheme({
|
|
||||||
primaryColor: "red",
|
|
||||||
autoContrast: true,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
(innerProps) => <ModalProvider {...innerProps} />,
|
(innerProps) => <ModalProvider {...innerProps} />,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
dir={direction}
|
||||||
|
data-mantine-color-scheme={colorScheme}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colorScheme === "dark" ? "#242424" : "#fff",
|
||||||
|
}}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
<head>
|
<head>
|
||||||
<ColorSchemeScript defaultColorScheme={colorScheme} />
|
|
||||||
<Analytics />
|
<Analytics />
|
||||||
|
<SearchEngineOptimization />
|
||||||
</head>
|
</head>
|
||||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
<body className={["font-sans", fontSans.variable].join(" ")}>
|
||||||
<StackedProvider>
|
<StackedProvider>
|
||||||
@@ -88,3 +99,7 @@ export default function Layout(props: { children: React.ReactNode; params: { loc
|
|||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getColorScheme = () => {
|
||||||
|
return cookies().get("homarr-color-scheme")?.value ?? "dark";
|
||||||
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
.bannerContainer {
|
.bannerContainer {
|
||||||
padding: 3rem;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
@@ -20,7 +19,7 @@
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50.8%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,17 +38,17 @@ export const HeroBanner = () => {
|
|||||||
const gridSpan = 12 / countIconGroups;
|
const gridSpan = 12 / countIconGroups;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classes.bannerContainer} bg="dark.6" pos="relative">
|
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Title order={2} c="dimmed">
|
<Title fz={{ base: "h4", md: "h2" }} c="dimmed">
|
||||||
Welcome back to your
|
Welcome back to your
|
||||||
</Title>
|
</Title>
|
||||||
<Group gap="xs">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<Image src="/logo/logo.png" w={40} h={40} />
|
<Image src="/logo/logo.png" w={{ base: 32, md: 40 }} h={{ base: 32, md: 40 }} />
|
||||||
<Title>Homarr Dashboard</Title>
|
<Title fz={{ base: "h3", md: "h1" }}>Homarr Board</Title>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Box className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
|
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
|
||||||
<Grid>
|
<Grid>
|
||||||
{Array(countIconGroups)
|
{Array(countIconGroups)
|
||||||
.fill(0)
|
.fill(0)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
@@ -17,15 +18,15 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconLanguage, IconLibrary, IconUsers } from "@tabler/icons-react";
|
import { IconLanguage, IconLibrary, IconUsers } from "@tabler/icons-react";
|
||||||
import { setStaticParamsLocale } from "next-international/server";
|
|
||||||
|
|
||||||
import { getScopedI18n, getStaticParams } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||||
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { getPackageAttributesAsync } from "~/versions/package-reader";
|
import { getPackageAttributesAsync } from "~/versions/package-reader";
|
||||||
import contributorsData from "../../../../../../../static-data/contributors.json";
|
import type githubContributorsJson from "../../../../../../../static-data/contributors.json";
|
||||||
import translatorsData from "../../../../../../../static-data/translators.json";
|
import type crowdinContributorsJson from "../../../../../../../static-data/translators.json";
|
||||||
import logo from "../../../../../public/logo/logo.png";
|
|
||||||
import classes from "./about.module.css";
|
import classes from "./about.module.css";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
@@ -36,21 +37,32 @@ export async function generateMetadata() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageProps {
|
const getHost = () => {
|
||||||
params: {
|
if (process.env.HOSTNAME) {
|
||||||
locale: string;
|
return `${process.env.HOSTNAME}:3000`;
|
||||||
};
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AboutPage({ params: { locale } }: PageProps) {
|
return headers().get("host");
|
||||||
setStaticParamsLocale(locale);
|
};
|
||||||
|
|
||||||
|
export default async function AboutPage() {
|
||||||
|
const baseServerUrl = `http://${getHost()}`;
|
||||||
const t = await getScopedI18n("management.page.about");
|
const t = await getScopedI18n("management.page.about");
|
||||||
const attributes = await getPackageAttributesAsync();
|
const attributes = await getPackageAttributesAsync();
|
||||||
|
const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>
|
||||||
|
res.json(),
|
||||||
|
)) as typeof githubContributorsJson;
|
||||||
|
|
||||||
|
const crowdinContributors = (await fetch(`${baseServerUrl}/api/about/contributors/crowdin`).then((res) =>
|
||||||
|
res.json(),
|
||||||
|
)) as typeof crowdinContributorsJson;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<DynamicBreadcrumb />
|
||||||
<Center w="100%">
|
<Center w="100%">
|
||||||
<Group py="lg">
|
<Group py="lg">
|
||||||
<Image src={logo} width={100} height={100} alt="" />
|
<Image src={homarrLogoPath} width={100} height={100} alt="" />
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Title order={1} tt="uppercase">
|
<Title order={1} tt="uppercase">
|
||||||
Homarr
|
Homarr
|
||||||
@@ -68,14 +80,14 @@ export default async function AboutPage({ params: { locale } }: PageProps) {
|
|||||||
<Text>{t("accordion.contributors.title")}</Text>
|
<Text>{t("accordion.contributors.title")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t("accordion.contributors.subtitle", {
|
{t("accordion.contributors.subtitle", {
|
||||||
count: contributorsData.length,
|
count: githubContributors.length,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</AccordionControl>
|
</AccordionControl>
|
||||||
<AccordionPanel>
|
<AccordionPanel>
|
||||||
<Flex wrap="wrap" gap="xs">
|
<Flex wrap="wrap" gap="xs">
|
||||||
{contributorsData.map((contributor) => (
|
{githubContributors.map((contributor) => (
|
||||||
<GenericContributorLinkCard
|
<GenericContributorLinkCard
|
||||||
key={contributor.login}
|
key={contributor.login}
|
||||||
link={`https://github.com/${contributor.login}`}
|
link={`https://github.com/${contributor.login}`}
|
||||||
@@ -92,14 +104,14 @@ export default async function AboutPage({ params: { locale } }: PageProps) {
|
|||||||
<Text>{t("accordion.translators.title")}</Text>
|
<Text>{t("accordion.translators.title")}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t("accordion.translators.subtitle", {
|
{t("accordion.translators.subtitle", {
|
||||||
count: translatorsData.length,
|
count: crowdinContributors.length,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</AccordionControl>
|
</AccordionControl>
|
||||||
<AccordionPanel>
|
<AccordionPanel>
|
||||||
<Flex wrap="wrap" gap="xs">
|
<Flex wrap="wrap" gap="xs">
|
||||||
{translatorsData.map((translator) => (
|
{crowdinContributors.map((translator) => (
|
||||||
<GenericContributorLinkCard
|
<GenericContributorLinkCard
|
||||||
key={translator.username}
|
key={translator.username}
|
||||||
link={`https://crowdin.com/profile/${translator.username}`}
|
link={`https://crowdin.com/profile/${translator.username}`}
|
||||||
@@ -162,9 +174,3 @@ const GenericContributorLinkCard = ({ name, image, link }: GenericContributorLin
|
|||||||
</AspectRatio>
|
</AspectRatio>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function generateStaticParams() {
|
|
||||||
return getStaticParams();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dynamic = "force-static";
|
|
||||||
@@ -6,12 +6,11 @@ import { IconTrash } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "../../../revalidatePathAction";
|
|
||||||
|
|
||||||
interface AppDeleteButtonProps {
|
interface AppDeleteButtonProps {
|
||||||
app: RouterOutputs["app"]["all"][number];
|
app: RouterOutputs["app"]["all"][number];
|
||||||
}
|
}
|
||||||
@@ -49,7 +48,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
|||||||
}, [app, mutate, t, openConfirmModal]);
|
}, [app, mutate, t, openConfirmModal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label="Delete app">
|
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
|
||||||
<IconTrash color="red" size={16} stroke={1.5} />
|
<IconTrash color="red" size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ export const AppForm = (props: AppFormProps) => {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput {...form.getInputProps("name")} withAsterisk label="Name" />
|
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
|
||||||
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
|
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
|
||||||
<Textarea {...form.getInputProps("description")} label="Description" />
|
<Textarea {...form.getInputProps("description")} label={t("app.field.description.label")} />
|
||||||
<TextInput {...form.getInputProps("href")} label="URL" />
|
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
|
||||||
|
|
||||||
<Group justify="end">
|
<Group justify="end">
|
||||||
<Button variant="default" component={Link} href="/manage/apps">
|
<Button variant="default" component={Link} href="/manage/apps">
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { useRouter } from "next/navigation";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { validation, z } from "@homarr/validation";
|
import type { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
import { AppForm } from "../../_form";
|
import { AppForm } from "../../_form";
|
||||||
|
|
||||||
interface AppEditFormProps {
|
interface AppEditFormProps {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Container, Stack, Title } from "@mantine/core";
|
|||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { AppEditForm } from "./_app-edit-form";
|
import { AppEditForm } from "./_app-edit-form";
|
||||||
|
|
||||||
interface AppEditPageProps {
|
interface AppEditPageProps {
|
||||||
@@ -14,11 +15,14 @@ export default async function AppEditPage({ params }: AppEditPageProps) {
|
|||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<>
|
||||||
<Stack>
|
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, app.name]])} nonInteractable={["edit"]} />
|
||||||
<Title>{t("app.page.edit.title")}</Title>
|
<Container>
|
||||||
<AppEditForm app={app} />
|
<Stack>
|
||||||
</Stack>
|
<Title>{t("app.page.edit.title")}</Title>
|
||||||
</Container>
|
<AppEditForm app={app} />
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,12 +4,12 @@ import { useCallback } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { validation, z } from "@homarr/validation";
|
import type { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
import { AppForm } from "../_form";
|
import { AppForm } from "../_form";
|
||||||
|
|
||||||
export const AppNewForm = () => {
|
export const AppNewForm = () => {
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { Container, Stack, Title } from "@mantine/core";
|
import { Container, Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { AppNewForm } from "./_app-new-form";
|
import { AppNewForm } from "./_app-new-form";
|
||||||
|
|
||||||
export default function AppNewPage() {
|
export default async function AppNewPage() {
|
||||||
|
const t = await getI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<>
|
||||||
<Stack>
|
<DynamicBreadcrumb />
|
||||||
<Title>New app</Title>
|
<Container>
|
||||||
<AppNewForm />
|
<Stack>
|
||||||
</Stack>
|
<Title>{t("app.page.create.title")}</Title>
|
||||||
</Container>
|
<AppNewForm />
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,36 +1,30 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
ActionIcon,
|
|
||||||
ActionIconGroup,
|
|
||||||
Anchor,
|
|
||||||
Avatar,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Container,
|
|
||||||
Group,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { IconApps, IconPencil } from "@tabler/icons-react";
|
import { IconApps, IconPencil } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
|
||||||
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { AppDeleteButton } from "./_app-delete-button";
|
import { AppDeleteButton } from "./_app-delete-button";
|
||||||
|
|
||||||
export default async function AppsPage() {
|
export default async function AppsPage() {
|
||||||
const apps = await api.app.all();
|
const apps = await api.app.all();
|
||||||
|
const t = await getScopedI18n("app");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<ManageContainer>
|
||||||
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Title>Apps</Title>
|
<Title>{t("page.list.title")}</Title>
|
||||||
<Button component={Link} href="/manage/apps/new">
|
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||||
New app
|
{t("page.create.title")}
|
||||||
</Button>
|
</MobileAffixButton>
|
||||||
</Group>
|
</Group>
|
||||||
{apps.length === 0 && <AppNoResults />}
|
{apps.length === 0 && <AppNoResults />}
|
||||||
{apps.length > 0 && (
|
{apps.length > 0 && (
|
||||||
@@ -41,7 +35,7 @@ export default async function AppsPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</ManageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,10 +43,12 @@ interface AppCardProps {
|
|||||||
app: RouterOutputs["app"]["all"][number];
|
app: RouterOutputs["app"]["all"][number];
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppCard = ({ app }: AppCardProps) => {
|
const AppCard = async ({ app }: AppCardProps) => {
|
||||||
|
const t = await getScopedI18n("app");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group align="top" justify="start" wrap="nowrap">
|
<Group align="top" justify="start" wrap="nowrap">
|
||||||
<Avatar
|
<Avatar
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -65,15 +61,17 @@ const AppCard = ({ app }: AppCardProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text fw={500}>{app.name}</Text>
|
<Text fw={500} lineClamp={1}>
|
||||||
|
{app.name}
|
||||||
|
</Text>
|
||||||
{app.description && (
|
{app.description && (
|
||||||
<Text size="sm" c="gray.6">
|
<Text size="sm" c="gray.6" lineClamp={4}>
|
||||||
{app.description}
|
{app.description}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{app.href && (
|
{app.href && (
|
||||||
<Anchor href={app.href} size="sm" w="min-content">
|
<Anchor href={parseAppHrefWithVariablesServer(app.href)} lineClamp={1} size="sm" w="min-content">
|
||||||
{app.href}
|
{parseAppHrefWithVariablesServer(app.href)}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -85,7 +83,7 @@ const AppCard = ({ app }: AppCardProps) => {
|
|||||||
href={`/manage/apps/edit/${app.id}`}
|
href={`/manage/apps/edit/${app.id}`}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
aria-label="Edit app"
|
aria-label={t("page.edit.title")}
|
||||||
>
|
>
|
||||||
<IconPencil size={16} stroke={1.5} />
|
<IconPencil size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -107,7 +105,7 @@ const AppNoResults = async () => {
|
|||||||
<Text fw={500} size="lg">
|
<Text fw={500} size="lg">
|
||||||
{t("app.page.list.noResults.title")}
|
{t("app.page.list.noResults.title")}
|
||||||
</Text>
|
</Text>
|
||||||
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.description")}</Anchor>
|
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.action")}</Anchor>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
|
||||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
|
|
||||||
const iconProps = {
|
const iconProps = {
|
||||||
|
|||||||
@@ -1,44 +1,47 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { Affix, Button, Group, Menu } from "@mantine/core";
|
||||||
import { Button } from "@mantine/core";
|
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
||||||
import { IconCategoryPlus } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
|
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { BetaBadge } from "@homarr/ui";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
export const CreateBoardButton = () => {
|
||||||
import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
|
|
||||||
|
|
||||||
interface CreateBoardButtonProps {
|
|
||||||
boardNames: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { openModal } = useModalAction(AddBoardModal);
|
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
||||||
|
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
||||||
|
|
||||||
const { mutateAsync, isPending } = clientApi.board.createBoard.useMutation({
|
const buttonGroupContent = (
|
||||||
onSettled: async () => {
|
<>
|
||||||
await revalidatePathActionAsync("/manage/boards");
|
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
|
||||||
},
|
{t("management.page.board.action.new.label")}
|
||||||
});
|
</Button>
|
||||||
|
<Menu position="bottom-end">
|
||||||
const onClick = useCallback(() => {
|
<Menu.Target>
|
||||||
openModal({
|
<Button px="xs" ms={1}>
|
||||||
onSuccess: async (values) => {
|
<IconChevronDown size="1rem" />
|
||||||
await mutateAsync({
|
</Button>
|
||||||
name: values.name,
|
</Menu.Target>
|
||||||
});
|
<Menu.Dropdown>
|
||||||
},
|
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
||||||
boardNames,
|
<Group>
|
||||||
});
|
{t("board.action.oldImport.label")}
|
||||||
}, [mutateAsync, boardNames, openModal]);
|
<BetaBadge size="xs" />
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={onClick} loading={isPending}>
|
<>
|
||||||
{t("management.page.board.action.new.label")}
|
<Button.Group visibleFrom="md">{buttonGroupContent}</Button.Group>
|
||||||
</Button>
|
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
||||||
|
<Button.Group>{buttonGroupContent}</Button.Group>
|
||||||
|
</Affix>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Menu,
|
Menu,
|
||||||
MenuTarget,
|
MenuTarget,
|
||||||
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -18,33 +19,40 @@ import { IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/i
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { UserAvatar } from "@homarr/ui";
|
import { UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||||
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||||
import { CreateBoardButton } from "./_components/create-board-button";
|
import { CreateBoardButton } from "./_components/create-board-button";
|
||||||
|
|
||||||
export default async function ManageBoardsPage() {
|
export default async function ManageBoardsPage() {
|
||||||
const t = await getScopedI18n("management.page.board");
|
const t = await getScopedI18n("management.page.board");
|
||||||
|
const session = await auth();
|
||||||
const boards = await api.board.getAllBoards();
|
const boards = await api.board.getAllBoards();
|
||||||
|
const canCreateBoards = session?.user.permissions.includes("board-create");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ManageContainer>
|
||||||
<Group justify="space-between">
|
<DynamicBreadcrumb />
|
||||||
<Title mb="md">{t("title")}</Title>
|
<Stack>
|
||||||
<CreateBoardButton boardNames={boards.map((board) => board.name)} />
|
<Group justify="space-between">
|
||||||
</Group>
|
<Title mb="md">{t("title")}</Title>
|
||||||
|
{canCreateBoards && <CreateBoardButton />}
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Grid>
|
<Grid mb={{ base: "xl", md: 0 }}>
|
||||||
{boards.map((board) => (
|
{boards.map((board) => (
|
||||||
<GridCol span={{ base: 12, md: 6, xl: 4 }} key={board.id}>
|
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
||||||
<BoardCard board={board} />
|
<BoardCard board={board} />
|
||||||
</GridCol>
|
</GridCol>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
</>
|
</Stack>
|
||||||
|
</ManageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +91,7 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
|||||||
{board.creator && (
|
{board.creator && (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<UserAvatar user={board.creator} size="sm" />
|
<UserAvatar user={board.creator} size="sm" />
|
||||||
<Text>{board.creator?.name}</Text>
|
<Text>{board.creator.name}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { IconPlayerPlay, IconSelector, IconSettings } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { integrationPermissions, integrationPermissionsMap } from "@homarr/definitions";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { AccessSettings } from "~/components/access/access-settings";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
integration: RouterOutputs["integration"]["byId"];
|
||||||
|
initialPermissions: RouterOutputs["integration"]["getIntegrationPermissions"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IntegrationAccessSettings = ({ integration, initialPermissions }: Props) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
const { data } = clientApi.integration.getIntegrationPermissions.useQuery(
|
||||||
|
{
|
||||||
|
id: integration.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
initialData: initialPermissions,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const usersMutation = clientApi.integration.saveUserIntegrationPermissions.useMutation();
|
||||||
|
const groupsMutation = clientApi.integration.saveGroupIntegrationPermissions.useMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccessSettings
|
||||||
|
entity={{
|
||||||
|
id: integration.id,
|
||||||
|
ownerId: null,
|
||||||
|
owner: null,
|
||||||
|
}}
|
||||||
|
permission={{
|
||||||
|
items: integrationPermissions,
|
||||||
|
default: "use",
|
||||||
|
fullAccessGroupPermission: "integration-full-all",
|
||||||
|
icons: {
|
||||||
|
use: IconSelector,
|
||||||
|
interact: IconPlayerPlay,
|
||||||
|
full: IconSettings,
|
||||||
|
},
|
||||||
|
groupPermissionMapping: integrationPermissionsMap,
|
||||||
|
}}
|
||||||
|
translate={(key) => t(`integration.permission.${key}`)}
|
||||||
|
query={{
|
||||||
|
data,
|
||||||
|
invalidate: () => utils.integration.getIntegrationPermissions.invalidate(),
|
||||||
|
}}
|
||||||
|
groupsMutation={groupsMutation}
|
||||||
|
usersMutation={usersMutation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,7 +12,7 @@ import type { RouterOutputs } from "@homarr/api";
|
|||||||
import { integrationSecretKindObject } from "@homarr/definitions";
|
import { integrationSecretKindObject } from "@homarr/definitions";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { integrationSecretIcons } from "./_integration-secret-icons";
|
import { integrationSecretIcons } from "./integration-secret-icons";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import { integrationSecretKindObject } from "@homarr/definitions";
|
|||||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { integrationSecretIcons } from "./_integration-secret-icons";
|
import { integrationSecretIcons } from "./integration-secret-icons";
|
||||||
|
|
||||||
interface IntegrationSecretInputProps {
|
interface IntegrationSecretInputProps {
|
||||||
withAsterisk?: boolean;
|
withAsterisk?: boolean;
|
||||||
@@ -50,7 +50,7 @@ const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) =>
|
|||||||
<PasswordInput
|
<PasswordInput
|
||||||
{...props}
|
{...props}
|
||||||
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
|
||||||
description={t(`integration.secrets.secureNotice`)}
|
description={t("integration.secrets.secureNotice")}
|
||||||
w="100%"
|
w="100%"
|
||||||
leftSection={<Icon size={20} stroke={1.5} />}
|
leftSection={<Icon size={20} stroke={1.5} />}
|
||||||
/>
|
/>
|
||||||
@@ -5,12 +5,11 @@ import { ActionIcon } from "@mantine/core";
|
|||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "../../../revalidatePathAction";
|
|
||||||
|
|
||||||
interface DeleteIntegrationActionButtonProps {
|
interface DeleteIntegrationActionButtonProps {
|
||||||
count: number;
|
count: number;
|
||||||
integration: { id: string; name: string };
|
integration: { id: string; name: string };
|
||||||
@@ -56,7 +55,7 @@ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteInte
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
aria-label="Delete integration"
|
aria-label={t("title")}
|
||||||
>
|
>
|
||||||
<IconTrash color="red" size={16} stroke={1.5} />
|
<IconTrash color="red" size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
|
||||||
import { Alert, Anchor, Group, Loader } from "@mantine/core";
|
|
||||||
import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { RouterInputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
interface UseTestConnectionDirtyProps {
|
|
||||||
defaultDirty: boolean;
|
|
||||||
initialFormValue: {
|
|
||||||
url: string;
|
|
||||||
secrets: { kind: string; value: string | null }[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useTestConnectionDirty = ({ defaultDirty, initialFormValue }: UseTestConnectionDirtyProps) => {
|
|
||||||
const [isDirty, setIsDirty] = useState(defaultDirty);
|
|
||||||
const prevFormValueRef = useRef(initialFormValue);
|
|
||||||
|
|
||||||
return {
|
|
||||||
onValuesChange: (values: typeof initialFormValue) => {
|
|
||||||
if (isDirty) return;
|
|
||||||
|
|
||||||
// If relevant values changed, set dirty
|
|
||||||
if (
|
|
||||||
prevFormValueRef.current.url !== values.url ||
|
|
||||||
!prevFormValueRef.current.secrets
|
|
||||||
.map((secret) => secret.value)
|
|
||||||
.every((secretValue, index) => values.secrets[index]?.value === secretValue)
|
|
||||||
) {
|
|
||||||
setIsDirty(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If relevant values changed back to last tested, set not dirty
|
|
||||||
setIsDirty(false);
|
|
||||||
},
|
|
||||||
isDirty,
|
|
||||||
removeDirty: () => {
|
|
||||||
prevFormValueRef.current = initialFormValue;
|
|
||||||
setIsDirty(false);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TestConnectionProps {
|
|
||||||
isDirty: boolean;
|
|
||||||
removeDirty: () => void;
|
|
||||||
integration: RouterInputs["integration"]["testConnection"] & { name: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TestConnection = ({ integration, removeDirty, isDirty }: TestConnectionProps) => {
|
|
||||||
const t = useScopedI18n("integration.testConnection");
|
|
||||||
const { mutateAsync, ...mutation } = clientApi.integration.testConnection.useMutation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group>
|
|
||||||
<Anchor
|
|
||||||
type="button"
|
|
||||||
component="button"
|
|
||||||
onClick={async () => {
|
|
||||||
await mutateAsync(integration, {
|
|
||||||
onSuccess: () => {
|
|
||||||
removeDirty();
|
|
||||||
showSuccessNotification({
|
|
||||||
title: t("notification.success.title"),
|
|
||||||
message: t("notification.success.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
if (error.data?.zodError?.fieldErrors.url) {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("notification.invalidUrl.title"),
|
|
||||||
message: t("notification.invalidUrl.message"),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error.message === "SECRETS_NOT_DEFINED") {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("notification.notAllSecretsProvided.title"),
|
|
||||||
message: t("notification.notAllSecretsProvided.message"),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("notification.commonError.title"),
|
|
||||||
message: t("notification.commonError.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("action")}
|
|
||||||
</Anchor>
|
|
||||||
<TestConnectionIcon isDirty={isDirty} {...mutation} size={20} />
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TestConnectionIconProps {
|
|
||||||
isDirty: boolean;
|
|
||||||
isPending: boolean;
|
|
||||||
isSuccess: boolean;
|
|
||||||
isError: boolean;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TestConnectionIcon = ({ isDirty, isPending, isSuccess, isError, size }: TestConnectionIconProps) => {
|
|
||||||
if (isPending) return <Loader color="blue" size={size} />;
|
|
||||||
if (isDirty) return null;
|
|
||||||
if (isSuccess) return <IconCheck size={size} stroke={1.5} color="green" />;
|
|
||||||
if (isError) return <IconX size={size} stroke={1.5} color="red" />;
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TestConnectionNoticeAlert = () => {
|
|
||||||
const t = useI18n();
|
|
||||||
return (
|
|
||||||
<Alert variant="light" color="yellow" title="Test Connection" icon={<IconInfoCircle />}>
|
|
||||||
{t("integration.testConnection.alertNotice")}
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -6,18 +6,18 @@ import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
|
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
import { SecretCard } from "../../_components/secrets/integration-secret-card";
|
||||||
import { SecretCard } from "../../_integration-secret-card";
|
import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";
|
||||||
import { IntegrationSecretInput } from "../../_integration-secret-inputs";
|
|
||||||
import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../../_integration-test-connection";
|
|
||||||
|
|
||||||
interface EditIntegrationForm {
|
interface EditIntegrationForm {
|
||||||
integration: RouterOutputs["integration"]["byId"];
|
integration: RouterOutputs["integration"]["byId"];
|
||||||
@@ -30,30 +30,23 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
getAllSecretKindOptions(integration.kind).find((secretKinds) =>
|
getAllSecretKindOptions(integration.kind).find((secretKinds) =>
|
||||||
integration.secrets.every((secret) => secretKinds.includes(secret.kind)),
|
integration.secrets.every((secret) => secretKinds.includes(secret.kind)),
|
||||||
) ?? getDefaultSecretKinds(integration.kind);
|
) ?? getDefaultSecretKinds(integration.kind);
|
||||||
const initialFormValues = {
|
|
||||||
name: integration.name,
|
|
||||||
url: integration.url,
|
|
||||||
secrets: secretsKinds.map((kind) => ({
|
|
||||||
kind,
|
|
||||||
value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
|
|
||||||
defaultDirty: true,
|
|
||||||
initialFormValue: initialFormValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const form = useZodForm(validation.integration.update.omit({ id: true }), {
|
const form = useZodForm(validation.integration.update.omit({ id: true }), {
|
||||||
initialValues: initialFormValues,
|
initialValues: {
|
||||||
onValuesChange,
|
name: integration.name,
|
||||||
|
url: integration.url,
|
||||||
|
secrets: secretsKinds.map((kind) => ({
|
||||||
|
kind,
|
||||||
|
value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
|
||||||
|
})),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
|
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
|
||||||
|
|
||||||
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
|
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
|
||||||
|
|
||||||
const handleSubmitAsync = async (values: FormType) => {
|
const handleSubmitAsync = async (values: FormType) => {
|
||||||
if (isDirty) return;
|
|
||||||
await mutateAsync(
|
await mutateAsync(
|
||||||
{
|
{
|
||||||
id: integration.id,
|
id: integration.id,
|
||||||
@@ -71,7 +64,19 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
});
|
});
|
||||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (error) => {
|
||||||
|
const testConnectionError = convertIntegrationTestConnectionError(error.data?.error);
|
||||||
|
|
||||||
|
if (testConnectionError) {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
|
||||||
|
message: testConnectionError.message
|
||||||
|
? testConnectionError.message
|
||||||
|
: t(`integration.testConnection.notification.${testConnectionError.key}.message`),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
title: t("integration.page.edit.notification.error.title"),
|
title: t("integration.page.edit.notification.error.title"),
|
||||||
message: t("integration.page.edit.notification.error.message"),
|
message: t("integration.page.edit.notification.error.message"),
|
||||||
@@ -84,8 +89,6 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TestConnectionNoticeAlert />
|
|
||||||
|
|
||||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||||
|
|
||||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||||
@@ -95,20 +98,21 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
{secretsKinds.map((kind, index) => (
|
{secretsKinds.map((kind, index) => (
|
||||||
<SecretCard
|
<SecretCard
|
||||||
key={kind}
|
key={kind}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
secret={secretsMap.get(kind)!}
|
secret={secretsMap.get(kind)!}
|
||||||
onCancel={() =>
|
onCancel={() =>
|
||||||
new Promise((res) => {
|
new Promise((resolve) => {
|
||||||
// When nothing changed, just close the secret card
|
// When nothing changed, just close the secret card
|
||||||
if ((form.values.secrets[index]?.value ?? "") === (secretsMap.get(kind)?.value ?? "")) {
|
if ((form.values.secrets[index]?.value ?? "") === (secretsMap.get(kind)?.value ?? "")) {
|
||||||
return res(true);
|
return resolve(true);
|
||||||
}
|
}
|
||||||
openConfirmModal({
|
openConfirmModal({
|
||||||
title: t("integration.secrets.reset.title"),
|
title: t("integration.secrets.reset.title"),
|
||||||
children: t("integration.secrets.reset.message"),
|
children: t("integration.secrets.reset.message"),
|
||||||
onCancel: () => res(false),
|
onCancel: () => resolve(false),
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(kind)!.value ?? "");
|
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(kind)?.value ?? "");
|
||||||
res(true);
|
resolve(true);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -125,24 +129,13 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="end" align="center">
|
||||||
<TestConnection
|
<Button variant="default" component={Link} href="/manage/integrations">
|
||||||
isDirty={isDirty}
|
{t("common.action.backToOverview")}
|
||||||
removeDirty={removeDirty}
|
</Button>
|
||||||
integration={{
|
<Button type="submit" loading={isPending}>
|
||||||
id: integration.id,
|
{t("integration.testConnection.action.edit")}
|
||||||
kind: integration.kind,
|
</Button>
|
||||||
...form.values,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Group>
|
|
||||||
<Button variant="default" component={Link} href="/manage/integrations">
|
|
||||||
{t("common.action.backToOverview")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={isPending} disabled={isDirty}>
|
|
||||||
{t("common.action.save")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Container, Group, Stack, Title } from "@mantine/core";
|
import { Container, Fieldset, Group, Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
import { getIntegrationName } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
import { IntegrationAvatar } from "../../_integration-avatar";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
|
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
||||||
|
import { IntegrationAccessSettings } from "../../_components/integration-access-settings";
|
||||||
import { EditIntegrationForm } from "./_integration-edit-form";
|
import { EditIntegrationForm } from "./_integration-edit-form";
|
||||||
|
|
||||||
interface EditIntegrationPageProps {
|
interface EditIntegrationPageProps {
|
||||||
@@ -12,18 +15,28 @@ interface EditIntegrationPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
|
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
|
||||||
const t = await getScopedI18n("integration.page.edit");
|
const editT = await getScopedI18n("integration.page.edit");
|
||||||
const integration = await api.integration.byId({ id: params.id });
|
const t = await getI18n();
|
||||||
|
const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound);
|
||||||
|
const integrationPermissions = await api.integration.getIntegrationPermissions({ id: integration.id });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<>
|
||||||
<Stack>
|
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, integration.name]])} nonInteractable={["edit"]} />
|
||||||
<Group align="center">
|
<Container>
|
||||||
<IntegrationAvatar kind={integration.kind} size="md" />
|
<Stack>
|
||||||
<Title>{t("title", { name: getIntegrationName(integration.kind) })}</Title>
|
<Group align="center">
|
||||||
</Group>
|
<IntegrationAvatar kind={integration.kind} size="md" />
|
||||||
<EditIntegrationForm integration={integration} />
|
<Title>{editT("title", { name: getIntegrationName(integration.kind) })}</Title>
|
||||||
</Stack>
|
</Group>
|
||||||
</Container>
|
<EditIntegrationForm integration={integration} />
|
||||||
|
|
||||||
|
<Title order={2}>{t("permission.title")}</Title>
|
||||||
|
<Fieldset>
|
||||||
|
<IntegrationAccessSettings integration={integration} initialPermissions={integrationPermissions} />
|
||||||
|
</Fieldset>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,12 @@
|
|||||||
import type { ChangeEvent } from "react";
|
import type { ChangeEvent } from "react";
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Group, Menu, ScrollArea, Stack, Text, TextInput } from "@mantine/core";
|
import { Flex, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core";
|
||||||
import { IconSearch } from "@tabler/icons-react";
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
import { IntegrationAvatar } from "../_integration-avatar";
|
|
||||||
|
|
||||||
export const IntegrationCreateDropdownContent = () => {
|
export const IntegrationCreateDropdownContent = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -25,7 +24,7 @@ export const IntegrationCreateDropdownContent = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Flex direction={{ base: "column-reverse", md: "column" }} gap="sm">
|
||||||
<TextInput
|
<TextInput
|
||||||
leftSection={<IconSearch stroke={1.5} size={20} />}
|
leftSection={<IconSearch stroke={1.5} size={20} />}
|
||||||
placeholder={t("integration.page.list.search")}
|
placeholder={t("integration.page.list.search")}
|
||||||
@@ -47,6 +46,6 @@ export const IntegrationCreateDropdownContent = () => {
|
|||||||
) : (
|
) : (
|
||||||
<Menu.Item disabled>{t("common.noResults")}</Menu.Item>
|
<Menu.Item disabled>{t("common.noResults")}</Menu.Item>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -3,21 +3,22 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button, Fieldset, Group, SegmentedControl, Stack, TextInput } from "@mantine/core";
|
import { Alert, Button, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
|
||||||
|
import { IconInfoCircle } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
||||||
import { getAllSecretKindOptions } from "@homarr/definitions";
|
import { getAllSecretKindOptions, getIntegrationName } from "@homarr/definitions";
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { IntegrationSecretInput } from "../_integration-secret-inputs";
|
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
|
||||||
import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../_integration-test-connection";
|
|
||||||
import { revalidatePathActionAsync } from "../../../../revalidatePathAction";
|
|
||||||
|
|
||||||
interface NewIntegrationFormProps {
|
interface NewIntegrationFormProps {
|
||||||
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
|
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
|
||||||
@@ -28,27 +29,20 @@ interface NewIntegrationFormProps {
|
|||||||
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
|
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const secretKinds = getAllSecretKindOptions(searchParams.kind);
|
const secretKinds = getAllSecretKindOptions(searchParams.kind);
|
||||||
const initialFormValues = {
|
|
||||||
name: searchParams.name ?? "",
|
|
||||||
url: searchParams.url ?? "",
|
|
||||||
secrets: secretKinds[0].map((kind) => ({
|
|
||||||
kind,
|
|
||||||
value: "",
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
|
|
||||||
defaultDirty: true,
|
|
||||||
initialFormValue: initialFormValues,
|
|
||||||
});
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const form = useZodForm(validation.integration.create.omit({ kind: true }), {
|
const form = useZodForm(validation.integration.create.omit({ kind: true }), {
|
||||||
initialValues: initialFormValues,
|
initialValues: {
|
||||||
onValuesChange,
|
name: searchParams.name ?? getIntegrationName(searchParams.kind),
|
||||||
|
url: searchParams.url ?? "",
|
||||||
|
secrets: secretKinds[0].map((kind) => ({
|
||||||
|
kind,
|
||||||
|
value: "",
|
||||||
|
})),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
|
const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
|
||||||
|
|
||||||
const handleSubmitAsync = async (values: FormType) => {
|
const handleSubmitAsync = async (values: FormType) => {
|
||||||
if (isDirty) return;
|
|
||||||
await mutateAsync(
|
await mutateAsync(
|
||||||
{
|
{
|
||||||
kind: searchParams.kind,
|
kind: searchParams.kind,
|
||||||
@@ -62,7 +56,19 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
});
|
});
|
||||||
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (error) => {
|
||||||
|
const testConnectionError = convertIntegrationTestConnectionError(error.data?.error);
|
||||||
|
|
||||||
|
if (testConnectionError) {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
|
||||||
|
message: testConnectionError.message
|
||||||
|
? testConnectionError.message
|
||||||
|
: t(`integration.testConnection.notification.${testConnectionError.key}.message`),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
title: t("integration.page.create.notification.error.title"),
|
title: t("integration.page.create.notification.error.title"),
|
||||||
message: t("integration.page.create.notification.error.message"),
|
message: t("integration.page.create.notification.error.message"),
|
||||||
@@ -75,9 +81,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TestConnectionNoticeAlert />
|
<TextInput withAsterisk label={t("integration.field.name.label")} autoFocus {...form.getInputProps("name")} />
|
||||||
|
|
||||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
|
||||||
|
|
||||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||||
|
|
||||||
@@ -92,28 +96,21 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
{...form.getInputProps(`secrets.${index}.value`)}
|
{...form.getInputProps(`secrets.${index}.value`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{form.values.secrets.length === 0 && (
|
||||||
|
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
|
||||||
|
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="end" align="center">
|
||||||
<TestConnection
|
<Button variant="default" component={Link} href="/manage/integrations">
|
||||||
isDirty={isDirty}
|
{t("common.action.backToOverview")}
|
||||||
removeDirty={removeDirty}
|
</Button>
|
||||||
integration={{
|
<Button type="submit" loading={isPending}>
|
||||||
id: null,
|
{t("integration.testConnection.action.create")}
|
||||||
kind: searchParams.kind,
|
</Button>
|
||||||
...form.values,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group>
|
|
||||||
<Button variant="default" component={Link} href="/manage/integrations">
|
|
||||||
{t("common.action.backToOverview")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={isPending} disabled={isDirty}>
|
|
||||||
{t("common.action.create")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
@@ -129,12 +126,20 @@ const SecretKindsSegmentedControl = ({ secretKinds, form }: SecretKindsSegmented
|
|||||||
const t = useScopedI18n("integration.secrets");
|
const t = useScopedI18n("integration.secrets");
|
||||||
|
|
||||||
const secretKindGroups = secretKinds.map((kinds) => ({
|
const secretKindGroups = secretKinds.map((kinds) => ({
|
||||||
label: kinds.map((kind) => t(`kind.${kind}.label`)).join(" & "),
|
label:
|
||||||
value: kinds.join("-"),
|
kinds.length === 0
|
||||||
|
? t("noSecretsRequired.segmentTitle")
|
||||||
|
: kinds.map((kind) => t(`kind.${kind}.label`)).join(" & "),
|
||||||
|
value: kinds.length === 0 ? "empty" : kinds.join("-"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const onChange = useCallback(
|
const onChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
|
if (value === "empty") {
|
||||||
|
form.setFieldValue("secrets", []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const kinds = value.split("-") as IntegrationSecretKind[];
|
const kinds = value.split("-") as IntegrationSecretKind[];
|
||||||
const secrets = kinds.map((kind) => ({
|
const secrets = kinds.map((kind) => ({
|
||||||
kind,
|
kind,
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Container, Group, Stack, Title } from "@mantine/core";
|
import { Container, Group, Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
import { IntegrationAvatar } from "@homarr/ui";
|
||||||
import type { validation } from "@homarr/validation";
|
import type { validation } from "@homarr/validation";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import { IntegrationAvatar } from "../_integration-avatar";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { NewIntegrationForm } from "./_integration-new-form";
|
import { NewIntegrationForm } from "./_integration-new-form";
|
||||||
|
|
||||||
interface NewIntegrationPageProps {
|
interface NewIntegrationPageProps {
|
||||||
@@ -17,24 +19,32 @@ interface NewIntegrationPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
|
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
|
||||||
const result = z.enum([integrationKinds[0]!, ...integrationKinds.slice(1)]).safeParse(searchParams.kind);
|
const session = await auth();
|
||||||
|
if (!session?.user.permissions.includes("integration-create")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = z.enum(integrationKinds).safeParse(searchParams.kind);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = await getScopedI18n("integration.page.create");
|
const tCreate = await getScopedI18n("integration.page.create");
|
||||||
|
|
||||||
const currentKind = result.data;
|
const currentKind = result.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<>
|
||||||
<Stack>
|
<DynamicBreadcrumb />
|
||||||
<Group align="center">
|
<Container>
|
||||||
<IntegrationAvatar kind={currentKind} size="md" />
|
<Stack>
|
||||||
<Title>{t("title", { name: getIntegrationName(currentKind) })}</Title>
|
<Group align="center">
|
||||||
</Group>
|
<IntegrationAvatar kind={currentKind} size="md" />
|
||||||
<NewIntegrationForm searchParams={searchParams} />
|
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
|
||||||
</Stack>
|
</Group>
|
||||||
</Container>
|
<NewIntegrationForm searchParams={searchParams} />
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Fragment } from "react";
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
AccordionControl,
|
AccordionControl,
|
||||||
@@ -5,9 +7,11 @@ import {
|
|||||||
AccordionPanel,
|
AccordionPanel,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
ActionIconGroup,
|
ActionIconGroup,
|
||||||
|
Affix,
|
||||||
Anchor,
|
Anchor,
|
||||||
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Container,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Menu,
|
Menu,
|
||||||
MenuDropdown,
|
MenuDropdown,
|
||||||
@@ -22,18 +26,20 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconChevronDown, IconPencil } from "@tabler/icons-react";
|
import { IconChevronDown, IconChevronUp, IconPencil } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import { objectEntries } from "@homarr/common";
|
import { objectEntries } from "@homarr/common";
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
import { getIntegrationName } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import { CountBadge } from "@homarr/ui";
|
import { CountBadge, IntegrationAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||||
import { IntegrationAvatar } from "./_integration-avatar";
|
|
||||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||||
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
||||||
|
|
||||||
@@ -45,29 +51,58 @@ interface IntegrationsPageProps {
|
|||||||
|
|
||||||
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
|
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
|
||||||
const integrations = await api.integration.all();
|
const integrations = await api.integration.all();
|
||||||
|
const session = await auth();
|
||||||
const t = await getScopedI18n("integration");
|
const t = await getScopedI18n("integration");
|
||||||
|
|
||||||
|
const canCreateIntegrations = session?.user.permissions.includes("integration-create") ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<ManageContainer>
|
||||||
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Title>{t("page.list.title")}</Title>
|
<Title>{t("page.list.title")}</Title>
|
||||||
<Menu width={256} trapFocus position="bottom-start" withinPortal shadow="md" keepMounted={false}>
|
|
||||||
<MenuTarget>
|
{canCreateIntegrations && (
|
||||||
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
<>
|
||||||
</MenuTarget>
|
<Box>
|
||||||
<MenuDropdown>
|
<IntegrationSelectMenu>
|
||||||
<IntegrationCreateDropdownContent />
|
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
||||||
</MenuDropdown>
|
<MenuTarget>
|
||||||
</Menu>
|
<Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||||
|
</MenuTarget>
|
||||||
|
</Affix>
|
||||||
|
</IntegrationSelectMenu>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box visibleFrom="md">
|
||||||
|
<IntegrationSelectMenu>
|
||||||
|
<MenuTarget>
|
||||||
|
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||||
|
</MenuTarget>
|
||||||
|
</IntegrationSelectMenu>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</ManageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
|
||||||
|
return (
|
||||||
|
<Menu width={256} trapFocus position="bottom-end" withinPortal shadow="md" keepMounted={false}>
|
||||||
|
{children}
|
||||||
|
<MenuDropdown>
|
||||||
|
<IntegrationCreateDropdownContent />
|
||||||
|
</MenuDropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface IntegrationListProps {
|
interface IntegrationListProps {
|
||||||
integrations: RouterOutputs["integration"]["all"];
|
integrations: RouterOutputs["integration"]["all"];
|
||||||
activeTab?: IntegrationKind;
|
activeTab?: IntegrationKind;
|
||||||
@@ -75,6 +110,8 @@ interface IntegrationListProps {
|
|||||||
|
|
||||||
const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps) => {
|
const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps) => {
|
||||||
const t = await getScopedI18n("integration");
|
const t = await getScopedI18n("integration");
|
||||||
|
const session = await auth();
|
||||||
|
const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
|
||||||
|
|
||||||
if (integrations.length === 0) {
|
if (integrations.length === 0) {
|
||||||
return <div>{t("page.list.empty")}</div>;
|
return <div>{t("page.list.empty")}</div>;
|
||||||
@@ -82,6 +119,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
|
|
||||||
const grouppedIntegrations = integrations.reduce(
|
const grouppedIntegrations = integrations.reduce(
|
||||||
(acc, integration) => {
|
(acc, integration) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
if (!acc[integration.kind]) {
|
if (!acc[integration.kind]) {
|
||||||
acc[integration.kind] = [];
|
acc[integration.kind] = [];
|
||||||
}
|
}
|
||||||
@@ -104,7 +142,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
</Group>
|
</Group>
|
||||||
</AccordionControl>
|
</AccordionControl>
|
||||||
<AccordionPanel>
|
<AccordionPanel>
|
||||||
<Table>
|
<Table visibleFrom="md">
|
||||||
<TableThead>
|
<TableThead>
|
||||||
<TableTr>
|
<TableTr>
|
||||||
<TableTh>{t("field.name.label")}</TableTh>
|
<TableTh>{t("field.name.label")}</TableTh>
|
||||||
@@ -123,24 +161,57 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
</TableTd>
|
</TableTd>
|
||||||
<TableTd>
|
<TableTd>
|
||||||
<Group justify="end">
|
<Group justify="end">
|
||||||
<ActionIconGroup>
|
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||||
<ActionIcon
|
<ActionIconGroup>
|
||||||
component={Link}
|
<ActionIcon
|
||||||
href={`/manage/integrations/edit/${integration.id}`}
|
component={Link}
|
||||||
variant="subtle"
|
href={`/manage/integrations/edit/${integration.id}`}
|
||||||
color="gray"
|
variant="subtle"
|
||||||
aria-label="Edit integration"
|
color="gray"
|
||||||
>
|
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||||
<IconPencil size={16} stroke={1.5} />
|
>
|
||||||
</ActionIcon>
|
<IconPencil size={16} stroke={1.5} />
|
||||||
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
</ActionIcon>
|
||||||
</ActionIconGroup>
|
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
||||||
|
</ActionIconGroup>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
</TableTr>
|
</TableTr>
|
||||||
))}
|
))}
|
||||||
</TableTbody>
|
</TableTbody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
<Stack gap="xs" hiddenFrom="md">
|
||||||
|
{integrations.map((integration, index) => (
|
||||||
|
<Fragment key={integration.id}>
|
||||||
|
{index !== 0 && <Divider />}
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Text>{integration.name}</Text>
|
||||||
|
{hasFullAccess ||
|
||||||
|
(integration.permissions.hasFullAccess && (
|
||||||
|
<ActionIconGroup>
|
||||||
|
<ActionIcon
|
||||||
|
component={Link}
|
||||||
|
href={`/manage/integrations/edit/${integration.id}`}
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||||
|
>
|
||||||
|
<IconPencil size={16} stroke={1.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
||||||
|
</ActionIconGroup>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||||
|
{integration.url}
|
||||||
|
</Anchor>
|
||||||
|
</Stack>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
</AccordionPanel>
|
</AccordionPanel>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
IconMailForward,
|
IconMailForward,
|
||||||
IconPlug,
|
IconPlug,
|
||||||
IconQuestionMark,
|
IconQuestionMark,
|
||||||
|
IconReport,
|
||||||
|
IconSearch,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconTool,
|
IconTool,
|
||||||
IconUser,
|
IconUser,
|
||||||
@@ -21,6 +23,9 @@ import {
|
|||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
|
import { createDocumentationLink } from "@homarr/definitions";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { MainHeader } from "~/components/layout/header";
|
import { MainHeader } from "~/components/layout/header";
|
||||||
@@ -30,6 +35,7 @@ import { ClientShell } from "~/components/layout/shell";
|
|||||||
|
|
||||||
export default async function ManageLayout({ children }: PropsWithChildren) {
|
export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||||
const t = await getScopedI18n("management.navbar");
|
const t = await getScopedI18n("management.navbar");
|
||||||
|
const session = await auth();
|
||||||
const navigationLinks: NavigationLink[] = [
|
const navigationLinks: NavigationLink[] = [
|
||||||
{
|
{
|
||||||
label: t("items.home"),
|
label: t("items.home"),
|
||||||
@@ -51,9 +57,15 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
href: "/manage/integrations",
|
href: "/manage/integrations",
|
||||||
label: t("items.integrations"),
|
label: t("items.integrations"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: IconSearch,
|
||||||
|
href: "/manage/search-engines",
|
||||||
|
label: t("items.searchEngies"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: IconUser,
|
icon: IconUser,
|
||||||
label: t("items.users.label"),
|
label: t("items.users.label"),
|
||||||
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: t("items.users.items.manage"),
|
label: t("items.users.items.manage"),
|
||||||
@@ -64,6 +76,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
label: t("items.users.items.invites"),
|
label: t("items.users.items.invites"),
|
||||||
icon: IconMailForward,
|
icon: IconMailForward,
|
||||||
href: "/manage/users/invites",
|
href: "/manage/users/invites",
|
||||||
|
hidden: !isProviderEnabled("credentials"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.users.items.groups"),
|
label: t("items.users.items.groups"),
|
||||||
@@ -75,23 +88,35 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
{
|
{
|
||||||
label: t("items.tools.label"),
|
label: t("items.tools.label"),
|
||||||
icon: IconTool,
|
icon: IconTool,
|
||||||
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.docker"),
|
label: t("items.tools.items.docker"),
|
||||||
icon: IconBrandDocker,
|
icon: IconBrandDocker,
|
||||||
href: "/manage/tools/docker",
|
href: "/manage/tools/docker",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t("items.tools.items.api"),
|
||||||
|
icon: IconPlug,
|
||||||
|
href: "/manage/tools/api",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.logs"),
|
label: t("items.tools.items.logs"),
|
||||||
icon: IconLogs,
|
icon: IconLogs,
|
||||||
href: "/manage/tools/logs",
|
href: "/manage/tools/logs",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t("items.tools.items.tasks"),
|
||||||
|
icon: IconReport,
|
||||||
|
href: "/manage/tools/tasks",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.settings"),
|
label: t("items.settings"),
|
||||||
href: "/manage/settings",
|
href: "/manage/settings",
|
||||||
icon: IconSettings,
|
icon: IconSettings,
|
||||||
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.help.label"),
|
label: t("items.help.label"),
|
||||||
@@ -100,7 +125,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
{
|
{
|
||||||
label: t("items.help.items.documentation"),
|
label: t("items.help.items.documentation"),
|
||||||
icon: IconBook2,
|
icon: IconBook2,
|
||||||
href: "https://homarr.dev/docs/getting-started/prerequisites",
|
href: createDocumentationLink("/docs/getting-started"),
|
||||||
external: true,
|
external: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -110,7 +135,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
external: true,
|
external: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.docker"),
|
label: t("items.help.items.discord"),
|
||||||
icon: IconBrandDiscord,
|
icon: IconBrandDiscord,
|
||||||
href: "https://discord.com/invite/aCsmEV5RgA",
|
href: "https://discord.com/invite/aCsmEV5RgA",
|
||||||
external: true,
|
external: true,
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
|
|||||||
import { IconArrowRight } from "@tabler/icons-react";
|
import { IconArrowRight } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { HeroBanner } from "./_components/hero-banner";
|
import { HeroBanner } from "./_components/hero-banner";
|
||||||
|
|
||||||
@@ -13,6 +16,7 @@ interface LinkProps {
|
|||||||
subtitle: string;
|
subtitle: string;
|
||||||
count: number;
|
count: number;
|
||||||
href: string;
|
href: string;
|
||||||
|
hidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
@@ -25,6 +29,7 @@ export async function generateMetadata() {
|
|||||||
|
|
||||||
export default async function ManagementPage() {
|
export default async function ManagementPage() {
|
||||||
const statistics = await api.home.getStats();
|
const statistics = await api.home.getStats();
|
||||||
|
const session = await auth();
|
||||||
const t = await getScopedI18n("management.page.home");
|
const t = await getScopedI18n("management.page.home");
|
||||||
|
|
||||||
const links: LinkProps[] = [
|
const links: LinkProps[] = [
|
||||||
@@ -32,62 +37,69 @@ export default async function ManagementPage() {
|
|||||||
count: statistics.countBoards,
|
count: statistics.countBoards,
|
||||||
href: "/manage/boards",
|
href: "/manage/boards",
|
||||||
subtitle: t("statisticLabel.boards"),
|
subtitle: t("statisticLabel.boards"),
|
||||||
title: t("statistic.countBoards"),
|
title: t("statistic.board"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
count: statistics.countUsers,
|
count: statistics.countUsers,
|
||||||
href: "/manage/boards",
|
href: "/manage/users",
|
||||||
subtitle: t("statisticLabel.authentication"),
|
subtitle: t("statisticLabel.authentication"),
|
||||||
title: t("statistic.createUser"),
|
title: t("statistic.user"),
|
||||||
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
count: statistics.countInvites,
|
count: statistics.countInvites,
|
||||||
href: "/manage/boards",
|
href: "/manage/users/invites",
|
||||||
subtitle: t("statisticLabel.authentication"),
|
subtitle: t("statisticLabel.authentication"),
|
||||||
title: t("statistic.createInvite"),
|
title: t("statistic.invite"),
|
||||||
|
hidden: !isProviderEnabled("credentials") || !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
count: statistics.countIntegrations,
|
count: statistics.countIntegrations,
|
||||||
href: "/manage/integrations",
|
href: "/manage/integrations",
|
||||||
subtitle: t("statisticLabel.resources"),
|
subtitle: t("statisticLabel.resources"),
|
||||||
title: t("statistic.addIntegration"),
|
title: t("statistic.integration"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
count: statistics.countApps,
|
count: statistics.countApps,
|
||||||
href: "/manage/apps",
|
href: "/manage/apps",
|
||||||
subtitle: t("statisticLabel.resources"),
|
subtitle: t("statisticLabel.resources"),
|
||||||
title: t("statistic.addApp"),
|
title: t("statistic.app"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
count: statistics.countGroups,
|
count: statistics.countGroups,
|
||||||
href: "/manage/users/groups",
|
href: "/manage/users/groups",
|
||||||
subtitle: t("statisticLabel.authorization"),
|
subtitle: t("statisticLabel.authorization"),
|
||||||
title: t("statistic.manageRoles"),
|
title: t("statistic.group"),
|
||||||
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<DynamicBreadcrumb />
|
||||||
<HeroBanner />
|
<HeroBanner />
|
||||||
<Space h="md" />
|
<Space h="md" />
|
||||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||||
{links.map((link, index) => (
|
{links.map(
|
||||||
<Card component={Link} href={link.href} key={`link-${index}`} withBorder>
|
(link) =>
|
||||||
<Group justify="space-between">
|
!link.hidden && (
|
||||||
<Group>
|
<Card component={Link} href={link.href} key={link.href} withBorder>
|
||||||
<Text size="2.4rem" fw="bolder">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
{link.count}
|
<Group wrap="nowrap">
|
||||||
</Text>
|
<Text size="2.4rem" fw="bolder">
|
||||||
<Stack gap={0}>
|
{link.count}
|
||||||
<Text c="red" size="xs">
|
</Text>
|
||||||
{link.subtitle}
|
<Stack gap={0}>
|
||||||
</Text>
|
<Text c="red" size="xs">
|
||||||
<Text fw="bold">{link.title}</Text>
|
{link.subtitle}
|
||||||
</Stack>
|
</Text>
|
||||||
</Group>
|
<Text fw="bold">{link.title}</Text>
|
||||||
<IconArrowRight />
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
<IconArrowRight />
|
||||||
))}
|
</Group>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button, Grid, Group, Stack, Textarea, TextInput } from "@mantine/core";
|
||||||
|
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import type { z } from "@homarr/validation";
|
||||||
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { IconPicker } from "~/components/icons/picker/icon-picker";
|
||||||
|
|
||||||
|
type FormType = z.infer<typeof validation.searchEngine.manage>;
|
||||||
|
|
||||||
|
interface SearchEngineFormProps {
|
||||||
|
submitButtonTranslation: (t: TranslationFunction) => string;
|
||||||
|
initialValues?: FormType;
|
||||||
|
handleSubmit: (values: FormType) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
disableShort?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||||
|
const { submitButtonTranslation, handleSubmit, initialValues, isPending, disableShort } = props;
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
const form = useZodForm(validation.searchEngine.manage, {
|
||||||
|
initialValues: initialValues ?? {
|
||||||
|
name: "",
|
||||||
|
short: "",
|
||||||
|
iconUrl: "",
|
||||||
|
urlTemplate: "",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack>
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span={{ base: 12, md: 8, lg: 9, xl: 10 }}>
|
||||||
|
<TextInput {...form.getInputProps("name")} withAsterisk label={t("search.engine.field.name.label")} />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 4, lg: 3, xl: 2 }}>
|
||||||
|
<TextInput
|
||||||
|
{...form.getInputProps("short")}
|
||||||
|
disabled={disableShort}
|
||||||
|
withAsterisk
|
||||||
|
label={t("search.engine.field.short.label")}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
|
||||||
|
<TextInput
|
||||||
|
{...form.getInputProps("urlTemplate")}
|
||||||
|
withAsterisk
|
||||||
|
label={t("search.engine.field.urlTemplate.label")}
|
||||||
|
/>
|
||||||
|
<Textarea {...form.getInputProps("description")} label={t("search.engine.field.description.label")} />
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<Button variant="default" component={Link} href="/manage/search-engines">
|
||||||
|
{t("common.action.backToOverview")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={isPending}>
|
||||||
|
{submitButtonTranslation(t)}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { ActionIcon } from "@mantine/core";
|
||||||
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
interface SearchEngineDeleteButtonProps {
|
||||||
|
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => {
|
||||||
|
const t = useScopedI18n("search.engine.page.delete");
|
||||||
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
const { mutate, isPending } = clientApi.searchEngine.delete.useMutation();
|
||||||
|
|
||||||
|
const onClick = useCallback(() => {
|
||||||
|
openConfirmModal({
|
||||||
|
title: t("title"),
|
||||||
|
children: t("message", searchEngine),
|
||||||
|
onConfirm: () => {
|
||||||
|
mutate(
|
||||||
|
{ id: searchEngine.id },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t("notification.success.title"),
|
||||||
|
message: t("notification.success.message"),
|
||||||
|
});
|
||||||
|
void revalidatePathActionAsync("/manage/search-engines");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("notification.error.title"),
|
||||||
|
message: t("notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [searchEngine, mutate, t, openConfirmModal]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
|
||||||
|
<IconTrash color="red" size={16} stroke={1.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import type { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { SearchEngineForm } from "../../_form";
|
||||||
|
|
||||||
|
interface SearchEngineEditFormProps {
|
||||||
|
searchEngine: RouterOutputs["searchEngine"]["byId"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchEngineEditForm = ({ searchEngine }: SearchEngineEditFormProps) => {
|
||||||
|
const t = useScopedI18n("search.engine.page.edit.notification");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { mutate, isPending } = clientApi.searchEngine.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t("success.title"),
|
||||||
|
message: t("success.message"),
|
||||||
|
});
|
||||||
|
void revalidatePathActionAsync("/manage/search-engines").then(() => {
|
||||||
|
router.push("/manage/search-engines");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("error.title"),
|
||||||
|
message: t("error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(values: z.infer<typeof validation.searchEngine.manage>) => {
|
||||||
|
mutate({
|
||||||
|
id: searchEngine.id,
|
||||||
|
...values,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[mutate, searchEngine.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchEngineForm
|
||||||
|
submitButtonTranslation={submitButtonTranslation}
|
||||||
|
initialValues={searchEngine}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
isPending={isPending}
|
||||||
|
disableShort
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
|
import { SearchEngineEditForm } from "./_search-engine-edit-form";
|
||||||
|
|
||||||
|
interface SearchEngineEditPageProps {
|
||||||
|
params: { id: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SearchEngineEditPage({ params }: SearchEngineEditPageProps) {
|
||||||
|
const searchEngine = await api.searchEngine.byId({ id: params.id });
|
||||||
|
const t = await getI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ManageContainer>
|
||||||
|
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, searchEngine.name]])} nonInteractable={["edit"]} />
|
||||||
|
<Stack>
|
||||||
|
<Title>{t("search.engine.page.edit.title")}</Title>
|
||||||
|
<SearchEngineEditForm searchEngine={searchEngine} />
|
||||||
|
</Stack>
|
||||||
|
</ManageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import type { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { SearchEngineForm } from "../_form";
|
||||||
|
|
||||||
|
export const SearchEngineNewForm = () => {
|
||||||
|
const t = useScopedI18n("search.engine.page.create.notification");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { mutate, isPending } = clientApi.searchEngine.create.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
showSuccessNotification({
|
||||||
|
title: t("success.title"),
|
||||||
|
message: t("success.message"),
|
||||||
|
});
|
||||||
|
await revalidatePathActionAsync("/manage/search-engines");
|
||||||
|
router.push("/manage/search-engines");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("error.title"),
|
||||||
|
message: t("error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(values: z.infer<typeof validation.searchEngine.manage>) => {
|
||||||
|
mutate(values);
|
||||||
|
},
|
||||||
|
[mutate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchEngineForm
|
||||||
|
submitButtonTranslation={submitButtonTranslation}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
isPending={isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
|
import { SearchEngineNewForm } from "./_search-engine-new-form";
|
||||||
|
|
||||||
|
export default async function SearchEngineNewPage() {
|
||||||
|
const t = await getI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ManageContainer>
|
||||||
|
<DynamicBreadcrumb />
|
||||||
|
<Stack>
|
||||||
|
<Title>{t("search.engine.page.create.title")}</Title>
|
||||||
|
<SearchEngineNewForm />
|
||||||
|
</Stack>
|
||||||
|
</ManageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import { IconPencil, IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
import { SearchInput, TablePagination } from "@homarr/ui";
|
||||||
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
|
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
|
||||||
|
|
||||||
|
const searchParamsSchema = z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
|
||||||
|
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
|
||||||
|
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
interface SearchEnginesPageProps {
|
||||||
|
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
||||||
|
const searchParams = searchParamsSchema.parse(props.searchParams);
|
||||||
|
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
|
||||||
|
|
||||||
|
const tEngine = await getScopedI18n("search.engine");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ManageContainer>
|
||||||
|
<DynamicBreadcrumb />
|
||||||
|
<Stack>
|
||||||
|
<Title>{tEngine("page.list.title")}</Title>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} />
|
||||||
|
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
||||||
|
{tEngine("page.create.title")}
|
||||||
|
</MobileAffixButton>
|
||||||
|
</Group>
|
||||||
|
{searchEngines.length === 0 && <SearchEngineNoResults />}
|
||||||
|
{searchEngines.length > 0 && (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{searchEngines.map((searchEngine) => (
|
||||||
|
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</ManageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchEngineCardProps {
|
||||||
|
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
||||||
|
const t = await getScopedI18n("search.engine");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
|
||||||
|
<Avatar
|
||||||
|
size="sm"
|
||||||
|
src={searchEngine.iconUrl}
|
||||||
|
radius={0}
|
||||||
|
styles={{
|
||||||
|
image: {
|
||||||
|
objectFit: "contain",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw={500} lineClamp={1}>
|
||||||
|
{searchEngine.name}
|
||||||
|
</Text>
|
||||||
|
{searchEngine.description && (
|
||||||
|
<Text size="sm" c="gray.6" lineClamp={4}>
|
||||||
|
{searchEngine.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Anchor href={searchEngine.urlTemplate.replace("%s", "test")} lineClamp={1} size="sm">
|
||||||
|
{searchEngine.urlTemplate}
|
||||||
|
</Anchor>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<ActionIconGroup>
|
||||||
|
<ActionIcon
|
||||||
|
component={Link}
|
||||||
|
href={`/manage/search-engines/edit/${searchEngine.id}`}
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label={t("page.edit.title")}
|
||||||
|
>
|
||||||
|
<IconPencil size={16} stroke={1.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
<SearchEngineDeleteButton searchEngine={searchEngine} />
|
||||||
|
</ActionIconGroup>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SearchEngineNoResults = async () => {
|
||||||
|
const t = await getI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder bg="transparent">
|
||||||
|
<Stack align="center" gap="sm">
|
||||||
|
<IconSearch size="2rem" />
|
||||||
|
<Text fw={500} size="lg">
|
||||||
|
{t("search.engine.page.list.noResults.title")}
|
||||||
|
</Text>
|
||||||
|
<Anchor href="/manage/search-engines/new">{t("search.engine.page.list.noResults.action")}</Anchor>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import type { MantineSpacing } from "@mantine/core";
|
import { Card, LoadingOverlay, Stack, Title } from "@mantine/core";
|
||||||
import { Card, Group, LoadingOverlay, Stack, Switch, Text, Title, UnstyledButton } from "@mantine/core";
|
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { useForm } from "@homarr/form";
|
import { useForm } from "@homarr/form";
|
||||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
||||||
|
|
||||||
interface AnalyticsSettingsProps {
|
interface AnalyticsSettingsProps {
|
||||||
initialData: typeof defaultServerSettings.analytics;
|
initialData: typeof defaultServerSettings.analytics;
|
||||||
@@ -62,6 +60,7 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
|||||||
ms="xl"
|
ms="xl"
|
||||||
title={t("integrationData.title")}
|
title={t("integrationData.title")}
|
||||||
text={t("integrationData.text")}
|
text={t("integrationData.text")}
|
||||||
|
disabled={!form.values.enableGeneral}
|
||||||
/>
|
/>
|
||||||
<SwitchSetting
|
<SwitchSetting
|
||||||
form={form}
|
form={form}
|
||||||
@@ -69,6 +68,7 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
|||||||
ms="xl"
|
ms="xl"
|
||||||
title={t("widgetData.title")}
|
title={t("widgetData.title")}
|
||||||
text={t("widgetData.text")}
|
text={t("widgetData.text")}
|
||||||
|
disabled={!form.values.enableGeneral}
|
||||||
/>
|
/>
|
||||||
<SwitchSetting
|
<SwitchSetting
|
||||||
form={form}
|
form={form}
|
||||||
@@ -76,43 +76,10 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
|||||||
ms="xl"
|
ms="xl"
|
||||||
title={t("usersData.title")}
|
title={t("usersData.title")}
|
||||||
text={t("usersData.text")}
|
text={t("usersData.text")}
|
||||||
|
disabled={!form.values.enableGeneral}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SwitchSetting = ({
|
|
||||||
form,
|
|
||||||
ms,
|
|
||||||
title,
|
|
||||||
text,
|
|
||||||
formKey,
|
|
||||||
}: {
|
|
||||||
form: UseFormReturnType<typeof defaultServerSettings.analytics>;
|
|
||||||
formKey: keyof typeof defaultServerSettings.analytics;
|
|
||||||
ms?: MantineSpacing;
|
|
||||||
title: string;
|
|
||||||
text: ReactNode;
|
|
||||||
}) => {
|
|
||||||
const disabled = formKey !== "enableGeneral" && !form.values.enableGeneral;
|
|
||||||
const handleClick = React.useCallback(() => {
|
|
||||||
if (disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
form.setFieldValue(formKey, !form.values[formKey]);
|
|
||||||
}, [form, formKey, disabled]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group ms={ms} justify="space-between" gap="lg" align="center" wrap="nowrap">
|
|
||||||
<UnstyledButton style={{ flexGrow: 1 }} onClick={handleClick}>
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Text fw="bold">{title}</Text>
|
|
||||||
<Text c="gray.5">{text}</Text>
|
|
||||||
</Stack>
|
|
||||||
</UnstyledButton>
|
|
||||||
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Card, LoadingOverlay, Stack, Text, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useForm } from "@homarr/form";
|
||||||
|
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
||||||
|
|
||||||
|
interface CrawlingAndIndexingSettingsProps {
|
||||||
|
initialData: typeof defaultServerSettings.crawlingAndIndexing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexingSettingsProps) => {
|
||||||
|
const t = useScopedI18n("management.page.settings.section.crawlingAndIndexing");
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: initialData,
|
||||||
|
onValuesChange: (updatedValues, _) => {
|
||||||
|
if (!form.isValid()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void mutateAsync({
|
||||||
|
settingsKey: "crawlingAndIndexing",
|
||||||
|
value: updatedValues,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
|
||||||
|
onSettled: async () => {
|
||||||
|
await revalidatePathActionAsync("/manage/settings");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Title order={2}>{t("title")}</Title>
|
||||||
|
|
||||||
|
<Card pos="relative" withBorder>
|
||||||
|
<Text c={"dimmed"} mb={"lg"}>
|
||||||
|
{t("warning")}
|
||||||
|
</Text>
|
||||||
|
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
||||||
|
<Stack>
|
||||||
|
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
||||||
|
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
||||||
|
<SwitchSetting
|
||||||
|
form={form}
|
||||||
|
formKey="noTranslate"
|
||||||
|
title={t("noTranslate.title")}
|
||||||
|
text={t("noTranslate.text")}
|
||||||
|
/>
|
||||||
|
<SwitchSetting
|
||||||
|
form={form}
|
||||||
|
formKey="noSiteLinksSearchBox"
|
||||||
|
title={t("noSiteLinksSearchBox.title")}
|
||||||
|
text={t("noSiteLinksSearchBox.text")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||