Compare commits
574 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dc6d70dd9 | ||
|
|
fcdb72079d | ||
|
|
f71b399ab9 | ||
|
|
2a606c614d | ||
|
|
c850d2623c | ||
|
|
c263d7a74b | ||
|
|
c4515b8f74 | ||
|
|
8cdf1c0f1d | ||
|
|
8b17ffb3db | ||
|
|
7705b012fe | ||
|
|
c3bffe9993 | ||
|
|
4e3d8bc666 | ||
|
|
70f556ab7d | ||
|
|
23b0a92539 | ||
|
|
7d5b5c8ba1 | ||
|
|
07b64a4dc0 | ||
|
|
29081877fb | ||
|
|
b996a2db04 | ||
|
|
66ec08d278 | ||
|
|
4ba8e42352 | ||
|
|
c4f2b6855b | ||
|
|
a2e29f0379 | ||
|
|
904f529c19 | ||
|
|
8a06f98f45 | ||
|
|
99fcb6db04 | ||
|
|
6928992f8b | ||
|
|
32ab234adf | ||
|
|
9364655114 | ||
|
|
fb9de5cd64 | ||
|
|
1fe2e599a5 | ||
|
|
024a08ee76 | ||
|
|
0eb284ed9c | ||
|
|
878686f469 | ||
|
|
db1dd7c07e | ||
|
|
8ac5bd97b4 | ||
|
|
6f5c657264 | ||
|
|
91f1df45b0 | ||
|
|
3ad884b661 | ||
|
|
7db65635c3 | ||
|
|
9e03e8b837 | ||
|
|
9f8a3edcb3 | ||
|
|
d049da3b20 | ||
|
|
694748cda6 | ||
|
|
ef88644e6e | ||
|
|
98b5640c2c | ||
|
|
e921bf5276 | ||
|
|
8d1c39255f | ||
|
|
9dfbec9b1a | ||
|
|
7a0f2f6300 | ||
|
|
5d5c9e9c22 | ||
|
|
d9f5663ead | ||
|
|
20c35af7e0 | ||
|
|
b879db6447 | ||
|
|
153d59bf27 | ||
|
|
9e889465fb | ||
|
|
31984791ab | ||
|
|
892a7698eb | ||
|
|
7f4ff8adbb | ||
|
|
493d3d5dee | ||
|
|
febd100f96 | ||
|
|
21a9c39f12 | ||
|
|
5078129ac1 | ||
|
|
9b6bacc0c2 | ||
|
|
93b22227ef | ||
|
|
402b10ca98 | ||
|
|
23541606ca | ||
|
|
4eab3b6935 | ||
|
|
ea420936fa | ||
|
|
979f002f0c | ||
|
|
54fe38a92b | ||
|
|
1e2618d3b8 | ||
|
|
6b0fc80f1e | ||
|
|
d6d0539fa7 | ||
|
|
81742cbbaf | ||
|
|
300cd50310 | ||
|
|
d227a5db78 | ||
|
|
4f2dd9d240 | ||
|
|
9f750f7f6b | ||
|
|
e5a1563f39 | ||
|
|
bc3634e181 | ||
|
|
3343fba861 | ||
|
|
eff4712f69 | ||
|
|
2788264265 | ||
|
|
6fe52143a8 | ||
|
|
058b38528f | ||
|
|
d1619b3723 | ||
|
|
0bdf5f206e | ||
|
|
34aca8ff3f | ||
|
|
b4db7fdf11 | ||
|
|
d10d7fd96d | ||
|
|
9c0c2fc3f9 | ||
|
|
913b3c5fc1 | ||
|
|
6dd3fd275a | ||
|
|
2b05016586 | ||
|
|
52cc943185 | ||
|
|
edd6f7e807 | ||
|
|
d13e6fb0cc | ||
|
|
2208e9b562 | ||
|
|
dadcdb22f5 | ||
|
|
4246b85b43 | ||
|
|
4d259e50f3 | ||
|
|
e5fff0bc08 | ||
|
|
7a3aab2482 | ||
|
|
455ac9a275 | ||
|
|
b70cebb3f2 | ||
|
|
e3b24eea78 | ||
|
|
a96a48ed19 | ||
|
|
6114763e39 | ||
|
|
575f86cc34 | ||
|
|
93cf8b1258 | ||
|
|
76b7523d4a | ||
|
|
c2c1037c01 | ||
|
|
d3feee301e | ||
|
|
531a61a57b | ||
|
|
af252846f7 | ||
|
|
a94fa74a12 | ||
|
|
3e24c3b61b | ||
|
|
581e809fd2 | ||
|
|
e2ceb52c75 | ||
|
|
f975a71230 | ||
|
|
03df998b86 | ||
|
|
b758781407 | ||
|
|
8dec3a5277 | ||
|
|
191d6af4bd | ||
|
|
543b0a5a80 | ||
|
|
b86a644dcd | ||
|
|
e7a3ec3ee2 | ||
|
|
1f23be963a | ||
|
|
726050e777 | ||
|
|
06af621e7c | ||
|
|
30e67151fe | ||
|
|
0ba34cc8eb | ||
|
|
7a5ae2f19e | ||
|
|
de27e453c3 | ||
|
|
b913a14105 | ||
|
|
2bf0bc8334 | ||
|
|
b5529d3a8c | ||
|
|
b5a091861d | ||
|
|
931f980e16 | ||
|
|
5f091eada1 | ||
|
|
bd1a16a8f8 | ||
|
|
6f8669cec7 | ||
|
|
7d52ae3e7d | ||
|
|
3e194246ab | ||
|
|
cce28116a8 | ||
|
|
90125d51f2 | ||
|
|
dc670b8693 | ||
|
|
3f8a33e77c | ||
|
|
3763a1541a | ||
|
|
4bd61d1f80 | ||
|
|
713c6fc7d9 | ||
|
|
b5efcda25e | ||
|
|
12d7e76731 | ||
|
|
249c948d11 | ||
|
|
b8d0b1bd2a | ||
|
|
77b5c93652 | ||
|
|
137be6962c | ||
|
|
357406d2a4 | ||
|
|
4ba98ede77 | ||
|
|
89f250bfb7 | ||
|
|
425c70bfd8 | ||
|
|
74ffd63828 | ||
|
|
f8a2f81ce4 | ||
|
|
e8f05783a9 | ||
|
|
ae0f2282de | ||
|
|
cc0091f753 | ||
|
|
1e84db4a47 | ||
|
|
8f59da1ce7 | ||
|
|
e46751844c | ||
|
|
d131ac5d03 | ||
|
|
878c82e8f0 | ||
|
|
69fed72a81 | ||
|
|
bda6388100 | ||
|
|
1d6a9ec563 | ||
|
|
56517b9840 | ||
|
|
03a0945a29 | ||
|
|
023a0ca824 | ||
|
|
f6a0910c40 | ||
|
|
869708469d | ||
|
|
f51c8e7643 | ||
|
|
256d886930 | ||
|
|
e74a4b5320 | ||
|
|
751ffdab61 | ||
|
|
fec1fc3c76 | ||
|
|
35d27ffb04 | ||
|
|
3ba6b58b9e | ||
|
|
8ce81771b2 | ||
|
|
17c1ff1c4c | ||
|
|
9a2f83b2b5 | ||
|
|
dfd9a3efb8 | ||
|
|
5e630368e6 | ||
|
|
5327160774 | ||
|
|
b092c86658 | ||
|
|
2988b24fac | ||
|
|
b49b0ebf16 | ||
|
|
45f972e351 | ||
|
|
a824349ad2 | ||
|
|
2fd933fc32 | ||
|
|
b48cf19acf | ||
|
|
669a11186b | ||
|
|
355cdd2550 | ||
|
|
34a08e461f | ||
|
|
b747e63d51 | ||
|
|
557cd4f7e2 | ||
|
|
b0189c6457 | ||
|
|
8da630e149 | ||
|
|
d65eafd37f | ||
|
|
4895cbb9dc | ||
|
|
1467c8e416 | ||
|
|
e12cd68010 | ||
|
|
e997d11c2c | ||
|
|
4947e0490a | ||
|
|
c68a7ee22a | ||
|
|
c4d99606cb | ||
|
|
5e3e70454d | ||
|
|
860808a654 | ||
|
|
476c98d4f3 | ||
|
|
35a3224f70 | ||
|
|
0c001a6d81 | ||
|
|
7a9ab1c803 | ||
|
|
eb6ba5f15d | ||
|
|
52cde329d7 | ||
|
|
aae2cd6866 | ||
|
|
becdf04acf | ||
|
|
e3c853361d | ||
|
|
385fc839b5 | ||
|
|
5d72d36c20 | ||
|
|
1fcd2fffd0 | ||
|
|
7cdc84b45d | ||
|
|
3f317d2559 | ||
|
|
788df59ae1 | ||
|
|
d57188addf | ||
|
|
778f7cedbb | ||
|
|
f71218998f | ||
|
|
d1d6a6b373 | ||
|
|
41de28c15f | ||
|
|
c1bbeeb6ac | ||
|
|
9f3d7df5cd | ||
|
|
a7d6637a81 | ||
|
|
14fa2e32ea | ||
|
|
c08f0dad8b | ||
|
|
43853ef14f | ||
|
|
b831c6c3bd | ||
|
|
1f51d60ed2 | ||
|
|
600a3e2045 | ||
|
|
5458657c8b | ||
|
|
603ee9d6bf | ||
|
|
3405f524df | ||
|
|
1fbbef41a7 | ||
|
|
eef04075a5 | ||
|
|
7880bb4abe | ||
|
|
ac0c6aa729 | ||
|
|
691e6c1afb | ||
|
|
09559c78af | ||
|
|
3d6ea5cf7c | ||
|
|
0a7a606541 | ||
|
|
61cc6c8d26 | ||
|
|
5e0db07ef3 | ||
|
|
d188d67c5a | ||
|
|
bd034c11b6 | ||
|
|
0ec867b185 | ||
|
|
c29b077e93 | ||
|
|
d8a42b4c3a | ||
|
|
7879476739 | ||
|
|
fd40e2b7bc | ||
|
|
aefe5e0848 | ||
|
|
fd7bfd845e | ||
|
|
01ca369388 | ||
|
|
15ef29ecea | ||
|
|
b19162ce91 | ||
|
|
7c31b210bd | ||
|
|
a63c214d8d | ||
|
|
c9ada8f41d | ||
|
|
06fac716d1 | ||
|
|
c462bc30e1 | ||
|
|
ad00e54df8 | ||
|
|
ca4543b227 | ||
|
|
ddca307001 | ||
|
|
dd651eb324 | ||
|
|
6ea7347ae1 | ||
|
|
b4810aa4cd | ||
|
|
8a99076a21 | ||
|
|
432bbcb4ac | ||
|
|
0f006a737f | ||
|
|
73d9a535bc | ||
|
|
da5eddaa6a | ||
|
|
d2362d4ddd | ||
|
|
18e41b3064 | ||
|
|
148dff996c | ||
|
|
20d12c4d7c | ||
|
|
f25389a638 | ||
|
|
a10e9646e6 | ||
|
|
8a697bb7d2 | ||
|
|
834eca8461 | ||
|
|
b5347b1801 | ||
|
|
939d9d4463 | ||
|
|
56aee7e96e | ||
|
|
49ab0d425d | ||
|
|
9227b00d1a | ||
|
|
a736bb3b21 | ||
|
|
ecbc0f64d6 | ||
|
|
a522a74199 | ||
|
|
bee4094b84 | ||
|
|
e3e909a023 | ||
|
|
d91d341bbf | ||
|
|
a3d85a7454 | ||
|
|
a5a5b0c3a4 | ||
|
|
50e7204a4f | ||
|
|
0c60b8710e | ||
|
|
bb92930116 | ||
|
|
c1483d4c99 | ||
|
|
aeedd45bb6 | ||
|
|
861cb6efc6 | ||
|
|
a8f4b47803 | ||
|
|
437d769527 | ||
|
|
8dfed49dcf | ||
|
|
794a0a1b14 | ||
|
|
86bd650213 | ||
|
|
c4cb462a5e | ||
|
|
3aecc4fd5b | ||
|
|
7c550af923 | ||
|
|
6588a0eede | ||
|
|
7b219158e1 | ||
|
|
1c7ab0e1ea | ||
|
|
148aa30915 | ||
|
|
781d9a0355 | ||
|
|
6a5c6817d0 | ||
|
|
3d3a292284 | ||
|
|
5bf9547e55 | ||
|
|
45078bb632 | ||
|
|
87060410b5 | ||
|
|
0bc898c999 | ||
|
|
94e188c6c0 | ||
|
|
8a74b3d736 | ||
|
|
897d93b9ab | ||
|
|
f7b0f692d9 | ||
|
|
80c0b84785 | ||
|
|
dc685084d1 | ||
|
|
763c767286 | ||
|
|
01302cf25e | ||
|
|
6dee9260c1 | ||
|
|
8f1847e38b | ||
|
|
176e0b4961 | ||
|
|
de9be5fcf4 | ||
|
|
b46db36a1f | ||
|
|
8dd9287eaf | ||
|
|
ab784515c0 | ||
|
|
337c8c1ee2 | ||
|
|
b46e637a64 | ||
|
|
b2d8cdb43f | ||
|
|
23ae01d34d | ||
|
|
306c496489 | ||
|
|
13e57eb2ae | ||
|
|
c4ecdc9175 | ||
|
|
2e99ee43ce | ||
|
|
4c7c457839 | ||
|
|
9b84df6774 | ||
|
|
9d010f8996 | ||
|
|
21b7ca1a0a | ||
|
|
26578ed55d | ||
|
|
1b6369264b | ||
|
|
7c0033397b | ||
|
|
49a75a08aa | ||
|
|
4dad18e45f | ||
|
|
0016333972 | ||
|
|
b65291bb01 | ||
|
|
dac8a08fd2 | ||
|
|
c18808503c | ||
|
|
06563feffc | ||
|
|
468a529f94 | ||
|
|
0c568a327f | ||
|
|
2e9db77b00 | ||
|
|
c85917e378 | ||
|
|
d2d9810afd | ||
|
|
db13d43f99 | ||
|
|
1acc64c074 | ||
|
|
32c4cd40d3 | ||
|
|
abf02ddbac | ||
|
|
552cfcf805 | ||
|
|
4198ad73de | ||
|
|
b6a49e3b11 | ||
|
|
e716624a3f | ||
|
|
6a8825ecb7 | ||
|
|
e171866226 | ||
|
|
c98616234f | ||
|
|
4fa9f8967b | ||
|
|
5ea42b6c15 | ||
|
|
d0bc6610b1 | ||
|
|
995769de27 | ||
|
|
aeb4f8ed9a | ||
|
|
44e1bef29b | ||
|
|
b472b44c5b | ||
|
|
227b762740 | ||
|
|
c0765eeb92 | ||
|
|
da1b727788 | ||
|
|
2875a857a5 | ||
|
|
81952acb84 | ||
|
|
33fd901552 | ||
|
|
394ae44401 | ||
|
|
5e3aaca8d8 | ||
|
|
1647ee023a | ||
|
|
a95451d12c | ||
|
|
c6c1caa420 | ||
|
|
14b2f3a9f6 | ||
|
|
022fe6af3a | ||
|
|
ed827c279a | ||
|
|
aede99b709 | ||
|
|
281fa9ebdd | ||
|
|
b7c2a423c8 | ||
|
|
a39e841af2 | ||
|
|
00384abe9c | ||
|
|
4b1b107f44 | ||
|
|
4175bd3a5e | ||
|
|
15aeb5c922 | ||
|
|
168f8d85ce | ||
|
|
5fc42d3850 | ||
|
|
64d7d16ea2 | ||
|
|
fa9de43123 | ||
|
|
87618181ab | ||
|
|
03cf9dbe79 | ||
|
|
4afc621d10 | ||
|
|
4f1d7fbd5f | ||
|
|
c5b7116992 | ||
|
|
b08610196c | ||
|
|
3ad070ea18 | ||
|
|
94bc49a0b3 | ||
|
|
9269593c46 | ||
|
|
baa19b5c4c | ||
|
|
4fc48d3259 | ||
|
|
03c0f4649f | ||
|
|
0280fc0f59 | ||
|
|
d0b48c90a6 | ||
|
|
e90ec0b27c | ||
|
|
fc66171a09 | ||
|
|
769f7f45ee | ||
|
|
ca89e8e4f9 | ||
|
|
d7dda228b6 | ||
|
|
28136a656f | ||
|
|
4502ca5543 | ||
|
|
b4decf9124 | ||
|
|
c5d12a49f4 | ||
|
|
dcab31b30c | ||
|
|
c917d643d2 | ||
|
|
8c2ca8656b | ||
|
|
9a22d51996 | ||
|
|
d95352f467 | ||
|
|
8dbfffc383 | ||
|
|
1606ce357e | ||
|
|
096d27f107 | ||
|
|
8cc03a973f | ||
|
|
f4c4f408e2 | ||
|
|
a65c7171c1 | ||
|
|
d14c4fa606 | ||
|
|
7e4c915b0b | ||
|
|
a5a15963b8 | ||
|
|
88515fc571 | ||
|
|
b20e8ae528 | ||
|
|
dcbbe9a9a6 | ||
|
|
61cabaa356 | ||
|
|
30f0df5a10 | ||
|
|
37562c06ec | ||
|
|
478fc2c274 | ||
|
|
5aa113ec16 | ||
|
|
a390e1bdf9 | ||
|
|
44e9a336aa | ||
|
|
8ac347f52d | ||
|
|
a325af3cda | ||
|
|
49ebfdbbaf | ||
|
|
742c7b9dd9 | ||
|
|
558d2a09f7 | ||
|
|
584362b426 | ||
|
|
0bcdcadee4 | ||
|
|
8ae6835a10 | ||
|
|
8addfbdb3d | ||
|
|
db59d55326 | ||
|
|
30ee16a9f6 | ||
|
|
8c981811b3 | ||
|
|
975807c143 | ||
|
|
5aaf9111fd | ||
|
|
a10898f662 | ||
|
|
a853078862 | ||
|
|
b1c2b01998 | ||
|
|
e0f5705c78 | ||
|
|
db1b58e32e | ||
|
|
a777edb881 | ||
|
|
81434a5649 | ||
|
|
62642d891f | ||
|
|
68126aad88 | ||
|
|
7e8d85ca73 | ||
|
|
b2205a6258 | ||
|
|
158702bdfc | ||
|
|
7220bf1ff4 | ||
|
|
03cb2d76d7 | ||
|
|
d6350131e6 | ||
|
|
27db3532d9 | ||
|
|
03f826ffeb | ||
|
|
049a588287 | ||
|
|
3aeafec90f | ||
|
|
32ec9b98d4 | ||
|
|
6646906d58 | ||
|
|
31c3290aec | ||
|
|
31c4192c24 | ||
|
|
d4f8fdbd83 | ||
|
|
877d951a1b | ||
|
|
3a6ee5e3c0 | ||
|
|
ca980b96fa | ||
|
|
a5a0f36166 | ||
|
|
238cefde73 | ||
|
|
b31949b468 | ||
|
|
645abea72a | ||
|
|
7a9209af03 | ||
|
|
c83abc6e9a | ||
|
|
9b891c83fa | ||
|
|
dc8c749212 | ||
|
|
88ad2e7fc2 | ||
|
|
44fb07840e | ||
|
|
e786f9d21f | ||
|
|
9134d07969 | ||
|
|
913264b0db | ||
|
|
2bbc3e6426 | ||
|
|
4c74aec703 | ||
|
|
83ca5b23f5 | ||
|
|
61a679b7cb | ||
|
|
fdbf094893 | ||
|
|
fad0dfbe43 | ||
|
|
7ae8e52da8 | ||
|
|
6a1f25ecf6 | ||
|
|
10ba6cb51b | ||
|
|
5943d2aaa6 | ||
|
|
c434b54969 | ||
|
|
3840d4a1be | ||
|
|
2b77b2411c | ||
|
|
714474d406 | ||
|
|
a1a1bc44ae | ||
|
|
18d9ed7b0d | ||
|
|
9094c1d23f | ||
|
|
dcac312d86 | ||
|
|
2e438f9814 | ||
|
|
ae014e2a14 | ||
|
|
5f5b66a21e | ||
|
|
723e8696af | ||
|
|
32c3c9955c | ||
|
|
847a23ddeb | ||
|
|
08e3e01526 | ||
|
|
82b26add88 | ||
|
|
e3ab34eae1 | ||
|
|
65d9a4125f | ||
|
|
d05344eea7 | ||
|
|
47a2d981c8 | ||
|
|
2229573101 | ||
|
|
a8059c6f1f | ||
|
|
51c1963f4b | ||
|
|
8c10daeddd | ||
|
|
d168e98607 | ||
|
|
a45d990eb7 | ||
|
|
31ae43708a | ||
|
|
cefe327251 | ||
|
|
22280e9d89 | ||
|
|
c73ece41f0 | ||
|
|
7825f7666f | ||
|
|
fc6901ee65 | ||
|
|
1794a07204 | ||
|
|
7215d188a2 | ||
|
|
94890da48f | ||
|
|
078c5c8889 | ||
|
|
23a0cb1245 | ||
|
|
b4015bd8d1 | ||
|
|
3cffaa6e32 | ||
|
|
e68faa63c5 | ||
|
|
e0706a6a89 | ||
|
|
adcb405643 | ||
|
|
63df7db2f5 | ||
|
|
04eb1b1b0e | ||
|
|
40bf57882a |
24
.github/DISCUSSION_TEMPLATE/q-a.yml
vendored
@@ -1,24 +0,0 @@
|
||||
title: 'Q&A'
|
||||
body:
|
||||
- type: input
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: shlink-web-client version
|
||||
placeholder: x.y.z
|
||||
- type: dropdown
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: How do you use shlink-web-client
|
||||
options:
|
||||
- https://app.shlink.io
|
||||
- Docker image
|
||||
- Self-hosted
|
||||
- Other (explain in summary)
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Summary
|
||||
value: '<!-- Describe your issue, question or request here. -->'
|
||||
7
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,7 +0,0 @@
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be expected.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
-->
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -2,4 +2,4 @@ blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Question - Support
|
||||
about: Do you need help setting up or using shlink-web-client?
|
||||
url: https://github.com/shlinkio/shlink-web-client/discussions/new?category=q-a
|
||||
url: https://github.com/orgs/shlinkio/discussions/new?category=help-wanted
|
||||
|
||||
18
.github/dependabot.yml
vendored
@@ -12,17 +12,21 @@ updates:
|
||||
fontawesome:
|
||||
patterns:
|
||||
- '@fortawesome/*'
|
||||
eslint-plugins: # TODO Add eslint back once updated to v9
|
||||
eslint:
|
||||
patterns:
|
||||
- '@shlinkio/eslint-config-js-coding-standard'
|
||||
- 'typescript-eslint'
|
||||
- '*eslint-plugin*'
|
||||
- 'eslint'
|
||||
shlink:
|
||||
patterns:
|
||||
- '@shlinkio/*'
|
||||
types:
|
||||
react:
|
||||
patterns:
|
||||
- '@types/*'
|
||||
- 'react'
|
||||
- 'react-dom'
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
testing:
|
||||
patterns:
|
||||
- '@testing-library/*'
|
||||
@@ -37,10 +41,10 @@ updates:
|
||||
workbox:
|
||||
patterns:
|
||||
- 'workbox*'
|
||||
ignore:
|
||||
# Bootstrap can introduce visual breaking changes on styles
|
||||
# Ignore it, since the plan is to remove it anyway
|
||||
- dependency-name: 'bootstrap'
|
||||
tailwindcss:
|
||||
patterns:
|
||||
- 'tailwindcss'
|
||||
- '@tailwindcss/*'
|
||||
- package-ecosystem: docker
|
||||
directory: '/'
|
||||
schedule:
|
||||
|
||||
10
.github/workflows/ci-docker-image-build.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
name: Test docker image build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
|
||||
jobs:
|
||||
build-docker-image:
|
||||
uses: shlinkio/github-actions/.github/workflows/docker-image-build-ci.yml@main
|
||||
3
.github/workflows/ci.yml
vendored
@@ -11,5 +11,6 @@ jobs:
|
||||
ci:
|
||||
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
|
||||
with:
|
||||
node-version: 20.7
|
||||
node-version: 22.x
|
||||
publish-coverage: true
|
||||
install-playwright: true
|
||||
|
||||
10
.github/workflows/deploy-preview.yml
vendored
@@ -5,23 +5,23 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
- name: Use node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.7
|
||||
node-version: 22.10
|
||||
- name: Build
|
||||
run: |
|
||||
npm ci && \
|
||||
node ./scripts/set-homepage.cjs /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \
|
||||
npm run build
|
||||
node --run build
|
||||
- name: Deploy preview
|
||||
uses: shlinkio/deploy-preview-action@v1.0.1
|
||||
with:
|
||||
|
||||
8
.github/workflows/publish-release.yml
vendored
@@ -7,14 +7,14 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Use node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.7
|
||||
node-version: 22.10
|
||||
- name: Generate release assets
|
||||
run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
|
||||
- name: Publish release with assets
|
||||
|
||||
4
.gitignore
vendored
@@ -7,9 +7,7 @@
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
|
||||
npm-debug.log*
|
||||
|
||||
docker-compose.override.yml
|
||||
home
|
||||
public/servers.json*
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"@shlinkio/stylelint-config-css-coding-standard"
|
||||
]
|
||||
}
|
||||
117
CHANGELOG.md
@@ -4,6 +4,121 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## [4.5.0] - 2025-08-08
|
||||
### Added
|
||||
* [shlink-web-component#755](https://github.com/shlinkio/shlink-web-component/issues/755) Add support for `any-value-query-param` and `valueless-query-param` redirect conditions when using Shlink >=4.5.0.
|
||||
* [shlink-web-component#756](https://github.com/shlinkio/shlink-web-component/issues/756) Add support for desktop device types on device redirect conditions, when using Shlink >=4.5.0.
|
||||
* [shlink-web-component#713](https://github.com/shlinkio/shlink-web-component/issues/713) Expose a new `ShlinkSidebarToggleButton` component that can be used to customize the location of the sidebar toggle, rather than making it assume there's a header bar and position it there.
|
||||
* [shlink-web-component#657](https://github.com/shlinkio/shlink-web-component/issues/657) Allow visits table columns to be customized via settings, and add a new optional "Region" column.
|
||||
|
||||
As a side effect, the "Show user agent" toggle has been removed from the list, as this can now be globally configured in the settings.
|
||||
|
||||
### Changed
|
||||
* Update to FontAwesome 7
|
||||
* Update to Recharts 3
|
||||
* Update to `@shlinkio/shlink-web-component` 0.16.1
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [shlink-web-component#698](https://github.com/shlinkio/shlink-web-component/issues/698) Fix line chart selection triggering after clicking a dot in the chart. It now works only when dragging while the mouse is clicked.
|
||||
|
||||
|
||||
## [4.4.1] - 2025-06-23
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* [shlink-web-component#661](https://github.com/shlinkio/shlink-web-component/issues/661) and [#1571](https://github.com/shlinkio/shlink-web-client/issues/1571) Fully replace bootstrap with tailwind.
|
||||
* Add the new light theme brand color.
|
||||
* Update to `@shlinkio/shlink-frontend-kit` 1.0.0 and `@shlinkio/shlink-web-component` 0.15
|
||||
* Replace reactstrap nav bar with `NavBar` component from `@shlinkio/shlink-frontend-kit`
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## [4.4.0] - 2025-04-20
|
||||
### Added
|
||||
* [#1510](https://github.com/shlinkio/shlink-web-client/issues/1510) Existing HTTP credentials (cookies, TLS certs, authentication headers) can now be forwarded to the API server if appropriate [CORS headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials) are set
|
||||
* [shlink-web-component#637](https://github.com/shlinkio/shlink-web-component/pull/637) QR codes are now generated client-side, without hitting Shlink.
|
||||
* [shlink-web-component#641](https://github.com/shlinkio/shlink-web-component/issues/641) It is now possible to provide any logo to be used with QR codes.
|
||||
* [shlink-web-component#640](https://github.com/shlinkio/shlink-web-component/issues/640) Allow default QR code settings to be handled via app settings.
|
||||
|
||||
### Changed
|
||||
* Update to `react-router` 7.0
|
||||
* Update to `@shlinkio/shlink-frontend-kit` 0.8.x
|
||||
* Update to `@shlinkio/shlink-web-component` 0.13.x
|
||||
* Update to `@shlinkio/shlink-js-sdk` 2.0.0
|
||||
* Add `eslint-plugin-react-compiler`
|
||||
* Run unit tests in a headless browser using vitest browser mode and playwright.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## [4.3.0] - 2024-11-30
|
||||
### Added
|
||||
* [#1360](https://github.com/shlinkio/shlink-web-client/issues/1360) Added ability for server IDs to be generated based on the server name and URL, instead of generating a random UUID.
|
||||
|
||||
This can improve sharing a predefined set of servers cia servers.json, env vars, or simply export and import your servers in some other device, and then be able to share server URLs which continue working.
|
||||
|
||||
All existing servers will keep their generated IDs in existing devices for backwards compatibility, but newly created servers will use the new approach.
|
||||
|
||||
* [shlink-web-component#491](https://github.com/shlinkio/shlink-web-component/issues/491) Add support for colors in QR code configurator.
|
||||
* [shlink-web-component#515](https://github.com/shlinkio/shlink-web-component/issues/515) Add support for geolocation redirect conditions, when using Shlink 4.3 or newer.
|
||||
* [shlink-web-component#514](https://github.com/shlinkio/shlink-web-component/issues/514) Allow filtering short URLs list by domain, when using Shlink 4.3 or newer.
|
||||
* [shlink-web-component#520](https://github.com/shlinkio/shlink-web-component/issues/520) Allow navigating from domains list to short URLs list filtered by one domain, when using Shlink 4.3 or newer.
|
||||
* [shlink-web-component#517](https://github.com/shlinkio/shlink-web-component/issues/517) Update list of known domains when a short URL is created with a new domain.
|
||||
* [shlink-web-component#292](https://github.com/shlinkio/shlink-web-component/issues/292) Add icon in short URLs list indicating if a short URL has redirect rules.
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [shlink-web-component#504](https://github.com/shlinkio/shlink-web-component/issues/504) Fix fallback interval not causing new visits to be loaded.
|
||||
|
||||
|
||||
## [4.2.2] - 2024-10-19
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* Update to `@shlinkio/shlink-frontend-kit` 0.6.0
|
||||
* Update to `@shlinkio/shlink-web-component` 0.10.1
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [shlink-web-component#475](https://github.com/shlinkio/shlink-web-component/issues/475) Fix incorrect amount of dots being displayed in line charts when the difference in days/weeks/months is rounded up.
|
||||
|
||||
|
||||
## [4.2.1] - 2024-10-09
|
||||
### Added
|
||||
* *Nothing*
|
||||
@@ -252,7 +367,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
* [#774](https://github.com/shlinkio/shlink-web-client/issues/774) Dropped support for Shlink older than 2.8.0.
|
||||
|
||||
### Fixed
|
||||
* [#715](https://github.com/shlinkio/shlink-web-client/issues/715) Fixed connection still failing on miss-configured servers, after editing their params to set proper values.
|
||||
* [#715](https://github.com/shlinkio/shlink-web-client/issues/715) Fixed connection still failing on misconfigured servers, after editing their params to set proper values.
|
||||
|
||||
|
||||
## [3.8.2] - 2022-12-17
|
||||
|
||||
@@ -14,16 +14,13 @@ Because of this, the only actual dependencies are [docker](https://docs.docker.c
|
||||
|
||||
The first thing you need to do is fork the repository, and clone it in your local machine.
|
||||
|
||||
Then you will have to follow these steps:
|
||||
Then simply run `docker compose up` and you will have the project exposed in port `3000` (http://localhost:3000).
|
||||
|
||||
* Copy the file `docker-compose.override.yml.dist` by also removing the `dist` extension.
|
||||
* Start-up the project by running `docker compose up`.
|
||||
|
||||
Once this is finished, you will have the project exposed in port `3000` (http://localhost:3000).
|
||||
> The first time the container is created, the project dependencies will be installed and the container may take a bit longer to start.
|
||||
|
||||
## Project structure
|
||||
|
||||
This project is a [react](https://reactjs.org/) & [redux](https://redux.js.org/) application, built with [typescript](https://www.typescriptlang.org/), which is distributed as a 100% client-side progressive web application.
|
||||
This project is a [react](https://react.dev/) & [redux](https://redux.js.org/) application, built with [typescript](https://www.typescriptlang.org/), which is distributed as a 100% client-side progressive web application.
|
||||
|
||||
This is the basic project structure:
|
||||
|
||||
@@ -39,7 +36,7 @@ shlink-web-client
|
||||
```
|
||||
|
||||
* `config`: It contains some configuration scripts, used during testing, linting and building of the project.
|
||||
* `public`: Will act as the application document root once built, and contains some static assets (favicons, images, etc).
|
||||
* `public`: Will act as the application document root once built, and contains some static assets (favicons, images, etc.).
|
||||
* `scripts`: It has some of the CLI scripts used to run tests or building.
|
||||
* `src`: Contains the main source code of the application, including both web components, SASS stylesheets and files with logic.
|
||||
* `test`: Contains the project tests.
|
||||
@@ -48,20 +45,16 @@ shlink-web-client
|
||||
|
||||
> Note: The `indocker` shell script is a helper used to run commands inside the docker container.
|
||||
|
||||
* `./indocker npm run lint`: Checks coding styles are fulfilled, both in JS/TS files as well as in stylesheets.
|
||||
* `./indocker npm run lint:js`: Checks coding styles are fulfilled in JS/TS files.
|
||||
* `./indocker npm run lint:css`: Checks coding styles are fulfilled in stylesheets.
|
||||
* `./indocker npm run lint:js:fix`: Fixes coding styles in JS/TS files.
|
||||
* `./indocker npm run lint:css:fix`: Fixes coding styles in stylesheets.
|
||||
* `./indocker npm run test`: Runs unit tests with Jest.
|
||||
* `./indocker npm run mutate`: Runs mutation tests with StrykerJS (this command can be very slow).
|
||||
* `./indocker node --run lint`: Checks coding styles are fulfilled in JS/TS files.
|
||||
* `./indocker node --run lint:fix`: Fixes coding styles in JS/TS files.
|
||||
* `./indocker node --run test`: Runs unit tests with Jest.
|
||||
|
||||
## Building the project
|
||||
|
||||
The source code in this project cannot be run directly in a web browser, you need to build it first.
|
||||
|
||||
* `./indocker npm run build`: Builds the project using a combination of `webpack`, `babel` and `tsc`, generating the final static files. The content is placed in the `build` folder, which is automatically created if it does not exist.
|
||||
* `./indocker npm run serve:build`: Serves the static files inside the `build` folder in port 5000 (http://localhost:5000). Useful to test the content built with previous command.
|
||||
* `./indocker node --run run build`: Builds the project for production using [vite](https://vite.dev/), generating the final static files. The content is placed in the `build` folder, which is automatically created if it does not exist.
|
||||
* `./indocker node --run run preview`: Serves the static files inside the `build` folder in a random port. Useful to test the content built with previous command.
|
||||
|
||||
## Pull request process
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
FROM node:22.9-alpine as node
|
||||
FROM node:24.4-alpine AS node
|
||||
COPY . /shlink-web-client
|
||||
ARG VERSION="latest"
|
||||
ENV VERSION ${VERSION}
|
||||
RUN cd /shlink-web-client && npm ci && npm run build
|
||||
ENV VERSION=${VERSION}
|
||||
RUN cd /shlink-web-client && npm ci && node --run build
|
||||
|
||||
FROM nginxinc/nginx-unprivileged:1.27-alpine
|
||||
FROM nginxinc/nginx-unprivileged:1.29-alpine
|
||||
ARG UID=101
|
||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
[](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
||||
|
||||
[](https://fosstodon.org/@shlinkio)
|
||||
[](https://twitter.com/shlinkio)
|
||||
[](https://bsky.app/profile/shlinkio.bsky.social)
|
||||
[](https://bsky.app/profile/shlink.io)
|
||||
[](https://slnk.to/donate)
|
||||
|
||||
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import axe from 'axe-core';
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
axe.configure({
|
||||
checks: [
|
||||
{
|
||||
// Disable color contrast checking, as it doesn't work in jsdom
|
||||
id: 'color-contrast',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Clear all mocks and cleanup DOM after every test
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
HTMLCanvasElement.prototype.getContext = (() => {}) as any;
|
||||
(global as any).scrollTo = () => {};
|
||||
(global as any).matchMedia = () => ({ matches: false });
|
||||
|
||||
15
dev.Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM mcr.microsoft.com/playwright:v1.54.2-noble
|
||||
|
||||
ENV NODE_VERSION 22.14
|
||||
ENV TINI_VERSION v0.19.0
|
||||
|
||||
# Install Node.js
|
||||
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash && \
|
||||
\. "$HOME/.nvm/nvm.sh" && \
|
||||
nvm install ${NODE_VERSION}
|
||||
|
||||
# Install tini
|
||||
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /sbin/tini
|
||||
RUN chmod +x /sbin/tini
|
||||
# Set tini as the entry point, as node does not properly handle signals
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
2
dist/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,7 +0,0 @@
|
||||
services:
|
||||
shlink_web_client_node:
|
||||
user: 1000:1000
|
||||
volumes:
|
||||
- /etc/passwd:/etc/passwd:ro
|
||||
- /etc/group:/etc/group:ro
|
||||
- ./home:/home/alejandro
|
||||
@@ -1,8 +1,12 @@
|
||||
services:
|
||||
shlink_web_client_node:
|
||||
container_name: shlink_web_client_node
|
||||
image: node:22.3-alpine
|
||||
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||
user: 1000:1000 # With this, files created via `indocker` script will belong to the host user
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./dev.Dockerfile
|
||||
working_dir: /home/shlink/www
|
||||
command: /bin/sh -c "npm install && npm run start"
|
||||
volumes:
|
||||
- ./:/home/shlink/www
|
||||
ports:
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
<div id="root" class="h-full"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
16557
package-lock.json
generated
110
package.json
@@ -7,85 +7,79 @@
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "npm run lint:css && npm run lint:js",
|
||||
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
||||
"lint:js": "eslint src test config/test",
|
||||
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
|
||||
"lint:css:fix": "npm run lint:css -- --fix",
|
||||
"lint:js:fix": "npm run lint:js -- --fix",
|
||||
"lint": "eslint src test config/test",
|
||||
"lint:fix": "node --run lint -- --fix",
|
||||
"types": "tsc",
|
||||
"start": "vite serve --host=0.0.0.0",
|
||||
"preview": "vite preview --host=0.0.0.0",
|
||||
"build": "npm run types && vite build && node scripts/replace-version.mjs",
|
||||
"build:dist": "npm run build && node scripts/create-dist-file.mjs",
|
||||
"build": "node --run types && vite build && node scripts/replace-version.mjs",
|
||||
"build:dist": "node --run build && node scripts/create-dist-file.mjs",
|
||||
"test": "vitest run --run",
|
||||
"test:watch": "vitest --watch",
|
||||
"test:ci": "npm run test -- --coverage",
|
||||
"test:verbose": "npm run test -- --verbose"
|
||||
"test:ci": "node --run test -- --coverage",
|
||||
"test:verbose": "node --run test -- --verbose"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.6.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.6.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.6.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@fortawesome/fontawesome-free": "^7.0.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.0.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.0.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.0.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.0.0",
|
||||
"@fortawesome/react-fontawesome": "^0.2.3",
|
||||
"@json2csv/plainjs": "^7.0.6",
|
||||
"@reduxjs/toolkit": "^2.2.7",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@shlinkio/data-manipulation": "^1.0.3",
|
||||
"@shlinkio/shlink-frontend-kit": "^0.5.2",
|
||||
"@shlinkio/shlink-js-sdk": "^1.2.0",
|
||||
"@shlinkio/shlink-web-component": "^0.8.1",
|
||||
"bootstrap": "5.2.3",
|
||||
"@shlinkio/shlink-frontend-kit": "^1.1.0",
|
||||
"@shlinkio/shlink-js-sdk": "^2.2.1",
|
||||
"@shlinkio/shlink-web-component": "^0.16.1",
|
||||
"bottlejs": "^2.0.1",
|
||||
"clsx": "^2.1.1",
|
||||
"compare-versions": "^6.1.1",
|
||||
"csvtojson": "^2.0.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-external-link": "^2.3.1",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"reactstrap": "^9.2.3",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-external-link": "^2.5.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.7.1",
|
||||
"redux-localstorage-simple": "^2.5.1",
|
||||
"uuid": "^10.0.0",
|
||||
"workbox-core": "^7.1.0",
|
||||
"workbox-expiration": "^7.1.0",
|
||||
"workbox-precaching": "^7.1.0",
|
||||
"workbox-routing": "^7.1.0",
|
||||
"workbox-strategies": "^7.1.0"
|
||||
"workbox-core": "^7.3.0",
|
||||
"workbox-expiration": "^7.3.0",
|
||||
"workbox-precaching": "^7.3.0",
|
||||
"workbox-routing": "^7.3.0",
|
||||
"workbox-strategies": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@shlinkio/eslint-config-js-coding-standard": "~3.1.0",
|
||||
"@shlinkio/stylelint-config-css-coding-standard": "~1.1.1",
|
||||
"@stylistic/eslint-plugin": "^2.9.0",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@shlinkio/eslint-config-js-coding-standard": "~3.5.0",
|
||||
"@stylistic/eslint-plugin": "^5.2.2",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@testing-library/jest-dom": "^6.6.4",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@total-typescript/shoehorn": "^0.1.2",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@vitest/coverage-v8": "^2.1.2",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"adm-zip": "^0.5.16",
|
||||
"axe-core": "^4.10.0",
|
||||
"chalk": "^5.3.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.0",
|
||||
"eslint-plugin-react": "^7.37.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"axe-core": "^4.10.3",
|
||||
"chalk": "^5.4.1",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"history": "^5.3.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"sass": "^1.79.4",
|
||||
"stylelint": "^15.11.0",
|
||||
"typescript": "^5.6.2",
|
||||
"typescript-eslint": "^8.8.0",
|
||||
"vite": "^5.4.8",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vitest": "^2.0.2"
|
||||
"playwright": "^1.54.2",
|
||||
"tailwindcss": "^4.1.3",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"vite": "^7.0.6",
|
||||
"vite-plugin-pwa": "^1.0.2",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"browserslist": [
|
||||
">0.2%",
|
||||
|
||||
|
Before Width: | Height: | Size: 642 B After Width: | Height: | Size: 662 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 4.4 KiB |
@@ -1 +1,8 @@
|
||||
<svg width="512pt" height="512pt" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg"><g fill="#4595e3"><path d=" M 23.71 85.08 C 17.22 49.81 49.44 14.86 85.08 18.12 C 118.83 19.21 145.72 53.33 139.45 86.37 C 155.64 102.30 171.32 118.83 187.87 134.36 C 198.32 111.73 208.84 89.12 219.57 66.62 C 226.05 53.84 243.47 48.74 255.73 56.27 C 263.76 62.10 270.34 69.69 277.25 76.75 C 286.28 86.61 285.72 102.89 276.31 112.31 C 223.38 165.37 170.38 218.37 117.35 271.34 C 107.72 280.99 91.01 281.25 81.11 271.86 C 74.39 264.94 66.82 258.69 61.24 250.77 C 53.72 238.52 58.85 221.07 71.64 214.62 C 94.11 203.87 116.72 193.38 139.33 182.91 C 123.81 166.36 107.30 150.68 91.37 134.49 C 60.20 140.28 27.37 116.78 23.71 85.08 Z" /><path d=" M 205.21 201.23 C 225.32 181.36 260.88 181.11 281.14 200.86 C 299.25 218.75 317.37 236.65 335.10 254.93 C 356.73 278.01 352.01 318.70 326.03 336.56 C 320.07 330.47 313.73 324.65 308.12 318.28 C 323.86 309.39 328.76 286.18 316.63 272.39 C 301.73 256.95 286.30 242.03 271.24 226.75 C 264.49 219.65 256.80 212.00 246.37 211.52 C 224.65 208.64 205.52 233.36 214.49 253.58 C 221.09 266.81 234.22 275.12 243.62 286.24 C 240.43 295.96 238.09 306.13 238.29 316.46 C 225.55 304.29 213.16 291.73 200.89 279.09 C 180.97 257.57 183.10 220.45 205.21 201.23 Z" /><path d=" M 273.90 352.07 C 252.28 328.99 256.98 288.31 282.96 270.46 C 288.93 276.54 295.26 282.36 300.88 288.72 C 285.14 297.62 280.23 320.82 292.38 334.61 C 307.27 350.05 322.70 364.96 337.75 380.25 C 344.51 387.35 352.20 395.00 362.64 395.48 C 384.35 398.37 403.49 373.64 394.51 353.42 C 387.92 340.18 374.78 331.88 365.38 320.76 C 368.56 311.04 370.91 300.86 370.71 290.54 C 383.45 302.70 395.84 315.27 408.11 327.91 C 428.03 349.43 425.90 386.55 403.78 405.77 C 383.68 425.64 348.13 425.89 327.86 406.14 C 309.75 388.25 291.60 370.37 273.90 352.07 Z" /><path d=" M 422.11 403.83 C 431.96 394.07 441.60 384.06 451.66 374.51 C 460.90 383.74 471.89 392.70 474.89 406.11 C 480.16 429.97 484.08 454.13 488.76 478.12 C 490.00 483.41 484.47 488.29 479.35 486.63 C 454.66 481.52 429.55 478.12 405.14 471.84 C 393.17 467.97 385.20 457.75 376.55 449.27 C 386.39 439.49 396.13 429.60 406.06 419.91 C 416.37 433.45 435.74 414.00 422.11 403.83 Z" /></g></svg>
|
||||
<svg width="512pt" height="512pt" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="#2078CF">
|
||||
<path d=" M 23.71 85.08 C 17.22 49.81 49.44 14.86 85.08 18.12 C 118.83 19.21 145.72 53.33 139.45 86.37 C 155.64 102.30 171.32 118.83 187.87 134.36 C 198.32 111.73 208.84 89.12 219.57 66.62 C 226.05 53.84 243.47 48.74 255.73 56.27 C 263.76 62.10 270.34 69.69 277.25 76.75 C 286.28 86.61 285.72 102.89 276.31 112.31 C 223.38 165.37 170.38 218.37 117.35 271.34 C 107.72 280.99 91.01 281.25 81.11 271.86 C 74.39 264.94 66.82 258.69 61.24 250.77 C 53.72 238.52 58.85 221.07 71.64 214.62 C 94.11 203.87 116.72 193.38 139.33 182.91 C 123.81 166.36 107.30 150.68 91.37 134.49 C 60.20 140.28 27.37 116.78 23.71 85.08 Z"/>
|
||||
<path d=" M 205.21 201.23 C 225.32 181.36 260.88 181.11 281.14 200.86 C 299.25 218.75 317.37 236.65 335.10 254.93 C 356.73 278.01 352.01 318.70 326.03 336.56 C 320.07 330.47 313.73 324.65 308.12 318.28 C 323.86 309.39 328.76 286.18 316.63 272.39 C 301.73 256.95 286.30 242.03 271.24 226.75 C 264.49 219.65 256.80 212.00 246.37 211.52 C 224.65 208.64 205.52 233.36 214.49 253.58 C 221.09 266.81 234.22 275.12 243.62 286.24 C 240.43 295.96 238.09 306.13 238.29 316.46 C 225.55 304.29 213.16 291.73 200.89 279.09 C 180.97 257.57 183.10 220.45 205.21 201.23 Z"/>
|
||||
<path d=" M 273.90 352.07 C 252.28 328.99 256.98 288.31 282.96 270.46 C 288.93 276.54 295.26 282.36 300.88 288.72 C 285.14 297.62 280.23 320.82 292.38 334.61 C 307.27 350.05 322.70 364.96 337.75 380.25 C 344.51 387.35 352.20 395.00 362.64 395.48 C 384.35 398.37 403.49 373.64 394.51 353.42 C 387.92 340.18 374.78 331.88 365.38 320.76 C 368.56 311.04 370.91 300.86 370.71 290.54 C 383.45 302.70 395.84 315.27 408.11 327.91 C 428.03 349.43 425.90 386.55 403.78 405.77 C 383.68 425.64 348.13 425.89 327.86 406.14 C 309.75 388.25 291.60 370.37 273.90 352.07 Z"/>
|
||||
<path d=" M 422.11 403.83 C 431.96 394.07 441.60 384.06 451.66 374.51 C 460.90 383.74 471.89 392.70 474.89 406.11 C 480.16 429.97 484.08 454.13 488.76 478.12 C 490.00 483.41 484.47 488.29 479.35 486.63 C 454.66 481.52 429.55 478.12 405.14 471.84 C 393.17 467.97 385.20 457.75 376.55 449.27 C 386.39 439.49 396.13 429.60 406.06 419.91 C 416.37 433.45 435.74 414.00 422.11 403.83 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 287 B After Width: | Height: | Size: 319 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 381 B After Width: | Height: | Size: 420 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 437 B After Width: | Height: | Size: 509 B |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 466 B After Width: | Height: | Size: 570 B |
|
Before Width: | Height: | Size: 551 B After Width: | Height: | Size: 642 B |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 638 B After Width: | Height: | Size: 799 B |
|
Before Width: | Height: | Size: 684 B After Width: | Height: | Size: 834 B |
|
Before Width: | Height: | Size: 750 B After Width: | Height: | Size: 908 B |
|
Before Width: | Height: | Size: 783 B After Width: | Height: | Size: 973 B |
|
Before Width: | Height: | Size: 984 B After Width: | Height: | Size: 1.1 KiB |
@@ -1,23 +1,20 @@
|
||||
// Do this as the first thing so that any code reading it knows the right env.
|
||||
process.env.BABEL_ENV = 'production';
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
import chalk from 'chalk';
|
||||
import AdmZip from 'adm-zip';
|
||||
import fs from 'fs';
|
||||
|
||||
function zipDist(version) {
|
||||
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
|
||||
const fileBaseName = `shlink-web-client_${version}_dist`;
|
||||
const versionFileName = `./dist/${fileBaseName}.zip`;
|
||||
|
||||
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
|
||||
const zip = new AdmZip();
|
||||
|
||||
try {
|
||||
if (fs.existsSync(versionFileName)) {
|
||||
fs.unlink(versionFileName);
|
||||
fs.unlinkSync(versionFileName);
|
||||
}
|
||||
|
||||
zip.addLocalFolder('./build', `shlink-web-client_${version}_dist`);
|
||||
zip.addLocalFolder('./build', fileBaseName);
|
||||
zip.writeZip(versionFileName);
|
||||
console.log(chalk.green('Dist file properly generated'));
|
||||
} catch (e) {
|
||||
|
||||
@@ -4,11 +4,14 @@ set -e
|
||||
|
||||
ME=$(basename $0)
|
||||
|
||||
# In order to allow people to pre-configure a server in their shlink-web-client instance via env vars, this function
|
||||
# dumps a servers.json file based on the values provided via env vars
|
||||
setup_single_shlink_server() {
|
||||
[ -n "$SHLINK_SERVER_URL" ] || return 0
|
||||
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0
|
||||
local name="${SHLINK_SERVER_NAME:-Shlink}"
|
||||
echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\"}]" > /usr/share/nginx/html/servers.json
|
||||
local forwardCredentials="${SHLINK_SERVER_FORWARD_CREDENTIALS:-false}"
|
||||
echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\",\"forwardCredentials\":${forwardCredentials}}]" > /usr/share/nginx/html/servers.json
|
||||
}
|
||||
|
||||
setup_single_shlink_server
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { GetState } from '../../container/types';
|
||||
import type { ServerWithId } from '../../servers/data';
|
||||
import { hasServerData } from '../../servers/data';
|
||||
|
||||
const apiClients: Record<string, ShlinkApiClient> = {};
|
||||
const apiClients: Map<string, ShlinkApiClient> = new Map();
|
||||
|
||||
const isGetState = (getStateOrSelectedServer: GetState | ServerWithId): getStateOrSelectedServer is GetState =>
|
||||
typeof getStateOrSelectedServer === 'function';
|
||||
@@ -18,14 +18,22 @@ const getSelectedServerFromState = (getState: GetState): ServerWithId => {
|
||||
};
|
||||
|
||||
export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelectedServer: GetState | ServerWithId) => {
|
||||
const { url: baseUrl, apiKey } = isGetState(getStateOrSelectedServer)
|
||||
const { url: baseUrl, apiKey, forwardCredentials } = isGetState(getStateOrSelectedServer)
|
||||
? getSelectedServerFromState(getStateOrSelectedServer)
|
||||
: getStateOrSelectedServer;
|
||||
const serverKey = `${apiKey}_${baseUrl}`;
|
||||
const serverKey = `${apiKey}_${baseUrl}_${forwardCredentials ? 'forward' : 'no-forward'}`;
|
||||
const existingApiClient = apiClients.get(serverKey);
|
||||
|
||||
const apiClient = apiClients[serverKey] ?? new ShlinkApiClient(httpClient, { apiKey, baseUrl });
|
||||
apiClients[serverKey] = apiClient;
|
||||
if (existingApiClient) {
|
||||
return existingApiClient;
|
||||
}
|
||||
|
||||
const apiClient = new ShlinkApiClient(
|
||||
httpClient,
|
||||
{ apiKey, baseUrl },
|
||||
{ requestCredentials: forwardCredentials ? 'include' : undefined },
|
||||
);
|
||||
apiClients.set(serverKey, apiClient);
|
||||
return apiClient;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
|
||||
.app-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app {
|
||||
padding-top: $headerHeight;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.shlink-wrapper {
|
||||
min-height: 100%;
|
||||
padding-bottom: $footer-height + $footer-margin;
|
||||
margin-bottom: -($footer-height + $footer-margin);
|
||||
}
|
||||
|
||||
.shlink-footer {
|
||||
height: $footer-height;
|
||||
margin-top: $footer-margin;
|
||||
padding: 0;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 0 15px;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import { changeThemeInMarkup, getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
|
||||
import { ShlinkSidebarToggleButton, ShlinkSidebarVisibilityProvider } from '@shlinkio/shlink-web-component';
|
||||
import type { Settings } from '@shlinkio/shlink-web-component/settings';
|
||||
import { clsx } from 'clsx';
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { Route, Routes, useLocation } from 'react-router';
|
||||
import { AppUpdateBanner } from '../common/AppUpdateBanner';
|
||||
import { NotFound } from '../common/NotFound';
|
||||
import type { FCWithDeps } from '../container/utils';
|
||||
import { componentFactory, useDependencies } from '../container/utils';
|
||||
import type { ServersMap } from '../servers/data';
|
||||
import { forceUpdate } from '../utils/helpers/sw';
|
||||
import './App.scss';
|
||||
|
||||
type AppProps = {
|
||||
fetchServers: () => void;
|
||||
@@ -50,8 +50,8 @@ const App: FCWithDeps<AppProps, AppDeps> = (
|
||||
const isHome = location.pathname === '/';
|
||||
|
||||
useEffect(() => {
|
||||
// Try to fetch the remote servers if the list is empty at first
|
||||
// We use a ref because we don't care if the servers list becomes empty later
|
||||
// Try to fetch the remote servers if the list is empty during first render.
|
||||
// We use a ref because we don't care if the servers list becomes empty later.
|
||||
if (Object.keys(initialServers.current).length === 0) {
|
||||
fetchServers();
|
||||
}
|
||||
@@ -62,28 +62,41 @@ const App: FCWithDeps<AppProps, AppDeps> = (
|
||||
}, [settings.ui?.theme]);
|
||||
|
||||
return (
|
||||
<div className="container-fluid app-container">
|
||||
<MainHeader />
|
||||
<div className="h-full">
|
||||
<ShlinkSidebarVisibilityProvider>
|
||||
<ShlinkSidebarToggleButton className="fixed top-3.5 left-3 z-901" />
|
||||
<MainHeader />
|
||||
|
||||
<div className="app">
|
||||
<div className={clsx('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
|
||||
<Routes>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="/settings/*" element={<Settings />} />
|
||||
<Route path="/manage-servers" element={<ManageServers />} />
|
||||
<Route path="/server/create" element={<CreateServer />} />
|
||||
<Route path="/server/:serverId/edit" element={<EditServer />} />
|
||||
<Route path="/server/:serverId/*" element={<ShlinkWebComponentContainer />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
<div className="h-full pt-(--header-height)">
|
||||
<div
|
||||
data-testid="shlink-wrapper"
|
||||
className={clsx(
|
||||
'min-h-full pb-[calc(var(--footer-height)+var(--footer-margin))] -mb-[calc(var(--footer-height)+var(--footer-margin))]',
|
||||
{ 'flex items-center pt-4': isHome },
|
||||
)}
|
||||
>
|
||||
<Routes>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="/settings">
|
||||
{['', '*'].map((path) => <Route key={path} path={path} element={<Settings />} />)}
|
||||
</Route>
|
||||
<Route path="/manage-servers" element={<ManageServers />} />
|
||||
<Route path="/server/create" element={<CreateServer />} />
|
||||
<Route path="/server/:serverId/edit" element={<EditServer />} />
|
||||
<Route path="/server/:serverId">
|
||||
{['', '*'].map((path) => <Route key={path} path={path} element={<ShlinkWebComponentContainer />} />)}
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
<div className="h-(--footer-height) mt-(--footer-margin) md:px-4">
|
||||
<ShlinkVersionsContainer />
|
||||
</div>
|
||||
</div>
|
||||
</ShlinkSidebarVisibilityProvider>
|
||||
|
||||
<div className="shlink-footer">
|
||||
<ShlinkVersionsContainer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppUpdateBanner isOpen={appUpdated} toggle={resetAppUpdate} forceUpdate={forceUpdate} />
|
||||
<AppUpdateBanner isOpen={appUpdated} onClose={resetAppUpdate} forceUpdate={forceUpdate} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
@import '../utils/mixins/horizontal-align';
|
||||
|
||||
.app-update-banner.app-update-banner {
|
||||
@include horizontal-align();
|
||||
|
||||
position: fixed;
|
||||
top: $headerHeight - 25px;
|
||||
padding: 0 4rem 0 0;
|
||||
z-index: 1040;
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
text-align: center;
|
||||
width: 700px;
|
||||
max-width: calc(100% - 30px);
|
||||
box-shadow: 0 0 1rem var(--brand-color);
|
||||
}
|
||||
@@ -1,34 +1,46 @@
|
||||
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { SimpleCard, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { MouseEventHandler } from 'react';
|
||||
import { forwardRef, useCallback } from 'react';
|
||||
import { Alert, Button } from 'reactstrap';
|
||||
import './AppUpdateBanner.scss';
|
||||
import { Button, Card, CloseButton,useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import { clsx } from 'clsx';
|
||||
import type { FC } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
interface AppUpdateBannerProps {
|
||||
isOpen: boolean;
|
||||
toggle: MouseEventHandler<any>;
|
||||
onClose: () => void;
|
||||
forceUpdate: () => void;
|
||||
}
|
||||
|
||||
export const AppUpdateBanner = forwardRef<HTMLElement, AppUpdateBannerProps>(({ isOpen, toggle, forceUpdate }, ref) => {
|
||||
const [isUpdating,, setUpdating] = useToggle();
|
||||
export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, onClose, forceUpdate }) => {
|
||||
const { flag: isUpdating, setToTrue: setUpdating } = useToggle();
|
||||
const update = useCallback(() => {
|
||||
setUpdating();
|
||||
forceUpdate();
|
||||
}, [forceUpdate, setUpdating]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert className="app-update-banner" isOpen={isOpen} toggle={toggle} tag={SimpleCard} color="secondary" innerRef={ref}>
|
||||
<h4 className="mb-4">This app has just been updated!</h4>
|
||||
<p className="mb-0">
|
||||
<Card
|
||||
role="alert"
|
||||
className={clsx(
|
||||
'w-[700px] max-w-[calc(100%-30px)]',
|
||||
'fixed top-[35px] left-[50%] translate-x-[-50%] z-[1040]',
|
||||
)}
|
||||
>
|
||||
<Card.Header className="flex items-center justify-between">
|
||||
<h5>This app has just been updated!</h5>
|
||||
<CloseButton onClick={onClose} />
|
||||
</Card.Header>
|
||||
<Card.Body className="flex gap-4 items-center justify-between max-md:flex-col">
|
||||
Restart it to enjoy the new features.
|
||||
<Button role="button" disabled={isUpdating} className="ms-2" color="secondary" size="sm" onClick={update}>
|
||||
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ms-1" /></>}
|
||||
<Button disabled={isUpdating} variant="secondary" solid onClick={update}>
|
||||
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} /></>}
|
||||
{isUpdating && <>Restarting...</>}
|
||||
</Button>
|
||||
</p>
|
||||
</Alert>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
||||
import { Button } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
import { Component } from 'react';
|
||||
import { Button } from 'reactstrap';
|
||||
import { ErrorLayout } from './ErrorLayout';
|
||||
|
||||
type ErrorHandlerProps = PropsWithChildren<{
|
||||
location?: typeof window.location;
|
||||
@@ -33,14 +33,11 @@ export class ErrorHandler extends Component<ErrorHandlerProps, ErrorHandlerState
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="home">
|
||||
<SimpleCard className="p-4">
|
||||
<h1>Oops! This is awkward :S</h1>
|
||||
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
|
||||
<br />
|
||||
<Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
|
||||
</SimpleCard>
|
||||
</div>
|
||||
<ErrorLayout title="Oops! This is awkward :S">
|
||||
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
|
||||
<br />
|
||||
<Button size="lg" onClick={() => location.reload()}>Take me back</Button>
|
||||
</ErrorLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
15
src/common/ErrorLayout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
|
||||
export type ErrorLayoutProps = PropsWithChildren<{
|
||||
title: string;
|
||||
}>;
|
||||
|
||||
export const ErrorLayout: FC<ErrorLayoutProps> = ({ children, title }) => (
|
||||
<div className="pt-4">
|
||||
<SimpleCard className="p-4 w-full lg:w-[65%] m-auto">
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</SimpleCard>
|
||||
</div>
|
||||
);
|
||||
@@ -1,58 +0,0 @@
|
||||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
@import '../utils/mixins/vertical-align';
|
||||
|
||||
$mainCardWidth: 720px;
|
||||
$fiveColumnsSize: .4167; // 12 / 5 -> Can't use "/" operator in latest dart-sass
|
||||
|
||||
.home {
|
||||
position: relative;
|
||||
padding-top: 15px;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding-top: 0;
|
||||
height: calc(100vh - #{$headerHeight} - #{($footer-height + $footer-margin)});
|
||||
}
|
||||
}
|
||||
|
||||
.home__logo-wrapper {
|
||||
padding: 1.5rem !important;
|
||||
height: 100% !important;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.home__logo {
|
||||
@include vertical-align();
|
||||
|
||||
width: calc(#{$mainCardWidth * $fiveColumnsSize} - 3rem);
|
||||
}
|
||||
|
||||
.home__main-card {
|
||||
margin: 0 auto;
|
||||
max-width: $mainCardWidth;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
@include vertical-align();
|
||||
}
|
||||
}
|
||||
|
||||
.home__title-wrapper {
|
||||
padding: 1.5rem !important;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.home__title {
|
||||
text-align: center;
|
||||
font-size: 1.75rem;
|
||||
margin: 0;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.home__servers-container {
|
||||
@media (min-width: $mdMin) {
|
||||
border-left: 1px solid var(--border-color);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Button, Card } from '@shlinkio/shlink-frontend-kit';
|
||||
import { clsx } from 'clsx';
|
||||
import { useEffect } from 'react';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Card, Row } from 'reactstrap';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { ServersMap } from '../servers/data';
|
||||
import { ServersListGroup } from '../servers/ServersListGroup';
|
||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||
import './Home.scss';
|
||||
|
||||
interface HomeProps {
|
||||
export type HomeProps = {
|
||||
servers: ServersMap;
|
||||
}
|
||||
};
|
||||
|
||||
export const Home = ({ servers }: HomeProps) => {
|
||||
const navigate = useNavigate();
|
||||
@@ -27,41 +27,44 @@ export const Home = ({ servers }: HomeProps) => {
|
||||
}, [serversList, navigate]);
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<Card className="home__main-card">
|
||||
<Row className="g-0">
|
||||
<div className="col-md-5 d-none d-md-block">
|
||||
<div className="home__logo-wrapper">
|
||||
<div className="home__logo">
|
||||
<ShlinkLogo />
|
||||
</div>
|
||||
<div className="px-3 w-full">
|
||||
<Card className="mx-auto max-w-[720px] overflow-hidden">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<div className="p-6 hidden md:flex items-center w-[40%]">
|
||||
<div className="w-full">
|
||||
<ShlinkLogo />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-7 home__servers-container">
|
||||
<div className="home__title-wrapper">
|
||||
<h1 className="home__title">Welcome!</h1>
|
||||
</div>
|
||||
<ServersListGroup embedded servers={serversList}>
|
||||
{!hasServers && (
|
||||
<div className="p-4 text-center">
|
||||
<p className="mb-5">This application will help you manage your Shlink servers.</p>
|
||||
<p>
|
||||
<Link to="/server/create" className="btn btn-outline-primary btn-lg me-2">
|
||||
<FontAwesomeIcon icon={faPlus} /> <span className="ms-1">Add a server</span>
|
||||
</Link>
|
||||
</p>
|
||||
<p className="mb-0 mt-5">
|
||||
<ExternalLink href="https://shlink.io/documentation">
|
||||
<small>
|
||||
<span className="me-1">Learn more about Shlink</span> <FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
</small>
|
||||
</ExternalLink>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="md:border-l border-lm-border dark:border-dm-border flex-grow">
|
||||
<h1
|
||||
className={clsx(
|
||||
'p-4 text-center border-lm-border dark:border-dm-border',
|
||||
{ 'border-b': !hasServers },
|
||||
)}
|
||||
</ServersListGroup>
|
||||
>
|
||||
Welcome!
|
||||
</h1>
|
||||
{hasServers ? <ServersListGroup servers={serversList} /> : (
|
||||
<div className="p-6 text-center flex flex-col gap-12 text-xl">
|
||||
<p>This application will help you manage your Shlink servers.</p>
|
||||
<p>
|
||||
<Button to="/server/create" size="lg" inline>
|
||||
<FontAwesomeIcon icon={faPlus} widthAuto /> Add a server
|
||||
</Button>
|
||||
</p>
|
||||
<p>
|
||||
<ExternalLink href="https://shlink.io/documentation">
|
||||
<small>
|
||||
<span className="mr-2">Learn more about Shlink</span>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
</small>
|
||||
</ExternalLink>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
|
||||
.main-header.main-header {
|
||||
color: white;
|
||||
background-color: var(--brand-color) !important;
|
||||
|
||||
.navbar-brand {
|
||||
color: inherit !important;
|
||||
}
|
||||
}
|
||||
|
||||
.main-header__brand-logo {
|
||||
width: 26px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.main-header__toggle-icon {
|
||||
width: 20px;
|
||||
transition: transform 300ms;
|
||||
}
|
||||
|
||||
.main-header__toggle-icon--opened {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
import { faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import { clsx } from 'clsx';
|
||||
import { NavBar } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
import type { FCWithDeps } from '../container/utils';
|
||||
import { componentFactory, useDependencies } from '../container/utils';
|
||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||
import './MainHeader.scss';
|
||||
|
||||
type MainHeaderDeps = {
|
||||
ServersDropdown: FC;
|
||||
@@ -17,37 +13,28 @@ type MainHeaderDeps = {
|
||||
|
||||
const MainHeader: FCWithDeps<unknown, MainHeaderDeps> = () => {
|
||||
const { ServersDropdown } = useDependencies(MainHeader);
|
||||
const [isNotCollapsed, toggleCollapse, , collapse] = useToggle();
|
||||
const location = useLocation();
|
||||
const { pathname } = location;
|
||||
|
||||
// In mobile devices, collapse the navbar when location changes
|
||||
useEffect(collapse, [location, collapse]);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const settingsPath = '/settings';
|
||||
const toggleClass = clsx('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isNotCollapsed });
|
||||
|
||||
return (
|
||||
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
||||
<NavbarBrand tag={Link} to="/">
|
||||
<ShlinkLogo className="main-header__brand-logo" color="white" /> Shlink
|
||||
</NavbarBrand>
|
||||
|
||||
<NavbarToggler onClick={toggleCollapse}>
|
||||
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
||||
</NavbarToggler>
|
||||
|
||||
<Collapse navbar isOpen={isNotCollapsed}>
|
||||
<Nav navbar className="ms-auto">
|
||||
<NavItem>
|
||||
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
|
||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<ServersDropdown />
|
||||
</Nav>
|
||||
</Collapse>
|
||||
</Navbar>
|
||||
<NavBar
|
||||
className="[&]:fixed top-0 z-900"
|
||||
brand={(
|
||||
<Link to="/" className="[&]:text-white no-underline flex items-center gap-2">
|
||||
<ShlinkLogo className="w-7" color="white" /> <small className="font-normal">Shlink</small>
|
||||
</Link>
|
||||
)}
|
||||
>
|
||||
<NavBar.MenuItem
|
||||
to={settingsPath}
|
||||
active={pathname.startsWith(settingsPath)}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||
</NavBar.MenuItem>
|
||||
<ServersDropdown />
|
||||
</NavBar>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
|
||||
.no-menu-wrapper {
|
||||
padding: 15px 0 0;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 30px 20px 20px;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { clsx } from 'clsx';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import './NoMenuLayout.scss';
|
||||
|
||||
export const NoMenuLayout: FC<PropsWithChildren> = ({ children }) => (
|
||||
<div className="no-menu-wrapper container-xl">{children}</div>
|
||||
export type NoMenuLayoutProps = PropsWithChildren & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const NoMenuLayout: FC<NoMenuLayoutProps> = ({ children, className }) => (
|
||||
<div className={clsx('container mx-auto p-5 pt-8 max-md:p-3 max-md:py-4', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
||||
import { Button } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ErrorLayout } from './ErrorLayout';
|
||||
|
||||
type NotFoundProps = PropsWithChildren<{ to?: string }>;
|
||||
|
||||
export const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
|
||||
<div className="home">
|
||||
<SimpleCard className="p-4">
|
||||
<h2>Oops! We could not find requested route.</h2>
|
||||
<p>
|
||||
Use your browser's back button to navigate to the page you have previously come from, or just press this
|
||||
button.
|
||||
</p>
|
||||
<br />
|
||||
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
|
||||
</SimpleCard>
|
||||
</div>
|
||||
<ErrorLayout title="Oops! We could not find requested route.">
|
||||
<p>
|
||||
Use your browser's back button to navigate to the page you have previously come from, or just press this
|
||||
button.
|
||||
</p>
|
||||
<br />
|
||||
<Button inline to={to} size="lg">{children}</Button>
|
||||
</ErrorLayout>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
export const ScrollToTop: FC<PropsWithChildren> = ({ children }) => {
|
||||
const location = useLocation();
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface ShlinkVersionsProps {
|
||||
}
|
||||
|
||||
const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => (
|
||||
<ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="text-muted">
|
||||
<ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="text-gray-500">
|
||||
<b>{version}</b>
|
||||
</ExternalLink>
|
||||
);
|
||||
@@ -21,7 +21,7 @@ export const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIE
|
||||
const normalizedClientVersion = normalizeVersion(clientVersion);
|
||||
|
||||
return (
|
||||
<small className="text-muted">
|
||||
<small className="text-gray-500">
|
||||
{isReachableServer(selectedServer) && (
|
||||
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
|
||||
)}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
|
||||
.shlink-versions-container--with-sidebar {
|
||||
margin-left: 0;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
margin-left: $asideMenuWidth;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { clsx } from 'clsx';
|
||||
import type { SelectedServer } from '../servers/data';
|
||||
import { isReachableServer } from '../servers/data';
|
||||
import { ShlinkVersions } from './ShlinkVersions';
|
||||
import './ShlinkVersionsContainer.scss';
|
||||
|
||||
export type ShlinkVersionsContainerProps = {
|
||||
selectedServer: SelectedServer;
|
||||
@@ -10,9 +9,7 @@ export type ShlinkVersionsContainerProps = {
|
||||
|
||||
export const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => (
|
||||
<div
|
||||
className={clsx('text-center', {
|
||||
'shlink-versions-container--with-sidebar': isReachableServer(selectedServer),
|
||||
})}
|
||||
className={clsx('text-center', { 'md:ml-(--aside-menu-width)': isReachableServer(selectedServer) })}
|
||||
>
|
||||
<ShlinkVersions selectedServer={selectedServer} />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ShlinkWebComponentType, TagColorsStorage } from '@shlinkio/shlink-web-component';
|
||||
import type { ShlinkWebComponentProps, TagColorsStorage } from '@shlinkio/shlink-web-component';
|
||||
import type { Settings } from '@shlinkio/shlink-web-component/settings';
|
||||
import type { FC } from 'react';
|
||||
import { memo } from 'react';
|
||||
@@ -17,7 +17,7 @@ type ShlinkWebComponentContainerProps = WithSelectedServerProps & {
|
||||
type ShlinkWebComponentContainerDeps = {
|
||||
buildShlinkApiClient: ShlinkApiClientBuilder,
|
||||
TagColorsStorage: TagColorsStorage,
|
||||
ShlinkWebComponent: ShlinkWebComponentType,
|
||||
ShlinkWebComponent: FC<ShlinkWebComponentProps>,
|
||||
ServerError: FC,
|
||||
};
|
||||
|
||||
@@ -51,6 +51,7 @@ const ShlinkWebComponentContainer: FCWithDeps<
|
||||
createNotFound={(nonPrefixedHomePath) => (
|
||||
<NotFound to={`${routesPrefix}${nonPrefixedHomePath}`}>List short URLs</NotFound>
|
||||
)}
|
||||
autoSidebarToggle={false}
|
||||
/>
|
||||
);
|
||||
}));
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { MAIN_COLOR } from '@shlinkio/shlink-frontend-kit';
|
||||
import { brandColor } from '@shlinkio/shlink-frontend-kit';
|
||||
|
||||
export interface ShlinkLogoProps {
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ShlinkLogo = ({ color = MAIN_COLOR, className }: ShlinkLogoProps) => (
|
||||
export const ShlinkLogo = ({ color = brandColor(), className }: ShlinkLogoProps) => (
|
||||
<svg className={className} viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill={color}>
|
||||
<path
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FetchHttpClient } from '@shlinkio/shlink-js-sdk/browser';
|
||||
import { FetchHttpClient } from '@shlinkio/shlink-js-sdk/fetch';
|
||||
import { ShlinkWebComponent } from '@shlinkio/shlink-web-component';
|
||||
import type Bottle from 'bottlejs';
|
||||
import type { ConnectDecorator } from '../../container/types';
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { IContainer } from 'bottlejs';
|
||||
import type { FC } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export type FCWithDeps<Props, Deps> = FC<Props> & Partial<Deps>;
|
||||
|
||||
export function useDependencies<Deps>(obj: Deps): Omit<Required<Deps>, keyof FC> {
|
||||
const depsRef = useRef(obj as Omit<Required<Deps>, keyof FC>);
|
||||
return depsRef.current;
|
||||
return useMemo(() => obj as Omit<Required<Deps>, keyof FC>, [obj]);
|
||||
}
|
||||
|
||||
export function componentFactory<Deps, CompType = Omit<Partial<Deps>, keyof FC>>(
|
||||
@@ -20,7 +19,6 @@ export function componentFactory<Deps, CompType = Omit<Partial<Deps>, keyof FC>>
|
||||
console.error(`[Debug] Could not find "${dep as string}" dependency in container`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
Component[dep] = resolvedDependency;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base'; // Before bootstrap stylesheet. Includes SASS var overrides
|
||||
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
||||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/index'; // After bootstrap. Includes CSS overwrites
|
||||
@import 'node_modules/@shlinkio/shlink-web-component/dist/index';
|
||||
@@ -1,16 +1,16 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import pack from '../package.json';
|
||||
import { container } from './container';
|
||||
import { setUpStore } from './container/store';
|
||||
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
||||
import './index.scss';
|
||||
import './tailwind.css';
|
||||
|
||||
const store = setUpStore(container);
|
||||
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
|
||||
|
||||
createRoot(document.getElementById('root')!).render( // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<Provider store={store}>
|
||||
<BrowserRouter basename={pack.homepage}>
|
||||
<ErrorHandler>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import { Result, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { ResultProps,TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import { Button, Result,useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from 'reactstrap';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||
import type { FCWithDeps } from '../container/utils';
|
||||
import { componentFactory, useDependencies } from '../container/utils';
|
||||
import { useGoBack } from '../utils/helpers/hooks';
|
||||
import { randomUUID } from '../utils/utils';
|
||||
import type { ServerData, ServersMap, ServerWithId } from './data';
|
||||
import { ensureUniqueIds } from './helpers';
|
||||
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
|
||||
import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
@@ -26,11 +25,11 @@ type CreateServerDeps = {
|
||||
useTimeoutToggle: TimeoutToggle;
|
||||
};
|
||||
|
||||
const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
|
||||
<div className="mt-3">
|
||||
<Result type={type}>
|
||||
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
||||
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
||||
const ImportResult = ({ variant }: Pick<ResultProps, 'variant'>) => (
|
||||
<div className="mt-4">
|
||||
<Result variant={variant}>
|
||||
{variant === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
||||
{variant === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
||||
</Result>
|
||||
</div>
|
||||
);
|
||||
@@ -40,51 +39,50 @@ const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers
|
||||
const navigate = useNavigate();
|
||||
const goBack = useGoBack();
|
||||
const hasServers = !!Object.keys(servers).length;
|
||||
const [serversImported, setServersImported] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
||||
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
||||
const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
const [serversImported, setServersImported] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
|
||||
const { flag: isConfirmModalOpen, toggle: toggleConfirmModal } = useToggle();
|
||||
const [serverData, setServerData] = useState<ServerData>();
|
||||
const saveNewServer = useCallback((theServerData: ServerData) => {
|
||||
const id = randomUUID();
|
||||
const saveNewServer = useCallback((newServerData: ServerData) => {
|
||||
const [newServerWithUniqueId] = ensureUniqueIds(servers, [newServerData]);
|
||||
|
||||
createServers([{ ...theServerData, id }]);
|
||||
navigate(`/server/${id}`);
|
||||
}, [createServers, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverData) {
|
||||
return;
|
||||
}
|
||||
createServers([newServerWithUniqueId]);
|
||||
navigate(`/server/${newServerWithUniqueId.id}`);
|
||||
}, [createServers, navigate, servers]);
|
||||
const onSubmit = useCallback((newServerData: ServerData) => {
|
||||
setServerData(newServerData);
|
||||
|
||||
const serverExists = Object.values(servers).some(
|
||||
({ url, apiKey }) => serverData?.url === url && serverData?.apiKey === apiKey,
|
||||
({ url, apiKey }) => newServerData.url === url && newServerData.apiKey === apiKey,
|
||||
);
|
||||
|
||||
if (serverExists) {
|
||||
toggleConfirmModal();
|
||||
} else {
|
||||
saveNewServer(serverData);
|
||||
saveNewServer(newServerData);
|
||||
}
|
||||
}, [saveNewServer, serverData, servers, toggleConfirmModal]);
|
||||
}, [saveNewServer, servers, toggleConfirmModal]);
|
||||
|
||||
return (
|
||||
<NoMenuLayout>
|
||||
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={setServerData}>
|
||||
<ServerForm title="Add new server" onSubmit={onSubmit}>
|
||||
{!hasServers && (
|
||||
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />
|
||||
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onError={setErrorImporting} />
|
||||
)}
|
||||
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
|
||||
<Button outline color="primary" className="ms-2">Create server</Button>
|
||||
{hasServers && <Button variant="secondary" onClick={goBack}>Cancel</Button>}
|
||||
<Button type="submit">Create server</Button>
|
||||
</ServerForm>
|
||||
|
||||
{serversImported && <ImportResult type="success" />}
|
||||
{errorImporting && <ImportResult type="error" />}
|
||||
{serversImported && <ImportResult variant="success" />}
|
||||
{errorImporting && <ImportResult variant="error" />}
|
||||
|
||||
<DuplicatedServersModal
|
||||
isOpen={isConfirmModalOpen}
|
||||
open={isConfirmModalOpen}
|
||||
duplicatedServers={serverData ? [serverData] : []}
|
||||
onDiscard={goBack}
|
||||
onSave={() => serverData && saveNewServer(serverData)}
|
||||
onClose={goBack}
|
||||
onConfirm={() => serverData && saveNewServer(serverData)}
|
||||
/>
|
||||
</NoMenuLayout>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import { clsx } from 'clsx';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { FCWithDeps } from '../container/utils';
|
||||
import { componentFactory, useDependencies } from '../container/utils';
|
||||
import type { ServerWithId } from './data';
|
||||
@@ -10,28 +9,29 @@ import type { DeleteServerModalProps } from './DeleteServerModal';
|
||||
|
||||
export type DeleteServerButtonProps = PropsWithChildren<{
|
||||
server: ServerWithId;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
}>;
|
||||
|
||||
type DeleteServerButtonDeps = {
|
||||
DeleteServerModal: FC<DeleteServerModalProps>;
|
||||
};
|
||||
|
||||
const DeleteServerButton: FCWithDeps<DeleteServerButtonProps, DeleteServerButtonDeps> = (
|
||||
{ server, className, children, textClassName },
|
||||
) => {
|
||||
const DeleteServerButton: FCWithDeps<DeleteServerButtonProps, DeleteServerButtonDeps> = ({ server, children }) => {
|
||||
const { DeleteServerModal } = useDependencies(DeleteServerButton);
|
||||
const [isModalOpen, , showModal, hideModal] = useToggle();
|
||||
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle();
|
||||
const navigate = useNavigate();
|
||||
const onClose = useCallback((confirmed: boolean) => {
|
||||
hideModal();
|
||||
if (confirmed) {
|
||||
navigate('/');
|
||||
}
|
||||
}, [hideModal, navigate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className={clsx(className, 'p-0 bg-transparent border-0')} onClick={showModal}>
|
||||
{!children && <FontAwesomeIcon fixedWidth icon={deleteIcon} />}
|
||||
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
||||
<button type="button" className="text-danger hover:underline" onClick={showModal}>
|
||||
{children}
|
||||
</button>
|
||||
|
||||
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
|
||||
<DeleteServerModal server={server} open={isModalOpen} onClose={onClose} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,44 +1,37 @@
|
||||
import type { ExitAction } from '@shlinkio/shlink-frontend-kit';
|
||||
import { CardModal } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { useCallback } from 'react';
|
||||
import type { ServerWithId } from './data';
|
||||
|
||||
export interface DeleteServerModalProps {
|
||||
export type DeleteServerModalProps = {
|
||||
server: ServerWithId;
|
||||
toggle: () => void;
|
||||
isOpen: boolean;
|
||||
redirectHome?: boolean;
|
||||
}
|
||||
onClose: (confirmed: boolean) => void;
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
interface DeleteServerModalConnectProps extends DeleteServerModalProps {
|
||||
type DeleteServerModalConnectProps = DeleteServerModalProps & {
|
||||
deleteServer: (server: ServerWithId) => void;
|
||||
}
|
||||
};
|
||||
|
||||
export const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
|
||||
{ server, toggle, isOpen, deleteServer, redirectHome = true },
|
||||
) => {
|
||||
const navigate = useNavigate();
|
||||
const doDelete = useRef<boolean>(false);
|
||||
const toggleAndDelete = () => {
|
||||
doDelete.current = true;
|
||||
toggle();
|
||||
};
|
||||
const onClosed = () => {
|
||||
if (!doDelete.current) {
|
||||
return;
|
||||
export const DeleteServerModal: FC<DeleteServerModalConnectProps> = ({ server, onClose, open, deleteServer }) => {
|
||||
const onClosed = useCallback((exitAction: ExitAction) => {
|
||||
if (exitAction === 'confirm') {
|
||||
deleteServer(server);
|
||||
}
|
||||
|
||||
deleteServer(server);
|
||||
if (redirectHome) {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
}, [deleteServer, server]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={onClosed}>
|
||||
<ModalHeader toggle={toggle} className="text-danger">Remove server</ModalHeader>
|
||||
<ModalBody>
|
||||
<CardModal
|
||||
open={open}
|
||||
title="Remove server"
|
||||
variant="danger"
|
||||
onClose={() => onClose(false)}
|
||||
onConfirm={() => onClose(true)}
|
||||
onClosed={onClosed}
|
||||
confirmText="Delete"
|
||||
>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
|
||||
<p>
|
||||
<i>
|
||||
@@ -46,11 +39,7 @@ export const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
|
||||
You can create it again at any moment.
|
||||
</i>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="link" onClick={toggle}>Cancel</Button>
|
||||
<Button color="danger" onClick={toggleAndDelete}>Delete</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</div>
|
||||
</CardModal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useParsedQuery } from '@shlinkio/shlink-frontend-kit';
|
||||
import { Button,useParsedQuery } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { Button } from 'reactstrap';
|
||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||
import type { FCWithDeps } from '../container/utils';
|
||||
import { componentFactory } from '../container/utils';
|
||||
@@ -40,12 +39,12 @@ const EditServer: FCWithDeps<EditServerProps, EditServerDeps> = withSelectedServ
|
||||
return (
|
||||
<NoMenuLayout>
|
||||
<ServerForm
|
||||
title={<h5 className="mb-0">Edit "{selectedServer.name}"</h5>}
|
||||
title={<>Edit "{selectedServer.name}"</>}
|
||||
initialValues={selectedServer}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<Button outline className="me-2" onClick={goBack}>Cancel</Button>
|
||||
<Button outline color="primary">Save</Button>
|
||||
<Button variant="secondary" onClick={goBack}>Cancel</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</ServerForm>
|
||||
</NoMenuLayout>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import { Result, SearchField, SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
||||
import { Button, Result, SearchInput, SimpleCard, Table } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Row } from 'reactstrap';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||
import type { FCWithDeps } from '../container/utils';
|
||||
import { componentFactory, useDependencies } from '../container/utils';
|
||||
@@ -34,60 +32,57 @@ const ManageServers: FCWithDeps<ManageServersProps, ManageServersDeps> = ({ serv
|
||||
useTimeoutToggle,
|
||||
ManageServersRow,
|
||||
} = useDependencies(ManageServers);
|
||||
const allServers = Object.values(servers);
|
||||
const [serversList, setServersList] = useState(allServers);
|
||||
const filterServers = (searchTerm: string) => setServersList(
|
||||
allServers.filter(({ name, url }) => `${name} ${url}`.toLowerCase().match(searchTerm.toLowerCase())),
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const allServers = useMemo(() => Object.values(servers), [servers]);
|
||||
const filteredServers = useMemo(
|
||||
() => allServers.filter(({ name, url }) => `${name} ${url}`.toLowerCase().match(searchTerm.toLowerCase())),
|
||||
[allServers, searchTerm],
|
||||
);
|
||||
const hasAutoConnect = serversList.some(({ autoConnect }) => !!autoConnect);
|
||||
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
||||
|
||||
useEffect(() => {
|
||||
setServersList(Object.values(servers));
|
||||
}, [servers]);
|
||||
const hasAutoConnect = allServers.some(({ autoConnect }) => !!autoConnect);
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
|
||||
|
||||
return (
|
||||
<NoMenuLayout>
|
||||
<SearchField className="mb-3" onChange={filterServers} />
|
||||
<NoMenuLayout className="flex flex-col gap-y-4">
|
||||
<SearchInput onChange={setSearchTerm} />
|
||||
|
||||
<Row className="mb-3">
|
||||
<div className="col-md-6 d-flex d-md-block mb-2 mb-md-0">
|
||||
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
|
||||
{allServers.length > 0 && (
|
||||
<Button outline className="ms-2 flex-fill" onClick={async () => serversExporter.exportServers()}>
|
||||
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
|
||||
<div className="flex flex-col md:flex-row gap-2">
|
||||
<div className="flex gap-2">
|
||||
<ImportServersBtn className="flex-grow" onError={setErrorImporting}>Import servers</ImportServersBtn>
|
||||
{filteredServers.length > 0 && (
|
||||
<Button variant="secondary" className="flex-grow" onClick={async () => serversExporter.exportServers()}>
|
||||
<FontAwesomeIcon icon={exportIcon} widthAuto /> Export servers
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-md-6 text-md-end d-flex d-md-block">
|
||||
<Button outline color="primary" className="flex-fill" tag={Link} to="/server/create">
|
||||
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
|
||||
</Button>
|
||||
</div>
|
||||
</Row>
|
||||
<Button className="md:ml-auto" to="/server/create">
|
||||
<FontAwesomeIcon icon={plusIcon} widthAuto /> Add a server
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SimpleCard>
|
||||
<table className="table table-hover responsive-table mb-0">
|
||||
<thead className="responsive-table__header">
|
||||
<tr>
|
||||
{hasAutoConnect && <th style={{ width: '50px' }}><span className="sr-only">Auto-connect</span></th>}
|
||||
<th>Name</th>
|
||||
<th>Base URL</th>
|
||||
<th><span className="sr-only">Options</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!serversList.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>}
|
||||
{serversList.map((server) => (
|
||||
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<SimpleCard className="card">
|
||||
<Table header={(
|
||||
<Table.Row>
|
||||
{hasAutoConnect && (
|
||||
<Table.Cell className="w-[35px]"><span className="sr-only">Auto-connect</span></Table.Cell>
|
||||
)}
|
||||
<Table.Cell>Name</Table.Cell>
|
||||
<Table.Cell>Base URL</Table.Cell>
|
||||
<Table.Cell><span className="sr-only">Options</span></Table.Cell>
|
||||
</Table.Row>
|
||||
)}>
|
||||
{!filteredServers.length && (
|
||||
<Table.Row className="text-center"><Table.Cell colSpan={4}>No servers found.</Table.Cell></Table.Row>
|
||||
)}
|
||||
{filteredServers.map((server) => (
|
||||
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
|
||||
))}
|
||||
</Table>
|
||||
</SimpleCard>
|
||||
|
||||
{errorImporting && (
|
||||
<div className="mt-3">
|
||||
<Result type="error">The servers could not be imported. Make sure the format is correct.</Result>
|
||||
<div>
|
||||
<Result variant="error">The servers could not be imported. Make sure the format is correct.</Result>
|
||||
</div>
|
||||
)}
|
||||
</NoMenuLayout>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { faCheck as checkIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Table, Tooltip, useTooltip } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import { Link } from 'react-router';
|
||||
import type { FCWithDeps } from '../container/utils';
|
||||
import { componentFactory, useDependencies } from '../container/utils';
|
||||
import type { ServerWithId } from './data';
|
||||
@@ -19,29 +19,32 @@ type ManageServersRowDeps = {
|
||||
|
||||
const ManageServersRow: FCWithDeps<ManageServersRowProps, ManageServersRowDeps> = ({ server, hasAutoConnect }) => {
|
||||
const { ManageServersRowDropdown } = useDependencies(ManageServersRow);
|
||||
const { anchor, tooltip } = useTooltip();
|
||||
|
||||
return (
|
||||
<tr className="responsive-table__row">
|
||||
<Table.Row className="relative">
|
||||
{hasAutoConnect && (
|
||||
<td className="responsive-table__cell" data-th="Auto-connect">
|
||||
<Table.Cell columnName="Auto-connect">
|
||||
{server.autoConnect && (
|
||||
<>
|
||||
<FontAwesomeIcon icon={checkIcon} className="text-primary" id="autoConnectIcon" />
|
||||
<UncontrolledTooltip target="autoConnectIcon" placement="right">
|
||||
Auto-connect to this server
|
||||
</UncontrolledTooltip>
|
||||
<FontAwesomeIcon
|
||||
icon={checkIcon}
|
||||
className="text-lm-brand dark:text-dm-brand"
|
||||
{...anchor}
|
||||
/>
|
||||
<Tooltip {...tooltip}>Auto-connect to this server</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</Table.Cell>
|
||||
)}
|
||||
<th className="responsive-table__cell" data-th="Name">
|
||||
<Table.Cell className="font-bold" columnName="Name">
|
||||
<Link to={`/server/${server.id}`}>{server.name}</Link>
|
||||
</th>
|
||||
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td>
|
||||
<td className="responsive-table__cell text-end">
|
||||
</Table.Cell>
|
||||
<Table.Cell columnName="Base URL" className="max-lg:border-b-0">{server.url}</Table.Cell>
|
||||
<Table.Cell className="text-right max-lg:absolute right-0 -top-1 mx-lg:pt-0">
|
||||
<ManageServersRowDropdown server={server} />
|
||||
</td>
|
||||
</tr>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,10 +6,8 @@ import {
|
||||
faPlug as connectIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { RowDropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import { RowDropdown,useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import type { FCWithDeps } from '../container/utils';
|
||||
import { componentFactory, useDependencies } from '../container/utils';
|
||||
import type { ServerWithId } from './data';
|
||||
@@ -31,29 +29,31 @@ const ManageServersRowDropdown: FCWithDeps<ManageServersRowDropdownConnectProps,
|
||||
{ server, setAutoConnect },
|
||||
) => {
|
||||
const { DeleteServerModal } = useDependencies(ManageServersRowDropdown);
|
||||
const [isModalOpen,, showModal, hideModal] = useToggle();
|
||||
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle();
|
||||
const serverUrl = `/server/${server.id}`;
|
||||
const { autoConnect: isAutoConnect } = server;
|
||||
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;
|
||||
|
||||
return (
|
||||
<RowDropdownBtn minWidth={isAutoConnect ? 210 : 170}>
|
||||
<DropdownItem tag={Link} to={serverUrl}>
|
||||
<FontAwesomeIcon icon={connectIcon} fixedWidth /> Connect
|
||||
</DropdownItem>
|
||||
<DropdownItem tag={Link} to={`${serverUrl}/edit`}>
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit server
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => setAutoConnect(server, !isAutoConnect)}>
|
||||
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
|
||||
</DropdownItem>
|
||||
<DropdownItem divider tag="hr" />
|
||||
<DropdownItem className="dropdown-item--danger" onClick={showModal}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server
|
||||
</DropdownItem>
|
||||
<>
|
||||
<RowDropdown menuAlignment="right">
|
||||
<RowDropdown.Item to={serverUrl} className="gap-1.5">
|
||||
<FontAwesomeIcon icon={connectIcon} /> Connect
|
||||
</RowDropdown.Item>
|
||||
<RowDropdown.Item to={`${serverUrl}/edit`} className="gap-1.5">
|
||||
<FontAwesomeIcon icon={editIcon} /> Edit server
|
||||
</RowDropdown.Item>
|
||||
<RowDropdown.Item onClick={() => setAutoConnect(server, !isAutoConnect)} className="gap-1.5">
|
||||
<FontAwesomeIcon icon={autoConnectIcon} /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
|
||||
</RowDropdown.Item>
|
||||
<RowDropdown.Separator />
|
||||
<RowDropdown.Item className="[&]:text-danger gap-1.5" onClick={showModal}>
|
||||
<FontAwesomeIcon icon={deleteIcon} /> Remove server
|
||||
</RowDropdown.Item>
|
||||
</RowDropdown>
|
||||
|
||||
<DeleteServerModal redirectHome={false} server={server} isOpen={isModalOpen} toggle={hideModal} />
|
||||
</RowDropdownBtn>
|
||||
<DeleteServerModal server={server} open={isModalOpen} onClose={hideModal} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||
import { Dropdown, NavBar } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { SelectedServer, ServersMap } from './data';
|
||||
import { getServerId } from './data';
|
||||
|
||||
@@ -13,36 +12,29 @@ export interface ServersDropdownProps {
|
||||
export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
|
||||
const serversList = Object.values(servers);
|
||||
|
||||
const renderServers = () => {
|
||||
if (serversList.length === 0) {
|
||||
return (
|
||||
<DropdownItem tag={Link} to="/server/create">
|
||||
<FontAwesomeIcon icon={plusIcon} /> <span className="ms-1">Add a server</span>
|
||||
</DropdownItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{serversList.map(({ name, id }) => (
|
||||
<DropdownItem key={id} tag={Link} to={`/server/${id}`} active={getServerId(selectedServer) === id}>
|
||||
{name}
|
||||
</DropdownItem>
|
||||
))}
|
||||
<DropdownItem divider tag="hr" />
|
||||
<DropdownItem tag={Link} to="/manage-servers">
|
||||
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Manage servers</span>
|
||||
</DropdownItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<UncontrolledDropdown nav inNavbar>
|
||||
<DropdownToggle nav caret>
|
||||
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Servers</span>
|
||||
</DropdownToggle>
|
||||
<DropdownMenu end style={{ right: 0 }}>{renderServers()}</DropdownMenu>
|
||||
</UncontrolledDropdown>
|
||||
<NavBar.Dropdown buttonContent={(
|
||||
<span className="flex items-center gap-1.5">
|
||||
<FontAwesomeIcon icon={serverIcon} /> Servers
|
||||
</span>
|
||||
)}>
|
||||
{serversList.length === 0 ? (
|
||||
<Dropdown.Item to="/server/create">
|
||||
<FontAwesomeIcon icon={plusIcon} /> Add a server
|
||||
</Dropdown.Item>
|
||||
) : (
|
||||
<>
|
||||
{serversList.map(({ name, id }) => (
|
||||
<Dropdown.Item key={id} to={`/server/${id}`} selected={getServerId(selectedServer) === id}>
|
||||
{name}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
<Dropdown.Separator />
|
||||
<Dropdown.Item to="/manage-servers">
|
||||
<FontAwesomeIcon icon={serverIcon} /> Manage servers
|
||||
</Dropdown.Item>
|
||||
</>
|
||||
)}
|
||||
</NavBar.Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
@import '../utils/mixins/vertical-align';
|
||||
@import '../utils/mixins/thin-scroll';
|
||||
|
||||
.servers-list__list-group.servers-list__list-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.servers-list__list-group:not(.servers-list__list-group--embedded) {
|
||||
max-width: 400px;
|
||||
box-shadow: 0 .125rem .25rem rgb(0 0 0 / .075);
|
||||
}
|
||||
|
||||
.servers-list__server-item.servers-list__server-item {
|
||||
text-align: left;
|
||||
position: relative;
|
||||
padding: .75rem 2.5rem .75rem 1rem;
|
||||
}
|
||||
|
||||
.servers-list__server-item:not(:hover) {
|
||||
color: $mainColor;
|
||||
}
|
||||
|
||||
.servers-list__server-item:hover {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.servers-list__server-item-icon {
|
||||
@include vertical-align();
|
||||
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.servers-list__list-group--embedded.servers-list__list-group--embedded {
|
||||
border-radius: 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
max-height: 220px;
|
||||
overflow-x: auto;
|
||||
|
||||
@include thin-scroll();
|
||||
}
|
||||
|
||||
.servers-list__server-item {
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,43 @@
|
||||
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { clsx } from 'clsx';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ListGroup, ListGroupItem } from 'reactstrap';
|
||||
import type { FC } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import type { ServerWithId } from './data';
|
||||
import './ServersListGroup.scss';
|
||||
|
||||
type ServersListGroupProps = PropsWithChildren<{
|
||||
type ServersListGroupProps = {
|
||||
servers: ServerWithId[];
|
||||
embedded?: boolean;
|
||||
}>;
|
||||
borderless?: boolean;
|
||||
};
|
||||
|
||||
const ServerListItem = ({ id, name }: { id: string; name: string }) => (
|
||||
<ListGroupItem tag={Link} to={`/server/${id}`} className="servers-list__server-item">
|
||||
{name}
|
||||
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
|
||||
</ListGroupItem>
|
||||
<Link
|
||||
to={`/server/${id}`}
|
||||
className={clsx(
|
||||
'servers-list__server-item',
|
||||
'flex items-center justify-between gap-x-2 px-4 py-3',
|
||||
'rounded-none hover:bg-lm-secondary hover:dark:bg-dm-secondary',
|
||||
'border-b last:border-0 border-lm-border dark:border-dm-border',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{name}</span>
|
||||
<FontAwesomeIcon icon={chevronIcon} />
|
||||
</Link>
|
||||
);
|
||||
|
||||
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, children, embedded = false }) => (
|
||||
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderless }) => (
|
||||
<>
|
||||
{children && <div data-testid="title" className="mb-0 fs-5 fw-normal lh-sm">{children}</div>}
|
||||
{servers.length > 0 && (
|
||||
<ListGroup
|
||||
<div
|
||||
data-testid="list"
|
||||
tag="div"
|
||||
className={clsx('servers-list__list-group', { 'servers-list__list-group--embedded': embedded })}
|
||||
className={clsx(
|
||||
'w-full border-lm-border dark:border-dm-border',
|
||||
'md:max-h-56 md:overflow-y-auto -mb-1 scroll-thin',
|
||||
{ 'border-y': !borderless },
|
||||
)}
|
||||
>
|
||||
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
|
||||
</ListGroup>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface ServerData {
|
||||
name: string;
|
||||
url: string;
|
||||
apiKey: string;
|
||||
forwardCredentials?: boolean;
|
||||
}
|
||||
|
||||
export interface ServerWithId extends ServerData {
|
||||
@@ -44,4 +45,31 @@ export const isNotFoundServer = (server: SelectedServer): server is NotFoundServ
|
||||
|
||||
export const getServerId = (server: SelectedServer) => (isServerWithId(server) ? server.id : '');
|
||||
|
||||
export const serverWithIdToServerData = ({ name, url, apiKey }: ServerWithId): ServerData => ({ name, url, apiKey });
|
||||
/**
|
||||
* Expose values that represent provided server, in a way that can be serialized in JSON or CSV strings.
|
||||
*/
|
||||
export const serializeServer = ({ name, url, apiKey, forwardCredentials }: ServerData): Record<string, string> => ({
|
||||
name,
|
||||
url,
|
||||
apiKey,
|
||||
forwardCredentials: forwardCredentials ? 'true' : 'false',
|
||||
});
|
||||
|
||||
const validateServerData = (server: any): server is ServerData =>
|
||||
typeof server.url === 'string' && typeof server.apiKey === 'string' && typeof server.name === 'string';
|
||||
|
||||
/**
|
||||
* Provided a record, it picks the right properties to build a ServerData object.
|
||||
* @throws Error If any of the required ServerData properties is missing.
|
||||
*/
|
||||
export const deserializeServer = (potentialServer: Record<string, unknown>): ServerData => {
|
||||
const { forwardCredentials, ...serverData } = potentialServer;
|
||||
if (!validateServerData(serverData)) {
|
||||
throw new Error('Server is missing required "url", "apiKey" and/or "name" properties');
|
||||
}
|
||||
|
||||
return {
|
||||
...serverData,
|
||||
forwardCredentials: forwardCredentials === 'true',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,41 +1,42 @@
|
||||
import { CardModal } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { Fragment } from 'react';
|
||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import type { ServerData } from '../data';
|
||||
|
||||
interface DuplicatedServersModalProps {
|
||||
export type DuplicatedServersModalProps = {
|
||||
duplicatedServers: ServerData[];
|
||||
isOpen: boolean;
|
||||
onDiscard: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
|
||||
{ isOpen, duplicatedServers, onDiscard, onSave },
|
||||
{ open, duplicatedServers, onClose, onConfirm },
|
||||
) => {
|
||||
const hasMultipleServers = duplicatedServers.length > 1;
|
||||
|
||||
return (
|
||||
<Modal centered isOpen={isOpen}>
|
||||
<ModalHeader>Duplicated server{hasMultipleServers && 's'}</ModalHeader>
|
||||
<ModalBody>
|
||||
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
|
||||
<ul>
|
||||
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
|
||||
<Fragment key={index}>
|
||||
<li>URL: <b>{url}</b></li>
|
||||
<li>API key: <b>{apiKey}</b></li>
|
||||
</Fragment>
|
||||
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
|
||||
</ul>
|
||||
<span>
|
||||
{hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}?
|
||||
</span>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="link" onClick={onDiscard}>{hasMultipleServers ? 'Ignore duplicates' : 'Discard'}</Button>
|
||||
<Button color="primary" onClick={onSave}>Save anyway</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
<CardModal
|
||||
size="lg"
|
||||
title={`Duplicated server${hasMultipleServers ? 's' : ''}`}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
confirmText={`Save duplicate${hasMultipleServers ? 's' : ''}`}
|
||||
cancelText={hasMultipleServers ? 'Ignore duplicates' : 'Discard'}
|
||||
>
|
||||
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
|
||||
<ul className="list-disc mt-4">
|
||||
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
|
||||
<Fragment key={index}>
|
||||
<li>URL: <b>{url}</b></li>
|
||||
<li>API key: <b>{apiKey}</b></li>
|
||||
</Fragment>
|
||||
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
|
||||
</ul>
|
||||
<span>
|
||||
{hasMultipleServers ? 'Do you want to save duplicated servers' : 'Do you want to save this server'}?
|
||||
</span>
|
||||
</CardModal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useElementRef, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import { Button, Tooltip, useToggle , useTooltip } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { ChangeEvent, PropsWithChildren } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||
import type { FCWithDeps } from '../../container/utils';
|
||||
import { componentFactory, useDependencies } from '../../container/utils';
|
||||
import type { ServerData, ServersMap } from '../data';
|
||||
import type { ServerData, ServersMap, ServerWithId } from '../data';
|
||||
import type { ServersImporter } from '../services/ServersImporter';
|
||||
import { DuplicatedServersModal } from './DuplicatedServersModal';
|
||||
import { dedupServers, ensureUniqueIds } from './index';
|
||||
|
||||
export type ImportServersBtnProps = PropsWithChildren<{
|
||||
onImport?: () => void;
|
||||
onImportError?: (error: Error) => void;
|
||||
onError?: (error: Error) => void;
|
||||
tooltipPlacement?: 'top' | 'bottom';
|
||||
className?: string;
|
||||
}>;
|
||||
|
||||
type ImportServersBtnConnectProps = ImportServersBtnProps & {
|
||||
createServers: (servers: ServerData[]) => void;
|
||||
createServers: (servers: ServerWithId[]) => void;
|
||||
servers: ServersMap;
|
||||
};
|
||||
|
||||
@@ -26,78 +26,86 @@ type ImportServersBtnDeps = {
|
||||
ServersImporter: ServersImporter
|
||||
};
|
||||
|
||||
const serversInclude = (servers: ServerData[], { url, apiKey }: ServerData) =>
|
||||
servers.some((server) => server.url === url && server.apiKey === apiKey);
|
||||
|
||||
const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBtnDeps> = ({
|
||||
createServers,
|
||||
servers,
|
||||
children,
|
||||
onImport = () => {},
|
||||
onImportError = () => {},
|
||||
onImport,
|
||||
onError = () => {},
|
||||
tooltipPlacement = 'bottom',
|
||||
className = '',
|
||||
}) => {
|
||||
const { ServersImporter: serversImporter } = useDependencies(ImportServersBtn);
|
||||
const ref = useElementRef<HTMLInputElement>();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { anchor, tooltip } = useTooltip({ placement: tooltipPlacement });
|
||||
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
|
||||
const [isModalOpen,, showModal, hideModal] = useToggle();
|
||||
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle();
|
||||
const newServersCreatedRef = useRef(false);
|
||||
|
||||
const serversToCreate = useRef<ServerData[]>([]);
|
||||
const create = useCallback((serversData: ServerData[]) => {
|
||||
createServers(serversData);
|
||||
onImport();
|
||||
}, [createServers, onImport]);
|
||||
const onFile = useCallback(
|
||||
async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||
serversImporter.importServersFromFile(target.files?.[0])
|
||||
.then((newServers) => {
|
||||
serversToCreate.current = newServers;
|
||||
.then((importedServers) => {
|
||||
const { duplicatedServers, newServers } = dedupServers(servers, importedServers);
|
||||
|
||||
const existingServers = Object.values(servers);
|
||||
const dupServers = newServers.filter((server) => serversInclude(existingServers, server));
|
||||
const hasDuplicatedServers = !!dupServers.length;
|
||||
// Immediately create new servers
|
||||
newServersCreatedRef.current = newServers.length > 0;
|
||||
createServers(ensureUniqueIds(servers, newServers));
|
||||
|
||||
if (!hasDuplicatedServers) {
|
||||
create(newServers);
|
||||
} else {
|
||||
setDuplicatedServers(dupServers);
|
||||
// For duplicated servers, ask for confirmation
|
||||
if (duplicatedServers.length > 0) {
|
||||
setDuplicatedServers(duplicatedServers);
|
||||
showModal();
|
||||
} else {
|
||||
onImport?.();
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
// Reset input after processing file
|
||||
(target as { value: string | null }).value = null; // eslint-disable-line no-param-reassign
|
||||
// Reset file input after processing file
|
||||
(target as { value: string | null }).value = null;
|
||||
})
|
||||
.catch(onImportError),
|
||||
[create, onImportError, servers, serversImporter, showModal],
|
||||
.catch(onError),
|
||||
[createServers, onError, onImport, servers, serversImporter, showModal],
|
||||
);
|
||||
|
||||
const createAllServers = useCallback(() => {
|
||||
create(serversToCreate.current);
|
||||
const createDuplicatedServers = useCallback(() => {
|
||||
createServers(ensureUniqueIds(servers, duplicatedServers));
|
||||
hideModal();
|
||||
}, [create, hideModal, serversToCreate]);
|
||||
const createNonDuplicatedServers = useCallback(() => {
|
||||
create(serversToCreate.current.filter((server) => !serversInclude(duplicatedServers, server)));
|
||||
onImport?.();
|
||||
}, [createServers, duplicatedServers, hideModal, onImport, servers]);
|
||||
const discardDuplicatedServers = useCallback(() => {
|
||||
hideModal();
|
||||
}, [create, duplicatedServers, hideModal]);
|
||||
// If duplicated servers were discarded but some non-duplicated servers were created, call onImport
|
||||
if (newServersCreatedRef.current) {
|
||||
onImport?.();
|
||||
}
|
||||
}, [hideModal, onImport]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button outline id="importBtn" className={className} onClick={() => ref.current?.click()}>
|
||||
<FontAwesomeIcon icon={importIcon} fixedWidth /> {children ?? 'Import from file'}
|
||||
<Button variant="secondary" className={className} onClick={() => fileInputRef.current?.click()} {...anchor}>
|
||||
<FontAwesomeIcon icon={importIcon} widthAuto /> {children ?? 'Import from file'}
|
||||
</Button>
|
||||
<UncontrolledTooltip placement={tooltipPlacement} target="importBtn">
|
||||
<Tooltip {...tooltip}>
|
||||
You can create servers by importing a CSV file with <b>name</b>, <b>apiKey</b> and <b>url</b> columns.
|
||||
</UncontrolledTooltip>
|
||||
</Tooltip>
|
||||
|
||||
<input type="file" accept=".csv" className="d-none" ref={ref} onChange={onFile} aria-hidden />
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
ref={fileInputRef}
|
||||
onChange={onFile}
|
||||
data-testid="csv-file-input"
|
||||
/>
|
||||
|
||||
<DuplicatedServersModal
|
||||
isOpen={isModalOpen}
|
||||
open={isModalOpen}
|
||||
duplicatedServers={duplicatedServers}
|
||||
onDiscard={createNonDuplicatedServers}
|
||||
onSave={createAllServers}
|
||||
onClose={discardDuplicatedServers}
|
||||
onConfirm={createDuplicatedServers}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||
|
||||
.server-error__container {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.server-error__delete-btn {
|
||||
color: $dangerColor;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.server-error__delete-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Message } from '@shlinkio/shlink-frontend-kit';
|
||||
import { Card, Message } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router';
|
||||
import { NoMenuLayout } from '../../common/NoMenuLayout';
|
||||
import type { FCWithDeps } from '../../container/utils';
|
||||
import { componentFactory, useDependencies } from '../../container/utils';
|
||||
@@ -8,7 +8,6 @@ import type { SelectedServer, ServersMap } from '../data';
|
||||
import { isServerWithId } from '../data';
|
||||
import type { DeleteServerButtonProps } from '../DeleteServerButton';
|
||||
import { ServersListGroup } from '../ServersListGroup';
|
||||
import './ServerError.scss';
|
||||
|
||||
type ServerErrorProps = {
|
||||
servers: ServersMap;
|
||||
@@ -24,8 +23,8 @@ const ServerError: FCWithDeps<ServerErrorProps, ServerErrorDeps> = ({ servers, s
|
||||
|
||||
return (
|
||||
<NoMenuLayout>
|
||||
<div className="server-error__container flex-column">
|
||||
<Message className="w-100 mb-3 mb-md-5" type="error" fullWidth>
|
||||
<div className="flex flex-col items-center gap-y-4 md:gap-y-8">
|
||||
<Message className="w-full lg:w-[80%]" variant="error">
|
||||
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
|
||||
{isServerWithId(selectedServer) && (
|
||||
<>
|
||||
@@ -35,21 +34,21 @@ const ServerError: FCWithDeps<ServerErrorProps, ServerErrorDeps> = ({ servers, s
|
||||
)}
|
||||
</Message>
|
||||
|
||||
<ServersListGroup servers={Object.values(servers)}>
|
||||
<p className="mb-md-3">
|
||||
These are the Shlink servers currently configured. Choose one of
|
||||
them or <Link to="/server/create">add a new one</Link>.
|
||||
</p>
|
||||
</ServersListGroup>
|
||||
<p className="text-xl">
|
||||
These are the Shlink servers currently configured. Choose one of
|
||||
them or <Link to="/server/create">add a new one</Link>.
|
||||
</p>
|
||||
<Card className="w-full max-w-100 overflow-hidden">
|
||||
<ServersListGroup borderless servers={Object.values(servers)} />
|
||||
</Card>
|
||||
|
||||
{isServerWithId(selectedServer) && (
|
||||
<div className="container mt-3 mt-md-5">
|
||||
<p className="fs-5 fw-normal lh-sm">
|
||||
Alternatively, if you think you may have miss-configured this server, you
|
||||
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or
|
||||
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xl">
|
||||
Alternatively, if you think you may have misconfigured this server, you
|
||||
can <DeleteServerButton server={selectedServer}>remove
|
||||
it</DeleteServerButton> or
|
||||
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</NoMenuLayout>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { InputFormGroup, SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
||||
import {
|
||||
Checkbox,
|
||||
Details,
|
||||
Label,
|
||||
LabelledInput,
|
||||
LabelledRevealablePasswordInput,
|
||||
SimpleCard,
|
||||
useToggle,
|
||||
} from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC, PropsWithChildren, ReactNode } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||
import { useState } from 'react';
|
||||
import { usePreventDefault } from '../../utils/utils';
|
||||
import type { ServerData } from '../data';
|
||||
|
||||
type ServerFormProps = PropsWithChildren<{
|
||||
@@ -11,28 +19,45 @@ type ServerFormProps = PropsWithChildren<{
|
||||
}>;
|
||||
|
||||
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const handleSubmit = handleEventPreventingDefault(() => onSubmit({ name, url, apiKey }));
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValues) {
|
||||
setName(initialValues.name);
|
||||
setUrl(initialValues.url);
|
||||
setApiKey(initialValues.apiKey);
|
||||
}
|
||||
}, [initialValues]);
|
||||
const [name, setName] = useState(initialValues?.name ?? '');
|
||||
const [url, setUrl] = useState(initialValues?.url ?? '');
|
||||
const [apiKey, setApiKey] = useState(initialValues?.apiKey ?? '');
|
||||
const { flag: forwardCredentials, toggle: toggleForwardCredentials } = useToggle(
|
||||
initialValues?.forwardCredentials ?? false,
|
||||
);
|
||||
const handleSubmit = usePreventDefault(() => onSubmit({ name, url, apiKey, forwardCredentials }));
|
||||
|
||||
return (
|
||||
<form className="server-form" name="serverForm" onSubmit={handleSubmit}>
|
||||
<SimpleCard className="mb-3" title={title}>
|
||||
<InputFormGroup value={name} onChange={setName}>Name</InputFormGroup>
|
||||
<InputFormGroup type="url" value={url} onChange={setUrl}>URL</InputFormGroup>
|
||||
<InputFormGroup value={apiKey} onChange={setApiKey}>API key</InputFormGroup>
|
||||
<form name="serverForm" onSubmit={handleSubmit}>
|
||||
<SimpleCard className="mb-4" bodyClassName="flex flex-col gap-y-3" title={title}>
|
||||
<LabelledInput label="Name" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
<LabelledInput label="URL" type="url" value={url} onChange={(e) => setUrl(e.target.value)} required />
|
||||
<LabelledRevealablePasswordInput
|
||||
label="API key"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Details summary="Advanced options">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Label className="flex items-center gap-x-1.5 cursor-pointer">
|
||||
<Checkbox onChange={toggleForwardCredentials} checked={forwardCredentials} />
|
||||
Forward credentials to this server on every request.
|
||||
</Label>
|
||||
<small className="pl-5.5 text-gray-600 dark:text-gray-400 mt-0.5">
|
||||
{'"'}Credentials{'"'} here means cookies, TLS client certificates, or authentication headers containing a username
|
||||
and password.
|
||||
</small>
|
||||
<small className="pl-5.5 text-gray-600 dark:text-gray-400">
|
||||
<b>Important!</b> If you are not sure what this means, leave it unchecked. Enabling this option will
|
||||
make all requests fail for Shlink older than v4.5.0, as it requires the server to set a more strict
|
||||
value for <code className="whitespace-nowrap">Access-Control-Allow-Origin</code> than <code>*</code>.
|
||||
</small>
|
||||
</div>
|
||||
</Details>
|
||||
</SimpleCard>
|
||||
|
||||
<div className="text-end">{children}</div>
|
||||
<div className="flex items-center justify-end gap-x-2">{children}</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
85
src/servers/helpers/index.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { groupBy } from '@shlinkio/data-manipulation';
|
||||
import type { ServerData, ServersMap, ServerWithId } from '../data';
|
||||
|
||||
/**
|
||||
* Builds a potentially unique ID for a server, based on concatenating their name and the hostname of their domain, all
|
||||
* in lowercase and replacing invalid URL characters with hyphens.
|
||||
*/
|
||||
function idForServer(server: ServerData): string {
|
||||
let urlSegment = server.url;
|
||||
try {
|
||||
const { host, pathname } = new URL(urlSegment);
|
||||
urlSegment = host;
|
||||
|
||||
// Remove leading slash from pathname
|
||||
const normalizedPathname = pathname.substring(1);
|
||||
|
||||
// Include pathname in the ID, if not empty
|
||||
if (normalizedPathname.length > 0) {
|
||||
urlSegment = `${urlSegment} ${normalizedPathname}`;
|
||||
}
|
||||
} catch {
|
||||
// If the server URL is not valid, use the value as is
|
||||
}
|
||||
|
||||
return `${server.name} ${urlSegment}`.toLowerCase().replace(/[^a-zA-Z0-9-_.~]/g, '-');
|
||||
}
|
||||
|
||||
export function serversListToMap(servers: ServerWithId[]): ServersMap {
|
||||
const serversMap: ServersMap = {};
|
||||
servers.forEach((server) => {
|
||||
serversMap[server.id] = server;
|
||||
});
|
||||
|
||||
return serversMap;
|
||||
}
|
||||
|
||||
const serversInclude = (serversList: ServerData[], { url, apiKey }: ServerData) =>
|
||||
serversList.some((server) => server.url === url && server.apiKey === apiKey);
|
||||
|
||||
export type DedupServersResult = {
|
||||
/** Servers which already exist in the reference list */
|
||||
duplicatedServers: ServerData[];
|
||||
/** Servers which are new based on a reference list */
|
||||
newServers: ServerData[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a list of new servers, checks which of them already exist in a servers map, and which don't
|
||||
*/
|
||||
export function dedupServers(servers: ServersMap, serversToAdd: ServerData[]): DedupServersResult {
|
||||
const serversList = Object.values(servers);
|
||||
const { duplicatedServers = [], newServers = [] } = groupBy(
|
||||
serversToAdd,
|
||||
(server) => serversInclude(serversList, server) ? 'duplicatedServers' : 'newServers',
|
||||
);
|
||||
|
||||
return { duplicatedServers, newServers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a servers map and a list of servers, return the same list of servers but all with an ID, ensuring the ID is
|
||||
* unique both among all those servers and existing ones
|
||||
*/
|
||||
export function ensureUniqueIds(existingServers: ServersMap, serversList: ServerData[]): ServerWithId[] {
|
||||
const existingIds = new Set(Object.keys(existingServers));
|
||||
const serversWithId: ServerWithId[] = [];
|
||||
|
||||
serversList.forEach((server) => {
|
||||
const baseId = idForServer(server);
|
||||
|
||||
let id = baseId;
|
||||
let iterations = 1;
|
||||
while (existingIds.has(id)) {
|
||||
id = `${baseId}-${iterations}`;
|
||||
iterations++;
|
||||
}
|
||||
|
||||
serversWithId.push({ ...server, id });
|
||||
|
||||
// Add this server's ID to the list, so that it is taken into consideration for the next ones
|
||||
existingIds.add(id);
|
||||
});
|
||||
|
||||
return serversWithId;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Message } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useParams } from 'react-router';
|
||||
import { NoMenuLayout } from '../../common/NoMenuLayout';
|
||||
import type { FCWithDeps } from '../../container/utils';
|
||||
import { useDependencies } from '../../container/utils';
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { HttpClient } from '@shlinkio/shlink-js-sdk';
|
||||
import pack from '../../../package.json';
|
||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||
import type { ServerData } from '../data';
|
||||
import { hasServerData } from '../data';
|
||||
import { ensureUniqueIds } from '../helpers';
|
||||
import { createServers } from './servers';
|
||||
|
||||
const responseToServersList = (data: any): ServerData[] => (Array.isArray(data) ? data.filter(hasServerData) : []);
|
||||
const responseToServersList = (data: any) => ensureUniqueIds(
|
||||
{},
|
||||
(Array.isArray(data) ? data.filter(hasServerData) : []),
|
||||
);
|
||||
|
||||
export const fetchServers = (httpClient: HttpClient) => createAsyncThunk(
|
||||
'shlink/remoteServers/fetchServers',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { randomUUID } from '../../utils/utils';
|
||||
import type { ServerData, ServersMap, ServerWithId } from '../data';
|
||||
import { serversListToMap } from '../helpers';
|
||||
|
||||
interface EditServer {
|
||||
serverId: string;
|
||||
@@ -15,19 +15,6 @@ interface SetAutoConnect {
|
||||
|
||||
const initialState: ServersMap = {};
|
||||
|
||||
const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
||||
if ('id' in server) {
|
||||
return server;
|
||||
}
|
||||
|
||||
return { ...server, id: randomUUID() };
|
||||
};
|
||||
|
||||
const serversListToMap = (servers: ServerWithId[]): ServersMap => servers.reduce<ServersMap>(
|
||||
(acc, server) => ({ ...acc, [server.id]: server }),
|
||||
{},
|
||||
);
|
||||
|
||||
export const { actions, reducer } = createSlice({
|
||||
name: 'shlink/servers',
|
||||
initialState,
|
||||
@@ -70,10 +57,7 @@ export const { actions, reducer } = createSlice({
|
||||
},
|
||||
},
|
||||
createServers: {
|
||||
prepare: (servers: ServerData[]) => {
|
||||
const payload = serversListToMap(servers.map(serverWithId));
|
||||
return { payload };
|
||||
},
|
||||
prepare: (servers: ServerWithId[]) => ({ payload: serversListToMap(servers) }),
|
||||
reducer: (state, { payload: newServers }: PayloadAction<ServersMap>) => ({ ...state, ...newServers }),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,27 +2,30 @@ import type { JsonToCsv } from '../../utils/helpers/csvjson';
|
||||
import { saveCsv } from '../../utils/helpers/files';
|
||||
import type { LocalStorage } from '../../utils/services/LocalStorage';
|
||||
import type { ServersMap } from '../data';
|
||||
import { serverWithIdToServerData } from '../data';
|
||||
import { serializeServer } from '../data';
|
||||
|
||||
const SERVERS_FILENAME = 'shlink-servers.csv';
|
||||
|
||||
export class ServersExporter {
|
||||
public constructor(
|
||||
private readonly storage: LocalStorage,
|
||||
private readonly window: Window,
|
||||
private readonly jsonToCsv: JsonToCsv,
|
||||
) {}
|
||||
readonly #storage: LocalStorage;
|
||||
readonly #window: Window;
|
||||
readonly #jsonToCsv: JsonToCsv;
|
||||
|
||||
public constructor(storage: LocalStorage, window: Window, jsonToCsv: JsonToCsv) {
|
||||
this.#storage = storage;
|
||||
this.#window = window;
|
||||
this.#jsonToCsv = jsonToCsv;
|
||||
}
|
||||
|
||||
public readonly exportServers = async () => {
|
||||
const servers = Object.values(this.storage.get<ServersMap>('servers') ?? {}).map(serverWithIdToServerData);
|
||||
const servers = Object.values(this.#storage.get<ServersMap>('servers') ?? {}).map(serializeServer);
|
||||
|
||||
try {
|
||||
const csv = this.jsonToCsv(servers);
|
||||
|
||||
saveCsv(this.window, csv, SERVERS_FILENAME);
|
||||
const csv = this.#jsonToCsv(servers);
|
||||
saveCsv(this.#window, csv, SERVERS_FILENAME);
|
||||
} catch (e) {
|
||||
// FIXME Handle error
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||