mirror of
https://github.com/Ravinou/borgwarehouse
synced 2026-03-14 22:35:46 +01:00
Compare commits
537 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a10003b43 | ||
|
|
3316be7b3c | ||
|
|
3772949fdb | ||
|
|
80b440e2b9 | ||
|
|
d7487fbc4a | ||
|
|
4b76223097 | ||
|
|
735a043858 | ||
|
|
0f2a4a716f | ||
|
|
29ca7dfeec | ||
|
|
1ddaa0f648 | ||
|
|
577fdbcd68 | ||
|
|
836450e8db | ||
|
|
efd7083463 | ||
|
|
187f9839f3 | ||
|
|
afa5c9bb1f | ||
|
|
a1bd3deb1d | ||
|
|
8f7be1d14e | ||
|
|
42cca2763f | ||
|
|
916b562f73 | ||
|
|
e34037ebce | ||
|
|
c819521b05 | ||
|
|
c5e99cabe5 | ||
|
|
079bf35d87 | ||
|
|
14dc526177 | ||
|
|
9d6211b0aa | ||
|
|
3fcd39cf40 |
||
|
|
85b9ccaf04 |
||
|
|
d944a70497 |
||
|
|
961d30354d |
||
|
|
ccfb21a790 |
||
|
|
3925c8ab8c |
||
|
|
1faa710105 |
||
|
|
2db5e65f9a |
||
|
|
5bb148459c |
||
|
|
9981b81cf1 |
||
|
|
a240f66e90 |
||
|
|
12be382b75 |
||
|
|
311fb04c34 |
||
|
|
abe1fd74e0 |
||
|
|
4d3336ff97 |
||
|
|
9e6df1e5b3 |
||
|
|
897152d7fc |
||
|
|
3ccf5abc3d |
||
|
|
02cd028c40 |
||
|
|
5636142104 |
||
|
|
7c1790eb8c |
||
|
|
904fa5928b |
||
|
|
446e75d7aa |
||
|
|
cf7b43625c |
||
|
|
d201b290e9 |
||
|
|
7b8c4d6017 |
||
|
|
13872ed29e |
||
|
|
90506c0827 |
||
|
|
9c267e75ec |
||
|
|
d9a8ecf70b |
||
|
|
2ce7232849 |
||
|
|
3ff2ace3bb |
||
|
|
d5e8064348 |
||
|
|
dbe7b4081e |
||
|
|
6a3625ef98 |
||
|
|
f1731d769d |
||
|
|
63216622a6 |
||
|
|
f5af821d47 |
||
|
|
340f186a37 |
||
|
|
53b29ea6f9 |
||
|
|
665974a15a |
||
|
|
ac3c4c7b1d |
||
|
|
2d094cfed9 |
||
|
|
aeb299ab3b |
||
|
|
53079bebce |
||
|
|
5a96fb88b6 |
||
|
|
b988b81089 |
||
|
|
b3d517d791 |
||
|
|
5ee55d18df |
||
|
|
eed58c129e |
||
|
|
d5a440448e |
||
|
|
b6cc2c351e |
||
|
|
b38ffd7be7 |
||
|
|
4009b0cf8b |
||
|
|
599fe35ddc |
||
|
|
97b31e6bae |
||
|
|
0c6e24f599 |
||
|
|
980399d238 |
||
|
|
4189cc34ee |
||
|
|
6565442042 |
||
|
|
4e7b880624 |
||
|
|
c2912253df |
||
|
|
4f011f6c48 |
||
|
|
8202bcd2ad |
||
|
|
1ea2174f9f |
||
|
|
e3a973c09c |
||
|
|
57e4550e42 |
||
|
|
ceeb7a3e6a |
||
|
|
3df990f217 |
||
|
|
ca9097a2aa |
||
|
|
aa8e493e37 |
||
|
|
b6652a80f5 |
||
|
|
88c8f920d9 |
||
|
|
3c1ff79add |
||
|
|
6acfdbbfa1 |
||
|
|
0263edd44f |
||
|
|
05ba852371 |
||
|
|
f15fbbcb6d |
||
|
|
3f47efc572 |
||
|
|
1feb666c4c |
||
|
|
9921d9d40d |
||
|
|
64fcafdfa0 |
||
|
|
5e420d04ca |
||
|
|
220f88bd6d |
||
|
|
cb64164b01 |
||
|
|
6680f48253 |
||
|
|
d808f464ad |
||
|
|
3337e9b97a |
||
|
|
7edfc75379 |
||
|
|
e2c74d067b |
||
|
|
250bf4ef0c |
||
|
|
26c784900a |
||
|
|
092a1d8d3d |
||
|
|
4514d6b7f2 |
||
|
|
754eef3b41 |
||
|
|
db4749479f |
||
|
|
515535a5b3 |
||
|
|
aa85cee260 |
||
|
|
62aedfa1e1 |
||
|
|
0f03b26a63 |
||
|
|
b4c62818e0 |
||
|
|
964f011a8a |
||
|
|
bcd7d25cd2 |
||
|
|
f792e65b88 |
||
|
|
a01980a323 |
||
|
|
4b1c8c5930 |
||
|
|
29e748e0d6 |
||
|
|
680e826a4f |
||
|
|
fd0ba8baaa |
||
|
|
c65b8a9e5e |
||
|
|
73ecb4bf72 |
||
|
|
720328db0c |
||
|
|
cb6e1413d7 |
||
|
|
10e13763fa |
||
|
|
49e1f8c2a2 |
||
|
|
d2acc5bba0 |
||
|
|
05f532c10d |
||
|
|
8be42035e7 |
||
|
|
33a0de52e3 |
||
|
|
18925266f8 |
||
|
|
933f5931e2 |
||
|
|
d50750033e |
||
|
|
f0783a3027 |
||
|
|
939f56137b |
||
|
|
05a76a5f2a |
||
|
|
e4f694d383 |
||
|
|
25022778b9 |
||
|
|
b1cee4486b |
||
|
|
e6310a5412 |
||
|
|
a0924a8ba9 |
||
|
|
daa199dfb0 |
||
|
|
799bf03cd5 |
||
|
|
d601c6dadf |
||
|
|
d6245d65c4 |
||
|
|
56ff17853a |
||
|
|
1e4a34edce |
||
|
|
afe828fc1a |
||
|
|
5567cddfdb |
||
|
|
766a63d524 |
||
|
|
a62e55b42a |
||
|
|
a3d156bdbf |
||
|
|
144bea3947 |
||
|
|
6f24a63077 |
||
|
|
58f55fa9fc |
||
|
|
fb61846bbb |
||
|
|
4de1884de8 |
||
|
|
c5e206a818 |
||
|
|
f7faada494 |
||
|
|
3d66ff18e6 |
||
|
|
03e4b175df |
||
|
|
0ee771f64a |
||
|
|
533bfce0d0 |
||
|
|
fec9ba21ad |
||
|
|
a9dadb9a53 |
||
|
|
ae27636dac |
||
|
|
86133a64b0 |
||
|
|
db36c806b6 |
||
|
|
ff25907bb3 |
||
|
|
e939b704ef |
||
|
|
90816bd705 |
||
|
|
c6911e77d2 |
||
|
|
26f8864ebf |
||
|
|
8237b428bc |
||
|
|
73842a8d62 |
||
|
|
7266ea464e |
||
|
|
837b5f01f9 |
||
|
|
8b16a713a5 |
||
|
|
3815109958 |
||
|
|
785413eec7 |
||
|
|
e1f234d54b |
||
|
|
52d8bca2ad |
||
|
|
9e2ae9f0fa |
||
|
|
201f5b41a1 |
||
|
|
2463a61943 |
||
|
|
ca8199ca33 |
||
|
|
7ec99a75c7 |
||
|
|
3105963b11 |
||
|
|
11aa62a548 |
||
|
|
e4dc585fe5 |
||
|
|
49cfbf44e0 |
||
|
|
ddbb629d75 |
||
|
|
fb68c4331b |
||
|
|
8b4ca5d7bc |
||
|
|
2316fb573e |
||
|
|
cb2032f309 |
||
|
|
1ae96c8f9a |
||
|
|
b7d3aec3b1 |
||
|
|
93000d4406 |
||
|
|
149fad13ec |
||
|
|
35ad73fd23 |
||
|
|
dac0c41df4 |
||
|
|
da60d50dcb |
||
|
|
d753df49a0 |
||
|
|
b40c7d7343 |
||
|
|
73c8350442 |
||
|
|
6a661a4f6a |
||
|
|
c6111329de |
||
|
|
8a0a69b7dc |
||
|
|
5ce6e2c19c |
||
|
|
e1cd8e1642 |
||
|
|
f9856e5689 |
||
|
|
70eaa38f1f |
||
|
|
acdaaffc16 |
||
|
|
b266787295 |
||
|
|
7625e5af02 |
||
|
|
73e35295dc |
||
|
|
313a2f30f9 |
||
|
|
21330fa672 |
||
|
|
7aa47195f1 |
||
|
|
086ae6dad3 |
||
|
|
67861260f8 |
||
|
|
d7bd79b5b4 |
||
|
|
0c4d5a898b |
||
|
|
46b923da77 |
||
|
|
80277dbe75 |
||
|
|
448781c3c3 |
||
|
|
c4f59c905b |
||
|
|
8a64fe16da |
||
|
|
940367e6b2 |
||
|
|
233b621bc7 |
||
|
|
0d3377baa6 |
||
|
|
d66e7a2263 |
||
|
|
b32318ccc7 |
||
|
|
349275b908 |
||
|
|
12de337017 |
||
|
|
d245e30af7 |
||
|
|
7b6d1a2785 |
||
|
|
f228117720 |
||
|
|
d9500df622 |
||
|
|
4f175114ff |
||
|
|
e323cdd9b8 |
||
|
|
5e66de2f64 |
||
|
|
e485d6f394 |
||
|
|
cd24d4479f |
||
|
|
d84215df4b |
||
|
|
a5f3530431 |
||
|
|
5da8e61b8f |
||
|
|
82aa9015c8 |
||
|
|
42a6f0f551 |
||
|
|
e984dcf17b |
||
|
|
4e78e65d2d |
||
|
|
e652a95a0b |
||
|
|
ff4a676f32 |
||
|
|
1ba028ad14 |
||
|
|
b501cbe93c |
||
|
|
85eb0891c6 |
||
|
|
b29c6ba7b3 |
||
|
|
3afeeb7134 |
||
|
|
70faeba69a |
||
|
|
2a862e23bd |
||
|
|
7477dcfdbd |
||
|
|
fa3e2067c9 |
||
|
|
2664f2b3d7 |
||
|
|
08bb4ebe70 |
||
|
|
edbaf23f3f |
||
|
|
53d749e529 |
||
|
|
2bab3f1180 |
||
|
|
d003d2173b |
||
|
|
4e0dc479bd |
||
|
|
e1263867f8 |
||
|
|
f7b7f62069 |
||
|
|
31a1ad9769 |
||
|
|
09f2b9b599 |
||
|
|
6eedd52a70 |
||
|
|
b22dcdaa4d |
||
|
|
1b6c0ae4be |
||
|
|
39a8335787 |
||
|
|
28ae1ff42c |
||
|
|
36bc28d85a |
||
|
|
ed247c592f |
||
|
|
5150257441 |
||
|
|
22779c590e |
||
|
|
c3d1469d5a |
||
|
|
8e80bfa49c |
||
|
|
5af9e0e2e3 |
||
|
|
2ac4fa3b2a |
||
|
|
a19bb130f0 |
||
|
|
307b2a5676 |
||
|
|
b8b20ebc7c |
||
|
|
1dca17b50f |
||
|
|
7415039a2b |
||
|
|
333d7fa119 |
||
|
|
7c3632a131 |
||
|
|
4d32f1e002 |
||
|
|
4f9234e325 |
||
|
|
9a33efc121 |
||
|
|
f5bbae9c0d |
||
|
|
2d2446ea51 |
||
|
|
97d909aaed |
||
|
|
0198051eb0 |
||
|
|
28c7e60481 |
||
|
|
cedcdb2418 |
||
|
|
55fd807e8e |
||
|
|
c546bc7bc2 |
||
|
|
c8dbaac92e |
||
|
|
0c407cd732 |
||
|
|
1e08e64ce6 |
||
|
|
8e9fa1eb56 |
||
|
|
66047df78a |
||
|
|
96ae9b8f65 |
||
|
|
88a80f42e8 |
||
|
|
0d645fc461 |
||
|
|
bdc0104c9a |
||
|
|
0935409d08 |
||
|
|
a70e39bd6d |
||
|
|
904eb1db60 |
||
|
|
94ce693c54 |
||
|
|
14ba99028e |
||
|
|
b2c9d6d9ae |
||
|
|
bfe7deb9c4 |
||
|
|
1ae44441be |
||
|
|
14883f24c7 |
||
|
|
c36f63e54a |
||
|
|
73ac5339f4 |
||
|
|
cef8263641 |
||
|
|
8620fb2e02 |
||
|
|
fc3f57e24c |
||
|
|
b0fae4f59d |
||
|
|
72d3fecb33 |
||
|
|
0c6220f533 |
||
|
|
bac544e552 |
||
|
|
28dbe55417 |
||
|
|
26d822f426 |
||
|
|
d627671483 |
||
|
|
fa2d0fa4ca |
||
|
|
6fa2b8d2e2 |
||
|
|
3a5a301cb9 |
||
|
|
a1c19396c2 |
||
|
|
43ec07bafb |
||
|
|
56502311db |
||
|
|
cd8f625173 |
||
|
|
3f65b27d1a |
||
|
|
5bee3d8b1e |
||
|
|
d5ddfa1809 |
||
|
|
ac16be3c8c |
||
|
|
7ab8c48326 |
||
|
|
a26f4a4a4c |
||
|
|
4308f37126 |
||
|
|
7f952e7211 |
||
|
|
3c5ff142a3 |
||
|
|
7e1d4cfe2c |
||
|
|
9ebd1608c8 |
||
|
|
545d26c066 |
||
|
|
972a83733e |
||
|
|
4f066af82e |
||
|
|
75aa177a11 |
||
|
|
2b1f405d96 |
||
|
|
5bc4f9aa52 |
||
|
|
52ae3bc9d8 |
||
|
|
05d5297898 |
||
|
|
0298e71892 |
||
|
|
7f61e464eb |
||
|
|
893c86dc75 |
||
|
|
027ccd7c28 |
||
|
|
a8161f2ea7 |
||
|
|
fab8f17c77 |
||
|
|
da4b5b2cec |
||
|
|
4d62b9988a |
||
|
|
f0206076ab |
||
|
|
fdc2cd186c |
||
|
|
e704e5283b |
||
|
|
6a6bf3fa93 |
||
|
|
fc00745140 |
||
|
|
e977638af2 |
||
|
|
bd836e0937 |
||
|
|
ef204563dc |
||
|
|
6ee8ab288a |
||
|
|
d47e54abee |
||
|
|
19d6b99807 |
||
|
|
12afb2ac55 |
||
|
|
02798de614 |
||
|
|
a8e7074064 |
||
|
|
cbb6d36fb3 |
||
|
|
18a79c04c0 |
||
|
|
52778b39a6 |
||
|
|
bff91d065b |
||
|
|
42bac2e038 |
||
|
|
2497e87313 |
||
|
|
93b6d27ddb |
||
|
|
dd563b3d6e |
||
|
|
551c591599 |
||
|
|
8b8a95ebba |
||
|
|
518939aa8c |
||
|
|
635ed848e1 |
||
|
|
9590cfb717 |
||
|
|
5739ae67c2 |
||
|
|
85d934c112 |
||
|
|
35ef56d15b |
||
|
|
3c6af174c8 |
||
|
|
e9c6014a56 |
||
|
|
a9da4b980a |
||
|
|
f0f7b880d4 |
||
|
|
82f947bf38 |
||
|
|
5fa0cf0735 |
||
|
|
24cd77dd00 |
||
|
|
53584899b3 |
||
|
|
faa5fdbe52 |
||
|
|
2eacabef56 |
||
|
|
be3cac0a63 |
||
|
|
b98951c83d |
||
|
|
38013ca100 |
||
|
|
02c8e7cb05 |
||
|
|
c0bb4b8928 |
||
|
|
2e75250203 |
||
|
|
728704b6c9 |
||
|
|
482fd448a0 |
||
|
|
52f6708a0f |
||
|
|
5df908b64e |
||
|
|
8e7b96ef44 |
||
|
|
0c511cb948 |
||
|
|
d03aa3a0a8 |
||
|
|
54ecf4cebf |
||
|
|
b814ee54ba |
||
|
|
1d5b2943c3 |
||
|
|
77edb8c941 |
||
|
|
e87e6d9760 |
||
|
|
f38822ff14 |
||
|
|
54d2b245a9 |
||
|
|
07d8c0bb3d |
||
|
|
026497303d |
||
|
|
e2cd515ee6 |
||
|
|
0c3f136cb7 |
||
|
|
5320b1c8db |
||
|
|
57124899a2 |
||
|
|
dd51c23aaf |
||
|
|
3587942d31 |
||
|
|
0c352dce5e |
||
|
|
9d03010278 |
||
|
|
c3b782f434 |
||
|
|
2f509f7903 |
||
|
|
a795831e3b |
||
|
|
00c98f842d |
||
|
|
f092d73679 |
||
|
|
ba11e0ab04 |
||
|
|
aa57a19ff1 |
||
|
|
83fe9a5355 |
||
|
|
e4d9484759 |
||
|
|
202e7dcef9 |
||
|
|
85951c4cd0 |
||
|
|
bfe1bd3433 |
||
|
|
3ea38e6648 |
||
|
|
76d20b699e |
||
|
|
9980dea4e2 |
||
|
|
9252df2e87 |
||
|
|
00b047aa70 |
||
|
|
9e74d09418 |
||
|
|
bba618aa69 |
||
|
|
2fd3329e7d |
||
|
|
0b564d10f8 |
||
|
|
b6104d2c3e |
||
|
|
3d0bc52fc3 |
||
|
|
c8f5ae92db |
||
|
|
e8038e7467 |
||
|
|
00e76b37a6 |
||
|
|
3a5f6fb6a2 |
||
|
|
fbfa81fd07 |
||
|
|
cbd6e92b3e |
||
|
|
49291f0fd4 |
||
|
|
727c56e1f0 |
||
|
|
91aae7fdb9 |
||
|
|
b8259dd4ae |
||
|
|
20048ba80c |
||
|
|
b781f47ae2 |
||
|
|
0fb445d998 |
||
|
|
9a9f7305ed |
||
|
|
37167a7d6e |
||
|
|
61042e6cdb |
||
|
|
d14262bce2 |
||
|
|
d413d8c732 |
||
|
|
aa3e4b90f4 |
||
|
|
d2fab3eeea |
||
|
|
5c74509d8b |
||
|
|
32483c23b6 |
||
|
|
5bea6c1aa6 |
||
|
|
3f57e310d2 |
||
|
|
ee9a4f00c9 |
||
|
|
2d452e101c |
||
|
|
a85c457603 |
||
|
|
56b50826d8 |
||
|
|
8140962395 |
||
|
|
125c0317cf |
||
|
|
9a493f6b18 |
||
|
|
370f207d9e |
||
|
|
65d0848376 |
||
|
|
51bca3098d |
||
|
|
43e3b708cd |
||
|
|
26530d6bdc |
||
|
|
13aa4055ff |
||
|
|
6763cac795 |
||
|
|
2085a7bb67 |
||
|
|
b7ec34588f |
||
|
|
f7de065ee2 |
||
|
|
b620cd27b8 |
||
|
|
d51103c4f5 |
||
|
|
b37bfa37df |
||
|
|
de3fa3f3af |
||
|
|
f8b6c46d80 |
||
|
|
8a7e30d008 |
||
|
|
7dbe181629 |
||
|
|
36753d0aa0 |
||
|
|
67d959f9fb |
||
|
|
81ed817045 |
||
|
|
02efa8ad94 |
||
|
|
3f8fbb77bb |
||
|
|
52ed551200 |
||
|
|
ea5d565567 |
||
|
|
fa218b0522 |
||
|
|
56098d3f8d |
||
|
|
08e6b0e6a6 |
||
|
|
a79a91ecb0 |
||
|
|
779cceacf2 |
||
|
|
94fd0c6188 |
250 changed files with 18667 additions and 13164 deletions
30
.commitlintrc.mjs
Normal file
30
.commitlintrc.mjs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
const config = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: {
|
||||
'type-enum': [
|
||||
2,
|
||||
'always',
|
||||
[
|
||||
'build',
|
||||
'chore',
|
||||
'config',
|
||||
'doc',
|
||||
'feat',
|
||||
'fix',
|
||||
'hotfix',
|
||||
'i18n',
|
||||
'refactor',
|
||||
'revert',
|
||||
'test',
|
||||
'ui',
|
||||
'wip',
|
||||
'publish',
|
||||
'docker',
|
||||
'WIP',
|
||||
],
|
||||
],
|
||||
},
|
||||
ignores: [(message) => message.includes('WIP'), (message) => message.includes('wip')],
|
||||
};
|
||||
|
||||
export default config;
|
||||
16
.env.sample
16
.env.sample
|
|
@ -23,8 +23,6 @@ CONFIG_PATH=./config
|
|||
SSH_PATH=./ssh
|
||||
SSH_HOST=./ssh_host
|
||||
BORG_REPOSITORY_PATH=./repos
|
||||
TMP_PATH=./tmp
|
||||
LOGS_PATH=./logs
|
||||
|
||||
## Optional variables section ##
|
||||
|
||||
|
|
@ -32,10 +30,22 @@ LOGS_PATH=./logs
|
|||
FQDN_LAN=
|
||||
SSH_SERVER_PORT_LAN=
|
||||
|
||||
# Disable the DELETE feature
|
||||
#DISABLE_DELETE_REPO=true
|
||||
|
||||
# Disable the integrations (API tokens to CRUD repositories)
|
||||
#DISABLE_INTEGRATIONS=true
|
||||
|
||||
# Hide the SSH port in the UI : quickcommands & wizard
|
||||
#HIDE_SSH_PORT=true
|
||||
|
||||
# SMTP server settings
|
||||
MAIL_SMTP_FROM=
|
||||
MAIL_SMTP_HOST=
|
||||
MAIL_SMTP_PORT=
|
||||
MAIL_SMTP_LOGIN=
|
||||
MAIL_SMTP_PWD=
|
||||
MAIL_REJECT_SELFSIGNED_TLS=
|
||||
MAIL_REJECT_SELFSIGNED_TLS=
|
||||
|
||||
# Force app to start on IPv6
|
||||
#HOSTNAME=::
|
||||
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report a bug
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**BorgWarehouse version :**
|
||||
**Installation type :**
|
||||
- [ ] Docker
|
||||
- [ ] Baremetal (Debian/Ubuntu)
|
||||
- [ ] Other environment :
|
||||
|
||||
-------
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
**Please, [BorgWarehouse's documentation](https://borgwarehouse.com/)
|
||||
is up to date and comprehensive, so take the time to look for answers. You can also look for answers in the project's historical [github issues](https://github.com/Ravinou/borgwarehouse/issues?q=is%3Aissue%20state%3Aclosed). I take time to answer each issue, but it's always less time for BorgWarehouse development. Thanks in advance.**
|
||||
21
.github/ISSUE_TEMPLATE/i-need-help.md
vendored
Normal file
21
.github/ISSUE_TEMPLATE/i-need-help.md
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
name: I need help
|
||||
about: You need help about installation, usage, or specific cases.
|
||||
title: ''
|
||||
labels: help wanted
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**BorgWarehouse version :**
|
||||
**Installation type :**
|
||||
- [ ] Docker
|
||||
- [ ] Baremetal (Debian/Ubuntu)
|
||||
- [ ] Other environment :
|
||||
|
||||
-------
|
||||
|
||||
Describe your problem here.
|
||||
|
||||
**Please, [BorgWarehouse's documentation](https://borgwarehouse.com/)
|
||||
is up to date and comprehensive, so take the time to look for answers. You can also look for answers in the project's historical [github issues](https://github.com/Ravinou/borgwarehouse/issues?q=is%3Aissue%20state%3Aclosed). I take time to answer each issue, but it's always less time for BorgWarehouse development. Thanks in advance.**
|
||||
20
.github/dependabot.yml
vendored
20
.github/dependabot.yml
vendored
|
|
@ -1,16 +1,18 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
- package-ecosystem: 'docker'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: "daily"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
interval: 'daily'
|
||||
# Note: Dependabot uses "npm" ecosystem but automatically detects pnpm-lock.yaml
|
||||
# Make sure package-lock.json is gitignored to prevent confusion
|
||||
- package-ecosystem: 'npm'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: 'daily'
|
||||
# Maintain dependencies for GitHub Actions
|
||||
# src: https://github.com/marketplace/actions/build-and-push-docker-images#keep-up-to-date-with-github-dependabot
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: 'daily'
|
||||
|
|
|
|||
29
.github/workflows/bats.yml
vendored
Normal file
29
.github/workflows/bats.yml
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
name: Bats
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
jobs:
|
||||
bats-test:
|
||||
name: Run bats tests against shells
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build container & run bats tests
|
||||
run: |
|
||||
docker compose -f tests/bats/docker-compose.yml up --abort-on-container-exit --build
|
||||
57
.github/workflows/docker-image-develop.yml
vendored
57
.github/workflows/docker-image-develop.yml
vendored
|
|
@ -1,29 +1,38 @@
|
|||
name: Build and Push Docker Image for Develop Branch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'develop'
|
||||
push:
|
||||
branches:
|
||||
- 'develop'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64 # linux/arm/v7 arm32 is not supported by node20 https://github.com/nodejs/docker-node/issues/1946
|
||||
tags: borgwarehouse/borgwarehouse:develop
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
echo "Current Commit: $COMMIT"
|
||||
jq '.version = "develop-'$COMMIT'"' package.json > package.tmp.json
|
||||
mv package.tmp.json package.json
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64 # linux/arm/v7 arm32 is not supported by node20 https://github.com/nodejs/docker-node/issues/1946
|
||||
tags: borgwarehouse/borgwarehouse:develop
|
||||
|
|
|
|||
6
.github/workflows/docker-image-latest.yml
vendored
6
.github/workflows/docker-image-latest.yml
vendored
|
|
@ -1,4 +1,6 @@
|
|||
name: Build and Push Docker Image
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -10,7 +12,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
|
|
@ -21,7 +23,7 @@ jobs:
|
|||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
|
|
|||
7
.github/workflows/docker-image-release.yml
vendored
7
.github/workflows/docker-image-release.yml
vendored
|
|
@ -5,12 +5,15 @@ on:
|
|||
types:
|
||||
- published
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
|
|
@ -24,7 +27,7 @@ jobs:
|
|||
id: get_release_tag
|
||||
run: echo "::set-output name=TAG::${{ github.event.release.tag_name }}"
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
|
|
|||
37
.github/workflows/docker-image-test.yml
vendored
37
.github/workflows/docker-image-test.yml
vendored
|
|
@ -1,21 +1,24 @@
|
|||
name: Test Docker Container Build on Pull Request
|
||||
name: Test to build docker container on Pull Request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
build-container:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build Docker Container
|
||||
run: |
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t borgwarehouse:pr-${{ github.event.pull_request.number }} .
|
||||
build-container:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build BorgWarehouse Container
|
||||
run: |
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t borgwarehouse:pr-${{ github.event.pull_request.number }} .
|
||||
|
|
|
|||
12
.github/workflows/shellcheck.yml
vendored
12
.github/workflows/shellcheck.yml
vendored
|
|
@ -4,19 +4,21 @@ on:
|
|||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
branches: main
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
name: "Shellcheck"
|
||||
name: 'Shellcheck'
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
shellcheck:
|
||||
name: Shellcheck
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Run ShellCheck
|
||||
uses: ludeeus/action-shellcheck@master
|
||||
env:
|
||||
|
|
|
|||
63
.github/workflows/vitest.yml
vendored
Normal file
63
.github/workflows/vitest.yml
vendored
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
name: Vitest & ESLint CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Vitest
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run Vitest
|
||||
run: pnpm run test
|
||||
|
||||
lint:
|
||||
name: Run ESLint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run ESLint
|
||||
run: pnpm exec eslint
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -50,6 +50,14 @@ typings/
|
|||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# pnpm
|
||||
.pnpm-store/
|
||||
pnpm-debug.log*
|
||||
|
||||
# Lock files (pnpm-lock.yaml is used)
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
|
|
@ -111,4 +119,7 @@ config/repo.json
|
|||
config/users.json
|
||||
|
||||
# docker files
|
||||
docker-compose.yml
|
||||
docker-compose.yml
|
||||
|
||||
# Commit tests docker-compose
|
||||
!tests/bats/docker-compose.yml
|
||||
92
.husky/append-icon.sh
Executable file
92
.husky/append-icon.sh
Executable file
|
|
@ -0,0 +1,92 @@
|
|||
#!/bin/bash
|
||||
|
||||
# define log prefix
|
||||
prefix="pre-commit:"
|
||||
|
||||
# store message file, first and only param of hook
|
||||
commitMessageFile="$1"
|
||||
|
||||
# breaking change icon !
|
||||
boomIcon=':boom:'
|
||||
|
||||
# check for breaking change in file content
|
||||
# find any line starting with 'BREAKING CHANGE'
|
||||
function checkBreakingChangeInBody() {
|
||||
breakingChange='BREAKING CHANGE'
|
||||
while read -r line; do
|
||||
if [[ "$line" == "$breakingChange"* ]]; then
|
||||
echo "$prefix found $breakingChange in message body"
|
||||
return 0
|
||||
fi
|
||||
done < "$1"
|
||||
return 1
|
||||
}
|
||||
|
||||
function findTypeIcon() {
|
||||
message="$1"
|
||||
|
||||
if [[ "$message" =~ ^.*!:\ .* ]]; then
|
||||
echo "$boomIcon"
|
||||
return 0
|
||||
fi
|
||||
|
||||
declare -A icons=(
|
||||
[build]='🤖'
|
||||
[chore]='🧹'
|
||||
["chore(deps)"]='🧹'
|
||||
[config]='🔧'
|
||||
[deploy]='🚀'
|
||||
[doc]='📚'
|
||||
[feat]='✨'
|
||||
[fix]='🐛'
|
||||
[hotfix]='🚑'
|
||||
[i18n]='💬'
|
||||
[publish]='📦'
|
||||
[refactor]='⚡'
|
||||
[revert]='⏪'
|
||||
[test]='✅'
|
||||
[ui]='🎨'
|
||||
[wip]='🚧'
|
||||
[WIP]='🚧'
|
||||
[docker]='🐳'
|
||||
)
|
||||
|
||||
commit_type="${message%%:*}"
|
||||
|
||||
icon="${icons[$commit_type]}"
|
||||
if [[ -n "$icon" ]]; then
|
||||
echo "$icon"
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# extract original message from the first line of file
|
||||
message=$(head -n 1 <"$commitMessageFile")
|
||||
echo "$prefix commit subject: '$message'"
|
||||
|
||||
if checkBreakingChangeInBody "$commitMessageFile"; then
|
||||
echo 'setting breaking change icon'
|
||||
icon=$boomIcon
|
||||
else
|
||||
icon=$(findTypeIcon "$message")
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "$prefix ❌ unable to find icon corresponding to commit type. Make sure your commit-lint config (.commitlintrc.js) and append-msg script (append-msg.sh) types match"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# check if icon has been appended before
|
||||
if [[ "$message" == *"$icon"* ]]; then
|
||||
echo "⏭️ skipping icon append as it's been added before"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# otherwise append icon
|
||||
updatedMessage="${message/:/: $icon}"
|
||||
|
||||
# replace first line of file with updated message
|
||||
sed -i "1s/.*/$updatedMessage/" "$commitMessageFile"
|
||||
|
||||
echo "$prefix ✅ appended icon $icon to commit message subject"
|
||||
5
.husky/commit-msg
Executable file
5
.husky/commit-msg
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
# run commit lint
|
||||
npx commitlint --edit "$1"
|
||||
|
||||
# run script to prepend message with icon
|
||||
./.husky/append-icon.sh "$1"
|
||||
5
.husky/prepare-commit-msg
Executable file
5
.husky/prepare-commit-msg
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
# Check if it's an amend commit
|
||||
if [ "$2" = "commit" ]; then
|
||||
echo "Amendment detected, appending icon..."
|
||||
./.husky/append-icon.sh "$1"
|
||||
fi
|
||||
7
.npmrc
Normal file
7
.npmrc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Configuration pnpm
|
||||
auto-install-peers=true
|
||||
strict-peer-dependencies=false
|
||||
shamefully-hoist=false
|
||||
|
||||
# Force pnpm usage (prevent npm/yarn)
|
||||
package-manager=pnpm
|
||||
|
|
@ -1,21 +1,20 @@
|
|||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 4,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"arrowParens": "always",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "lf",
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"insertPragma": false,
|
||||
"singleAttributePerLine": false,
|
||||
"bracketSameLine": false,
|
||||
"jsxBracketSameLine": false,
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 80,
|
||||
"proseWrap": "preserve",
|
||||
"quoteProps": "as-needed",
|
||||
"requirePragma": false,
|
||||
"useTabs": false,
|
||||
"embeddedLanguageFormatting": "auto"
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"arrowParens": "always",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "lf",
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"insertPragma": false,
|
||||
"singleAttributePerLine": false,
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 100,
|
||||
"proseWrap": "preserve",
|
||||
"quoteProps": "as-needed",
|
||||
"requirePragma": false,
|
||||
"useTabs": false,
|
||||
"embeddedLanguageFormatting": "auto"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
//Lib
|
||||
import React from 'react';
|
||||
import { useState } from 'react';
|
||||
import classes from './QuickCommands.module.css';
|
||||
import { IconSettingsAutomation, IconCopy } from '@tabler/icons-react';
|
||||
|
||||
export default function QuickCommands(props) {
|
||||
////Vars
|
||||
const wizardEnv = props.wizardEnv;
|
||||
//Needed to generate command for borg over LAN instead of WAN if env vars are set and option enabled.
|
||||
let FQDN;
|
||||
let SSH_SERVER_PORT;
|
||||
if (
|
||||
props.lanCommand &&
|
||||
wizardEnv.FQDN_LAN &&
|
||||
wizardEnv.SSH_SERVER_PORT_LAN
|
||||
) {
|
||||
FQDN = wizardEnv.FQDN_LAN;
|
||||
SSH_SERVER_PORT = wizardEnv.SSH_SERVER_PORT_LAN;
|
||||
} else {
|
||||
FQDN = wizardEnv.FQDN;
|
||||
SSH_SERVER_PORT = wizardEnv.SSH_SERVER_PORT;
|
||||
}
|
||||
|
||||
//State
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
//Functions
|
||||
const handleCopy = async () => {
|
||||
// Asynchronously call copy to clipboard
|
||||
navigator.clipboard
|
||||
.writeText(
|
||||
`ssh://${wizardEnv.UNIX_USER}@${FQDN}:${SSH_SERVER_PORT}/./${props.repositoryName}`
|
||||
)
|
||||
.then(() => {
|
||||
// If successful, update the isCopied state value
|
||||
setIsCopied(true);
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 1500);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
{isCopied ? (
|
||||
<div className={classes.copyValid}>Copied !</div>
|
||||
) : (
|
||||
<div className={classes.tooltip}>
|
||||
ssh://{wizardEnv.UNIX_USER}@{FQDN}:{SSH_SERVER_PORT}/./
|
||||
{props.repositoryName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.lanCommand && <div className={classes.lanBadge}>LAN</div>}
|
||||
|
||||
<div className={classes.icons}>
|
||||
<button onClick={handleCopy} className={classes.copyButton}>
|
||||
<IconCopy color='#65748b' stroke={1.25} />
|
||||
</button>
|
||||
<div className={classes.quickSetting}>
|
||||
<IconSettingsAutomation color='#65748b' stroke={1.25} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,116 +1,117 @@
|
|||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
margin: auto 47px auto auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
margin: auto 25px auto auto;
|
||||
}
|
||||
|
||||
.icons {
|
||||
position: relative;
|
||||
bottom: 13px;
|
||||
position: relative;
|
||||
bottom: 13px;
|
||||
}
|
||||
|
||||
.quickSetting {
|
||||
position: absolute;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
position: absolute;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lanBadge {
|
||||
border-radius: 5px;
|
||||
border: 1px solid #6d4aff;
|
||||
color: #6d4aff;
|
||||
font-size: 0.9em;
|
||||
padding: 2px 5px;
|
||||
margin-right: 8px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #6d4aff;
|
||||
color: #6d4aff;
|
||||
font-size: 0.9em;
|
||||
padding: 2px 5px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid #6d4aff21;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0px 1px rgba(0, 0, 0, 0.1) inset;
|
||||
color: #65748b;
|
||||
font-size: 0.95rem;
|
||||
padding: 5px 5px;
|
||||
transition: 0.5s opacity;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid #6d4aff21;
|
||||
background-color: #fafafa;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0px 1px rgba(0, 0, 0, 0.1) inset;
|
||||
color: #65748b;
|
||||
font-size: 0.95rem;
|
||||
padding: 5px 5px;
|
||||
transition: 0.5s opacity;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
border: none;
|
||||
background-color: none;
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
border: none;
|
||||
background-color: none;
|
||||
}
|
||||
|
||||
.copyValid {
|
||||
margin: auto 8px auto auto;
|
||||
font-size: 0.95rem;
|
||||
color: #6d4aff;
|
||||
animation: scale-in-center 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
|
||||
margin: auto 8px auto auto;
|
||||
padding: 6px 6px;
|
||||
font-size: 0.95rem;
|
||||
color: #6d4aff;
|
||||
animation: scale-in-center 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
|
||||
}
|
||||
|
||||
@keyframes scale-in-center {
|
||||
0% {
|
||||
-webkit-transform: scale(0);
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
0% {
|
||||
-webkit-transform: scale(0);
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* On Hover */
|
||||
|
||||
.container:hover .tooltip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid #6d4aff21;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0px 1px rgba(0, 0, 0, 0.1) inset;
|
||||
color: #65748b;
|
||||
font-size: 0.95rem;
|
||||
padding: 5px 5px;
|
||||
transition: 0.5s opacity;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid #6d4aff21;
|
||||
background-color: #fafafa;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0px 1px rgba(0, 0, 0, 0.1) inset;
|
||||
color: #65748b;
|
||||
font-size: 0.95rem;
|
||||
padding: 5px 5px;
|
||||
transition: 0.5s opacity;
|
||||
}
|
||||
|
||||
.container:hover .copyButton {
|
||||
position: absolute;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.container:hover .quickSetting {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.container:hover .lanBadge {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media all and (max-width: 1000px) {
|
||||
.container {
|
||||
display: none;
|
||||
}
|
||||
.container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
Components/Repo/QuickCommands/QuickCommands.tsx
Normal file
62
Components/Repo/QuickCommands/QuickCommands.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
import { useState } from 'react';
|
||||
import classes from './QuickCommands.module.css';
|
||||
import { IconSettingsAutomation, IconCopy } from '@tabler/icons-react';
|
||||
import { lanCommandOption } from '~/helpers/functions';
|
||||
import { WizardEnvType } from '~/types/domain/config.types';
|
||||
|
||||
type QuickCommandsProps = {
|
||||
repositoryName: string;
|
||||
wizardEnv?: WizardEnvType;
|
||||
lanCommand?: boolean;
|
||||
};
|
||||
|
||||
export default function QuickCommands(props: QuickCommandsProps) {
|
||||
const wizardEnv = props.wizardEnv;
|
||||
//Needed to generate command for borg over LAN instead of WAN if env vars are set and option enabled.
|
||||
const { FQDN, SSH_SERVER_PORT } = lanCommandOption(wizardEnv, props.lanCommand);
|
||||
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
// Asynchronously call copy to clipboard
|
||||
navigator.clipboard
|
||||
.writeText(
|
||||
`ssh://${wizardEnv?.UNIX_USER}@${FQDN}${SSH_SERVER_PORT ? SSH_SERVER_PORT : ''}/./${props.repositoryName}`
|
||||
)
|
||||
.then(() => {
|
||||
setIsCopied(true);
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 1500);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
{isCopied ? (
|
||||
<div className={classes.copyValid}>Copied !</div>
|
||||
) : (
|
||||
<div className={classes.tooltip}>
|
||||
ssh://{wizardEnv?.UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT ? SSH_SERVER_PORT : ''}/./
|
||||
{props.repositoryName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.lanCommand && <div className={classes.lanBadge}>LAN</div>}
|
||||
|
||||
<div className={classes.icons}>
|
||||
<button onClick={handleCopy} className={classes.copyButton}>
|
||||
<IconCopy color='#65748b' stroke={1.25} />
|
||||
</button>
|
||||
<div className={classes.quickSetting}>
|
||||
<IconSettingsAutomation color='#65748b' stroke={1.25} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
//Lib
|
||||
import { useState } from 'react';
|
||||
import classes from './Repo.module.css';
|
||||
import {
|
||||
IconSettings,
|
||||
IconInfoCircle,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconBellOff,
|
||||
IconLockPlus,
|
||||
} from '@tabler/icons-react';
|
||||
import timestampConverter from '../../helpers/functions/timestampConverter';
|
||||
import StorageBar from '../UI/StorageBar/StorageBar';
|
||||
import QuickCommands from './QuickCommands/QuickCommands';
|
||||
|
||||
export default function Repo(props) {
|
||||
//Load displayDetails from LocalStorage
|
||||
const displayDetailsFromLS = () => {
|
||||
try {
|
||||
if (
|
||||
localStorage.getItem('displayDetailsRepo' + props.id) === null
|
||||
) {
|
||||
localStorage.setItem(
|
||||
'displayDetailsRepo' + props.id,
|
||||
JSON.stringify(true)
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
return JSON.parse(
|
||||
localStorage.getItem('displayDetailsRepo' + props.id)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'LocalStorage error, key',
|
||||
'displayDetailsRepo' + props.id,
|
||||
'will be removed. Try again.',
|
||||
'Error message on this key : ',
|
||||
error
|
||||
);
|
||||
localStorage.removeItem('displayDetailsRepo' + props.id);
|
||||
}
|
||||
};
|
||||
|
||||
//States
|
||||
const [displayDetails, setDisplayDetails] = useState(displayDetailsFromLS);
|
||||
|
||||
//BUTTON : Display or not repo details for ONE repo
|
||||
const displayDetailsForOneHandler = (boolean) => {
|
||||
//Update localStorage
|
||||
localStorage.setItem(
|
||||
'displayDetailsRepo' + props.id,
|
||||
JSON.stringify(boolean)
|
||||
);
|
||||
setDisplayDetails(boolean);
|
||||
};
|
||||
|
||||
//Status indicator
|
||||
const statusIndicator = () => {
|
||||
return props.status
|
||||
? classes.statusIndicatorGreen
|
||||
: classes.statusIndicatorRed;
|
||||
};
|
||||
|
||||
//Alert indicator
|
||||
const alertIndicator = () => {
|
||||
if (props.alert === 0) {
|
||||
return (
|
||||
<div className={classes.alertIcon}>
|
||||
<IconBellOff size={16} color='grey' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const appendOnlyModeIndicator = () => {
|
||||
if (props.appendOnlyMode) {
|
||||
return (
|
||||
<div className={classes.appendOnlyModeIcon}>
|
||||
<IconLockPlus size={16} color='grey' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{displayDetails ? (
|
||||
<>
|
||||
<div className={classes.RepoOpen}>
|
||||
<div className={classes.openFlex}>
|
||||
<div className={statusIndicator()} />
|
||||
<div className={classes.alias}>{props.alias}</div>
|
||||
{appendOnlyModeIndicator()}
|
||||
{alertIndicator()}
|
||||
{props.comment && (
|
||||
<div className={classes.comment}>
|
||||
<IconInfoCircle size={16} color='grey' />
|
||||
<div className={classes.toolTip}>
|
||||
{props.comment}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<QuickCommands
|
||||
repositoryName={props.repositoryName}
|
||||
lanCommand={props.lanCommand}
|
||||
wizardEnv={props.wizardEnv}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<table className={classes.tabInfo}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '15%' }}>Repository</th>
|
||||
<th style={{ width: '10%' }}>
|
||||
Storage Size
|
||||
</th>
|
||||
<th style={{ width: '30%' }}>
|
||||
Storage Used
|
||||
</th>
|
||||
<th style={{ width: '15%' }}>
|
||||
Last change
|
||||
</th>
|
||||
<th style={{ width: '5%' }}>ID</th>
|
||||
<th style={{ width: '5%' }}>Edit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{props.repositoryName}</th>
|
||||
<th>{props.storageSize} GB</th>
|
||||
<th style={{ padding: '0 4% 0 4%' }}>
|
||||
<StorageBar
|
||||
storageUsed={props.storageUsed}
|
||||
storageSize={props.storageSize}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
<div className={classes.lastSave}>
|
||||
{props.lastSave === 0
|
||||
? '-'
|
||||
: timestampConverter(
|
||||
props.lastSave
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th>#{props.id}</th>
|
||||
<th>
|
||||
<div className={classes.editButton}>
|
||||
<IconSettings
|
||||
width={24}
|
||||
color='#6d4aff'
|
||||
onClick={() =>
|
||||
props.repoManageEditHandler()
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={classes.RepoClose}>
|
||||
<div className={classes.closeFlex}>
|
||||
<div className={statusIndicator()} />
|
||||
<div className={classes.alias}>{props.alias}</div>
|
||||
{appendOnlyModeIndicator()}
|
||||
{alertIndicator()}
|
||||
{props.comment && (
|
||||
<div className={classes.comment}>
|
||||
<IconInfoCircle size={16} color='#637381' />
|
||||
<div className={classes.toolTip}>
|
||||
{props.comment}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={classes.lastSave}>
|
||||
{props.lastSave === 0
|
||||
? null
|
||||
: timestampConverter(props.lastSave)}
|
||||
<span
|
||||
style={{ marginLeft: '20px', color: '#637381' }}
|
||||
>
|
||||
#{props.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{displayDetails ? (
|
||||
<div className={classes.chevron}>
|
||||
<IconChevronUp
|
||||
color='#494b7a'
|
||||
size={28}
|
||||
onClick={() => {
|
||||
displayDetailsForOneHandler(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classes.chevron}>
|
||||
<IconChevronDown
|
||||
color='#494b7a'
|
||||
size={28}
|
||||
onClick={() => {
|
||||
displayDetailsForOneHandler(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,250 +1,309 @@
|
|||
/*Repo CLOSE*/
|
||||
.RepoClose {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||
0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
width: auto;
|
||||
max-height: 65px;
|
||||
margin: 20px 0px 0px 0px;
|
||||
border-radius: 5px;
|
||||
overflow: visible;
|
||||
/* Need to display comment on hover (which is position : absolute) */
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||
0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
width: auto;
|
||||
max-height: 65px;
|
||||
margin: 20px 0px 0px 0px;
|
||||
border-radius: 5px;
|
||||
overflow: visible;
|
||||
/* Need to display comment on hover (which is position : absolute) */
|
||||
position: relative;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.closeFlex {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.RepoClose .lastSave {
|
||||
padding: 15px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.RepoClose .leftGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.RepoClose .alias {
|
||||
font-weight: bold;
|
||||
color: #111827;
|
||||
font-size: 1.05em;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* REPO OPEN */
|
||||
.RepoOpen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||
0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
width: auto;
|
||||
max-height: 200px;
|
||||
margin: 20px 0px 0px 0px;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
transition: max-height 0.1s linear;
|
||||
overflow: visible;
|
||||
/* Need to display comment on hover (which is position : absolute) */
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||
0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
width: auto;
|
||||
margin: 20px 0px 0px 0px;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
transition: max-height 0.1s linear;
|
||||
overflow: visible;
|
||||
/* Need to display comment on hover (which is position : absolute) */
|
||||
position: relative;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.openFlex {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
width: 100%;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aliasFlex {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
.indicatorsFlex {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.tabInfo {
|
||||
width: 100%;
|
||||
overflow-wrap: break-word;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 25px auto;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
overflow-wrap: break-word;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 15px auto;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.tabInfo thead tr {
|
||||
height: 50px;
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
height: 50px;
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tabInfo thead th {
|
||||
font-size: 1em;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
font-weight: normal;
|
||||
font-size: 1em;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tabInfo tbody tr {
|
||||
background-color: #f3f4f6;
|
||||
height: 50px;
|
||||
background-color: #f3f4f6;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.tabInfo tbody tr th {
|
||||
color: #65748b;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 400;
|
||||
color: #65748b;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/*STATUS*/
|
||||
|
||||
.statusIndicatorGreen {
|
||||
background: rgb(9, 255, 0);
|
||||
border-radius: 50%;
|
||||
margin: 10px;
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
box-shadow: 0 0 0 0 rgb(9, 255, 0);
|
||||
transform: scale(1);
|
||||
animation: pulseGreen 5s infinite;
|
||||
animation-delay: 1s;
|
||||
.statusIndicatorGreen,
|
||||
.statusIndicatorRed {
|
||||
border-radius: 50%;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
animation: pulse 5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulseGreen {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(17, 255, 0, 0.7);
|
||||
}
|
||||
|
||||
10% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 10px rgba(17, 255, 0, 0);
|
||||
}
|
||||
|
||||
90% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(17, 255, 0, 0);
|
||||
}
|
||||
.statusIndicatorGreen {
|
||||
background: #00d26a;
|
||||
box-shadow: 0 0 0 0 rgba(0, 210, 106, 0.7);
|
||||
}
|
||||
|
||||
.statusIndicatorRed {
|
||||
background: rgb(255, 0, 0);
|
||||
border-radius: 50%;
|
||||
margin: 10px;
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
|
||||
box-shadow: 0 0 0 0 rgb(255, 0, 0);
|
||||
transform: scale(1);
|
||||
animation: pulseRed 5s infinite;
|
||||
animation-delay: 0.5s;
|
||||
background: #ff3d3d;
|
||||
box-shadow: 0 0 0 0 rgba(255, 61, 61, 0.7);
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
@keyframes pulseRed {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(255, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
10% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 10px rgba(255, 0, 0, 0);
|
||||
}
|
||||
|
||||
90% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(255, 0, 0, 0);
|
||||
}
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
10% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
|
||||
}
|
||||
90% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Alert icon */
|
||||
|
||||
.alertIcon {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.appendOnlyModeIcon {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* GENERAL */
|
||||
.alias {
|
||||
font-weight: bold;
|
||||
color: #111827;
|
||||
font-size: 1.05em;
|
||||
font-weight: bold;
|
||||
color: #111827;
|
||||
font-size: 1.05em;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.RepoOpen .alias {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.lastSave {
|
||||
color: #65748b;
|
||||
color: #65748b;
|
||||
}
|
||||
|
||||
.editButton {
|
||||
cursor: pointer;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Comment */
|
||||
.comment {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolTip {
|
||||
visibility: hidden;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 400px;
|
||||
max-height: 250px;
|
||||
background-color: #fff;
|
||||
color: #637381;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 5px 5px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
margin: 0px 0 0 20px;
|
||||
opacity: 1;
|
||||
transition: 0.5s opacity;
|
||||
box-shadow:
|
||||
0 3px 6px rgba(0, 0, 0, 0.16),
|
||||
0 3px 6px rgba(0, 0, 0, 0.23);
|
||||
overflow: auto;
|
||||
visibility: hidden;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 400px;
|
||||
max-height: 250px;
|
||||
background-color: #fff;
|
||||
color: #637381;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 5px 5px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
margin: 0px 0 0 20px;
|
||||
opacity: 1;
|
||||
transition: 0.5s opacity;
|
||||
box-shadow:
|
||||
0 3px 6px rgba(0, 0, 0, 0.16),
|
||||
0 3px 6px rgba(0, 0, 0, 0.23);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.comment:hover .toolTip,
|
||||
.comment:active .toolTip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
margin: auto;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.chevron :focus,
|
||||
.chevron :hover {
|
||||
cursor: pointer;
|
||||
filter: invert(27%) sepia(82%) saturate(2209%) hue-rotate(240deg)
|
||||
brightness(99%) contrast(105%);
|
||||
cursor: pointer;
|
||||
filter: invert(27%) sepia(82%) saturate(2209%) hue-rotate(240deg) brightness(99%) contrast(105%);
|
||||
}
|
||||
|
||||
/* MOBILE */
|
||||
@media all and (max-width: 1000px) {
|
||||
.tabInfo {
|
||||
display: none;
|
||||
}
|
||||
.toolTip {
|
||||
display: none;
|
||||
}
|
||||
.comment {
|
||||
display: none;
|
||||
}
|
||||
.lastSave {
|
||||
display: none;
|
||||
}
|
||||
.closeFlex {
|
||||
margin: auto;
|
||||
}
|
||||
.openFlex {
|
||||
margin: auto;
|
||||
width: auto;
|
||||
}
|
||||
.openFlex,
|
||||
.tabInfo,
|
||||
.toolTip,
|
||||
.comment,
|
||||
.chevron {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.RepoOpen,
|
||||
.RepoClose {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
justify-content: space-between !important;
|
||||
max-height: 65px !important;
|
||||
padding: 15px !important;
|
||||
margin: 20px 0 0 0 !important;
|
||||
}
|
||||
|
||||
.closeFlex {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.alias {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.leftGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rightGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lastSave {
|
||||
display: block !important;
|
||||
color: #65748b;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85em;
|
||||
flex-shrink: 0;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.appendOnlyModeIcon,
|
||||
.alertIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
261
Components/Repo/Repo.tsx
Normal file
261
Components/Repo/Repo.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import classes from './Repo.module.css';
|
||||
import {
|
||||
IconSettings,
|
||||
IconInfoCircle,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconBellOff,
|
||||
IconLockPlus,
|
||||
} from '@tabler/icons-react';
|
||||
import StorageBar from '../UI/StorageBar/StorageBar';
|
||||
import QuickCommands from './QuickCommands/QuickCommands';
|
||||
import { Repository, WizardEnvType, Optional } from '~/types';
|
||||
import { fromUnixTime, formatDistanceStrict } from 'date-fns';
|
||||
import useMedia from 'use-media';
|
||||
|
||||
type RepoProps = Omit<Repository, 'unixUser' | 'displayDetails'> & {
|
||||
repoManageEditHandler: () => void;
|
||||
wizardEnv: Optional<WizardEnvType>;
|
||||
};
|
||||
|
||||
export default function Repo(props: RepoProps) {
|
||||
const isMobile = useMedia({ maxWidth: 1000 });
|
||||
|
||||
const currentDate = useMemo(() => new Date(), []);
|
||||
|
||||
//Load displayDetails from LocalStorage
|
||||
const displayDetailsFromLS = (): boolean => {
|
||||
const key = `displayDetailsRepo${props.id}`;
|
||||
|
||||
try {
|
||||
const storedValue = localStorage.getItem(key);
|
||||
|
||||
if (storedValue === null) {
|
||||
const defaultValue = true;
|
||||
localStorage.setItem(key, JSON.stringify(defaultValue));
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const parsedValue = JSON.parse(storedValue);
|
||||
if (typeof parsedValue === 'boolean') {
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
localStorage.removeItem(key);
|
||||
return true;
|
||||
} catch (error) {
|
||||
localStorage.removeItem(key);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
//States
|
||||
const [displayDetails, setDisplayDetails] = useState(displayDetailsFromLS);
|
||||
|
||||
//BUTTON : Display or not repo details for ONE repo
|
||||
const displayDetailsForOneHandler = (boolean: boolean) => {
|
||||
//Update localStorage
|
||||
localStorage.setItem('displayDetailsRepo' + props.id, JSON.stringify(boolean));
|
||||
setDisplayDetails(boolean);
|
||||
};
|
||||
|
||||
//Status indicator
|
||||
const statusIndicator = () => {
|
||||
return props.status ? classes.statusIndicatorGreen : classes.statusIndicatorRed;
|
||||
};
|
||||
|
||||
//Alert indicator
|
||||
const alertIndicator = () => {
|
||||
if (props.alert === 0) {
|
||||
return (
|
||||
<div className={classes.alertIcon}>
|
||||
<IconBellOff size={16} color='grey' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const appendOnlyModeIndicator = () => {
|
||||
if (props.appendOnlyMode) {
|
||||
return (
|
||||
<div className={classes.appendOnlyModeIcon}>
|
||||
<IconLockPlus size={16} color='grey' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const mobileView = () => {
|
||||
return (
|
||||
<>
|
||||
<div className={classes.RepoClose}>
|
||||
<div className={classes.closeFlex}>
|
||||
<div className={classes.leftGroup}>
|
||||
<div className={statusIndicator()} />
|
||||
<div className={classes.alias}>{props.alias}</div>
|
||||
</div>
|
||||
{appendOnlyModeIndicator()}
|
||||
{alertIndicator()}
|
||||
{props.comment && (
|
||||
<div className={classes.comment}>
|
||||
<IconInfoCircle size={16} color='#637381' />
|
||||
<div className={classes.toolTip}>{props.comment}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={classes.lastSave}>
|
||||
<span
|
||||
title={
|
||||
props.lastSave === 0 ? undefined : fromUnixTime(props.lastSave).toLocaleString()
|
||||
}
|
||||
>
|
||||
{props.lastSave === 0
|
||||
? '-'
|
||||
: formatDistanceStrict(fromUnixTime(props.lastSave), currentDate, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
return mobileView();
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
{displayDetails ? (
|
||||
<>
|
||||
<div className={classes.RepoOpen}>
|
||||
<div className={classes.indicatorsFlex}>
|
||||
<div className={statusIndicator()} />
|
||||
{props.comment && (
|
||||
<div className={classes.comment}>
|
||||
<IconInfoCircle size={16} color='grey' />
|
||||
<div className={classes.toolTip}>{props.comment}</div>
|
||||
</div>
|
||||
)}
|
||||
{appendOnlyModeIndicator()}
|
||||
{alertIndicator()}
|
||||
<QuickCommands
|
||||
repositoryName={props.repositoryName}
|
||||
lanCommand={props.lanCommand}
|
||||
wizardEnv={props.wizardEnv}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.aliasFlex}>
|
||||
<div className={classes.alias}>{props.alias}</div>
|
||||
</div>
|
||||
|
||||
<table className={classes.tabInfo}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '15%' }}>Repository</th>
|
||||
<th style={{ width: '10%' }}>Storage Size</th>
|
||||
<th style={{ width: '30%' }}>Storage Used</th>
|
||||
<th style={{ width: '15%' }}>Last change</th>
|
||||
<th style={{ width: '10%' }}>Edit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{props.repositoryName}</th>
|
||||
<th>{props.storageSize} GB</th>
|
||||
<th style={{ padding: '0 4% 0 4%' }}>
|
||||
<StorageBar storageUsed={props.storageUsed} storageSize={props.storageSize} />
|
||||
</th>
|
||||
<th>
|
||||
<div
|
||||
className={classes.lastSave}
|
||||
title={
|
||||
props.lastSave === 0
|
||||
? undefined
|
||||
: fromUnixTime(props.lastSave).toLocaleString()
|
||||
}
|
||||
>
|
||||
{props.lastSave === 0
|
||||
? '-'
|
||||
: formatDistanceStrict(fromUnixTime(props.lastSave), currentDate, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
</th>
|
||||
<th>
|
||||
<div className={classes.editButton}>
|
||||
<IconSettings
|
||||
width={24}
|
||||
color='#6d4aff'
|
||||
onClick={() => props.repoManageEditHandler()}
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={classes.RepoClose}>
|
||||
<div className={classes.closeFlex}>
|
||||
<div className={classes.leftGroup}>
|
||||
<div className={statusIndicator()} />
|
||||
<div className={classes.alias}>{props.alias}</div>
|
||||
</div>
|
||||
{appendOnlyModeIndicator()}
|
||||
{alertIndicator()}
|
||||
{props.comment && (
|
||||
<div className={classes.comment}>
|
||||
<IconInfoCircle size={16} color='#637381' />
|
||||
<div className={classes.toolTip}>{props.comment}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={classes.lastSave}>
|
||||
<span
|
||||
title={
|
||||
props.lastSave === 0
|
||||
? undefined
|
||||
: fromUnixTime(props.lastSave).toLocaleString()
|
||||
}
|
||||
>
|
||||
{props.lastSave === 0
|
||||
? '-'
|
||||
: formatDistanceStrict(fromUnixTime(props.lastSave), currentDate, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{displayDetails ? (
|
||||
<div className={classes.chevron}>
|
||||
<IconChevronUp
|
||||
color='#494b7a'
|
||||
size={28}
|
||||
onClick={() => {
|
||||
displayDetailsForOneHandler(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classes.chevron}>
|
||||
<IconChevronDown
|
||||
color='#494b7a'
|
||||
size={28}
|
||||
onClick={() => {
|
||||
displayDetailsForOneHandler(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
//Lib
|
||||
import classes from './CopyButton.module.css';
|
||||
import { useState } from 'react';
|
||||
import { IconCopy } from '@tabler/icons-react';
|
||||
|
||||
export default function CopyButton(props) {
|
||||
//State
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
//Function
|
||||
const handleCopy = async (data) => {
|
||||
navigator.clipboard
|
||||
.writeText(data)
|
||||
.then(() => {
|
||||
// If successful, update the isCopied state value
|
||||
setIsCopied(true);
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 1500);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={classes.copyButton}
|
||||
onClick={() => handleCopy(props.dataToCopy)}
|
||||
>
|
||||
<IconCopy color='#65748b' stroke={1.25} size={props.size} />
|
||||
</button>
|
||||
{isCopied ? (
|
||||
<span className={classes.copyValid}>Copied !</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,26 +1,35 @@
|
|||
.copyButton {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.copyButton span {
|
||||
font-size: 0.95rem;
|
||||
color: #6d4aff;
|
||||
margin-right: 5px;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.copyValid {
|
||||
font-size: 0.95rem;
|
||||
color: #6d4aff;
|
||||
animation: scale-in-center 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
|
||||
font-size: 0.95rem;
|
||||
color: #6d4aff;
|
||||
animation: scale-in-center 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
|
||||
}
|
||||
|
||||
@keyframes scale-in-center {
|
||||
0% {
|
||||
-webkit-transform: scale(0);
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
0% {
|
||||
-webkit-transform: scale(0);
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
46
Components/UI/CopyButton/CopyButton.tsx
Normal file
46
Components/UI/CopyButton/CopyButton.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import classes from './CopyButton.module.css';
|
||||
import { useState, ReactNode } from 'react';
|
||||
import { IconChecks, IconCopy } from '@tabler/icons-react';
|
||||
|
||||
type CopyButtonProps = {
|
||||
dataToCopy: string;
|
||||
children?: ReactNode;
|
||||
displayIconConfirmation?: boolean;
|
||||
size?: number;
|
||||
stroke?: number;
|
||||
};
|
||||
|
||||
export default function CopyButton(props: CopyButtonProps) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = async (data: string) => {
|
||||
navigator.clipboard
|
||||
.writeText(data)
|
||||
.then(() => {
|
||||
// If successful, update the isCopied state value
|
||||
setIsCopied(true);
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 1500);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={classes.copyButton} onClick={() => handleCopy(props.dataToCopy)}>
|
||||
{props.children}
|
||||
{isCopied && props.displayIconConfirmation ? (
|
||||
<IconChecks color='#07bc0c' stroke={props.stroke || 1.25} size={props.size} />
|
||||
) : (
|
||||
<IconCopy color='#65748b' stroke={props.stroke || 1.25} size={props.size} />
|
||||
)}
|
||||
</button>
|
||||
{isCopied
|
||||
? !props.displayIconConfirmation && <span className={classes.copyValid}>Copied !</span>
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
//Lib
|
||||
import classes from './Error.module.css';
|
||||
|
||||
export default function Error(props) {
|
||||
return <div className={classes.errorMessage}>{props.message}</div>;
|
||||
}
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
.errorMessage {
|
||||
margin: 15px 0px;
|
||||
background-color: red;
|
||||
color: white;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
animation: myAnim 1s ease 0s 1 normal forwards;
|
||||
margin: 15px 0px;
|
||||
background-color: red;
|
||||
color: white;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
animation: myAnim 1s ease 0s 1 normal forwards;
|
||||
}
|
||||
|
||||
@keyframes myAnim {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
Components/UI/Error/Error.tsx
Normal file
9
Components/UI/Error/Error.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import classes from './Error.module.css';
|
||||
|
||||
type ErrorProps = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export default function Error(props: ErrorProps) {
|
||||
return <div className={classes.errorMessage}>{props.message}</div>;
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
//Lib
|
||||
import classes from './Info.module.css';
|
||||
|
||||
export default function Info(props) {
|
||||
return <div className={classes.infoMessage}>{props.message}</div>;
|
||||
}
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
.infoMessage {
|
||||
margin: 15px 0px;
|
||||
background-color: rgb(17, 147, 0);
|
||||
color: white;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
animation: myAnim 1s ease 0s 1 normal forwards;
|
||||
margin: 15px 0px;
|
||||
background-color: rgb(17, 147, 0);
|
||||
color: white;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
animation: myAnim 1s ease 0s 1 normal forwards;
|
||||
}
|
||||
|
||||
@keyframes myAnim {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
Components/UI/Info/Info.tsx
Normal file
17
Components/UI/Info/Info.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { ReactNode } from 'react';
|
||||
import classes from './Info.module.css';
|
||||
|
||||
type InfoProps = {
|
||||
message: string;
|
||||
color?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export default function Info(props: InfoProps) {
|
||||
return (
|
||||
<div className={classes.infoMessage} style={{ backgroundColor: props.color }}>
|
||||
{props.message}
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
//Lib
|
||||
import classes from './Footer.module.css';
|
||||
import packageInfo from '../../../../package.json';
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<div className={classes.footer}>
|
||||
<p>
|
||||
About{' '}
|
||||
<a
|
||||
className={classes.site}
|
||||
target='_blank'
|
||||
href='https://borgwarehouse.com/'
|
||||
rel='noreferrer'
|
||||
>
|
||||
BorgWarehouse
|
||||
</a>{' '}
|
||||
- v{packageInfo.version}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
|
|
@ -1,26 +1,25 @@
|
|||
.footer {
|
||||
color: #494b7a;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
color: #494b7a;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
padding-left: 70px;
|
||||
padding-left: 70px;
|
||||
}
|
||||
|
||||
a.site {
|
||||
color: #6d4aff;
|
||||
text-decoration: none;
|
||||
color: #6d4aff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media all and (max-width: 1000px) {
|
||||
.footer {
|
||||
width: 100%;
|
||||
}
|
||||
.footer p {
|
||||
padding-left: 0;
|
||||
}
|
||||
.footer {
|
||||
width: 100%;
|
||||
}
|
||||
.footer p {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
Components/UI/Layout/Footer/Footer.tsx
Normal file
23
Components/UI/Layout/Footer/Footer.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import classes from './Footer.module.css';
|
||||
import packageInfo from '~/package.json';
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<div className={classes.footer}>
|
||||
<p>
|
||||
About{' '}
|
||||
<a
|
||||
className={classes.site}
|
||||
target='_blank'
|
||||
href='https://borgwarehouse.com/'
|
||||
rel='noreferrer'
|
||||
>
|
||||
BorgWarehouse
|
||||
</a>{' '}
|
||||
- v{packageInfo.version}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
//Lib
|
||||
import classes from './Header.module.css';
|
||||
|
||||
//Components
|
||||
import Nav from './Nav/Nav';
|
||||
|
||||
function Header() {
|
||||
return (
|
||||
<header className={classes.Header}>
|
||||
<div className={[classes.flex, 'container'].join(' ')}>
|
||||
<div className={classes.logo}>BorgWarehouse</div>
|
||||
|
||||
<nav>
|
||||
<Nav />
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
|
|
@ -1,31 +1,31 @@
|
|||
.Header {
|
||||
width: 100%;
|
||||
background: #111827;
|
||||
box-shadow:
|
||||
0 3px 6px rgba(0, 0, 0, 0.16),
|
||||
0 3px 6px rgba(0, 0, 0, 0.23);
|
||||
height: 50px;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: static;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
width: 100%;
|
||||
background: #111827;
|
||||
box-shadow:
|
||||
0 3px 6px rgba(0, 0, 0, 0.16),
|
||||
0 3px 6px rgba(0, 0, 0, 0.23);
|
||||
height: 50px;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: static;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 1500px;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 1500px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
color: #6d4aff;
|
||||
text-shadow: #6d4aff 0px 0px 18px;
|
||||
margin-left: 20px;
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
color: #6d4aff;
|
||||
text-shadow: #6d4aff 0px 0px 18px;
|
||||
margin-left: 70px;
|
||||
}
|
||||
|
|
|
|||
28
Components/UI/Layout/Header/Header.tsx
Normal file
28
Components/UI/Layout/Header/Header.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import Image from 'next/image';
|
||||
import classes from './Header.module.css';
|
||||
import Nav from './Nav/Nav';
|
||||
|
||||
function Header() {
|
||||
return (
|
||||
<header className={classes.Header}>
|
||||
<div className={[classes.flex, 'container'].join(' ')}>
|
||||
<div className={classes.logo}>
|
||||
<Image
|
||||
src='/borgwarehouse-logo-violet.svg'
|
||||
alt='BorgWarehouse'
|
||||
width={225}
|
||||
height={40}
|
||||
className={classes.logoImage}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<Nav />
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
//Lib
|
||||
import classes from './Nav.module.css';
|
||||
import { IconUser, IconLogout } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useSession, signOut } from 'next-auth/react';
|
||||
|
||||
export default function Nav() {
|
||||
////Var
|
||||
//Get the current route to light the right Item
|
||||
const router = useRouter();
|
||||
const currentRoute = router.pathname;
|
||||
const { status, data } = useSession();
|
||||
|
||||
//Function
|
||||
const onLogoutClickedHandler = async () => {
|
||||
//This bug is open : https://github.com/nextauthjs/next-auth/issues/1542
|
||||
//I put redirect to false and redirect with router.
|
||||
//The result on logout click is an ugly piece of page for a few milliseconds before returning to the login page.
|
||||
//It's ugly if you are perfectionist but functional and invisible for most of users while waiting for a next-auth fix.
|
||||
await signOut({ redirect: false });
|
||||
router.replace('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className={classes.Nav}>
|
||||
<li
|
||||
style={{ margin: '0px 15px 0px 0px' }}
|
||||
className={classes.account}
|
||||
>
|
||||
<Link
|
||||
href='/account'
|
||||
className={
|
||||
currentRoute === '/account' ? classes.active : null
|
||||
}
|
||||
>
|
||||
<div className={classes.user}>
|
||||
<div>
|
||||
<IconUser size={28} />
|
||||
</div>
|
||||
<div className={classes.username}>
|
||||
{status === 'authenticated' && data.user.name}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<div className={classes.logout}>
|
||||
<a onClick={onLogoutClickedHandler}>
|
||||
<IconLogout size={28} />
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,54 +1,54 @@
|
|||
.Nav {
|
||||
list-style-type: none;
|
||||
margin: 0px 15px 0px 0px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
list-style-type: none;
|
||||
margin: 0px 15px 0px 0px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.username::first-letter {
|
||||
text-transform: capitalize;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.account {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #494b7a;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #494b7a;
|
||||
}
|
||||
|
||||
.account a {
|
||||
color: #494b7a;
|
||||
text-decoration: none;
|
||||
color: #494b7a;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.account :focus,
|
||||
.account .active,
|
||||
.account :hover {
|
||||
color: #6d4aff;
|
||||
text-shadow: #6d4aff 0px 0px 18px;
|
||||
color: #6d4aff;
|
||||
text-shadow: #6d4aff 0px 0px 18px;
|
||||
}
|
||||
|
||||
.logout {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #494b7a;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #494b7a;
|
||||
}
|
||||
|
||||
.logout :focus,
|
||||
.logout .active,
|
||||
.logout :hover {
|
||||
color: #6d4aff;
|
||||
text-shadow: #6d4aff 0px 0px 18px;
|
||||
color: #6d4aff;
|
||||
text-shadow: #6d4aff 0px 0px 18px;
|
||||
}
|
||||
|
||||
@media all and (max-width: 1000px) {
|
||||
.account {
|
||||
display: none;
|
||||
}
|
||||
.account {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
Components/UI/Layout/Header/Nav/Nav.tsx
Normal file
43
Components/UI/Layout/Header/Nav/Nav.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import classes from './Nav.module.css';
|
||||
import { IconUser, IconLogout } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useSession, signOut } from 'next-auth/react';
|
||||
|
||||
export default function Nav() {
|
||||
const router = useRouter();
|
||||
const currentRoute = router.pathname;
|
||||
const { status, data } = useSession();
|
||||
|
||||
const onLogoutClickedHandler = async () => {
|
||||
//This bug is open : https://github.com/nextauthjs/next-auth/issues/1542
|
||||
//I put redirect to false and redirect with router.
|
||||
//The result on logout click is an ugly piece of page for a few milliseconds before returning to the login page.
|
||||
//It's ugly if you are perfectionist but functional and invisible for most of users while waiting for a next-auth fix.
|
||||
await signOut({ redirect: false });
|
||||
router.replace('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className={classes.Nav}>
|
||||
<li style={{ margin: '0px 15px 0px 0px' }} className={classes.account}>
|
||||
<Link href='/account' className={currentRoute === '/account' ? classes.active : undefined}>
|
||||
<div className={classes.user}>
|
||||
<div>
|
||||
<IconUser size={28} />
|
||||
</div>
|
||||
<div className={classes.username}>{status === 'authenticated' && data.user?.name}</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<div className={classes.logout}>
|
||||
<a onClick={onLogoutClickedHandler}>
|
||||
<IconLogout size={28} />
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
//Lib
|
||||
import Footer from './Footer/Footer';
|
||||
import Header from './Header/Header';
|
||||
import NavSide from './NavSide/NavSide';
|
||||
import classes from './Layout.module.css';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
function Layout(props) {
|
||||
//Var
|
||||
const { status } = useSession();
|
||||
|
||||
if (status === 'authenticated') {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<NavSide />
|
||||
<div className={classes.mainWrapper}>{props.children}</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
} else if (status === 'unauthenticated') {
|
||||
return (
|
||||
<>
|
||||
<div className={classes.login}>{props.children}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Layout;
|
||||
|
|
@ -1,23 +1,23 @@
|
|||
.mainWrapper {
|
||||
margin: auto;
|
||||
max-width: 1400px;
|
||||
height: calc(100vh - 100px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
overflow: auto;
|
||||
/* to prevent main content under navside on little screen */
|
||||
padding-left: 90px;
|
||||
/* Disable scrollbar */
|
||||
scrollbar-width: none;
|
||||
margin: auto;
|
||||
max-width: 1400px;
|
||||
height: calc(100vh - 100px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
overflow: auto;
|
||||
/* to prevent main content under navside on little screen */
|
||||
padding-left: 90px;
|
||||
/* Disable scrollbar */
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.login {
|
||||
background-color: #111827;
|
||||
background-color: #111827;
|
||||
}
|
||||
|
||||
@media all and (max-width: 1000px) {
|
||||
.mainWrapper {
|
||||
padding-left: 0px;
|
||||
}
|
||||
.mainWrapper {
|
||||
padding-left: 0px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
Components/UI/Layout/Layout.tsx
Normal file
32
Components/UI/Layout/Layout.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import Footer from './Footer/Footer';
|
||||
import Header from './Header/Header';
|
||||
import NavSide from './NavSide/NavSide';
|
||||
import classes from './Layout.module.css';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
type LayoutProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function Layout(props: LayoutProps) {
|
||||
const { status } = useSession();
|
||||
|
||||
if (status === 'authenticated') {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<NavSide />
|
||||
<div className={classes.mainWrapper}>{props.children}</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
} else if (status === 'unauthenticated') {
|
||||
return (
|
||||
<>
|
||||
<div className={classes.login}>{props.children}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Layout;
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
//Lib
|
||||
import classes from './NavSide.module.css';
|
||||
import {
|
||||
IconServer,
|
||||
IconSettingsAutomation,
|
||||
IconActivityHeartbeat,
|
||||
} from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
//Composants
|
||||
|
||||
export default function NavSide() {
|
||||
////Var
|
||||
//Get the current route to light the right Item
|
||||
const router = useRouter();
|
||||
const currentRoute = router.pathname;
|
||||
|
||||
return (
|
||||
<ul className={classes.NavSide}>
|
||||
<li className={classes.NavSideItem}>
|
||||
<Link
|
||||
href='/'
|
||||
className={currentRoute === '/' ? classes.active : null}
|
||||
>
|
||||
<IconServer size={40} />
|
||||
</Link>
|
||||
<span className={classes.tooltip}>Repositories</span>
|
||||
</li>
|
||||
<li className={classes.NavSideItem}>
|
||||
<Link
|
||||
href='/setup-wizard/1'
|
||||
className={
|
||||
currentRoute === '/setup-wizard/[slug]'
|
||||
? classes.active
|
||||
: null
|
||||
}
|
||||
>
|
||||
<IconSettingsAutomation size={40} />
|
||||
</Link>
|
||||
<span className={classes.tooltip}>Setup Wizard</span>
|
||||
</li>
|
||||
<li className={classes.NavSideItem}>
|
||||
<Link
|
||||
href='/monitoring'
|
||||
className={
|
||||
currentRoute === '/monitoring' ? classes.active : null
|
||||
}
|
||||
>
|
||||
<IconActivityHeartbeat size={40} />
|
||||
</Link>
|
||||
<span className={classes.tooltip}>Monitoring</span>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,74 +1,74 @@
|
|||
/* NAVSIDE */
|
||||
|
||||
.NavSide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
text-align: center;
|
||||
/* border-right: 2px solid #e5e7eb; */
|
||||
height: calc(100% - 50px);
|
||||
width: 70px;
|
||||
list-style-type: none;
|
||||
/* background: #1b1340; */
|
||||
background: #111827;
|
||||
/* box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
text-align: center;
|
||||
/* border-right: 2px solid #e5e7eb; */
|
||||
height: calc(100% - 50px);
|
||||
width: 70px;
|
||||
list-style-type: none;
|
||||
/* background: #1b1340; */
|
||||
background: #111827;
|
||||
/* box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); */
|
||||
}
|
||||
|
||||
ul.NavSide {
|
||||
padding-top: 10px;
|
||||
margin: 0;
|
||||
padding-inline-start: 0px;
|
||||
padding-top: 10px;
|
||||
margin: 0;
|
||||
padding-inline-start: 0px;
|
||||
}
|
||||
|
||||
/* NAV SIDE ITEMS */
|
||||
.NavSideItem {
|
||||
margin: 0px 0px 30px 0px;
|
||||
margin: 0px 0px 30px 0px;
|
||||
}
|
||||
|
||||
.NavSideItem a {
|
||||
text-decoration: none;
|
||||
color: #494b7a;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
color: #494b7a;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.NavSideItem a:hover,
|
||||
.NavSideItem a:focus,
|
||||
.NavSideItem a.active {
|
||||
color: #6d4aff;
|
||||
font-weight: bold;
|
||||
/* border-bottom: 2px solid #6d4aff; */
|
||||
/* padding-bottom: 15px; */
|
||||
text-shadow: #6d4aff 0px 0px 18px;
|
||||
color: #6d4aff;
|
||||
font-weight: bold;
|
||||
/* border-bottom: 2px solid #6d4aff; */
|
||||
/* padding-bottom: 15px; */
|
||||
text-shadow: #6d4aff 0px 0px 18px;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
visibility: hidden;
|
||||
width: 120px;
|
||||
/* background-color: #1b1340; */
|
||||
background-color: #111827;
|
||||
color: #d1d5db;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 5px 0;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
margin: 5px 0 0 20px;
|
||||
opacity: 0;
|
||||
transition: 0.5s opacity;
|
||||
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.251);
|
||||
visibility: hidden;
|
||||
width: 120px;
|
||||
/* background-color: #1b1340; */
|
||||
background-color: #111827;
|
||||
color: #d1d5db;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 5px 0;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
margin: 5px 0 0 20px;
|
||||
opacity: 0;
|
||||
transition: 0.5s opacity;
|
||||
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.251);
|
||||
}
|
||||
|
||||
.NavSideItem:hover .tooltip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media all and (max-width: 1000px) {
|
||||
.NavSide {
|
||||
display: none;
|
||||
}
|
||||
.NavSide {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
38
Components/UI/Layout/NavSide/NavSide.tsx
Normal file
38
Components/UI/Layout/NavSide/NavSide.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import classes from './NavSide.module.css';
|
||||
import { IconServer, IconSettingsAutomation, IconActivityHeartbeat } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
export default function NavSide() {
|
||||
const router = useRouter();
|
||||
const currentRoute = router.pathname;
|
||||
|
||||
return (
|
||||
<ul className={classes.NavSide}>
|
||||
<li className={classes.NavSideItem}>
|
||||
<Link href='/' className={currentRoute === '/' ? classes.active : undefined}>
|
||||
<IconServer size={40} />
|
||||
</Link>
|
||||
<span className={classes.tooltip}>Repositories</span>
|
||||
</li>
|
||||
<li className={classes.NavSideItem}>
|
||||
<Link
|
||||
href='/setup-wizard/1'
|
||||
className={currentRoute === '/setup-wizard/[slug]' ? classes.active : undefined}
|
||||
>
|
||||
<IconSettingsAutomation size={40} />
|
||||
</Link>
|
||||
<span className={classes.tooltip}>Setup Wizard</span>
|
||||
</li>
|
||||
<li className={classes.NavSideItem}>
|
||||
<Link
|
||||
href='/monitoring'
|
||||
className={currentRoute === '/monitoring' ? classes.active : undefined}
|
||||
>
|
||||
<IconActivityHeartbeat size={40} />
|
||||
</Link>
|
||||
<span className={classes.tooltip}>Monitoring</span>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
//Lib
|
||||
import classes from './ShimmerRepoList.module.css';
|
||||
|
||||
export default function ShimmerRepoList() {
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<div className={classes.loadingButtonContainer}>
|
||||
<div className={classes.buttonIsLoading} />
|
||||
</div>
|
||||
<div className={classes.loadingRepoContainer}>
|
||||
<div className={classes.repoIsLoading} />
|
||||
<div className={classes.repoIsLoading} />
|
||||
<div className={classes.repoIsLoading} />
|
||||
<div className={classes.repoIsLoading} />
|
||||
<div className={classes.repoIsLoading} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,50 +1,50 @@
|
|||
.container {
|
||||
display: flex;
|
||||
width: 90%;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
width: 90%;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.loadingButtonContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 20px auto;
|
||||
width: 90%;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 20px auto;
|
||||
width: 90%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.buttonIsLoading {
|
||||
height: 62px;
|
||||
width: 211px;
|
||||
margin: auto;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.05);
|
||||
background: linear-gradient(110deg, #ececec 8%, #f5f5f5 18%, #ececec 33%);
|
||||
border-radius: 5px;
|
||||
background-size: 200% 100%;
|
||||
animation: 1.5s shine linear infinite;
|
||||
height: 62px;
|
||||
width: 211px;
|
||||
margin: auto;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.05);
|
||||
background: linear-gradient(110deg, #ececec 8%, #f5f5f5 18%, #ececec 33%);
|
||||
border-radius: 5px;
|
||||
background-size: 200% 100%;
|
||||
animation: 1.5s shine linear infinite;
|
||||
}
|
||||
|
||||
.loadingRepoContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.repoIsLoading {
|
||||
width: 100%;
|
||||
height: 65px;
|
||||
margin: 20px auto;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.05);
|
||||
background: linear-gradient(110deg, #ececec 8%, #f5f5f5 18%, #ececec 33%);
|
||||
border-radius: 5px;
|
||||
background-size: 200% 100%;
|
||||
animation: 1.5s shine linear infinite;
|
||||
width: 100%;
|
||||
height: 65px;
|
||||
margin: 20px auto;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.05);
|
||||
background: linear-gradient(110deg, #ececec 8%, #f5f5f5 18%, #ececec 33%);
|
||||
border-radius: 5px;
|
||||
background-size: 200% 100%;
|
||||
animation: 1.5s shine linear infinite;
|
||||
}
|
||||
|
||||
@keyframes shine {
|
||||
to {
|
||||
background-position-x: -200%;
|
||||
}
|
||||
to {
|
||||
background-position-x: -200%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
Components/UI/ShimmerRepoList/ShimmerRepoList.tsx
Normal file
22
Components/UI/ShimmerRepoList/ShimmerRepoList.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import classes from './ShimmerRepoList.module.css';
|
||||
|
||||
const LOADING_REPO_COUNT = 5;
|
||||
|
||||
function ShimmerRepoItem() {
|
||||
return <div className={classes.repoIsLoading} />;
|
||||
}
|
||||
|
||||
export default function ShimmerRepoList() {
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<div className={classes.loadingButtonContainer}>
|
||||
<div className={classes.buttonIsLoading} />
|
||||
</div>
|
||||
<div className={classes.loadingRepoContainer}>
|
||||
{Array.from({ length: LOADING_REPO_COUNT }, (_, i) => (
|
||||
<ShimmerRepoItem key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
//Lib
|
||||
import classes from './StorageBar.module.css';
|
||||
|
||||
export default function StorageBar(props) {
|
||||
//Var
|
||||
//storageUsed is in octet, storageSize is in GB. Round to 1 decimal for %.
|
||||
const storageUsedPercent = (
|
||||
((props.storageUsed / 1000000) * 100) /
|
||||
props.storageSize
|
||||
).toFixed(1);
|
||||
|
||||
return (
|
||||
<div className={classes.barContainer}>
|
||||
<div className={classes.barBackground}>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
width: storageUsedPercent + '%',
|
||||
transition: 'width 0.5s 0s ease',
|
||||
}}
|
||||
>
|
||||
<div className={classes.progressionStyle} />
|
||||
</div>
|
||||
<div className={classes.tooltip}>
|
||||
{storageUsedPercent}% (
|
||||
{(props.storageUsed / 1000000).toFixed(1)} GB /{' '}
|
||||
{props.storageSize} GB)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,36 +1,36 @@
|
|||
.barContainer {
|
||||
margin: auto;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.barBackground {
|
||||
background-color: #704dff5e;
|
||||
border-radius: 3px;
|
||||
height: 19px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background-color: #704dff5e;
|
||||
border-radius: 3px;
|
||||
height: 19px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progressionStyle {
|
||||
background-color: #704dff;
|
||||
border-radius: 3px;
|
||||
height: 19px;
|
||||
width: 100%;
|
||||
background-color: #704dff;
|
||||
border-radius: 3px;
|
||||
height: 19px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
visibility: hidden;
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transition: 0.5s opacity;
|
||||
position: absolute;
|
||||
left: calc(30%);
|
||||
top: 0px;
|
||||
visibility: hidden;
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transition: 0.5s opacity;
|
||||
position: absolute;
|
||||
left: calc(30%);
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
.barBackground:hover .tooltip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
|
|
|||
33
Components/UI/StorageBar/StorageBar.tsx
Normal file
33
Components/UI/StorageBar/StorageBar.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import classes from './StorageBar.module.css';
|
||||
|
||||
type StorageBarProps = {
|
||||
storageUsed: number;
|
||||
storageSize: number;
|
||||
};
|
||||
|
||||
export default function StorageBar(props: StorageBarProps) {
|
||||
//storageUsed is in kB, storageSize is in GB. Round to 1 decimal for %.
|
||||
const storageUsedPercent = (((props.storageUsed / 1024 ** 2) * 100) / props.storageSize).toFixed(
|
||||
1
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classes.barContainer}>
|
||||
<div className={classes.barBackground}>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
width: storageUsedPercent + '%',
|
||||
transition: 'width 0.5s 0s ease',
|
||||
}}
|
||||
>
|
||||
<div className={classes.progressionStyle} />
|
||||
</div>
|
||||
<div className={classes.tooltip}>
|
||||
{storageUsedPercent}% ({(props.storageUsed / 1024 ** 2).toFixed(1)} GB /{' '}
|
||||
{props.storageSize} GB)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
//Lib
|
||||
import classes from './Switch.module.css';
|
||||
|
||||
export default function Switch(props) {
|
||||
return (
|
||||
<>
|
||||
<div className={classes.switchWrapper}>
|
||||
<div className={classes.switch}>
|
||||
<label className={classes.pureMaterialSwitch}>
|
||||
<input
|
||||
checked={props.checked}
|
||||
disabled={props.disabled}
|
||||
type='checkbox'
|
||||
onChange={(e) => props.onChange(e.target.checked)}
|
||||
/>
|
||||
|
||||
<span>{props.switchName}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className={classes.switchDescription}>
|
||||
<span>{props.switchDescription}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,157 +1,84 @@
|
|||
/* Wrapper styles */
|
||||
.switchWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Switch container */
|
||||
.switch {
|
||||
display: flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.switchDescription {
|
||||
display: flex;
|
||||
margin: 8px 0px 0px 0px;
|
||||
color: #6c737f;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.pureMaterialSwitch {
|
||||
z-index: 0;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
color: rgba(var(--pure-material-onsurface-rgb, 0, 0, 0), 0.87);
|
||||
font-family: var(
|
||||
--pure-material-font,
|
||||
'Roboto',
|
||||
'Segoe UI',
|
||||
BlinkMacSystemFont,
|
||||
system-ui,
|
||||
-apple-system
|
||||
);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
/* Label */
|
||||
.switchLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Input */
|
||||
.pureMaterialSwitch > input {
|
||||
appearance: none;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
z-index: -1;
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: -8px;
|
||||
display: block;
|
||||
margin: 0;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: rgba(var(--pure-material-onsurface-rgb, 0, 0, 0), 0.38);
|
||||
outline: none;
|
||||
opacity: 0;
|
||||
transform: scale(1);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.3s 0.1s,
|
||||
transform 0.2s 0.1s;
|
||||
.switchLabel input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Span */
|
||||
.pureMaterialSwitch > span {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: #494b7a;
|
||||
/* Slider */
|
||||
.switchSlider {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
background: #ccc;
|
||||
border-radius: 12px;
|
||||
transition: #ccc 0.3s ease;
|
||||
}
|
||||
|
||||
/* Track */
|
||||
.pureMaterialSwitch > span::before {
|
||||
content: '';
|
||||
float: right;
|
||||
display: inline-block;
|
||||
margin: 5px 0 5px 30px;
|
||||
border-radius: 7px;
|
||||
width: 36px;
|
||||
height: 14px;
|
||||
background-color: rgba(var(--pure-material-onsurface-rgb, 0, 0, 0), 0.38);
|
||||
vertical-align: top;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
opacity 0.2s;
|
||||
.switchSlider::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Thumb */
|
||||
.pureMaterialSwitch > span::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 16px;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: rgb(var(--pure-material-onprimary-rgb, 255, 255, 255));
|
||||
box-shadow:
|
||||
0 3px 1px -2px rgba(0, 0, 0, 0.2),
|
||||
0 2px 2px 0 rgba(0, 0, 0, 0.14),
|
||||
0 1px 5px 0 rgba(0, 0, 0, 0.12);
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
transform 0.2s;
|
||||
/* Checked styles */
|
||||
.switchLabel input:checked + .switchSlider {
|
||||
background: #704dff;
|
||||
}
|
||||
|
||||
/* Checked */
|
||||
.pureMaterialSwitch > input:checked {
|
||||
right: -10px;
|
||||
background-color: rgb(var(--pure-material-primary-rgb, 109, 74, 255));
|
||||
.switchLabel input:checked + .switchSlider::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.pureMaterialSwitch > input:checked + span::before {
|
||||
background-color: rgba(var(--pure-material-primary-rgb, 109, 74, 255), 0.6);
|
||||
/* Disabled styles */
|
||||
.switchLabel input:disabled + .switchSlider {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.pureMaterialSwitch > input:checked + span::after {
|
||||
background-color: rgb(var(--pure-material-primary-rgb, 109, 74, 255));
|
||||
transform: translateX(16px);
|
||||
.switchLabel input:disabled + .switchSlider::after {
|
||||
background: #bdbdbd;
|
||||
}
|
||||
|
||||
/* Active */
|
||||
.pureMaterialSwitch > input:active {
|
||||
opacity: 1;
|
||||
transform: scale(0);
|
||||
transition:
|
||||
transform 0s,
|
||||
opacity 0s;
|
||||
/* Switch text */
|
||||
.switchText {
|
||||
font-size: 1rem;
|
||||
color: #494b7a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pureMaterialSwitch > input:active + span::before {
|
||||
background-color: rgba(var(--pure-material-primary-rgb, 109, 74, 255), 0.6);
|
||||
/* Description */
|
||||
.switchDescription {
|
||||
font-size: 0.875rem;
|
||||
color: #6c737f;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pureMaterialSwitch > input:checked:active + span::before {
|
||||
background-color: rgba(var(--pure-material-onsurface-rgb, 0, 0, 0), 0.38);
|
||||
}
|
||||
|
||||
/* Disabled */
|
||||
.pureMaterialSwitch > input:disabled + span {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
/* .pureMaterialSwitch > input:disabled {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.pureMaterialSwitch > input:disabled + span {
|
||||
color: rgb(var(--pure-material-onsurface-rgb, 0, 0, 0));
|
||||
opacity: 0.38;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pureMaterialSwitch > input:disabled + span::before {
|
||||
background-color: rgba(var(--pure-material-onsurface-rgb, 0, 0, 0), 0.38);
|
||||
}
|
||||
|
||||
.pureMaterialSwitch > input:checked:disabled + span::before {
|
||||
background-color: rgba(var(--pure-material-primary-rgb, 109, 74, 255), 0.6);
|
||||
} */
|
||||
|
|
|
|||
45
Components/UI/Switch/Switch.tsx
Normal file
45
Components/UI/Switch/Switch.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Optional } from '~/types';
|
||||
import classes from './Switch.module.css';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
type SwitchProps = {
|
||||
switchName: string;
|
||||
switchDescription: string;
|
||||
checked: Optional<boolean>;
|
||||
disabled: boolean;
|
||||
loading?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
export default function Switch(props: SwitchProps) {
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
useEffect(() => {
|
||||
if (props.loading) {
|
||||
start();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
}, [props.loading, start, stop]);
|
||||
|
||||
return (
|
||||
<div className={classes.switchWrapper}>
|
||||
<div className={classes.switch}>
|
||||
<label className={classes.switchLabel}>
|
||||
<>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={props.checked || false}
|
||||
disabled={props.disabled}
|
||||
onChange={(e) => props.onChange(e.target.checked)}
|
||||
/>
|
||||
<span className={classes.switchSlider}></span>
|
||||
</>
|
||||
<span className={classes.switchText}>{props.switchName}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className={classes.switchDescription}>{props.switchDescription}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
//Lib
|
||||
import React from 'react';
|
||||
import classes from '../WizardStep1/WizardStep1.module.css';
|
||||
import { IconDeviceDesktopAnalytics, IconTerminal2 } from '@tabler/icons-react';
|
||||
|
||||
function WizardStep1() {
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<h1>
|
||||
<IconTerminal2 className={classes.icon} />
|
||||
Command Line Interface
|
||||
</h1>
|
||||
<div className={classes.description}>
|
||||
We recommend using the official <b>BorgBackup</b> client which
|
||||
is supported by most Linux distributions.
|
||||
<br />
|
||||
More information about installation can be{' '}
|
||||
<a
|
||||
href='https://borgbackup.readthedocs.io/en/stable/installation.html'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
found here
|
||||
</a>
|
||||
.<br />
|
||||
To <b>automate your backup</b>, you can also use{' '}
|
||||
<a
|
||||
href='https://torsion.org/borgmatic/'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Borgmatic
|
||||
</a>{' '}
|
||||
which is a{' '}
|
||||
<a
|
||||
href='https://packages.debian.org/buster/borgmatic'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Debian package
|
||||
</a>
|
||||
. On the step 4, you will find a pattern of default config.
|
||||
</div>
|
||||
<div className={classes.separator}></div>
|
||||
<h1>
|
||||
<IconDeviceDesktopAnalytics className={classes.icon} />
|
||||
Graphical User Interface
|
||||
</h1>
|
||||
<div className={classes.description}>
|
||||
<b>Vorta</b> is an opensource (GPLv3) backup client for Borg
|
||||
Backup.
|
||||
<br />
|
||||
It runs on Linux, MacOS and Windows (via Windows’ Linux
|
||||
Subsystem (WSL)). Find the right way to install it{' '}
|
||||
<a
|
||||
href='https://vorta.borgbase.com/'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
just here
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
<img src='/vorta-demo.gif' alt='Vorta GIF' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WizardStep1;
|
||||
|
|
@ -1,137 +1,137 @@
|
|||
.container {
|
||||
margin: 40px 20px 20px 5px;
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||
0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
border-radius: 5px;
|
||||
text-align: left;
|
||||
padding: 30px 70px;
|
||||
animation: animStep ease-in 0.3s 1 normal none;
|
||||
margin: 40px 20px 20px 5px;
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||
0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
border-radius: 5px;
|
||||
text-align: left;
|
||||
padding: 30px 70px;
|
||||
animation: animStep ease-in 0.3s 1 normal none;
|
||||
}
|
||||
|
||||
@keyframes animStep {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.container h1 {
|
||||
text-align: center;
|
||||
font-size: 1.7em;
|
||||
padding-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
font-size: 1.7em;
|
||||
padding-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container a {
|
||||
color: #6d4aff;
|
||||
color: #6d4aff;
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.container {
|
||||
line-height: 1.8em;
|
||||
font-size: 1.05em;
|
||||
line-height: 1.8em;
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
h1 .icon {
|
||||
color: #6d4aff;
|
||||
margin-right: 5px;
|
||||
color: #6d4aff;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.container img {
|
||||
max-width: 650px;
|
||||
max-width: 650px;
|
||||
}
|
||||
|
||||
.code {
|
||||
background-color: #111827;
|
||||
color: #f8f8f2;
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
Monaco,
|
||||
Consolas,
|
||||
liberation mono,
|
||||
courier new,
|
||||
monospace;
|
||||
padding: 5px 15px;
|
||||
border-radius: 5px;
|
||||
display: inline-block;
|
||||
margin: 5px 0px 10px 0px;
|
||||
font-size: 0.8em;
|
||||
white-space: pre;
|
||||
line-height: 1em;
|
||||
background-color: #111827;
|
||||
color: #f8f8f2;
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
Monaco,
|
||||
Consolas,
|
||||
liberation mono,
|
||||
courier new,
|
||||
monospace;
|
||||
padding: 5px 15px;
|
||||
border-radius: 5px;
|
||||
display: inline-block;
|
||||
margin: 5px 0px 10px 0px;
|
||||
font-size: 0.8em;
|
||||
white-space: pre;
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
.verifyOrange {
|
||||
background-color: #ff7a1b;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
background-color: #ff7a1b;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.verifyRed {
|
||||
background-color: #ea1313;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin-top: 10px;
|
||||
background-color: #ea1313;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.verifyOrange li,
|
||||
.container li {
|
||||
list-style: disc;
|
||||
margin-top: 1px;
|
||||
margin-left: 30px;
|
||||
list-style: disc;
|
||||
margin-top: 1px;
|
||||
margin-left: 30px;
|
||||
}
|
||||
|
||||
.verifyOrange li .sshPublicKey {
|
||||
background-color: #282a36;
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
Monaco,
|
||||
Consolas,
|
||||
liberation mono,
|
||||
courier new,
|
||||
monospace;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
background-color: #282a36;
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
Monaco,
|
||||
Consolas,
|
||||
liberation mono,
|
||||
courier new,
|
||||
monospace;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.verifyRed .alert,
|
||||
.verifyOrange .alert {
|
||||
text-align: center;
|
||||
font-size: 1.1em;
|
||||
padding-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
font-size: 1.1em;
|
||||
padding-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.iconAlert {
|
||||
margin-right: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-style: italic;
|
||||
color: #494b7a4d;
|
||||
font-size: 0.8em;
|
||||
font-style: italic;
|
||||
color: #494b7a4d;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.note a {
|
||||
color: #6d4aff73;
|
||||
color: #6d4aff73;
|
||||
}
|
||||
|
|
|
|||
56
Components/WizardSteps/WizardStep1/WizardStep1.tsx
Normal file
56
Components/WizardSteps/WizardStep1/WizardStep1.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import React from 'react';
|
||||
import classes from '../WizardStep1/WizardStep1.module.css';
|
||||
import { IconDeviceDesktopAnalytics, IconTerminal2 } from '@tabler/icons-react';
|
||||
|
||||
function WizardStep1() {
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<h1>
|
||||
<IconTerminal2 className={classes.icon} />
|
||||
Command Line Interface
|
||||
</h1>
|
||||
<div className={classes.description}>
|
||||
We recommend using the official <b>BorgBackup</b> client which is supported by most Linux
|
||||
distributions.
|
||||
<br />
|
||||
More information about installation can be{' '}
|
||||
<a
|
||||
href='https://borgbackup.readthedocs.io/en/stable/installation.html'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
found here
|
||||
</a>
|
||||
.<br />
|
||||
To <b>automate your backup</b>, you can also use{' '}
|
||||
<a href='https://torsion.org/borgmatic/' target='_blank' rel='noreferrer'>
|
||||
Borgmatic
|
||||
</a>{' '}
|
||||
which is a{' '}
|
||||
<a href='https://packages.debian.org/buster/borgmatic' target='_blank' rel='noreferrer'>
|
||||
Debian package
|
||||
</a>
|
||||
. On the step 4, you will find a pattern of default config.
|
||||
</div>
|
||||
<div className={classes.separator}></div>
|
||||
<h1>
|
||||
<IconDeviceDesktopAnalytics className={classes.icon} />
|
||||
Graphical User Interface
|
||||
</h1>
|
||||
<div className={classes.description}>
|
||||
BorgWarehouse is <b>compatible with all BorgBackup graphical clients</b>, including the
|
||||
well-known{' '}
|
||||
<a href='https://apps.gnome.org/PikaBackup/' target='_blank' rel='noreferrer'>
|
||||
Pika Backup
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a href='https://vorta.borgbase.com/' target='_blank' rel='noreferrer'>
|
||||
Vorta
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WizardStep1;
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
//Lib
|
||||
import React from 'react';
|
||||
import classes from '../WizardStep1/WizardStep1.module.css';
|
||||
import { IconTool, IconAlertCircle } from '@tabler/icons-react';
|
||||
import CopyButton from '../../UI/CopyButton/CopyButton';
|
||||
import lanCommandOption from '../../../helpers/functions/lanCommandOption';
|
||||
|
||||
function WizardStep2(props) {
|
||||
////Vars
|
||||
const wizardEnv = props.wizardEnv;
|
||||
const UNIX_USER = wizardEnv.UNIX_USER;
|
||||
//Needed to generate command for borg over LAN instead of WAN if env vars are set and option enabled.
|
||||
const { FQDN, SSH_SERVER_PORT } = lanCommandOption(
|
||||
wizardEnv,
|
||||
props.selectedOption.lanCommand
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<h1>
|
||||
<IconTool className={classes.icon} />
|
||||
Initialize a repository
|
||||
</h1>
|
||||
<div className={classes.description}>
|
||||
To initialize your repository with borgbackup :
|
||||
<br />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className={classes.code}>
|
||||
borg init -e repokey-blake2 ssh://
|
||||
{UNIX_USER}@{FQDN}:{SSH_SERVER_PORT}/./
|
||||
{props.selectedOption.repositoryName}
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg init -e repokey-blake2 ssh://${UNIX_USER}@${FQDN}:${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.note}>
|
||||
The encryption mode is the one recommended by BorgBackup.
|
||||
For more information,{' '}
|
||||
<a
|
||||
href='https://borgbackup.readthedocs.io/en/stable/usage/init.html?highlight=init#more-encryption-modes'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
click here
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classes.separator}></div>
|
||||
<h2>Borgmatic</h2>
|
||||
<div className={classes.description}>
|
||||
If you are using Borgmatic and have <b>already edited</b> the configuration file
|
||||
(find a sample on the step 4) :
|
||||
<br />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className={classes.code}>
|
||||
borgmatic init -e repokey-blake2
|
||||
</div>
|
||||
<CopyButton dataToCopy='borgmatic init -e repokey-blake2' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Vorta</h2>
|
||||
<div className={classes.description}>
|
||||
To "Initialize a new repository" or "Add existing repository",
|
||||
copy this into the field "Repository URL" of Vorta :
|
||||
<br />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className={classes.code}>
|
||||
ssh://
|
||||
{UNIX_USER}@{FQDN}:{SSH_SERVER_PORT}/./
|
||||
{props.selectedOption.repositoryName}
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`ssh://${UNIX_USER}@${FQDN}:${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}`}
|
||||
/>
|
||||
</div>
|
||||
For more information about the Vorta graphical client, please
|
||||
refer to{' '}
|
||||
<a
|
||||
href='https://vorta.borgbase.com/usage/remote/'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
this documentation
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
|
||||
<div className={classes.separator} />
|
||||
<div className={classes.verifyOrange}>
|
||||
<div className={classes.alert}>
|
||||
<IconAlertCircle className={classes.iconAlert} />
|
||||
<b>Check the fingerprint of server</b>
|
||||
</div>
|
||||
To check that you are talking to the right server, please make
|
||||
sure to validate one of the following key's fingerprint when you
|
||||
first connect :
|
||||
<li>
|
||||
<span className={classes.sshPublicKey}>
|
||||
ECDSA : {wizardEnv.SSH_SERVER_FINGERPRINT_ECDSA}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className={classes.sshPublicKey}>
|
||||
ED25519 : {wizardEnv.SSH_SERVER_FINGERPRINT_ED25519}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className={classes.sshPublicKey}>
|
||||
RSA : {wizardEnv.SSH_SERVER_FINGERPRINT_RSA}
|
||||
</span>
|
||||
</li>
|
||||
</div>
|
||||
|
||||
<div className={classes.verifyRed}>
|
||||
<div className={classes.alert}>
|
||||
<IconAlertCircle className={classes.iconAlert} />
|
||||
<b>Save your passphrase</b>
|
||||
</div>
|
||||
Once again, the server cannot access your encrypted backup data
|
||||
or the encryption passphrase. Remember to put your passphrase in
|
||||
your password manager when you initialise your repository.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WizardStep2;
|
||||
132
Components/WizardSteps/WizardStep2/WizardStep2.tsx
Normal file
132
Components/WizardSteps/WizardStep2/WizardStep2.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { IconAlertCircle, IconTool } from '@tabler/icons-react';
|
||||
import CopyButton from '../../UI/CopyButton/CopyButton';
|
||||
import { WizardStepProps } from '~/types';
|
||||
import classes from '../WizardStep1/WizardStep1.module.css';
|
||||
import { lanCommandOption } from '~/helpers/functions';
|
||||
|
||||
function WizardStep2(props: WizardStepProps) {
|
||||
const wizardEnv = props.wizardEnv;
|
||||
const UNIX_USER = wizardEnv?.UNIX_USER;
|
||||
//Needed to generate command for borg over LAN instead of WAN if env vars are set and option enabled.
|
||||
const { FQDN, SSH_SERVER_PORT } = lanCommandOption(wizardEnv, props.selectedRepo?.lanCommand);
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<h1>
|
||||
<IconTool className={classes.icon} />
|
||||
Initialize a repository
|
||||
</h1>
|
||||
<div className={classes.description}>
|
||||
To initialize your repository with borgbackup :
|
||||
<br />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className={classes.code}>
|
||||
borg init -e repokey-blake2 ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedRepo?.repositoryName}
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg init -e repokey-blake2 ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.note}>
|
||||
The encryption mode is the one recommended by BorgBackup. For more information,{' '}
|
||||
<a
|
||||
href='https://borgbackup.readthedocs.io/en/stable/usage/init.html?highlight=init#more-encryption-modes'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
click here
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classes.separator}></div>
|
||||
<h2>Borgmatic</h2>
|
||||
<div className={classes.description}>
|
||||
If you are using Borgmatic and have <b>already edited</b> the configuration file (find a
|
||||
sample on the step 4) :
|
||||
<br />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className={classes.code}>borgmatic init -e repokey-blake2</div>
|
||||
<CopyButton dataToCopy='borgmatic init -e repokey-blake2' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Pika, Vorta...</h2>
|
||||
<div className={classes.description}>
|
||||
To "Initialize a new repository" or "Add existing repository", copy this
|
||||
into the field "Repository URL" of your graphical client :
|
||||
<br />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className={classes.code}>
|
||||
ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedRepo?.repositoryName}
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classes.separator} />
|
||||
<div className={classes.verifyOrange}>
|
||||
<div className={classes.alert}>
|
||||
<IconAlertCircle className={classes.iconAlert} />
|
||||
<b>Check the fingerprint of server</b>
|
||||
</div>
|
||||
To check that you are talking to the right server, please make sure to validate one of the
|
||||
following key's fingerprint when you first connect :
|
||||
<li>
|
||||
<span className={classes.sshPublicKey}>
|
||||
ECDSA : {wizardEnv?.SSH_SERVER_FINGERPRINT_ECDSA}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className={classes.sshPublicKey}>
|
||||
ED25519 : {wizardEnv?.SSH_SERVER_FINGERPRINT_ED25519}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className={classes.sshPublicKey}>
|
||||
RSA : {wizardEnv?.SSH_SERVER_FINGERPRINT_RSA}
|
||||
</span>
|
||||
</li>
|
||||
</div>
|
||||
|
||||
<div className={classes.verifyRed}>
|
||||
<div className={classes.alert}>
|
||||
<IconAlertCircle className={classes.iconAlert} />
|
||||
<b>Save your passphrase</b>
|
||||
</div>
|
||||
Once again, the server cannot access your encrypted backup data or the encryption
|
||||
passphrase. Remember to put your passphrase in your password manager when you initialise
|
||||
your repository.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WizardStep2;
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
//Lib
|
||||
import React from 'react';
|
||||
import classes from '../WizardStep1/WizardStep1.module.css';
|
||||
import { IconChecks, IconPlayerPlay } from '@tabler/icons-react';
|
||||
import CopyButton from '../../UI/CopyButton/CopyButton';
|
||||
import lanCommandOption from '../../../helpers/functions/lanCommandOption';
|
||||
|
||||
function WizardStep3(props) {
|
||||
////Vars
|
||||
const wizardEnv = props.wizardEnv;
|
||||
const UNIX_USER = wizardEnv.UNIX_USER;
|
||||
//Needed to generate command for borg over LAN instead of WAN if env vars are set and option enabled.
|
||||
const { FQDN, SSH_SERVER_PORT } = lanCommandOption(
|
||||
wizardEnv,
|
||||
props.selectedOption.lanCommand
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<h1>
|
||||
<IconPlayerPlay className={classes.icon} />
|
||||
Launch a backup
|
||||
</h1>
|
||||
<div className={classes.description}>
|
||||
To launch a backup with borgbackup :
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className={classes.code}>
|
||||
borg create ssh://
|
||||
{UNIX_USER}@{FQDN}:{SSH_SERVER_PORT}/./
|
||||
{props.selectedOption.repositoryName}
|
||||
::archive1 /your/pathToBackup
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg create ssh://${UNIX_USER}@${FQDN}:${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}::archive1 /your/pathToBackup`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.separator}></div>
|
||||
<h1>
|
||||
<IconChecks className={classes.icon} />
|
||||
Check your backup{' '}
|
||||
<span style={{ color: '#494b7a4d', fontWeight: 'normal' }}>
|
||||
(always)
|
||||
</span>
|
||||
</h1>
|
||||
<div className={classes.description}>
|
||||
BorgWarehouse <b>only stores</b> your backups. They are
|
||||
encrypted and <b>there is no way</b> for BorgWarehouse to know
|
||||
if the backup is intact.
|
||||
<br />
|
||||
You should regularly test your backups and check that the data
|
||||
is recoverable.{' '}
|
||||
<b>
|
||||
BorgWarehouse cannot do this for you and does not guarantee
|
||||
anything.
|
||||
</b>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<span className={classes.description}>
|
||||
Based on the Borg documentation, you have multiple ways to check
|
||||
that your backups are correct with your tools (tar, rsync, diff
|
||||
or other tools).
|
||||
<br />
|
||||
<li>Check the integrity of a repository with :</li>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className={classes.code}>
|
||||
borg check -v --progress ssh://
|
||||
{UNIX_USER}@{FQDN}:{SSH_SERVER_PORT}/./
|
||||
{props.selectedOption.repositoryName}
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg check -v --progress ssh://${UNIX_USER}@${FQDN}:${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}`}
|
||||
/>
|
||||
</div>
|
||||
<li>List the remote archives with :</li>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className={classes.code}>
|
||||
borg list ssh://
|
||||
{UNIX_USER}@{FQDN}:{SSH_SERVER_PORT}/./
|
||||
{props.selectedOption.repositoryName}
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg list ssh://${UNIX_USER}@${FQDN}:${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}`}
|
||||
/>
|
||||
</div>
|
||||
<li>Download a remote archive with the following command :</li>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className={classes.code}>
|
||||
borg export-tar --tar-filter="gzip -9" ssh://
|
||||
{UNIX_USER}@{FQDN}:{SSH_SERVER_PORT}/./
|
||||
{props.selectedOption.repositoryName}
|
||||
::archive1 archive1.tar.gz
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg export-tar --tar-filter="gzip -9" ssh://${UNIX_USER}@${FQDN}:${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}::archive1 archive1.tar.gz`}
|
||||
/>
|
||||
</div>
|
||||
<li>
|
||||
Mount an archive to compare or backup some files without
|
||||
download all the archive :
|
||||
</li>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className={classes.code}>
|
||||
borg mount ssh://
|
||||
{UNIX_USER}@{FQDN}:{SSH_SERVER_PORT}/./
|
||||
{props.selectedOption.repositoryName}
|
||||
::archive1 /tmp/yourMountPoint
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg mount ssh://${UNIX_USER}@${FQDN}:${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}::archive1 /tmp/yourMountPoint`}
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
To verify the consistency of a repository and the corresponding
|
||||
archives, please refer to{' '}
|
||||
<a
|
||||
href='https://borgbackup.readthedocs.io/en/stable/usage/check.html'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
this documentation
|
||||
</a>
|
||||
</span>
|
||||
<div className={classes.separator}></div>
|
||||
<h2>Borgmatic</h2>
|
||||
<div className={classes.description}>
|
||||
If you are using Borgmatic, please refer to{' '}
|
||||
<a
|
||||
href='https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/#consistency-check-configuration'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
this documentation
|
||||
</a>{' '}
|
||||
for a consistency check.
|
||||
</div>
|
||||
<h2>Vorta</h2>
|
||||
<div className={classes.description}>
|
||||
If you are using the Vorta graphical client, please refer to{' '}
|
||||
<a
|
||||
href='https://vorta.borgbase.com/usage/'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
this documentation
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WizardStep3;
|
||||
174
Components/WizardSteps/WizardStep3/WizardStep3.tsx
Normal file
174
Components/WizardSteps/WizardStep3/WizardStep3.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import React from 'react';
|
||||
import classes from '../WizardStep1/WizardStep1.module.css';
|
||||
import { IconChecks, IconPlayerPlay } from '@tabler/icons-react';
|
||||
import CopyButton from '../../UI/CopyButton/CopyButton';
|
||||
import { WizardStepProps } from '~/types';
|
||||
import { lanCommandOption } from '~/helpers/functions';
|
||||
|
||||
function WizardStep3(props: WizardStepProps) {
|
||||
const wizardEnv = props.wizardEnv;
|
||||
const UNIX_USER = wizardEnv?.UNIX_USER;
|
||||
//Needed to generate command for borg over LAN instead of WAN if env vars are set and option enabled.
|
||||
const { FQDN, SSH_SERVER_PORT } = lanCommandOption(wizardEnv, props.selectedRepo?.lanCommand);
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<h1>
|
||||
<IconPlayerPlay className={classes.icon} />
|
||||
Launch a backup
|
||||
</h1>
|
||||
<div className={classes.description}>
|
||||
To launch a backup with borgbackup :
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className={classes.code}>
|
||||
borg create ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedRepo?.repositoryName}
|
||||
::archive1 /your/pathToBackup
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg create ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}::archive1 /your/pathToBackup`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.separator}></div>
|
||||
<h1>
|
||||
<IconChecks className={classes.icon} />
|
||||
Check your backup{' '}
|
||||
<span style={{ color: '#494b7a4d', fontWeight: 'normal' }}> (always)</span>
|
||||
</h1>
|
||||
<div className={classes.description}>
|
||||
BorgWarehouse <b>only stores</b> your backups. They are encrypted and <b>there is no way</b>{' '}
|
||||
for BorgWarehouse to know if the backup is intact.
|
||||
<br />
|
||||
You should regularly test your backups and check that the data is recoverable.{' '}
|
||||
<b>BorgWarehouse cannot do this for you and does not guarantee anything.</b>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<span className={classes.description}>
|
||||
Based on the Borg documentation, you have multiple ways to check that your backups are
|
||||
correct with your tools (tar, rsync, diff or other tools).
|
||||
<br />
|
||||
<li>Check the integrity of a repository with :</li>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className={classes.code}>
|
||||
borg check -v --progress ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedRepo?.repositoryName}
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg check -v --progress ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}`}
|
||||
/>
|
||||
</div>
|
||||
<li>List the remote archives with :</li>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className={classes.code}>
|
||||
borg list ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedRepo?.repositoryName}
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg list ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}`}
|
||||
/>
|
||||
</div>
|
||||
<li>Download a remote archive with the following command :</li>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className={classes.code}>
|
||||
borg export-tar --tar-filter="gzip -9" ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedRepo?.repositoryName}
|
||||
::archive1 archive1.tar.gz
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg export-tar --tar-filter="gzip -9" ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}::archive1 archive1.tar.gz`}
|
||||
/>
|
||||
</div>
|
||||
<li>Mount an archive to compare or backup some files without download all the archive :</li>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className={classes.code}>
|
||||
borg mount ssh://
|
||||
{UNIX_USER}@{FQDN}
|
||||
{SSH_SERVER_PORT}/./
|
||||
{props.selectedRepo?.repositoryName}
|
||||
::archive1 /tmp/yourMountPoint
|
||||
</div>
|
||||
<CopyButton
|
||||
dataToCopy={`borg mount ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}::archive1 /tmp/yourMountPoint`}
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
To verify the consistency of a repository and the corresponding archives, please refer to{' '}
|
||||
<a
|
||||
href='https://borgbackup.readthedocs.io/en/stable/usage/check.html'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
this documentation
|
||||
</a>
|
||||
</span>
|
||||
<div className={classes.separator}></div>
|
||||
<h2>Borgmatic</h2>
|
||||
<div className={classes.description}>
|
||||
If you are using Borgmatic, please refer to{' '}
|
||||
<a
|
||||
href='https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/#consistency-check-configuration'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
this documentation
|
||||
</a>{' '}
|
||||
for a consistency check.
|
||||
</div>
|
||||
<h2>Pika, Vorta...</h2>
|
||||
<div className={classes.description}>
|
||||
If you are using the Vorta graphical client, please refer to{' '}
|
||||
<a href='https://vorta.borgbase.com/usage/' rel='noreferrer' target='_blank'>
|
||||
this documentation
|
||||
</a>
|
||||
.<br />
|
||||
If you are using Pika, please refer to{' '}
|
||||
<a href='https://apps.gnome.org/PikaBackup/' rel='noreferrer' target='_blank'>
|
||||
this documentation
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WizardStep3;
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
//Lib
|
||||
import React from 'react';
|
||||
import classes from '../WizardStep1/WizardStep1.module.css';
|
||||
import { IconWand } from '@tabler/icons-react';
|
||||
import CopyButton from '../../UI/CopyButton/CopyButton';
|
||||
import lanCommandOption from '../../../helpers/functions/lanCommandOption';
|
||||
|
||||
function WizardStep4(props) {
|
||||
////Vars
|
||||
const wizardEnv = props.wizardEnv;
|
||||
const UNIX_USER = wizardEnv.UNIX_USER;
|
||||
//Needed to generate command for borg over LAN instead of WAN if env vars are set and option enabled.
|
||||
const { FQDN, SSH_SERVER_PORT } = lanCommandOption(
|
||||
wizardEnv,
|
||||
props.selectedOption.lanCommand
|
||||
);
|
||||
|
||||
const configBorgmatic = `location:
|
||||
# List of source directories to backup.
|
||||
source_directories:
|
||||
- /your-repo-to-backup
|
||||
- /another/repo-to-backup
|
||||
|
||||
repositories:
|
||||
# Paths of local or remote repositories to backup to.
|
||||
- ssh://${UNIX_USER}@${FQDN}:${SSH_SERVER_PORT}/./${props.selectedOption.repositoryName}
|
||||
|
||||
storage:
|
||||
archive_name_format: '{FQDN}-documents-{now}'
|
||||
encryption_passphrase: "YOUR PASSPHRASE"
|
||||
|
||||
retention:
|
||||
# Retention policy for how many backups to keep.
|
||||
keep_daily: 7
|
||||
keep_weekly: 4
|
||||
keep_monthly: 6
|
||||
|
||||
consistency:
|
||||
# List of checks to run to validate your backups.
|
||||
checks:
|
||||
- name: repository
|
||||
- name: archives
|
||||
frequency: 2 weeks
|
||||
|
||||
#hooks:
|
||||
# Custom preparation scripts to run.
|
||||
#before_backup:
|
||||
# - prepare-for-backup.sh
|
||||
|
||||
# Databases to dump and include in backups.
|
||||
#postgresql_databases:
|
||||
# - name: users
|
||||
|
||||
# Third-party services to notify you if backups aren't happening.
|
||||
#healthchecks: https://hc-ping.com/be067061-cf96-4412-8eae-62b0c50d6a8c`;
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<h1>
|
||||
<IconWand className={classes.icon} />
|
||||
Automate your backup
|
||||
</h1>
|
||||
<div className={classes.description}>
|
||||
The official borgbackup project provides a script in its
|
||||
documentation
|
||||
<a
|
||||
href='https://borgbackup.readthedocs.io/en/stable/quickstart.html#automating-backups'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
right here
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
|
||||
<div className={classes.separator} />
|
||||
<h2>Vorta</h2>
|
||||
<div className={classes.description}>
|
||||
If you are using the Vorta graphical client, please refer
|
||||
to
|
||||
<a
|
||||
href='https://vorta.borgbase.com/usage/#scheduling-automatic-backups'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
this documentation
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
|
||||
<h2>Borgmatic</h2>
|
||||
<div className={classes.description}>
|
||||
If you are using Borgmatic, you can check
|
||||
<a
|
||||
href='https://torsion.org/borgmatic/docs/how-to/set-up-backups/#autopilot'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
this documentation
|
||||
</a>
|
||||
and <b>adapt</b> and use the following script :
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className={classes.code}>{configBorgmatic}</div>
|
||||
<div
|
||||
style={{
|
||||
margin: '15px 0 auto 0',
|
||||
display: 'flex',
|
||||
alignContent: 'center',
|
||||
}}
|
||||
>
|
||||
<CopyButton dataToCopy={configBorgmatic} size={32} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WizardStep4;
|
||||
125
Components/WizardSteps/WizardStep4/WizardStep4.tsx
Normal file
125
Components/WizardSteps/WizardStep4/WizardStep4.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import React from 'react';
|
||||
import classes from '../WizardStep1/WizardStep1.module.css';
|
||||
import { IconWand } from '@tabler/icons-react';
|
||||
import CopyButton from '../../UI/CopyButton/CopyButton';
|
||||
import { WizardStepProps } from '~/types';
|
||||
import { lanCommandOption } from '~/helpers/functions';
|
||||
|
||||
function WizardStep4(props: WizardStepProps) {
|
||||
const wizardEnv = props.wizardEnv;
|
||||
const UNIX_USER = wizardEnv?.UNIX_USER;
|
||||
//Needed to generate command for borg over LAN instead of WAN if env vars are set and option enabled.
|
||||
const { FQDN, SSH_SERVER_PORT } = lanCommandOption(wizardEnv, props.selectedRepo?.lanCommand);
|
||||
|
||||
const configBorgmatic = `
|
||||
# List of source directories to backup.
|
||||
source_directories:
|
||||
- /your-repo-to-backup
|
||||
- /another/repo-to-backup
|
||||
|
||||
repositories:
|
||||
# Paths of local or remote repositories to backup to.
|
||||
- path: ssh://${UNIX_USER}@${FQDN}${SSH_SERVER_PORT}/./${props.selectedRepo?.repositoryName}
|
||||
|
||||
archive_name_format: '{FQDN}-documents-{now}'
|
||||
encryption_passphrase: "YOUR PASSPHRASE"
|
||||
|
||||
# Retention policy for how many backups to keep.
|
||||
keep_daily: 7
|
||||
keep_weekly: 4
|
||||
keep_monthly: 6
|
||||
|
||||
# List of checks to run to validate your backups.
|
||||
checks:
|
||||
- name: repository
|
||||
- name: archives
|
||||
frequency: 2 weeks
|
||||
|
||||
#hooks:
|
||||
# Custom preparation scripts to run.
|
||||
#before_backup:
|
||||
# - prepare-for-backup.sh
|
||||
|
||||
# Databases to dump and include in backups.
|
||||
#postgresql_databases:
|
||||
# - name: users
|
||||
|
||||
# Third-party services to notify you if backups aren't happening.
|
||||
#healthchecks: https://hc-ping.com/be067061-cf96-4412-8eae-62b0c50d6a8c`;
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<h1>
|
||||
<IconWand className={classes.icon} />
|
||||
Automate your backup
|
||||
</h1>
|
||||
<div className={classes.description}>
|
||||
The official borgbackup project provides a script in its documentation
|
||||
<a
|
||||
href='https://borgbackup.readthedocs.io/en/stable/quickstart.html#automating-backups'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
right here
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
|
||||
<div className={classes.separator} />
|
||||
<h2>Pika, Vorta...</h2>
|
||||
<div className={classes.description}>
|
||||
If you are using the Vorta graphical client, please refer to
|
||||
<a
|
||||
href='https://vorta.borgbase.com/usage/#scheduling-automatic-backups'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
this documentation
|
||||
</a>
|
||||
.<br />
|
||||
If you are using Pika Backup, please refer to
|
||||
<a
|
||||
href='https://world.pages.gitlab.gnome.org/pika-backup/help/C/feature-schedule.html'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
this documentation
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
|
||||
<h2>Borgmatic</h2>
|
||||
<div className={classes.description}>
|
||||
If you are using Borgmatic, you can check
|
||||
<a
|
||||
href='https://torsion.org/borgmatic/docs/how-to/set-up-backups/#autopilot'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
this documentation
|
||||
</a>
|
||||
and <b>adapt</b> and use the following script :
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className={classes.code}>{configBorgmatic}</div>
|
||||
<div
|
||||
style={{
|
||||
margin: '15px 0 auto 0',
|
||||
display: 'flex',
|
||||
alignContent: 'center',
|
||||
}}
|
||||
>
|
||||
<CopyButton dataToCopy={configBorgmatic} size={32} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WizardStep4;
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
//Lib
|
||||
import React from 'react';
|
||||
import classes from './WizardStepBar.module.css';
|
||||
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
|
||||
|
||||
function WizardStepBar(props) {
|
||||
////Functions
|
||||
//Color onClick on a step
|
||||
const colorHandler = (step) => {
|
||||
if (step <= props.step) {
|
||||
return classes.active;
|
||||
} else {
|
||||
return classes.inactive;
|
||||
}
|
||||
};
|
||||
//Color onClick on next step button
|
||||
const colorChevronNextStep = () => {
|
||||
if (props.step < 4) {
|
||||
return classes.activeChevron;
|
||||
} else {
|
||||
return classes.inactiveChevron;
|
||||
}
|
||||
};
|
||||
//Color onClick on previous step button
|
||||
const colorChevronPreviousStep = () => {
|
||||
if (props.step > 1) {
|
||||
return classes.activeChevron;
|
||||
} else {
|
||||
return classes.inactiveChevron;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.stepBarContainer}>
|
||||
<IconChevronLeft
|
||||
size={32}
|
||||
className={colorChevronPreviousStep()}
|
||||
onClick={props.previousStepHandler}
|
||||
/>
|
||||
<ul>
|
||||
<li
|
||||
className={colorHandler(2)}
|
||||
onClick={() => props.setStep(1)}
|
||||
>
|
||||
<div
|
||||
className={[classes.number, colorHandler(1)].join(' ')}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<div className={[classes.text, colorHandler(1)].join(' ')}>
|
||||
Client Setup
|
||||
</div>
|
||||
<div className={classes.line}></div>
|
||||
</li>
|
||||
<li
|
||||
className={colorHandler(3)}
|
||||
onClick={() => props.setStep(2)}
|
||||
>
|
||||
<div
|
||||
className={[classes.number, colorHandler(2)].join(' ')}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<div className={[classes.text, colorHandler(2)].join(' ')}>
|
||||
Init. repository
|
||||
</div>
|
||||
<div className={classes.line}></div>
|
||||
</li>
|
||||
<li
|
||||
className={colorHandler(4)}
|
||||
onClick={() => props.setStep(3)}
|
||||
>
|
||||
<div
|
||||
className={[classes.number, colorHandler(3)].join(' ')}
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<div className={[classes.text, colorHandler(3)].join(' ')}>
|
||||
Launch & Verify
|
||||
</div>
|
||||
<div className={classes.line}></div>
|
||||
</li>
|
||||
<li onClick={() => props.setStep(4)}>
|
||||
<div
|
||||
className={[classes.number, colorHandler(4)].join(' ')}
|
||||
>
|
||||
4
|
||||
</div>
|
||||
<div className={[classes.text, colorHandler(4)].join(' ')}>
|
||||
Automation
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<IconChevronRight
|
||||
size={32}
|
||||
className={colorChevronNextStep()}
|
||||
onClick={props.nextStepHandler}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WizardStepBar;
|
||||
|
|
@ -1,94 +1,94 @@
|
|||
/* General */
|
||||
|
||||
.stepBarContainer {
|
||||
text-transform: uppercase;
|
||||
color: #494b7a;
|
||||
font-size: 1em;
|
||||
margin-top: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
color: #494b7a;
|
||||
font-size: 1em;
|
||||
margin-top: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stepBarContainer li {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
margin: 0px 30px;
|
||||
width: 180px;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
margin: 0px 30px;
|
||||
width: 180px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stepBarContainer ul {
|
||||
padding-top: 10px;
|
||||
margin: 0;
|
||||
padding-inline-start: 0px;
|
||||
padding-top: 10px;
|
||||
margin: 0;
|
||||
padding-inline-start: 0px;
|
||||
}
|
||||
|
||||
/* Transition Active / Inactive */
|
||||
|
||||
.number {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
color: #494b7a4d;
|
||||
background: #fff;
|
||||
border: 1px solid #494b7a4d;
|
||||
border-radius: 50%;
|
||||
width: 37px;
|
||||
padding: 8px 5px;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 700;
|
||||
z-index: 1;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
color: #494b7a4d;
|
||||
background: #fff;
|
||||
border: 1px solid #494b7a4d;
|
||||
border-radius: 50%;
|
||||
width: 37px;
|
||||
padding: 8px 5px;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 700;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.active.number {
|
||||
box-shadow: 0 0 15px 5px rgba(110, 74, 255, 0.405);
|
||||
color: #fff;
|
||||
background: #6d4aff;
|
||||
transition: all 1000ms cubic-bezier(0.25, 0.25, 0.75, 0.75);
|
||||
transition-delay: 500ms;
|
||||
box-shadow: 0 0 15px 5px rgba(110, 74, 255, 0.405);
|
||||
color: #fff;
|
||||
background: #6d4aff;
|
||||
transition: all 1000ms cubic-bezier(0.25, 0.25, 0.75, 0.75);
|
||||
transition-delay: 500ms;
|
||||
}
|
||||
|
||||
.text {
|
||||
padding: 8px 0 0 0;
|
||||
color: #494b7a4d;
|
||||
padding: 8px 0 0 0;
|
||||
color: #494b7a4d;
|
||||
}
|
||||
|
||||
.active.text {
|
||||
transition: all 1000ms cubic-bezier(0.25, 0.25, 0.75, 0.75);
|
||||
transition-delay: 500ms;
|
||||
color: #494b7a;
|
||||
transition: all 1000ms cubic-bezier(0.25, 0.25, 0.75, 0.75);
|
||||
transition-delay: 500ms;
|
||||
color: #494b7a;
|
||||
}
|
||||
|
||||
.line {
|
||||
background: #6d4aff;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
position: absolute;
|
||||
top: 17px;
|
||||
left: 108px;
|
||||
z-index: 0;
|
||||
transition: all 500ms cubic-bezier(0.25, 0.25, 0.75, 0.75);
|
||||
background: #6d4aff;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
position: absolute;
|
||||
top: 17px;
|
||||
left: 108px;
|
||||
z-index: 0;
|
||||
transition: all 500ms cubic-bezier(0.25, 0.25, 0.75, 0.75);
|
||||
}
|
||||
|
||||
.active .line {
|
||||
background: #6d4aff;
|
||||
width: 204px;
|
||||
height: 2px;
|
||||
position: absolute;
|
||||
top: 17px;
|
||||
left: 108px;
|
||||
z-index: 0;
|
||||
background: #6d4aff;
|
||||
width: 204px;
|
||||
height: 2px;
|
||||
position: absolute;
|
||||
top: 17px;
|
||||
left: 108px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.activeChevron {
|
||||
cursor: pointer;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.activeChevron:hover {
|
||||
color: #6d4aff;
|
||||
color: #6d4aff;
|
||||
}
|
||||
|
||||
.inactiveChevron {
|
||||
color: #494b7a4d;
|
||||
color: #494b7a4d;
|
||||
}
|
||||
|
|
|
|||
75
Components/WizardSteps/WizardStepBar/WizardStepBar.tsx
Normal file
75
Components/WizardSteps/WizardStepBar/WizardStepBar.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import React from 'react';
|
||||
import classes from './WizardStepBar.module.css';
|
||||
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
|
||||
|
||||
type WizardStepBarProps = {
|
||||
step: number;
|
||||
setStep: (step: number) => void;
|
||||
previousStepHandler: () => void;
|
||||
nextStepHandler: () => void;
|
||||
};
|
||||
|
||||
function WizardStepBar(props: WizardStepBarProps) {
|
||||
//Color onClick on a step
|
||||
const colorHandler = (step: number) => {
|
||||
if (step <= props.step) {
|
||||
return classes.active;
|
||||
} else {
|
||||
return classes.inactive;
|
||||
}
|
||||
};
|
||||
//Color onClick on next step button
|
||||
const colorChevronNextStep = () => {
|
||||
if (props.step < 4) {
|
||||
return classes.activeChevron;
|
||||
} else {
|
||||
return classes.inactiveChevron;
|
||||
}
|
||||
};
|
||||
//Color onClick on previous step button
|
||||
const colorChevronPreviousStep = () => {
|
||||
if (props.step > 1) {
|
||||
return classes.activeChevron;
|
||||
} else {
|
||||
return classes.inactiveChevron;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.stepBarContainer}>
|
||||
<IconChevronLeft
|
||||
size={32}
|
||||
className={colorChevronPreviousStep()}
|
||||
onClick={props.previousStepHandler}
|
||||
/>
|
||||
<ul>
|
||||
<li className={colorHandler(2)} onClick={() => props.setStep(1)}>
|
||||
<div className={[classes.number, colorHandler(1)].join(' ')}>1</div>
|
||||
<div className={[classes.text, colorHandler(1)].join(' ')}>Client Setup</div>
|
||||
<div className={classes.line}></div>
|
||||
</li>
|
||||
<li className={colorHandler(3)} onClick={() => props.setStep(2)}>
|
||||
<div className={[classes.number, colorHandler(2)].join(' ')}>2</div>
|
||||
<div className={[classes.text, colorHandler(2)].join(' ')}>Init. repository</div>
|
||||
<div className={classes.line}></div>
|
||||
</li>
|
||||
<li className={colorHandler(4)} onClick={() => props.setStep(3)}>
|
||||
<div className={[classes.number, colorHandler(3)].join(' ')}>3</div>
|
||||
<div className={[classes.text, colorHandler(3)].join(' ')}>Launch & Verify</div>
|
||||
<div className={classes.line}></div>
|
||||
</li>
|
||||
<li onClick={() => props.setStep(4)}>
|
||||
<div className={[classes.number, colorHandler(4)].join(' ')}>4</div>
|
||||
<div className={[classes.text, colorHandler(4)].join(' ')}>Automation</div>
|
||||
</li>
|
||||
</ul>
|
||||
<IconChevronRight
|
||||
size={32}
|
||||
className={colorChevronNextStep()}
|
||||
onClick={props.nextStepHandler}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WizardStepBar;
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
//Lib
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'chart.js';
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export default function StorageUsedChartBar() {
|
||||
//States
|
||||
const [data, setData] = useState([]);
|
||||
|
||||
//LifeCycle
|
||||
useEffect(() => {
|
||||
const dataFetch = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/repo', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
setData((await response.json()).repoList);
|
||||
} catch (error) {
|
||||
console.log('Fetching datas error');
|
||||
}
|
||||
};
|
||||
|
||||
dataFetch();
|
||||
}, []);
|
||||
|
||||
////Chart.js
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
},
|
||||
title: {
|
||||
position: 'bottom',
|
||||
display: true,
|
||||
text: 'Storage used for each repository',
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
max: 100,
|
||||
min: 0,
|
||||
ticks: {
|
||||
// Include a dollar sign in the ticks
|
||||
callback: function (value) {
|
||||
return value + '%';
|
||||
},
|
||||
stepSize: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const labels = data.map((repo) => repo.alias);
|
||||
|
||||
const dataChart = {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Storage used (%)',
|
||||
//storageUsed is in octet, storageSize is in GB. Round to 1 decimal for %.
|
||||
data: data.map((repo) =>
|
||||
(
|
||||
((repo.storageUsed / 1000000) * 100) /
|
||||
repo.storageSize
|
||||
).toFixed(1)
|
||||
),
|
||||
backgroundColor: '#704dff',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return <Bar options={options} data={dataChart} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'chart.js';
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Repository, Optional } from '~/types';
|
||||
|
||||
export default function StorageUsedChartBar() {
|
||||
const [data, setData] = useState<Optional<Array<Repository>>>();
|
||||
|
||||
useEffect(() => {
|
||||
const dataFetch = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/repositories', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
setData((await response.json()).repoList);
|
||||
} catch (error) {
|
||||
console.log('Fetching datas error');
|
||||
}
|
||||
};
|
||||
|
||||
dataFetch();
|
||||
}, []);
|
||||
|
||||
////Chart.js
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom' as const,
|
||||
},
|
||||
title: {
|
||||
position: 'bottom' as const,
|
||||
display: true,
|
||||
text: 'Storage used for each repository',
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
max: 100,
|
||||
min: 0,
|
||||
ticks: {
|
||||
// Include a dollar sign in the ticks
|
||||
callback: function (value: number | string) {
|
||||
return value + '%';
|
||||
},
|
||||
stepSize: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const labels = data?.map((repo) => repo.alias);
|
||||
|
||||
const dataChart = {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Storage used (%)',
|
||||
//storageUsed is in kB, storageSize is in GB. Round to 1 decimal for %.
|
||||
data: data?.map((repo) =>
|
||||
(((repo.storageUsed / 1024 ** 2) * 100) / repo.storageSize).toFixed(1)
|
||||
),
|
||||
backgroundColor: '#704dff',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return <Bar options={options} data={dataChart} />;
|
||||
}
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
//Lib
|
||||
import classes from './RepoList.module.css';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import useSWR, { useSWRConfig } from 'swr';
|
||||
import { ToastContainer, toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
//Composants
|
||||
import Repo from '../../Components/Repo/Repo';
|
||||
import RepoManage from '../RepoManage/RepoManage';
|
||||
import ShimmerRepoList from '../../Components/UI/ShimmerRepoList/ShimmerRepoList';
|
||||
|
||||
export default function RepoList() {
|
||||
////Var
|
||||
const router = useRouter();
|
||||
const { mutate } = useSWRConfig();
|
||||
const toastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 8000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
};
|
||||
|
||||
////Datas
|
||||
//Write a fetcher function to wrap the native fetch function and return the result of a call to url in json format
|
||||
const fetcher = async (url) => await fetch(url).then((res) => res.json());
|
||||
const { data, error } = useSWR('/api/repo', fetcher);
|
||||
|
||||
////LifeCycle
|
||||
//Component did mount
|
||||
useEffect(() => {
|
||||
//If the route is home/manage-repo/add, open the RepoAdd box.
|
||||
if (router.pathname === '/manage-repo/add') {
|
||||
setDisplayRepoAdd(!displayRepoAdd);
|
||||
}
|
||||
//If the route is home/manage-repo/edit, open the RepoAdd box.
|
||||
if (router.pathname.startsWith('/manage-repo/edit')) {
|
||||
setDisplayRepoEdit(!displayRepoEdit);
|
||||
}
|
||||
//Fetch wizardEnv to hydrate Repo components
|
||||
const fetchWizardEnv = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/account/getWizardEnv', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
setWizardEnv((await response.json()).wizardEnv);
|
||||
} catch (error) {
|
||||
console.log('Fetching datas error');
|
||||
}
|
||||
};
|
||||
fetchWizardEnv();
|
||||
}, []);
|
||||
|
||||
////States
|
||||
const [displayRepoAdd, setDisplayRepoAdd] = useState(false);
|
||||
const [displayRepoEdit, setDisplayRepoEdit] = useState(false);
|
||||
const [wizardEnv, setWizardEnv] = useState({});
|
||||
|
||||
////Functions
|
||||
|
||||
//Firstly, check the availability of data and condition it.
|
||||
if (!data) {
|
||||
//Force mutate after login (force a API GET on /api/repo to load repoList)
|
||||
mutate('/api/repo');
|
||||
return <ShimmerRepoList />;
|
||||
}
|
||||
if (error) {
|
||||
toast.error('An error has occurred.', toastOptions);
|
||||
return <ToastContainer />;
|
||||
}
|
||||
if (data.status == 500) {
|
||||
toast.error('API Error !', toastOptions);
|
||||
return <ToastContainer />;
|
||||
}
|
||||
|
||||
//BUTTON : Display RepoManage component box for ADD
|
||||
const manageRepoAddHandler = () => {
|
||||
router.replace('/manage-repo/add');
|
||||
};
|
||||
|
||||
//BUTTON : Display RepoManage component box for EDIT
|
||||
const repoManageEditHandler = (id) => {
|
||||
router.replace('/manage-repo/edit/' + id);
|
||||
};
|
||||
|
||||
//BUTTON : Close RepoManage component box (when cross is clicked)
|
||||
const closeRepoManageBoxHandler = () => {
|
||||
router.replace('/');
|
||||
};
|
||||
|
||||
// UI EFFECT : Display blur when display add repo modale
|
||||
const displayBlur = () => {
|
||||
if (displayRepoAdd || displayRepoEdit) {
|
||||
return classes.containerBlur;
|
||||
} else {
|
||||
return classes.container;
|
||||
}
|
||||
};
|
||||
|
||||
//Dynamic list of repositories (with a map of Repo components)
|
||||
const renderRepoList = data.repoList.map((repo, index) => {
|
||||
return (
|
||||
<>
|
||||
<Repo
|
||||
key={repo.id}
|
||||
id={repo.id}
|
||||
alias={repo.alias}
|
||||
status={repo.status}
|
||||
lastSave={repo.lastSave}
|
||||
alert={repo.alert}
|
||||
repositoryName={repo.repositoryName}
|
||||
storageSize={repo.storageSize}
|
||||
storageUsed={repo.storageUsed}
|
||||
sshPublicKey={repo.sshPublicKey}
|
||||
comment={repo.comment}
|
||||
lanCommand={repo.lanCommand}
|
||||
appendOnlyMode={repo.appendOnlyMode}
|
||||
repoManageEditHandler={() => repoManageEditHandler(repo.id)}
|
||||
wizardEnv={wizardEnv}
|
||||
></Repo>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={displayBlur()}>
|
||||
<div className={classes.containerAddRepo}>
|
||||
<Link
|
||||
href='/manage-repo/add'
|
||||
className={classes.newRepoButton}
|
||||
onClick={manageRepoAddHandler}
|
||||
>
|
||||
<IconPlus
|
||||
className={classes.plusIcon}
|
||||
size={24}
|
||||
stroke={2}
|
||||
/>
|
||||
<span>Add a repository</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className={classes.containerRepoList}>
|
||||
<div className={classes.RepoList}>{renderRepoList}</div>
|
||||
</div>
|
||||
</div>
|
||||
{displayRepoAdd ? (
|
||||
<RepoManage
|
||||
mode='add'
|
||||
repoList={data.repoList}
|
||||
closeHandler={closeRepoManageBoxHandler}
|
||||
/>
|
||||
) : null}
|
||||
{displayRepoEdit ? (
|
||||
<RepoManage
|
||||
mode='edit'
|
||||
repoList={data.repoList}
|
||||
closeHandler={closeRepoManageBoxHandler}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,125 +1,198 @@
|
|||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.containerBlur {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
filter: blur(3px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
filter: blur(3px);
|
||||
}
|
||||
|
||||
.containerRepoList {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.containerAddRepo {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 20px auto;
|
||||
width: 90%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 20px auto;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.newRepoButton {
|
||||
position: relative;
|
||||
margin: auto;
|
||||
padding: 19px 22px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
margin: auto;
|
||||
padding: 19px 22px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.newRepoButton:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 3px;
|
||||
display: block;
|
||||
border-radius: 28px;
|
||||
background: #6d4aff;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
transition: all 0.3s ease;
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 3px;
|
||||
display: block;
|
||||
border-radius: 28px;
|
||||
background: #6d4aff;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.newRepoButton span {
|
||||
position: relative;
|
||||
font-size: 16px;
|
||||
line-height: 18px;
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
padding-left: 15px;
|
||||
color: #494b7a;
|
||||
position: relative;
|
||||
font-size: 16px;
|
||||
line-height: 18px;
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
padding-left: 15px;
|
||||
color: #494b7a;
|
||||
}
|
||||
.newRepoButton .plusIcon {
|
||||
position: relative;
|
||||
top: 0;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke: #fff;
|
||||
transform: translateX(-5px);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
top: 0;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke: #fff;
|
||||
transform: translateX(-5px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.newRepoButton:hover:before {
|
||||
width: 100%;
|
||||
background: #6d4aff;
|
||||
width: 100%;
|
||||
background: #6d4aff;
|
||||
}
|
||||
.newRepoButton:hover span {
|
||||
color: #fff;
|
||||
color: #fff;
|
||||
}
|
||||
.newRepoButton:hover .plusIcon {
|
||||
transform: translateX(0);
|
||||
transform: translateX(0);
|
||||
}
|
||||
.newRepoButton:active {
|
||||
transform: scale(0.96);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.RepoList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 90%;
|
||||
margin: 5px auto;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 90%;
|
||||
margin: 5px auto;
|
||||
}
|
||||
|
||||
.unfoldButton {
|
||||
cursor: pointer;
|
||||
position: sticky;
|
||||
color: #a6a6b8;
|
||||
padding-top: 49px;
|
||||
align-self: flex-start;
|
||||
top: 0;
|
||||
cursor: pointer;
|
||||
position: sticky;
|
||||
color: #a6a6b8;
|
||||
padding-top: 49px;
|
||||
align-self: flex-start;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.foldButton {
|
||||
cursor: pointer;
|
||||
position: sticky;
|
||||
color: #a6a6b8;
|
||||
padding-top: 49px;
|
||||
align-self: flex-start;
|
||||
top: 0;
|
||||
cursor: pointer;
|
||||
position: sticky;
|
||||
color: #a6a6b8;
|
||||
padding-top: 49px;
|
||||
align-self: flex-start;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.unfoldButton:active,
|
||||
.foldButton:active {
|
||||
transform: scale(0.96);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
@media all and (max-width: 1000px) {
|
||||
.newRepoButton {
|
||||
display: none;
|
||||
}
|
||||
.chevron {
|
||||
display: none;
|
||||
}
|
||||
.containerAddRepo {
|
||||
display: none;
|
||||
}
|
||||
.newRepoButton {
|
||||
display: none;
|
||||
}
|
||||
.chevron {
|
||||
display: none;
|
||||
}
|
||||
.containerAddRepo {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 90%;
|
||||
margin: 20px auto 10px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.sortIcons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
cursor: pointer;
|
||||
color: #a6a6b8;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.icon:hover {
|
||||
transform: scale(1.1);
|
||||
color: #6d4aff;
|
||||
}
|
||||
|
||||
.iconActive {
|
||||
color: #6d4aff;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
padding: 8px 32px 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ccc;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.clearButton:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
|
|
|||
279
Containers/RepoList/RepoList.tsx
Normal file
279
Containers/RepoList/RepoList.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import classes from './RepoList.module.css';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSortAscendingLetters,
|
||||
IconSortDescendingLetters,
|
||||
IconSortAscending2,
|
||||
IconSortDescending2,
|
||||
IconDatabase,
|
||||
IconX,
|
||||
IconClock,
|
||||
IconCalendarUp,
|
||||
IconCalendarDown,
|
||||
IconSortAscendingSmallBig,
|
||||
IconSortDescendingSmallBig,
|
||||
IconSortDescending2Filled,
|
||||
} from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import useSWR, { useSWRConfig } from 'swr';
|
||||
import { ToastContainer, ToastOptions, toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
import Repo from '~/Components/Repo/Repo';
|
||||
import RepoManage from '../RepoManage/RepoManage';
|
||||
import ShimmerRepoList from '~/Components/UI/ShimmerRepoList/ShimmerRepoList';
|
||||
import { Repository, WizardEnvType, Optional } from '~/types';
|
||||
|
||||
type SortOption =
|
||||
| 'alias-asc'
|
||||
| 'alias-desc'
|
||||
| 'status-true'
|
||||
| 'status-false'
|
||||
| 'storage-used-asc'
|
||||
| 'storage-used-desc'
|
||||
| 'last-save-asc'
|
||||
| 'last-save-desc';
|
||||
|
||||
export default function RepoList() {
|
||||
const router = useRouter();
|
||||
const { mutate } = useSWRConfig();
|
||||
const [displayRepoAdd, setDisplayRepoAdd] = useState(false);
|
||||
const [displayRepoEdit, setDisplayRepoEdit] = useState(false);
|
||||
const [wizardEnv, setWizardEnv] = useState<Optional<WizardEnvType>>();
|
||||
|
||||
const [sortOption, setSortOption] = useState<SortOption>(() => {
|
||||
const savedSort = localStorage.getItem('repoSort');
|
||||
return (savedSort as SortOption) || 'alias-asc';
|
||||
});
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(() => {
|
||||
const savedSearch = localStorage.getItem('repoSearch');
|
||||
return savedSearch || '';
|
||||
});
|
||||
|
||||
const toastOptions: ToastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 8000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setDisplayRepoAdd(router.pathname === '/manage-repo/add');
|
||||
setDisplayRepoEdit(router.pathname.startsWith('/manage-repo/edit'));
|
||||
|
||||
const fetchWizardEnv = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/account/wizard-env');
|
||||
const data: WizardEnvType = await response.json();
|
||||
setWizardEnv(data);
|
||||
} catch (error) {
|
||||
console.log('Fetching wizard env error');
|
||||
}
|
||||
};
|
||||
fetchWizardEnv();
|
||||
}, [router.pathname]);
|
||||
|
||||
const fetcher = async (url: string) => await fetch(url).then((res) => res.json());
|
||||
const { data, error } = useSWR('/api/v1/repositories', fetcher);
|
||||
|
||||
if (!data) {
|
||||
mutate('/api/v1/repositories');
|
||||
return <ShimmerRepoList />;
|
||||
}
|
||||
|
||||
if (error || data.status == 500) {
|
||||
toast.error('Error loading repositories.', toastOptions);
|
||||
return <ToastContainer />;
|
||||
}
|
||||
|
||||
const handleSortChange = (option: SortOption) => {
|
||||
setSortOption(option);
|
||||
localStorage.setItem('repoSort', option);
|
||||
};
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const query = e.target.value;
|
||||
setSearchQuery(query);
|
||||
localStorage.setItem('repoSearch', query);
|
||||
};
|
||||
|
||||
const getSortedRepoList = () => {
|
||||
let repoList = [...data.repoList];
|
||||
|
||||
// Filter
|
||||
if (searchQuery) {
|
||||
repoList = repoList.filter((repo) =>
|
||||
`${repo.alias} ${repo.comment} ${repo.repositoryName}`
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
switch (sortOption) {
|
||||
case 'alias-asc':
|
||||
return repoList.sort((a, b) => a.alias.localeCompare(b.alias));
|
||||
case 'alias-desc':
|
||||
return repoList.sort((a, b) => b.alias.localeCompare(a.alias));
|
||||
case 'status-true':
|
||||
return repoList.sort((a, b) => Number(b.status) - Number(a.status));
|
||||
case 'status-false':
|
||||
return repoList.sort((a, b) => Number(a.status) - Number(b.status));
|
||||
case 'storage-used-asc':
|
||||
return repoList.sort((a, b) => {
|
||||
const aRatio = a.storageSize ? a.storageUsed / a.storageSize : 0;
|
||||
const bRatio = b.storageSize ? b.storageUsed / b.storageSize : 0;
|
||||
return aRatio - bRatio;
|
||||
});
|
||||
case 'storage-used-desc':
|
||||
return repoList.sort((a, b) => {
|
||||
const aRatio = a.storageSize ? a.storageUsed / a.storageSize : 0;
|
||||
const bRatio = b.storageSize ? b.storageUsed / b.storageSize : 0;
|
||||
return bRatio - aRatio;
|
||||
});
|
||||
case 'last-save-asc':
|
||||
return repoList.sort((a, b) => {
|
||||
const aDate = a.lastSave ? new Date(a.lastSave).getTime() : 0;
|
||||
const bDate = b.lastSave ? new Date(b.lastSave).getTime() : 0;
|
||||
return aDate - bDate;
|
||||
});
|
||||
case 'last-save-desc':
|
||||
return repoList.sort((a, b) => {
|
||||
const aDate = a.lastSave ? new Date(a.lastSave).getTime() : 0;
|
||||
const bDate = b.lastSave ? new Date(b.lastSave).getTime() : 0;
|
||||
return bDate - aDate;
|
||||
});
|
||||
default:
|
||||
return repoList;
|
||||
}
|
||||
};
|
||||
|
||||
const manageRepoAddHandler = () => router.replace('/manage-repo/add');
|
||||
const manageRepoEditHandler = (id: number) => router.replace('/manage-repo/edit/' + id);
|
||||
const closeRepoManageBoxHandler = () => router.replace('/');
|
||||
const displayBlur = () =>
|
||||
displayRepoAdd || displayRepoEdit ? classes.containerBlur : classes.container;
|
||||
|
||||
const renderRepoList = getSortedRepoList().map((repo: Repository) => (
|
||||
<Repo
|
||||
key={repo.id}
|
||||
id={repo.id}
|
||||
alias={repo.alias}
|
||||
status={repo.status}
|
||||
lastSave={repo.lastSave}
|
||||
alert={repo.alert}
|
||||
repositoryName={repo.repositoryName}
|
||||
storageUsed={repo.storageUsed}
|
||||
storageSize={repo.storageSize}
|
||||
sshPublicKey={repo.sshPublicKey}
|
||||
comment={repo.comment}
|
||||
lanCommand={repo.lanCommand}
|
||||
appendOnlyMode={repo.appendOnlyMode}
|
||||
repoManageEditHandler={() => manageRepoEditHandler(repo.id)}
|
||||
wizardEnv={wizardEnv}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={displayBlur()}>
|
||||
<div className={classes.containerAddRepo}>
|
||||
<Link
|
||||
href='/manage-repo/add'
|
||||
className={classes.newRepoButton}
|
||||
onClick={manageRepoAddHandler}
|
||||
>
|
||||
<IconPlus className={classes.plusIcon} size={24} stroke={2} />
|
||||
<span>Add a repository</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={classes.toolbar}>
|
||||
<div className={classes.searchContainer}>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Alias, comment, repository name...'
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className={classes.searchInput}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() =>
|
||||
handleSearchChange({
|
||||
target: { value: '' },
|
||||
} as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
className={classes.clearButton}
|
||||
title='Clear search'
|
||||
>
|
||||
<IconX size={16} stroke={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={classes.sortIcons}>
|
||||
<IconSortAscendingLetters
|
||||
className={sortOption === 'alias-asc' ? classes.iconActive : classes.icon}
|
||||
onClick={() => handleSortChange('alias-asc')}
|
||||
title='Alias A-Z'
|
||||
/>
|
||||
<IconSortDescendingLetters
|
||||
className={sortOption === 'alias-desc' ? classes.iconActive : classes.icon}
|
||||
onClick={() => handleSortChange('alias-desc')}
|
||||
title='Alias Z-A'
|
||||
/>
|
||||
<IconSortDescending2Filled
|
||||
className={sortOption === 'status-true' ? classes.iconActive : classes.icon}
|
||||
onClick={() => handleSortChange('status-true')}
|
||||
title='Status OK → KO'
|
||||
/>
|
||||
<IconSortDescending2
|
||||
className={sortOption === 'status-false' ? classes.iconActive : classes.icon}
|
||||
onClick={() => handleSortChange('status-false')}
|
||||
title='Status KO → OK'
|
||||
/>
|
||||
<IconCalendarDown
|
||||
className={sortOption === 'last-save-desc' ? classes.iconActive : classes.icon}
|
||||
onClick={() => handleSortChange('last-save-desc')}
|
||||
title='Last save (recent → old)'
|
||||
/>
|
||||
<IconCalendarUp
|
||||
className={sortOption === 'last-save-asc' ? classes.iconActive : classes.icon}
|
||||
onClick={() => handleSortChange('last-save-asc')}
|
||||
title='Last save (old → recent)'
|
||||
/>
|
||||
<IconSortAscendingSmallBig
|
||||
className={sortOption === 'storage-used-asc' ? classes.iconActive : classes.icon}
|
||||
onClick={() => handleSortChange('storage-used-asc')}
|
||||
title='Storage usage % low → high'
|
||||
/>
|
||||
<IconSortDescendingSmallBig
|
||||
className={sortOption === 'storage-used-desc' ? classes.iconActive : classes.icon}
|
||||
onClick={() => handleSortChange('storage-used-desc')}
|
||||
title='Storage usage % high → low'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classes.containerRepoList}>
|
||||
<div className={classes.RepoList}>{renderRepoList}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{displayRepoAdd && (
|
||||
<RepoManage mode='add' repoList={data.repoList} closeHandler={closeRepoManageBoxHandler} />
|
||||
)}
|
||||
{displayRepoEdit && (
|
||||
<RepoManage mode='edit' repoList={data.repoList} closeHandler={closeRepoManageBoxHandler} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,571 +0,0 @@
|
|||
//Lib
|
||||
import classes from './RepoManage.module.css';
|
||||
import { IconAlertCircle, IconX } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { SpinnerDotted } from 'spinners-react';
|
||||
import Select from 'react-select';
|
||||
import Link from 'next/link';
|
||||
import { IconExternalLink } from '@tabler/icons-react';
|
||||
|
||||
export default function RepoManage(props) {
|
||||
////Var
|
||||
let targetRepo;
|
||||
const router = useRouter();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm({ mode: 'onChange' });
|
||||
//List of possible times for alerts
|
||||
const alertOptions = [
|
||||
{ value: 0, label: 'Disabled' },
|
||||
{ value: 3600, label: '1 hour' },
|
||||
{ value: 21600, label: '6 hours' },
|
||||
{ value: 43200, label: '12 hours' },
|
||||
{ value: 90000, label: '1 day' },
|
||||
{ value: 172800, label: '2 days' },
|
||||
{ value: 259200, label: '3 days' },
|
||||
{ value: 345600, label: '4 days' },
|
||||
{ value: 432000, label: '5 days' },
|
||||
{ value: 518400, label: '6 days' },
|
||||
{ value: 604800, label: '7 days' },
|
||||
{ value: 864000, label: '10 days' },
|
||||
{ value: 1209600, label: '14 days' },
|
||||
{ value: 2592000, label: '30 days' },
|
||||
];
|
||||
const toastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
};
|
||||
|
||||
////State
|
||||
const [deleteDialog, setDeleteDialog] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
////Functions
|
||||
//router.query.slug is undefined for few milliseconds on first render for a direct URL access (https://github.com/vercel/next.js/discussions/11484).
|
||||
//If I call repoManage with edit mode (props), i'm firstly waiting that router.query.slug being available before rendering.
|
||||
if (!router.query.slug && props.mode == 'edit') {
|
||||
return (
|
||||
<SpinnerDotted
|
||||
size={30}
|
||||
thickness={100}
|
||||
speed={180}
|
||||
color='rgba(109, 74, 255, 1)'
|
||||
/>
|
||||
);
|
||||
} else if (props.mode == 'edit') {
|
||||
for (let element in props.repoList) {
|
||||
if (props.repoList[element].id == router.query.slug) {
|
||||
targetRepo = props.repoList[element];
|
||||
}
|
||||
}
|
||||
//If the ID does not exist > 404
|
||||
if (!targetRepo) {
|
||||
router.push('/404');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
//Delete a repo
|
||||
const deleteHandler = async () => {
|
||||
//API Call for delete
|
||||
fetch('/api/repo/id/' + router.query.slug + '/delete', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ toDelete: true }),
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
toast.success(
|
||||
'🗑 The repository #' +
|
||||
router.query.slug +
|
||||
' has been successfully deleted',
|
||||
toastOptions
|
||||
);
|
||||
router.replace('/');
|
||||
} else {
|
||||
toast.error('An error has occurred', toastOptions);
|
||||
router.replace('/');
|
||||
console.log('Fail to delete');
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('An error has occurred', toastOptions);
|
||||
router.replace('/');
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
//Verify that the SSH key is unique
|
||||
const isSSHKeyUnique = async (sshPublicKey) => {
|
||||
let isUnique = true;
|
||||
|
||||
// Extract the first two columns of the SSH key in the form
|
||||
const publicKeyPrefix = sshPublicKey.split(' ').slice(0, 2).join(' ');
|
||||
|
||||
await fetch('/api/repo', { method: 'GET' })
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
for (let element in data.repoList) {
|
||||
// Extract the first two columns of the SSH key in the repoList
|
||||
const repoPublicKeyPrefix = data.repoList[
|
||||
element
|
||||
].sshPublicKey
|
||||
.split(' ')
|
||||
.slice(0, 2)
|
||||
.join(' ');
|
||||
|
||||
if (
|
||||
repoPublicKeyPrefix === publicKeyPrefix && // Compare the first two columns of the SSH key
|
||||
(!targetRepo ||
|
||||
data.repoList[element].id != targetRepo.id)
|
||||
) {
|
||||
toast.error(
|
||||
'The SSH key is already used in repository #' +
|
||||
data.repoList[element].id +
|
||||
'. Please use another key or delete the key from the other repository.',
|
||||
toastOptions
|
||||
);
|
||||
isUnique = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error('An error has occurred', toastOptions);
|
||||
isUnique = false;
|
||||
});
|
||||
return isUnique;
|
||||
};
|
||||
|
||||
//Form submit Handler for ADD or EDIT a repo
|
||||
const formSubmitHandler = async (dataForm) => {
|
||||
//Loading button on submit to avoid multiple send.
|
||||
setIsLoading(true);
|
||||
//Verify that the SSH key is unique
|
||||
if (!(await isSSHKeyUnique(dataForm.sshkey))) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
//ADD a repo
|
||||
if (props.mode == 'add') {
|
||||
const newRepo = {
|
||||
alias: dataForm.alias,
|
||||
size: dataForm.size,
|
||||
sshPublicKey: dataForm.sshkey,
|
||||
comment: dataForm.comment,
|
||||
alert: dataForm.alert.value,
|
||||
lanCommand: dataForm.lanCommand,
|
||||
appendOnlyMode: dataForm.appendOnlyMode,
|
||||
};
|
||||
//POST API to send new repo
|
||||
await fetch('/api/repo/add', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(newRepo),
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
toast.success(
|
||||
'New repository added ! 🥳',
|
||||
toastOptions
|
||||
);
|
||||
router.replace('/');
|
||||
} else {
|
||||
const errorMessage = await response.json();
|
||||
toast.error(
|
||||
`An error has occurred : ${errorMessage.message}`,
|
||||
toastOptions
|
||||
);
|
||||
router.replace('/');
|
||||
console.log(`Fail to ${props.mode}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('An error has occurred', toastOptions);
|
||||
router.replace('/');
|
||||
console.log(error);
|
||||
});
|
||||
//EDIT a repo
|
||||
} else if (props.mode == 'edit') {
|
||||
const dataEdited = {
|
||||
alias: dataForm.alias,
|
||||
size: dataForm.size,
|
||||
sshPublicKey: dataForm.sshkey,
|
||||
comment: dataForm.comment,
|
||||
alert: dataForm.alert.value,
|
||||
lanCommand: dataForm.lanCommand,
|
||||
appendOnlyMode: dataForm.appendOnlyMode,
|
||||
};
|
||||
await fetch('/api/repo/id/' + router.query.slug + '/edit', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dataEdited),
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
toast.success(
|
||||
'The repository #' +
|
||||
targetRepo.id +
|
||||
' has been successfully edited !',
|
||||
toastOptions
|
||||
);
|
||||
router.replace('/');
|
||||
} else {
|
||||
const errorMessage = await response.json();
|
||||
toast.error(
|
||||
`An error has occurred : ${errorMessage.message}`,
|
||||
toastOptions
|
||||
);
|
||||
router.replace('/');
|
||||
console.log(`Fail to ${props.mode}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('An error has occurred', toastOptions);
|
||||
router.replace('/');
|
||||
console.log(error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.modaleWrapper} />
|
||||
<div className={classes.modale}>
|
||||
<div onClick={props.closeHandler} className={classes.close}>
|
||||
<IconX size={36} />
|
||||
</div>
|
||||
{deleteDialog ? (
|
||||
<div className={classes.deleteDialogWrapper}>
|
||||
<div>
|
||||
<IconAlertCircle
|
||||
size={80}
|
||||
color='red'
|
||||
style={{ margin: 'auto' }}
|
||||
/>
|
||||
<h1 style={{ textAlign: 'center' }}>
|
||||
Delete the repository{' '}
|
||||
<span
|
||||
style={{
|
||||
color: 'rgba(99, 115, 129, 0.38)',
|
||||
}}
|
||||
>
|
||||
#{targetRepo.id}
|
||||
</span>{' '}
|
||||
?
|
||||
</h1>
|
||||
</div>
|
||||
<div className={classes.deleteDialogMessage}>
|
||||
<div style={{ marginBottom: '5px' }}>
|
||||
You are about to permanently delete the
|
||||
repository <b>#{targetRepo.id}</b> and all the
|
||||
backups it contains.
|
||||
</div>
|
||||
<div>
|
||||
The data will not be recoverable and it will not
|
||||
be possible to go back.
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.deleteDialogButtonWrapper}>
|
||||
{isLoading ? (
|
||||
<SpinnerDotted
|
||||
size={30}
|
||||
thickness={150}
|
||||
speed={100}
|
||||
color='#6d4aff'
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={props.closeHandler}
|
||||
className={classes.cancelButton}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteHandler();
|
||||
setIsLoading(true);
|
||||
}}
|
||||
className={classes.deleteButton}
|
||||
>
|
||||
Yes, delete it !
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classes.formWrapper}>
|
||||
{props.mode == 'edit' && (
|
||||
<h1>
|
||||
Edit the repository{' '}
|
||||
<span
|
||||
style={{
|
||||
color: 'rgba(99, 115, 129, 0.38)',
|
||||
}}
|
||||
>
|
||||
#{targetRepo.id}
|
||||
</span>
|
||||
</h1>
|
||||
)}
|
||||
{props.mode == 'add' && <h1>Add a repository</h1>}
|
||||
<form
|
||||
className={classes.repoManageForm}
|
||||
onSubmit={handleSubmit(formSubmitHandler)}
|
||||
>
|
||||
{/* ALIAS */}
|
||||
<label htmlFor='alias'>Alias</label>
|
||||
<input
|
||||
className='form-control is-invalid'
|
||||
placeholder='Alias for the repository, e.g."Server 1"'
|
||||
type='text'
|
||||
defaultValue={
|
||||
props.mode == 'edit'
|
||||
? targetRepo.alias
|
||||
: null
|
||||
}
|
||||
{...register('alias', {
|
||||
required: 'An alias is required.',
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: '2 characters min',
|
||||
},
|
||||
maxLength: {
|
||||
value: 40,
|
||||
message: '40 characters max',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.alias && (
|
||||
<span className={classes.errorMessage}>
|
||||
{errors.alias.message}
|
||||
</span>
|
||||
)}
|
||||
{/* SSH KEY */}
|
||||
<label htmlFor='sshkey'>SSH public key</label>
|
||||
<textarea
|
||||
placeholder='Public key in OpenSSH format (rsa, ed25519, ed25519-sk)'
|
||||
type='text'
|
||||
defaultValue={
|
||||
props.mode == 'edit'
|
||||
? targetRepo.sshPublicKey
|
||||
: null
|
||||
}
|
||||
{...register('sshkey', {
|
||||
required: 'SSH public key is required.',
|
||||
pattern: {
|
||||
value: /^(ssh-ed25519 AAAAC3NzaC1lZDI1NTE5|sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29t|ssh-rsa AAAAB3NzaC1yc2)[0-9A-Za-z+/]+[=]{0,3}(\s.*)?$/,
|
||||
message:
|
||||
'Invalid public key. The SSH key needs to be in OpenSSH format (rsa, ed25519, ed25519-sk)',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.sshkey && (
|
||||
<span className={classes.errorMessage}>
|
||||
{errors.sshkey.message}
|
||||
</span>
|
||||
)}
|
||||
{/* SIZE */}
|
||||
<label htmlFor='size'>Storage Size (GB)</label>
|
||||
<input
|
||||
type='number'
|
||||
min='1'
|
||||
defaultValue={
|
||||
props.mode == 'edit'
|
||||
? targetRepo.storageSize
|
||||
: null
|
||||
}
|
||||
{...register('size', {
|
||||
required: 'A size is required.',
|
||||
})}
|
||||
/>
|
||||
{errors.size && (
|
||||
<span className={classes.errorMessage}>
|
||||
{errors.size.message}
|
||||
</span>
|
||||
)}
|
||||
{/* COMMENT */}
|
||||
<label htmlFor='comment'>Comment</label>
|
||||
<textarea
|
||||
type='text'
|
||||
placeholder='Little comment for your repository...'
|
||||
defaultValue={
|
||||
props.mode == 'edit'
|
||||
? targetRepo.comment
|
||||
: null
|
||||
}
|
||||
{...register('comment', {
|
||||
required: false,
|
||||
maxLength: {
|
||||
value: 200,
|
||||
message: '200 characters maximum.',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.comment && (
|
||||
<span className={classes.errorMessage}>
|
||||
{errors.comment.message}
|
||||
</span>
|
||||
)}
|
||||
{/* LAN COMMAND GENERATION */}
|
||||
<div className={classes.optionCommandWrapper}>
|
||||
<input
|
||||
type='checkbox'
|
||||
name='lanCommand'
|
||||
defaultChecked={
|
||||
props.mode == 'edit'
|
||||
? targetRepo.lanCommand
|
||||
: false
|
||||
}
|
||||
{...register('lanCommand')}
|
||||
/>
|
||||
<label htmlFor='lanCommand'>
|
||||
Generates commands for use over LAN.
|
||||
</label>
|
||||
<Link
|
||||
style={{
|
||||
alignSelf: 'baseline',
|
||||
marginLeft: '5px',
|
||||
}}
|
||||
href='https://borgwarehouse.com/docs/user-manual/repositories/#generates-commands-for-use-over-lan'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
<IconExternalLink
|
||||
size={16}
|
||||
color='#6c737f'
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
{/* APPEND-ONLY MODE */}
|
||||
<div className={classes.optionCommandWrapper}>
|
||||
<input
|
||||
type='checkbox'
|
||||
name='appendOnlyMode'
|
||||
defaultChecked={
|
||||
props.mode == 'edit'
|
||||
? targetRepo.appendOnlyMode
|
||||
: false
|
||||
}
|
||||
{...register('appendOnlyMode')}
|
||||
/>
|
||||
<label htmlFor='appendOnlyMode'>
|
||||
Enable append-only mode.
|
||||
</label>
|
||||
<Link
|
||||
style={{
|
||||
alignSelf: 'baseline',
|
||||
marginLeft: '5px',
|
||||
}}
|
||||
href='https://borgwarehouse.com/docs/user-manual/repositories/#append-only-mode'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
<IconExternalLink
|
||||
size={16}
|
||||
color='#6c737f'
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
{/* ALERT */}
|
||||
<label
|
||||
style={{ margin: '25px auto 10px auto' }}
|
||||
htmlFor='alert'
|
||||
>
|
||||
Alert if there is no backup since :
|
||||
</label>
|
||||
<div className={classes.selectAlert}>
|
||||
<Controller
|
||||
name='alert'
|
||||
defaultValue={
|
||||
props.mode == 'edit'
|
||||
? alertOptions.find(
|
||||
(x) =>
|
||||
x.value ===
|
||||
targetRepo.alert
|
||||
)
|
||||
: alertOptions[4]
|
||||
}
|
||||
control={control}
|
||||
render={({
|
||||
field: { onChange, value },
|
||||
}) => (
|
||||
<Select
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
options={alertOptions}
|
||||
isSearchable={false}
|
||||
maxMenuHeight={150}
|
||||
menuPlacement='top'
|
||||
theme={(theme) => ({
|
||||
...theme,
|
||||
borderRadius: '5px',
|
||||
colors: {
|
||||
...theme.colors,
|
||||
primary25: '#c3b6fa',
|
||||
primary: '#6d4aff',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
<SpinnerDotted
|
||||
size={30}
|
||||
thickness={150}
|
||||
speed={100}
|
||||
color='#6d4aff'
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type='submit'
|
||||
className='defaultButton'
|
||||
disabled={!isValid || isSubmitting}
|
||||
>
|
||||
{props.mode == 'edit' && 'Edit'}
|
||||
{props.mode == 'add' && 'Add'}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
{props.mode == 'edit' ? (
|
||||
<button
|
||||
className={classes.littleDeleteButton}
|
||||
onClick={() => setDeleteDialog(true)}
|
||||
>
|
||||
Delete this repository
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,268 +1,296 @@
|
|||
.modaleWrapper {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 50px 0px 0px 70px;
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 50px 0px 0px 70px;
|
||||
}
|
||||
|
||||
.modale {
|
||||
position: fixed;
|
||||
top: 10%;
|
||||
width: 1000px;
|
||||
height: auto;
|
||||
max-width: 75%;
|
||||
max-height: 85%;
|
||||
background: #fff;
|
||||
padding: 20px 20px 20px;
|
||||
overflow: auto;
|
||||
border-radius: 10px;
|
||||
box-shadow: 1px 2px 6px rgba(0, 0, 0, 0.4);
|
||||
margin: 0 auto;
|
||||
animation: append-animate 0.3s linear;
|
||||
position: fixed;
|
||||
top: 10%;
|
||||
width: 800px;
|
||||
height: auto;
|
||||
max-width: 75%;
|
||||
max-height: 85%;
|
||||
background: #fff;
|
||||
padding: 20px 20px 20px;
|
||||
overflow: auto;
|
||||
border-radius: 10px;
|
||||
box-shadow: 1px 2px 6px rgba(0, 0, 0, 0.4);
|
||||
margin: 0 auto;
|
||||
animation: append-animate 0.3s linear;
|
||||
}
|
||||
|
||||
.modale h2 {
|
||||
margin-top: 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
@keyframes append-animate {
|
||||
from {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
from {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.close {
|
||||
cursor: pointer;
|
||||
margin-left: 95%;
|
||||
color: #494b7a;
|
||||
cursor: pointer;
|
||||
margin-left: 95%;
|
||||
color: #494b7a;
|
||||
}
|
||||
|
||||
.close :hover {
|
||||
color: #aa60ff;
|
||||
color: #aa60ff;
|
||||
}
|
||||
|
||||
.repoManageForm {
|
||||
margin: auto;
|
||||
width: 80%;
|
||||
padding: 15px 30px 30px 30px;
|
||||
border-radius: 5px;
|
||||
text-align: left;
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
border-radius: 8px;
|
||||
background-color: #ffffff;
|
||||
font-family: Inter, sans-serif;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.formWrapper {
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: #494b7a;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.repoManageForm label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #494b7a;
|
||||
display: block;
|
||||
margin-top: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: #374151;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.repoManageForm input,
|
||||
.repoManageForm textarea,
|
||||
.repoManageForm select {
|
||||
border: 1px solid #6d4aff21;
|
||||
font-size: 16px;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
margin-bottom: 0px;
|
||||
outline: 0;
|
||||
padding: 15px;
|
||||
width: 100%;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
/* color: #1b1340; */
|
||||
color: #494b7a;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03) inset;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
background-color: #f9fafb;
|
||||
color: #111827;
|
||||
font-family: Inter;
|
||||
}
|
||||
|
||||
.repoManageForm textarea {
|
||||
resize: vertical;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.repoManageForm textarea:focus,
|
||||
.repoManageForm input:focus,
|
||||
.repoManageForm textarea:focus,
|
||||
.repoManageForm select:focus {
|
||||
outline: 1px solid #6d4aff;
|
||||
box-shadow: 0 0 10px 3px rgba(110, 74, 255, 0.605);
|
||||
border-color: #6d4aff;
|
||||
background-color: #ffffff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(109, 74, 255, 0.3);
|
||||
}
|
||||
|
||||
.repoManageForm .invalid {
|
||||
background: #f3c7c7;
|
||||
border: 1px solid #e45454;
|
||||
outline: 1px solid #ff4a4a;
|
||||
background-color: #fef2f2;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.repoManageForm .invalid:focus {
|
||||
background: #f3c7c7;
|
||||
border: 1px solid #e45454;
|
||||
outline: 1px solid #ff4a4a;
|
||||
box-shadow: 0 0 10px 3px rgba(255, 74, 74, 0.605);
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.repoManageForm button {
|
||||
display: block;
|
||||
margin: 15px auto;
|
||||
display: block;
|
||||
margin: 2rem auto 0 auto;
|
||||
background-color: #6d4aff;
|
||||
color: #ffffff;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.repoManageForm button:hover {
|
||||
display: block;
|
||||
margin: 15px auto;
|
||||
background-color: #5c3dff;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
color: red;
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.optionCommandWrapper {
|
||||
display: flex;
|
||||
margin-top: 20px;
|
||||
color: #494b7a;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.95rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.optionCommandWrapper label {
|
||||
margin: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.optionCommandWrapper input[type='checkbox'] {
|
||||
width: auto;
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
accent-color: #6d4aff;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #6d4aff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.optionCommandWrapper input[type='checkbox']:focus {
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
accent-color: #6d4aff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(109, 74, 255, 0.4);
|
||||
}
|
||||
|
||||
.selectAlert {
|
||||
max-width: 160px;
|
||||
}
|
||||
.selectAlertWrapper label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.selectAlertWrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-top: 1.5rem;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* DELETE DIALOG */
|
||||
|
||||
.deleteDialogWrapper {
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
width: 80%;
|
||||
height: 100%;
|
||||
max-height: 590px;
|
||||
color: #111827;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
width: 80%;
|
||||
height: 100%;
|
||||
max-height: 590px;
|
||||
color: #111827;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.deleteDialogMessage {
|
||||
background-color: #ea1313;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.1em;
|
||||
background-color: #ea1313;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.cancelButton {
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #c1c1c1;
|
||||
color: white;
|
||||
margin: 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #c1c1c1;
|
||||
color: white;
|
||||
margin: 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.cancelButton:hover {
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #9a9a9a;
|
||||
color: white;
|
||||
margin: 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #9a9a9a;
|
||||
color: white;
|
||||
margin: 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.cancelButton:active {
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #9a9a9a;
|
||||
color: white;
|
||||
margin: 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
transform: scale(0.9);
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #9a9a9a;
|
||||
color: white;
|
||||
margin: 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #ff0000;
|
||||
color: white;
|
||||
margin: 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #ff0000;
|
||||
color: white;
|
||||
margin: 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.deleteButton:hover {
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #ff4b4b;
|
||||
color: white;
|
||||
margin: 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #ff4b4b;
|
||||
color: white;
|
||||
margin: 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.deleteButton:active {
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #ff4b4b;
|
||||
color: white;
|
||||
margin: 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
transform: scale(0.9);
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #ff4b4b;
|
||||
color: white;
|
||||
margin: 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.littleDeleteButton {
|
||||
border: none;
|
||||
font-weight: 300;
|
||||
color: red;
|
||||
text-decoration: underline;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selectAlert {
|
||||
margin: auto auto 35px auto;
|
||||
max-width: 160px;
|
||||
margin-top: 10px;
|
||||
border: none;
|
||||
font-weight: 300;
|
||||
color: red;
|
||||
text-decoration: underline;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
|||
496
Containers/RepoManage/RepoManage.tsx
Normal file
496
Containers/RepoManage/RepoManage.tsx
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
import { IconAlertCircle, IconExternalLink, IconX } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import Select from 'react-select';
|
||||
import { toast, ToastOptions } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
import { alertOptions, Optional, Repository } from '~/types';
|
||||
import classes from './RepoManage.module.css';
|
||||
|
||||
type RepoManageProps = {
|
||||
mode: 'add' | 'edit';
|
||||
repoList: Optional<Array<Repository>>;
|
||||
closeHandler: () => void;
|
||||
};
|
||||
|
||||
type DataForm = {
|
||||
alias: string;
|
||||
storageSize: string;
|
||||
sshkey: string;
|
||||
comment: string;
|
||||
alert: { value: Optional<number>; label: string };
|
||||
lanCommand: boolean;
|
||||
appendOnlyMode: boolean;
|
||||
};
|
||||
|
||||
export default function RepoManage(props: RepoManageProps) {
|
||||
const router = useRouter();
|
||||
const targetRepo =
|
||||
props.mode === 'edit' && router.query.slug
|
||||
? props.repoList?.find((repo) => repo.id.toString() === router.query.slug)
|
||||
: undefined;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm<DataForm>({ mode: 'onChange' });
|
||||
|
||||
const toastOptions: ToastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
};
|
||||
|
||||
const [deleteDialog, setDeleteDialog] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
//router.query.slug is undefined for few milliseconds on first render for a direct URL access (https://github.com/vercel/next.js/discussions/11484).
|
||||
//If I call repoManage with edit mode (props), i'm firstly waiting that router.query.slug being available before rendering.
|
||||
if (props.mode === 'edit') {
|
||||
if (!router.query.slug) {
|
||||
start();
|
||||
return;
|
||||
} else if (!targetRepo) {
|
||||
stop();
|
||||
router.push('/404');
|
||||
}
|
||||
}
|
||||
|
||||
//Delete a repo
|
||||
const deleteHandler = async (repositoryName?: string) => {
|
||||
start();
|
||||
if (!repositoryName) {
|
||||
stop();
|
||||
toast.error('Repository name not found', toastOptions);
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
//API Call for delete
|
||||
await fetch('/api/v1/repositories/' + repositoryName, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
toast.success(
|
||||
'🗑 The repository ' + repositoryName + ' has been successfully deleted',
|
||||
toastOptions
|
||||
);
|
||||
router.replace('/');
|
||||
} else {
|
||||
if (response.status == 403) {
|
||||
toast.warning(
|
||||
'🔒 The server is currently protected against repository deletion.',
|
||||
toastOptions
|
||||
);
|
||||
setIsLoading(false);
|
||||
router.replace('/');
|
||||
} else {
|
||||
const errorMessage = await response.json();
|
||||
toast.error(`An error has occurred : ${errorMessage.message.stderr}`, toastOptions);
|
||||
router.replace('/');
|
||||
console.log('Fail to delete');
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('An error has occurred', toastOptions);
|
||||
router.replace('/');
|
||||
console.log(error);
|
||||
})
|
||||
.finally(() => {
|
||||
stop();
|
||||
});
|
||||
};
|
||||
|
||||
const isSSHKeyUnique = async (sshPublicKey: string): Promise<boolean> => {
|
||||
try {
|
||||
// Extract the first two columns of the SSH key in the form
|
||||
const publicKeyPrefix = sshPublicKey.split(' ').slice(0, 2).join(' ');
|
||||
|
||||
const response = await fetch('/api/v1/repositories', { method: 'GET' });
|
||||
const data: { repoList: Repository[] } = await response.json();
|
||||
|
||||
const conflictingRepo = data.repoList.find((repo: { sshPublicKey: string; id: number }) => {
|
||||
const repoPublicKeyPrefix = repo.sshPublicKey.split(' ').slice(0, 2).join(' ');
|
||||
return (
|
||||
repoPublicKeyPrefix === publicKeyPrefix && (!targetRepo || repo.id !== targetRepo.id)
|
||||
);
|
||||
});
|
||||
|
||||
if (conflictingRepo) {
|
||||
toast.error(
|
||||
`The SSH key is already used in repository ${conflictingRepo.repositoryName}. Please use another key or delete the key from the other repository.`,
|
||||
toastOptions
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast.error('An error has occurred', toastOptions);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
//Form submit Handler for ADD or EDIT a repo
|
||||
const formSubmitHandler = async (dataForm: DataForm) => {
|
||||
setIsLoading(true);
|
||||
start();
|
||||
|
||||
// Clean SSH key by removing leading/trailing whitespace and line breaks
|
||||
const cleanedSSHKey = dataForm.sshkey.trim();
|
||||
|
||||
//Verify that the SSH key is unique
|
||||
if (!(await isSSHKeyUnique(cleanedSSHKey))) {
|
||||
stop();
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
//ADD a repo
|
||||
if (props.mode == 'add') {
|
||||
const newRepo = {
|
||||
alias: dataForm.alias,
|
||||
storageSize: parseInt(dataForm.storageSize),
|
||||
sshPublicKey: cleanedSSHKey,
|
||||
comment: dataForm.comment,
|
||||
alert: dataForm.alert.value,
|
||||
lanCommand: dataForm.lanCommand,
|
||||
appendOnlyMode: dataForm.appendOnlyMode,
|
||||
};
|
||||
//POST API to send new repo
|
||||
await fetch('/api/v1/repositories', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(newRepo),
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
toast.success('New repository added ! 🥳', toastOptions);
|
||||
router.replace('/');
|
||||
} else {
|
||||
const errorMessage = await response.json();
|
||||
toast.error(`An error has occurred : ${errorMessage.message.stderr}`, toastOptions);
|
||||
router.replace('/');
|
||||
console.log(`Fail to ${props.mode}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('An error has occurred', toastOptions);
|
||||
router.replace('/');
|
||||
console.log(error);
|
||||
})
|
||||
.finally(() => {
|
||||
stop();
|
||||
setIsLoading(false);
|
||||
});
|
||||
//EDIT a repo
|
||||
} else if (props.mode == 'edit') {
|
||||
const dataEdited = {
|
||||
alias: dataForm.alias,
|
||||
storageSize: parseInt(dataForm.storageSize),
|
||||
sshPublicKey: cleanedSSHKey,
|
||||
comment: dataForm.comment,
|
||||
alert: dataForm.alert.value,
|
||||
lanCommand: dataForm.lanCommand,
|
||||
appendOnlyMode: dataForm.appendOnlyMode,
|
||||
};
|
||||
await fetch('/api/v1/repositories/' + targetRepo?.repositoryName, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dataEdited),
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
toast.success(
|
||||
'The repository ' + targetRepo?.repositoryName + ' has been successfully edited !',
|
||||
toastOptions
|
||||
);
|
||||
router.replace('/');
|
||||
} else {
|
||||
const errorMessage = await response.json();
|
||||
toast.error(`An error has occurred : ${errorMessage.message.stderr}`, toastOptions);
|
||||
router.replace('/');
|
||||
console.log(`Fail to ${props.mode}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('An error has occurred', toastOptions);
|
||||
router.replace('/');
|
||||
console.log(error);
|
||||
})
|
||||
.finally(() => {
|
||||
stop();
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.modaleWrapper} />
|
||||
<div className={classes.modale}>
|
||||
<div onClick={props.closeHandler} className={classes.close}>
|
||||
<IconX size={36} />
|
||||
</div>
|
||||
{deleteDialog ? (
|
||||
<div className={classes.deleteDialogWrapper}>
|
||||
<div>
|
||||
<IconAlertCircle size={80} color='red' style={{ margin: 'auto' }} />
|
||||
<h1 style={{ textAlign: 'center' }}>
|
||||
Delete the repository{' '}
|
||||
<span
|
||||
style={{
|
||||
color: 'rgba(99, 115, 129, 0.38)',
|
||||
}}
|
||||
>
|
||||
{targetRepo?.repositoryName}
|
||||
</span>{' '}
|
||||
?
|
||||
</h1>
|
||||
</div>
|
||||
<div className={classes.deleteDialogMessage}>
|
||||
<div style={{ marginBottom: '5px' }}>
|
||||
You are about to permanently delete the repository{' '}
|
||||
<b>{targetRepo?.repositoryName}</b> and all the backups it contains.
|
||||
</div>
|
||||
<div>The data will not be recoverable and it will not be possible to go back.</div>
|
||||
</div>
|
||||
<div className={classes.deleteDialogButtonWrapper}>
|
||||
<>
|
||||
<button
|
||||
onClick={props.closeHandler}
|
||||
disabled={isLoading}
|
||||
className={classes.cancelButton}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteHandler(targetRepo?.repositoryName);
|
||||
setIsLoading(true);
|
||||
}}
|
||||
className={classes.deleteButton}
|
||||
>
|
||||
Yes, delete it !
|
||||
</button>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classes.formWrapper}>
|
||||
{props.mode == 'edit' && (
|
||||
<h2>
|
||||
Edit the repository{' '}
|
||||
<span
|
||||
style={{
|
||||
color: '#6d4aff',
|
||||
}}
|
||||
>
|
||||
{targetRepo?.repositoryName}
|
||||
</span>
|
||||
</h2>
|
||||
)}
|
||||
{props.mode == 'add' && <h2>Add a repository</h2>}
|
||||
<form className={classes.repoManageForm} onSubmit={handleSubmit(formSubmitHandler)}>
|
||||
{/* ALIAS */}
|
||||
<label htmlFor='alias'>Alias</label>
|
||||
<input
|
||||
className='form-control is-invalid'
|
||||
placeholder='Alias for the repository, e.g."Server 1"'
|
||||
type='text'
|
||||
defaultValue={props.mode == 'edit' ? targetRepo?.alias : undefined}
|
||||
{...register('alias', {
|
||||
required: 'An alias is required.',
|
||||
minLength: {
|
||||
value: 1,
|
||||
message: '1 character min',
|
||||
},
|
||||
maxLength: {
|
||||
value: 100,
|
||||
message: '100 characters max',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.alias && <span className={classes.errorMessage}>{errors.alias.message}</span>}
|
||||
{/* SSH KEY */}
|
||||
<label htmlFor='sshkey'>SSH public key</label>
|
||||
<textarea
|
||||
placeholder='Public key in OpenSSH format (rsa, ed25519, ed25519-sk)'
|
||||
defaultValue={props.mode == 'edit' ? targetRepo?.sshPublicKey : undefined}
|
||||
{...register('sshkey', {
|
||||
required: 'SSH public key is required.',
|
||||
validate: (value) => {
|
||||
const trimmedValue = value.trim();
|
||||
const pattern =
|
||||
/^(ssh-ed25519 AAAAC3NzaC1lZDI1NTE5|sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29t|ssh-rsa AAAAB3NzaC1yc2)[0-9A-Za-z+/]+[=]{0,3}(\s.*)?$/;
|
||||
return (
|
||||
pattern.test(trimmedValue) ||
|
||||
'Invalid public key. The key needs to be in OpenSSH format (rsa, ed25519, ed25519-sk)'
|
||||
);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.sshkey && (
|
||||
<span className={classes.errorMessage}>{errors.sshkey.message}</span>
|
||||
)}
|
||||
{/* storageSize */}
|
||||
<label htmlFor='storageSize'>Storage Size (GB)</label>
|
||||
<input
|
||||
type='number'
|
||||
placeholder='1000'
|
||||
min='1'
|
||||
defaultValue={props.mode == 'edit' ? targetRepo?.storageSize : undefined}
|
||||
{...register('storageSize', {
|
||||
required: 'A storage size is required.',
|
||||
})}
|
||||
/>
|
||||
{errors.storageSize && (
|
||||
<span className={classes.errorMessage}>{errors.storageSize.message}</span>
|
||||
)}
|
||||
{/* COMMENT */}
|
||||
<label htmlFor='comment'>Comment</label>
|
||||
<textarea
|
||||
defaultValue={props.mode == 'edit' ? targetRepo?.comment : undefined}
|
||||
{...register('comment', {
|
||||
required: false,
|
||||
maxLength: {
|
||||
value: 500,
|
||||
message: '500 characters maximum.',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.comment && (
|
||||
<span className={classes.errorMessage}>{errors.comment.message}</span>
|
||||
)}
|
||||
{/* LAN COMMAND GENERATION */}
|
||||
<div className={classes.optionCommandWrapper}>
|
||||
<input
|
||||
type='checkbox'
|
||||
defaultChecked={props.mode == 'edit' ? targetRepo?.lanCommand : false}
|
||||
{...register('lanCommand')}
|
||||
/>
|
||||
<label htmlFor='lanCommand'>Generates commands for use over LAN</label>
|
||||
<Link
|
||||
href='https://borgwarehouse.com/docs/user-manual/repositories/#generates-commands-for-use-over-lan'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
<IconExternalLink size={16} color='#6c737f' />
|
||||
</Link>
|
||||
</div>
|
||||
{/* APPEND-ONLY MODE */}
|
||||
<div className={classes.optionCommandWrapper}>
|
||||
<input
|
||||
type='checkbox'
|
||||
defaultChecked={props.mode == 'edit' ? targetRepo?.appendOnlyMode : false}
|
||||
{...register('appendOnlyMode')}
|
||||
/>
|
||||
<label htmlFor='appendOnlyMode'>Enable append-only mode</label>
|
||||
<Link
|
||||
href='https://borgwarehouse.com/docs/user-manual/repositories/#append-only-mode'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
<IconExternalLink size={16} color='#6c737f' />
|
||||
</Link>
|
||||
</div>
|
||||
{/* ALERT */}
|
||||
<div className={classes.selectAlertWrapper}>
|
||||
<label htmlFor='alert'>Alert if there is no backup since :</label>
|
||||
<div className={classes.selectAlert}>
|
||||
<Controller
|
||||
name='alert'
|
||||
defaultValue={
|
||||
props.mode == 'edit'
|
||||
? alertOptions.find((x) => x.value === targetRepo?.alert) || {
|
||||
value: targetRepo?.alert,
|
||||
label: `Custom value (${targetRepo?.alert} seconds)`,
|
||||
}
|
||||
: alertOptions[4]
|
||||
}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
options={alertOptions}
|
||||
isSearchable={false}
|
||||
maxMenuHeight={300}
|
||||
menuPlacement='top'
|
||||
styles={{
|
||||
control: (base) => ({
|
||||
...base,
|
||||
minHeight: '35px',
|
||||
height: '35px',
|
||||
}),
|
||||
valueContainer: (base) => ({
|
||||
...base,
|
||||
height: '35px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
padding: '0 8px',
|
||||
}),
|
||||
input: (base) => ({
|
||||
...base,
|
||||
margin: 0,
|
||||
}),
|
||||
indicatorsContainer: (base) => ({
|
||||
...base,
|
||||
height: '35px',
|
||||
}),
|
||||
}}
|
||||
theme={(theme) => ({
|
||||
...theme,
|
||||
borderRadius: 5,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
primary25: '#c3b6fa',
|
||||
primary: '#6d4aff',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type='submit'
|
||||
className='defaultButton'
|
||||
disabled={!isValid || isSubmitting || isLoading}
|
||||
>
|
||||
{props.mode == 'edit' && 'Save'}
|
||||
{props.mode == 'add' && 'Add repository'}
|
||||
</button>
|
||||
</form>
|
||||
{props.mode == 'edit' ? (
|
||||
<button className={classes.littleDeleteButton} onClick={() => setDeleteDialog(true)}>
|
||||
Delete this repository
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
//Lib
|
||||
import React from 'react';
|
||||
import classes from './SetupWizard.module.css';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Select from 'react-select';
|
||||
|
||||
//Components
|
||||
import WizardStepBar from '../../Components/WizardSteps/WizardStepBar/WizardStepBar';
|
||||
import WizardStep1 from '../../Components/WizardSteps/WizardStep1/WizardStep1';
|
||||
import WizardStep2 from '../../Components/WizardSteps/WizardStep2/WizardStep2';
|
||||
import WizardStep3 from '../../Components/WizardSteps/WizardStep3/WizardStep3';
|
||||
import WizardStep4 from '../../Components/WizardSteps/WizardStep4/WizardStep4';
|
||||
|
||||
function SetupWizard(props) {
|
||||
////Var
|
||||
const router = useRouter();
|
||||
|
||||
////States
|
||||
const [list, setList] = useState([]);
|
||||
const [listIsLoading, setListIsLoading] = useState(true);
|
||||
const [step, setStep] = useState();
|
||||
const [wizardEnv, setWizardEnv] = useState({});
|
||||
const [selectedOption, setSelectedOption] = useState({
|
||||
id: '#id',
|
||||
repository: 'repo',
|
||||
});
|
||||
|
||||
////LifeCycle
|
||||
//ComponentDidMount
|
||||
useEffect(() => {
|
||||
//retrieve the repository list
|
||||
const repoList = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/repo', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
setList((await response.json()).repoList);
|
||||
setListIsLoading(false);
|
||||
} catch (error) {
|
||||
console.log('Fetching datas error');
|
||||
}
|
||||
};
|
||||
repoList();
|
||||
//Fetch wizardEnv to hydrate Wizard' steps
|
||||
const fetchWizardEnv = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/account/getWizardEnv', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
setWizardEnv((await response.json()).wizardEnv);
|
||||
} catch (error) {
|
||||
console.log('Fetching datas error');
|
||||
}
|
||||
};
|
||||
fetchWizardEnv();
|
||||
}, []);
|
||||
//Component did update
|
||||
useEffect(() => {
|
||||
//Go to the step in the URL param when URL change
|
||||
setStep(props.step);
|
||||
}, [props.step]);
|
||||
|
||||
////Functions
|
||||
|
||||
//Options for react-select
|
||||
const options = list.map((repo) => ({
|
||||
label: `${repo.alias} - #${repo.id}`,
|
||||
value: `${repo.alias} - #${repo.id}`,
|
||||
id: repo.id,
|
||||
repositoryName: repo.repositoryName,
|
||||
lanCommand: repo.lanCommand,
|
||||
}));
|
||||
|
||||
//Step button (free selection of user)
|
||||
const changeStepHandler = (x) => router.push('/setup-wizard/' + x);
|
||||
|
||||
//Next Step button
|
||||
const nextStepHandler = () => {
|
||||
if (step < 4) {
|
||||
router.push('/setup-wizard/' + `${Number(step) + 1}`);
|
||||
}
|
||||
};
|
||||
|
||||
//Previous Step button
|
||||
const previousStepHandler = () => {
|
||||
if (step > 1) {
|
||||
router.push('/setup-wizard/' + `${Number(step) - 1}`);
|
||||
}
|
||||
};
|
||||
|
||||
//Change Step with State
|
||||
const wizardStep = (step) => {
|
||||
if (step == 1) {
|
||||
return <WizardStep1 />;
|
||||
} else if (step == 2) {
|
||||
return (
|
||||
<WizardStep2
|
||||
selectedOption={selectedOption}
|
||||
wizardEnv={wizardEnv}
|
||||
/>
|
||||
);
|
||||
} else if (step == 3) {
|
||||
return (
|
||||
<WizardStep3
|
||||
selectedOption={selectedOption}
|
||||
wizardEnv={wizardEnv}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<WizardStep4
|
||||
selectedOption={selectedOption}
|
||||
wizardEnv={wizardEnv}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<WizardStepBar
|
||||
setStep={(x) => changeStepHandler(x)}
|
||||
step={step}
|
||||
nextStepHandler={() => nextStepHandler()}
|
||||
previousStepHandler={() => previousStepHandler()}
|
||||
/>
|
||||
<div className={classes.selectRepo}>
|
||||
<Select
|
||||
onChange={setSelectedOption}
|
||||
isLoading={listIsLoading}
|
||||
isDisabled={listIsLoading}
|
||||
options={options}
|
||||
isSearchable
|
||||
placeholder='Select your repository...'
|
||||
theme={(theme) => ({
|
||||
...theme,
|
||||
borderRadius: '5px',
|
||||
colors: {
|
||||
...theme.colors,
|
||||
primary25: '#c3b6fa',
|
||||
primary: '#6d4aff',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{wizardStep(step)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SetupWizard;
|
||||
|
|
@ -1,28 +1,28 @@
|
|||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
color: #494b7a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
color: #494b7a;
|
||||
}
|
||||
.container a {
|
||||
text-decoration: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wizardStepTransition-enter {
|
||||
opacity: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
.wizardStepTransition-enter-active {
|
||||
opacity: 1;
|
||||
transition: opacity 200ms;
|
||||
opacity: 1;
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
.wizardStepTransition-exit {
|
||||
opacity: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
.wizardStepTransition-exit-active {
|
||||
opacity: 0;
|
||||
transition: opacity 200ms;
|
||||
opacity: 0;
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
.selectRepo {
|
||||
margin: 30px auto auto auto;
|
||||
width: 300px;
|
||||
margin: 30px auto auto auto;
|
||||
width: 300px;
|
||||
}
|
||||
|
|
|
|||
170
Containers/SetupWizard/SetupWizard.tsx
Normal file
170
Containers/SetupWizard/SetupWizard.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Select, { SingleValue } from 'react-select';
|
||||
import classes from './SetupWizard.module.css';
|
||||
import { Optional, SelectedRepoWizard, Repository, WizardEnvType } from '~/types';
|
||||
|
||||
//Components
|
||||
import WizardStep1 from '../../Components/WizardSteps/WizardStep1/WizardStep1';
|
||||
import WizardStep2 from '../../Components/WizardSteps/WizardStep2/WizardStep2';
|
||||
import WizardStep3 from '../../Components/WizardSteps/WizardStep3/WizardStep3';
|
||||
import WizardStep4 from '../../Components/WizardSteps/WizardStep4/WizardStep4';
|
||||
import WizardStepBar from '../../Components/WizardSteps/WizardStepBar/WizardStepBar';
|
||||
|
||||
type SetupWizardProps = {
|
||||
step?: number;
|
||||
};
|
||||
|
||||
function SetupWizard(props: SetupWizardProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [repoList, setRepoList] = useState<Optional<Array<Repository>>>();
|
||||
const [repoListIsLoading, setRepoListIsLoading] = useState<boolean>(true);
|
||||
const [step, setStep] = useState<number>(1);
|
||||
const [wizardEnv, setWizardEnv] = useState<Optional<WizardEnvType>>();
|
||||
const [selectedItem, setSelectedItem] = useState<Optional<SelectedRepoWizard>>();
|
||||
|
||||
////LifeCycle
|
||||
//ComponentDidMount
|
||||
useEffect(() => {
|
||||
//retrieve the repository list
|
||||
const fetchRepoList = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/repositories', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
const repos = data.repoList;
|
||||
setRepoList(repos);
|
||||
setRepoListIsLoading(false);
|
||||
|
||||
// Auto-select first repository if available
|
||||
if (repos && repos.length > 0) {
|
||||
setSelectedItem({
|
||||
label: `${repos[0].alias} - ${repos[0].repositoryName}`,
|
||||
value: `${repos[0].alias} - ${repos[0].repositoryName}`,
|
||||
id: repos[0].id.toString(),
|
||||
repositoryName: repos[0].repositoryName,
|
||||
lanCommand: repos[0].lanCommand ? repos[0].lanCommand : false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Fetching datas error');
|
||||
}
|
||||
};
|
||||
fetchRepoList();
|
||||
//Fetch wizardEnv to hydrate Wizard' steps
|
||||
const fetchWizardEnv = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/account/wizard-env', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
const data: WizardEnvType = await response.json();
|
||||
setWizardEnv(data);
|
||||
} catch (error) {
|
||||
console.log('Fetching datas error');
|
||||
}
|
||||
};
|
||||
fetchWizardEnv();
|
||||
}, []);
|
||||
//Component did update
|
||||
useEffect(() => {
|
||||
//Go to the step in the URL param when URL change
|
||||
if (props.step) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setStep(props.step);
|
||||
}
|
||||
}, [props.step]);
|
||||
|
||||
//Options for react-select
|
||||
const options: Optional<Array<SelectedRepoWizard>> = useMemo(
|
||||
() =>
|
||||
repoList?.map((repo) => ({
|
||||
label: `${repo.alias} - ${repo.repositoryName}`,
|
||||
value: `${repo.alias} - ${repo.repositoryName}`,
|
||||
id: repo.id.toString(),
|
||||
repositoryName: repo.repositoryName,
|
||||
lanCommand: repo.lanCommand ? repo.lanCommand : false,
|
||||
})),
|
||||
[repoList]
|
||||
);
|
||||
|
||||
//Step button (free selection of user)
|
||||
const changeStepHandler = (x: number) => router.push('/setup-wizard/' + x.toString());
|
||||
|
||||
//Next Step button
|
||||
const nextStepHandler = () => {
|
||||
if (step && step < 4) {
|
||||
router.push('/setup-wizard/' + `${step + 1}`);
|
||||
}
|
||||
};
|
||||
|
||||
//Previous Step button
|
||||
const previousStepHandler = () => {
|
||||
if (step && step > 1) {
|
||||
router.push('/setup-wizard/' + `${step - 1}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onChangeSelect = (option: SingleValue<SelectedRepoWizard>) => {
|
||||
if (option) {
|
||||
setSelectedItem(option);
|
||||
} else {
|
||||
setSelectedItem(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
//Change Step with State
|
||||
const wizardStep = (step?: number) => {
|
||||
if (!step || step === 1) {
|
||||
return <WizardStep1 />;
|
||||
} else if (step === 2) {
|
||||
return <WizardStep2 selectedRepo={selectedItem} wizardEnv={wizardEnv} />;
|
||||
} else if (step === 3) {
|
||||
return <WizardStep3 selectedRepo={selectedItem} wizardEnv={wizardEnv} />;
|
||||
} else {
|
||||
return <WizardStep4 selectedRepo={selectedItem} wizardEnv={wizardEnv} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<WizardStepBar
|
||||
setStep={(x) => changeStepHandler(x)}
|
||||
step={step}
|
||||
nextStepHandler={() => nextStepHandler()}
|
||||
previousStepHandler={() => previousStepHandler()}
|
||||
/>
|
||||
<div className={classes.selectRepo}>
|
||||
<Select
|
||||
onChange={(item) => onChangeSelect(item)}
|
||||
isLoading={repoListIsLoading}
|
||||
isDisabled={repoListIsLoading}
|
||||
options={options}
|
||||
isSearchable
|
||||
value={selectedItem}
|
||||
placeholder='Select your repository...'
|
||||
theme={(theme) => ({
|
||||
...theme,
|
||||
borderRadius: 5,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
primary25: '#c3b6fa',
|
||||
primary: '#6d4aff',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{wizardStep(step)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SetupWizard;
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
//Lib
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from '../UserSettings.module.css';
|
||||
import { useState } from 'react';
|
||||
import { SpinnerCircularFixed } from 'spinners-react';
|
||||
import { IconExternalLink } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
//Components
|
||||
import Error from '../../../Components/UI/Error/Error';
|
||||
import Switch from '../../../Components/UI/Switch/Switch';
|
||||
import AppriseURLs from './AppriseURLs/AppriseURLs';
|
||||
import AppriseMode from './AppriseMode/AppriseMode';
|
||||
|
||||
export default function AppriseAlertSettings() {
|
||||
//Var
|
||||
const toastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
//Callback > re-enabled button after notification.
|
||||
onClose: () => setDisabled(false),
|
||||
};
|
||||
|
||||
////State
|
||||
const [checkIsLoading, setCheckIsLoading] = useState(true);
|
||||
const [error, setError] = useState();
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [checked, setChecked] = useState();
|
||||
const [testIsLoading, setTestIsLoading] = useState(false);
|
||||
const [info, setInfo] = useState(false);
|
||||
|
||||
////LifeCycle
|
||||
//Component did mount
|
||||
useEffect(() => {
|
||||
//Initial fetch to get the status of Apprise Alert
|
||||
const getAppriseAlert = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/account/getAppriseAlert', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
setChecked((await response.json()).appriseAlert);
|
||||
setCheckIsLoading(false);
|
||||
} catch (error) {
|
||||
setError(
|
||||
'Fetching apprise alert setting failed. Contact your administrator.'
|
||||
);
|
||||
console.log('Fetching apprise alert setting failed.');
|
||||
setCheckIsLoading(false);
|
||||
}
|
||||
};
|
||||
getAppriseAlert();
|
||||
}, []);
|
||||
|
||||
////Functions
|
||||
//Switch to enable/disable Apprise notifications
|
||||
const onChangeSwitchHandler = async (data) => {
|
||||
//Remove old error
|
||||
setError();
|
||||
//Disabled button
|
||||
setDisabled(true);
|
||||
await fetch('/api/account/updateAppriseAlert', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then((response) => {
|
||||
console.log(response);
|
||||
if (response.ok) {
|
||||
if (data.appriseAlert) {
|
||||
setChecked(!checked);
|
||||
toast.success(
|
||||
'Apprise notifications enabled.',
|
||||
toastOptions
|
||||
);
|
||||
} else {
|
||||
setChecked(!checked);
|
||||
toast.success(
|
||||
'Apprise notifications disabled.',
|
||||
toastOptions
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setError('Update apprise alert setting failed.');
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
setDisabled(false);
|
||||
}, 4000);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
setError('Update Apprise failed. Contact your administrator.');
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
setDisabled(false);
|
||||
}, 4000);
|
||||
});
|
||||
};
|
||||
|
||||
//Send Apprise test notification to services
|
||||
const onSendTestAppriseHandler = async () => {
|
||||
//Loading
|
||||
setTestIsLoading(true);
|
||||
//Remove old error
|
||||
setError();
|
||||
try {
|
||||
const response = await fetch('/api/account/sendTestApprise', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ sendTestApprise: true }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
setTestIsLoading(false);
|
||||
setError(result.message);
|
||||
} else {
|
||||
setTestIsLoading(false);
|
||||
setInfo(true);
|
||||
setTimeout(() => {
|
||||
setInfo(false);
|
||||
}, 4000);
|
||||
}
|
||||
} catch (error) {
|
||||
setTestIsLoading(false);
|
||||
console.log(error);
|
||||
setError('Send notification failed. Contact your administrator.');
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
}, 4000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* APPRISE ALERT */}
|
||||
<div className={classes.containerSetting}>
|
||||
<div className={classes.settingCategory}>
|
||||
<h2 style={{ alignSelf: 'baseline' }}>Apprise alert</h2>
|
||||
<Link
|
||||
style={{ alignSelf: 'baseline', marginLeft: '5px' }}
|
||||
href='https://borgwarehouse.com/docs/user-manual/account/#apprise'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
<IconExternalLink size={16} color='#6c737f' />
|
||||
</Link>
|
||||
</div>
|
||||
<div className={classes.setting}>
|
||||
<div className={classes.bwFormWrapper}>
|
||||
{/* NOTIFY SWITCH */}
|
||||
{checkIsLoading ? (
|
||||
<SpinnerCircularFixed
|
||||
size={30}
|
||||
thickness={150}
|
||||
speed={150}
|
||||
color='#704dff'
|
||||
secondaryColor='#c3b6fa'
|
||||
/>
|
||||
) : (
|
||||
<Switch
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
switchName='Notify my Apprise services'
|
||||
switchDescription='You will receive an alert on all your services every 24H if you have a down status.'
|
||||
onChange={(e) =>
|
||||
onChangeSwitchHandler({ appriseAlert: e })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* APPRISE SERVICES URLS */}
|
||||
<AppriseURLs />
|
||||
{/* APPRISE MODE SELECTION */}
|
||||
<AppriseMode />
|
||||
{/* APPRISE TEST BUTTON */}
|
||||
{testIsLoading ? (
|
||||
<SpinnerCircularFixed
|
||||
style={{ marginTop: '20px' }}
|
||||
size={30}
|
||||
thickness={150}
|
||||
speed={150}
|
||||
color='#704dff'
|
||||
secondaryColor='#c3b6fa'
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
style={{ marginTop: '20px' }}
|
||||
className='defaultButton'
|
||||
onClick={() => onSendTestAppriseHandler()}
|
||||
>
|
||||
Send a test notification
|
||||
</button>
|
||||
)}
|
||||
{info && (
|
||||
<span
|
||||
style={{ marginLeft: '10px', color: '#119300' }}
|
||||
>
|
||||
Notification successfully sent.
|
||||
</span>
|
||||
)}
|
||||
{error && <Error message={error} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import { IconExternalLink } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast, ToastOptions } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from '../UserSettings.module.css';
|
||||
|
||||
//Components
|
||||
import Switch from '~/Components/UI/Switch/Switch';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
import { Optional } from '~/types';
|
||||
import AppriseMode from './AppriseMode/AppriseMode';
|
||||
import AppriseURLs from './AppriseURLs/AppriseURLs';
|
||||
|
||||
type AppriseAlertDataForm = {
|
||||
appriseAlert: boolean;
|
||||
};
|
||||
|
||||
export default function AppriseAlertSettings() {
|
||||
const toastOptions: ToastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
};
|
||||
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
////State
|
||||
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
|
||||
const [isSwitchDisabled, setIsSwitchDisabled] = useState(true);
|
||||
const [isAlertEnabled, setIsAlertEnabled] = useState<Optional<boolean>>(undefined);
|
||||
const [info, setInfo] = useState(false);
|
||||
|
||||
////LifeCycle
|
||||
//Component did mount
|
||||
useEffect(() => {
|
||||
//Initial fetch to get the status of Apprise Alert
|
||||
const getAppriseAlert = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/notif/apprise/alert', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data: Optional<AppriseAlertDataForm> = await response.json();
|
||||
setIsAlertEnabled(data?.appriseAlert ?? false);
|
||||
setIsSwitchDisabled(false);
|
||||
} catch (error) {
|
||||
setIsSwitchDisabled(true);
|
||||
setIsAlertEnabled(false);
|
||||
toast.error('Fetching Apprise alert setting failed', toastOptions);
|
||||
}
|
||||
};
|
||||
getAppriseAlert();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
////Functions
|
||||
//Switch to enable/disable Apprise notifications
|
||||
const onChangeSwitchHandler = async (data: AppriseAlertDataForm) => {
|
||||
start();
|
||||
setIsSwitchDisabled(true);
|
||||
await fetch('/api/v1/notif/apprise/alert', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok && typeof data.appriseAlert === 'boolean') {
|
||||
setIsAlertEnabled(data.appriseAlert);
|
||||
toast.success(
|
||||
data.appriseAlert ? 'Apprise notifications enabled' : 'Apprise notifications disabled',
|
||||
toastOptions
|
||||
);
|
||||
} else {
|
||||
toast.error('Update Apprise failed', toastOptions);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Update Apprise failed', toastOptions);
|
||||
})
|
||||
.finally(() => {
|
||||
stop();
|
||||
setIsSwitchDisabled(false);
|
||||
});
|
||||
};
|
||||
|
||||
//Send Apprise test notification to services
|
||||
const onSendTestAppriseHandler = async () => {
|
||||
start();
|
||||
setIsSendingTestNotification(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/notif/apprise/test', {
|
||||
method: 'POST',
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error(result.message, toastOptions);
|
||||
} else {
|
||||
setInfo(true);
|
||||
setTimeout(() => {
|
||||
setInfo(false);
|
||||
}, 4000);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Sending test notification failed', toastOptions);
|
||||
} finally {
|
||||
stop();
|
||||
setIsSendingTestNotification(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* APPRISE ALERT */}
|
||||
<div className={classes.containerSetting}>
|
||||
<div className={classes.settingCategory}>
|
||||
<h2 style={{ alignSelf: 'baseline' }}>Apprise alert</h2>
|
||||
<Link
|
||||
style={{ alignSelf: 'baseline', marginLeft: '5px' }}
|
||||
href='https://borgwarehouse.com/docs/user-manual/account/#apprise'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
<IconExternalLink size={16} color='#6c737f' />
|
||||
</Link>
|
||||
</div>
|
||||
<div className={classes.setting}>
|
||||
<div className={classes.bwFormWrapper}>
|
||||
<Switch
|
||||
loading={isAlertEnabled === undefined}
|
||||
checked={isAlertEnabled}
|
||||
disabled={isSwitchDisabled}
|
||||
switchName='Notify my Apprise services'
|
||||
switchDescription='You will receive an alert on all your services every 24H if you have a down status.'
|
||||
onChange={(e) => onChangeSwitchHandler({ appriseAlert: e })}
|
||||
/>
|
||||
{isAlertEnabled && (
|
||||
<>
|
||||
<AppriseURLs />
|
||||
<AppriseMode />
|
||||
<button
|
||||
disabled={isSendingTestNotification}
|
||||
style={{ marginTop: '20px' }}
|
||||
className='defaultButton'
|
||||
onClick={() => onSendTestAppriseHandler()}
|
||||
>
|
||||
Send a test notification
|
||||
</button>
|
||||
{info && (
|
||||
<span style={{ marginLeft: '10px', color: '#119300' }}>
|
||||
Notification successfully sent.
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
//Lib
|
||||
import { useEffect } from 'react';
|
||||
import classes from '../../UserSettings.module.css';
|
||||
import { useState } from 'react';
|
||||
import { SpinnerCircularFixed } from 'spinners-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
//Components
|
||||
import Error from '../../../../Components/UI/Error/Error';
|
||||
|
||||
export default function AppriseMode() {
|
||||
//Var
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm({ mode: 'onBlur' });
|
||||
|
||||
////State
|
||||
const [formIsLoading, setFormIsLoading] = useState(false);
|
||||
const [modeFormIsSaved, setModeFormIsSaved] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [displayStatelessURL, setDisplayStatelessURL] = useState(false);
|
||||
const [appriseMode, setAppriseMode] = useState('stateless');
|
||||
const [appriseStatelessURL, setAppriseStatelessURL] = useState();
|
||||
|
||||
////LifeCycle
|
||||
//Component did mount
|
||||
useEffect(() => {
|
||||
//Initial fetch to get Apprise Mode enabled
|
||||
const getAppriseMode = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/account/getAppriseMode', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
const { appriseStatelessURL, appriseMode } =
|
||||
await response.json();
|
||||
setAppriseMode(appriseMode);
|
||||
if (appriseMode == 'stateless') {
|
||||
setAppriseStatelessURL(appriseStatelessURL);
|
||||
setDisplayStatelessURL(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Fetching Apprise Mode failed.');
|
||||
}
|
||||
};
|
||||
getAppriseMode();
|
||||
}, []);
|
||||
|
||||
////Functions
|
||||
//Form submit handler to modify Apprise Mode
|
||||
const modeFormSubmitHandler = async (data) => {
|
||||
//Remove old error
|
||||
setError();
|
||||
//Loading button on submit to avoid multiple send.
|
||||
setFormIsLoading(true);
|
||||
//POST API to update Apprise Mode
|
||||
try {
|
||||
const response = await fetch('/api/account/updateAppriseMode', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setFormIsLoading(false);
|
||||
setError(result.message);
|
||||
setTimeout(() => setError(), 4000);
|
||||
} else {
|
||||
setFormIsLoading(false);
|
||||
setModeFormIsSaved(true);
|
||||
setTimeout(() => setModeFormIsSaved(false), 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
setFormIsLoading(false);
|
||||
setError('Change mode failed. Contact your administrator.');
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
}, 4000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* APPRISE MODE SELECTION */}
|
||||
<div className={classes.headerFormAppriseUrls}>
|
||||
<div style={{ margin: '0px 10px 0px 0px' }}>Apprise mode</div>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{formIsLoading && (
|
||||
<SpinnerCircularFixed
|
||||
size={18}
|
||||
thickness={150}
|
||||
speed={150}
|
||||
color='#704dff'
|
||||
secondaryColor='#c3b6fa'
|
||||
/>
|
||||
)}
|
||||
{modeFormIsSaved && (
|
||||
<div className={classes.formIsSavedMessage}>
|
||||
✅ Apprise mode has been saved.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && <Error message={error} />}
|
||||
<form
|
||||
className={classes.bwForm}
|
||||
onBlur={handleSubmit(modeFormSubmitHandler)}
|
||||
>
|
||||
<div className='radio-group'>
|
||||
<label style={{ marginRight: '50px' }}>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<input
|
||||
{...register('appriseMode')}
|
||||
type='radio'
|
||||
value='package'
|
||||
onClick={() => {
|
||||
setDisplayStatelessURL(false);
|
||||
setAppriseMode('package');
|
||||
}}
|
||||
checked={
|
||||
appriseMode == 'package' ? true : false
|
||||
}
|
||||
/>
|
||||
<span>Local package</span>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<input
|
||||
{...register('appriseMode')}
|
||||
value='stateless'
|
||||
type='radio'
|
||||
onClick={() => {
|
||||
setDisplayStatelessURL(true);
|
||||
setAppriseMode('stateless');
|
||||
}}
|
||||
checked={
|
||||
appriseMode == 'stateless' ? true : false
|
||||
}
|
||||
/>
|
||||
<span>Stateless API server</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{displayStatelessURL && (
|
||||
<input
|
||||
type='text'
|
||||
placeholder='http://localhost:8000'
|
||||
defaultValue={appriseStatelessURL}
|
||||
{...register('appriseStatelessURL', {
|
||||
pattern: {
|
||||
value: /^(http|https):\/\/.+/g,
|
||||
message: 'Invalid URL format.',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{errors.appriseStatelessURL && (
|
||||
<small className={classes.errorMessage}>
|
||||
{errors.appriseStatelessURL.message}
|
||||
</small>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { AppriseModeDTO, AppriseModeEnum, Optional } from '~/types';
|
||||
import classes from '../../UserSettings.module.css';
|
||||
|
||||
//Components
|
||||
import Error from '~/Components/UI/Error/Error';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
import { useFormStatus } from '~/hooks';
|
||||
|
||||
type AppriseModeDataForm = {
|
||||
appriseMode: string;
|
||||
appriseStatelessURL: string;
|
||||
};
|
||||
|
||||
export default function AppriseMode() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<AppriseModeDataForm>({ mode: 'onChange' });
|
||||
|
||||
const { error, setIsLoading, handleSuccess, handleError, clearError } = useFormStatus();
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
const [displayStatelessURL, setDisplayStatelessURL] = useState<boolean>(false);
|
||||
const [appriseMode, setAppriseMode] = useState<Optional<AppriseModeEnum>>(
|
||||
AppriseModeEnum.STATELESS
|
||||
);
|
||||
const [appriseStatelessURL, setAppriseStatelessURL] = useState<Optional<string>>();
|
||||
|
||||
//Component did mount
|
||||
useEffect(() => {
|
||||
//Initial fetch to get Apprise Mode enabled
|
||||
const getAppriseMode = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/notif/apprise/mode', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data: AppriseModeDTO = await response.json();
|
||||
const { appriseStatelessURL, appriseMode } = data;
|
||||
setAppriseMode(appriseMode);
|
||||
|
||||
if (appriseMode == AppriseModeEnum.STATELESS) {
|
||||
setAppriseStatelessURL(appriseStatelessURL);
|
||||
setDisplayStatelessURL(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Fetching Apprise Mode failed.');
|
||||
}
|
||||
};
|
||||
getAppriseMode();
|
||||
}, []);
|
||||
|
||||
////Functions
|
||||
const modeFormSubmitHandler = async (data: AppriseModeDataForm) => {
|
||||
clearError();
|
||||
setIsLoading(true);
|
||||
start();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/notif/apprise/mode', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
handleError(result.message);
|
||||
} else {
|
||||
handleSuccess();
|
||||
}
|
||||
} catch (error) {
|
||||
handleError('The Apprise mode change has failed');
|
||||
} finally {
|
||||
stop();
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* APPRISE MODE SELECTION */}
|
||||
<div className={classes.headerFormAppriseUrls}>
|
||||
<div style={{ margin: '0px 10px 0px 0px' }}>Apprise mode</div>
|
||||
</div>
|
||||
{error && <Error message={error} />}
|
||||
<form className={classes.bwForm} onChange={handleSubmit(modeFormSubmitHandler)}>
|
||||
<div className='radio-group'>
|
||||
<label style={{ marginRight: '50px' }}>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<input
|
||||
{...register('appriseMode')}
|
||||
type='radio'
|
||||
value='package'
|
||||
onClick={() => {
|
||||
setDisplayStatelessURL(false);
|
||||
setAppriseMode(AppriseModeEnum.PACKAGE);
|
||||
}}
|
||||
checked={appriseMode == 'package' ? true : false}
|
||||
/>
|
||||
<span>Local package</span>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<input
|
||||
{...register('appriseMode')}
|
||||
value='stateless'
|
||||
type='radio'
|
||||
onClick={() => {
|
||||
setDisplayStatelessURL(true);
|
||||
setAppriseMode(AppriseModeEnum.STATELESS);
|
||||
}}
|
||||
checked={appriseMode == 'stateless' ? true : false}
|
||||
/>
|
||||
<span>Stateless API server</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{displayStatelessURL && (
|
||||
<input
|
||||
type='text'
|
||||
placeholder='http://localhost:8000'
|
||||
defaultValue={appriseStatelessURL}
|
||||
{...register('appriseStatelessURL', {
|
||||
pattern: {
|
||||
value: /^(http|https):\/\/.+/g,
|
||||
message: 'Invalid URL format.',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{errors.appriseStatelessURL && (
|
||||
<small className={classes.errorMessage}>{errors.appriseStatelessURL.message}</small>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
//Lib
|
||||
import { useEffect } from 'react';
|
||||
import classes from '../../UserSettings.module.css';
|
||||
import { useState } from 'react';
|
||||
import { SpinnerCircularFixed } from 'spinners-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
//Components
|
||||
import Error from '../../../../Components/UI/Error/Error';
|
||||
|
||||
export default function AppriseURLs() {
|
||||
//Var
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm({ mode: 'onBlur' });
|
||||
|
||||
////State
|
||||
const [formIsLoading, setFormIsLoading] = useState(false);
|
||||
const [urlsFormIsSaved, setUrlsFormIsSaved] = useState(false);
|
||||
const [appriseServicesList, setAppriseServicesList] = useState();
|
||||
const [error, setError] = useState();
|
||||
|
||||
////LifeCycle
|
||||
//Component did mount
|
||||
useEffect(() => {
|
||||
//Initial fetch to build the list of Apprise Services enabled
|
||||
const getAppriseServices = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
'/api/account/getAppriseServices',
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
let servicesArray = (await response.json()).appriseServices;
|
||||
const AppriseServicesListToText = () => {
|
||||
let list = '';
|
||||
for (let service of servicesArray) {
|
||||
list += service + '\n';
|
||||
}
|
||||
return list;
|
||||
};
|
||||
setAppriseServicesList(AppriseServicesListToText());
|
||||
} catch (error) {
|
||||
console.log('Fetching Apprise services list failed.');
|
||||
}
|
||||
};
|
||||
getAppriseServices();
|
||||
}, []);
|
||||
|
||||
////Functions
|
||||
//Form submit handler to modify Apprise services
|
||||
const urlsFormSubmitHandler = async (data) => {
|
||||
//Remove old error
|
||||
setError();
|
||||
//Loading button on submit to avoid multiple send.
|
||||
setFormIsLoading(true);
|
||||
//POST API to update Apprise Services
|
||||
try {
|
||||
const response = await fetch('/api/account/updateAppriseServices', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setFormIsLoading(false);
|
||||
setError(result.message);
|
||||
setTimeout(() => setError(), 4000);
|
||||
} else {
|
||||
setFormIsLoading(false);
|
||||
setUrlsFormIsSaved(true);
|
||||
setTimeout(() => setUrlsFormIsSaved(false), 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
setFormIsLoading(false);
|
||||
setError(
|
||||
'Failed to update your services. Contact your administrator.'
|
||||
);
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
}, 4000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* APPRISE SERVICES URLS */}
|
||||
<div className={classes.headerFormAppriseUrls}>
|
||||
<div style={{ marginRight: '10px' }}>Apprise URLs</div>
|
||||
{error && <Error message={error} />}
|
||||
<div style={{ display: 'flex' }}>
|
||||
{formIsLoading && (
|
||||
<SpinnerCircularFixed
|
||||
size={18}
|
||||
thickness={150}
|
||||
speed={150}
|
||||
color='#704dff'
|
||||
secondaryColor='#c3b6fa'
|
||||
/>
|
||||
)}
|
||||
{urlsFormIsSaved && (
|
||||
<div className={classes.formIsSavedMessage}>
|
||||
✅ Apprise configuration has been saved.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onBlur={handleSubmit(urlsFormSubmitHandler)}
|
||||
className={classes.bwForm + ' ' + classes.currentSetting}
|
||||
>
|
||||
<textarea
|
||||
style={{ height: '100px' }}
|
||||
type='text'
|
||||
placeholder={
|
||||
'matrixs://{user}:{password}@{matrixhost}\ndiscord://{WebhookID}/{WebhookToken}\nmmosts://user@hostname/authkey'
|
||||
}
|
||||
defaultValue={appriseServicesList}
|
||||
{...register('appriseURLs', {
|
||||
pattern: {
|
||||
value: /^.+:\/\/.+$/gm,
|
||||
message: 'Invalid URLs format.',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.appriseURLs && (
|
||||
<small className={classes.errorMessage}>
|
||||
{errors.appriseURLs.message}
|
||||
</small>
|
||||
)}
|
||||
</form>
|
||||
<div
|
||||
style={{
|
||||
color: '#6c737f',
|
||||
fontSize: '0.875rem',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
Use{' '}
|
||||
<a
|
||||
style={{
|
||||
color: '#6d4aff',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
href='https://github.com/caronc/apprise#supported-notifications'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Apprise URLs
|
||||
</a>{' '}
|
||||
to send a notification to any service. Only one URL per line.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { AppriseServicesDTO, Optional } from '~/types';
|
||||
import classes from '../../UserSettings.module.css';
|
||||
|
||||
//Components
|
||||
import Error from '~/Components/UI/Error/Error';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
import { useFormStatus } from '~/hooks';
|
||||
|
||||
type AppriseURLsDataForm = {
|
||||
appriseURLs: string;
|
||||
};
|
||||
|
||||
export default function AppriseURLs() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<AppriseURLsDataForm>({ mode: 'onBlur' });
|
||||
|
||||
const { isSaved, error, handleSuccess, handleError, clearError } = useFormStatus();
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
const [appriseServicesList, setAppriseServicesList] = useState<Optional<string>>();
|
||||
const [fetchError, setFetchError] = useState<Optional<boolean>>();
|
||||
|
||||
//Component did mount
|
||||
useEffect(() => {
|
||||
//Initial fetch to build the list of Apprise Services enabled
|
||||
const getAppriseServices = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/notif/apprise/services', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data: AppriseServicesDTO = await response.json();
|
||||
const servicesText = data.appriseServices?.join('\n');
|
||||
setAppriseServicesList(servicesText);
|
||||
setFetchError(false);
|
||||
} catch (error) {
|
||||
setFetchError(true);
|
||||
handleError('Fetching Apprise services list failed.');
|
||||
}
|
||||
};
|
||||
getAppriseServices();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
//Form submit handler to modify Apprise services
|
||||
const urlsFormSubmitHandler = async (data: AppriseURLsDataForm) => {
|
||||
clearError();
|
||||
start();
|
||||
if (fetchError) {
|
||||
handleError('Cannot update Apprise services. Failed to fetch the initial list.');
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/notif/apprise/services', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
handleError(result.message);
|
||||
} else {
|
||||
handleSuccess();
|
||||
}
|
||||
} catch (error) {
|
||||
handleError('Failed to update your Apprise services.');
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* APPRISE SERVICES URLS */}
|
||||
<div className={classes.headerFormAppriseUrls}>
|
||||
<div style={{ marginRight: '10px' }}>Apprise URLs</div>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{isSaved && (
|
||||
<div className={classes.formIsSavedMessage}>
|
||||
✅ Apprise configuration has been saved.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onBlur={handleSubmit(urlsFormSubmitHandler)}
|
||||
className={classes.bwForm + ' ' + classes.currentSetting}
|
||||
>
|
||||
<textarea
|
||||
style={{ height: '100px' }}
|
||||
placeholder={
|
||||
'matrixs://{user}:{password}@{matrixhost}\ndiscord://{WebhookID}/{WebhookToken}\nmmosts://user@hostname/authkey'
|
||||
}
|
||||
defaultValue={appriseServicesList}
|
||||
{...register('appriseURLs', {
|
||||
pattern: {
|
||||
value: /^.+:\/\/.+$/gm,
|
||||
message: 'Invalid URLs format.',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.appriseURLs && (
|
||||
<small className={classes.errorMessage}>{errors.appriseURLs.message}</small>
|
||||
)}
|
||||
</form>
|
||||
<div
|
||||
style={{
|
||||
color: '#6c737f',
|
||||
fontSize: '0.875rem',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
Use{' '}
|
||||
<a
|
||||
style={{
|
||||
color: '#6d4aff',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
href='https://github.com/caronc/apprise#supported-notifications'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Apprise URLs
|
||||
</a>{' '}
|
||||
to send a notification to any service. Only one URL per line.
|
||||
</div>
|
||||
{error && <Error message={error} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
//Lib
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from '../UserSettings.module.css';
|
||||
import { useState } from 'react';
|
||||
import { SpinnerCircularFixed } from 'spinners-react';
|
||||
import { IconExternalLink } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
//Components
|
||||
import Error from '../../../Components/UI/Error/Error';
|
||||
import Switch from '../../../Components/UI/Switch/Switch';
|
||||
|
||||
export default function EmailAlertSettings() {
|
||||
//Var
|
||||
const toastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
//Callback > re-enabled button after notification.
|
||||
onClose: () => setDisabled(false),
|
||||
};
|
||||
|
||||
////State
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [testIsLoading, setTestIsLoading] = useState(false);
|
||||
const [error, setError] = useState();
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [checked, setChecked] = useState();
|
||||
const [info, setInfo] = useState(false);
|
||||
|
||||
////LifeCycle
|
||||
//Component did mount
|
||||
useEffect(() => {
|
||||
const dataFetch = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/account/getEmailAlert', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
setChecked((await response.json()).emailAlert);
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
setError(
|
||||
'Fetching email alert setting failed. Contact your administrator.'
|
||||
);
|
||||
console.log('Fetching email alert setting failed.');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
dataFetch();
|
||||
}, []);
|
||||
|
||||
////Functions
|
||||
//Switch to enable/disable Email notifications
|
||||
const onChangeSwitchHandler = async (data) => {
|
||||
//Remove old error
|
||||
setError();
|
||||
//Disabled button
|
||||
setDisabled(true);
|
||||
await fetch('/api/account/updateEmailAlert', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then((response) => {
|
||||
console.log(response);
|
||||
if (response.ok) {
|
||||
if (data.emailAlert) {
|
||||
setChecked(!checked);
|
||||
toast.success(
|
||||
'Email notification enabled !',
|
||||
toastOptions
|
||||
);
|
||||
} else {
|
||||
setChecked(!checked);
|
||||
toast.success(
|
||||
'Email notification disabled !',
|
||||
toastOptions
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setError('Update email alert setting failed.');
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
setDisabled(false);
|
||||
}, 4000);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
setError('Update failed. Contact your administrator.');
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
setDisabled(false);
|
||||
}, 4000);
|
||||
});
|
||||
};
|
||||
|
||||
//Send a test notification by email
|
||||
const onSendTestMailHandler = async () => {
|
||||
//Loading
|
||||
setTestIsLoading(true);
|
||||
//Remove old error
|
||||
setError();
|
||||
await fetch('/api/account/sendTestEmail', {
|
||||
method: 'POST',
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
setTestIsLoading(false);
|
||||
setError('Failed to send the notification.');
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
}, 4000);
|
||||
} else {
|
||||
setTestIsLoading(false);
|
||||
setInfo(true);
|
||||
setTimeout(() => {
|
||||
setInfo(false);
|
||||
}, 4000);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setTestIsLoading(false);
|
||||
console.log(error);
|
||||
setError('Send email failed. Contact your administrator.');
|
||||
setTimeout(() => {
|
||||
setError();
|
||||
}, 4000);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* EMAIL ALERT */}
|
||||
<div className={classes.containerSetting}>
|
||||
<div className={classes.settingCategory}>
|
||||
<h2 style={{ alignSelf: 'baseline' }}>Email alert</h2>
|
||||
<Link
|
||||
style={{ alignSelf: 'baseline', marginLeft: '5px' }}
|
||||
href='https://borgwarehouse.com/docs/user-manual/account/#alerting'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
<IconExternalLink size={16} color='#6c737f' />
|
||||
</Link>
|
||||
</div>
|
||||
<div className={classes.setting}>
|
||||
<div className={classes.bwFormWrapper}>
|
||||
{isLoading ? (
|
||||
<SpinnerCircularFixed
|
||||
size={30}
|
||||
thickness={150}
|
||||
speed={150}
|
||||
color='#704dff'
|
||||
secondaryColor='#c3b6fa'
|
||||
/>
|
||||
) : (
|
||||
<Switch
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
switchName='Alert me by email'
|
||||
switchDescription='You will receive an alert every 24H if you have a down status.'
|
||||
onChange={(e) =>
|
||||
onChangeSwitchHandler({ emailAlert: e })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{testIsLoading ? (
|
||||
<SpinnerCircularFixed
|
||||
size={30}
|
||||
thickness={150}
|
||||
speed={150}
|
||||
color='#704dff'
|
||||
secondaryColor='#c3b6fa'
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className='defaultButton'
|
||||
onClick={onSendTestMailHandler}
|
||||
>
|
||||
Send a test mail
|
||||
</button>
|
||||
)}
|
||||
{info && (
|
||||
<span
|
||||
style={{ marginLeft: '10px', color: '#119300' }}
|
||||
>
|
||||
Mail successfully sent.
|
||||
</span>
|
||||
)}
|
||||
{error && <Error message={error} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
import { IconExternalLink } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast, ToastOptions } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
import { EmailAlertDTO, Optional } from '~/types';
|
||||
import classes from '../UserSettings.module.css';
|
||||
|
||||
//Components
|
||||
import Error from '~/Components/UI/Error/Error';
|
||||
import Switch from '~/Components/UI/Switch/Switch';
|
||||
import { useFormStatus } from '~/hooks';
|
||||
|
||||
export default function EmailAlertSettings() {
|
||||
const toastOptions: ToastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
//Callback > re-enabled button after notification.
|
||||
onClose: () => setIsSwitchDisabled(false),
|
||||
};
|
||||
|
||||
const { error, handleError, clearError } = useFormStatus();
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
////State
|
||||
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
|
||||
const [isSwitchDisabled, setIsSwitchDisabled] = useState(true);
|
||||
const [isAlertEnabled, setIsAlertEnabled] = useState<Optional<boolean>>(undefined);
|
||||
const [info, setInfo] = useState(false);
|
||||
|
||||
////LifeCycle
|
||||
//Component did mount
|
||||
useEffect(() => {
|
||||
const dataFetch = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/notif/email/alert', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data: Optional<EmailAlertDTO> = await response.json();
|
||||
setIsAlertEnabled(data?.emailAlert ?? false);
|
||||
setIsSwitchDisabled(false);
|
||||
} catch (error) {
|
||||
setIsSwitchDisabled(true);
|
||||
setIsAlertEnabled(false);
|
||||
handleError('Fetching email alert setting failed');
|
||||
}
|
||||
};
|
||||
dataFetch();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
////Functions
|
||||
//Switch to enable/disable Email notifications
|
||||
const onChangeSwitchHandler = async (data: EmailAlertDTO) => {
|
||||
clearError();
|
||||
start();
|
||||
setIsSwitchDisabled(true);
|
||||
await fetch('/api/v1/notif/email/alert', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok && typeof data.emailAlert === 'boolean') {
|
||||
setIsAlertEnabled(data.emailAlert);
|
||||
toast.success(
|
||||
data.emailAlert ? 'Email notification enabled !' : 'Email notification disabled !',
|
||||
toastOptions
|
||||
);
|
||||
} else {
|
||||
handleError('Update email alert setting failed.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
handleError('Update email alert setting failed.');
|
||||
})
|
||||
.finally(() => {
|
||||
stop();
|
||||
setIsSwitchDisabled(false);
|
||||
});
|
||||
};
|
||||
|
||||
//Send a test notification by email
|
||||
const onSendTestMailHandler = async () => {
|
||||
clearError();
|
||||
start();
|
||||
setIsSendingTestNotification(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/notif/email/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setIsSendingTestNotification(false);
|
||||
handleError(result.message);
|
||||
} else {
|
||||
setIsSendingTestNotification(false);
|
||||
setInfo(true);
|
||||
setTimeout(() => {
|
||||
setInfo(false);
|
||||
}, 4000);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsSendingTestNotification(false);
|
||||
handleError('Send notification failed');
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* EMAIL ALERT */}
|
||||
<div className={classes.containerSetting}>
|
||||
<div className={classes.settingCategory}>
|
||||
<h2 style={{ alignSelf: 'baseline' }}>Email alert</h2>
|
||||
<Link
|
||||
style={{ alignSelf: 'baseline', marginLeft: '5px' }}
|
||||
href='https://borgwarehouse.com/docs/user-manual/account/#alerting'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
<IconExternalLink size={16} color='#6c737f' />
|
||||
</Link>
|
||||
</div>
|
||||
<div className={classes.setting}>
|
||||
<div className={classes.bwFormWrapper}>
|
||||
<Switch
|
||||
loading={isAlertEnabled === undefined}
|
||||
checked={isAlertEnabled}
|
||||
disabled={isSwitchDisabled}
|
||||
switchName='Alert me by email'
|
||||
switchDescription='You will receive an alert every 24H if you have a down status.'
|
||||
onChange={(e) => onChangeSwitchHandler({ emailAlert: e })}
|
||||
/>
|
||||
|
||||
<button
|
||||
className='defaultButton'
|
||||
disabled={isSendingTestNotification}
|
||||
onClick={onSendTestMailHandler}
|
||||
>
|
||||
Send a test mail
|
||||
</button>
|
||||
{info && (
|
||||
<span style={{ marginLeft: '10px', color: '#119300' }}>Mail successfully sent.</span>
|
||||
)}
|
||||
{error && <Error message={error} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
//Lib
|
||||
import { toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from '../UserSettings.module.css';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { SpinnerDotted } from 'spinners-react';
|
||||
|
||||
//Components
|
||||
import Error from '../../../Components/UI/Error/Error';
|
||||
import Info from '../../../Components/UI/Info/Info';
|
||||
|
||||
export default function EmailSettings(props) {
|
||||
//Var
|
||||
const toastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 8000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm({ mode: 'onChange' });
|
||||
|
||||
////State
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState();
|
||||
const [info, setInfo] = useState(false);
|
||||
|
||||
////Functions
|
||||
//Form submit Handler for ADD a repo
|
||||
const formSubmitHandler = async (data) => {
|
||||
//Remove old error
|
||||
setError();
|
||||
//Loading button on submit to avoid multiple send.
|
||||
setIsLoading(true);
|
||||
//POST API to send the new mail address
|
||||
try {
|
||||
const response = await fetch('/api/account/updateEmail', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setIsLoading(false);
|
||||
reset();
|
||||
setError(result.message);
|
||||
setTimeout(() => setError(), 4000);
|
||||
} else {
|
||||
reset();
|
||||
setIsLoading(false);
|
||||
setInfo(true);
|
||||
toast.success('Email edited !', toastOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
reset();
|
||||
setIsLoading(false);
|
||||
setError("Can't update your email. Contact your administrator.");
|
||||
setTimeout(() => setError(), 4000);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{/* EMAIL */}
|
||||
<div className={classes.containerSetting}>
|
||||
<div className={classes.settingCategory}>
|
||||
<h2>Email</h2>
|
||||
</div>
|
||||
<div className={classes.setting}>
|
||||
<div className={classes.bwFormWrapper}>
|
||||
{info ? ( //For local JWTs (cookie) without an OAuth provider, Next-Auth does not allow
|
||||
//at the time this code is written to refresh client-side session information
|
||||
//without triggering a logout.
|
||||
//I chose to inform the user to reconnect rather than force logout.
|
||||
<Info message='Please, logout to update your session.' />
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit(formSubmitHandler)}
|
||||
className={
|
||||
classes.bwForm +
|
||||
' ' +
|
||||
classes.currentSetting
|
||||
}
|
||||
>
|
||||
<p>
|
||||
{error && <Error message={error} />}
|
||||
<input
|
||||
type='email'
|
||||
placeholder={props.email}
|
||||
{...register('email', {
|
||||
required: true,
|
||||
pattern: {
|
||||
value: /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
|
||||
message:
|
||||
'Your email is not valid.',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.email && (
|
||||
<small className={classes.errorMessage}>
|
||||
{errors.email.message}
|
||||
</small>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
className={classes.AccountSettingsButton}
|
||||
disabled={!isValid || isSubmitting}
|
||||
>
|
||||
{isLoading ? (
|
||||
<SpinnerDotted
|
||||
size={20}
|
||||
thickness={150}
|
||||
speed={100}
|
||||
color='#fff'
|
||||
/>
|
||||
) : (
|
||||
'Update your email'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
121
Containers/UserSettings/EmailSettings/EmailSettings.tsx
Normal file
121
Containers/UserSettings/EmailSettings/EmailSettings.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast, ToastOptions } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from '../UserSettings.module.css';
|
||||
|
||||
//Components
|
||||
import Error from '~/Components/UI/Error/Error';
|
||||
import Info from '~/Components/UI/Info/Info';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
import { useFormStatus } from '~/hooks';
|
||||
import { EmailSettingDTO } from '~/types/api/setting.types';
|
||||
|
||||
export default function EmailSettings(props: EmailSettingDTO) {
|
||||
const toastOptions: ToastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 8000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<EmailSettingDTO>({ mode: 'onChange' });
|
||||
|
||||
const { isLoading, error, setIsLoading, handleError, clearError } = useFormStatus();
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
////State
|
||||
const [info, setInfo] = useState(false);
|
||||
|
||||
////Functions
|
||||
const formSubmitHandler = async (data: EmailSettingDTO) => {
|
||||
start();
|
||||
clearError();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/account/email', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
reset();
|
||||
handleError(result.message);
|
||||
} else {
|
||||
reset();
|
||||
setIsLoading(false);
|
||||
setInfo(true);
|
||||
toast.success('Email edited !', toastOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
reset();
|
||||
handleError('Updating your email failed.');
|
||||
} finally {
|
||||
stop();
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{/* EMAIL */}
|
||||
<div className={classes.containerSetting}>
|
||||
<div className={classes.settingCategory}>
|
||||
<h2>Email</h2>
|
||||
</div>
|
||||
<div className={classes.setting}>
|
||||
<div className={classes.bwFormWrapper}>
|
||||
{info ? ( //For local JWTs (cookie) without an OAuth provider, Next-Auth does not allow
|
||||
//at the time this code is written to refresh client-side session information
|
||||
//without triggering a logout.
|
||||
//I chose to inform the user to reconnect rather than force logout.
|
||||
<Info message='Please, logout to update your session.' />
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit(formSubmitHandler)}
|
||||
className={classes.bwForm + ' ' + classes.currentSetting}
|
||||
>
|
||||
<p>
|
||||
{error && <Error message={error} />}
|
||||
<input
|
||||
type='email'
|
||||
placeholder={props.email}
|
||||
{...register('email', {
|
||||
required: true,
|
||||
pattern: {
|
||||
value:
|
||||
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
|
||||
message: 'Your email is not valid.',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.email && (
|
||||
<small className={classes.errorMessage}>{errors.email.message}</small>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
className={classes.AccountSettingsButton}
|
||||
disabled={isSubmitting || isLoading}
|
||||
>
|
||||
Update your email
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
340
Containers/UserSettings/Integrations/Integrations.tsx
Normal file
340
Containers/UserSettings/Integrations/Integrations.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import { IconExternalLink, IconTrash } from '@tabler/icons-react';
|
||||
import { fromUnixTime } from 'date-fns';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast, ToastOptions } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useFormStatus } from '~/hooks';
|
||||
import { IntegrationTokenType, Optional, TokenPermissionEnum, TokenPermissionsType } from '~/types';
|
||||
import classes from '../UserSettings.module.css';
|
||||
|
||||
//Components
|
||||
import CopyButton from '~/Components/UI/CopyButton/CopyButton';
|
||||
import Error from '~/Components/UI/Error/Error';
|
||||
import Info from '~/Components/UI/Info/Info';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
|
||||
type IntegrationsDataForm = {
|
||||
tokenName: string;
|
||||
};
|
||||
|
||||
export default function Integrations() {
|
||||
const toastOptions: ToastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm<IntegrationsDataForm>({ mode: 'onChange' });
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
const { error, handleError, clearError, setIsLoading, isLoading } = useFormStatus();
|
||||
|
||||
const renderPermissionBadges = (permissions: TokenPermissionsType) => {
|
||||
return Object.entries(permissions)
|
||||
.filter(([, hasPermission]) => hasPermission)
|
||||
.map(([key]) => (
|
||||
<div key={key} className={classes.permissionBadge}>
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
////State
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [tokenList, setTokenList] = useState<Array<IntegrationTokenType>>();
|
||||
const [lastGeneratedToken, setLastGeneratedToken] =
|
||||
useState<Optional<{ name: string; value: string }>>();
|
||||
const [deletingToken, setDeletingToken] = useState<Optional<IntegrationTokenType>>(undefined);
|
||||
const [permissions, setPermissions] = useState<TokenPermissionsType>({
|
||||
create: false,
|
||||
read: false,
|
||||
update: false,
|
||||
delete: false,
|
||||
});
|
||||
|
||||
const fetchTokenList = async () => {
|
||||
start();
|
||||
try {
|
||||
const response = await fetch('/api/v1/integration/token-manager', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
const data: Array<IntegrationTokenType> = await response.json();
|
||||
setTokenList(data);
|
||||
} catch (error) {
|
||||
handleError('Fetching token list failed.');
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
////LifeCycle
|
||||
useEffect(() => {
|
||||
fetchTokenList();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Permissions handler
|
||||
const hasNoPermissionSelected = () => {
|
||||
return !Object.values(permissions).some((value) => value);
|
||||
};
|
||||
const togglePermission = (permissionType: TokenPermissionEnum) => {
|
||||
const updatedPermissions = {
|
||||
...permissions,
|
||||
[permissionType]: !permissions[permissionType],
|
||||
};
|
||||
setPermissions(updatedPermissions);
|
||||
};
|
||||
const resetPermissions = () => {
|
||||
setPermissions({
|
||||
create: false,
|
||||
read: false,
|
||||
update: false,
|
||||
delete: false,
|
||||
});
|
||||
};
|
||||
|
||||
//Form submit handler to ADD a new token
|
||||
const formSubmitHandler = async (data: IntegrationsDataForm) => {
|
||||
start();
|
||||
clearError();
|
||||
setIsLoading(true);
|
||||
|
||||
// Post API to send the new token integration
|
||||
try {
|
||||
const response = await fetch('/api/v1/integration/token-manager', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: data.tokenName,
|
||||
permissions: permissions,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
setLastGeneratedToken({ name: data.tokenName, value: result.token });
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error(result.message, toastOptions);
|
||||
} else {
|
||||
fetchTokenList();
|
||||
toast.success('🔑 Token generated !', toastOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to generate a new token', toastOptions);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
resetPermissions();
|
||||
reset();
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
//Delete token
|
||||
const deleteTokenHandler = async (tokenName: string) => {
|
||||
setIsDeleteLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/integration/token-manager', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: tokenName,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error(result.message, toastOptions);
|
||||
setIsDeleteLoading(false);
|
||||
} else {
|
||||
fetchTokenList();
|
||||
setIsDeleteLoading(false);
|
||||
toast.success('🗑️ Token deleted !', toastOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsDeleteLoading(false);
|
||||
toast.error('Failed to delete the token', toastOptions);
|
||||
} finally {
|
||||
setIsDeleteLoading(false);
|
||||
setDeletingToken(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.containerSetting}>
|
||||
<div className={classes.settingCategory}>
|
||||
<h2 style={{ alignSelf: 'baseline' }}>Generate token</h2>
|
||||
<Link
|
||||
style={{ alignSelf: 'baseline', marginLeft: '5px' }}
|
||||
href='https://borgwarehouse.com/docs/developer-manual/api/'
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
<IconExternalLink size={16} color='#6c737f' />
|
||||
</Link>
|
||||
</div>
|
||||
<div className={classes.setting}>
|
||||
<form
|
||||
onSubmit={handleSubmit(formSubmitHandler)}
|
||||
className={[classes.bwForm, classes.tokenGen].join(' ')}
|
||||
>
|
||||
<div className={classes.tokenWrapper}>
|
||||
<input
|
||||
type='text'
|
||||
autoComplete='off'
|
||||
placeholder='Token name'
|
||||
{...register('tokenName', {
|
||||
required: true,
|
||||
pattern: /^[a-zA-Z0-9_-]*$/,
|
||||
maxLength: 25,
|
||||
})}
|
||||
/>
|
||||
|
||||
<div className={classes.permissionsWrapper}>
|
||||
<div
|
||||
className={`${classes.permissionBadge} ${permissions.create ? classes.highlight : ''}`}
|
||||
onClick={() => togglePermission(TokenPermissionEnum.CREATE)}
|
||||
>
|
||||
Create
|
||||
</div>
|
||||
<div
|
||||
className={`${classes.permissionBadge} ${permissions.read ? classes.highlight : ''}`}
|
||||
onClick={() => togglePermission(TokenPermissionEnum.READ)}
|
||||
>
|
||||
Read
|
||||
</div>
|
||||
<div
|
||||
className={`${classes.permissionBadge} ${permissions.update ? classes.highlight : ''}`}
|
||||
onClick={() => togglePermission(TokenPermissionEnum.UPDATE)}
|
||||
>
|
||||
Update
|
||||
</div>
|
||||
<div
|
||||
className={`${classes.permissionBadge} ${permissions.delete ? classes.highlight : ''}`}
|
||||
onClick={() => togglePermission(TokenPermissionEnum.DELETE)}
|
||||
>
|
||||
Delete
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={classes.AccountSettingsButton}
|
||||
disabled={!isValid || isSubmitting || hasNoPermissionSelected()}
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</form>
|
||||
{errors.tokenName && errors.tokenName.type === 'maxLength' && (
|
||||
<small className={classes.errorMessage}>25 characters max.</small>
|
||||
)}
|
||||
{errors.tokenName && errors.tokenName.type === 'pattern' && (
|
||||
<small className={classes.errorMessage}>
|
||||
Only alphanumeric characters, dashes, and underscores are allowed (no spaces).
|
||||
</small>
|
||||
)}
|
||||
{error && <Error message={error} />}
|
||||
</div>
|
||||
</div>
|
||||
{tokenList && tokenList.length > 0 && (
|
||||
<div className={classes.containerSetting}>
|
||||
<div className={classes.settingCategory}>
|
||||
<h2>API Tokens</h2>
|
||||
</div>
|
||||
<div className={classes.tokenCardList}>
|
||||
{tokenList
|
||||
.slice()
|
||||
.sort((a, b) => b.creation - a.creation)
|
||||
.map((token, index) => (
|
||||
<div key={index} className={classes.tokenCardWrapper}>
|
||||
<div
|
||||
className={`${classes.tokenCard} ${
|
||||
lastGeneratedToken && lastGeneratedToken.name === token.name
|
||||
? classes.tokenCardHighlight
|
||||
: ''
|
||||
} ${deletingToken && deletingToken.name === token.name ? classes.tokenCardBlurred : ''}`}
|
||||
>
|
||||
<div className={classes.tokenCardHeader}>{token.name}</div>
|
||||
<div className={classes.tokenCardBody}>
|
||||
<div className={classes.tokenInfo}>
|
||||
<strong>Created at:</strong>
|
||||
{fromUnixTime(token.creation).toLocaleString()}
|
||||
</div>
|
||||
<div className={classes.tokenInfo}>
|
||||
<strong>Permission:</strong>
|
||||
<div className={classes.permissionBadges}>
|
||||
{renderPermissionBadges(token.permissions)}
|
||||
</div>
|
||||
</div>
|
||||
{lastGeneratedToken && lastGeneratedToken.name === token.name && (
|
||||
<>
|
||||
<div className={classes.tokenInfo}>
|
||||
<strong>Token:</strong>
|
||||
<CopyButton
|
||||
size={22}
|
||||
displayIconConfirmation={true}
|
||||
dataToCopy={lastGeneratedToken.value}
|
||||
>
|
||||
<span>{lastGeneratedToken.value}</span>
|
||||
</CopyButton>
|
||||
</div>
|
||||
<Info
|
||||
color='#3498db'
|
||||
message='This token will not be shown again. Please save it.'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{deletingToken && deletingToken.name === token.name && (
|
||||
<div className={classes.deleteConfirmationButtons}>
|
||||
<button
|
||||
className={classes.confirmButton}
|
||||
onClick={() => deleteTokenHandler(token.name)}
|
||||
disabled={isDeleteLoading}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
{!isDeleteLoading && (
|
||||
<button
|
||||
className={classes.cancelButton}
|
||||
onClick={() => setDeletingToken(undefined)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.deleteToken}>
|
||||
<IconTrash
|
||||
cursor={'pointer'}
|
||||
color='#ea1313'
|
||||
strokeWidth={2}
|
||||
onClick={() => setDeletingToken(token)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
//Lib
|
||||
import { toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from '../UserSettings.module.css';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { SpinnerDotted } from 'spinners-react';
|
||||
|
||||
//Components
|
||||
import Error from '../../../Components/UI/Error/Error';
|
||||
|
||||
export default function PasswordSettings(props) {
|
||||
//Var
|
||||
const toastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm({ mode: 'onChange' });
|
||||
|
||||
////State
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState();
|
||||
|
||||
////Functions
|
||||
//Form submit Handler for ADD a repo
|
||||
const formSubmitHandler = async (data) => {
|
||||
console.log(data);
|
||||
|
||||
//Remove old error
|
||||
setError();
|
||||
//Loading button on submit to avoid multiple send.
|
||||
setIsLoading(true);
|
||||
//POST API to send the new and old password
|
||||
try {
|
||||
const response = await fetch('/api/account/updatePassword', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setIsLoading(false);
|
||||
reset();
|
||||
setError(result.message);
|
||||
setTimeout(() => setError(), 4000);
|
||||
} else {
|
||||
reset();
|
||||
setIsLoading(false);
|
||||
toast.success('🔑 Password edited !', toastOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
reset();
|
||||
setIsLoading(false);
|
||||
setError("Can't update your password. Contact your administrator.");
|
||||
setTimeout(() => setError(), 4000);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{/* PASSWORD */}
|
||||
<div className={classes.containerSetting}>
|
||||
<div className={classes.settingCategory}>
|
||||
<h2>Password</h2>
|
||||
</div>
|
||||
<div className={classes.setting}>
|
||||
<div className={classes.bwFormWrapper}>
|
||||
<form
|
||||
onSubmit={handleSubmit(formSubmitHandler)}
|
||||
className={classes.bwForm}
|
||||
>
|
||||
{error && <Error message={error} />}
|
||||
<p>
|
||||
<input
|
||||
type='password'
|
||||
placeholder='Current password'
|
||||
{...register('oldPassword', {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
{errors.oldPassword &&
|
||||
errors.oldPassword.type === 'required' && (
|
||||
<small className={classes.errorMessage}>
|
||||
This field is required.
|
||||
</small>
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<input
|
||||
type='password'
|
||||
placeholder='New password'
|
||||
{...register('newPassword', {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
{errors.newPassword && (
|
||||
<small className={classes.errorMessage}>
|
||||
This field is required.
|
||||
</small>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
className={classes.AccountSettingsButton}
|
||||
disabled={!isValid || isSubmitting}
|
||||
>
|
||||
{isLoading ? (
|
||||
<SpinnerDotted
|
||||
size={20}
|
||||
thickness={150}
|
||||
speed={100}
|
||||
color='#fff'
|
||||
/>
|
||||
) : (
|
||||
'Update your password'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
100
Containers/UserSettings/PasswordSettings/PasswordSettings.tsx
Normal file
100
Containers/UserSettings/PasswordSettings/PasswordSettings.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { useForm } from 'react-hook-form';
|
||||
import { toast, ToastOptions } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useFormStatus } from '~/hooks';
|
||||
import { PasswordSettingDTO } from '~/types';
|
||||
import classes from '../UserSettings.module.css';
|
||||
|
||||
//Components
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
|
||||
export default function PasswordSettings() {
|
||||
const toastOptions: ToastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<PasswordSettingDTO>({ mode: 'onChange' });
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
const { isLoading, setIsLoading } = useFormStatus();
|
||||
|
||||
////Functions
|
||||
const formSubmitHandler = async (data: PasswordSettingDTO) => {
|
||||
start();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/account/password', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error(result.message, toastOptions);
|
||||
} else {
|
||||
toast.success('🔑 Password edited !', toastOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to update password. Please try again.', toastOptions);
|
||||
} finally {
|
||||
stop();
|
||||
reset();
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{/* PASSWORD */}
|
||||
<div className={classes.containerSetting}>
|
||||
<div className={classes.settingCategory}>
|
||||
<h2>Password</h2>
|
||||
</div>
|
||||
<div className={classes.setting}>
|
||||
<div className={classes.bwFormWrapper}>
|
||||
<form onSubmit={handleSubmit(formSubmitHandler)} className={classes.bwForm}>
|
||||
<p>
|
||||
<input
|
||||
type='password'
|
||||
placeholder='Current password'
|
||||
{...register('oldPassword', {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<input
|
||||
type='password'
|
||||
placeholder='New password'
|
||||
{...register('newPassword', {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
</p>
|
||||
<button
|
||||
className={classes.AccountSettingsButton}
|
||||
disabled={isLoading || isSubmitting}
|
||||
>
|
||||
Update your password
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
//Lib
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from './UserSettings.module.css';
|
||||
import { useState } from 'react';
|
||||
|
||||
//Components
|
||||
import EmailSettings from './EmailSettings/EmailSettings';
|
||||
import PasswordSettings from './PasswordSettings/PasswordSettings';
|
||||
import UsernameSettings from './UsernameSettings/UsernameSettings';
|
||||
import EmailAlertSettings from './EmailAlertSettings/EmailAlertSettings';
|
||||
import AppriseAlertSettings from './AppriseAlertSettings/AppriseAlertSettings';
|
||||
|
||||
export default function UserSettings(props) {
|
||||
//States
|
||||
const [tab, setTab] = useState('General');
|
||||
|
||||
return (
|
||||
<div className={classes.containerSettings}>
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
color: '#494b7a',
|
||||
textAlign: 'left',
|
||||
marginLeft: '30px',
|
||||
}}
|
||||
>
|
||||
Account{' '}
|
||||
</h1>
|
||||
</div>
|
||||
<div className={classes.tabList}>
|
||||
<button
|
||||
className={
|
||||
tab == 'General'
|
||||
? classes.tabListButtonActive
|
||||
: classes.tabListButton
|
||||
}
|
||||
onClick={() => setTab('General')}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
tab == 'Notifications'
|
||||
? classes.tabListButtonActive
|
||||
: classes.tabListButton
|
||||
}
|
||||
onClick={() => setTab('Notifications')}
|
||||
>
|
||||
Notifications
|
||||
</button>
|
||||
</div>
|
||||
{tab == 'General' && (
|
||||
<>
|
||||
<PasswordSettings username={props.data.user.name} />
|
||||
<EmailSettings email={props.data.user.email} />
|
||||
<UsernameSettings username={props.data.user.name} />{' '}
|
||||
</>
|
||||
)}
|
||||
{tab == 'Notifications' && (
|
||||
<>
|
||||
<EmailAlertSettings />
|
||||
<AppriseAlertSettings />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,251 +1,490 @@
|
|||
.containerSettings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.containerSetting {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
width: 100%;
|
||||
margin: 40px 20px 0px 5px;
|
||||
text-align: left;
|
||||
padding: 28px 24px;
|
||||
animation: entrance ease-in 0.3s 1 normal none;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
width: 100%;
|
||||
margin: 40px 20px 0px 5px;
|
||||
text-align: left;
|
||||
padding: 28px 24px;
|
||||
animation: entrance ease-in 0.3s 1 normal none;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
@keyframes entrance {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.settingCategory {
|
||||
max-width: 33.3333%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
max-width: 33.3333%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
.settingCategory h2 {
|
||||
color: #494b7a;
|
||||
margin: 0;
|
||||
font-size: 1.3em;
|
||||
color: #494b7a;
|
||||
margin: 0;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.setting {
|
||||
max-width: 66.6666%;
|
||||
width: 100%;
|
||||
max-width: 66.6666%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Tokens generation */
|
||||
|
||||
.tokenGen {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tokenGen input {
|
||||
flex: 1;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.newTokenWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
color: #494b7a;
|
||||
outline: 1px solid #6d4aff;
|
||||
box-shadow: 0 0 10px 3px rgba(110, 74, 255, 0.605);
|
||||
animation: entrance ease-in 0.3s 1 normal none;
|
||||
padding: 10px;
|
||||
font-family: (--pure-material-font, 'Roboto', 'Segoe UI', BlinkMacSystemFont, system-ui);
|
||||
}
|
||||
|
||||
.tokenCardList {
|
||||
min-width: 50%;
|
||||
}
|
||||
|
||||
.tokenCardWrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tokenCard {
|
||||
width: 100%;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tokenCardHeader {
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 5px;
|
||||
color: #494b7a;
|
||||
}
|
||||
|
||||
.tokenCardBody {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.tokenCardBody .permissionBadges {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: baseline;
|
||||
align-content: baseline;
|
||||
}
|
||||
|
||||
.tokenInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin: 10px 0;
|
||||
color: #494b7a;
|
||||
}
|
||||
|
||||
.tokenCardHighlight {
|
||||
animation: highlightEffect 1s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes highlightEffect {
|
||||
0% {
|
||||
outline: 1px solid #6d4aff;
|
||||
box-shadow: 0 0 0 rgba(110, 74, 255, 0.5); /* Pas d'ombre au début */
|
||||
}
|
||||
50% {
|
||||
outline: 1px solid #6d4aff;
|
||||
box-shadow: 0 0 15px rgba(110, 74, 255, 0.6); /* Ombre qui s'agrandit */
|
||||
}
|
||||
100% {
|
||||
outline: 1px solid transparent; /* Bordure devient transparente */
|
||||
box-shadow: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cancelButton {
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #c1c1c1;
|
||||
color: white;
|
||||
margin: 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.cancelButton:hover {
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #9a9a9a;
|
||||
color: white;
|
||||
margin: 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.cancelButton:active {
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #9a9a9a;
|
||||
color: white;
|
||||
margin: 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.deleteConfirmationButtons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.confirmButton {
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #ff0000;
|
||||
color: white;
|
||||
margin: 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.confirmButton:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.confirmButton:hover {
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #ff4b4b;
|
||||
color: white;
|
||||
margin: 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.confirmButton:active {
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #ff4b4b;
|
||||
color: white;
|
||||
margin: 5px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.permissionBadge {
|
||||
user-select: none;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #6d4aff;
|
||||
color: #6d4aff;
|
||||
font-size: 0.9em;
|
||||
padding: 2px 5px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.tokenWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.permissionsWrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.permissionsWrapper .permissionBadge {
|
||||
user-select: none;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #9798b2;
|
||||
color: #9798b2;
|
||||
font-size: 0.9em;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.permissionsWrapper .permissionBadge.highlight {
|
||||
border-radius: 5px;
|
||||
border: 1px solid #6d4aff;
|
||||
color: #6d4aff;
|
||||
font-size: 0.9em;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
|
||||
.bwForm {
|
||||
width: 80%;
|
||||
border-radius: 5px;
|
||||
text-align: left;
|
||||
width: 80%;
|
||||
border-radius: 5px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bwFormWrapper {
|
||||
text-align: left;
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: #494b7a;
|
||||
font-family: var(
|
||||
--pure-material-font,
|
||||
'Roboto',
|
||||
'Segoe UI',
|
||||
BlinkMacSystemFont,
|
||||
system-ui,
|
||||
-apple-system
|
||||
);
|
||||
text-align: left;
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: #494b7a;
|
||||
font-family: var(
|
||||
--pure-material-font,
|
||||
'Roboto',
|
||||
'Segoe UI',
|
||||
BlinkMacSystemFont,
|
||||
system-ui,
|
||||
-apple-system
|
||||
);
|
||||
}
|
||||
|
||||
.bwFormWrapper p {
|
||||
margin-block-start: 0em;
|
||||
margin-block-start: 0em;
|
||||
}
|
||||
|
||||
.bwForm label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
/* margin-top: 20px; */
|
||||
color: #494b7a;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
/* margin-top: 20px; */
|
||||
color: #494b7a;
|
||||
}
|
||||
|
||||
.bwForm.tokenGen label {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.bwForm input,
|
||||
.bwForm textarea,
|
||||
.bwForm select {
|
||||
border: 1px solid #6d4aff21;
|
||||
font-size: 16px;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
margin-bottom: 0px;
|
||||
outline: 0;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
/* color: #1b1340; */
|
||||
color: #494b7a;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03) inset;
|
||||
font-family: (
|
||||
--pure-material-font,
|
||||
'Roboto',
|
||||
'Segoe UI',
|
||||
BlinkMacSystemFont,
|
||||
system-ui
|
||||
);
|
||||
border: 1px solid #6d4aff21;
|
||||
font-size: 16px;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
margin-bottom: 0px;
|
||||
outline: 0;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
/* color: #1b1340; */
|
||||
color: #494b7a;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03) inset;
|
||||
font-family: (--pure-material-font, 'Roboto', 'Segoe UI', BlinkMacSystemFont, system-ui);
|
||||
}
|
||||
|
||||
.bwForm textarea {
|
||||
resize: vertical;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
resize: vertical;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.bwForm textarea:focus,
|
||||
.bwForm input:focus,
|
||||
.bwForm select:focus {
|
||||
outline: 1px solid #6d4aff;
|
||||
box-shadow: 0 0 10px 3px rgba(110, 74, 255, 0.605);
|
||||
outline: 1px solid #6d4aff;
|
||||
box-shadow: 0 0 10px 3px rgba(110, 74, 255, 0.605);
|
||||
}
|
||||
|
||||
.bwForm .invalid {
|
||||
background: #f3c7c7;
|
||||
border: 1px solid #e45454;
|
||||
outline: 1px solid #ff4a4a;
|
||||
background: #f3c7c7;
|
||||
border: 1px solid #e45454;
|
||||
outline: 1px solid #ff4a4a;
|
||||
}
|
||||
|
||||
.bwForm .invalid:focus {
|
||||
background: #f3c7c7;
|
||||
border: 1px solid #e45454;
|
||||
outline: 1px solid #ff4a4a;
|
||||
box-shadow: 0 0 10px 3px rgba(255, 74, 74, 0.605);
|
||||
background: #f3c7c7;
|
||||
border: 1px solid #e45454;
|
||||
outline: 1px solid #ff4a4a;
|
||||
box-shadow: 0 0 10px 3px rgba(255, 74, 74, 0.605);
|
||||
}
|
||||
|
||||
.bwForm button {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
.bwForm button:hover {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
color: red;
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: red;
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.currentSetting input::placeholder {
|
||||
opacity: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.headerFormAppriseUrls {
|
||||
font-weight: 500;
|
||||
color: #494b7a;
|
||||
margin: 40px 0px 10px 0px;
|
||||
display: flex;
|
||||
padding-right: 5px;
|
||||
font-weight: 500;
|
||||
color: #494b7a;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
.formIsSavedMessage {
|
||||
color: rgb(0, 164, 0);
|
||||
animation: entrance 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
|
||||
font-weight: 300;
|
||||
color: rgb(0, 164, 0);
|
||||
animation: entrance 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.tabList {
|
||||
display: flex;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tabListButton {
|
||||
color: #494b7a;
|
||||
padding: 12px 0px;
|
||||
min-height: 48px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
line-height: 1.71;
|
||||
text-transform: none;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
text-decoration: none;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
margin-left: 30px;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: #494b7a;
|
||||
padding: 12px 0px;
|
||||
min-height: 48px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
line-height: 1.71;
|
||||
text-transform: none;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
text-decoration: none;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
margin-left: 30px;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tabListButton:hover {
|
||||
color: #6d4aff;
|
||||
border-bottom: 2px solid #6d4aff;
|
||||
color: #6d4aff;
|
||||
border-bottom: 2px solid #6d4aff;
|
||||
}
|
||||
|
||||
.tabListButtonActive {
|
||||
color: #6d4aff;
|
||||
border: 0;
|
||||
border-bottom: 2px solid #6d4aff;
|
||||
padding: 12px 0px;
|
||||
min-height: 48px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
line-height: 1.71;
|
||||
text-transform: none;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
text-decoration: none;
|
||||
background-color: transparent;
|
||||
margin-left: 30px;
|
||||
color: #6d4aff;
|
||||
border: 0;
|
||||
border-bottom: 2px solid #6d4aff;
|
||||
padding: 12px 0px;
|
||||
min-height: 48px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
line-height: 1.71;
|
||||
text-transform: none;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
text-decoration: none;
|
||||
background-color: transparent;
|
||||
margin-left: 30px;
|
||||
}
|
||||
|
||||
.AccountSettingsButton {
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #6d4aff;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 1em;
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #6d4aff;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.AccountSettingsButton:hover {
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #4f31ce;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 1em;
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #4f31ce;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.AccountSettingsButton:active {
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #4f31ce;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 1em;
|
||||
transform: scale(0.95);
|
||||
border: 0;
|
||||
padding: 10px 15px;
|
||||
background-color: #4f31ce;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 1em;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.AccountSettingsButton:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
|
|||
100
Containers/UserSettings/UserSettings.tsx
Normal file
100
Containers/UserSettings/UserSettings.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from './UserSettings.module.css';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Session } from 'next-auth';
|
||||
import { Optional, WizardEnvType, SessionStatus } from '~/types';
|
||||
|
||||
// Components
|
||||
import EmailSettings from './EmailSettings/EmailSettings';
|
||||
import PasswordSettings from './PasswordSettings/PasswordSettings';
|
||||
import UsernameSettings from './UsernameSettings/UsernameSettings';
|
||||
import EmailAlertSettings from './EmailAlertSettings/EmailAlertSettings';
|
||||
import AppriseAlertSettings from './AppriseAlertSettings/AppriseAlertSettings';
|
||||
import Integrations from './Integrations/Integrations';
|
||||
|
||||
type UserSettingsProps = {
|
||||
status: SessionStatus;
|
||||
data: Session;
|
||||
};
|
||||
|
||||
export default function UserSettings({ data }: UserSettingsProps) {
|
||||
const [tab, setTab] = useState<'General' | 'Notifications' | 'Integrations'>('General');
|
||||
const [wizardEnv, setWizardEnv] = useState<Optional<WizardEnvType>>(undefined);
|
||||
|
||||
// Fetch wizard environment on mount
|
||||
useEffect(() => {
|
||||
const fetchWizardEnv = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/account/wizard-env');
|
||||
const data: WizardEnvType = await response.json();
|
||||
setWizardEnv(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch wizard environment:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchWizardEnv();
|
||||
}, []);
|
||||
|
||||
// If Integrations tab is selected but disabled, fallback to General
|
||||
useEffect(() => {
|
||||
if (tab === 'Integrations' && wizardEnv?.DISABLE_INTEGRATIONS === 'true') {
|
||||
setTab('General');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wizardEnv?.DISABLE_INTEGRATIONS]);
|
||||
|
||||
return (
|
||||
<div className={classes.containerSettings}>
|
||||
<h1 style={{ color: '#494b7a', textAlign: 'left', marginLeft: '30px' }}>Account</h1>
|
||||
|
||||
{wizardEnv != undefined && (
|
||||
<>
|
||||
<div className={classes.tabList}>
|
||||
<button
|
||||
className={tab === 'General' ? classes.tabListButtonActive : classes.tabListButton}
|
||||
onClick={() => setTab('General')}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
tab === 'Notifications' ? classes.tabListButtonActive : classes.tabListButton
|
||||
}
|
||||
onClick={() => setTab('Notifications')}
|
||||
>
|
||||
Notifications
|
||||
</button>
|
||||
{wizardEnv.DISABLE_INTEGRATIONS !== 'true' && (
|
||||
<button
|
||||
className={
|
||||
tab === 'Integrations' ? classes.tabListButtonActive : classes.tabListButton
|
||||
}
|
||||
onClick={() => setTab('Integrations')}
|
||||
>
|
||||
Integrations
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tab === 'General' && (
|
||||
<>
|
||||
<PasswordSettings />
|
||||
<EmailSettings email={data.user?.email ?? undefined} />
|
||||
<UsernameSettings username={data.user?.name ?? undefined} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'Notifications' && (
|
||||
<>
|
||||
<EmailAlertSettings />
|
||||
<AppriseAlertSettings />
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'Integrations' && wizardEnv.DISABLE_INTEGRATIONS !== 'true' && <Integrations />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
//Lib
|
||||
import { toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import classes from '../UserSettings.module.css';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { SpinnerDotted } from 'spinners-react';
|
||||
|
||||
//Components
|
||||
import Error from '../../../Components/UI/Error/Error';
|
||||
import Info from '../../../Components/UI/Info/Info';
|
||||
|
||||
export default function UsernameSettings(props) {
|
||||
//Var
|
||||
const toastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 8000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm({ mode: 'onChange' });
|
||||
|
||||
////State
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState();
|
||||
const [info, setInfo] = useState(false);
|
||||
|
||||
////Functions
|
||||
//Form submit Handler for ADD a repo
|
||||
const formSubmitHandler = async (data) => {
|
||||
//Remove old error
|
||||
setError();
|
||||
//Loading button on submit to avoid multiple send.
|
||||
setIsLoading(true);
|
||||
//POST API to update the username
|
||||
try {
|
||||
const response = await fetch('/api/account/updateUsername', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setIsLoading(false);
|
||||
reset();
|
||||
setError(result.message);
|
||||
setTimeout(() => setError(), 4000);
|
||||
} else {
|
||||
reset();
|
||||
setIsLoading(false);
|
||||
setInfo(true);
|
||||
toast.success('Username edited !', toastOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
reset();
|
||||
setIsLoading(false);
|
||||
setError("Can't update your username. Contact your administrator.");
|
||||
setTimeout(() => setError(), 4000);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{/* Username */}
|
||||
<div className={classes.containerSetting}>
|
||||
<div className={classes.settingCategory}>
|
||||
<h2>Username</h2>
|
||||
</div>
|
||||
<div className={classes.setting}>
|
||||
<div className={classes.bwFormWrapper}>
|
||||
{info ? (
|
||||
//For local JWTs (cookie) without an OAuth provider, Next-Auth does not allow
|
||||
//at the time this code is written to refresh client-side session information
|
||||
//without triggering a logout.
|
||||
//I chose to inform the user to reconnect rather than force logout.
|
||||
<Info message='Please, logout to update your session.' />
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit(formSubmitHandler)}
|
||||
className={
|
||||
classes.bwForm +
|
||||
' ' +
|
||||
classes.currentSetting
|
||||
}
|
||||
>
|
||||
<p>
|
||||
{error && <Error message={error} />}
|
||||
<input
|
||||
type='text'
|
||||
placeholder={props.username}
|
||||
{...register('username', {
|
||||
required: 'A username is required.',
|
||||
pattern: {
|
||||
value: /^[a-z]{5,15}$/,
|
||||
message:
|
||||
'Only a-z characters are allowed.',
|
||||
},
|
||||
maxLength: {
|
||||
value: 10,
|
||||
message: '15 characters max.',
|
||||
},
|
||||
minLength: {
|
||||
value: 5,
|
||||
message: '5 characters min.',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.username && (
|
||||
<small className={classes.errorMessage}>
|
||||
{errors.username.message}
|
||||
</small>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
className={classes.AccountSettingsButton}
|
||||
disabled={!isValid || isSubmitting}
|
||||
>
|
||||
{isLoading ? (
|
||||
<SpinnerDotted
|
||||
size={20}
|
||||
thickness={150}
|
||||
speed={100}
|
||||
color='#fff'
|
||||
/>
|
||||
) : (
|
||||
'Update your username'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
123
Containers/UserSettings/UsernameSettings/UsernameSettings.tsx
Normal file
123
Containers/UserSettings/UsernameSettings/UsernameSettings.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast, ToastOptions } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useFormStatus } from '~/hooks';
|
||||
import { UsernameSettingDTO } from '~/types';
|
||||
import classes from '../UserSettings.module.css';
|
||||
|
||||
//Components
|
||||
import Info from '~/Components/UI/Info/Info';
|
||||
import { useLoader } from '~/contexts/LoaderContext';
|
||||
|
||||
export default function UsernameSettings(props: UsernameSettingDTO) {
|
||||
const toastOptions: ToastOptions = {
|
||||
position: 'top-right',
|
||||
autoClose: 8000,
|
||||
hideProgressBar: false,
|
||||
closeOnClick: true,
|
||||
pauseOnHover: true,
|
||||
draggable: true,
|
||||
progress: undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<UsernameSettingDTO>({ mode: 'onChange' });
|
||||
const { start, stop } = useLoader();
|
||||
|
||||
const { isLoading, setIsLoading } = useFormStatus();
|
||||
|
||||
////State
|
||||
const [info, setInfo] = useState(false);
|
||||
|
||||
////Functions
|
||||
const formSubmitHandler = async (data: UsernameSettingDTO) => {
|
||||
start();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/account/username', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error(result.message, toastOptions);
|
||||
} else {
|
||||
setInfo(true);
|
||||
toast.success('Username edited !', toastOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to update username. Please try again.', toastOptions);
|
||||
} finally {
|
||||
reset();
|
||||
stop();
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{/* Username */}
|
||||
<div className={classes.containerSetting}>
|
||||
<div className={classes.settingCategory}>
|
||||
<h2>Username</h2>
|
||||
</div>
|
||||
<div className={classes.setting}>
|
||||
<div className={classes.bwFormWrapper}>
|
||||
{info ? (
|
||||
//For local JWTs (cookie) without an OAuth provider, Next-Auth does not allow
|
||||
//at the time this code is written to refresh client-side session information
|
||||
//without triggering a logout.
|
||||
//I chose to inform the user to reconnect rather than force logout.
|
||||
<Info message='Please, logout to update your session' />
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit(formSubmitHandler)}
|
||||
className={classes.bwForm + ' ' + classes.currentSetting}
|
||||
>
|
||||
<p>
|
||||
<input
|
||||
type='text'
|
||||
placeholder={props.username}
|
||||
{...register('username', {
|
||||
required: 'A username is required.',
|
||||
pattern: {
|
||||
value: /^[a-z]{1,40}$/,
|
||||
message: 'Only a-z characters are allowed',
|
||||
},
|
||||
maxLength: {
|
||||
value: 40,
|
||||
message: '40 characters max.',
|
||||
},
|
||||
minLength: {
|
||||
value: 1,
|
||||
message: '1 characters min.',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.username && (
|
||||
<small className={classes.errorMessage}>{errors.username.message}</small>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
className={classes.AccountSettingsButton}
|
||||
disabled={isLoading || isSubmitting}
|
||||
>
|
||||
Update your username
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
Dockerfile
29
Dockerfile
|
|
@ -1,37 +1,47 @@
|
|||
FROM node:20-bookworm-slim as base
|
||||
|
||||
ARG UID=1001
|
||||
ARG GID=1001
|
||||
|
||||
FROM node:22-bookworm-slim as base
|
||||
|
||||
# build stage
|
||||
FROM base AS deps
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
|
||||
RUN npm ci --only=production
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN sed -i "s/images:/output: 'standalone',images:/" next.config.js
|
||||
RUN sed -i "s/images:/output: 'standalone',images:/" next.config.ts
|
||||
|
||||
RUN npm run build
|
||||
RUN pnpm run build
|
||||
|
||||
# run stage
|
||||
FROM base AS runner
|
||||
|
||||
ARG UID
|
||||
ARG GID
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV HOSTNAME=
|
||||
|
||||
RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list
|
||||
RUN apt-get update && apt-get install -y \
|
||||
supervisor curl jq jc borgbackup/bookworm-backports openssh-server rsyslog && \
|
||||
supervisor curl jq jc borgbackup/bookworm-backports openssh-server && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd -g ${GID} borgwarehouse && useradd -m -u ${UID} -g ${GID} borgwarehouse
|
||||
|
|
@ -46,11 +56,10 @@ COPY --from=builder --chown=borgwarehouse:borgwarehouse /app/.next/standalone ./
|
|||
COPY --from=builder --chown=borgwarehouse:borgwarehouse /app/public ./public
|
||||
COPY --from=builder --chown=borgwarehouse:borgwarehouse /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=borgwarehouse:borgwarehouse /app/docker/supervisord.conf ./
|
||||
COPY --from=builder --chown=borgwarehouse:borgwarehouse /app/docker/rsyslog.conf /etc/rsyslog.conf
|
||||
COPY --from=builder --chown=borgwarehouse:borgwarehouse /app/docker/sshd_config ./
|
||||
|
||||
USER borgwarehouse
|
||||
|
||||
EXPOSE 3000 22
|
||||
|
||||
ENTRYPOINT ["./docker-bw-init.sh"]
|
||||
ENTRYPOINT ["./docker-bw-init.sh"]
|
||||
|
|
|
|||
45
README.md
45
README.md
|
|
@ -1,16 +1,19 @@
|
|||
<div align="center">
|
||||
|
||||
[![TypeScript][typescript.js]][typescript-url]
|
||||
[![Next][Next.js]][Next-url]
|
||||
[![React][React.js]][React-url]
|
||||
|
||||
</div>
|
||||
<div align="center">
|
||||
|
||||
[](https://hub.docker.com/r/borgwarehouse/borgwarehouse)
|
||||
[](https://hub.docker.com/r/borgwarehouse/borgwarehouse)
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<h3 align="center">BorgWarehouse</h3>
|
||||
<img src="public/borgwarehouse-logo-violet.svg" alt="BorgWarehouse" style="margin: 30px 0">
|
||||
|
||||
<p align="center">
|
||||
A fast and modern WebUI for a BorgBackup's central repository server.
|
||||
|
|
@ -20,17 +23,17 @@
|
|||
|
||||
<div align="center">
|
||||
<a href="https://borgwarehouse.com">
|
||||
<img src="medias/borgwarehouse-og.png" alt="presentation">
|
||||
<img src="medias/borgwarehouse-og.jpg" alt="presentation">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## ⭐ Support the Project
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/sponsors/Ravinou"><img alt="GitHub Sponsors" src="https://img.shields.io/github/sponsors/Ravinou?style=for-the-badge&logo=github&label=Github%20Sponsors&link=https%3A%2F%2Fgithub.com%2Fsponsors%2FRavinou"></a>
|
||||
<a href="https://liberapay.com/R4VEN/"><img alt="Liberapay patrons" src="https://img.shields.io/liberapay/patrons/R4VEN?style=for-the-badge&logo=liberapay&label=Liberapay%20Sponsors&link=https%3A%2F%2Fliberapay.com%2FR4VEN"></a>
|
||||
</div>
|
||||
|
||||
|
||||
If you find BorgWarehouse helpful or interesting, please consider **giving it a star on GitHub** and **[sponsoring](https://github.com/sponsors/Ravinou)**. Your support is greatly appreciated!
|
||||
|
||||
## ✨ What is BorgWarehouse ?
|
||||
|
|
@ -41,13 +44,14 @@ Today, if you want to have a large server on which you centralize backups of Bor
|
|||
|
||||
With BorgWarehouse, you have an interface that allows you to do all this simply and quickly :
|
||||
|
||||
- **add** repositories
|
||||
- **edit** existing repositories
|
||||
- **delete** repositories
|
||||
- be **alerted** if there are no recent backups
|
||||
- **monitor** the volume of data
|
||||
- **flexibly manage quotas** for each repository
|
||||
- ...
|
||||
- **add** repositories
|
||||
- **edit** existing repositories
|
||||
- **delete** repositories
|
||||
- be **alerted** if there are no recent backups
|
||||
- **monitor** the volume of data
|
||||
- **flexibly manage quotas** for each repository
|
||||
- manage everything you want through the **REST API**
|
||||
- ...
|
||||
|
||||
The whole system part is automatically managed by BorgWarehouse and **you don't have to touch your terminal anymore** while enjoying a visual feedback on the status of your repositories.
|
||||
|
||||
|
|
@ -70,10 +74,23 @@ Check the online documentation [just here](https://borgwarehouse.com/docs/admin-
|
|||
|
||||
## ❤️ Special thanks to sponsors ❤️
|
||||
|
||||
<a href="https://github.com/shad-lp"><img src="https://avatars.githubusercontent.com/shad-lp" style="width:50px; border-radius:50%;"/></a>
|
||||
<a href="https://github.com/Magneticdud"><img src="https://avatars.githubusercontent.com/Magneticdud" style="width:50px; border-radius:50%;"/></a>
|
||||
<a href="https://github.com/dhenry123"><img src="https://avatars.githubusercontent.com/dhenry123" style="width:50px; border-radius:50%;"/></a>
|
||||
### 🥇 Current sponsors 🥇
|
||||
|
||||
<a href="https://github.com/royalmoose"><img src="https://avatars.githubusercontent.com/royalmoose" style="width:50px; border-radius:50%;"/></a>
|
||||
<a href="https://github.com/dhenry123"><img src="https://avatars.githubusercontent.com/dhenry123" style="width:50px; border-radius:50%;"/></a>
|
||||
<a href="https://github.com/fphammerle"><img src="https://avatars.githubusercontent.com/fphammerle" style="width:50px; border-radius:50%;"/></a>
|
||||
<a href="https://github.com/MacH59-cos"><img src="https://avatars.githubusercontent.com/MacH59-cos" style="width:50px; border-radius:50%;"/></a>
|
||||
<a href="https://github.com/shrippen"><img src="https://avatars.githubusercontent.com/shrippen" style="width:50px; border-radius:50%;"/></a>
|
||||
<a href="https://github.com/daschmidt1994"><img src="https://avatars.githubusercontent.com/daschmidt1994" style="width:50px; border-radius:50%;"/></a>
|
||||
|
||||
#### Past sponsors
|
||||
|
||||
<a href="https://github.com/Drallibor"><img src="https://avatars.githubusercontent.com/Drallibor" style="width:25px; border-radius:50%;"/></a>
|
||||
<a href="https://github.com/shad-lp"><img src="https://avatars.githubusercontent.com/shad-lp" style="width:25px; border-radius:50%;"/></a>
|
||||
<a href="https://github.com/Magneticdud"><img src="https://avatars.githubusercontent.com/Magneticdud" style="width:25px; border-radius:50%;"/></a>
|
||||
|
||||
[typescript.js]: https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white
|
||||
[typescript-url]: https://www.typescriptlang.org/
|
||||
[next.js]: https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white
|
||||
[next-url]: https://nextjs.org/
|
||||
[react.js]: https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue