Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
424f152e98 | ||
|
|
4356f3681e | ||
|
|
84e500279d | ||
|
|
6a79d01d79 | ||
|
|
42ab783085 | ||
|
|
364e399d62 | ||
|
|
5c11a95ddc | ||
|
|
3521d2d51a | ||
|
|
c840ff85bc | ||
|
|
aaa3e7ce0c | ||
|
|
58008c1e2f | ||
|
|
6d6840f85e | ||
|
|
79d52b6905 | ||
|
|
71f4f8c4c8 | ||
|
|
86cd1017b9 | ||
|
|
a5b366a92e | ||
|
|
ef65270778 | ||
|
|
f38f3438c9 | ||
|
|
096f03432c | ||
|
|
bf45860966 | ||
|
|
2686dad630 | ||
|
|
1037a747ea | ||
|
|
6c7cdb3adb | ||
|
|
d8006e7dcc | ||
|
|
770bfc65e3 | ||
|
|
feb93ef0c7 | ||
|
|
bbec01385f | ||
|
|
2e23b3dad8 | ||
|
|
825b1a3643 | ||
|
|
2193b6d8f8 | ||
|
|
31cc1abb51 | ||
|
|
fb1f0003e6 | ||
|
|
b0d623047e | ||
|
|
16cf818449 | ||
|
|
3b705f43b8 | ||
|
|
a8e045b223 | ||
|
|
57bbd43c74 | ||
|
|
716a2ab9d1 | ||
|
|
23cc4659c4 | ||
|
|
a1a6dc9d6e | ||
|
|
0e01dd8695 | ||
|
|
70ab29454a | ||
|
|
5ecf93a317 | ||
|
|
7f2713b0b9 | ||
|
|
838f7aae4d | ||
|
|
7343bdde13 | ||
|
|
3ffdc05442 | ||
|
|
a53a2cdd34 | ||
|
|
0ab9d0bd69 | ||
|
|
fb4a19bfe4 | ||
|
|
b277f444b2 | ||
|
|
d5f76218cd | ||
|
|
9968f149c6 | ||
|
|
6ad5fd5529 | ||
|
|
9b6eafc5fa | ||
|
|
cebc925b87 | ||
|
|
fd42bde76d | ||
|
|
2d7efc3ffa | ||
|
|
44e2105a8c | ||
|
|
4df6006f0e | ||
|
|
c1f2ede834 | ||
|
|
85a7b7df55 | ||
|
|
09dae45ae8 | ||
|
|
52d2a88b61 | ||
|
|
f2e7349cbd | ||
|
|
fc734e5931 | ||
|
|
9f982906f2 | ||
|
|
f2268c8041 | ||
|
|
c1fe6551ce | ||
|
|
91a199f7d6 | ||
|
|
27b8f65238 | ||
|
|
37f961c8cc | ||
|
|
63f863748e | ||
|
|
907c28c945 | ||
|
|
2e9b99dff7 | ||
|
|
93aedd994f | ||
|
|
bfe284cdd1 | ||
|
|
591c5b5cff | ||
|
|
3cbc20bdfe | ||
|
|
b6c28da32b | ||
|
|
7e349bb04b | ||
|
|
b68977c52c | ||
|
|
74e1b6b544 | ||
|
|
1c4d4f4e9d | ||
|
|
ecf299e03b | ||
|
|
225356cc4f | ||
|
|
32e6b8ee45 | ||
|
|
2e2814707e | ||
|
|
24daba743e | ||
|
|
d76b4d0ec1 | ||
|
|
982ab4393e | ||
|
|
5a3d9745d9 | ||
|
|
4a94d602c0 | ||
|
|
9fb14df210 | ||
|
|
38143b679e | ||
|
|
1fc48f9db0 | ||
|
|
8fea983c2e | ||
|
|
32ee9f3dcc | ||
|
|
cdfb61fb28 | ||
|
|
482ca497f4 | ||
|
|
219a6adedd | ||
|
|
50c96833a6 | ||
|
|
6018b20d4e | ||
|
|
ba4c9a683e | ||
|
|
86ae346979 | ||
|
|
06e4623f8d | ||
|
|
85a69b13e3 | ||
|
|
a9a3a4cdce | ||
|
|
2ffa9d0e71 | ||
|
|
73cb0b99ac | ||
|
|
563c5d8f3c | ||
|
|
72eda1f225 | ||
|
|
a2a65abd3d | ||
|
|
ed74604a54 | ||
|
|
ed10d63a04 | ||
|
|
ae6137710f | ||
|
|
a27a2c3da1 | ||
|
|
860d2e4f69 | ||
|
|
35f13d2a01 | ||
|
|
21dc2138b7 | ||
|
|
441cbbe717 | ||
|
|
0a46a8e477 | ||
|
|
fa625df16d | ||
|
|
18b579eb5f | ||
|
|
2f76e537b7 | ||
|
|
7ea9154b67 | ||
|
|
e233d60f87 | ||
|
|
b8877137ac | ||
|
|
6e772e0585 | ||
|
|
4ba9e94dd6 | ||
|
|
0ee343b99e | ||
|
|
879aa1152f | ||
|
|
1105f0029f | ||
|
|
f3ebd6ccdf | ||
|
|
775d0a53de | ||
|
|
70f9dc5460 | ||
|
|
27931be982 | ||
|
|
e9a74a01b9 | ||
|
|
d4f0aa761d | ||
|
|
6dcf1ad344 | ||
|
|
41f816be5b | ||
|
|
2fb014f2df | ||
|
|
908d0bbc50 | ||
|
|
36fc707705 | ||
|
|
5d38030841 | ||
|
|
4264f2320e | ||
|
|
12c3bb9848 | ||
|
|
9bc585648c | ||
|
|
acc440bb89 | ||
|
|
ccee3dd217 | ||
|
|
2edb9c701a | ||
|
|
07f89afea8 | ||
|
|
684b542979 | ||
|
|
3194733e95 | ||
|
|
3ca97fef0b | ||
|
|
7a9e9a1e0d | ||
|
|
9bd0e0f4ee | ||
|
|
a1fddaa6a6 | ||
|
|
11ce6faa37 | ||
|
|
f220bb6244 | ||
|
|
53acff9143 | ||
|
|
75d764bca8 | ||
|
|
2a45c5b946 | ||
|
|
4ab40c6fa8 | ||
|
|
ce32b4839d | ||
|
|
ceafd3747b | ||
|
|
15abfbbdf7 | ||
|
|
fc4cddcbac | ||
|
|
04895da08a | ||
|
|
747922f558 | ||
|
|
0aa72d118c | ||
|
|
2306ecda9f | ||
|
|
87221093d2 | ||
|
|
c51c5db3d7 | ||
|
|
aa503992af | ||
|
|
2a7d648049 | ||
|
|
27b8692a27 | ||
|
|
fe4dfc484b | ||
|
|
23574359f0 | ||
|
|
c6a1aeef4d | ||
|
|
7cd3156bd6 | ||
|
|
7966476cad | ||
|
|
10cebff4ed | ||
|
|
e05567d100 | ||
|
|
cd18458d0c | ||
|
|
31c661bc69 | ||
|
|
0809d5aa74 | ||
|
|
9535154128 | ||
|
|
319fb47744 | ||
|
|
7e1305c6a9 | ||
|
|
4643c0f004 | ||
|
|
68a72ce5e3 | ||
|
|
e332a6fcbe | ||
|
|
b7fc1ae4bb | ||
|
|
cf5bcab732 | ||
|
|
97eb4c54e2 | ||
|
|
8f393aa9e8 | ||
|
|
b45eeb95e9 | ||
|
|
90c105541e | ||
|
|
7a07f57d0c | ||
|
|
f31ccf2d6a | ||
|
|
aec9ec1efd | ||
|
|
8983734408 | ||
|
|
0c067c4e6f | ||
|
|
34506b6339 | ||
|
|
0ff7c8903b | ||
|
|
b294bf68cf | ||
|
|
deb5a716db | ||
|
|
326b769c23 | ||
|
|
49c0ebea6d | ||
|
|
f8bdd9c5c7 | ||
|
|
d908b3f676 | ||
|
|
baabf71707 | ||
|
|
02ddb1c9c6 | ||
|
|
3ff8539fec | ||
|
|
3bfdc2ded4 | ||
|
|
747660ee69 | ||
|
|
17401d1d72 | ||
|
|
134ecf1ee7 | ||
|
|
4e9b0af54c | ||
|
|
fd2ce2eee2 | ||
|
|
74a8495903 | ||
|
|
d3f9696127 | ||
|
|
3c3dbf51dc | ||
|
|
f03688144f | ||
|
|
8da1dd0d5f | ||
|
|
7a4c9a9b2e | ||
|
|
958c62c926 | ||
|
|
8d2d151787 | ||
|
|
87f4298d47 | ||
|
|
7ca67a13f1 | ||
|
|
9df0f2b5b5 | ||
|
|
5db973f935 | ||
|
|
a4ae38b5e4 | ||
|
|
4502569223 | ||
|
|
db198c6dab | ||
|
|
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 | ||
|
|
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 | ||
|
|
6ad201f4a2 | ||
|
|
87b46deaf7 | ||
|
|
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 | ||
|
|
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 |
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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
@@ -1,31 +1,19 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
$schema: "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": [
|
extends: ["config:recommended"],
|
||||||
"config:recommended"
|
packageRules: [
|
||||||
],
|
|
||||||
"packageRules": [
|
|
||||||
{
|
{
|
||||||
"matchPackagePatterns": [
|
matchPackagePatterns: ["^@homarr/"],
|
||||||
"^@homarr/",
|
enabled: false,
|
||||||
"tsx" // Disabled for now as version 0.14.4 did not work with the current version of homarr. It resulted in a ERR_MODULE_NOT_FOUND error
|
|
||||||
],
|
|
||||||
"enabled": false
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"matchUpdateTypes": [
|
matchUpdateTypes: ["minor", "patch", "pin", "digest"],
|
||||||
"minor",
|
automerge: true,
|
||||||
"patch",
|
},
|
||||||
"pin",
|
|
||||||
"digest"
|
|
||||||
],
|
|
||||||
"automerge": true
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"updateInternalDeps": true,
|
updateInternalDeps: true,
|
||||||
"rangeStrategy": "bump",
|
rangeStrategy: "bump",
|
||||||
"automerge": false,
|
automerge: false,
|
||||||
"baseBranches": [
|
baseBranches: ["dev"],
|
||||||
"dev"
|
dependencyDashboard: false,
|
||||||
],
|
}
|
||||||
"dependencyDashboard": false
|
|
||||||
}
|
|
||||||
@@ -67,11 +67,33 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
|
|
||||||
- name: 'Report Coverage'
|
- name: "Report Coverage"
|
||||||
# Set if: always() to also generate the report if tests are failing
|
# Set if: always() to also generate the report if tests are failing
|
||||||
# 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
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup
|
||||||
|
uses: ./tooling/github/setup
|
||||||
|
- name: Build docker image
|
||||||
|
id: build-docker-image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
tags: homarr-e2e
|
||||||
|
network: host
|
||||||
|
env:
|
||||||
|
SKIP_ENV_VALIDATION: true
|
||||||
|
- name: Run E2E Tests
|
||||||
|
shell: bash
|
||||||
|
run: pnpm test:e2e
|
||||||
|
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: "[Crowdin] Download translations"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * *" # every day at midnight
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
download-crowdin-translations:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Obtain token
|
||||||
|
id: obtainToken
|
||||||
|
uses: tibdex/github-app-token@v2
|
||||||
|
with:
|
||||||
|
private_key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }}
|
||||||
|
app_id: ${{ secrets.CROWDIN_APP_ID }}
|
||||||
|
|
||||||
|
- name: Download Crowdin translations
|
||||||
|
uses: crowdin/github-action@v2
|
||||||
|
with:
|
||||||
|
upload_sources: false
|
||||||
|
upload_translations: false
|
||||||
|
download_translations: true
|
||||||
|
localization_branch_name: crowdin
|
||||||
|
create_pull_request: true
|
||||||
|
pull_request_title: "chore(lang): updated translations from crowdin"
|
||||||
|
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
|
||||||
|
pull_request_base_branch_name: "dev"
|
||||||
|
github_user_name: "Crowdin Homarr"
|
||||||
|
github_user_email: "190541745+homarr-crowdin[bot]@users.noreply.github.com"
|
||||||
|
skip_untranslated_strings: true
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||||
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||||
|
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: "[Crowdin] Upload translations"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "packages/translation/src/lang/**"
|
||||||
|
branches: [dev]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
upload-crowdin-translations:
|
||||||
|
# Don't run this action if the downloaded translations are being pushed
|
||||||
|
if: "!contains(github.event.head_commit.message, 'chore(lang)')"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Upload Crowdin translations
|
||||||
|
uses: crowdin/github-action@v2
|
||||||
|
with:
|
||||||
|
upload_sources: true
|
||||||
|
upload_translations: true
|
||||||
|
download_translations: false
|
||||||
|
env:
|
||||||
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||||
|
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||||
@@ -1,18 +1,30 @@
|
|||||||
name: "[Deployment] Release"
|
name: "[Deployment] Release"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
push:
|
||||||
types:
|
|
||||||
- closed
|
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
workflow_dispatch: {}
|
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:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
SKIP_ENV_VALIDATION: true
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
TURBO_TELEMETRY_DISABLED: 1
|
TURBO_TELEMETRY_DISABLED: 1
|
||||||
|
|
||||||
concurrency: production
|
concurrency: production
|
||||||
@@ -21,47 +33,34 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
name: Deploy docker image
|
name: Deploy docker image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node-version: [20]
|
|
||||||
steps:
|
steps:
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications != false }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Deployment of an image has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
|
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
|
- uses: actions/checkout@v4
|
||||||
- name: Get Next Version
|
- name: Get Next Version
|
||||||
id: semver
|
id: semver
|
||||||
uses: ietf-tools/semver-action@v1
|
uses: ietf-tools/semver-action@v1
|
||||||
with:
|
with:
|
||||||
token: ${{ github.token }}
|
token: ${{ github.token }}
|
||||||
branch: master
|
branch: dev
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications != false }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Semver computed next tag to be ${{ steps.semver.outputs.next }}. Current is ${{ steps.semver.outputs.current }}"
|
args: "Semver computed next tag to be ${{ steps.semver.outputs.next }}. Current is ${{ steps.semver.outputs.current }}. Building images..."
|
||||||
- uses: pnpm/action-setup@v2
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
version: 8
|
registry: ${{ env.REGISTRY }}
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
username: ${{ github.actor }}
|
||||||
uses: actions/setup-node@v4
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
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
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@@ -72,23 +71,48 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest
|
type=raw,value=alpha
|
||||||
type=raw,value=${{ steps.semver.outputs.next }}
|
type=raw,value=early-adopters
|
||||||
|
# tags: |
|
||||||
|
# type=raw,value=latest
|
||||||
|
# type=raw,value=${{ steps.semver.outputs.next }}
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: buildPushAction
|
id: buildPushAction
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
|
if: ${{ github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null }}
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/arm/v7,linux/arm/v6
|
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: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
network: host
|
network: host
|
||||||
|
env:
|
||||||
|
SKIP_ENV_VALIDATION: true
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications != false && (github.events.inputs.push-image == 'true' || github.events.inputs.push-image == null) }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'. This was a dry run."
|
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."
|
||||||
@@ -4,6 +4,12 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
|
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
send-notifications:
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
description: Send notifications
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -14,6 +20,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
@@ -30,27 +37,39 @@ jobs:
|
|||||||
token: ${{ github.token }}
|
token: ${{ github.token }}
|
||||||
branch: dev
|
branch: dev
|
||||||
- name: Create pull request
|
- name: Create pull request
|
||||||
uses: devops-infra/action-pull-request@v0.5.5
|
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"
|
||||||
id: create-pull-request
|
env:
|
||||||
with:
|
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
title: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
|
|
||||||
new_string: "**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 }}``"
|
|
||||||
source_branch: dev
|
|
||||||
target_branch: main
|
|
||||||
label: automerge
|
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
|
if: ${{ github.events.inputs.send-notifications }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})"
|
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
|
- id: automerge
|
||||||
if: ${{ steps.semver.outputs.bump != 'major' }}
|
if: ${{ steps.semver.outputs.bump != 'major' }}
|
||||||
name: automerge
|
name: automerge
|
||||||
uses: "pascalgn/automerge-action@v0.16.3"
|
uses: "pascalgn/automerge-action@v0.16.4"
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }}
|
||||||
MERGE_METHOD: merge # we prefer merge commits for merging to master
|
MERGE_METHOD: merge # we prefer merge commits for merging to master
|
||||||
MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
|
MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
|
||||||
MERGE_DELETE_BRANCH: false # never set to true!
|
MERGE_DELETE_BRANCH: false # never set to true!
|
||||||
@@ -60,21 +79,21 @@ jobs:
|
|||||||
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
|
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
|
||||||
|
|
||||||
- name: Merged Discord notification
|
- name: Merged Discord notification
|
||||||
if: ${{ steps.automerge.outputs.mergeResult == 'merged' }}
|
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.events.inputs.send-notifications }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
|
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
|
||||||
- name: Major Bump Discord notification
|
- name: Major Bump Discord notification
|
||||||
if: ${{ steps.semver.outputs.bump == 'major' }}
|
if: ${{ steps.semver.outputs.bump == 'major' && github.events.inputs.send-notifications }}
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
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 }}"
|
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
|
- name: Discord Fail Notification
|
||||||
if: failure()
|
if: failure() && github.events.inputs.send-notifications
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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
|
||||||
@@ -21,6 +22,4 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.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
|
|
||||||
@@ -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
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
"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",
|
||||||
|
"lokalise.i18n-ally"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# .vscode/i18n-ally-custom-framework.yml
|
||||||
|
|
||||||
|
# An array of strings which contain Language Ids defined by VS Code
|
||||||
|
# You can check available language ids here: https://code.visualstudio.com/docs/languages/identifiers
|
||||||
|
languageIds:
|
||||||
|
- javascript
|
||||||
|
- typescript
|
||||||
|
- javascriptreact
|
||||||
|
- typescriptreact
|
||||||
|
|
||||||
|
# An array of RegExes to find the key usage. **The key should be captured in the first match group**.
|
||||||
|
# You should unescape RegEx strings in order to fit in the YAML file
|
||||||
|
# To help with this, you can use https://www.freeformatter.com/json-escape.html
|
||||||
|
usageMatchRegex:
|
||||||
|
# The following example shows how to detect `t("your.i18n.keys")`
|
||||||
|
# the `{key}` will be placed by a proper keypath matching regex,
|
||||||
|
# you can ignore it and use your own matching rules as well
|
||||||
|
- "[^\\w\\d]t\\(['\"`]({key})['\"`]"
|
||||||
|
|
||||||
|
# A RegEx to set a custom scope range. This scope will be used as a prefix when detecting keys
|
||||||
|
# and works like how the i18next framework identifies the namespace scope from the
|
||||||
|
# useTranslation() hook.
|
||||||
|
# You should unescape RegEx strings in order to fit in the YAML file
|
||||||
|
# To help with this, you can use https://www.freeformatter.com/json-escape.html
|
||||||
|
scopeRangeRegex: "(getScopedI18n|useScopedI18n)\\(\\s*['\"](.*?)['\"]\\)"
|
||||||
|
|
||||||
|
# An array of strings containing refactor templates.
|
||||||
|
# The "$1" will be replaced by the keypath specified.
|
||||||
|
# Optional: uncomment the following two lines to use
|
||||||
|
|
||||||
|
# refactorTemplates:
|
||||||
|
# - i18n.get("$1")
|
||||||
|
|
||||||
|
|
||||||
|
# If set to true, only enables this custom framework (will disable all built-in frameworks)
|
||||||
|
monopoly: true
|
||||||
@@ -9,18 +9,35 @@
|
|||||||
"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": [
|
||||||
|
"ajnart",
|
||||||
"cqmin",
|
"cqmin",
|
||||||
|
"gridstack",
|
||||||
"homarr",
|
"homarr",
|
||||||
"jellyfin",
|
"jellyfin",
|
||||||
|
"llen",
|
||||||
|
"lpop",
|
||||||
|
"lpush",
|
||||||
|
"lrange",
|
||||||
|
"ltrim",
|
||||||
|
"mantine",
|
||||||
|
"manuel-rw",
|
||||||
|
"Meierschlumpf",
|
||||||
|
"overseerr",
|
||||||
|
"Sabnzbd",
|
||||||
|
"SeDemal",
|
||||||
|
"Sonarr",
|
||||||
"superjson",
|
"superjson",
|
||||||
|
"tabler",
|
||||||
"trpc",
|
"trpc",
|
||||||
"Umami",
|
"Umami"
|
||||||
"Sonarr"
|
|
||||||
],
|
],
|
||||||
"i18n-ally.dirStructure": "auto",
|
"i18n-ally.dirStructure": "auto",
|
||||||
"i18n-ally.enabledFrameworks": ["next-international"],
|
"i18n-ally.displayLanguage": "en",
|
||||||
"i18n-ally.localesPaths": ["./packages/translation/src/lang/"],
|
"i18n-ally.enabledFrameworks": [
|
||||||
"i18n-ally.enabledParsers": ["ts"],
|
"custom"
|
||||||
"i18n-ally.extract.keyMaxLength": 0,
|
],
|
||||||
"i18n-ally.keystyle": "flat"
|
"i18n-ally.localesPaths": [
|
||||||
|
"packages/translation/src/lang",
|
||||||
|
],
|
||||||
|
"i18n-ally.keystyle": "auto",
|
||||||
}
|
}
|
||||||
@@ -1,87 +1,80 @@
|
|||||||
FROM node:20.15.0-alpine AS base
|
FROM node:22.12.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 . .
|
|
||||||
RUN npm i -g turbo
|
|
||||||
RUN turbo prune @homarr/nextjs --docker --out-dir ./next-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/db --docker --out-dir ./migration-out
|
|
||||||
|
|
||||||
# Add lockfile and package.json's of isolated subworkspace
|
|
||||||
FROM base AS installer
|
|
||||||
RUN apk add --no-cache libc6-compat curl bash
|
RUN apk add --no-cache libc6-compat curl bash
|
||||||
RUN apk update
|
RUN apk update
|
||||||
WORKDIR /app
|
COPY . .
|
||||||
|
|
||||||
# First install the dependencies (as they change less often)
|
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
|
||||||
COPY .gitignore .gitignore
|
|
||||||
|
|
||||||
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/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/pnpm-lock.yaml ./pnpm-lock.yaml
|
|
||||||
RUN corepack enable pnpm && pnpm install
|
|
||||||
|
|
||||||
COPY --from=builder /app/next-out/json/ .
|
|
||||||
COPY --from=builder /app/next-out/pnpm-lock.yaml ./pnpm-lock.yaml
|
|
||||||
|
|
||||||
|
# Install sharp for image optimization
|
||||||
RUN corepack enable pnpm && pnpm install sharp -w
|
RUN corepack enable pnpm && pnpm install sharp -w
|
||||||
|
|
||||||
# Build the project
|
|
||||||
COPY --from=builder /app/tasks-out/full/ .
|
|
||||||
COPY --from=builder /app/websocket-out/full/ .
|
|
||||||
COPY --from=builder /app/next-out/full/ .
|
|
||||||
COPY --from=builder /app/migration-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/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=builder --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=builder /app/apps/nextjs/next.config.mjs .
|
||||||
COPY --from=installer /app/apps/nextjs/package.json .
|
COPY --from=builder /app/apps/nextjs/package.json .
|
||||||
|
|
||||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
|
||||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
|
||||||
COPY --from=installer --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
|
||||||
|
|
||||||
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations
|
COPY --from=builder --chown=nextjs:nodejs /app/packages/db/migrations ./db/migrations
|
||||||
|
|
||||||
# Automatically leverage output traces to reduce image size
|
# Automatically leverage output traces to reduce image size
|
||||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
|
COPY --from=builder --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=builder --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=builder --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"]
|
||||||
@@ -1,19 +1,29 @@
|
|||||||
// 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 createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
import "./src/env.mjs";
|
import "./src/env.mjs";
|
||||||
|
|
||||||
|
// Package path does not work... so we need to use relative path
|
||||||
|
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
|
||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const nextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
/** 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 }) => {
|
||||||
config.module.rules.push({
|
if (isServer) {
|
||||||
test: /\.node$/,
|
config.module.rules.push({
|
||||||
loader: "node-loader",
|
test: /\.node$/,
|
||||||
});
|
loader: "node-loader",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
@@ -25,4 +35,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default 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 withNextIntl(nextConfig);
|
||||||
@@ -7,12 +7,13 @@
|
|||||||
"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": "eslint",
|
|
||||||
"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",
|
||||||
@@ -22,27 +23,31 @@
|
|||||||
"@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.11.2",
|
||||||
"@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.11.1",
|
"@mantine/colors-generator": "^7.14.3",
|
||||||
"@mantine/core": "^7.11.1",
|
"@mantine/core": "^7.14.3",
|
||||||
"@mantine/hooks": "^7.11.1",
|
"@mantine/hooks": "^7.14.3",
|
||||||
"@mantine/modals": "^7.11.1",
|
"@mantine/modals": "^7.14.3",
|
||||||
"@mantine/tiptap": "^7.11.1",
|
"@mantine/tiptap": "^7.14.3",
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@million/lint": "1.0.13",
|
||||||
"@t3-oss/env-nextjs": "^0.10.1",
|
"@t3-oss/env-nextjs": "^0.11.1",
|
||||||
"@tanstack/react-query": "^5.50.1",
|
"@tabler/icons-react": "^3.24.0",
|
||||||
"@tanstack/react-query-devtools": "^5.50.1",
|
"@tanstack/react-query": "^5.62.3",
|
||||||
"@tanstack/react-query-next-experimental": "5.50.1",
|
"@tanstack/react-query-devtools": "^5.62.3",
|
||||||
"@tabler/icons-react": "^3.9.0",
|
"@tanstack/react-query-next-experimental": "5.62.3",
|
||||||
"@trpc/client": "next",
|
"@trpc/client": "next",
|
||||||
"@trpc/next": "next",
|
"@trpc/next": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
@@ -50,23 +55,24 @@
|
|||||||
"@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.7",
|
||||||
"flag-icons": "^7.2.3",
|
"flag-icons": "^7.2.3",
|
||||||
"glob": "^10.4.3",
|
"glob": "^11.0.0",
|
||||||
"jotai": "^2.8.4",
|
"jotai": "^2.10.3",
|
||||||
"mantine-react-table": "2.0.0-beta.5",
|
"mantine-react-table": "2.0.0-beta.7",
|
||||||
"next": "^14.2.4",
|
"next": "^14.2.20",
|
||||||
"postcss-preset-mantine": "^1.15.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-error-boundary": "^4.0.13",
|
"react-error-boundary": "^4.1.2",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"sass": "^1.77.6",
|
"sass": "^1.82.0",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.2",
|
||||||
|
"swagger-ui-react": "^5.18.2",
|
||||||
"use-deep-compare-effect": "^1.8.1"
|
"use-deep-compare-effect": "^1.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -74,15 +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.14.10",
|
"@types/node": "^22.10.1",
|
||||||
"@types/prismjs": "^1.26.4",
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.13",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.1",
|
||||||
"concurrently": "^8.2.2",
|
"@types/swagger-ui-react": "^4.18.3",
|
||||||
"eslint": "^9.6.0",
|
"concurrently": "^9.1.0",
|
||||||
"node-loader": "^2.0.0",
|
"eslint": "^9.16.0",
|
||||||
"prettier": "^3.3.2",
|
"node-loader": "^2.1.0",
|
||||||
"typescript": "^5.5.3"
|
"prettier": "^3.4.2",
|
||||||
},
|
"typescript": "^5.7.2"
|
||||||
"prettier": "@homarr/prettier-config"
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 497 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid meet" viewBox="0 0 575 289.83" width="575" height="289.83"><defs><path d="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z" id="d1pwhf9wy2"></path><path d="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z" id="g5jjnq26yS"></path><path d="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z" id="i3Prh1JpXt"></path><path d="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z" id="a4ov9rRGQm"></path><path d="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z" id="fk968BpsX"></path></defs><g><g><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill="#f6c700" fill-opacity="1"></use><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#g5jjnq26yS" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#g5jjnq26yS" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#fk968BpsX" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#fk968BpsX" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="708.767" height="179.332">
|
||||||
|
<path fill="#d51007" d="m158.431 165.498-8.354-22.708s-13.575 15.14-33.932 15.14c-18.013 0-30.802-15.662-30.802-40.721 0-32.106 16.182-43.591 32.107-43.591 22.969 0 30.277 14.878 36.543 33.934l8.354 26.103c8.351 25.318 24.013 45.678 69.17 45.678 32.37 0 54.295-9.918 54.295-36.02 0-21.143-12.009-32.107-34.458-37.328l-16.705-3.654c-11.484-2.61-14.877-7.309-14.877-15.14 0-8.875 7.046-14.096 18.533-14.096 12.529 0 19.315 4.699 20.36 15.923l26.102-3.133c-2.088-23.492-18.271-33.15-44.896-33.15-23.491 0-46.462 8.875-46.462 37.327 0 17.75 8.614 28.975 30.277 34.195l17.752 4.175c13.312 3.133 17.748 8.614 17.748 16.185 0 9.656-9.396 13.572-27.146 13.572-26.364 0-37.325-13.834-43.591-32.89l-8.614-26.101c-10.961-33.934-28.452-46.463-63.169-46.463-38.37 0-58.731 24.275-58.731 65.517 0 39.677 20.361 61.08 56.906 61.08 29.492 0 43.59-13.834 43.59-13.834zM46.726 153.229c-2.61.784-5.221 1.306-8.614 1.306-6.265 0-10.703-2.87-10.703-10.442V1.827H0v148.792c0 19.577 13.575 27.672 29.497 27.672 5.221 0 10.181-.785 16.446-2.349l.783-22.713zm330.185-4.176c-6.787 4.701-12.529 7.051-20.36 7.051-9.92 0-15.401-5.221-15.401-18.012V77.006h36.023V55.603H341.41V26.625l-27.669 3.394v25.583h-17.49v21.403h17.49v66.826c0 24.02 13.834 35.5 36.284 35.5 12.269 0 23.232-2.346 31.847-7.305l-4.961-22.973zm23.807 9.396c0 10.705 8.354 19.318 19.056 19.318 11.226 0 19.578-8.613 19.578-19.318 0-10.963-8.353-19.313-19.578-19.313-10.702 0-19.056 8.35-19.056 19.313zm67.009-81.443v99.195h27.409V77.006h30.803V55.603h-30.803V44.638c0-16.444 7.049-21.665 18.534-21.665 8.092 0 13.574 1.825 19.839 5.221l4.437-22.974C530.638 1.827 522.023 0 511.582 0c-22.973 0-43.855 10.963-43.855 43.593v12.01h-17.489v21.403h17.489zm167.427 2.352c-3.133-19.578-15.923-26.629-32.63-26.629-16.706 0-31.062 7.571-37.329 26.104l-3.393-23.23h-22.188v120.598h27.409v-68.129c0-23.235 12.008-32.11 24.799-32.11 13.312 0 18.795 8.875 18.795 23.232V176.2h27.147v-68.39c0-22.974 12.269-31.849 25.061-31.849 13.052 0 18.532 8.875 18.532 23.232v77.006h27.409v-86.66c0-25.843-15.14-36.81-35.24-36.81-16.965 0-32.107 7.571-38.372 26.629z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 190.24 81.52"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="40.76" x2="190.24" y2="40.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 2</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M105.67,36.06h66.9A17.67,17.67,0,0,0,190.24,18.4h0A17.67,17.67,0,0,0,172.57.73h-66.9A17.67,17.67,0,0,0,88,18.4h0A17.67,17.67,0,0,0,105.67,36.06Zm-88,45h76.9A17.67,17.67,0,0,0,112.24,63.4h0A17.67,17.67,0,0,0,94.57,45.73H17.67A17.67,17.67,0,0,0,0,63.4H0A17.67,17.67,0,0,0,17.67,81.06ZM10.41,35.42h7.8V6.92h10.1V0H.31v6.9h10.1Zm28.1,0h7.8V8.25h.1l9,27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2,23.1h-.1L50.31,0H38.51ZM152.43,55.67a15.07,15.07,0,0,0-4.52-5.52,18.57,18.57,0,0,0-6.68-3.08,33.54,33.54,0,0,0-8.07-1h-11.7v35.4h12.75a24.58,24.58,0,0,0,7.55-1.15A19.34,19.34,0,0,0,148.11,77a16.27,16.27,0,0,0,4.37-5.5,16.91,16.91,0,0,0,1.63-7.58A18.5,18.5,0,0,0,152.43,55.67ZM145,68.6A8.8,8.8,0,0,1,142.36,72a10.7,10.7,0,0,1-4,1.82,21.57,21.57,0,0,1-5,.55h-4.05v-21h4.6a17,17,0,0,1,4.67.63,11.66,11.66,0,0,1,3.88,1.87A9.14,9.14,0,0,1,145,59a9.87,9.87,0,0,1,1,4.52A11.89,11.89,0,0,1,145,68.6Zm44.63-.13a8,8,0,0,0-1.58-2.62A8.38,8.38,0,0,0,185.63,64a10.31,10.31,0,0,0-3.17-1v-.1a9.22,9.22,0,0,0,4.42-2.82,7.43,7.43,0,0,0,1.68-5,8.42,8.42,0,0,0-1.15-4.65,8.09,8.09,0,0,0-3-2.72,12.56,12.56,0,0,0-4.18-1.3,32.84,32.84,0,0,0-4.62-.33h-13.2v35.4h14.5a22.41,22.41,0,0,0,4.72-.5,13.53,13.53,0,0,0,4.28-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68A9.39,9.39,0,0,0,189.66,68.47ZM170.21,52.72h5.3a10,10,0,0,1,1.85.18,6.18,6.18,0,0,1,1.7.57,3.39,3.39,0,0,1,1.22,1.13,3.22,3.22,0,0,1,.48,1.82,3.63,3.63,0,0,1-.43,1.8,3.4,3.4,0,0,1-1.12,1.2,4.92,4.92,0,0,1-1.58.65,7.51,7.51,0,0,1-1.77.2h-5.65Zm11.72,20a3.9,3.9,0,0,1-1.22,1.3,4.64,4.64,0,0,1-1.68.7,8.18,8.18,0,0,1-1.82.2h-7v-8h5.9a15.35,15.35,0,0,1,2,.15,8.47,8.47,0,0,1,2.05.55,4,4,0,0,1,1.57,1.18,3.11,3.11,0,0,1,.63,2A3.71,3.71,0,0,1,181.93,72.72Z"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="64mm"
|
||||||
|
height="64mm"
|
||||||
|
viewBox="0 0 64 64"
|
||||||
|
version="1.1"
|
||||||
|
id="svg5"
|
||||||
|
inkscape:version="1.2-alpha1 (b6a15bbbed, 2022-02-23)"
|
||||||
|
sodipodi:docname="vgmdb.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview7"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.1089995"
|
||||||
|
inkscape:cx="96.017091"
|
||||||
|
inkscape:cy="132.29021"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="1017"
|
||||||
|
inkscape:window-x="1912"
|
||||||
|
inkscape:window-y="456"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1" />
|
||||||
|
<defs
|
||||||
|
id="defs2" />
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<path
|
||||||
|
style="fill:#000000;stroke-width:0.21854"
|
||||||
|
d="m 24.915135,62.650271 c 0.887879,-1.778597 2.041345,-3.66055 2.243584,-3.66055 0.373389,0 3.112623,2.34553 4.058216,3.474939 l 0.933036,1.114407 h -3.849247 -3.849246 z m 12.769897,0.133716 c 0.188983,-1.699691 0.540579,-9.438559 0.541952,-11.928786 l 0.0013,-2.561742 1.233546,-0.358603 c 1.858993,-0.540424 4.864719,-0.351614 6.306093,0.396126 2.102595,1.090761 3.373658,3.018391 3.382575,5.129841 0.0167,3.956206 -2.25508,7.722455 -5.608397,9.297806 -1.304436,0.612809 -1.766356,0.710219 -3.720191,0.784508 l -2.230831,0.08482 0.09384,-0.843972 z M 15.28167,58.692354 C 13.855849,58.372721 13.307133,58.098082 12.027965,57.063833 10.767623,56.044804 9.571224,54.160633 8.9251418,52.177293 8.4791686,50.808245 8.4074221,50.178438 8.4306037,47.836119 8.5286648,37.927793 11.975024,28.061437 18.601259,18.719217 c 1.935825,-2.729288 2.971061,-3.715223 4.909347,-4.675547 2.754154,-1.364545 6.425257,-1.245307 7.097695,0.230534 0.528157,1.159175 -0.351225,2.928281 -5.658254,11.38306 -3.70676,5.905347 -6.114927,9.18009 -8.52131,11.587702 l -1.747431,1.748322 0.748093,2.185403 c 1.052426,3.074452 2.737855,6.406887 3.730724,7.376398 l 0.831803,0.812233 1.792285,-1.012255 c 0.985756,-0.556742 3.87521,-2.58299 6.421008,-4.502779 l 4.628722,-3.490523 -0.152738,4.29269 c -0.08401,2.36098 -0.222472,4.480622 -0.3077,4.710314 -0.332428,0.895893 -8.050465,7.1666 -10.09734,8.203831 -2.185833,1.107645 -5.028822,1.564407 -6.994493,1.123754 z m 12.832756,-0.03877 c -0.369353,-0.206871 -0.546974,-0.44325 -0.453135,-0.603036 0.708434,-1.206309 4.008342,-5.172791 4.307161,-5.177197 0.225634,-0.0033 -0.01427,3.110825 -0.312679,4.058824 -0.487124,1.547514 -2.288476,2.42313 -3.541347,1.721409 z M 54.400377,47.270961 c -2.064993,-1.073365 -4.152108,-1.442529 -8.74161,-1.546198 -3.114822,-0.07036 -4.685696,-0.01346 -5.837847,0.211437 l -1.576311,0.307699 -0.101497,-0.893553 C 38.087288,44.858893 37.933571,42.73579 37.801516,40.63234 l -0.240099,-3.824455 0.497396,-0.409308 c 3.74237,-3.079615 11.651909,-7.818064 15.417757,-9.236475 2.486857,-0.936679 2.235049,-2.072647 2.235049,10.082864 0,5.829561 -0.07376,10.585246 -0.163906,10.568187 -0.09015,-0.01705 -0.606449,-0.261044 -1.147336,-0.542192 z M 23.409747,36.75325 c 0.981959,-3.570355 4.756318,-11.031164 6.826933,-13.49486 0.464503,-0.552684 0.586271,-0.604153 0.775553,-0.327811 0.553602,0.808232 1.251919,4.110924 1.621318,7.668016 l 0.126807,1.221076 -3.079512,1.795215 c -1.693731,0.987367 -3.814306,2.281815 -4.71239,2.876551 -0.898084,0.594738 -1.666906,1.081339 -1.708492,1.081339 -0.04159,0 0.02582,-0.368786 0.149783,-0.819526 z m 13.184647,-7.110817 c -0.04629,-0.145705 -0.341909,-1.543377 -0.656926,-3.10594 -0.315019,-1.562562 -0.864147,-3.967401 -1.220285,-5.344086 l -0.647526,-2.503063 1.150575,-1.176622 c 0.632814,-0.647143 2.643448,-2.301196 4.468071,-3.675673 3.438183,-2.589963 4.301443,-3.419286 4.80161,-4.6128516 0.714642,-1.7053778 -0.03157,-3.7643498 -1.67217,-4.613896 -0.898369,-0.4651992 -1.181912,-0.4997701 -3.278104,-0.3996832 -2.190425,0.1045861 -4.050576,0.523224 -8.85088,1.9919437 l -1.092702,0.334327 0.546351,-0.4412242 c 6.292255,-5.0815261 14.525967,-6.98424479 19.646997,-4.540202 2.438424,1.163752 4.050578,3.1575295 5.198539,6.4291158 l 0.605044,1.7243152 0.0047,7.3023343 0.0047,7.302333 -2.622483,0.481857 c -4.702423,0.864027 -9.663403,2.365904 -14.706862,4.452327 -1.118146,0.462565 -1.619586,0.580467 -1.67861,0.394688 z"
|
||||||
|
id="path6625" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
import definition from "../boards/(content)/(home)/_definition";
|
||||||
|
|
||||||
|
const { layout } = definition;
|
||||||
|
|
||||||
|
export default layout;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import definition from "../boards/(content)/(home)/_definition";
|
||||||
|
|
||||||
|
const { generateMetadataAsync: generateMetadata, page } = definition;
|
||||||
|
|
||||||
|
export default page;
|
||||||
|
|
||||||
|
export { generateMetadata };
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
|
||||||
import { AppShellMain } from "@mantine/core";
|
|
||||||
|
|
||||||
import { MainHeader } from "~/components/layout/header";
|
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
|
||||||
|
|
||||||
export default function MainLayout({ children }: PropsWithChildren) {
|
|
||||||
return (
|
|
||||||
<ClientShell hasNavigation={false}>
|
|
||||||
<MainHeader />
|
|
||||||
<AppShellMain>{children}</AppShellMain>
|
|
||||||
</ClientShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Stack, Title } from "@mantine/core";
|
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Title>Home</Title>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
import { useSuspenseDayJsLocalization } from "@homarr/translation/dayjs";
|
||||||
|
|
||||||
|
export const DayJsLoader = ({ children }: PropsWithChildren) => {
|
||||||
|
// Load the dayjs localization for the current locale with suspense
|
||||||
|
useSuspenseDayJsLocalization();
|
||||||
|
|
||||||
|
return children;
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"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";
|
||||||
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
|
import { colorSchemeCookieKey } from "@homarr/definitions";
|
||||||
|
|
||||||
|
export const CustomMantineProvider = ({
|
||||||
|
children,
|
||||||
|
defaultColorScheme,
|
||||||
|
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
|
||||||
|
const manager = useColorSchemeManager();
|
||||||
|
return (
|
||||||
|
<DirectionProvider>
|
||||||
|
<MantineProvider
|
||||||
|
defaultColorScheme={defaultColorScheme}
|
||||||
|
colorSchemeManager={manager}
|
||||||
|
theme={createTheme({
|
||||||
|
primaryColor: "red",
|
||||||
|
autoContrast: true,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MantineProvider>
|
||||||
|
</DirectionProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useColorSchemeManager(): MantineColorSchemeManager {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
|
||||||
|
setClientCookie(colorSchemeCookieKey, 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[colorSchemeCookieKey] 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,12 +0,0 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
|
||||||
|
|
||||||
import { defaultLocale } from "@homarr/translation";
|
|
||||||
import { I18nProviderClient } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
export const NextInternationalProvider = ({ children, locale }: PropsWithChildren<{ locale: string }>) => {
|
|
||||||
return (
|
|
||||||
<I18nProviderClient locale={locale} fallback={defaultLocale}>
|
|
||||||
{children}
|
|
||||||
</I18nProviderClient>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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,46 @@ 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, createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/client";
|
||||||
|
|
||||||
|
import { env } from "~/env.mjs";
|
||||||
|
|
||||||
|
const getWebSocketProtocol = () => {
|
||||||
|
// window is not defined on server side
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return "ws";
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
};
|
||||||
|
|
||||||
|
const constructWebsocketUrl = () => {
|
||||||
|
const fallback = `${getWebSocketProtocol()}://localhost:3001/websockets`;
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.NODE_ENV === "development") {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${getWebSocketProtocol()}://${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,28 +66,36 @@ 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: superjson,
|
transformer: {
|
||||||
url: `${getBaseUrl()}/api/trpc`,
|
serialize(object: unknown) {
|
||||||
headers() {
|
return object;
|
||||||
const headers = new Headers();
|
},
|
||||||
headers.set("x-trpc-source", "nextjs-react");
|
deserialize(data: unknown) {
|
||||||
return headers;
|
return data;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})(args)({ op, next });
|
url: getTrpcUrl(),
|
||||||
};
|
headers: createHeadersCallbackForSource("nextjs-react (form-data)"),
|
||||||
},
|
}),
|
||||||
|
false: unstable_httpBatchStreamLink({
|
||||||
|
transformer: superjson,
|
||||||
|
url: getTrpcUrl(),
|
||||||
|
headers: createHeadersCallbackForSource("nextjs-react (json)"),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -69,9 +109,3 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
</clientApi.Provider>
|
</clientApi.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBaseUrl() {
|
|
||||||
if (typeof window !== "undefined") return window.location.origin;
|
|
||||||
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
|
|
||||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
|
||||||
}
|
|
||||||
@@ -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,78 +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";
|
|
||||||
|
|
||||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
interface LoginFormProps {
|
||||||
|
providers: string[];
|
||||||
|
oidcClientName: string;
|
||||||
|
isOidcAutoLoginEnabled: boolean;
|
||||||
|
callbackUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const LoginForm = () => {
|
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(async (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
|
||||||
await revalidatePathActionAsync("/");
|
throw response.error;
|
||||||
router.push("/");
|
}
|
||||||
})
|
|
||||||
.catch((error: Error | string) => {
|
showSuccessNotification({
|
||||||
setIsLoading(false);
|
title: t("action.login.notification.success.title"),
|
||||||
setError(error.toString());
|
message: t("action.login.notification.success.message"),
|
||||||
showErrorNotification({
|
|
||||||
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>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import { Box, LoadingOverlay, Stack } from "@mantine/core";
|
import { Box, LoadingOverlay, Stack } 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 { DDGridStack } from "@homarr/gridstack";
|
|
||||||
|
|
||||||
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||||
@@ -41,47 +40,15 @@ export const useUpdateBoard = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const dd = new DDGridStack();
|
|
||||||
|
|
||||||
export const ClientBoard = () => {
|
export const ClientBoard = () => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const isReady = useIsBoardReady();
|
const isReady = useIsBoardReady();
|
||||||
const { updateBoard } = useUpdateBoard();
|
|
||||||
|
|
||||||
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);
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!boxRef.current) return;
|
|
||||||
dd.droppable(boxRef.current, {
|
|
||||||
accept: () => true,
|
|
||||||
})
|
|
||||||
.on(boxRef.current, "dropover", (event, el, helper) => {
|
|
||||||
console.log("dropover", event, el, helper);
|
|
||||||
})
|
|
||||||
.on(boxRef.current, "dropout", (event, el, helper) => {
|
|
||||||
console.log("dropout", event, el, helper);
|
|
||||||
})
|
|
||||||
.on(boxRef.current, "drop", (event, el, helper) => {
|
|
||||||
console.log("drop", event, el, helper);
|
|
||||||
const itemId = el.getAttribute("data-id");
|
|
||||||
if (!itemId) return;
|
|
||||||
|
|
||||||
// Same as removeItem
|
|
||||||
updateBoard((previous) => {
|
|
||||||
return {
|
|
||||||
...previous,
|
|
||||||
// Filter removed item out of items array
|
|
||||||
sections: previous.sections.map((section) => ({
|
|
||||||
...section,
|
|
||||||
items: section.items.filter((item) => item.id !== itemId),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
@@ -93,12 +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" }}>
|
||||||
<Box ref={boxRef} w="100%" h={80} bg="red"></Box>
|
{fullWidthSortedSections.map((section) =>
|
||||||
{sortedSections.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>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const BoardProvider = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
|
setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [data.sections.length, setReadySections]);
|
}, [data.sections.length, setReadySections]);
|
||||||
|
|
||||||
const markAsReady = useCallback((id: string) => {
|
const markAsReady = useCallback((id: string) => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,29 +1,34 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import type { MouseEvent } from "react";
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
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 { useModalAction } from "@homarr/modals";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useConfirmModal, 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 { env } from "~/env.mjs";
|
||||||
import { useEditMode, useRequiredBoard } from "./_context";
|
import { useEditMode, useRequiredBoard } from "./_context";
|
||||||
|
|
||||||
export const BoardContentHeaderActions = () => {
|
export const BoardContentHeaderActions = () => {
|
||||||
@@ -52,6 +57,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 +98,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,9 +141,72 @@ const EditModeMenu = () => {
|
|||||||
setEditMode(true);
|
setEditMode(true);
|
||||||
}, [board, isEditMode, saveBoard, setEditMode]);
|
}, [board, isEditMode, saveBoard, setEditMode]);
|
||||||
|
|
||||||
|
useHotkeys([["mod+e", toggle]]);
|
||||||
|
usePreventLeaveWithDirty(isEditMode);
|
||||||
|
|
||||||
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} />}
|
||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = (event: MouseEvent<HTMLElement>) => {
|
||||||
|
const target = (event.target as HTMLElement).closest("a");
|
||||||
|
|
||||||
|
if (!target) return;
|
||||||
|
if (!isDirty) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
openConfirmModal({
|
||||||
|
title: t("board.action.edit.confirmLeave.title"),
|
||||||
|
children: t("board.action.edit.confirmLeave.message"),
|
||||||
|
onConfirm() {
|
||||||
|
router.push(target.href);
|
||||||
|
},
|
||||||
|
confirmProps: {
|
||||||
|
children: t("common.action.discard"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePopState = (event: Event) => {
|
||||||
|
if (isDirty) {
|
||||||
|
window.history.pushState(null, document.title, window.location.href);
|
||||||
|
event.preventDefault();
|
||||||
|
} else {
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
if (!isDirty) return;
|
||||||
|
if (env.NODE_ENV === "development") return; // Allow to reload in development
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll("a").forEach((link) => {
|
||||||
|
link.addEventListener("click", handleClick as never);
|
||||||
|
});
|
||||||
|
window.addEventListener("popstate", handlePopState);
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.querySelectorAll("a").forEach((link) => {
|
||||||
|
link.removeEventListener("click", handleClick as never);
|
||||||
|
window.removeEventListener("popstate", handlePopState);
|
||||||
|
});
|
||||||
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isDirty]);
|
||||||
|
};
|
||||||
@@ -4,10 +4,17 @@ import type { PropsWithChildren } from "react";
|
|||||||
import type { MantineColorsTuple } from "@mantine/core";
|
import type { MantineColorsTuple } from "@mantine/core";
|
||||||
import { createTheme, darken, lighten, MantineProvider } from "@mantine/core";
|
import { createTheme, darken, lighten, MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
|
|
||||||
|
import { useColorSchemeManager } from "../../_client-providers/mantine";
|
||||||
import { useRequiredBoard } from "./_context";
|
import { useRequiredBoard } from "./_context";
|
||||||
|
|
||||||
export const BoardMantineProvider = ({ children }: PropsWithChildren) => {
|
export const BoardMantineProvider = ({
|
||||||
|
children,
|
||||||
|
defaultColorScheme,
|
||||||
|
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
const colorSchemeManager = useColorSchemeManager();
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
colors: {
|
colors: {
|
||||||
@@ -18,7 +25,11 @@ export const BoardMantineProvider = ({ children }: PropsWithChildren) => {
|
|||||||
autoContrast: true,
|
autoContrast: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return <MantineProvider theme={theme}>{children}</MantineProvider>;
|
return (
|
||||||
|
<MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
|
||||||
|
{children}
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateColors = (hex: string) => {
|
export const generateColors = (hex: string) => {
|
||||||
|
|||||||
@@ -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,137 +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}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
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,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,7 +12,7 @@ import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
|||||||
import { useRequiredBoard } from "../../(content)/_context";
|
import { useRequiredBoard } from "../../(content)/_context";
|
||||||
import classes from "./danger.module.css";
|
import classes from "./danger.module.css";
|
||||||
|
|
||||||
export const DangerZoneSettingsContent = () => {
|
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const t = useScopedI18n("board.setting");
|
const t = useScopedI18n("board.setting");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -90,14 +90,18 @@ export const DangerZoneSettingsContent = () => {
|
|||||||
buttonText={t("section.dangerZone.action.rename.button")}
|
buttonText={t("section.dangerZone.action.rename.button")}
|
||||||
onClick={onRenameClick}
|
onClick={onRenameClick}
|
||||||
/>
|
/>
|
||||||
<Divider />
|
{hideVisibility ? null : (
|
||||||
<DangerZoneRow
|
<>
|
||||||
label={t("section.dangerZone.action.visibility.label")}
|
<Divider />
|
||||||
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
<DangerZoneRow
|
||||||
buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
label={t("section.dangerZone.action.visibility.label")}
|
||||||
onClick={onVisibilityClick}
|
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
|
||||||
isPending={isChangeVisibilityPending}
|
buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
||||||
/>
|
onClick={onVisibilityClick}
|
||||||
|
isPending={isChangeVisibilityPending}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Divider />
|
<Divider />
|
||||||
<DangerZoneRow
|
<DangerZoneRow
|
||||||
label={t("section.dangerZone.action.delete.label")}
|
label={t("section.dangerZone.action.delete.label")}
|
||||||
|
|||||||
@@ -14,14 +14,16 @@ import { TRPCError } from "@trpc/server";
|
|||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { capitalize } from "@homarr/common";
|
import { capitalize } from "@homarr/common";
|
||||||
|
import { db } from "@homarr/db";
|
||||||
|
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
import type { TranslationObject } from "@homarr/translation";
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
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 +46,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: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,6 +65,7 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
|
|||||||
|
|
||||||
export default async function BoardSettingsPage({ params, searchParams }: Props) {
|
export default async function BoardSettingsPage({ params, searchParams }: Props) {
|
||||||
const { board, permissions } = await getBoardAndPermissionsAsync(params);
|
const { board, permissions } = await getBoardAndPermissionsAsync(params);
|
||||||
|
const boardSettings = await getServerSettingByKeyAsync(db, "board");
|
||||||
const { hasFullAccess } = await getBoardPermissionsAsync(board);
|
const { hasFullAccess } = await getBoardPermissionsAsync(board);
|
||||||
const t = await getScopedI18n("board.setting");
|
const t = await getScopedI18n("board.setting");
|
||||||
|
|
||||||
@@ -89,10 +92,10 @@ 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 hideVisibility={boardSettings.homeBoardId === board.id} />
|
||||||
</AccordionItemFor>
|
</AccordionItemFor>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ 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";
|
||||||
import { ClientShell } from "~/components/layout/shell";
|
import { ClientShell } from "~/components/layout/shell";
|
||||||
|
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||||
import type { Board } from "./_types";
|
import type { Board } from "./_types";
|
||||||
import { BoardProvider } from "./(content)/_context";
|
import { BoardProvider } from "./(content)/_context";
|
||||||
import type { Params } from "./(content)/_creator";
|
import type { Params } from "./(content)/_creator";
|
||||||
@@ -18,13 +18,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,
|
||||||
@@ -40,23 +38,22 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
const colorScheme = await getCurrentColorSchemeAsync();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlobalItemServerDataRunner board={initialBoard} shouldRun={isBoardContentPage}>
|
<BoardProvider initialBoard={initialBoard}>
|
||||||
<BoardProvider initialBoard={initialBoard}>
|
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||||
<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 }>;
|
||||||
@@ -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,19 +1,28 @@
|
|||||||
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 { notFound } from "next/navigation";
|
||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
|
||||||
|
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 { SpotlightProvider } from "@homarr/spotlight";
|
||||||
|
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||||
|
import { getI18nMessages } 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 { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||||
|
import { DayJsLoader } from "./_client-providers/dayjs-loader";
|
||||||
import { JotaiProvider } from "./_client-providers/jotai";
|
import { JotaiProvider } from "./_client-providers/jotai";
|
||||||
import { NextInternationalProvider } from "./_client-providers/next-international";
|
import { CustomMantineProvider } from "./_client-providers/mantine";
|
||||||
import { AuthProvider } from "./_client-providers/session";
|
import { AuthProvider } from "./_client-providers/session";
|
||||||
import { TRPCReactProvider } from "./_client-providers/trpc";
|
import { TRPCReactProvider } from "./_client-providers/trpc";
|
||||||
import { composeWrappers } from "./compose";
|
import { composeWrappers } from "./compose";
|
||||||
@@ -23,8 +32,8 @@ const fontSans = Inter({
|
|||||||
variable: "--font-sans",
|
variable: "--font-sans",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
metadataBase: new URL("http://localhost:3000"),
|
export const generateMetadata = async (): Promise<Metadata> => ({
|
||||||
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 +44,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: (await getCurrentColorSchemeAsync()) === "dark" ? "black-translucent" : "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: [
|
themeColor: [
|
||||||
@@ -49,35 +63,43 @@ 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";
|
if (!isLocaleSupported(props.params.locale)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
const colorScheme = await getCurrentColorSchemeAsync();
|
||||||
|
const direction = isLocaleRTL(props.params.locale) ? "rtl" : "ltr";
|
||||||
|
const i18nMessages = await getI18nMessages();
|
||||||
|
|
||||||
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) => <DayJsLoader {...innerProps} />,
|
||||||
(innerProps) => (
|
(innerProps) => <NextIntlClientProvider {...innerProps} messages={i18nMessages} />,
|
||||||
<MantineProvider
|
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
|
||||||
{...innerProps}
|
|
||||||
defaultColorScheme="dark"
|
|
||||||
theme={createTheme({
|
|
||||||
primaryColor: "red",
|
|
||||||
autoContrast: true,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
(innerProps) => <ModalProvider {...innerProps} />,
|
(innerProps) => <ModalProvider {...innerProps} />,
|
||||||
|
(innerProps) => <SpotlightProvider {...innerProps} />,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
||||||
|
<html
|
||||||
|
lang={props.params.locale}
|
||||||
|
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>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50.8%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
@@ -17,16 +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 { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
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 classes from "./about.module.css";
|
import classes from "./about.module.css";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
@@ -37,16 +37,26 @@ 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 />
|
<DynamicBreadcrumb />
|
||||||
@@ -70,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}`}
|
||||||
@@ -94,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}`}
|
||||||
@@ -164,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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
import { Container, Stack, Title } from "@mantine/core";
|
import { Container, Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
@@ -11,6 +13,11 @@ interface AppEditPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function AppEditPage({ params }: AppEditPageProps) {
|
export default async function AppEditPage({ params }: AppEditPageProps) {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user.permissions.includes("app-modify-all")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
const app = await api.app.byId({ id: params.id });
|
const app = await api.app.byId({ id: params.id });
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
|
|
||||||
|
|||||||
@@ -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,11 +1,19 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
import { Container, Stack, Title } from "@mantine/core";
|
import { Container, Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { AppNewForm } from "./_app-new-form";
|
import { AppNewForm } from "./_app-new-form";
|
||||||
|
|
||||||
export default async function AppNewPage() {
|
export default async function AppNewPage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user.permissions.includes("app-create")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
import { IconApps, IconPencil } from "@tabler/icons-react";
|
import { IconBox, 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 { parseAppHrefWithVariablesServer } from "@homarr/common/server";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { ManageContainer } from "~/components/manage/manage-container";
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
|
import { NoResults } from "~/components/no-results";
|
||||||
import { AppDeleteButton } from "./_app-delete-button";
|
import { AppDeleteButton } from "./_app-delete-button";
|
||||||
|
|
||||||
export default async function AppsPage() {
|
export default async function AppsPage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
const apps = await api.app.all();
|
const apps = await api.app.all();
|
||||||
const t = await getScopedI18n("app");
|
const t = await getScopedI18n("app");
|
||||||
|
|
||||||
@@ -21,9 +31,11 @@ export default async function AppsPage() {
|
|||||||
<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>
|
||||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
{session.user.permissions.includes("app-create") && (
|
||||||
{t("page.create.title")}
|
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||||
</MobileAffixButton>
|
{t("page.create.title")}
|
||||||
|
</MobileAffixButton>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
{apps.length === 0 && <AppNoResults />}
|
{apps.length === 0 && <AppNoResults />}
|
||||||
{apps.length > 0 && (
|
{apps.length > 0 && (
|
||||||
@@ -44,6 +56,7 @@ interface AppCardProps {
|
|||||||
|
|
||||||
const AppCard = async ({ app }: AppCardProps) => {
|
const AppCard = async ({ app }: AppCardProps) => {
|
||||||
const t = await getScopedI18n("app");
|
const t = await getScopedI18n("app");
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -69,24 +82,26 @@ const AppCard = async ({ app }: AppCardProps) => {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{app.href && (
|
{app.href && (
|
||||||
<Anchor href={app.href} lineClamp={1} 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>
|
||||||
</Group>
|
</Group>
|
||||||
<Group>
|
<Group>
|
||||||
<ActionIconGroup>
|
<ActionIconGroup>
|
||||||
<ActionIcon
|
{session?.user.permissions.includes("app-modify-all") && (
|
||||||
component={Link}
|
<ActionIcon
|
||||||
href={`/manage/apps/edit/${app.id}`}
|
component={Link}
|
||||||
variant="subtle"
|
href={`/manage/apps/edit/${app.id}`}
|
||||||
color="gray"
|
variant="subtle"
|
||||||
aria-label={t("page.edit.title")}
|
color="gray"
|
||||||
>
|
aria-label={t("page.edit.title")}
|
||||||
<IconPencil size={16} stroke={1.5} />
|
>
|
||||||
</ActionIcon>
|
<IconPencil size={16} stroke={1.5} />
|
||||||
<AppDeleteButton app={app} />
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
{session?.user.permissions.includes("app-full-all") && <AppDeleteButton app={app} />}
|
||||||
</ActionIconGroup>
|
</ActionIconGroup>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -96,16 +111,17 @@ const AppCard = async ({ app }: AppCardProps) => {
|
|||||||
|
|
||||||
const AppNoResults = async () => {
|
const AppNoResults = async () => {
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder bg="transparent">
|
<NoResults
|
||||||
<Stack align="center" gap="sm">
|
icon={IconBox}
|
||||||
<IconApps size="2rem" />
|
title={t("app.page.list.noResults.title")}
|
||||||
<Text fw={500} size="lg">
|
action={{
|
||||||
{t("app.page.list.noResults.title")}
|
label: t("app.page.list.noResults.action"),
|
||||||
</Text>
|
href: "/manage/apps/new",
|
||||||
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.description")}</Anchor>
|
hidden: !session?.user.permissions.includes("app-create"),
|
||||||
</Stack>
|
}}
|
||||||
</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 { IconCategoryPlus } from "@tabler/icons-react";
|
import { IconCategoryPlus, IconChevronDown, IconFileImport } 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";
|
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
|
||||||
|
|
||||||
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 (
|
||||||
<MobileAffixButton leftSection={<IconCategoryPlus size="1rem" />} onClick={onClick} loading={isPending}>
|
<>
|
||||||
{t("management.page.board.action.new.label")}
|
<Button.Group visibleFrom="md">{buttonGroupContent}</Button.Group>
|
||||||
</MobileAffixButton>
|
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
||||||
|
<Button.Group>{buttonGroupContent}</Button.Group>
|
||||||
|
</Affix>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -19,6 +19,7 @@ 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";
|
||||||
|
|
||||||
@@ -30,8 +31,9 @@ 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>
|
<ManageContainer>
|
||||||
@@ -39,7 +41,7 @@ export default async function ManageBoardsPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title mb="md">{t("title")}</Title>
|
<Title mb="md">{t("title")}</Title>
|
||||||
<CreateBoardButton boardNames={boards.map((board) => board.name)} />
|
{canCreateBoards && <CreateBoardButton />}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Grid mb={{ base: "xl", md: 0 }}>
|
<Grid mb={{ base: "xl", md: 0 }}>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { ActionIcon, Avatar, Button, Card, Collapse, Group, Kbd, Stack, Text } from "@mantine/core";
|
import { ActionIcon, Avatar, Button, Card, Collapse, Group, Kbd, Stack, Text } from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { IconEye, IconEyeOff } from "@tabler/icons-react";
|
import { IconEye, IconEyeOff } from "@tabler/icons-react";
|
||||||
@@ -23,7 +22,6 @@ interface SecretCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
||||||
const params = useParams<{ locale: string }>();
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { isPublic } = integrationSecretKindObject[secret.kind];
|
const { isPublic } = integrationSecretKindObject[secret.kind];
|
||||||
const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] = useDisclosure(false);
|
const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] = useDisclosure(false);
|
||||||
@@ -45,7 +43,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
|||||||
<Group>
|
<Group>
|
||||||
<Text c="gray.6" size="sm">
|
<Text c="gray.6" size="sm">
|
||||||
{t("integration.secrets.lastUpdated", {
|
{t("integration.secrets.lastUpdated", {
|
||||||
date: dayjs().locale(params.locale).to(dayjs(secret.updatedAt)),
|
date: dayjs().to(dayjs(secret.updatedAt)),
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
{isPublic ? (
|
{isPublic ? (
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||||
@@ -15,7 +16,6 @@ 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 "../../_components/secrets/integration-secret-card";
|
||||||
import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";
|
import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +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 { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { IntegrationAvatar } from "../../_integration-avatar";
|
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||||
|
import { IntegrationAccessSettings } from "../../_components/integration-access-settings";
|
||||||
import { EditIntegrationForm } from "./_integration-edit-form";
|
import { EditIntegrationForm } from "./_integration-edit-form";
|
||||||
|
|
||||||
interface EditIntegrationPageProps {
|
interface EditIntegrationPageProps {
|
||||||
@@ -13,8 +15,10 @@ 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 (
|
||||||
<>
|
<>
|
||||||
@@ -23,9 +27,14 @@ export default async function EditIntegrationPage({ params }: EditIntegrationPag
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group align="center">
|
<Group align="center">
|
||||||
<IntegrationAvatar kind={integration.kind} size="md" />
|
<IntegrationAvatar kind={integration.kind} size="md" />
|
||||||
<Title>{t("title", { name: getIntegrationName(integration.kind) })}</Title>
|
<Title>{editT("title", { name: getIntegrationName(integration.kind) })}</Title>
|
||||||
</Group>
|
</Group>
|
||||||
<EditIntegrationForm integration={integration} />
|
<EditIntegrationForm integration={integration} />
|
||||||
|
|
||||||
|
<Title order={2}>{t("permission.title")}</Title>
|
||||||
|
<Fieldset>
|
||||||
|
<IntegrationAccessSettings integration={integration} initialPermissions={integrationPermissions} />
|
||||||
|
</Fieldset>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ 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();
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
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 { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||||
@@ -17,7 +19,6 @@ import type { z } from "@homarr/validation";
|
|||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
|
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
|
||||||
import { revalidatePathActionAsync } from "../../../../revalidatePathAction";
|
|
||||||
|
|
||||||
interface NewIntegrationFormProps {
|
interface NewIntegrationFormProps {
|
||||||
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
|
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
|
||||||
@@ -31,7 +32,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
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: {
|
initialValues: {
|
||||||
name: searchParams.name ?? "",
|
name: searchParams.name ?? getIntegrationName(searchParams.kind),
|
||||||
url: searchParams.url ?? "",
|
url: searchParams.url ?? "",
|
||||||
secrets: secretKinds[0].map((kind) => ({
|
secrets: secretKinds[0].map((kind) => ({
|
||||||
kind,
|
kind,
|
||||||
@@ -80,7 +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>
|
||||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
<TextInput withAsterisk label={t("integration.field.name.label")} autoFocus {...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,6 +96,11 @@ 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>
|
||||||
|
|
||||||
@@ -120,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,14 +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 { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { IntegrationAvatar } from "../_integration-avatar";
|
|
||||||
import { NewIntegrationForm } from "./_integration-new-form";
|
import { NewIntegrationForm } from "./_integration-new-form";
|
||||||
|
|
||||||
interface NewIntegrationPageProps {
|
interface NewIntegrationPageProps {
|
||||||
@@ -18,13 +19,17 @@ interface NewIntegrationPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
|
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
const session = await auth();
|
||||||
const result = z.enum([integrationKinds[0]!, ...integrationKinds.slice(1)]).safeParse(searchParams.kind);
|
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;
|
||||||
|
|
||||||
@@ -35,7 +40,7 @@ export default async function IntegrationsNewPage({ searchParams }: NewIntegrati
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group align="center">
|
<Group align="center">
|
||||||
<IntegrationAvatar kind={currentKind} size="md" />
|
<IntegrationAvatar kind={currentKind} size="md" />
|
||||||
<Title>{t("title", { name: getIntegrationName(currentKind) })}</Title>
|
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
|
||||||
</Group>
|
</Group>
|
||||||
<NewIntegrationForm searchParams={searchParams} />
|
<NewIntegrationForm searchParams={searchParams} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
AccordionControl,
|
AccordionControl,
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
@@ -26,20 +27,21 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconChevronDown, IconChevronUp, IconPencil } from "@tabler/icons-react";
|
import { IconChevronDown, IconChevronUp, IconPencil, IconPlugX } 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 { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
|
import { NoResults } from "~/components/no-results";
|
||||||
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";
|
||||||
|
|
||||||
@@ -50,9 +52,17 @@ interface IntegrationsPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
|
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
const integrations = await api.integration.all();
|
const integrations = await api.integration.all();
|
||||||
const t = await getScopedI18n("integration");
|
const t = await getScopedI18n("integration");
|
||||||
|
|
||||||
|
const canCreateIntegrations = session.user.permissions.includes("integration-create");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageContainer>
|
<ManageContainer>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
@@ -60,23 +70,27 @@ export default async function IntegrationsPage({ searchParams }: IntegrationsPag
|
|||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Title>{t("page.list.title")}</Title>
|
<Title>{t("page.list.title")}</Title>
|
||||||
|
|
||||||
<Box>
|
{canCreateIntegrations && (
|
||||||
<IntegrationSelectMenu>
|
<>
|
||||||
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
<Box>
|
||||||
<MenuTarget>
|
<IntegrationSelectMenu>
|
||||||
<Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
||||||
</MenuTarget>
|
<MenuTarget>
|
||||||
</Affix>
|
<Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||||
</IntegrationSelectMenu>
|
</MenuTarget>
|
||||||
</Box>
|
</Affix>
|
||||||
|
</IntegrationSelectMenu>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box visibleFrom="md">
|
<Box visibleFrom="md">
|
||||||
<IntegrationSelectMenu>
|
<IntegrationSelectMenu>
|
||||||
<MenuTarget>
|
<MenuTarget>
|
||||||
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||||
</MenuTarget>
|
</MenuTarget>
|
||||||
</IntegrationSelectMenu>
|
</IntegrationSelectMenu>
|
||||||
</Box>
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||||
@@ -103,9 +117,11 @@ 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 <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const grouppedIntegrations = integrations.reduce(
|
const grouppedIntegrations = integrations.reduce(
|
||||||
@@ -152,18 +168,20 @@ 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={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
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>
|
||||||
@@ -178,18 +196,21 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
<Text>{integration.name}</Text>
|
<Text>{integration.name}</Text>
|
||||||
<ActionIconGroup>
|
{hasFullAccess ||
|
||||||
<ActionIcon
|
(integration.permissions.hasFullAccess && (
|
||||||
component={Link}
|
<ActionIconGroup>
|
||||||
href={`/manage/integrations/edit/${integration.id}`}
|
<ActionIcon
|
||||||
variant="subtle"
|
component={Link}
|
||||||
color="gray"
|
href={`/manage/integrations/edit/${integration.id}`}
|
||||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
variant="subtle"
|
||||||
>
|
color="gray"
|
||||||
<IconPencil size={16} stroke={1.5} />
|
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||||
</ActionIcon>
|
>
|
||||||
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
<IconPencil size={16} stroke={1.5} />
|
||||||
</ActionIconGroup>
|
</ActionIcon>
|
||||||
|
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
||||||
|
</ActionIconGroup>
|
||||||
|
))}
|
||||||
</Group>
|
</Group>
|
||||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||||
{integration.url}
|
{integration.url}
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import {
|
|||||||
IconLayoutDashboard,
|
IconLayoutDashboard,
|
||||||
IconLogs,
|
IconLogs,
|
||||||
IconMailForward,
|
IconMailForward,
|
||||||
|
IconPhoto,
|
||||||
IconPlug,
|
IconPlug,
|
||||||
IconQuestionMark,
|
IconQuestionMark,
|
||||||
IconReport,
|
IconReport,
|
||||||
|
IconSearch,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconTool,
|
IconTool,
|
||||||
IconUser,
|
IconUser,
|
||||||
@@ -22,6 +24,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";
|
||||||
@@ -31,6 +36,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"),
|
||||||
@@ -46,15 +52,30 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
icon: IconBox,
|
icon: IconBox,
|
||||||
href: "/manage/apps",
|
href: "/manage/apps",
|
||||||
label: t("items.apps"),
|
label: t("items.apps"),
|
||||||
|
hidden: !session,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: IconPlug,
|
icon: IconPlug,
|
||||||
href: "/manage/integrations",
|
href: "/manage/integrations",
|
||||||
label: t("items.integrations"),
|
label: t("items.integrations"),
|
||||||
|
hidden: !session,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconSearch,
|
||||||
|
href: "/manage/search-engines",
|
||||||
|
label: t("items.searchEngies"),
|
||||||
|
hidden: !session,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconPhoto,
|
||||||
|
href: "/manage/medias",
|
||||||
|
label: t("items.medias"),
|
||||||
|
hidden: !session,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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"),
|
||||||
@@ -65,6 +86,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"),
|
||||||
@@ -76,21 +98,32 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
{
|
{
|
||||||
label: t("items.tools.label"),
|
label: t("items.tools.label"),
|
||||||
icon: IconTool,
|
icon: IconTool,
|
||||||
|
// As permissions always include there children permissions, we can check other-view-logs as admin includes it
|
||||||
|
hidden: !session?.user.permissions.includes("other-view-logs"),
|
||||||
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",
|
||||||
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("items.tools.items.api"),
|
||||||
|
icon: IconPlug,
|
||||||
|
href: "/manage/tools/api",
|
||||||
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.logs"),
|
label: t("items.tools.items.logs"),
|
||||||
icon: IconLogs,
|
icon: IconLogs,
|
||||||
href: "/manage/tools/logs",
|
href: "/manage/tools/logs",
|
||||||
|
hidden: !session?.user.permissions.includes("other-view-logs"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("items.tools.items.tasks"),
|
label: t("items.tools.items.tasks"),
|
||||||
icon: IconReport,
|
icon: IconReport,
|
||||||
href: "/manage/tools/tasks",
|
href: "/manage/tools/tasks",
|
||||||
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -98,6 +131,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
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"),
|
||||||
@@ -106,7 +140,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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -116,7 +150,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,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ActionIcon, CopyButton, Tooltip } from "@mantine/core";
|
||||||
|
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
interface CopyMediaProps {
|
||||||
|
media: RouterOutputs["media"]["getPaginated"]["items"][number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CopyMedia = ({ media }: CopyMediaProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
const url =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? `${window.location.protocol}://${window.location.hostname}:${window.location.port}/api/user-medias/${media.id}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CopyButton value={url}>
|
||||||
|
{({ copy, copied }) => (
|
||||||
|
<Tooltip label={t("media.action.copy.label")} openDelay={500}>
|
||||||
|
<ActionIcon onClick={copy} color={copied ? "teal" : "gray"} variant="subtle">
|
||||||
|
{copied ? <IconCheck size={16} stroke={1.5} /> : <IconCopy size={16} stroke={1.5} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</CopyButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ActionIcon, Tooltip } 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 { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
interface DeleteMediaProps {
|
||||||
|
media: RouterOutputs["media"]["getPaginated"]["items"][number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeleteMedia = ({ media }: DeleteMediaProps) => {
|
||||||
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
const t = useI18n();
|
||||||
|
const { mutateAsync, isPending } = clientApi.media.deleteMedia.useMutation();
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
openConfirmModal({
|
||||||
|
title: t("media.action.delete.label"),
|
||||||
|
children: t.rich("media.action.delete.description", { bName: () => <b>{media.name}</b> }),
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
onConfirm: async () => {
|
||||||
|
await mutateAsync({ id: media.id });
|
||||||
|
await revalidatePathActionAsync("/manage/medias");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label={t("media.action.delete.label")} openDelay={500}>
|
||||||
|
<ActionIcon color="red" variant="subtle" onClick={onClick} loading={isPending}>
|
||||||
|
<IconTrash color="red" size={16} stroke={1.5} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { ChangeEvent } from "react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Switch } from "@mantine/core";
|
||||||
|
import type { SwitchProps } from "@mantine/core";
|
||||||
|
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
type ShowAllSwitchProps = Pick<SwitchProps, "defaultChecked">;
|
||||||
|
|
||||||
|
export const IncludeFromAllUsersSwitch = ({ defaultChecked }: ShowAllSwitchProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathName = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [checked, setChecked] = useState(defaultChecked);
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setChecked(event.target.checked);
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.set("includeFromAllUsers", event.target.checked.toString());
|
||||||
|
if (params.has("page")) params.set("page", "1"); // Reset page to 1
|
||||||
|
router.replace(`${pathName}?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={defaultChecked}
|
||||||
|
checked={checked}
|
||||||
|
label={t("management.page.media.includeFromAllUsers")}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, FileButton } from "@mantine/core";
|
||||||
|
import { IconUpload } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { supportedMediaUploadFormats } from "@homarr/validation";
|
||||||
|
|
||||||
|
export const UploadMedia = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
|
||||||
|
|
||||||
|
const handleFileUploadAsync = async (file: File | null) => {
|
||||||
|
if (!file) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
await mutateAsync(formData, {
|
||||||
|
onSuccess() {
|
||||||
|
showSuccessNotification({
|
||||||
|
message: t("media.action.upload.notification.success.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
message: t("media.action.upload.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async onSettled() {
|
||||||
|
await revalidatePathActionAsync("/manage/medias");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
|
||||||
|
{({ onClick }) => (
|
||||||
|
<Button onClick={onClick} loading={isPending} rightSection={<IconUpload size={16} stroke={1.5} />}>
|
||||||
|
{t("media.action.upload.label")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { humanFileSize } from "@homarr/common";
|
||||||
|
import { getI18n } from "@homarr/translation/server";
|
||||||
|
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
|
||||||
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
|
import { ManageContainer } from "~/components/manage/manage-container";
|
||||||
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
|
import { CopyMedia } from "./_actions/copy-media";
|
||||||
|
import { DeleteMedia } from "./_actions/delete-media";
|
||||||
|
import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
|
||||||
|
import { UploadMedia } from "./_actions/upload-media";
|
||||||
|
|
||||||
|
const searchParamsSchema = z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
includeFromAllUsers: z
|
||||||
|
.string()
|
||||||
|
.regex(/true|false/)
|
||||||
|
.catch("false")
|
||||||
|
.transform((value) => value === "true"),
|
||||||
|
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 MediaListPageProps {
|
||||||
|
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function GroupsListPage(props: MediaListPageProps) {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await getI18n();
|
||||||
|
const searchParams = searchParamsSchema.parse(props.searchParams);
|
||||||
|
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ManageContainer size="xl">
|
||||||
|
<DynamicBreadcrumb />
|
||||||
|
<Stack>
|
||||||
|
<Title>{t("media.plural")}</Title>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group>
|
||||||
|
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
|
||||||
|
{session.user.permissions.includes("media-view-all") && (
|
||||||
|
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{session.user.permissions.includes("media-upload") && <UploadMedia />}
|
||||||
|
</Group>
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<TableThead>
|
||||||
|
<TableTr>
|
||||||
|
<TableTh></TableTh>
|
||||||
|
<TableTh>{t("media.field.name")}</TableTh>
|
||||||
|
<TableTh>{t("media.field.size")}</TableTh>
|
||||||
|
<TableTh>{t("media.field.creator")}</TableTh>
|
||||||
|
<TableTh></TableTh>
|
||||||
|
</TableTr>
|
||||||
|
</TableThead>
|
||||||
|
<TableTbody>
|
||||||
|
{medias.map((media) => (
|
||||||
|
<Row key={media.id} media={media} />
|
||||||
|
))}
|
||||||
|
</TableTbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</ManageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowProps {
|
||||||
|
media: RouterOutputs["media"]["getPaginated"]["items"][number];
|
||||||
|
}
|
||||||
|
|
||||||
|
const Row = async ({ media }: RowProps) => {
|
||||||
|
const session = await auth();
|
||||||
|
const canDelete = media.creatorId === session?.user.id || session?.user.permissions.includes("media-full-all");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableTr>
|
||||||
|
<TableTd w={64}>
|
||||||
|
<Image
|
||||||
|
src={`/api/user-medias/${media.id}`}
|
||||||
|
alt={media.name}
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
style={{ objectFit: "contain" }}
|
||||||
|
/>
|
||||||
|
</TableTd>
|
||||||
|
<TableTd>{media.name}</TableTd>
|
||||||
|
<TableTd>{humanFileSize(media.size)}</TableTd>
|
||||||
|
<TableTd>
|
||||||
|
{media.creator ? (
|
||||||
|
<Group gap="sm">
|
||||||
|
<UserAvatar user={media.creator} size="sm" />
|
||||||
|
<Anchor component={Link} href={`/manage/users/${media.creator.id}/general`} size="sm">
|
||||||
|
{media.creator.name}
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</TableTd>
|
||||||
|
<TableTd w={64}>
|
||||||
|
<Group wrap="nowrap" gap="xs">
|
||||||
|
<CopyMedia media={media} />
|
||||||
|
{canDelete && <DeleteMedia media={media} />}
|
||||||
|
</Group>
|
||||||
|
</TableTd>
|
||||||
|
</TableTr>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,8 @@ 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 { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
@@ -14,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() {
|
||||||
@@ -26,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[] = [
|
||||||
@@ -33,37 +37,41 @@ 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"),
|
||||||
|
hidden: !session?.user,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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 (
|
||||||
@@ -72,24 +80,27 @@ export default async function ManagementPage() {
|
|||||||
<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" wrap="nowrap">
|
!link.hidden && (
|
||||||
<Group wrap="nowrap">
|
<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,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { SegmentedControlItem } from "@mantine/core";
|
||||||
|
import { Button, Fieldset, Grid, Group, SegmentedControl, Stack, Textarea, TextInput } from "@mantine/core";
|
||||||
|
import { WidgetIntegrationSelect } from "node_modules/@homarr/widgets/src/widget-integration-select";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { searchEngineTypes } from "@homarr/definitions";
|
||||||
|
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 [integrationData] = clientApi.integration.allThatSupportSearch.useSuspenseQuery();
|
||||||
|
|
||||||
|
const form = useZodForm(validation.searchEngine.manage, {
|
||||||
|
initialValues: initialValues ?? {
|
||||||
|
name: "",
|
||||||
|
short: "",
|
||||||
|
iconUrl: "",
|
||||||
|
urlTemplate: "",
|
||||||
|
description: "",
|
||||||
|
type: "generic",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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")} />
|
||||||
|
|
||||||
|
<Fieldset legend={t("search.engine.page.edit.configControl")}>
|
||||||
|
<SegmentedControl
|
||||||
|
data={searchEngineTypes.map(
|
||||||
|
(type) =>
|
||||||
|
({
|
||||||
|
label: t(`search.engine.page.edit.searchEngineType.${type}`),
|
||||||
|
value: type,
|
||||||
|
}) satisfies SegmentedControlItem,
|
||||||
|
)}
|
||||||
|
{...form.getInputProps("type")}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
{form.values.type === "generic" && (
|
||||||
|
<TextInput
|
||||||
|
{...form.getInputProps("urlTemplate")}
|
||||||
|
withAsterisk
|
||||||
|
label={t("search.engine.field.urlTemplate.label")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.values.type === "fromIntegration" && (
|
||||||
|
<WidgetIntegrationSelect
|
||||||
|
label="Integration"
|
||||||
|
data={integrationData}
|
||||||
|
canSelectMultiple={false}
|
||||||
|
onChange={(value) => form.setFieldValue("integrationId", value[0])}
|
||||||
|
value={form.values.integrationId !== undefined ? [form.values.integrationId] : []}
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
<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,35 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
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 session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user.permissions.includes("search-engine-modify-all")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
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,29 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
import { auth } from "@homarr/auth/next";
|
||||||
|
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 session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user.permissions.includes("search-engine-create")) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await getI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ManageContainer>
|
||||||
|
<DynamicBreadcrumb />
|
||||||
|
<Stack>
|
||||||
|
<Title>{t("search.engine.page.create.title")}</Title>
|
||||||
|
<SearchEngineNewForm />
|
||||||
|
</Stack>
|
||||||
|
</ManageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
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 { auth } from "@homarr/auth/next";
|
||||||
|
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 { NoResults } from "~/components/no-results";
|
||||||
|
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 session = await auth();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
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} />
|
||||||
|
{session.user.permissions.includes("search-engine-create") && (
|
||||||
|
<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");
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
{searchEngine.type === "generic" && searchEngine.urlTemplate !== null && (
|
||||||
|
<Anchor href={searchEngine.urlTemplate.replace("%s", "test")} lineClamp={1} size="sm">
|
||||||
|
{searchEngine.urlTemplate}
|
||||||
|
</Anchor>
|
||||||
|
)}
|
||||||
|
{searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null && (
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{t("page.list.interactive")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<ActionIconGroup>
|
||||||
|
{session?.user.permissions.includes("search-engine-modify-all") && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{session?.user.permissions.includes("search-engine-full-all") && (
|
||||||
|
<SearchEngineDeleteButton searchEngine={searchEngine} />
|
||||||
|
)}
|
||||||
|
</ActionIconGroup>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SearchEngineNoResults = async () => {
|
||||||
|
const t = await getI18n();
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NoResults
|
||||||
|
icon={IconSearch}
|
||||||
|
title={t("search.engine.page.list.noResults.title")}
|
||||||
|
action={{
|
||||||
|
label: t("search.engine.page.list.noResults.action"),
|
||||||
|
href: "/manage/search-engines/new",
|
||||||
|
hidden: !session?.user.permissions.includes("search-engine-create"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,45 +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" fz={{ base: "xs", md: "sm" }}>
|
|
||||||
{text}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</UnstyledButton>
|
|
||||||
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Group, Text } from "@mantine/core";
|
||||||
|
import { IconMoon, IconSun } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { ColorScheme } from "@homarr/definitions";
|
||||||
|
import { colorSchemes } from "@homarr/definitions";
|
||||||
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import { SelectWithCustomItems } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { CommonSettingsForm } from "./common-form";
|
||||||
|
|
||||||
|
export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["appearance"] }) => {
|
||||||
|
const tApperance = useScopedI18n("management.page.settings.section.appearance");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommonSettingsForm settingKey="appearance" defaultValues={defaultValues}>
|
||||||
|
{(form) => (
|
||||||
|
<>
|
||||||
|
<SelectWithCustomItems
|
||||||
|
label={tApperance("defaultColorScheme.label")}
|
||||||
|
data={colorSchemes.map((scheme) => ({
|
||||||
|
value: scheme,
|
||||||
|
label: tApperance(`defaultColorScheme.options.${scheme}`),
|
||||||
|
}))}
|
||||||
|
{...form.getInputProps("defaultColorScheme")}
|
||||||
|
SelectOption={ApperanceCustomOption}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommonSettingsForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const appearanceIcons = {
|
||||||
|
light: IconSun,
|
||||||
|
dark: IconMoon,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ApperanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
|
||||||
|
const Icon = appearanceIcons[value];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group>
|
||||||
|
<Icon size={16} stroke={1.5} />
|
||||||
|
<Text fz="sm" fw={500}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Group, Text } from "@mantine/core";
|
||||||
|
import { IconLayoutDashboard } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import { SelectWithCustomItems } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { CommonSettingsForm } from "./common-form";
|
||||||
|
|
||||||
|
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
|
||||||
|
const tBoard = useScopedI18n("management.page.settings.section.board");
|
||||||
|
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
|
||||||
|
{(form) => (
|
||||||
|
<>
|
||||||
|
<SelectWithCustomItems
|
||||||
|
label={tBoard("homeBoard.label")}
|
||||||
|
description={tBoard("homeBoard.description")}
|
||||||
|
data={selectableBoards.map((board) => ({
|
||||||
|
value: board.id,
|
||||||
|
label: board.name,
|
||||||
|
image: board.logoImageUrl,
|
||||||
|
}))}
|
||||||
|
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
|
||||||
|
<Group>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
|
||||||
|
<Text fz="sm" fw={500}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{...form.getInputProps("homeBoardId")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommonSettingsForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Group, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useForm } from "@homarr/form";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
export const CommonSettingsForm = <TKey extends keyof ServerSettings>({
|
||||||
|
settingKey,
|
||||||
|
defaultValues,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
settingKey: TKey;
|
||||||
|
defaultValues: ServerSettings[TKey];
|
||||||
|
children: (form: ReturnType<typeof useForm<ServerSettings[TKey]>>) => React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const tSettings = useScopedI18n("management.page.settings");
|
||||||
|
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
showSuccessNotification({
|
||||||
|
message: tSettings("notification.success.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
message: tSettings("notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmitAsync = async (values: ServerSettings[TKey]) => {
|
||||||
|
await mutateAsync({
|
||||||
|
settingsKey: settingKey,
|
||||||
|
value: values,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{children(form)}
|
||||||
|
<Group justify="end">
|
||||||
|
<Button type="submit" loading={isPending}>
|
||||||
|
{t("common.action.save")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ServerSettings } from "@homarr/server-settings";
|
||||||
|
import type { SupportedLanguage } from "@homarr/translation";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { LanguageCombobox } from "~/components/language/language-combobox";
|
||||||
|
import { CommonSettingsForm } from "./common-form";
|
||||||
|
|
||||||
|
export const CultureSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["culture"] }) => {
|
||||||
|
const tCulture = useScopedI18n("management.page.settings.section.culture");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommonSettingsForm settingKey="culture" defaultValues={defaultValues}>
|
||||||
|
{(form) => (
|
||||||
|
<>
|
||||||
|
<LanguageCombobox
|
||||||
|
label={tCulture("defaultLocale.label")}
|
||||||
|
value={form.getInputProps("defaultLocale").value as SupportedLanguage}
|
||||||
|
{...form.getInputProps("defaultLocale")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommonSettingsForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import React from "react";
|
||||||
|
import type { MantineSpacing } from "@mantine/core";
|
||||||
|
import { Group, Stack, Switch, Text, UnstyledButton } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { Modify } from "@homarr/common/types";
|
||||||
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
|
|
||||||
|
export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
||||||
|
form,
|
||||||
|
ms,
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
formKey,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
form: Modify<
|
||||||
|
UseFormReturnType<TFormValue, () => TFormValue>,
|
||||||
|
{
|
||||||
|
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
formKey: keyof TFormValue;
|
||||||
|
ms?: MantineSpacing;
|
||||||
|
title: string;
|
||||||
|
text: ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) => {
|
||||||
|
const handleClick = React.useCallback(() => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setFieldValue(formKey, (previous) => !previous);
|
||||||
|
}, [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" fz={{ base: "xs", md: "sm" }}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</UnstyledButton>
|
||||||
|
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,8 +3,12 @@ import { Stack, Title } from "@mantine/core";
|
|||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
|
||||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||||
import { AnalyticsSettings } from "./_components/analytics.settings";
|
import { AnalyticsSettings } from "./_components/analytics.settings";
|
||||||
|
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
|
||||||
|
import { BoardSettingsForm } from "./_components/board-settings-form";
|
||||||
|
import { CultureSettingsForm } from "./_components/culture-settings-form";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const t = await getScopedI18n("management");
|
const t = await getScopedI18n("management");
|
||||||
@@ -17,13 +21,26 @@ export async function generateMetadata() {
|
|||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const serverSettings = await api.serverSettings.getAll();
|
const serverSettings = await api.serverSettings.getAll();
|
||||||
const t = await getScopedI18n("management.page.settings");
|
const tSettings = await getScopedI18n("management.page.settings");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{t("title")}</Title>
|
<Title order={1}>{tSettings("title")}</Title>
|
||||||
<AnalyticsSettings initialData={serverSettings.analytics} />
|
<AnalyticsSettings initialData={serverSettings.analytics} />
|
||||||
|
<CrawlingAndIndexingSettings initialData={serverSettings.crawlingAndIndexing} />
|
||||||
|
<Stack>
|
||||||
|
<Title order={2}>{tSettings("section.board.title")}</Title>
|
||||||
|
<BoardSettingsForm defaultValues={serverSettings.board} />
|
||||||
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<Title order={2}>{tSettings("section.appearance.title")}</Title>
|
||||||
|
<AppearanceSettingsForm defaultValues={serverSettings.appearance} />
|
||||||
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<Title order={2}>{tSettings("section.culture.title")}</Title>
|
||||||
|
<CultureSettingsForm defaultValues={serverSettings.culture} />
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Button, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||||
|
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useModalAction } from "@homarr/modals";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
import { UserAvatar } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { CopyApiKeyModal } from "~/app/[locale]/manage/tools/api/components/copy-api-key-modal";
|
||||||
|
|
||||||
|
interface ApiKeysManagementProps {
|
||||||
|
apiKeys: RouterOutputs["apiKeys"]["getAll"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
||||||
|
const { openModal } = useModalAction(CopyApiKeyModal);
|
||||||
|
const { mutate, isPending } = clientApi.apiKeys.create.useMutation({
|
||||||
|
async onSuccess(data) {
|
||||||
|
openModal({
|
||||||
|
apiKey: data.apiKey,
|
||||||
|
});
|
||||||
|
await revalidatePathActionAsync("/manage/tools/api");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const t = useScopedI18n("management.page.tool.api.tab.apiKey");
|
||||||
|
|
||||||
|
const columns = useMemo<MRT_ColumnDef<RouterOutputs["apiKeys"]["getAll"][number]>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: "id",
|
||||||
|
header: t("table.header.id"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "user",
|
||||||
|
header: t("table.header.createdBy"),
|
||||||
|
Cell: ({ row }) => (
|
||||||
|
<Group gap={"xs"}>
|
||||||
|
<UserAvatar user={row.original.user} size={"sm"} />
|
||||||
|
<Text>{row.original.user.name}</Text>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useMantineReactTable({
|
||||||
|
columns,
|
||||||
|
data: apiKeys,
|
||||||
|
renderTopToolbarCustomActions: () => (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
mutate();
|
||||||
|
}}
|
||||||
|
loading={isPending}
|
||||||
|
>
|
||||||
|
{t("button.createApiToken")}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
enableDensityToggle: false,
|
||||||
|
state: {
|
||||||
|
density: "xs",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Title>{t("title")}</Title>
|
||||||
|
<MantineReactTable table={table} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
|
||||||
|
import { createModal } from "@homarr/modals";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
export const CopyApiKeyModal = createModal<{ apiKey: string }>(({ actions, innerProps }) => {
|
||||||
|
const t = useScopedI18n("management.page.tool.api.modal.createApiToken");
|
||||||
|
const [visible, { toggle }] = useDisclosure(false);
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Text>{t("description")}</Text>
|
||||||
|
<PasswordInput value={innerProps.apiKey} visible={visible} onVisibilityChange={toggle} readOnly />
|
||||||
|
<CopyButton value={innerProps.apiKey}>
|
||||||
|
{({ copy }) => (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
copy();
|
||||||
|
actions.closeModal();
|
||||||
|
}}
|
||||||
|
variant="default"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{t("button")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CopyButton>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}).withOptions({
|
||||||
|
defaultTitle(t) {
|
||||||
|
return t("management.page.tool.api.modal.createApiToken.title");
|
||||||
|
},
|
||||||
|
});
|
||||||