mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-02 22:01:52 +00:00
Compare commits
890 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ed19af295 | ||
|
|
46f7a67b14 | ||
|
|
6f2639fd1f | ||
|
|
f1568eb43b | ||
|
|
aefc632ed7 | ||
|
|
bd3555db94 | ||
|
|
ed366fa4cc | ||
|
|
30aeba0af2 | ||
|
|
b4c3bd16b1 | ||
|
|
4b97abaf72 | ||
|
|
e387706a7b | ||
|
|
a7cc8786c3 | ||
|
|
5c0573deb7 | ||
|
|
bc8956db4a | ||
|
|
d44be88b3f | ||
|
|
64ee9a39cc | ||
|
|
4999f982e4 | ||
|
|
f3f0dbac19 | ||
|
|
c2818645c4 | ||
|
|
b0a4aee175 | ||
|
|
67c45f444b | ||
|
|
f3570d0c9d | ||
|
|
653be14e77 | ||
|
|
71c8b596e7 | ||
|
|
ba45b6a7a6 | ||
|
|
cd311aeaa5 | ||
|
|
4f7a6d5c96 | ||
|
|
a4fb439dd4 | ||
|
|
3870752bba | ||
|
|
7f059c3f3b | ||
|
|
686fe5abbe | ||
|
|
1f47658c3c | ||
|
|
d582a0f9e5 | ||
|
|
b1d51a4103 | ||
|
|
fb0adf74f3 | ||
|
|
0b16300a70 | ||
|
|
43302ef5a8 | ||
|
|
3846ca293c | ||
|
|
00f154ef4e | ||
|
|
4ea826ed2c | ||
|
|
d327142d00 | ||
|
|
37adcb52cf | ||
|
|
fcbb9cda12 | ||
|
|
116c36febc | ||
|
|
43a409fb30 | ||
|
|
63f26d0089 | ||
|
|
218ea09d23 | ||
|
|
a322886710 | ||
|
|
29182ae349 | ||
|
|
bc3bc8dd8a | ||
|
|
e128b847be | ||
|
|
f72251c125 | ||
|
|
9784cbb3ac | ||
|
|
e6f9003fb6 | ||
|
|
e8a5eadd2b | ||
|
|
337bfc47c1 | ||
|
|
677f1da8df | ||
|
|
8918b1ac96 | ||
|
|
0de8eb1568 | ||
|
|
c00aaa9018 | ||
|
|
e837ee5225 | ||
|
|
fc589a0b29 | ||
|
|
b0769d1cf8 | ||
|
|
5befd67270 | ||
|
|
59bdeb67bc | ||
|
|
2d1de2cac8 | ||
|
|
d3f180f270 | ||
|
|
5482766d03 | ||
|
|
dfbe43ef02 | ||
|
|
ed7bb20bbb | ||
|
|
57a2a03469 | ||
|
|
df9c1a88fc | ||
|
|
c8b78d04e2 | ||
|
|
c516969686 | ||
|
|
fd4295ade8 | ||
|
|
39d4f8c73d | ||
|
|
d9b55f1d95 | ||
|
|
66d8a32f49 | ||
|
|
8b091a7b23 | ||
|
|
8edb3dc923 | ||
|
|
a93d1e821d | ||
|
|
9c5cad7571 | ||
|
|
b970b38c29 | ||
|
|
763ef207f1 | ||
|
|
a949ec9e8e | ||
|
|
9518ad9bb4 | ||
|
|
5a0d67e409 | ||
|
|
e6932e7353 | ||
|
|
ddb08f4d2e | ||
|
|
368f7acd2d | ||
|
|
6714f90c37 | ||
|
|
bbaa4d0f05 | ||
|
|
3aa990a1b0 | ||
|
|
464ee11d0d | ||
|
|
f909d38130 | ||
|
|
77e59c886d | ||
|
|
05254326cb | ||
|
|
932dec3bde | ||
|
|
e976a0c716 | ||
|
|
71b68562db | ||
|
|
460d2d23ce | ||
|
|
6aee08b866 | ||
|
|
271b19a4ec | ||
|
|
d5b1dc5bff | ||
|
|
965e69c525 | ||
|
|
bd549c8642 | ||
|
|
66edaed3a0 | ||
|
|
79831322fd | ||
|
|
ed1c5a2197 | ||
|
|
6d32379b67 | ||
|
|
56a62ae505 | ||
|
|
50072f5997 | ||
|
|
e875e05538 | ||
|
|
e2c8551baf | ||
|
|
1b0a2811e0 | ||
|
|
9cac4d23a7 | ||
|
|
b323ddbd33 | ||
|
|
98450ebec3 | ||
|
|
28a5166f56 | ||
|
|
4f128b3fe8 | ||
|
|
fd5060b996 | ||
|
|
a2df486280 | ||
|
|
4e9b19afd1 | ||
|
|
4d78949b8d | ||
|
|
13bafdc924 | ||
|
|
ea95e8e7b5 | ||
|
|
eaa1a2f2ca | ||
|
|
9d6121903e | ||
|
|
2795bf050e | ||
|
|
0e4e430673 | ||
|
|
3e58d861ec | ||
|
|
2d8c2f92c4 | ||
|
|
56fa114f3c | ||
|
|
0a57390c46 | ||
|
|
ea7345b872 | ||
|
|
e44520b2c2 | ||
|
|
92ddcad753 | ||
|
|
e632c5b04f | ||
|
|
47d30aaa34 | ||
|
|
a26019ca78 | ||
|
|
ef8db5e2cd | ||
|
|
18f952f4fc | ||
|
|
389f4efa4d | ||
|
|
d1e6b052d9 | ||
|
|
7fd360495b | ||
|
|
187e26810d | ||
|
|
8a1edfe7cf | ||
|
|
81d405d7be | ||
|
|
c4148f0494 | ||
|
|
a8f996bec7 | ||
|
|
faa81ea1a5 | ||
|
|
ec360d3a28 | ||
|
|
749074604f | ||
|
|
c60a6a78c8 | ||
|
|
f15b803851 | ||
|
|
c949359d6f | ||
|
|
73d4707420 | ||
|
|
4f731d9de8 | ||
|
|
2b400beb31 | ||
|
|
a3616b56f5 | ||
|
|
65a162bdd2 | ||
|
|
0e7c2f00d1 | ||
|
|
2b59d02ed9 | ||
|
|
45c6d3996e | ||
|
|
bb7545824a | ||
|
|
feb2154257 | ||
|
|
8551fcf08f | ||
|
|
61b094ee7d | ||
|
|
42714066bf | ||
|
|
94350683bd | ||
|
|
3d7950bb51 | ||
|
|
ec4b777429 | ||
|
|
61b61bce1c | ||
|
|
dcfb5ab054 | ||
|
|
6346f82a0a | ||
|
|
31f1d5b530 | ||
|
|
fc71c0f5c8 | ||
|
|
7ab368a424 | ||
|
|
1cee36ec9f | ||
|
|
74635281de | ||
|
|
0f43ad59a0 | ||
|
|
b97ea17950 | ||
|
|
3f48ca401d | ||
|
|
3ecad0161b | ||
|
|
9ff331e2db | ||
|
|
27e3b6f0d0 | ||
|
|
6a739b7a25 | ||
|
|
56313e5db8 | ||
|
|
d8e4a4b891 | ||
|
|
dee1932a64 | ||
|
|
661b9b2cc1 | ||
|
|
f24fb61e20 | ||
|
|
0993b43c79 | ||
|
|
ec403d7b1f | ||
|
|
f4fa1582a7 | ||
|
|
e5a84b1505 | ||
|
|
ce871fe2a2 | ||
|
|
5a713fe92f | ||
|
|
819df9cf3d | ||
|
|
a67e0b052f | ||
|
|
c088259e46 | ||
|
|
82f8636af5 | ||
|
|
f0ad4dad9f | ||
|
|
acf19823b0 | ||
|
|
c02fba8d82 | ||
|
|
a4f36f8620 | ||
|
|
987c27a221 | ||
|
|
248f887fb3 | ||
|
|
8fd07070b8 | ||
|
|
45c918f4ee | ||
|
|
4f267a0275 | ||
|
|
ad1caaf5dd | ||
|
|
1e0528fca0 | ||
|
|
b30df582f2 | ||
|
|
f0b42cdc09 | ||
|
|
308660287e | ||
|
|
c80a8e9601 | ||
|
|
059d17f8d6 | ||
|
|
de027eccad | ||
|
|
643494a54b | ||
|
|
71a010d5d7 | ||
|
|
b419586504 | ||
|
|
78a519c649 | ||
|
|
23ee3d18a6 | ||
|
|
a6b2f1b385 | ||
|
|
30a71ac8b7 | ||
|
|
ae9e5a0566 | ||
|
|
f24c8052a9 | ||
|
|
b0fa14fcfe | ||
|
|
338c2a1191 | ||
|
|
405a150a2b | ||
|
|
3c402f8787 | ||
|
|
7d10efc286 | ||
|
|
cf5205e976 | ||
|
|
eab072831d | ||
|
|
c4e928ff09 | ||
|
|
97024d828e | ||
|
|
c6e500ba71 | ||
|
|
eb39d97cc5 | ||
|
|
071eaddfd1 | ||
|
|
0eec9b185f | ||
|
|
5edb62e76b | ||
|
|
9bc5a050eb | ||
|
|
4a80f224d8 | ||
|
|
0608d3cf19 | ||
|
|
8fbe6bb17d | ||
|
|
60929342fb | ||
|
|
e0d43020dc | ||
|
|
2de0276195 | ||
|
|
1011b062ae | ||
|
|
c8b530cc1a | ||
|
|
6e72c343ab | ||
|
|
1c37186461 | ||
|
|
34a59db4cf | ||
|
|
12f61d03be | ||
|
|
aca9218f9d | ||
|
|
b727a704a6 | ||
|
|
1e03eed6c0 | ||
|
|
e9fcdcb049 | ||
|
|
5b7f1ef18a | ||
|
|
715128a653 | ||
|
|
83fbdbb135 | ||
|
|
2e963bdc8e | ||
|
|
8d6e93ea4f | ||
|
|
112a8cdf2f | ||
|
|
27476d8b23 | ||
|
|
2ad2d69b2b | ||
|
|
a3d6944fc1 | ||
|
|
552169ee77 | ||
|
|
4f03ab18e5 | ||
|
|
184d5d97e7 | ||
|
|
ba667a0768 | ||
|
|
15b3424d7f | ||
|
|
98398a048b | ||
|
|
3cb066f5f5 | ||
|
|
053b38bee3 | ||
|
|
1f9356cc21 | ||
|
|
f07e7fd31c | ||
|
|
7794876d7c | ||
|
|
e77b4d7a82 | ||
|
|
af0d2d3cdc | ||
|
|
7e132be686 | ||
|
|
aba1972d0d | ||
|
|
0268bb6930 | ||
|
|
ecd6e6a066 | ||
|
|
6411c6169b | ||
|
|
a78467065a | ||
|
|
c05c74f009 | ||
|
|
ace29ca4a4 | ||
|
|
4f90d147a4 | ||
|
|
9348f211f0 | ||
|
|
729d9e4a39 | ||
|
|
3274088b54 | ||
|
|
49c841ca07 | ||
|
|
91f319df65 | ||
|
|
dbf4b0926e | ||
|
|
994f31b7e5 | ||
|
|
6213067f35 | ||
|
|
76fb45c97e | ||
|
|
2bf5f276f5 | ||
|
|
eaadd6f7af | ||
|
|
86c6acb7b8 | ||
|
|
de32d899bc | ||
|
|
d4356ba6e6 | ||
|
|
275aee4de2 | ||
|
|
57075c581d | ||
|
|
d8442e435d | ||
|
|
e954a860bf | ||
|
|
5598fe0f53 | ||
|
|
e77508edcc | ||
|
|
c517c0521c | ||
|
|
e22856ff74 | ||
|
|
a30687e4ea | ||
|
|
64ba346566 | ||
|
|
3745b297db | ||
|
|
401418c049 | ||
|
|
7adb40489d | ||
|
|
482314b9f4 | ||
|
|
138e40315d | ||
|
|
7d6afd47b1 | ||
|
|
ed1f650fc6 | ||
|
|
17e4e06fcc | ||
|
|
654b36ab08 | ||
|
|
9abbfc5b1e | ||
|
|
c9d906316f | ||
|
|
8d476e0729 | ||
|
|
7a320c9574 | ||
|
|
3f1392ce62 | ||
|
|
79e54ea230 | ||
|
|
e2473207ba | ||
|
|
fb961dd47b | ||
|
|
ff1821666e | ||
|
|
9a62bcd8fb | ||
|
|
9c6c1b43c8 | ||
|
|
4986dbcb91 | ||
|
|
527d4acf17 | ||
|
|
0237253caf | ||
|
|
47f5f47867 | ||
|
|
70d4572797 | ||
|
|
8bfa14386b | ||
|
|
9f6401c30b | ||
|
|
14b2ee53b5 | ||
|
|
7db9974e8d | ||
|
|
7d29129ca1 | ||
|
|
42152c6872 | ||
|
|
b7e9afd54a | ||
|
|
3bc9bd2ef8 | ||
|
|
7bc3819ebe | ||
|
|
0642443aa9 | ||
|
|
2e77cd1969 | ||
|
|
21b8e05e35 | ||
|
|
ed038b9799 | ||
|
|
5f33059de1 | ||
|
|
3bc5b4c154 | ||
|
|
a2421ee2d3 | ||
|
|
109baef828 | ||
|
|
303900756d | ||
|
|
fe81e023e8 | ||
|
|
5906921eec | ||
|
|
ee826458be | ||
|
|
7169c6e083 | ||
|
|
0bb5c7d8af | ||
|
|
a6892b8a12 | ||
|
|
765c4713a2 | ||
|
|
e6737ff1f2 | ||
|
|
7a2d0e5dee | ||
|
|
daf076a57e | ||
|
|
af08b53002 | ||
|
|
39d5853fe3 | ||
|
|
9cbeef1cb4 | ||
|
|
2857e59273 | ||
|
|
04571ea634 | ||
|
|
5241925acc | ||
|
|
844cf51d04 | ||
|
|
b0c1549005 | ||
|
|
16d2e437b6 | ||
|
|
944b166e43 | ||
|
|
e5f99d0893 | ||
|
|
57e73dcba6 | ||
|
|
80f0f9bd08 | ||
|
|
1486d1fba5 | ||
|
|
e28f74169d | ||
|
|
2375882c73 | ||
|
|
7b344998ea | ||
|
|
e8ea3b4abe | ||
|
|
bd0fca23cf | ||
|
|
6d392ba403 | ||
|
|
e135dd92ec | ||
|
|
36af3c3dd0 | ||
|
|
c0e33d6a6a | ||
|
|
41398f659e | ||
|
|
8618519b6b | ||
|
|
c7c32b494e | ||
|
|
ec9fd67b8a | ||
|
|
7637ce3107 | ||
|
|
ada5488a6c | ||
|
|
478209f50d | ||
|
|
7f4263966e | ||
|
|
002f280364 | ||
|
|
d8a6676d30 | ||
|
|
beff6668de | ||
|
|
4baa901f1c | ||
|
|
f19746cd58 | ||
|
|
85161915b1 | ||
|
|
29bf53bf88 | ||
|
|
d2284cd181 | ||
|
|
88305a57bf | ||
|
|
f4908cacc3 | ||
|
|
2925752fde | ||
|
|
1bf3569774 | ||
|
|
9e6907deb4 | ||
|
|
eaa6efe803 | ||
|
|
d38020e2d1 | ||
|
|
4c1d285d04 | ||
|
|
c71e0919e9 | ||
|
|
a295734c13 | ||
|
|
d00b6165b3 | ||
|
|
0cbba1182f | ||
|
|
785806b7a1 | ||
|
|
15b7fd5c93 | ||
|
|
9b32bd2817 | ||
|
|
8b5b035568 | ||
|
|
f7cc90bb77 | ||
|
|
7b0cda7191 | ||
|
|
9791486341 | ||
|
|
40ef51a348 | ||
|
|
a90287ed02 | ||
|
|
12f6a132bd | ||
|
|
1da7119c5c | ||
|
|
01f6f11ee2 | ||
|
|
57d4db5daa | ||
|
|
c7559e78a2 | ||
|
|
2f76c5381f | ||
|
|
304a7431ad | ||
|
|
691dabcfbc | ||
|
|
2dd35dcd44 | ||
|
|
44930b8c5f | ||
|
|
310913b222 | ||
|
|
b877aa8e5b | ||
|
|
27e3d65143 | ||
|
|
b462169e1e | ||
|
|
dc2f30c73b | ||
|
|
8df1ba4671 | ||
|
|
56a3dbd07f | ||
|
|
856ee6d65c | ||
|
|
9518a5e442 | ||
|
|
3a8c7a7bf4 | ||
|
|
7fb0658349 | ||
|
|
6d79851d18 | ||
|
|
f89e4244ea | ||
|
|
3c23016028 | ||
|
|
27c4bd792b | ||
|
|
1b158b3df4 | ||
|
|
19f0dc2920 | ||
|
|
a15917b1ae | ||
|
|
7e5397dd38 | ||
|
|
382d7b1c9f | ||
|
|
58ee123cef | ||
|
|
039a56f410 | ||
|
|
6780aa623b | ||
|
|
7752140c9d | ||
|
|
8bfd38d861 | ||
|
|
27b6676edc | ||
|
|
66c91722fc | ||
|
|
178f15b7d3 | ||
|
|
0e47f9b502 | ||
|
|
d2ad1cd54b | ||
|
|
91e003153b | ||
|
|
c6cca9c91f | ||
|
|
7330fd85ff | ||
|
|
b61d863356 | ||
|
|
f54460e8f8 | ||
|
|
036c8aafcb | ||
|
|
d55160e8f6 | ||
|
|
0572bc2854 | ||
|
|
aceb2350cf | ||
|
|
923575b38b | ||
|
|
f41a8473f8 | ||
|
|
b94cdb2680 | ||
|
|
0cdae72ebd | ||
|
|
75931edc33 | ||
|
|
d1fcd10c04 | ||
|
|
06f4cff97e | ||
|
|
0804322a9f | ||
|
|
53ba14e6f6 | ||
|
|
ead5f2033b | ||
|
|
74ac122787 | ||
|
|
13785c7beb | ||
|
|
9887cae4fd | ||
|
|
410d372755 | ||
|
|
e7a969a78d | ||
|
|
b1d6f58619 | ||
|
|
f49b74229c | ||
|
|
d88f822125 | ||
|
|
dce1cefd49 | ||
|
|
8e71b2e2b1 | ||
|
|
69cb3bd619 | ||
|
|
bf29158a8a | ||
|
|
a28a4846bc | ||
|
|
5eee86003d | ||
|
|
37a3a2022b | ||
|
|
c6be8bd96f | ||
|
|
5166340779 | ||
|
|
520e52595f | ||
|
|
461c0e0bc9 | ||
|
|
0ecb771b23 | ||
|
|
c89e2b5d25 | ||
|
|
aa8f2a0cbc | ||
|
|
eb90aa2274 | ||
|
|
2b5420a429 | ||
|
|
3484e74559 | ||
|
|
edd536cc1e | ||
|
|
322396a366 | ||
|
|
9f02bc6496 | ||
|
|
590393dcfd | ||
|
|
8029823271 | ||
|
|
4417a17d5c | ||
|
|
b8a7dccf92 | ||
|
|
cbe5f98aa3 | ||
|
|
6c2f5b99ac | ||
|
|
fa64c950ca | ||
|
|
0e4667e59c | ||
|
|
56d9dcf562 | ||
|
|
d5e8f81076 | ||
|
|
69905c4b38 | ||
|
|
08694d7693 | ||
|
|
8045fa8886 | ||
|
|
0789494a40 | ||
|
|
34837f2917 | ||
|
|
9e8c743d53 | ||
|
|
239cc4ab84 | ||
|
|
b3e79f4219 | ||
|
|
7c11a6d1ab | ||
|
|
635ee6c5eb | ||
|
|
f79bd39de7 | ||
|
|
5c6979122d | ||
|
|
402efac12e | ||
|
|
770ba624c2 | ||
|
|
d4236b914d | ||
|
|
2cc92b5b41 | ||
|
|
f0598ba47f | ||
|
|
66c5c7ebf1 | ||
|
|
741bc21a55 | ||
|
|
fb1ced5e3f | ||
|
|
3999d14bab | ||
|
|
99c77622cd | ||
|
|
bc5c25deb0 | ||
|
|
0275908f69 | ||
|
|
4be1a295d8 | ||
|
|
ee65c0c050 | ||
|
|
d718329b52 | ||
|
|
55716a8f7f | ||
|
|
5ef719c592 | ||
|
|
3a57416525 | ||
|
|
5bd57e71fd | ||
|
|
c4ed838510 | ||
|
|
affe2309b0 | ||
|
|
638ce89780 | ||
|
|
a0ab9533cb | ||
|
|
7b80948eea | ||
|
|
1cf96c7212 | ||
|
|
151175dc70 | ||
|
|
a30376344e | ||
|
|
db0c43dcdd | ||
|
|
a3550f8e52 | ||
|
|
3a3babadeb | ||
|
|
e22ad2c822 | ||
|
|
342dda3ec9 | ||
|
|
b7af07c043 | ||
|
|
6b338275d3 | ||
|
|
a72d3b2720 | ||
|
|
18042dba6e | ||
|
|
6e09d1372f | ||
|
|
ce02d29ca3 | ||
|
|
e193c700d6 | ||
|
|
bfeb282aa9 | ||
|
|
5caa648112 | ||
|
|
4546b74b6f | ||
|
|
2fb5507803 | ||
|
|
93329c5a12 | ||
|
|
5a91b668dc | ||
|
|
66aac4771c | ||
|
|
ce04b8eb58 | ||
|
|
e0c20c704e | ||
|
|
d5fadc56af | ||
|
|
bbc3342c00 | ||
|
|
76ebbd318a | ||
|
|
24801b068b | ||
|
|
4c21ad0a89 | ||
|
|
f626f9b046 | ||
|
|
ccffa0fe12 | ||
|
|
d5530b4614 | ||
|
|
7c327099bb | ||
|
|
577d7e79da | ||
|
|
31736fad1e | ||
|
|
6319a81ddb | ||
|
|
0ca6ff6906 | ||
|
|
eb69165781 | ||
|
|
4e3d311bef | ||
|
|
54b7aeed20 | ||
|
|
2ba8db1fd3 | ||
|
|
f74270a767 | ||
|
|
9a245fbf13 | ||
|
|
f16e9565e2 | ||
|
|
e65f9a7b89 | ||
|
|
0141a1e0ed | ||
|
|
937876ce67 | ||
|
|
b52120e0d3 | ||
|
|
62b65334b5 | ||
|
|
76dae535d9 | ||
|
|
23ba140ff4 | ||
|
|
76ff7d81b9 | ||
|
|
66deba29f5 | ||
|
|
e44527e9c9 | ||
|
|
aec629b95c | ||
|
|
fa4664e583 | ||
|
|
2952ac8892 | ||
|
|
cf4fc4fa30 | ||
|
|
2d61748aac | ||
|
|
7f61825768 | ||
|
|
c3d6c83ec4 | ||
|
|
c3e38fd580 | ||
|
|
db778a73f7 | ||
|
|
f0a04ced75 | ||
|
|
d6bb718672 | ||
|
|
6d887ec4a8 | ||
|
|
859cd9e5e3 | ||
|
|
eabd7d9ecb | ||
|
|
205e3ffb90 | ||
|
|
8c7a91c7b8 | ||
|
|
56aab349db | ||
|
|
6628a4059e | ||
|
|
10c9f7dabd | ||
|
|
d703e5e182 | ||
|
|
3ad0c4d009 | ||
|
|
1403538660 | ||
|
|
ca670d810d | ||
|
|
d5e20f445d | ||
|
|
eea76d88c3 | ||
|
|
a019bd30df | ||
|
|
631b46393b | ||
|
|
98aa85ca14 | ||
|
|
ea01d22369 | ||
|
|
ff1d2f63c8 | ||
|
|
71468379bd | ||
|
|
843f646264 | ||
|
|
508623f89f | ||
|
|
482489599e | ||
|
|
03f63e3ee3 | ||
|
|
3f3523b80f | ||
|
|
1594717f33 | ||
|
|
ed92b9c949 | ||
|
|
e76b22b2ae | ||
|
|
e380ddb40f | ||
|
|
426d000a59 | ||
|
|
fee62484b5 | ||
|
|
d3f9650e82 | ||
|
|
ad46927750 | ||
|
|
bd79230007 | ||
|
|
5224e7b4ef | ||
|
|
70ce099913 | ||
|
|
b4c2fb5b8f | ||
|
|
6fbf65c873 | ||
|
|
13d3a95a06 | ||
|
|
56b3523c5b | ||
|
|
8a69adfbc9 | ||
|
|
87a32b412f | ||
|
|
df87ad5867 | ||
|
|
f15bbcd027 | ||
|
|
3c9c0fe994 | ||
|
|
a665e96908 | ||
|
|
fddba80b08 | ||
|
|
caa3a09827 | ||
|
|
fa70520f38 | ||
|
|
b789f64a54 | ||
|
|
ce0fc1094e | ||
|
|
ad0a889548 | ||
|
|
1fe76500e8 | ||
|
|
86544f4b24 | ||
|
|
c8f8416c06 | ||
|
|
3d2228441a | ||
|
|
3f616d5482 | ||
|
|
47fb26368b | ||
|
|
fb2194d2d1 | ||
|
|
8ec49b8cfc | ||
|
|
4d77c3abf9 | ||
|
|
d921c44d3b | ||
|
|
eb0ab92472 | ||
|
|
9904ac757b | ||
|
|
71ee886e24 | ||
|
|
25e53bf627 | ||
|
|
d7edd69e60 | ||
|
|
115038f80f | ||
|
|
5479210366 | ||
|
|
46d012b6ff | ||
|
|
80dcbf0668 | ||
|
|
d0825089d0 | ||
|
|
f653739d50 | ||
|
|
2553b27d7d | ||
|
|
3cd30b61e4 | ||
|
|
ae4921b865 | ||
|
|
c89bcab770 | ||
|
|
f97ef8df83 | ||
|
|
e7466ced18 | ||
|
|
0ee899f309 | ||
|
|
36c97ad804 | ||
|
|
d6633f7555 | ||
|
|
61af43f9d9 | ||
|
|
9523277311 | ||
|
|
9703eba6ec | ||
|
|
83791157ce | ||
|
|
7f6c71e8d7 | ||
|
|
9dbf790cc8 | ||
|
|
f313a39b81 | ||
|
|
53f16ac8b5 | ||
|
|
13c681dc39 | ||
|
|
f35be007c1 | ||
|
|
e2d26e8bdd | ||
|
|
5a373fd7ae | ||
|
|
3c53f7d0fc | ||
|
|
57e3db1e1c | ||
|
|
5afd3869dd | ||
|
|
c3ebb0d10f | ||
|
|
4885088d59 | ||
|
|
872890e674 | ||
|
|
8a2e39a935 | ||
|
|
f8edcda665 | ||
|
|
c95cb144a8 | ||
|
|
f9da22c5a1 | ||
|
|
be085f50e0 | ||
|
|
1122f4e560 | ||
|
|
ecefa22204 | ||
|
|
e2ba63ff58 | ||
|
|
277069a0af | ||
|
|
0c9434b555 | ||
|
|
0fce6dd821 | ||
|
|
4b8e5bf3fc | ||
|
|
3546a17575 | ||
|
|
556495ea7e | ||
|
|
e9cef8a029 | ||
|
|
e577eb48d6 | ||
|
|
d08a69954a | ||
|
|
fe81bfccef | ||
|
|
4869435aca | ||
|
|
0822cebb10 | ||
|
|
01a18f2342 | ||
|
|
a22274f382 | ||
|
|
c0098ac7fd | ||
|
|
ba5a99dc2a | ||
|
|
1927ad2d3a | ||
|
|
0356a0204d | ||
|
|
3bf64bee1e | ||
|
|
da484374a1 | ||
|
|
7b9447b717 | ||
|
|
e583eb2759 | ||
|
|
93b4de60f6 | ||
|
|
16f4f7eac8 | ||
|
|
90d4fe72db | ||
|
|
e1298cfa81 | ||
|
|
6be3a1223f | ||
|
|
81d24432a9 | ||
|
|
1d193f1187 | ||
|
|
c56994c813 | ||
|
|
44862073bb | ||
|
|
9eb9182c21 | ||
|
|
b2abfd543e | ||
|
|
8c6eaf2f1d | ||
|
|
811544d7df | ||
|
|
9fdfdf865e | ||
|
|
6a354c277c | ||
|
|
89f6c6c283 | ||
|
|
d534a4e441 | ||
|
|
4c3772d5c8 | ||
|
|
ee95d5a1b7 | ||
|
|
51379eb2a0 | ||
|
|
f69f791790 | ||
|
|
54b1ab12cd | ||
|
|
18d417e78c | ||
|
|
7a48a06442 | ||
|
|
195aaa8be6 | ||
|
|
94d2f3167b | ||
|
|
344f5e9b0d | ||
|
|
b211a29fc5 | ||
|
|
c25355c531 | ||
|
|
5cf0c86a14 | ||
|
|
852e791c80 | ||
|
|
f5d03ed3a2 | ||
|
|
4642e07fd3 | ||
|
|
83221c1066 | ||
|
|
214b952e84 | ||
|
|
42adbb3739 | ||
|
|
9e63c463ca | ||
|
|
260a6c4940 | ||
|
|
fa949cde12 | ||
|
|
23da0328ec | ||
|
|
7da634e772 | ||
|
|
79f7459d77 | ||
|
|
4002392b12 | ||
|
|
e9e53bb69b | ||
|
|
623deec973 | ||
|
|
3453d4ffd5 | ||
|
|
f9ef7eccf8 | ||
|
|
3cdcffaac3 | ||
|
|
0f23cdcd21 | ||
|
|
9dc6c756f2 | ||
|
|
0491694839 | ||
|
|
f1f3c3f98b | ||
|
|
ec3ad8412c | ||
|
|
d39512732a | ||
|
|
95abf4f898 | ||
|
|
61a1087d91 | ||
|
|
3f245a757e | ||
|
|
4e236a80de | ||
|
|
288f6e2cf8 | ||
|
|
9b6d4a4d97 | ||
|
|
f2a8865679 | ||
|
|
017db18e70 | ||
|
|
19c4a61524 | ||
|
|
f01c9bd5c8 | ||
|
|
2a5fa54ae1 | ||
|
|
7a1b6367a8 | ||
|
|
058860737e | ||
|
|
20f2fd1080 | ||
|
|
16ce1d24af | ||
|
|
a51db38749 | ||
|
|
6090f97347 | ||
|
|
c74355e363 | ||
|
|
a013d40bf1 | ||
|
|
7f7473c348 | ||
|
|
df6f1b984f | ||
|
|
b9905c8bf4 | ||
|
|
32957835b3 | ||
|
|
2efc5feb3f | ||
|
|
526fa14dce | ||
|
|
4d969b994e | ||
|
|
d62edb2249 | ||
|
|
bc82e7e7fd | ||
|
|
1e460d3ef7 | ||
|
|
143a05cab1 | ||
|
|
bf1b59c0d8 | ||
|
|
5ab38027bf | ||
|
|
3e6aee47e5 | ||
|
|
60282281a3 | ||
|
|
2017ee7456 | ||
|
|
e60d241fcf | ||
|
|
43af6fdaba | ||
|
|
f359a16004 | ||
|
|
1b413fb0b7 | ||
|
|
20a9259109 | ||
|
|
8d5f7e942d | ||
|
|
17d5c4327b | ||
|
|
9b30a82a79 | ||
|
|
a0ec3c0293 | ||
|
|
d9e39eee2b | ||
|
|
032e9c53f3 | ||
|
|
dba0ac6442 | ||
|
|
920effb4c6 | ||
|
|
bd6e455cd6 | ||
|
|
b9fc906537 | ||
|
|
1415f196bb | ||
|
|
8f7e356e54 | ||
|
|
0ed88079ad | ||
|
|
5182f9d147 | ||
|
|
4e1579832e | ||
|
|
ff48c0cd45 | ||
|
|
02c7125236 | ||
|
|
dc397d4b82 | ||
|
|
2a206f11b9 | ||
|
|
369fcf2f6a | ||
|
|
983e4db3b1 | ||
|
|
2a7c2474cd | ||
|
|
c890124e67 | ||
|
|
3e21cccb14 | ||
|
|
dafebc3df9 | ||
|
|
6619e7cdb6 | ||
|
|
c54f314424 | ||
|
|
4964f28169 | ||
|
|
dead22c332 | ||
|
|
aba65346b4 | ||
|
|
4621246cec | ||
|
|
f83280068b | ||
|
|
0671fa6567 | ||
|
|
5c80e853c6 | ||
|
|
6c90d7072f | ||
|
|
18bccab27a | ||
|
|
b9213952d3 | ||
|
|
f1ae68a300 | ||
|
|
3f0409f25a | ||
|
|
6f568a16bf | ||
|
|
39ae3b4762 |
@@ -1,7 +1,9 @@
|
|||||||
./.github
|
./.github
|
||||||
|
./.stryker-tmp
|
||||||
./build
|
./build
|
||||||
./coverage
|
./coverage
|
||||||
./node_modules
|
./node_modules
|
||||||
./test
|
./test
|
||||||
./shlink-web-client.gif
|
./shlink-web-client.gif
|
||||||
./dist
|
./dist
|
||||||
|
./docs
|
||||||
|
|||||||
26
.eslintrc
26
.eslintrc
@@ -1,28 +1,16 @@
|
|||||||
{
|
{
|
||||||
|
"root": true,
|
||||||
"extends": [
|
"extends": [
|
||||||
"@shlinkio/js-coding-standard"
|
"@shlinkio/js-coding-standard"
|
||||||
],
|
],
|
||||||
"plugins": ["jest"],
|
|
||||||
"env": {
|
|
||||||
"jest/globals": true
|
|
||||||
},
|
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"tsconfigRootDir": ".",
|
"project": "./tsconfig.json"
|
||||||
"createDefaultProgram": true
|
|
||||||
},
|
|
||||||
"globals": {
|
|
||||||
"process": true,
|
|
||||||
"setImmediate": true
|
|
||||||
},
|
},
|
||||||
|
"ignorePatterns": ["src/service*.ts"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"max-len": ["error", {
|
"jsx-a11y/control-has-associated-label": "off",
|
||||||
"code": 120,
|
"jsx-a11y/label-has-associated-control": "off",
|
||||||
"ignoreStrings": true,
|
"jsx-a11y/click-events-have-key-events": "off",
|
||||||
"ignoreTemplateLiterals": true,
|
"jsx-a11y/no-static-element-interactions": "off"
|
||||||
"ignoreComments": true
|
|
||||||
}],
|
|
||||||
"no-mixed-operators": "off",
|
|
||||||
"react/display-name": "off",
|
|
||||||
"@typescript-eslint/require-array-sort-compare": "off"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
.github/workflows/ci.yml
vendored
Normal file
16
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
name: Continuous integration
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request: null
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
|
||||||
|
with:
|
||||||
|
node-version: 16.13
|
||||||
|
with-mutation-tests: true
|
||||||
|
publish-coverage: true
|
||||||
29
.github/workflows/deploy-preview.yml
vendored
Normal file
29
.github/workflows/deploy-preview.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
name: Deploy preview
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target: null
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
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@v1
|
||||||
|
with:
|
||||||
|
node-version: 16.13
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
npm ci && \
|
||||||
|
node ./scripts/set-homepage.js /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \
|
||||||
|
rm src/service-worker.ts && \
|
||||||
|
npm run build
|
||||||
|
- name: Deploy preview
|
||||||
|
uses: shlinkio/deploy-preview-action@v1.0.1
|
||||||
|
with:
|
||||||
|
folder: build
|
||||||
18
.github/workflows/docker-image-build.yml
vendored
18
.github/workflows/docker-image-build.yml
vendored
@@ -3,22 +3,26 @@ name: Build docker image
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- develop
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-20.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
- name: Install buildx
|
- name: Set up QEMU
|
||||||
id: buildx
|
uses: docker/setup-qemu-action@v1
|
||||||
uses: crazy-max/ghaction-docker-buildx@v1
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
with:
|
with:
|
||||||
buildx-version: latest
|
version: latest
|
||||||
- name: Login to docker hub
|
- name: Login to docker hub
|
||||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||||
- name: Build the image
|
- name: Build the image
|
||||||
run: bash ./scripts/docker/build
|
run: bash ./scripts/docker/build
|
||||||
|
|||||||
27
.github/workflows/publish-release.yml
vendored
Normal file
27
.github/workflows/publish-release.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
name: Publish release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: Use node.js
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: 16.13
|
||||||
|
- name: Generate release assets
|
||||||
|
run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
|
||||||
|
- name: Publish release with assets
|
||||||
|
uses: docker://antonyurchenko/git-release:latest
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
ALLOW_EMPTY_CHANGELOG: "true"
|
||||||
|
with:
|
||||||
|
args: |
|
||||||
|
dist/shlink-web-client_*_dist.zip
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
tools:
|
|
||||||
external_code_coverage:
|
|
||||||
timeout: 1200
|
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": [
|
"extends": [
|
||||||
"stylelint-config-adidas",
|
"@shlinkio/stylelint-config-css-coding-standard"
|
||||||
"stylelint-config-adidas-bem",
|
|
||||||
"stylelint-config-recommended-scss"
|
|
||||||
],
|
|
||||||
"syntax": "scss",
|
|
||||||
"plugins": [
|
|
||||||
"stylelint-scss"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
55
.travis.yml
55
.travis.yml
@@ -1,55 +0,0 @@
|
|||||||
dist: bionic
|
|
||||||
|
|
||||||
language: node_js
|
|
||||||
|
|
||||||
branches:
|
|
||||||
only:
|
|
||||||
- /.*/
|
|
||||||
|
|
||||||
cache:
|
|
||||||
directories:
|
|
||||||
- node_modules
|
|
||||||
|
|
||||||
node_js:
|
|
||||||
- '14.15.0'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
fast_finish: true
|
|
||||||
allow_failures:
|
|
||||||
- name: 'Mutation tests'
|
|
||||||
include:
|
|
||||||
|
|
||||||
- name: 'Lint'
|
|
||||||
install: npm ci
|
|
||||||
script: npm run lint
|
|
||||||
|
|
||||||
- name: 'Unit tests'
|
|
||||||
install: npm ci
|
|
||||||
script: npm run test:ci
|
|
||||||
after_success:
|
|
||||||
- node_modules/.bin/ocular coverage/clover.xml
|
|
||||||
|
|
||||||
- name: 'Mutation tests'
|
|
||||||
install: npm ci
|
|
||||||
before_script:
|
|
||||||
- echo "Building commit range ${TRAVIS_COMMIT_RANGE}"
|
|
||||||
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/main} --name-only | grep -E 'src\/(.*).(ts|tsx)$' | paste -sd ",")
|
|
||||||
script: npm run mutate:ci
|
|
||||||
|
|
||||||
- name: 'Build docker image'
|
|
||||||
services:
|
|
||||||
- docker
|
|
||||||
script: docker build -t shlink-web-client:test .
|
|
||||||
|
|
||||||
- name: 'Publish release'
|
|
||||||
if: tag IS present
|
|
||||||
script: echo "Publishing GitHub release"
|
|
||||||
before_deploy: npm run build ${TRAVIS_TAG#?}
|
|
||||||
deploy:
|
|
||||||
provider: releases
|
|
||||||
api_key:
|
|
||||||
secure: jBvPwC7EAbViaNR83rwMSt5XQDK0Iu9rgvEMa7GoyShbHcvUCCPd73Tu9quNpKi6NKsDY3INHgtch3vgonjGNGDGJ+yDyIBzXcvsAX2x3UcHpRbgY12uiINVmQxBI1+OVQB016Nm+cKC/i5Z36K4EmDbYfo+MrKndngM6AjcQFTwI8EwniIMaQgg4gNes//K8NhP5u0c3gwG+Q6jEGnq6uH3kcRgh6/epIZYpQyxjqWqKwF77sgcYj+X2Nf6XxtB5neuCi301UKLoLx8G0skh/Lm6KAIO4s9iIhIFa3UpoF21Ka0TxLpd2JxalLryCnFGlWWE6lxC9Htmc0TeRowJQlGdJXCskJ37xT9MljKY0fwNMu06VS/FUgykuCv+jP3zQu51pKu7Ew7+WeNPjautoOTu54VkdGyHcf2ThBNEyJQuiEwAQe4u7yAxY6R5ovEdvHBSIg4w1E5/Mxy5SMTCUlIAv6H7QQ1X9Z/zJm9HH5KeKz5tsHvQ/RIdSpgHXq/tC8o4Yup/LCFucXfrgvy/8pJoO1UpOlmvm62974NFfo0EG5YWwv6brUqz3QXpMjb8sWqgjltYMYJX3J7WZ34rIc+zt4NAmfhqgczaOC4pUGCiJ8jX3rMWIaQRn1AJ+5V337jL9fNDpTHny4phQjHrMJ1e0HZuNp0Xb5Q8wgqDPM=
|
|
||||||
file: "./dist/shlink-web-client_${TRAVIS_TAG#?}_dist.zip"
|
|
||||||
skip_cleanup: true
|
|
||||||
on:
|
|
||||||
tags: true
|
|
||||||
843
CHANGELOG.md
843
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
|||||||
FROM node:14.15.0-alpine as node
|
FROM node:16.13-alpine as node
|
||||||
COPY . /shlink-web-client
|
COPY . /shlink-web-client
|
||||||
ARG VERSION="latest"
|
ARG VERSION="latest"
|
||||||
ENV VERSION ${VERSION}
|
ENV VERSION ${VERSION}
|
||||||
RUN cd /shlink-web-client && \
|
RUN cd /shlink-web-client && npm ci && NODE_ENV=production npm run build
|
||||||
npm install && npm run build -- ${VERSION} --no-dist
|
|
||||||
|
|
||||||
FROM nginx:1.19.3-alpine
|
FROM nginx:1.21-alpine
|
||||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||||
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
|
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
|
||||||
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY scripts/docker/servers_from_env.sh /docker-entrypoint.d/30-shlink-servers-json.sh
|
||||||
COPY --from=node /shlink-web-client/build /usr/share/nginx/html
|
COPY --from=node /shlink-web-client/build /usr/share/nginx/html
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -1,11 +1,11 @@
|
|||||||
# shlink-web-client
|
# shlink-web-client
|
||||||
|
|
||||||
[](https://travis-ci.com/shlinkio/shlink-web-client)
|
[](https://github.com/shlinkio/shlink-web-client/actions?query=workflow%3A%22Continuous+integration%22)
|
||||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/)
|
[](https://app.codecov.io/gh/shlinkio/shlink-web-client)
|
||||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/)
|
|
||||||
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
||||||
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||||
[](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
[](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
||||||
|
[](https://twitter.com/shlinkio)
|
||||||
[](https://slnk.to/donate)
|
[](https://slnk.to/donate)
|
||||||
|
|
||||||
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
||||||
@@ -70,6 +70,25 @@ If you are using the shlink-web-client docker image, you can mount the `servers.
|
|||||||
|
|
||||||
docker run --name shlink-web-client -p 8000:80 -v ${PWD}/servers.json:/usr/share/nginx/html/servers.json shlinkio/shlink-web-client
|
docker run --name shlink-web-client -p 8000:80 -v ${PWD}/servers.json:/usr/share/nginx/html/servers.json shlinkio/shlink-web-client
|
||||||
|
|
||||||
|
Alternatively, you can mount a `conf.d` directory, which in turn contains the `servers.json` file, in a volume inside `/usr/share/nginx/html`. *(since shlink-web-client 3.2.0)*.
|
||||||
|
|
||||||
|
docker run --name shlink-web-client -p 8000:80 -v ${PWD}/my-config/:/usr/share/nginx/html/conf.d/ shlinkio/shlink-web-client
|
||||||
|
|
||||||
|
If you want to pre-configure a single server, you can provide its config via env vars. When the container starts up, it will build the `servers.json` file dynamically based on them. *(since shlink-web-client 3.2.0)*.
|
||||||
|
|
||||||
|
* `SHLINK_SERVER_URL`: The fully qualified URL for the Shlink server.
|
||||||
|
* `SHLINK_SERVER_API_KEY`: The API key.
|
||||||
|
* `SHLINK_SERVER_NAME`: The name to be displayed. Defaults to **Shlink** if not provided.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker run \
|
||||||
|
--name shlink-web-client \
|
||||||
|
-p 8000:80 \
|
||||||
|
-e SHLINK_SERVER_URL=https://doma.in \
|
||||||
|
-e SHLINK_SERVER_API_KEY=6aeb82c6-e275-4538-a747-31f9abfba63c \
|
||||||
|
shlinkio/shlink-web-client
|
||||||
|
```
|
||||||
|
|
||||||
> **Be extremely careful when using this feature.**
|
> **Be extremely careful when using this feature.**
|
||||||
>
|
>
|
||||||
> Due to shlink-web-client's client-side nature, the file needs to be accessible from the browser.
|
> Due to shlink-web-client's client-side nature, the file needs to be accessible from the browser.
|
||||||
|
|||||||
11
babel.config.js
Normal file
11
babel.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
[
|
||||||
|
'react-app',
|
||||||
|
{
|
||||||
|
runtime: 'automatic',
|
||||||
|
typescript: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -20,6 +20,11 @@ server {
|
|||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# servers.json may be on the root, or in conf.d directory
|
||||||
|
location = /servers.json {
|
||||||
|
try_files /servers.json /conf.d/servers.json;
|
||||||
|
}
|
||||||
|
|
||||||
# When requesting static paths with extension, try them, and return a 404 if not found
|
# When requesting static paths with extension, try them, and return a 404 if not found
|
||||||
location ~* .+\.(css|js|html|png|jpe?g|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
|
location ~* .+\.(css|js|html|png|jpe?g|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
|
||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ =404;
|
||||||
|
|||||||
101
config/env.js
101
config/env.js
@@ -1,101 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const paths = require('./paths');
|
|
||||||
|
|
||||||
// Make sure that including paths.js after env.js will read .env variables.
|
|
||||||
delete require.cache[require.resolve('./paths')];
|
|
||||||
|
|
||||||
const { NODE_ENV } = process.env;
|
|
||||||
|
|
||||||
if (!NODE_ENV) {
|
|
||||||
throw new Error(
|
|
||||||
'The NODE_ENV environment variable is required but was not specified.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use
|
|
||||||
const dotenvFiles = [
|
|
||||||
`${paths.dotenv}.${NODE_ENV}.local`,
|
|
||||||
`${paths.dotenv}.${NODE_ENV}`,
|
|
||||||
|
|
||||||
// Don't include `.env.local` for `test` environment
|
|
||||||
// since normally you expect tests to produce the same
|
|
||||||
// results for everyone
|
|
||||||
NODE_ENV !== 'test' && `${paths.dotenv}.local`,
|
|
||||||
paths.dotenv,
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
// Load environment variables from .env* files. Suppress warnings using silent
|
|
||||||
// if this file is missing. dotenv will never modify any environment variables
|
|
||||||
// that have already been set. Variable expansion is supported in .env files.
|
|
||||||
// https://github.com/motdotla/dotenv
|
|
||||||
// https://github.com/motdotla/dotenv-expand
|
|
||||||
dotenvFiles.forEach((dotenvFile) => {
|
|
||||||
if (fs.existsSync(dotenvFile)) {
|
|
||||||
require('dotenv-expand')(
|
|
||||||
require('dotenv').config({
|
|
||||||
path: dotenvFile,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// We support resolving modules according to `NODE_PATH`.
|
|
||||||
// This lets you use absolute paths in imports inside large monorepos:
|
|
||||||
// https://github.com/facebook/create-react-app/issues/253.
|
|
||||||
// It works similar to `NODE_PATH` in Node itself:
|
|
||||||
// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders
|
|
||||||
// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored.
|
|
||||||
// Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims.
|
|
||||||
// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421
|
|
||||||
// We also resolve them to make sure all tools using them work consistently.
|
|
||||||
const appDirectory = fs.realpathSync(process.cwd());
|
|
||||||
|
|
||||||
process.env.NODE_PATH = (process.env.NODE_PATH || '')
|
|
||||||
.split(path.delimiter)
|
|
||||||
.filter((folder) => folder && !path.isAbsolute(folder))
|
|
||||||
.map((folder) => path.resolve(appDirectory, folder))
|
|
||||||
.join(path.delimiter);
|
|
||||||
|
|
||||||
// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be
|
|
||||||
// injected into the application via DefinePlugin in Webpack configuration.
|
|
||||||
const REACT_APP = /^REACT_APP_/i;
|
|
||||||
|
|
||||||
function getClientEnvironment(publicUrl) {
|
|
||||||
const raw = Object.keys(process.env)
|
|
||||||
.filter((key) => REACT_APP.test(key))
|
|
||||||
.reduce(
|
|
||||||
(env, key) => {
|
|
||||||
env[key] = process.env[key];
|
|
||||||
|
|
||||||
return env;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
|
|
||||||
// Useful for determining whether we’re running in production mode.
|
|
||||||
// Most importantly, it switches React into the correct mode.
|
|
||||||
NODE_ENV: process.env.NODE_ENV || 'development',
|
|
||||||
|
|
||||||
// Useful for resolving the correct path to static assets in `public`.
|
|
||||||
// For example, <img src={process.env.PUBLIC_URL + '/img/logo.png'} />.
|
|
||||||
// This should only be used as an escape hatch. Normally you would put
|
|
||||||
// images into the `src` and `import` them in code to get their paths.
|
|
||||||
PUBLIC_URL: publicUrl,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stringify all values so we can feed into Webpack DefinePlugin
|
|
||||||
const stringified = {
|
|
||||||
'process.env': Object.keys(raw).reduce((env, key) => {
|
|
||||||
env[key] = JSON.stringify(raw[key]);
|
|
||||||
|
|
||||||
return env;
|
|
||||||
}, {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
return { raw, stringified };
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = getClientEnvironment;
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
|
|
||||||
// This is a custom Jest transformer turning style imports into empty objects.
|
// This is a custom Jest transformer turning style imports into empty objects.
|
||||||
// http://facebook.github.io/jest/docs/en/webpack.html
|
// http://facebook.github.io/jest/docs/en/webpack.html
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
process() {
|
process() {
|
||||||
return 'module.exports = {};';
|
return { code: 'module.exports = {};' };
|
||||||
},
|
},
|
||||||
getCacheKey() {
|
getCacheKey() {
|
||||||
// The output is always the same.
|
// The output is always the same.
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ module.exports = {
|
|||||||
};`;
|
};`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `module.exports = ${assetFilename};`;
|
return {
|
||||||
|
code: `module.exports = ${assetFilename};`
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
4
config/jest/setupBeforeEnzyme.js
Normal file
4
config/jest/setupBeforeEnzyme.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import * as util from 'util';
|
||||||
|
|
||||||
|
global.TextEncoder = util.TextEncoder;
|
||||||
|
global.TextDecoder = util.TextDecoder;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import Enzyme from 'enzyme';
|
import Enzyme from 'enzyme';
|
||||||
import Adapter from 'enzyme-adapter-react-16';
|
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||||
|
|
||||||
Enzyme.configure({ adapter: new Adapter() });
|
Enzyme.configure({ adapter: new Adapter() });
|
||||||
7
config/jest/setupTests.ts
Normal file
7
config/jest/setupTests.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import 'jest-canvas-mock';
|
||||||
|
import ResizeObserver from 'resize-observer-polyfill';
|
||||||
|
|
||||||
|
(global as any).ResizeObserver = ResizeObserver;
|
||||||
|
(global as any).scrollTo = () => {};
|
||||||
|
(global as any).matchMedia = (media: string) => ({ matches: false, media });
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const url = require('url');
|
|
||||||
|
|
||||||
// Make sure any symlinks in the project folder are resolved:
|
|
||||||
// https://github.com/facebook/create-react-app/issues/637
|
|
||||||
const appDirectory = fs.realpathSync(process.cwd());
|
|
||||||
const resolveApp = (relativePath) => path.resolve(appDirectory, relativePath);
|
|
||||||
|
|
||||||
const envPublicUrl = process.env.PUBLIC_URL;
|
|
||||||
|
|
||||||
function ensureSlash(inputPath, needsSlash) {
|
|
||||||
const hasSlash = inputPath.endsWith('/');
|
|
||||||
|
|
||||||
if (hasSlash && !needsSlash) {
|
|
||||||
return inputPath.substr(0, inputPath.length - 1);
|
|
||||||
} else if (!hasSlash && needsSlash) {
|
|
||||||
return `${inputPath}/`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return inputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPublicUrl = (appPackageJson) =>
|
|
||||||
envPublicUrl || require(appPackageJson).homepage;
|
|
||||||
|
|
||||||
// We use `PUBLIC_URL` environment variable or "homepage" field to infer
|
|
||||||
// "public path" at which the app is served.
|
|
||||||
// Webpack needs to know it to put the right <script> hrefs into HTML even in
|
|
||||||
// single-page apps that may serve index.html for nested URLs like /todos/42.
|
|
||||||
// We can't use a relative path in HTML because we don't want to load something
|
|
||||||
// like /todos/42/static/js/bundle.7289d.js. We have to know the root.
|
|
||||||
function getServedPath(appPackageJson) {
|
|
||||||
const publicUrl = getPublicUrl(appPackageJson);
|
|
||||||
const servedUrl =
|
|
||||||
envPublicUrl || (publicUrl ? url.parse(publicUrl).pathname : '/');
|
|
||||||
|
|
||||||
return ensureSlash(servedUrl, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const moduleFileExtensions = [
|
|
||||||
'web.mjs',
|
|
||||||
'mjs',
|
|
||||||
'web.js',
|
|
||||||
'js',
|
|
||||||
'web.ts',
|
|
||||||
'ts',
|
|
||||||
'web.tsx',
|
|
||||||
'tsx',
|
|
||||||
'json',
|
|
||||||
'web.jsx',
|
|
||||||
'jsx',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Resolve file paths in the same order as webpack
|
|
||||||
const resolveModule = (resolveFn, filePath) => {
|
|
||||||
const extension = moduleFileExtensions.find((extension) =>
|
|
||||||
fs.existsSync(resolveFn(`${filePath}.${extension}`)));
|
|
||||||
|
|
||||||
if (extension) {
|
|
||||||
return resolveFn(`${filePath}.${extension}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolveFn(`${filePath}.js`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// config after eject: we're in ./config/
|
|
||||||
module.exports = {
|
|
||||||
dotenv: resolveApp('.env'),
|
|
||||||
appPath: resolveApp('.'),
|
|
||||||
appBuild: resolveApp('build'),
|
|
||||||
appPublic: resolveApp('public'),
|
|
||||||
appHtml: resolveApp('public/index.html'),
|
|
||||||
appIndexJs: resolveModule(resolveApp, 'src/index'),
|
|
||||||
appPackageJson: resolveApp('package.json'),
|
|
||||||
appSrc: resolveApp('src'),
|
|
||||||
appTsConfig: resolveApp('tsconfig.json'),
|
|
||||||
yarnLockFile: resolveApp('yarn.lock'),
|
|
||||||
testsSetup: resolveModule(resolveApp, 'src/setupTests'),
|
|
||||||
proxySetup: resolveApp('src/setupProxy.js'),
|
|
||||||
appNodeModules: resolveApp('node_modules'),
|
|
||||||
publicUrl: getPublicUrl(resolveApp('package.json')),
|
|
||||||
servedPath: getServedPath(resolveApp('package.json')),
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports.moduleFileExtensions = moduleFileExtensions;
|
|
||||||
@@ -1,678 +0,0 @@
|
|||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const webpack = require('webpack');
|
|
||||||
const resolve = require('resolve');
|
|
||||||
const PnpWebpackPlugin = require('pnp-webpack-plugin');
|
|
||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
|
||||||
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
|
|
||||||
const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin');
|
|
||||||
const TerserPlugin = require('terser-webpack-plugin');
|
|
||||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
|
||||||
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
|
|
||||||
const safePostCssParser = require('postcss-safe-parser');
|
|
||||||
const ManifestPlugin = require('webpack-manifest-plugin');
|
|
||||||
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
|
|
||||||
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
|
|
||||||
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
|
|
||||||
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
|
|
||||||
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
|
|
||||||
const ModuleNotFoundPlugin = require('react-dev-utils/ModuleNotFoundPlugin');
|
|
||||||
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin-alt');
|
|
||||||
const typescriptFormatter = require('react-dev-utils/typescriptFormatter');
|
|
||||||
const getClientEnvironment = require('./env');
|
|
||||||
const paths = require('./paths');
|
|
||||||
|
|
||||||
// Source maps are resource heavy and can cause out of memory issue for large source files.
|
|
||||||
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';
|
|
||||||
|
|
||||||
// Some apps do not need the benefits of saving a web request, so not inlining the chunk
|
|
||||||
// makes for a smoother build process.
|
|
||||||
const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false';
|
|
||||||
|
|
||||||
// Check if TypeScript is setup
|
|
||||||
const useTypeScript = fs.existsSync(paths.appTsConfig);
|
|
||||||
|
|
||||||
// style files regexes
|
|
||||||
const cssRegex = /\.css$/;
|
|
||||||
const cssModuleRegex = /\.module\.css$/;
|
|
||||||
const sassRegex = /\.(scss|sass)$/;
|
|
||||||
const sassModuleRegex = /\.module\.(scss|sass)$/;
|
|
||||||
|
|
||||||
// This is the production and development configuration.
|
|
||||||
// It is focused on developer experience, fast rebuilds, and a minimal bundle.
|
|
||||||
/* eslint-disable complexity */
|
|
||||||
module.exports = (webpackEnv) => {
|
|
||||||
const isEnvDevelopment = webpackEnv === 'development';
|
|
||||||
const isEnvProduction = webpackEnv === 'production';
|
|
||||||
|
|
||||||
// Webpack uses `publicPath` to determine where the app is being served from.
|
|
||||||
// It requires a trailing slash, or the file assets will get an incorrect path.
|
|
||||||
// In development, we always serve from the root. This makes config easier.
|
|
||||||
const publicPath = isEnvProduction
|
|
||||||
? paths.servedPath
|
|
||||||
: isEnvDevelopment && '/';
|
|
||||||
|
|
||||||
// Some apps do not use client-side routing with pushState.
|
|
||||||
// For these, "homepage" can be set to "." to enable relative asset paths.
|
|
||||||
const shouldUseRelativeAssetPaths = publicPath === './';
|
|
||||||
|
|
||||||
// `publicUrl` is just like `publicPath`, but we will provide it to our app
|
|
||||||
// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
|
|
||||||
// Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz.
|
|
||||||
const publicUrl = isEnvProduction
|
|
||||||
? publicPath.slice(0, -1)
|
|
||||||
: isEnvDevelopment && '';
|
|
||||||
|
|
||||||
// Get environment variables to inject into our app.
|
|
||||||
const env = getClientEnvironment(publicUrl);
|
|
||||||
|
|
||||||
// common function to get style loaders
|
|
||||||
const getStyleLoaders = (cssOptions, preProcessor) => {
|
|
||||||
const loaders = [
|
|
||||||
isEnvDevelopment && require.resolve('style-loader'),
|
|
||||||
isEnvProduction && {
|
|
||||||
loader: MiniCssExtractPlugin.loader,
|
|
||||||
options: Object.assign(
|
|
||||||
{},
|
|
||||||
shouldUseRelativeAssetPaths ? { publicPath: '../../' } : undefined,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
loader: require.resolve('css-loader'),
|
|
||||||
options: cssOptions,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
|
|
||||||
// Options for PostCSS as we reference these options twice
|
|
||||||
// Adds vendor prefixing based on your specified browser support in
|
|
||||||
// package.json
|
|
||||||
loader: require.resolve('postcss-loader'),
|
|
||||||
options: {
|
|
||||||
|
|
||||||
// Necessary for external CSS imports to work
|
|
||||||
// https://github.com/facebook/create-react-app/issues/2677
|
|
||||||
ident: 'postcss',
|
|
||||||
plugins: () => [
|
|
||||||
require('postcss-flexbugs-fixes'),
|
|
||||||
require('postcss-preset-env')({
|
|
||||||
autoprefixer: {
|
|
||||||
flexbox: 'no-2009',
|
|
||||||
},
|
|
||||||
stage: 3,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
sourceMap: isEnvProduction && shouldUseSourceMap,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
if (preProcessor) {
|
|
||||||
loaders.push({
|
|
||||||
loader: require.resolve(preProcessor),
|
|
||||||
options: {
|
|
||||||
sourceMap: isEnvProduction && shouldUseSourceMap,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return loaders;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: isEnvProduction ? 'production' : isEnvDevelopment && 'development',
|
|
||||||
|
|
||||||
// Stop compilation early in production
|
|
||||||
bail: isEnvProduction,
|
|
||||||
devtool: isEnvProduction
|
|
||||||
? shouldUseSourceMap
|
|
||||||
? 'source-map'
|
|
||||||
: false
|
|
||||||
: isEnvDevelopment && 'cheap-module-source-map',
|
|
||||||
|
|
||||||
// These are the "entry points" to our application.
|
|
||||||
// This means they will be the "root" imports that are included in JS bundle.
|
|
||||||
entry: [
|
|
||||||
|
|
||||||
// Include an alternative client for WebpackDevServer. A client's job is to
|
|
||||||
// connect to WebpackDevServer by a socket and get notified about changes.
|
|
||||||
// When you save a file, the client will either apply hot updates (in case
|
|
||||||
// of CSS changes), or refresh the page (in case of JS changes). When you
|
|
||||||
// make a syntax error, this client will display a syntax error overlay.
|
|
||||||
// Note: instead of the default WebpackDevServer client, we use a custom one
|
|
||||||
// to bring better experience for Create React App users. You can replace
|
|
||||||
// the line below with these two lines if you prefer the stock client:
|
|
||||||
// require.resolve('webpack-dev-server/client') + '?/',
|
|
||||||
// require.resolve('webpack/hot/dev-server'),
|
|
||||||
isEnvDevelopment &&
|
|
||||||
require.resolve('react-dev-utils/webpackHotDevClient'),
|
|
||||||
|
|
||||||
// Finally, this is your app's code:
|
|
||||||
paths.appIndexJs,
|
|
||||||
|
|
||||||
// We include the app code last so that if there is a runtime error during
|
|
||||||
// initialization, it doesn't blow up the WebpackDevServer client, and
|
|
||||||
// changing JS code would still trigger a refresh.
|
|
||||||
].filter(Boolean),
|
|
||||||
output: {
|
|
||||||
|
|
||||||
// The build folder.
|
|
||||||
path: isEnvProduction ? paths.appBuild : undefined,
|
|
||||||
|
|
||||||
// Add /* filename */ comments to generated require()s in the output.
|
|
||||||
pathinfo: isEnvDevelopment,
|
|
||||||
|
|
||||||
// There will be one main bundle, and one file per asynchronous chunk.
|
|
||||||
// In development, it does not produce real files.
|
|
||||||
filename: isEnvProduction
|
|
||||||
? 'static/js/[name].[chunkhash:8].js'
|
|
||||||
: isEnvDevelopment && 'static/js/bundle.js',
|
|
||||||
|
|
||||||
// There are also additional JS chunk files if you use code splitting.
|
|
||||||
chunkFilename: isEnvProduction
|
|
||||||
? 'static/js/[name].[chunkhash:8].chunk.js'
|
|
||||||
: isEnvDevelopment && 'static/js/[name].chunk.js',
|
|
||||||
|
|
||||||
// We inferred the "public path" (such as / or /my-project) from homepage.
|
|
||||||
// We use "/" in development.
|
|
||||||
publicPath,
|
|
||||||
|
|
||||||
// Point sourcemap entries to original disk location (format as URL on Windows)
|
|
||||||
devtoolModuleFilenameTemplate: isEnvProduction
|
|
||||||
? (info) =>
|
|
||||||
path
|
|
||||||
.relative(paths.appSrc, info.absoluteResourcePath)
|
|
||||||
.replace(/\\/g, '/')
|
|
||||||
: isEnvDevelopment &&
|
|
||||||
((info) => path.resolve(info.absoluteResourcePath).replace(/\\/g, '/')),
|
|
||||||
},
|
|
||||||
optimization: {
|
|
||||||
minimize: isEnvProduction,
|
|
||||||
minimizer: [
|
|
||||||
|
|
||||||
// This is only used in production mode
|
|
||||||
new TerserPlugin({
|
|
||||||
terserOptions: {
|
|
||||||
parse: {
|
|
||||||
|
|
||||||
// we want terser to parse ecma 8 code. However, we don't want it
|
|
||||||
// to apply any minfication steps that turns valid ecma 5 code
|
|
||||||
// into invalid ecma 5 code. This is why the 'compress' and 'output'
|
|
||||||
// sections only apply transformations that are ecma 5 safe
|
|
||||||
// https://github.com/facebook/create-react-app/pull/4234
|
|
||||||
ecma: 8,
|
|
||||||
},
|
|
||||||
compress: {
|
|
||||||
ecma: 5,
|
|
||||||
warnings: false,
|
|
||||||
|
|
||||||
// Disabled because of an issue with Uglify breaking seemingly valid code:
|
|
||||||
// https://github.com/facebook/create-react-app/issues/2376
|
|
||||||
// Pending further investigation:
|
|
||||||
// https://github.com/mishoo/UglifyJS2/issues/2011
|
|
||||||
comparisons: false,
|
|
||||||
|
|
||||||
// Disabled because of an issue with Terser breaking valid code:
|
|
||||||
// https://github.com/facebook/create-react-app/issues/5250
|
|
||||||
// Pending futher investigation:
|
|
||||||
// https://github.com/terser-js/terser/issues/120
|
|
||||||
inline: 2,
|
|
||||||
},
|
|
||||||
mangle: {
|
|
||||||
safari10: true,
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
ecma: 5,
|
|
||||||
comments: false,
|
|
||||||
|
|
||||||
// Turned on because emoji and regex is not minified properly using default
|
|
||||||
// https://github.com/facebook/create-react-app/issues/2488
|
|
||||||
ascii_only: true, // eslint-disable-line @typescript-eslint/camelcase
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Use multi-process parallel running to improve the build speed
|
|
||||||
// Default number of concurrent runs: os.cpus().length - 1
|
|
||||||
parallel: true,
|
|
||||||
|
|
||||||
// Enable file caching
|
|
||||||
cache: true,
|
|
||||||
sourceMap: shouldUseSourceMap,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// This is only used in production mode
|
|
||||||
new OptimizeCSSAssetsPlugin({
|
|
||||||
cssProcessorOptions: {
|
|
||||||
parser: safePostCssParser,
|
|
||||||
map: shouldUseSourceMap
|
|
||||||
? {
|
|
||||||
|
|
||||||
// `inline: false` forces the sourcemap to be output into a
|
|
||||||
// separate file
|
|
||||||
inline: false,
|
|
||||||
|
|
||||||
// `annotation: true` appends the sourceMappingURL to the end of
|
|
||||||
// the css file, helping the browser find the sourcemap
|
|
||||||
annotation: true,
|
|
||||||
}
|
|
||||||
: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Automatically split vendor and commons
|
|
||||||
// https://twitter.com/wSokra/status/969633336732905474
|
|
||||||
// https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366
|
|
||||||
splitChunks: {
|
|
||||||
chunks: 'all',
|
|
||||||
name: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Keep the runtime chunk separated to enable long term caching
|
|
||||||
// https://twitter.com/wSokra/status/969679223278505985
|
|
||||||
runtimeChunk: true,
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
|
|
||||||
// This allows you to set a fallback for where Webpack should look for modules.
|
|
||||||
// We placed these paths second because we want `node_modules` to "win"
|
|
||||||
// if there are any conflicts. This matches Node resolution mechanism.
|
|
||||||
// https://github.com/facebook/create-react-app/issues/253
|
|
||||||
modules: [ 'node_modules' ].concat(
|
|
||||||
|
|
||||||
// It is guaranteed to exist because we tweak it in `env.js`
|
|
||||||
process.env.NODE_PATH.split(path.delimiter).filter(Boolean),
|
|
||||||
),
|
|
||||||
|
|
||||||
// These are the reasonable defaults supported by the Node ecosystem.
|
|
||||||
// We also include JSX as a common component filename extension to support
|
|
||||||
// some tools, although we do not recommend using it, see:
|
|
||||||
// https://github.com/facebook/create-react-app/issues/290
|
|
||||||
// `web` extension prefixes have been added for better support
|
|
||||||
// for React Native Web.
|
|
||||||
extensions: paths.moduleFileExtensions
|
|
||||||
.map((ext) => `.${ext}`)
|
|
||||||
.filter((ext) => useTypeScript || !ext.includes('ts')),
|
|
||||||
alias: {
|
|
||||||
|
|
||||||
// Support React Native Web
|
|
||||||
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
|
|
||||||
'react-native': 'react-native-web',
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
|
|
||||||
// Adds support for installing with Plug'n'Play, leading to faster installs and adding
|
|
||||||
// guards against forgotten dependencies and such.
|
|
||||||
PnpWebpackPlugin,
|
|
||||||
|
|
||||||
// Prevents users from importing files from outside of src/ (or node_modules/).
|
|
||||||
// This often causes confusion because we only process files within src/ with babel.
|
|
||||||
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
|
|
||||||
// please link the files into your node_modules/ and let module-resolution kick in.
|
|
||||||
// Make sure your source files are compiled, as they will not be processed in any way.
|
|
||||||
new ModuleScopePlugin(paths.appSrc, [ paths.appPackageJson ]),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
resolveLoader: {
|
|
||||||
plugins: [
|
|
||||||
|
|
||||||
// Also related to Plug'n'Play, but this time it tells Webpack to load its loaders
|
|
||||||
// from the current package.
|
|
||||||
PnpWebpackPlugin.moduleLoader(module),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
module: {
|
|
||||||
strictExportPresence: true,
|
|
||||||
rules: [
|
|
||||||
|
|
||||||
// Disable require.ensure as it's not a standard language feature.
|
|
||||||
{ parser: { requireEnsure: false } },
|
|
||||||
|
|
||||||
// First, run the linter.
|
|
||||||
// It's important to do this before Babel processes the JS.
|
|
||||||
{
|
|
||||||
test: /\.(js|mjs|jsx)$/,
|
|
||||||
enforce: 'pre',
|
|
||||||
use: [
|
|
||||||
{
|
|
||||||
options: {
|
|
||||||
formatter: require.resolve('react-dev-utils/eslintFormatter'),
|
|
||||||
eslintPath: require.resolve('eslint'),
|
|
||||||
|
|
||||||
},
|
|
||||||
loader: require.resolve('eslint-loader'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
include: paths.appSrc,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
|
|
||||||
// "oneOf" will traverse all following loaders until one will
|
|
||||||
// match the requirements. When no loader matches it will fall
|
|
||||||
// back to the "file" loader at the end of the loader list.
|
|
||||||
oneOf: [
|
|
||||||
|
|
||||||
// "url" loader works like "file" loader except that it embeds assets
|
|
||||||
// smaller than specified limit in bytes as data URLs to avoid requests.
|
|
||||||
// A missing `test` is equivalent to a match.
|
|
||||||
{
|
|
||||||
test: [ /\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/ ],
|
|
||||||
loader: require.resolve('url-loader'),
|
|
||||||
options: {
|
|
||||||
limit: 10000,
|
|
||||||
name: 'static/media/[name].[hash:8].[ext]',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Process application JS with Babel.
|
|
||||||
// The preset includes JSX, Flow, TypeScript, and some ESnext features.
|
|
||||||
{
|
|
||||||
test: /\.(js|mjs|jsx|ts|tsx)$/,
|
|
||||||
include: paths.appSrc,
|
|
||||||
loader: require.resolve('babel-loader'),
|
|
||||||
options: {
|
|
||||||
customize: require.resolve(
|
|
||||||
'babel-preset-react-app/webpack-overrides',
|
|
||||||
),
|
|
||||||
|
|
||||||
plugins: [
|
|
||||||
[
|
|
||||||
require.resolve('babel-plugin-named-asset-import'),
|
|
||||||
{
|
|
||||||
loaderMap: {
|
|
||||||
svg: {
|
|
||||||
ReactComponent:
|
|
||||||
'@svgr/webpack?-prettier,-svgo![path]',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
// This is a feature of `babel-loader` for webpack (not Babel itself).
|
|
||||||
// It enables caching results in ./node_modules/.cache/babel-loader/
|
|
||||||
// directory for faster rebuilds.
|
|
||||||
cacheDirectory: true,
|
|
||||||
cacheCompression: isEnvProduction,
|
|
||||||
compact: isEnvProduction,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Process any JS outside of the app with Babel.
|
|
||||||
// Unlike the application JS, we only compile the standard ES features.
|
|
||||||
{
|
|
||||||
test: /\.(js|mjs)$/,
|
|
||||||
exclude: /@babel(?:\/|\\{1,2})runtime/,
|
|
||||||
loader: require.resolve('babel-loader'),
|
|
||||||
options: {
|
|
||||||
babelrc: false,
|
|
||||||
configFile: false,
|
|
||||||
compact: false,
|
|
||||||
presets: [
|
|
||||||
[
|
|
||||||
require.resolve('babel-preset-react-app/dependencies'),
|
|
||||||
{ helpers: true },
|
|
||||||
],
|
|
||||||
],
|
|
||||||
cacheDirectory: true,
|
|
||||||
cacheCompression: isEnvProduction,
|
|
||||||
|
|
||||||
// If an error happens in a package, it's possible to be
|
|
||||||
// because it was compiled. Thus, we don't want the browser
|
|
||||||
// debugger to show the original code. Instead, the code
|
|
||||||
// being evaluated would be much more helpful.
|
|
||||||
sourceMaps: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// "postcss" loader applies autoprefixer to our CSS.
|
|
||||||
// "css" loader resolves paths in CSS and adds assets as dependencies.
|
|
||||||
// "style" loader turns CSS into JS modules that inject <style> tags.
|
|
||||||
// In production, we use MiniCSSExtractPlugin to extract that CSS
|
|
||||||
// to a file, but in development "style" loader enables hot editing
|
|
||||||
// of CSS.
|
|
||||||
// By default we support CSS Modules with the extension .module.css
|
|
||||||
{
|
|
||||||
test: cssRegex,
|
|
||||||
exclude: cssModuleRegex,
|
|
||||||
use: getStyleLoaders({
|
|
||||||
importLoaders: 1,
|
|
||||||
sourceMap: isEnvProduction && shouldUseSourceMap,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Don't consider CSS imports dead code even if the
|
|
||||||
// containing package claims to have no side effects.
|
|
||||||
// Remove this when webpack adds a warning or an error for this.
|
|
||||||
// See https://github.com/webpack/webpack/issues/6571
|
|
||||||
sideEffects: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Adds support for CSS Modules (https://github.com/css-modules/css-modules)
|
|
||||||
// using the extension .module.css
|
|
||||||
{
|
|
||||||
test: cssModuleRegex,
|
|
||||||
use: getStyleLoaders({
|
|
||||||
importLoaders: 1,
|
|
||||||
sourceMap: isEnvProduction && shouldUseSourceMap,
|
|
||||||
modules: true,
|
|
||||||
getLocalIdent: getCSSModuleLocalIdent,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Opt-in support for SASS (using .scss or .sass extensions).
|
|
||||||
// By default we support SASS Modules with the
|
|
||||||
// extensions .module.scss or .module.sass
|
|
||||||
{
|
|
||||||
test: sassRegex,
|
|
||||||
exclude: sassModuleRegex,
|
|
||||||
use: getStyleLoaders(
|
|
||||||
{
|
|
||||||
importLoaders: 2,
|
|
||||||
sourceMap: isEnvProduction && shouldUseSourceMap,
|
|
||||||
},
|
|
||||||
'sass-loader',
|
|
||||||
),
|
|
||||||
|
|
||||||
// Don't consider CSS imports dead code even if the
|
|
||||||
// containing package claims to have no side effects.
|
|
||||||
// Remove this when webpack adds a warning or an error for this.
|
|
||||||
// See https://github.com/webpack/webpack/issues/6571
|
|
||||||
sideEffects: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Adds support for CSS Modules, but using SASS
|
|
||||||
// using the extension .module.scss or .module.sass
|
|
||||||
{
|
|
||||||
test: sassModuleRegex,
|
|
||||||
use: getStyleLoaders(
|
|
||||||
{
|
|
||||||
importLoaders: 2,
|
|
||||||
sourceMap: isEnvProduction && shouldUseSourceMap,
|
|
||||||
modules: true,
|
|
||||||
getLocalIdent: getCSSModuleLocalIdent,
|
|
||||||
},
|
|
||||||
'sass-loader',
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
// "file" loader makes sure those assets get served by WebpackDevServer.
|
|
||||||
// When you `import` an asset, you get its (virtual) filename.
|
|
||||||
// In production, they would get copied to the `build` folder.
|
|
||||||
// This loader doesn't use a "test" so it will catch all modules
|
|
||||||
// that fall through the other loaders.
|
|
||||||
{
|
|
||||||
loader: require.resolve('file-loader'),
|
|
||||||
|
|
||||||
// Exclude `js` files to keep "css" loader working as it injects
|
|
||||||
// its runtime that would otherwise be processed through "file" loader.
|
|
||||||
// Also exclude `html` and `json` extensions so they get processed
|
|
||||||
// by webpacks internal loaders.
|
|
||||||
exclude: [ /\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/ ],
|
|
||||||
options: {
|
|
||||||
name: 'static/media/[name].[hash:8].[ext]',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ** STOP ** Are you adding a new loader?
|
|
||||||
// Make sure to add the new loader(s) before the "file" loader.
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
|
|
||||||
// Generates an `index.html` file with the <script> injected.
|
|
||||||
new HtmlWebpackPlugin(
|
|
||||||
Object.assign(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
inject: true,
|
|
||||||
template: paths.appHtml,
|
|
||||||
},
|
|
||||||
isEnvProduction
|
|
||||||
? {
|
|
||||||
minify: {
|
|
||||||
removeComments: true,
|
|
||||||
collapseWhitespace: true,
|
|
||||||
removeRedundantAttributes: true,
|
|
||||||
useShortDoctype: true,
|
|
||||||
removeEmptyAttributes: true,
|
|
||||||
removeStyleLinkTypeAttributes: true,
|
|
||||||
keepClosingSlash: true,
|
|
||||||
minifyJS: true,
|
|
||||||
minifyCSS: true,
|
|
||||||
minifyURLs: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Inlines the webpack runtime script. This script is too small to warrant
|
|
||||||
// a network request.
|
|
||||||
isEnvProduction &&
|
|
||||||
shouldInlineRuntimeChunk &&
|
|
||||||
new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [ /runtime~.+[.]js/ ]),
|
|
||||||
|
|
||||||
// Makes some environment variables available in index.html.
|
|
||||||
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
|
|
||||||
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
|
||||||
// In production, it will be an empty string unless you specify "homepage"
|
|
||||||
// in `package.json`, in which case it will be the pathname of that URL.
|
|
||||||
// In development, this will be an empty string.
|
|
||||||
new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw),
|
|
||||||
|
|
||||||
// This gives some necessary context to module not found errors, such as
|
|
||||||
// the requesting resource.
|
|
||||||
new ModuleNotFoundPlugin(paths.appPath),
|
|
||||||
|
|
||||||
// Makes some environment variables available to the JS code, for example:
|
|
||||||
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
|
|
||||||
// It is absolutely essential that NODE_ENV is set to production
|
|
||||||
// during a production build.
|
|
||||||
// Otherwise React will be compiled in the very slow development mode.
|
|
||||||
new webpack.DefinePlugin(env.stringified),
|
|
||||||
|
|
||||||
// This is necessary to emit hot updates (currently CSS only):
|
|
||||||
isEnvDevelopment && new webpack.HotModuleReplacementPlugin(),
|
|
||||||
|
|
||||||
// Watcher doesn't work well if you mistype casing in a path so we use
|
|
||||||
// a plugin that prints an error when you attempt to do this.
|
|
||||||
// See https://github.com/facebook/create-react-app/issues/240
|
|
||||||
isEnvDevelopment && new CaseSensitivePathsPlugin(),
|
|
||||||
|
|
||||||
// If you require a missing module and then `npm install` it, you still have
|
|
||||||
// to restart the development server for Webpack to discover it. This plugin
|
|
||||||
// makes the discovery automatic so you don't have to restart.
|
|
||||||
// See https://github.com/facebook/create-react-app/issues/186
|
|
||||||
isEnvDevelopment &&
|
|
||||||
new WatchMissingNodeModulesPlugin(paths.appNodeModules),
|
|
||||||
isEnvProduction &&
|
|
||||||
new MiniCssExtractPlugin({
|
|
||||||
|
|
||||||
// Options similar to the same options in webpackOptions.output
|
|
||||||
// both options are optional
|
|
||||||
filename: 'static/css/[name].[contenthash:8].css',
|
|
||||||
chunkFilename: 'static/css/[name].[contenthash:8].chunk.css',
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Generate a manifest file which contains a mapping of all asset filenames
|
|
||||||
// to their corresponding output file so that tools can pick it up without
|
|
||||||
// having to parse `index.html`.
|
|
||||||
new ManifestPlugin({
|
|
||||||
fileName: 'asset-manifest.json',
|
|
||||||
publicPath,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Moment.js is an extremely popular library that bundles large locale files
|
|
||||||
// by default due to how Webpack interprets its code. This is a practical
|
|
||||||
// solution that requires the user to opt into importing specific locales.
|
|
||||||
// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
|
|
||||||
// You can remove this if you don't use Moment.js:
|
|
||||||
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
|
|
||||||
|
|
||||||
// Generate a service worker script that will precache, and keep up to date,
|
|
||||||
// the HTML & assets that are part of the Webpack build.
|
|
||||||
isEnvProduction &&
|
|
||||||
new WorkboxWebpackPlugin.GenerateSW({
|
|
||||||
clientsClaim: true,
|
|
||||||
exclude: [ /\.map$/, /asset-manifest\.json$/ ],
|
|
||||||
importWorkboxFrom: 'cdn',
|
|
||||||
navigateFallback: `${publicUrl}/index.html`,
|
|
||||||
navigateFallbackBlacklist: [
|
|
||||||
|
|
||||||
// Exclude URLs starting with /_, as they're likely an API call
|
|
||||||
new RegExp('^/_'),
|
|
||||||
|
|
||||||
// Exclude URLs containing a dot, as they're likely a resource in
|
|
||||||
// public/ and not a SPA route
|
|
||||||
new RegExp('/[^/]+\\.[^/]+$'),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
|
|
||||||
// TypeScript type checking
|
|
||||||
useTypeScript &&
|
|
||||||
new ForkTsCheckerWebpackPlugin({
|
|
||||||
typescript: resolve.sync('typescript', {
|
|
||||||
basedir: paths.appNodeModules,
|
|
||||||
}),
|
|
||||||
async: false,
|
|
||||||
checkSyntacticErrors: true,
|
|
||||||
tsconfig: paths.appTsConfig,
|
|
||||||
compilerOptions: {
|
|
||||||
module: 'esnext',
|
|
||||||
moduleResolution: 'node',
|
|
||||||
resolveJsonModule: true,
|
|
||||||
isolatedModules: true,
|
|
||||||
noEmit: true,
|
|
||||||
jsx: 'preserve',
|
|
||||||
},
|
|
||||||
reportFiles: [
|
|
||||||
'**',
|
|
||||||
'!**/*.json',
|
|
||||||
'!**/__tests__/**',
|
|
||||||
'!**/?(*.)(spec|test).*',
|
|
||||||
'!**/src/setupProxy.*',
|
|
||||||
'!**/src/setupTests.*',
|
|
||||||
],
|
|
||||||
watch: paths.appSrc,
|
|
||||||
silent: true,
|
|
||||||
formatter: typescriptFormatter,
|
|
||||||
}),
|
|
||||||
].filter(Boolean),
|
|
||||||
|
|
||||||
// Some libraries import Node modules but don't use them in the browser.
|
|
||||||
// Tell Webpack to provide empty mocks for them so importing them works.
|
|
||||||
node: {
|
|
||||||
dgram: 'empty',
|
|
||||||
fs: 'empty',
|
|
||||||
net: 'empty',
|
|
||||||
tls: 'empty',
|
|
||||||
child_process: 'empty', // eslint-disable-line @typescript-eslint/camelcase
|
|
||||||
},
|
|
||||||
|
|
||||||
// Turn off performance processing because we utilize
|
|
||||||
// our own hints via the FileSizeReporter
|
|
||||||
performance: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const errorOverlayMiddleware = require('react-dev-utils/errorOverlayMiddleware');
|
|
||||||
const evalSourceMapMiddleware = require('react-dev-utils/evalSourceMapMiddleware');
|
|
||||||
const noopServiceWorkerMiddleware = require('react-dev-utils/noopServiceWorkerMiddleware');
|
|
||||||
const ignoredFiles = require('react-dev-utils/ignoredFiles');
|
|
||||||
const paths = require('./paths');
|
|
||||||
|
|
||||||
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
|
|
||||||
const host = process.env.HOST || '0.0.0.0';
|
|
||||||
|
|
||||||
module.exports = function(proxy, allowedHost) {
|
|
||||||
return {
|
|
||||||
|
|
||||||
// WebpackDevServer 2.4.3 introduced a security fix that prevents remote
|
|
||||||
// websites from potentially accessing local content through DNS rebinding:
|
|
||||||
// https://github.com/webpack/webpack-dev-server/issues/887
|
|
||||||
// https://medium.com/webpack/webpack-dev-server-middleware-security-issues-1489d950874a
|
|
||||||
// However, it made several existing use cases such as development in cloud
|
|
||||||
// environment or subdomains in development significantly more complicated:
|
|
||||||
// https://github.com/facebook/create-react-app/issues/2271
|
|
||||||
// https://github.com/facebook/create-react-app/issues/2233
|
|
||||||
// While we're investigating better solutions, for now we will take a
|
|
||||||
// compromise. Since our WDS configuration only serves files in the `public`
|
|
||||||
// folder we won't consider accessing them a vulnerability. However, if you
|
|
||||||
// use the `proxy` feature, it gets more dangerous because it can expose
|
|
||||||
// remote code execution vulnerabilities in backends like Django and Rails.
|
|
||||||
// So we will disable the host check normally, but enable it if you have
|
|
||||||
// specified the `proxy` setting. Finally, we let you override it if you
|
|
||||||
// really know what you're doing with a special environment variable.
|
|
||||||
disableHostCheck:
|
|
||||||
!proxy || process.env.DANGEROUSLY_DISABLE_HOST_CHECK === 'true',
|
|
||||||
|
|
||||||
// Enable gzip compression of generated files.
|
|
||||||
compress: true,
|
|
||||||
|
|
||||||
// Silence WebpackDevServer's own logs since they're generally not useful.
|
|
||||||
// It will still show compile warnings and errors with this setting.
|
|
||||||
clientLogLevel: 'none',
|
|
||||||
|
|
||||||
// By default WebpackDevServer serves physical files from current directory
|
|
||||||
// in addition to all the virtual build products that it serves from memory.
|
|
||||||
// This is confusing because those files won’t automatically be available in
|
|
||||||
// production build folder unless we copy them. However, copying the whole
|
|
||||||
// project directory is dangerous because we may expose sensitive files.
|
|
||||||
// Instead, we establish a convention that only files in `public` directory
|
|
||||||
// get served. Our build script will copy `public` into the `build` folder.
|
|
||||||
// In `index.html`, you can get URL of `public` folder with %PUBLIC_URL%:
|
|
||||||
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
|
||||||
// In JavaScript code, you can access it with `process.env.PUBLIC_URL`.
|
|
||||||
// Note that we only recommend to use `public` folder as an escape hatch
|
|
||||||
// for files like `favicon.ico`, `manifest.json`, and libraries that are
|
|
||||||
// for some reason broken when imported through Webpack. If you just want to
|
|
||||||
// use an image, put it in `src` and `import` it from JavaScript instead.
|
|
||||||
contentBase: paths.appPublic,
|
|
||||||
|
|
||||||
// By default files from `contentBase` will not trigger a page reload.
|
|
||||||
watchContentBase: true,
|
|
||||||
|
|
||||||
// Enable hot reloading server. It will provide /sockjs-node/ endpoint
|
|
||||||
// for the WebpackDevServer client so it can learn when the files were
|
|
||||||
// updated. The WebpackDevServer client is included as an entry point
|
|
||||||
// in the Webpack development configuration. Note that only changes
|
|
||||||
// to CSS are currently hot reloaded. JS changes will refresh the browser.
|
|
||||||
hot: true,
|
|
||||||
|
|
||||||
// It is important to tell WebpackDevServer to use the same "root" path
|
|
||||||
// as we specified in the config. In development, we always serve from /.
|
|
||||||
publicPath: '/',
|
|
||||||
|
|
||||||
// WebpackDevServer is noisy by default so we emit custom message instead
|
|
||||||
// by listening to the compiler events with `compiler.hooks[...].tap` calls above.
|
|
||||||
quiet: true,
|
|
||||||
|
|
||||||
// Reportedly, this avoids CPU overload on some systems.
|
|
||||||
// https://github.com/facebook/create-react-app/issues/293
|
|
||||||
// src/node_modules is not ignored to support absolute imports
|
|
||||||
// https://github.com/facebook/create-react-app/issues/1065
|
|
||||||
watchOptions: {
|
|
||||||
ignored: ignoredFiles(paths.appSrc),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Enable HTTPS if the HTTPS environment variable is set to 'true'
|
|
||||||
https: protocol === 'https',
|
|
||||||
host,
|
|
||||||
overlay: false,
|
|
||||||
historyApiFallback: {
|
|
||||||
|
|
||||||
// Paths with dots should still use the history fallback.
|
|
||||||
// See https://github.com/facebook/create-react-app/issues/387.
|
|
||||||
disableDotRule: true,
|
|
||||||
},
|
|
||||||
public: allowedHost,
|
|
||||||
proxy,
|
|
||||||
before(app, server) {
|
|
||||||
if (fs.existsSync(paths.proxySetup)) {
|
|
||||||
// This registers user provided middleware for proxy reasons
|
|
||||||
require(paths.proxySetup)(app);
|
|
||||||
}
|
|
||||||
|
|
||||||
// This lets us fetch source contents from webpack for the error overlay
|
|
||||||
app.use(evalSourceMapMiddleware(server));
|
|
||||||
|
|
||||||
// This lets us open files from the runtime error overlay.
|
|
||||||
app.use(errorOverlayMiddleware());
|
|
||||||
|
|
||||||
// This service worker file is effectively a 'no-op' that will reset any
|
|
||||||
// previous service worker registered for the same host:port combination.
|
|
||||||
// We do this in development to avoid hitting the production cache if
|
|
||||||
// it used the same host and port.
|
|
||||||
// https://github.com/facebook/create-react-app/issues/2272#issuecomment-302832432
|
|
||||||
app.use(noopServiceWorkerMiddleware());
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -3,7 +3,7 @@ version: '3'
|
|||||||
services:
|
services:
|
||||||
shlink_web_client_node:
|
shlink_web_client_node:
|
||||||
container_name: shlink_web_client_node
|
container_name: shlink_web_client_node
|
||||||
image: node:14.15.0-alpine
|
image: node:16.13-alpine
|
||||||
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/home/shlink/www
|
- ./:/home/shlink/www
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# How to handle setting auto-connect on servers
|
||||||
|
|
||||||
|
* Status: Accepted
|
||||||
|
* Date: 2021-10-31
|
||||||
|
|
||||||
|
## Context and problem statement
|
||||||
|
|
||||||
|
A new feature has been requested, to allow auto-connecting to servers. The request specifically mentioned doing it automatically when there's only one server configured, but it can be extended a bit to allow setting an "auto-connect" server, regardless the number of configured servers.
|
||||||
|
|
||||||
|
At all times, no more than one server can be set to "auto-connect" simultaneously. Setting a new one will effectively unset the previous one, if any.
|
||||||
|
|
||||||
|
## Considered option
|
||||||
|
|
||||||
|
* Auto-connect only of there's a single server configured.
|
||||||
|
* Allow to set the server as "auto-connect" during server creation, edition or import.
|
||||||
|
* Allow to set the server as "auto-connect" on a separated flow, where the full list of servers can be handled.
|
||||||
|
|
||||||
|
## Decision outcome
|
||||||
|
|
||||||
|
In order to make it more flexible, any server will be allowed to be set as "auto-connect", regardless the amount of configured servers.
|
||||||
|
|
||||||
|
Auto-connect will be handled from the new "Manage servers" section.
|
||||||
|
|
||||||
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
|
### Only one server
|
||||||
|
|
||||||
|
* Good:
|
||||||
|
* Does not require extending models, and the logic to auto-connect is based on the amount of configured servers.
|
||||||
|
* Bad:
|
||||||
|
* It's not flexible enough.
|
||||||
|
* Makes the app behave differently depending on the amount of configured servers, making it confusing.
|
||||||
|
|
||||||
|
### Auto-connect configured on existing creation/edition/import
|
||||||
|
|
||||||
|
* Good:
|
||||||
|
* Does not require creating a new section to handle "auto-connect".
|
||||||
|
* Bad:
|
||||||
|
* Requires extending the server model with a new prop.
|
||||||
|
* It's much harder to ensure data consistency, as we need to ensure only one server is set to "auto-connect".
|
||||||
|
* On import, many servers might have been set to "auto-connect". The expected behavior there can be unclear.
|
||||||
|
|
||||||
|
### Auto-connect configured on new section
|
||||||
|
|
||||||
|
* Good:
|
||||||
|
* It's much easier to ensure data consistency.
|
||||||
|
* It's much more clear and predictable, as the UI shows which is the server configured as auto-connect.
|
||||||
|
* We have controls in a single place to set/unset auto connect on servers, allowing only the proper option based on current state for every server.
|
||||||
|
* Bad:
|
||||||
|
* Requires extending the server model with a new prop.
|
||||||
|
* Requires creating a new section to handle "auto-connect".
|
||||||
5
docs/adr/README.md
Normal file
5
docs/adr/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Architectural Decision Records
|
||||||
|
|
||||||
|
Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome.
|
||||||
|
|
||||||
|
* [2021-10-31 How to handle setting auto-connect on servers](2021-10-31-how-to-handle-setting-auto-connect-on-servers.md)
|
||||||
@@ -1,44 +1,41 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
coverageDirectory: '<rootDir>/coverage',
|
coverageDirectory: '<rootDir>/coverage',
|
||||||
collectCoverageFrom: [
|
collectCoverageFrom: [
|
||||||
'src/**/*.{js,ts,tsx}',
|
'src/**/*.{ts,tsx}',
|
||||||
'!src/registerServiceWorker.js',
|
'!src/*.{ts,tsx}',
|
||||||
'!src/index.ts',
|
|
||||||
'!src/reducers/index.ts',
|
'!src/reducers/index.ts',
|
||||||
'!src/**/provideServices.ts',
|
'!src/**/provideServices.ts',
|
||||||
'!src/container/*.ts',
|
'!src/container/*.ts',
|
||||||
],
|
],
|
||||||
resolver: 'jest-pnp-resolver',
|
coverageThreshold: {
|
||||||
setupFiles: [
|
global: {
|
||||||
'react-app-polyfill/jsdom',
|
statements: 90,
|
||||||
'<rootDir>/config/setupEnzyme.js',
|
branches: 80,
|
||||||
],
|
functions: 85,
|
||||||
testMatch: [ '<rootDir>/test/**/*.test.{js,jsx,ts,tsx}' ],
|
lines: 90,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setupFiles: ['<rootDir>/config/jest/setupBeforeEnzyme.js', '<rootDir>/config/jest/setupEnzyme.js'],
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/config/jest/setupTests.ts'],
|
||||||
|
testMatch: ['<rootDir>/test/**/*.test.{ts,tsx}'],
|
||||||
testEnvironment: 'jsdom',
|
testEnvironment: 'jsdom',
|
||||||
testURL: 'http://localhost',
|
testEnvironmentOptions: {
|
||||||
|
url: 'http://localhost',
|
||||||
|
},
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.(ts|tsx|js|jsx|mjs)$': '<rootDir>/node_modules/babel-jest',
|
'^.+\\.(ts|tsx|js)$': '<rootDir>/node_modules/babel-jest',
|
||||||
'^.+\\.css$': '<rootDir>/config/jest/cssTransform.js',
|
'^.+\\.scss$': '<rootDir>/config/jest/cssTransform.js',
|
||||||
'^(?!.*\\.(ts|tsx|js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
|
'^(?!.*\\.(ts|tsx|js|json|scss)$)': '<rootDir>/config/jest/fileTransform.js',
|
||||||
},
|
},
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
|
'<rootDir>/.stryker-tmp',
|
||||||
'^.+\\.module\\.(css|sass|scss)$',
|
'node_modules\/(?!(\@react-leaflet|react-leaflet|leaflet|react-chartjs-2|react-colorful)\/)',
|
||||||
|
'^.+\\.module\\.scss$',
|
||||||
],
|
],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^react-native$': 'react-native-web',
|
'^.+\\.module\\.scss$': 'identity-obj-proxy',
|
||||||
'^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy',
|
'react-chartjs-2': '<rootDir>/node_modules/react-chartjs-2/dist/index.js',
|
||||||
|
'uuid': '<rootDir>/node_modules/uuid/dist/index.js',
|
||||||
},
|
},
|
||||||
moduleFileExtensions: [
|
moduleFileExtensions: ['js', 'ts', 'tsx', 'json'],
|
||||||
'web.js',
|
|
||||||
'js',
|
|
||||||
'web.ts',
|
|
||||||
'ts',
|
|
||||||
'web.tsx',
|
|
||||||
'tsx',
|
|
||||||
'json',
|
|
||||||
'web.jsx',
|
|
||||||
'jsx',
|
|
||||||
'node',
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
56093
package-lock.json
generated
56093
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
236
package.json
236
package.json
@@ -6,163 +6,107 @@
|
|||||||
"repository": "https://github.com/shlinkio/shlink-web-client",
|
"repository": "https://github.com/shlinkio/shlink-web-client",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "npm run lint:js && npm run lint:css",
|
"lint": "npm run lint:css && npm run lint:js",
|
||||||
"lint:js": "eslint --ext .js,.ts,.tsx src test scripts config",
|
|
||||||
"lint:js:fix": "npm run lint:js -- --fix",
|
|
||||||
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
||||||
|
"lint:js": "eslint --ext .js,.ts,.tsx src test",
|
||||||
|
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
|
||||||
"lint:css:fix": "npm run lint:css -- --fix",
|
"lint:css:fix": "npm run lint:css -- --fix",
|
||||||
"start": "node scripts/start.js",
|
"lint:js:fix": "npm run lint:js -- --fix",
|
||||||
"serve:build": "serve ./build",
|
"start": "DISABLE_ESLINT_PLUGIN=true react-scripts start",
|
||||||
"build": "node scripts/build.js",
|
"build": "DISABLE_ESLINT_PLUGIN=true react-scripts build && node scripts/replace-version.js",
|
||||||
"test": "node scripts/test.js --env=jsdom --colors",
|
"build:dist": "npm run build && node scripts/create-dist-file.js",
|
||||||
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
"build:serve": "serve -p 5000 ./build",
|
||||||
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
|
"test": "jest --env=jsdom --colors --verbose",
|
||||||
"mutate": "./node_modules/.bin/stryker run",
|
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
|
||||||
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES"
|
"test:ci": "npm run test:coverage -- --coverageReporters=clover --ci",
|
||||||
|
"test:pretty": "npm run test:coverage -- --coverageReporters=html",
|
||||||
|
"mutate": "./node_modules/.bin/stryker run --concurrency 4 --ignoreStatic"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^5.14.0",
|
"@fortawesome/fontawesome-free": "^6.0.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^1.2.30",
|
"@fortawesome/fontawesome-svg-core": "^1.3.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^5.14.0",
|
"@fortawesome/free-regular-svg-icons": "^6.0.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.14.0",
|
"@fortawesome/free-solid-svg-icons": "^6.0.0",
|
||||||
"@fortawesome/react-fontawesome": "^0.1.11",
|
"@fortawesome/react-fontawesome": "^0.1.17",
|
||||||
"array-filter": "^1.0.0",
|
"axios": "^0.26.0",
|
||||||
"array-map": "^0.0.0",
|
"bootstrap": "^5.1.3",
|
||||||
"array-reduce": "^0.0.0",
|
|
||||||
"axios": "^0.20.0",
|
|
||||||
"bootstrap": "^4.5.2",
|
|
||||||
"bottlejs": "^2.0.0",
|
"bottlejs": "^2.0.0",
|
||||||
"bowser": "^2.10.0",
|
"bowser": "^2.11.0",
|
||||||
"chart.js": "^2.9.3",
|
"chart.js": "^3.7.1",
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.3.1",
|
||||||
"compare-versions": "^3.6.0",
|
"compare-versions": "^4.1.3",
|
||||||
"csvjson": "^5.1.0",
|
"csvtojson": "^2.0.10",
|
||||||
"event-source-polyfill": "^1.0.17",
|
"date-fns": "^2.28.0",
|
||||||
|
"event-source-polyfill": "^1.0.25",
|
||||||
|
"json2csv": "^5.0.7",
|
||||||
"leaflet": "^1.7.1",
|
"leaflet": "^1.7.1",
|
||||||
"moment": "^2.27.0",
|
"qs": "^6.9.6",
|
||||||
"promise": "^8.0.3",
|
"ramda": "^0.27.2",
|
||||||
"qs": "^6.9.4",
|
"react": "^18.1.0",
|
||||||
"ramda": "^0.27.1",
|
"react-chartjs-2": "^4.1.0",
|
||||||
"react": "^16.13.1",
|
"react-colorful": "^5.5.1",
|
||||||
"react-autosuggest": "^10.0.2",
|
"react-copy-to-clipboard": "^5.0.4",
|
||||||
"react-chartjs-2": "^2.10.0",
|
"react-datepicker": "^4.7.0",
|
||||||
"react-color": "^2.18.1",
|
"react-dom": "^18.1.0",
|
||||||
"react-copy-to-clipboard": "^5.0.2",
|
"react-external-link": "^2.0.0",
|
||||||
"react-datepicker": "~1.5.0",
|
"react-leaflet": "^4.0.0",
|
||||||
"react-dom": "^16.13.1",
|
"react-redux": "^8.0.0",
|
||||||
"react-external-link": "^1.1.1",
|
"react-router-dom": "^6.3.0",
|
||||||
"react-leaflet": "^2.7.0",
|
"react-swipeable": "^6.2.0",
|
||||||
"react-moment": "^0.9.7",
|
"react-tag-autocomplete": "^6.3.0",
|
||||||
"react-redux": "^7.2.1",
|
"reactstrap": "^9.0.1",
|
||||||
"react-router-dom": "^5.2.0",
|
"redux": "^4.2.0",
|
||||||
"react-swipeable": "^5.5.1",
|
"redux-localstorage-simple": "^2.4.1",
|
||||||
"react-tagsinput": "^3.19.0",
|
"redux-thunk": "^2.4.1",
|
||||||
"reactstrap": "^8.0.1",
|
"stream": "^0.0.2",
|
||||||
"redux": "^4.0.4",
|
"uuid": "^8.3.2",
|
||||||
"redux-localstorage-simple": "^2.2.0",
|
"workbox-core": "^6.5.1",
|
||||||
"redux-thunk": "^2.3.0",
|
"workbox-expiration": "^6.5.1",
|
||||||
"uuid": "^3.3.3"
|
"workbox-precaching": "^6.5.1",
|
||||||
|
"workbox-routing": "^6.5.1",
|
||||||
|
"workbox-strategies": "^6.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.6.2",
|
"@shlinkio/eslint-config-js-coding-standard": "~2.0.2",
|
||||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.4",
|
"@shlinkio/stylelint-config-css-coding-standard": "~1.0.1",
|
||||||
"@babel/plugin-proposal-optional-chaining": "^7.11.0",
|
"@stryker-mutator/core": "^6.0.2",
|
||||||
"@shlinkio/eslint-config-js-coding-standard": "~1.1.0",
|
"@stryker-mutator/jest-runner": "^6.0.2",
|
||||||
"@stryker-mutator/core": "^3.2.4",
|
"@stryker-mutator/typescript-checker": "^6.0.2",
|
||||||
"@stryker-mutator/typescript": "^3.2.4",
|
"@testing-library/jest-dom": "^5.16.4",
|
||||||
"@stryker-mutator/jest-runner": "^3.2.4",
|
"@testing-library/react": "^13.1.1",
|
||||||
"@svgr/webpack": "^4.3.3",
|
"@testing-library/user-event": "^14.1.1",
|
||||||
"@types/chart.js": "^2.9.24",
|
"@types/enzyme": "^3.10.11",
|
||||||
"@types/classnames": "^2.2.10",
|
"@types/jest": "^27.4.1",
|
||||||
"@types/enzyme": "^3.10.5",
|
"@types/json2csv": "^5.0.3",
|
||||||
"@types/jest": "^26.0.10",
|
"@types/leaflet": "^1.7.9",
|
||||||
"@types/leaflet": "^1.5.17",
|
"@types/qs": "^6.9.7",
|
||||||
"@types/moment": "^2.13.0",
|
"@types/ramda": "0.27.38",
|
||||||
"@types/qs": "^6.9.4",
|
"@types/react": "^18.0.8",
|
||||||
"@types/ramda": "^0.27.14",
|
"@types/react-color": "^3.0.6",
|
||||||
"@types/react": "^16.9.46",
|
"@types/react-copy-to-clipboard": "^5.0.2",
|
||||||
"@types/react-autosuggest": "^10.0.0",
|
"@types/react-datepicker": "^4.3.4",
|
||||||
"@types/react-color": "^2.17.4",
|
"@types/react-dom": "^18.0.3",
|
||||||
"@types/react-copy-to-clipboard": "^4.3.0",
|
"@types/react-tag-autocomplete": "^6.1.1",
|
||||||
"@types/react-datepicker": "~1.8.0",
|
"@types/uuid": "^8.3.4",
|
||||||
"@types/react-dom": "^16.9.8",
|
"@wojtekmaj/enzyme-adapter-react-17": "0.6.5",
|
||||||
"@types/react-leaflet": "^2.5.2",
|
"adm-zip": "^0.5.9",
|
||||||
"@types/react-redux": "^7.1.9",
|
"babel-jest": "^28.0.3",
|
||||||
"@types/react-router-dom": "^5.1.5",
|
"chalk": "^5.0.1",
|
||||||
"@types/react-tagsinput": "^3.19.7",
|
|
||||||
"@types/reactstrap": "^8.5.1",
|
|
||||||
"@types/uuid": "^8.3.0",
|
|
||||||
"adm-zip": "^0.4.13",
|
|
||||||
"autoprefixer": "^9.6.3",
|
|
||||||
"babel-core": "7.0.0-bridge.0",
|
|
||||||
"babel-jest": "^26.3.0",
|
|
||||||
"babel-loader": "^8.0.6",
|
|
||||||
"babel-plugin-named-asset-import": "^0.3.4",
|
|
||||||
"babel-preset-react-app": "^9.0.2",
|
|
||||||
"babel-runtime": "^6.26.0",
|
|
||||||
"bfj": "^7.0.1",
|
|
||||||
"case-sensitive-paths-webpack-plugin": "^2.2.0",
|
|
||||||
"chalk": "^2.4.2",
|
|
||||||
"css-loader": "^3.2.0",
|
|
||||||
"dart-sass": "^1.25.0",
|
|
||||||
"dotenv": "^8.1.0",
|
|
||||||
"dotenv-expand": "^5.1.0",
|
|
||||||
"enzyme": "^3.11.0",
|
"enzyme": "^3.11.0",
|
||||||
"enzyme-adapter-react-16": "^1.15.2",
|
"eslint": "^8.12.0",
|
||||||
"eslint": "^6.8.0",
|
|
||||||
"eslint-loader": "^3.0.2",
|
|
||||||
"file-loader": "^4.2.0",
|
|
||||||
"fork-ts-checker-webpack-plugin-alt": "^0.4.14",
|
|
||||||
"fs-extra": "^8.1.0",
|
|
||||||
"html-webpack-plugin": "^4.0.0-beta.8",
|
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^26.4.2",
|
"jest": "^28.0.3",
|
||||||
"jest-pnp-resolver": "^1.2.2",
|
"jest-canvas-mock": "^2.4.0",
|
||||||
"jest-resolve": "^26.4.0",
|
"jest-environment-jsdom": "^28.0.2",
|
||||||
"mini-css-extract-plugin": "^0.8.0",
|
"react-scripts": "^5.0.1",
|
||||||
"object-assign": "^4.1.1",
|
"resize-observer-polyfill": "^1.5.1",
|
||||||
"ocular.js": "^0.1.0",
|
"sass": "^1.49.9",
|
||||||
"optimize-css-assets-webpack-plugin": "^5.0.3",
|
"serve": "^13.0.2",
|
||||||
"pnp-webpack-plugin": "^1.5.0",
|
"stryker-cli": "^1.0.2",
|
||||||
"postcss": "^7.0.18",
|
"stylelint": "^14.8.2",
|
||||||
"postcss-flexbugs-fixes": "^4.1.0",
|
|
||||||
"postcss-loader": "^3.0.0",
|
|
||||||
"postcss-preset-env": "^6.7.0",
|
|
||||||
"postcss-safe-parser": "^4.0.1",
|
|
||||||
"raf": "^3.4.1",
|
|
||||||
"react-app-polyfill": "^1.0.6",
|
|
||||||
"react-dev-utils": "^9.1.0",
|
|
||||||
"resolve": "^1.12.0",
|
|
||||||
"sass": "^1.28.0",
|
|
||||||
"sass-loader": "^10.0.2",
|
|
||||||
"serve": "^11.3.2",
|
|
||||||
"stryker-cli": "^1.0.0",
|
|
||||||
"style-loader": "^1.2.1",
|
|
||||||
"stylelint": "^13.7.0",
|
|
||||||
"stylelint-config-adidas": "^1.3.0",
|
|
||||||
"stylelint-config-adidas-bem": "^1.2.0",
|
|
||||||
"stylelint-config-recommended-scss": "^4.2.0",
|
|
||||||
"stylelint-scss": "^3.18.0",
|
|
||||||
"sw-precache-webpack-plugin": "^0.11.5",
|
|
||||||
"terser-webpack-plugin": "^2.1.2",
|
|
||||||
"ts-jest": "^26.3.0",
|
|
||||||
"ts-mockery": "^1.2.0",
|
"ts-mockery": "^1.2.0",
|
||||||
"typescript": "^3.9.7",
|
"typescript": "^4.6.2",
|
||||||
"url-loader": "^2.2.0",
|
"webpack": "^5.70.0"
|
||||||
"webpack": "^4.41.0",
|
|
||||||
"webpack-dev-server": "^3.8.2",
|
|
||||||
"webpack-manifest-plugin": "^2.2.0",
|
|
||||||
"whatwg-fetch": "^3.0.0",
|
|
||||||
"workbox-webpack-plugin": "^4.3.1"
|
|
||||||
},
|
|
||||||
"babel": {
|
|
||||||
"presets": [
|
|
||||||
"react-app"
|
|
||||||
],
|
|
||||||
"plugins": [
|
|
||||||
"@babel/plugin-proposal-optional-chaining",
|
|
||||||
"@babel/plugin-proposal-nullish-coalescing-operator"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
">0.2%",
|
">0.2%",
|
||||||
|
|||||||
227
scripts/build.js
227
scripts/build.js
@@ -1,227 +0,0 @@
|
|||||||
/* eslint-disable no-console, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
|
|
||||||
|
|
||||||
// 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';
|
|
||||||
|
|
||||||
// Makes the script crash on unhandled rejections instead of silently
|
|
||||||
// ignoring them. In the future, promise rejections that are not handled will
|
|
||||||
// terminate the Node.js process with a non-zero exit code.
|
|
||||||
process.on('unhandledRejection', (err) => {
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure environment variables are read.
|
|
||||||
require('../config/env');
|
|
||||||
|
|
||||||
const chalk = require('chalk');
|
|
||||||
const fs = require('fs-extra');
|
|
||||||
const webpack = require('webpack');
|
|
||||||
const bfj = require('bfj');
|
|
||||||
const AdmZip = require('adm-zip');
|
|
||||||
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
|
|
||||||
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
|
|
||||||
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
|
|
||||||
const printBuildError = require('react-dev-utils/printBuildError');
|
|
||||||
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
|
|
||||||
const paths = require('../config/paths');
|
|
||||||
const configFactory = require('../config/webpack.config');
|
|
||||||
|
|
||||||
const { measureFileSizesBeforeBuild, printFileSizesAfterBuild } = FileSizeReporter;
|
|
||||||
|
|
||||||
// These sizes are pretty large. We'll warn for bundles exceeding them.
|
|
||||||
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; // eslint-disable-line
|
|
||||||
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024; // eslint-disable-line
|
|
||||||
|
|
||||||
const isInteractive = process.stdout.isTTY;
|
|
||||||
|
|
||||||
// Warn and crash if required files are missing
|
|
||||||
if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process CLI arguments
|
|
||||||
const argvSliceStart = 2;
|
|
||||||
const argv = process.argv.slice(argvSliceStart);
|
|
||||||
const writeStatsJson = argv.includes('--stats');
|
|
||||||
const withoutDist = argv.includes('--no-dist');
|
|
||||||
const { version, hasVersion } = getVersionFromArgs(argv);
|
|
||||||
|
|
||||||
// Generate configuration
|
|
||||||
const config = configFactory('production');
|
|
||||||
|
|
||||||
checkBrowsers(paths.appPath, isInteractive)
|
|
||||||
.then(() =>
|
|
||||||
|
|
||||||
// First, read the current file sizes in build directory.
|
|
||||||
// This lets us display how much they changed later.
|
|
||||||
measureFileSizesBeforeBuild(paths.appBuild))
|
|
||||||
.then((previousFileSizes) => {
|
|
||||||
// Remove all content but keep the directory so that
|
|
||||||
// if you're in it, you don't end up in Trash
|
|
||||||
fs.emptyDirSync(paths.appBuild);
|
|
||||||
|
|
||||||
// Merge with the public folder
|
|
||||||
copyPublicFolder();
|
|
||||||
|
|
||||||
// Start the webpack build
|
|
||||||
return build(previousFileSizes);
|
|
||||||
})
|
|
||||||
.then(
|
|
||||||
({ stats, previousFileSizes, warnings }) => {
|
|
||||||
if (warnings.length) {
|
|
||||||
console.log(chalk.yellow('Compiled with warnings.\n'));
|
|
||||||
console.log(warnings.join('\n\n'));
|
|
||||||
console.log(
|
|
||||||
`\nSearch for the ${
|
|
||||||
chalk.underline(chalk.yellow('keywords'))
|
|
||||||
} to learn more about each warning.`,
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`To ignore, add ${
|
|
||||||
chalk.cyan('// eslint-disable-next-line')
|
|
||||||
} to the line before.\n`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(chalk.green('Compiled successfully.\n'));
|
|
||||||
hasVersion && replaceVersionPlaceholder(version);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('File sizes after gzip:\n');
|
|
||||||
printFileSizesAfterBuild(
|
|
||||||
stats,
|
|
||||||
previousFileSizes,
|
|
||||||
paths.appBuild,
|
|
||||||
WARN_AFTER_BUNDLE_GZIP_SIZE,
|
|
||||||
WARN_AFTER_CHUNK_GZIP_SIZE,
|
|
||||||
);
|
|
||||||
console.log();
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
console.log(chalk.red('Failed to compile.\n'));
|
|
||||||
printBuildError(err);
|
|
||||||
process.exit(1);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.then(() => hasVersion && !withoutDist && zipDist(version))
|
|
||||||
.catch((err) => {
|
|
||||||
if (err && err.message) {
|
|
||||||
console.log(err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create the production build and print the deployment instructions.
|
|
||||||
function build(previousFileSizes) {
|
|
||||||
console.log('Creating an optimized production build...');
|
|
||||||
|
|
||||||
const compiler = webpack(config);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
compiler.run((err, stats) => {
|
|
||||||
let messages;
|
|
||||||
|
|
||||||
if (err) {
|
|
||||||
if (!err.message) {
|
|
||||||
return reject(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
messages = formatWebpackMessages({
|
|
||||||
errors: [ err.message ],
|
|
||||||
warnings: [],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
messages = formatWebpackMessages(
|
|
||||||
stats.toJson({ all: false, warnings: true, errors: true }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (messages.errors.length) {
|
|
||||||
// Only keep the first error. Others are often indicative
|
|
||||||
// of the same problem, but confuse the reader with noise.
|
|
||||||
if (messages.errors.length > 1) {
|
|
||||||
messages.errors.length = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return reject(new Error(messages.errors.join('\n\n')));
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
process.env.CI &&
|
|
||||||
(typeof process.env.CI !== 'string' ||
|
|
||||||
process.env.CI.toLowerCase() !== 'false') &&
|
|
||||||
messages.warnings.length
|
|
||||||
) {
|
|
||||||
console.log(
|
|
||||||
chalk.yellow(
|
|
||||||
'\nTreating warnings as errors because process.env.CI = true.\n' +
|
|
||||||
'Most CI servers set it automatically.\n',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return reject(new Error(messages.warnings.join('\n\n')));
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveArgs = {
|
|
||||||
stats,
|
|
||||||
previousFileSizes,
|
|
||||||
warnings: messages.warnings,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (writeStatsJson) {
|
|
||||||
return bfj // eslint-disable-line promise/no-promise-in-callback
|
|
||||||
.write(`${paths.appBuild}/bundle-stats.json`, stats.toJson())
|
|
||||||
.then(() => resolve(resolveArgs))
|
|
||||||
.catch((error) => reject(new Error(error)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolve(resolveArgs);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyPublicFolder() {
|
|
||||||
fs.copySync(paths.appPublic, paths.appBuild, {
|
|
||||||
dereference: true,
|
|
||||||
filter: (file) => file !== paths.appHtml,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function zipDist(version) {
|
|
||||||
const versionFileName = `./dist/shlink-web-client_${version}_dist.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
zip.addLocalFolder('./build', `shlink-web-client_${version}_dist`);
|
|
||||||
zip.writeZip(versionFileName);
|
|
||||||
console.log(chalk.green('Dist file properly generated'));
|
|
||||||
} catch (e) {
|
|
||||||
console.log(chalk.red('An error occurred while generating dist file'));
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
console.log();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVersionFromArgs(argv) {
|
|
||||||
const [ version ] = argv;
|
|
||||||
|
|
||||||
return { version, hasVersion: !!version };
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceVersionPlaceholder(version) {
|
|
||||||
const staticJsFilesPath = './build/static/js';
|
|
||||||
const versionPlaceholder = '%_VERSION_%';
|
|
||||||
|
|
||||||
const isMainFile = (file) => file.startsWith('main.') && file.endsWith('.js');
|
|
||||||
const [ mainJsFile ] = fs.readdirSync(staticJsFilesPath).filter(isMainFile);
|
|
||||||
const filePath = `${staticJsFilesPath}/${mainJsFile}`;
|
|
||||||
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
|
||||||
const replaced = fileContent.replace(versionPlaceholder, version);
|
|
||||||
|
|
||||||
fs.writeFileSync(filePath, replaced, 'utf-8');
|
|
||||||
}
|
|
||||||
34
scripts/create-dist-file.js
Normal file
34
scripts/create-dist-file.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// 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';
|
||||||
|
|
||||||
|
const chalk = require('chalk');
|
||||||
|
const AdmZip = require('adm-zip');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
function zipDist(version) {
|
||||||
|
const versionFileName = `./dist/shlink-web-client_${version}_dist.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
zip.addLocalFolder('./build', `shlink-web-client_${version}_dist`);
|
||||||
|
zip.writeZip(versionFileName);
|
||||||
|
console.log(chalk.green('Dist file properly generated'));
|
||||||
|
} catch (e) {
|
||||||
|
console.log(chalk.red('An error occurred while generating dist file'));
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = process.env.VERSION;
|
||||||
|
|
||||||
|
if (version) {
|
||||||
|
zipDist(version);
|
||||||
|
}
|
||||||
@@ -5,12 +5,12 @@ set -ex
|
|||||||
PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64"
|
PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64"
|
||||||
DOCKER_IMAGE="shlinkio/shlink-web-client"
|
DOCKER_IMAGE="shlinkio/shlink-web-client"
|
||||||
|
|
||||||
if [[ "$GITHUB_REF" == *"main"* ]]; then
|
if [[ "$GITHUB_REF" == *"develop"* ]]; then
|
||||||
docker buildx build --push \
|
docker buildx build --push \
|
||||||
--platform ${PLATFORMS} \
|
--platform ${PLATFORMS} \
|
||||||
-t ${DOCKER_IMAGE}:latest .
|
-t ${DOCKER_IMAGE}:latest .
|
||||||
|
|
||||||
# If ref is not main, then this is a tag. Build that docker tag and also "stable"
|
# If ref is not develop, then this is a tag. Build that docker tag and also "stable"
|
||||||
else
|
else
|
||||||
VERSION=${GITHUB_REF#refs/tags/v}
|
VERSION=${GITHUB_REF#refs/tags/v}
|
||||||
TAGS="-t ${DOCKER_IMAGE}:${VERSION}"
|
TAGS="-t ${DOCKER_IMAGE}:${VERSION}"
|
||||||
|
|||||||
16
scripts/docker/servers_from_env.sh
Executable file
16
scripts/docker/servers_from_env.sh
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
ME=$(basename $0)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_single_shlink_server
|
||||||
|
|
||||||
|
exit 0
|
||||||
20
scripts/replace-version.js
Normal file
20
scripts/replace-version.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
function replaceVersionPlaceholder(version) {
|
||||||
|
const staticJsFilesPath = './build/static/js';
|
||||||
|
const versionPlaceholder = '%_VERSION_%';
|
||||||
|
|
||||||
|
const isMainFile = (file) => file.startsWith('main.') && file.endsWith('.js');
|
||||||
|
const [ mainJsFile ] = fs.readdirSync(staticJsFilesPath).filter(isMainFile);
|
||||||
|
const filePath = `${staticJsFilesPath}/${mainJsFile}`;
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const replaced = fileContent.replace(versionPlaceholder, version);
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, replaced, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = process.env.VERSION;
|
||||||
|
|
||||||
|
if (version) {
|
||||||
|
replaceVersionPlaceholder(version);
|
||||||
|
}
|
||||||
13
scripts/set-homepage.js
Normal file
13
scripts/set-homepage.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const [ homepage ] = argv;
|
||||||
|
|
||||||
|
if (!homepage) {
|
||||||
|
throw new Error('Homepage has to be provided as the first arg for this script');
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageJsonPath = `${__dirname}/../package.json`;
|
||||||
|
const packageJson = require(packageJsonPath);
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
packageJson.homepage = homepage;
|
||||||
|
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
||||||
125
scripts/start.js
125
scripts/start.js
@@ -1,125 +0,0 @@
|
|||||||
/* eslint-disable no-console, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
|
|
||||||
|
|
||||||
// Do this as the first thing so that any code reading it knows the right env.
|
|
||||||
process.env.BABEL_ENV = 'development';
|
|
||||||
process.env.NODE_ENV = 'development';
|
|
||||||
|
|
||||||
// Makes the script crash on unhandled rejections instead of silently
|
|
||||||
// ignoring them. In the future, promise rejections that are not handled will
|
|
||||||
// terminate the Node.js process with a non-zero exit code.
|
|
||||||
process.on('unhandledRejection', (err) => {
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure environment variables are read.
|
|
||||||
require('../config/env');
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const chalk = require('chalk');
|
|
||||||
const webpack = require('webpack');
|
|
||||||
const WebpackDevServer = require('webpack-dev-server');
|
|
||||||
const clearConsole = require('react-dev-utils/clearConsole');
|
|
||||||
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
|
|
||||||
const {
|
|
||||||
choosePort,
|
|
||||||
createCompiler,
|
|
||||||
prepareProxy,
|
|
||||||
prepareUrls,
|
|
||||||
} = require('react-dev-utils/WebpackDevServerUtils');
|
|
||||||
const openBrowser = require('react-dev-utils/openBrowser');
|
|
||||||
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
|
|
||||||
const paths = require('../config/paths');
|
|
||||||
const configFactory = require('../config/webpack.config');
|
|
||||||
const createDevServerConfig = require('../config/webpackDevServer.config');
|
|
||||||
|
|
||||||
const useYarn = fs.existsSync(paths.yarnLockFile);
|
|
||||||
const isInteractive = process.stdout.isTTY;
|
|
||||||
|
|
||||||
// Warn and crash if required files are missing
|
|
||||||
if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tools like Cloud9 rely on this.
|
|
||||||
const DEFAULT_PORT = 3000;
|
|
||||||
const PORT = parseInt(process.env.PORT) || DEFAULT_PORT;
|
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
|
||||||
|
|
||||||
if (process.env.HOST) {
|
|
||||||
console.log(
|
|
||||||
chalk.cyan(
|
|
||||||
`Attempting to bind to HOST environment variable: ${chalk.yellow(
|
|
||||||
chalk.bold(process.env.HOST),
|
|
||||||
)}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
'If this was unintentional, check that you haven\'t mistakenly set it in your shell.',
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`Learn more here: ${chalk.yellow('http://bit.ly/CRA-advanced-config')}`,
|
|
||||||
);
|
|
||||||
console.log();
|
|
||||||
}
|
|
||||||
|
|
||||||
checkBrowsers(paths.appPath, isInteractive)
|
|
||||||
.then(() =>
|
|
||||||
|
|
||||||
// We attempt to use the default port but if it is busy, we offer the user to
|
|
||||||
// run on a different port. `choosePort()` Promise resolves to the next free port.
|
|
||||||
choosePort(HOST, PORT))
|
|
||||||
.then((port) => {
|
|
||||||
if (port === null) {
|
|
||||||
// We have not found a port.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = configFactory('development');
|
|
||||||
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
|
|
||||||
const appName = require(paths.appPackageJson).name;
|
|
||||||
|
|
||||||
const urls = prepareUrls(protocol, HOST, port);
|
|
||||||
|
|
||||||
// Create a webpack compiler that is configured with custom messages.
|
|
||||||
const compiler = createCompiler({ webpack, config, appName, urls, useYarn });
|
|
||||||
|
|
||||||
// Load proxy config
|
|
||||||
const proxySetting = require(paths.appPackageJson).proxy;
|
|
||||||
|
|
||||||
const proxyConfig = prepareProxy(proxySetting, paths.appPublic);
|
|
||||||
|
|
||||||
// Serve webpack assets generated by the compiler over a web server.
|
|
||||||
const serverConfig = createDevServerConfig(
|
|
||||||
proxyConfig,
|
|
||||||
urls.lanUrlForConfig,
|
|
||||||
);
|
|
||||||
const devServer = new WebpackDevServer(compiler, serverConfig);
|
|
||||||
|
|
||||||
// Launch WebpackDevServer.
|
|
||||||
devServer.listen(port, HOST, (err) => {
|
|
||||||
if (err) {
|
|
||||||
return console.log(err);
|
|
||||||
}
|
|
||||||
if (isInteractive) {
|
|
||||||
clearConsole();
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(chalk.cyan('Starting the development server...\n'));
|
|
||||||
|
|
||||||
return openBrowser(urls.localUrlForBrowser);
|
|
||||||
});
|
|
||||||
|
|
||||||
[ 'SIGINT', 'SIGTERM' ].forEach((sig) => {
|
|
||||||
process.on(sig, () => {
|
|
||||||
devServer.close();
|
|
||||||
process.exit();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err && err.message) {
|
|
||||||
console.log(err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
|
|
||||||
// Do this as the first thing so that any code reading it knows the right env.
|
|
||||||
process.env.BABEL_ENV = 'test';
|
|
||||||
process.env.NODE_ENV = 'test';
|
|
||||||
process.env.PUBLIC_URL = '';
|
|
||||||
|
|
||||||
// Makes the script crash on unhandled rejections instead of silently
|
|
||||||
// ignoring them. In the future, promise rejections that are not handled will
|
|
||||||
// terminate the Node.js process with a non-zero exit code.
|
|
||||||
process.on('unhandledRejection', (err) => {
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure environment variables are read.
|
|
||||||
require('../config/env');
|
|
||||||
|
|
||||||
// Make tests to be matched inside tests folder
|
|
||||||
const jest = require('jest');
|
|
||||||
|
|
||||||
const argumentsToRemove = 2;
|
|
||||||
const argv = process.argv.slice(argumentsToRemove);
|
|
||||||
|
|
||||||
jest.run(argv);
|
|
||||||
12
shlink-web-client.d.ts
vendored
12
shlink-web-client.d.ts
vendored
@@ -1,11 +1,9 @@
|
|||||||
declare module 'event-source-polyfill' {
|
declare module 'event-source-polyfill' {
|
||||||
export const EventSourcePolyfill: any;
|
declare class EventSourcePolyfill {
|
||||||
}
|
public onmessage?: ({ data }: { data: string }) => void;
|
||||||
|
public onerror?: ({ status }: { status: number }) => void;
|
||||||
declare module 'csvjson' {
|
public close: () => void;
|
||||||
export declare class CsvJson {
|
public constructor(hubUrl: URL, options?: any);
|
||||||
public toObject<T>(content: string): T[];
|
|
||||||
public toCSV<T>(data: T[], options: { headers: string }): string;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 MiB After Width: | Height: | Size: 2.3 MiB |
52
src/App.tsx
52
src/App.tsx
@@ -1,52 +0,0 @@
|
|||||||
import React, { useEffect, FC } from 'react';
|
|
||||||
import { Route, Switch } from 'react-router-dom';
|
|
||||||
import NotFound from './common/NotFound';
|
|
||||||
import { ServersMap } from './servers/data';
|
|
||||||
import './App.scss';
|
|
||||||
|
|
||||||
interface AppProps {
|
|
||||||
fetchServers: Function;
|
|
||||||
servers: ServersMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
const App = (
|
|
||||||
MainHeader: FC,
|
|
||||||
Home: FC,
|
|
||||||
MenuLayout: FC,
|
|
||||||
CreateServer: FC,
|
|
||||||
EditServer: FC,
|
|
||||||
Settings: FC,
|
|
||||||
ShlinkVersions: FC,
|
|
||||||
) => ({ fetchServers, servers }: AppProps) => {
|
|
||||||
// On first load, try to fetch the remote servers if the list is empty
|
|
||||||
useEffect(() => {
|
|
||||||
if (Object.keys(servers).length === 0) {
|
|
||||||
fetchServers();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container-fluid app-container">
|
|
||||||
<MainHeader />
|
|
||||||
|
|
||||||
<div className="app">
|
|
||||||
<div className="shlink-wrapper">
|
|
||||||
<Switch>
|
|
||||||
<Route exact path="/" component={Home} />
|
|
||||||
<Route exact path="/settings" component={Settings} />
|
|
||||||
<Route exact path="/server/create" component={CreateServer} />
|
|
||||||
<Route exact path="/server/:serverId/edit" component={EditServer} />
|
|
||||||
<Route path="/server/:serverId" component={MenuLayout} />
|
|
||||||
<Route component={NotFound} />
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="shlink-footer text-center text-md-right">
|
|
||||||
<ShlinkVersions />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
18
src/api/ShlinkApiError.tsx
Normal file
18
src/api/ShlinkApiError.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { ProblemDetailsError } from './types';
|
||||||
|
import { isInvalidArgumentError } from './utils';
|
||||||
|
|
||||||
|
export interface ShlinkApiErrorProps {
|
||||||
|
errorData?: ProblemDetailsError;
|
||||||
|
fallbackMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShlinkApiError = ({ errorData, fallbackMessage }: ShlinkApiErrorProps) => (
|
||||||
|
<>
|
||||||
|
{errorData?.detail ?? fallbackMessage}
|
||||||
|
{isInvalidArgumentError(errorData) && (
|
||||||
|
<p className="mb-0">
|
||||||
|
Invalid elements: [{errorData.invalidElements.join(', ')}]
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import qs from 'qs';
|
|
||||||
import { isEmpty, isNil, reject } from 'ramda';
|
import { isEmpty, isNil, reject } from 'ramda';
|
||||||
import { AxiosInstance, AxiosResponse, Method } from 'axios';
|
import { AxiosInstance, AxiosResponse, Method } from 'axios';
|
||||||
import { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams';
|
|
||||||
import { ShortUrl, ShortUrlData } from '../../short-urls/data';
|
import { ShortUrl, ShortUrlData } from '../../short-urls/data';
|
||||||
import { OptionalString } from '../utils';
|
import { OptionalString } from '../../utils/utils';
|
||||||
import {
|
import {
|
||||||
ShlinkHealth,
|
ShlinkHealth,
|
||||||
ShlinkMercureInfo,
|
ShlinkMercureInfo,
|
||||||
@@ -12,25 +10,35 @@ import {
|
|||||||
ShlinkTagsResponse,
|
ShlinkTagsResponse,
|
||||||
ShlinkVisits,
|
ShlinkVisits,
|
||||||
ShlinkVisitsParams,
|
ShlinkVisitsParams,
|
||||||
ShlinkShortUrlMeta,
|
ShlinkShortUrlData,
|
||||||
} from './types';
|
ShlinkDomainsResponse,
|
||||||
|
ShlinkVisitsOverview,
|
||||||
|
ShlinkEditDomainRedirects,
|
||||||
|
ShlinkDomainRedirects,
|
||||||
|
ShlinkShortUrlsListParams,
|
||||||
|
ShlinkShortUrlsListNormalizedParams,
|
||||||
|
} from '../types';
|
||||||
|
import { stringifyQuery } from '../../utils/helpers/query';
|
||||||
|
import { orderToString } from '../../utils/helpers/ordering';
|
||||||
|
|
||||||
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
const buildShlinkBaseUrl = (url: string) => (url ? `${url}/rest/v2` : '');
|
||||||
const rejectNilProps = reject(isNil);
|
const rejectNilProps = reject(isNil);
|
||||||
|
const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => {
|
||||||
|
const { orderBy = {}, ...rest } = params;
|
||||||
|
|
||||||
|
return { ...rest, orderBy: orderToString(orderBy) };
|
||||||
|
};
|
||||||
|
|
||||||
export default class ShlinkApiClient {
|
export default class ShlinkApiClient {
|
||||||
private apiVersion: number;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly axios: AxiosInstance,
|
private readonly axios: AxiosInstance,
|
||||||
private readonly baseUrl: string,
|
private readonly baseUrl: string,
|
||||||
private readonly apiKey: string,
|
private readonly apiKey: string,
|
||||||
) {
|
) {
|
||||||
this.apiVersion = 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly listShortUrls = async (params: ShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
|
public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
|
||||||
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', params)
|
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', normalizeOrderByInParams(params))
|
||||||
.then(({ data }) => data.shortUrls);
|
.then(({ data }) => data.shortUrls);
|
||||||
|
|
||||||
public readonly createShortUrl = async (options: ShortUrlData): Promise<ShortUrl> => {
|
public readonly createShortUrl = async (options: ShortUrlData): Promise<ShortUrl> => {
|
||||||
@@ -48,6 +56,22 @@ export default class ShlinkApiClient {
|
|||||||
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query)
|
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query)
|
||||||
.then(({ data }) => data.visits);
|
.then(({ data }) => data.visits);
|
||||||
|
|
||||||
|
public readonly getDomainVisits = async (domain: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
||||||
|
this.performRequest<{ visits: ShlinkVisits }>(`/domains/${domain}/visits`, 'GET', query)
|
||||||
|
.then(({ data }) => data.visits);
|
||||||
|
|
||||||
|
public readonly getOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
||||||
|
this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query)
|
||||||
|
.then(({ data }) => data.visits);
|
||||||
|
|
||||||
|
public readonly getNonOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
||||||
|
this.performRequest<{ visits: ShlinkVisits }>('/visits/non-orphan', 'GET', query)
|
||||||
|
.then(({ data }) => data.visits);
|
||||||
|
|
||||||
|
public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
|
||||||
|
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET')
|
||||||
|
.then(({ data }) => data.visits);
|
||||||
|
|
||||||
public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise<ShortUrl> =>
|
public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise<ShortUrl> =>
|
||||||
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'GET', { domain })
|
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'GET', { domain })
|
||||||
.then(({ data }) => data);
|
.then(({ data }) => data);
|
||||||
@@ -56,21 +80,12 @@ export default class ShlinkApiClient {
|
|||||||
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
|
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
|
||||||
.then(() => {});
|
.then(() => {});
|
||||||
|
|
||||||
public readonly updateShortUrlTags = async (
|
public readonly updateShortUrl = async (
|
||||||
shortCode: string,
|
shortCode: string,
|
||||||
domain: OptionalString,
|
domain: OptionalString,
|
||||||
tags: string[],
|
edit: ShlinkShortUrlData,
|
||||||
): Promise<string[]> =>
|
): Promise<ShortUrl> =>
|
||||||
this.performRequest<{ tags: string[] }>(`/short-urls/${shortCode}/tags`, 'PUT', { domain }, { tags })
|
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, edit).then(({ data }) => data);
|
||||||
.then(({ data }) => data.tags);
|
|
||||||
|
|
||||||
public readonly updateShortUrlMeta = async (
|
|
||||||
shortCode: string,
|
|
||||||
domain: OptionalString,
|
|
||||||
meta: ShlinkShortUrlMeta,
|
|
||||||
): Promise<ShlinkShortUrlMeta> =>
|
|
||||||
this.performRequest(`/short-urls/${shortCode}`, 'PATCH', { domain }, meta)
|
|
||||||
.then(() => meta);
|
|
||||||
|
|
||||||
public readonly listTags = async (): Promise<ShlinkTags> =>
|
public readonly listTags = async (): Promise<ShlinkTags> =>
|
||||||
this.performRequest<{ tags: ShlinkTagsResponse }>('/tags', 'GET', { withStats: 'true' })
|
this.performRequest<{ tags: ShlinkTagsResponse }>('/tags', 'GET', { withStats: 'true' })
|
||||||
@@ -93,35 +108,21 @@ export default class ShlinkApiClient {
|
|||||||
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
|
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
|
||||||
.then((resp) => resp.data);
|
.then((resp) => resp.data);
|
||||||
|
|
||||||
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
|
public readonly listDomains = async (): Promise<ShlinkDomainsResponse> =>
|
||||||
try {
|
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains);
|
||||||
return await this.axios({
|
|
||||||
method,
|
|
||||||
url: `${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}`,
|
|
||||||
headers: { 'X-Api-Key': this.apiKey },
|
|
||||||
params: rejectNilProps(query),
|
|
||||||
data: body,
|
|
||||||
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
const { response } = e;
|
|
||||||
|
|
||||||
// Due to a bug on all previous Shlink versions, requests to non-matching URLs will always result on a CORS error
|
public readonly editDomainRedirects = async (
|
||||||
// when performed from the browser (due to the preflight request not returning a 2xx status.
|
domainRedirects: ShlinkEditDomainRedirects,
|
||||||
// See https://github.com/shlinkio/shlink/issues/614), which will make the "response" prop not to be set here.
|
): Promise<ShlinkDomainRedirects> =>
|
||||||
// The bug will be fixed on upcoming Shlink patches, but for other versions, we can consider this situation as
|
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data);
|
||||||
// if a request has been performed to a not supported API version.
|
|
||||||
const apiVersionIsNotSupported = !response;
|
|
||||||
|
|
||||||
// When the request is not invalid or we have already tried both API versions, throw the error and let the
|
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> =>
|
||||||
// caller handle it
|
this.axios({
|
||||||
if (!apiVersionIsNotSupported || this.apiVersion === 1) {
|
method,
|
||||||
throw e;
|
url: `${buildShlinkBaseUrl(this.baseUrl)}${url}`,
|
||||||
}
|
headers: { 'X-Api-Key': this.apiKey },
|
||||||
|
params: rejectNilProps(query),
|
||||||
this.apiVersion = this.apiVersion - 1;
|
data: body,
|
||||||
|
paramsSerializer: stringifyQuery,
|
||||||
return await this.performRequest(url, method, query, body);
|
});
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
8
src/api/services/provideServices.ts
Normal file
8
src/api/services/provideServices.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import Bottle from 'bottlejs';
|
||||||
|
import buildShlinkApiClient from './ShlinkApiClientBuilder';
|
||||||
|
|
||||||
|
const provideServices = (bottle: Bottle) => {
|
||||||
|
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provideServices;
|
||||||
6
src/api/types/actions.ts
Normal file
6
src/api/types/actions.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { Action } from 'redux';
|
||||||
|
import { ProblemDetailsError } from './index';
|
||||||
|
|
||||||
|
export interface ApiErrorAction extends Action<string> {
|
||||||
|
errorData?: ProblemDetailsError;
|
||||||
|
}
|
||||||
122
src/api/types/index.ts
Normal file
122
src/api/types/index.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { Visit } from '../../visits/types';
|
||||||
|
import { OptionalString } from '../../utils/utils';
|
||||||
|
import { ShortUrl, ShortUrlMeta, ShortUrlsOrder } from '../../short-urls/data';
|
||||||
|
|
||||||
|
export interface ShlinkShortUrlsResponse {
|
||||||
|
data: ShortUrl[];
|
||||||
|
pagination: ShlinkPaginator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkMercureInfo {
|
||||||
|
token: string;
|
||||||
|
mercureHubUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkHealth {
|
||||||
|
status: 'pass' | 'fail';
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShlinkTagsStats {
|
||||||
|
tag: string;
|
||||||
|
shortUrlsCount: number;
|
||||||
|
visitsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkTags {
|
||||||
|
tags: string[];
|
||||||
|
stats: ShlinkTagsStats[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkTagsResponse {
|
||||||
|
data: string[];
|
||||||
|
stats: ShlinkTagsStats[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkPaginator {
|
||||||
|
currentPage: number;
|
||||||
|
pagesCount: number;
|
||||||
|
totalItems: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkVisits {
|
||||||
|
data: Visit[];
|
||||||
|
pagination: ShlinkPaginator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkVisitsOverview {
|
||||||
|
visitsCount: number;
|
||||||
|
orphanVisitsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkVisitsParams {
|
||||||
|
domain?: OptionalString;
|
||||||
|
page?: number;
|
||||||
|
itemsPerPage?: number;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
excludeBots?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkShortUrlData extends ShortUrlMeta {
|
||||||
|
longUrl?: string;
|
||||||
|
title?: string;
|
||||||
|
validateUrl?: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkDomainRedirects {
|
||||||
|
baseUrlRedirect: string | null;
|
||||||
|
regular404Redirect: string | null;
|
||||||
|
invalidShortUrlRedirect: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkEditDomainRedirects extends Partial<ShlinkDomainRedirects> {
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkDomain {
|
||||||
|
domain: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
redirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.8
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkDomainsResponse {
|
||||||
|
data: ShlinkDomain[];
|
||||||
|
defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TagsFilteringMode = 'all' | 'any';
|
||||||
|
|
||||||
|
export interface ShlinkShortUrlsListParams {
|
||||||
|
page?: string;
|
||||||
|
itemsPerPage?: number;
|
||||||
|
tags?: string[];
|
||||||
|
searchTerm?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
orderBy?: ShortUrlsOrder;
|
||||||
|
tagsMode?: TagsFilteringMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> {
|
||||||
|
orderBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProblemDetailsError {
|
||||||
|
type: string;
|
||||||
|
detail: string;
|
||||||
|
title: string;
|
||||||
|
status: number;
|
||||||
|
[extraProps: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvalidArgumentError extends ProblemDetailsError {
|
||||||
|
type: 'INVALID_ARGUMENT';
|
||||||
|
invalidElements: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvalidShortUrlDeletion extends ProblemDetailsError {
|
||||||
|
type: 'INVALID_SHORTCODE_DELETION' | 'INVALID_SHORT_URL_DELETION';
|
||||||
|
threshold: number;
|
||||||
|
}
|
||||||
10
src/api/utils/index.ts
Normal file
10
src/api/utils/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import { InvalidArgumentError, InvalidShortUrlDeletion, ProblemDetailsError } from '../types';
|
||||||
|
|
||||||
|
export const parseApiError = (e: AxiosError<ProblemDetailsError>) => e.response?.data;
|
||||||
|
|
||||||
|
export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError =>
|
||||||
|
error?.type === 'INVALID_ARGUMENT';
|
||||||
|
|
||||||
|
export const isInvalidDeletionError = (error?: ProblemDetailsError): error is InvalidShortUrlDeletion =>
|
||||||
|
error?.type === 'INVALID_SHORTCODE_DELETION' || error?.type === 'INVALID_SHORT_URL_DELETION';
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
@import './utils/base';
|
@import '../utils/base';
|
||||||
|
|
||||||
.app-container {
|
.app-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
67
src/app/App.tsx
Normal file
67
src/app/App.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect, FC } from 'react';
|
||||||
|
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { NotFound } from '../common/NotFound';
|
||||||
|
import { ServersMap } from '../servers/data';
|
||||||
|
import { Settings } from '../settings/reducers/settings';
|
||||||
|
import { changeThemeInMarkup } from '../utils/theme';
|
||||||
|
import { AppUpdateBanner } from '../common/AppUpdateBanner';
|
||||||
|
import { forceUpdate } from '../utils/helpers/sw';
|
||||||
|
import './App.scss';
|
||||||
|
|
||||||
|
interface AppProps {
|
||||||
|
fetchServers: () => void;
|
||||||
|
servers: ServersMap;
|
||||||
|
settings: Settings;
|
||||||
|
resetAppUpdate: () => void;
|
||||||
|
appUpdated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const App = (
|
||||||
|
MainHeader: FC,
|
||||||
|
Home: FC,
|
||||||
|
MenuLayout: FC,
|
||||||
|
CreateServer: FC,
|
||||||
|
EditServer: FC,
|
||||||
|
SettingsComp: FC,
|
||||||
|
ManageServers: FC,
|
||||||
|
ShlinkVersionsContainer: FC,
|
||||||
|
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const isHome = location.pathname === '/';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// On first load, try to fetch the remote servers if the list is empty
|
||||||
|
if (Object.keys(servers).length === 0) {
|
||||||
|
fetchServers();
|
||||||
|
}
|
||||||
|
|
||||||
|
changeThemeInMarkup(settings.ui?.theme ?? 'light');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid app-container">
|
||||||
|
<MainHeader />
|
||||||
|
|
||||||
|
<div className="app">
|
||||||
|
<div className={classNames('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
|
||||||
|
<Routes>
|
||||||
|
<Route index element={<Home />} />
|
||||||
|
<Route path="/settings/*" element={<SettingsComp />} />
|
||||||
|
<Route path="/manage-servers" element={<ManageServers />} />
|
||||||
|
<Route path="/server/create" element={<CreateServer />} />
|
||||||
|
<Route path="/server/:serverId/edit" element={<EditServer />} />
|
||||||
|
<Route path="/server/:serverId/*" element={<MenuLayout />} />
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shlink-footer">
|
||||||
|
<ShlinkVersionsContainer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppUpdateBanner isOpen={appUpdated} toggle={resetAppUpdate} forceUpdate={forceUpdate} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
16
src/app/reducers/appUpdates.ts
Normal file
16
src/app/reducers/appUpdates.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Action } from 'redux';
|
||||||
|
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||||
|
|
||||||
|
export const APP_UPDATE_AVAILABLE = 'shlink/appUpdates/APP_UPDATE_AVAILABLE';
|
||||||
|
export const RESET_APP_UPDATE = 'shlink/appUpdates/RESET_APP_UPDATE';
|
||||||
|
|
||||||
|
const initialState = false;
|
||||||
|
|
||||||
|
export default buildReducer<boolean, Action<string>>({
|
||||||
|
[APP_UPDATE_AVAILABLE]: () => true,
|
||||||
|
[RESET_APP_UPDATE]: () => false,
|
||||||
|
}, initialState);
|
||||||
|
|
||||||
|
export const appUpdateAvailable = buildActionCreator(APP_UPDATE_AVAILABLE);
|
||||||
|
|
||||||
|
export const resetAppUpdate = buildActionCreator(RESET_APP_UPDATE);
|
||||||
27
src/app/services/provideServices.ts
Normal file
27
src/app/services/provideServices.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import Bottle from 'bottlejs';
|
||||||
|
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
|
||||||
|
import { App } from '../App';
|
||||||
|
import { ConnectDecorator } from '../../container/types';
|
||||||
|
|
||||||
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
|
// Components
|
||||||
|
bottle.serviceFactory(
|
||||||
|
'App',
|
||||||
|
App,
|
||||||
|
'MainHeader',
|
||||||
|
'Home',
|
||||||
|
'MenuLayout',
|
||||||
|
'CreateServer',
|
||||||
|
'EditServer',
|
||||||
|
'Settings',
|
||||||
|
'ManageServers',
|
||||||
|
'ShlinkVersionsContainer',
|
||||||
|
);
|
||||||
|
bottle.decorator('App', connect(['servers', 'settings', 'appUpdated'], ['fetchServers', 'resetAppUpdate']));
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);
|
||||||
|
bottle.serviceFactory('resetAppUpdate', () => resetAppUpdate);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provideServices;
|
||||||
17
src/common/AppUpdateBanner.scss
Normal file
17
src/common/AppUpdateBanner.scss
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
@import '../utils/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);
|
||||||
|
}
|
||||||
34
src/common/AppUpdateBanner.tsx
Normal file
34
src/common/AppUpdateBanner.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { FC, MouseEventHandler } from 'react';
|
||||||
|
import { Alert, Button } from 'reactstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
|
import './AppUpdateBanner.scss';
|
||||||
|
|
||||||
|
interface AppUpdateBannerProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
toggle: MouseEventHandler<any>;
|
||||||
|
forceUpdate: Function;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, toggle, forceUpdate }) => {
|
||||||
|
const [isUpdating,, setUpdating] = useToggle();
|
||||||
|
const update = () => {
|
||||||
|
setUpdating();
|
||||||
|
forceUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert className="app-update-banner" isOpen={isOpen} toggle={toggle} tag={SimpleCard} color="secondary">
|
||||||
|
<h4 className="mb-4">This app has just been updated!</h4>
|
||||||
|
<p className="mb-0">
|
||||||
|
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" /></>}
|
||||||
|
{isUpdating && <>Restarting...</>}
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
@import '../utils/base';
|
@import '../utils/base';
|
||||||
@import '../utils/mixins/vertical-align';
|
@import '../utils/mixins/vertical-align';
|
||||||
|
|
||||||
$asideMenuMobileWidth: 280px;
|
|
||||||
|
|
||||||
.aside-menu {
|
.aside-menu {
|
||||||
background-color: #f7f7f7;
|
width: $asideMenuWidth;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
box-shadow: rgb(0 0 0 / .05) 0 8px 15px;
|
||||||
position: fixed !important;
|
position: fixed !important;
|
||||||
padding-top: 13px;
|
padding-top: 13px;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
@@ -18,20 +18,18 @@ $asideMenuMobileWidth: 280px;
|
|||||||
|
|
||||||
@media (min-width: $mdMin) {
|
@media (min-width: $mdMin) {
|
||||||
padding: 30px 15px 15px;
|
padding: 30px 15px 15px;
|
||||||
border-right: 1px solid #eeeeee;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: $smMax) {
|
@media (max-width: $smMax) {
|
||||||
width: $asideMenuMobileWidth !important;
|
|
||||||
transition: left 300ms;
|
transition: left 300ms;
|
||||||
top: $headerHeight - 3px;
|
top: $headerHeight - 3px;
|
||||||
box-shadow: -10px 0 50px 11px rgba(0, 0, 0, .55);
|
box-shadow: -10px 0 50px 11px rgb(0 0 0 / .55);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.aside-menu--hidden {
|
.aside-menu--hidden {
|
||||||
@media (max-width: $smMax) {
|
@media (max-width: $smMax) {
|
||||||
left: -($asideMenuMobileWidth + 35px);
|
left: -($asideMenuWidth + 35px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,20 +42,20 @@ $asideMenuMobileWidth: 280px;
|
|||||||
margin: 0 -15px;
|
margin: 0 -15px;
|
||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
@media (max-width: $smMax) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.aside-menu__item:hover {
|
.aside-menu__item:hover {
|
||||||
background-color: $lightHoverColor;
|
background-color: var(--secondary-color);
|
||||||
}
|
|
||||||
|
|
||||||
.aside-menu__item--selected {
|
|
||||||
color: #ffffff;
|
|
||||||
background-color: $mainColor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aside-menu__item--selected,
|
||||||
.aside-menu__item--selected:hover {
|
.aside-menu__item--selected:hover {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background-color: $mainColor;
|
background-color: var(--brand-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aside-menu__item--divider {
|
.aside-menu__item--divider {
|
||||||
|
|||||||
@@ -3,31 +3,30 @@ import {
|
|||||||
faLink as createIcon,
|
faLink as createIcon,
|
||||||
faTags as tagsIcon,
|
faTags as tagsIcon,
|
||||||
faPen as editIcon,
|
faPen as editIcon,
|
||||||
|
faHome as overviewIcon,
|
||||||
|
faGlobe as domainsIcon,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import React, { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { NavLink, NavLinkProps } from 'react-router-dom';
|
import { NavLink, NavLinkProps, useLocation } from 'react-router-dom';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Location } from 'history';
|
|
||||||
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
||||||
import { ServerWithId } from '../servers/data';
|
import { isServerWithId, SelectedServer } from '../servers/data';
|
||||||
|
import { supportsDomainRedirects } from '../utils/helpers/features';
|
||||||
import './AsideMenu.scss';
|
import './AsideMenu.scss';
|
||||||
|
|
||||||
export interface AsideMenuProps {
|
export interface AsideMenuProps {
|
||||||
selectedServer: ServerWithId;
|
selectedServer: SelectedServer;
|
||||||
className?: string;
|
|
||||||
showOnMobile?: boolean;
|
showOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AsideMenuItemProps extends NavLinkProps {
|
interface AsideMenuItemProps extends NavLinkProps {
|
||||||
to: string;
|
to: string;
|
||||||
className?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
|
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
className={classNames('aside-menu__item', className)}
|
className={({ isActive }) => classNames('aside-menu__item', className, { 'aside-menu__item--selected': isActive })}
|
||||||
activeClassName="aside-menu__item--selected"
|
|
||||||
to={to}
|
to={to}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
@@ -36,39 +35,56 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
|
|||||||
);
|
);
|
||||||
|
|
||||||
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||||
{ selectedServer, className, showOnMobile = false }: AsideMenuProps,
|
{ selectedServer, showOnMobile = false }: AsideMenuProps,
|
||||||
) => {
|
) => {
|
||||||
const serverId = selectedServer ? selectedServer.id : '';
|
const hasId = isServerWithId(selectedServer);
|
||||||
const asideClass = classNames('aside-menu', className, {
|
const serverId = hasId ? selectedServer.id : '';
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
|
||||||
|
const asideClass = classNames('aside-menu', {
|
||||||
'aside-menu--hidden': !showOnMobile,
|
'aside-menu--hidden': !showOnMobile,
|
||||||
});
|
});
|
||||||
const shortUrlsIsActive = (_: null, location: Location) => location.pathname.match('/list-short-urls') !== null;
|
|
||||||
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`;
|
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={asideClass}>
|
<aside className={asideClass}>
|
||||||
<nav className="nav flex-column aside-menu__nav">
|
<nav className="nav flex-column aside-menu__nav">
|
||||||
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
<AsideMenuItem to={buildPath('/overview')}>
|
||||||
<FontAwesomeIcon icon={listIcon} />
|
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
|
||||||
|
<span className="aside-menu__item-text">Overview</span>
|
||||||
|
</AsideMenuItem>
|
||||||
|
<AsideMenuItem
|
||||||
|
to={buildPath('/list-short-urls/1')}
|
||||||
|
className={classNames({ 'aside-menu__item--selected': pathname.match('/list-short-urls') !== null })}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon fixedWidth icon={listIcon} />
|
||||||
<span className="aside-menu__item-text">List short URLs</span>
|
<span className="aside-menu__item-text">List short URLs</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
<AsideMenuItem to={buildPath('/create-short-url')}>
|
<AsideMenuItem to={buildPath('/create-short-url')}>
|
||||||
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
|
<FontAwesomeIcon fixedWidth icon={createIcon} flip="horizontal" />
|
||||||
<span className="aside-menu__item-text">Create short URL</span>
|
<span className="aside-menu__item-text">Create short URL</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
<AsideMenuItem to={buildPath('/manage-tags')}>
|
<AsideMenuItem to={buildPath('/manage-tags')}>
|
||||||
<FontAwesomeIcon icon={tagsIcon} />
|
<FontAwesomeIcon fixedWidth icon={tagsIcon} />
|
||||||
<span className="aside-menu__item-text">Manage tags</span>
|
<span className="aside-menu__item-text">Manage tags</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
|
{addManageDomainsLink && (
|
||||||
|
<AsideMenuItem to={buildPath('/manage-domains')}>
|
||||||
|
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
|
||||||
|
<span className="aside-menu__item-text">Manage domains</span>
|
||||||
|
</AsideMenuItem>
|
||||||
|
)}
|
||||||
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
||||||
<FontAwesomeIcon icon={editIcon} />
|
<FontAwesomeIcon fixedWidth icon={editIcon} />
|
||||||
<span className="aside-menu__item-text">Edit this server</span>
|
<span className="aside-menu__item-text">Edit this server</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
<DeleteServerButton
|
{hasId && (
|
||||||
className="aside-menu__item aside-menu__item--danger"
|
<DeleteServerButton
|
||||||
textClassName="aside-menu__item-text"
|
className="aside-menu__item aside-menu__item--danger"
|
||||||
server={selectedServer}
|
textClassName="aside-menu__item-text"
|
||||||
/>
|
server={selectedServer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
@import '../utils/mixins/vertical-align.scss';
|
|
||||||
|
|
||||||
.error-handler {
|
|
||||||
@include vertical-align();
|
|
||||||
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import { Component, ReactNode } from 'react';
|
||||||
import { Button } from 'reactstrap';
|
import { Button } from 'reactstrap';
|
||||||
import './ErrorHandler.scss';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
|
||||||
interface ErrorHandlerState {
|
interface ErrorHandlerState {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ErrorHandler = (
|
export const ErrorHandler = (
|
||||||
{ location }: Window,
|
{ location }: Window,
|
||||||
{ error }: Console,
|
{ error }: Console,
|
||||||
) => class ErrorHandler extends React.Component<any, ErrorHandlerState> {
|
) => class extends Component<any, ErrorHandlerState> {
|
||||||
public constructor(props: object) {
|
public constructor(props: object) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { hasError: false };
|
this.state = { hasError: false };
|
||||||
@@ -25,20 +25,22 @@ const ErrorHandler = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public render(): ReactNode | undefined {
|
public render(): ReactNode {
|
||||||
if (this.state.hasError) {
|
const { hasError } = this.state;
|
||||||
|
if (hasError) {
|
||||||
return (
|
return (
|
||||||
<div className="error-handler">
|
<div className="home">
|
||||||
<h1>Oops! This is awkward :S</h1>
|
<SimpleCard className="p-4">
|
||||||
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
|
<h1>Oops! This is awkward :S</h1>
|
||||||
<br />
|
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
|
||||||
<Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
|
<br />
|
||||||
|
<Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
|
||||||
|
</SimpleCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.props.children;
|
const { children } = this.props;
|
||||||
|
return children;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ErrorHandler;
|
|
||||||
|
|||||||
@@ -1,18 +1,58 @@
|
|||||||
@import '../utils/base';
|
@import '../utils/base';
|
||||||
|
@import '../utils/mixins/vertical-align';
|
||||||
|
|
||||||
|
$mainCardWidth: 720px;
|
||||||
|
$fiveColumnsSize: .4167; // 12 / 5 -> Can't use "/" operator in latest dart-sass
|
||||||
|
|
||||||
.home {
|
.home {
|
||||||
text-align: center;
|
position: relative;
|
||||||
height: calc(100vh - #{$headerHeight} - #{($footer-height + $footer-margin)});
|
padding-top: 15px;
|
||||||
display: flex;
|
width: 100%;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
@media (min-width: $mdMin) {
|
||||||
flex-flow: column;
|
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 {
|
.home__title {
|
||||||
|
text-align: center;
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
@media (min-width: $mdMin) {
|
||||||
font-size: 2.2rem;
|
font-size: 2.2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home__servers-container {
|
||||||
|
@media (min-width: $mdMin) {
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,27 +1,67 @@
|
|||||||
import React from 'react';
|
import { useEffect } from 'react';
|
||||||
import { isEmpty, values } from 'ramda';
|
import { isEmpty, values } from 'ramda';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { Card, Row } from 'reactstrap';
|
||||||
|
import { ExternalLink } from 'react-external-link';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||||
import ServersListGroup from '../servers/ServersListGroup';
|
import ServersListGroup from '../servers/ServersListGroup';
|
||||||
import './Home.scss';
|
|
||||||
import { ServersMap } from '../servers/data';
|
import { ServersMap } from '../servers/data';
|
||||||
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
|
import './Home.scss';
|
||||||
|
|
||||||
export interface HomeProps {
|
interface HomeProps {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Home = ({ servers }: HomeProps) => {
|
export const Home = ({ servers }: HomeProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const serversList = values(servers);
|
const serversList = values(servers);
|
||||||
const hasServers = !isEmpty(serversList);
|
const hasServers = !isEmpty(serversList);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Try to redirect to the first server marked as auto-connect
|
||||||
|
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
|
||||||
|
autoConnectServer && navigate(`/server/${autoConnectServer.id}`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="home">
|
<div className="home">
|
||||||
<h1 className="home__title">Welcome to Shlink</h1>
|
<Card className="home__main-card">
|
||||||
<ServersListGroup servers={serversList}>
|
<Row className="g-0">
|
||||||
{hasServers && <span>Please, select a server.</span>}
|
<div className="col-md-5 d-none d-md-block">
|
||||||
{!hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
|
<div className="home__logo-wrapper">
|
||||||
</ServersListGroup>
|
<div className="home__logo">
|
||||||
|
<ShlinkLogo />
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</ServersListGroup>
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Home;
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
@import '../utils/base';
|
@import '../utils/base';
|
||||||
|
|
||||||
.main-header.main-header {
|
.main-header.main-header {
|
||||||
background-color: $mainColor !important;
|
|
||||||
color: white;
|
color: white;
|
||||||
|
background-color: var(--brand-color) !important;
|
||||||
|
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
|
|||||||
@@ -1,28 +1,27 @@
|
|||||||
import { faPlus as plusIcon, faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import React, { FC, useEffect } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { RouteComponentProps } from 'react-router';
|
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
import shlinkLogo from './shlink-logo-white.png';
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
import './MainHeader.scss';
|
import './MainHeader.scss';
|
||||||
|
|
||||||
const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps) => {
|
export const MainHeader = (ServersDropdown: FC) => () => {
|
||||||
const [ isOpen, toggleOpen, , close ] = useToggle();
|
const [isOpen, toggleOpen, , close] = useToggle();
|
||||||
|
const location = useLocation();
|
||||||
const { pathname } = location;
|
const { pathname } = location;
|
||||||
|
|
||||||
useEffect(close, [ location ]);
|
useEffect(close, [location]);
|
||||||
|
|
||||||
const createServerPath = '/server/create';
|
|
||||||
const settingsPath = '/settings';
|
const settingsPath = '/settings';
|
||||||
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
|
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
||||||
<NavbarBrand tag={Link} to="/">
|
<NavbarBrand tag={Link} to="/">
|
||||||
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
|
<ShlinkLogo className="main-header__brand-logo" color="white" /> Shlink
|
||||||
</NavbarBrand>
|
</NavbarBrand>
|
||||||
|
|
||||||
<NavbarToggler onClick={toggleOpen}>
|
<NavbarToggler onClick={toggleOpen}>
|
||||||
@@ -30,22 +29,15 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)
|
|||||||
</NavbarToggler>
|
</NavbarToggler>
|
||||||
|
|
||||||
<Collapse navbar isOpen={isOpen}>
|
<Collapse navbar isOpen={isOpen}>
|
||||||
<Nav navbar className="ml-auto">
|
<Nav navbar className="ms-auto">
|
||||||
<NavItem>
|
<NavItem>
|
||||||
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
|
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
|
||||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
<NavItem>
|
|
||||||
<NavLink tag={Link} to={createServerPath} active={pathname === createServerPath}>
|
|
||||||
<FontAwesomeIcon icon={plusIcon} /> Add server
|
|
||||||
</NavLink>
|
|
||||||
</NavItem>
|
|
||||||
<ServersDropdown />
|
<ServersDropdown />
|
||||||
</Nav>
|
</Nav>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MainHeader;
|
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
@import '../utils/base';
|
@import '../utils/base';
|
||||||
|
|
||||||
.menu-layout__swipeable {
|
.menu-layout__swipeable {
|
||||||
$offset: 15px;
|
|
||||||
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin-right: -$offset;
|
|
||||||
margin-left: -$offset;
|
|
||||||
padding-left: $offset;
|
|
||||||
padding-right: $offset;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-layout__swipeable-inner {
|
.menu-layout__swipeable-inner {
|
||||||
@@ -22,7 +16,7 @@
|
|||||||
z-index: 1035;
|
z-index: 1035;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: rgba(255, 255, 255, .5);
|
color: rgb(255 255 255 / .5);
|
||||||
|
|
||||||
@media (max-width: $smMax) {
|
@media (max-width: $smMax) {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -33,11 +27,11 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-layout__container {
|
.menu-layout__container.menu-layout__container {
|
||||||
padding: 20px 0 0;
|
padding: 20px 0 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
@media (min-width: $mdMin) {
|
||||||
padding: 30px 15px 0;
|
padding: 30px 0 0 $asideMenuWidth;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +1,88 @@
|
|||||||
import React, { FC, useEffect } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { Route, Switch } from 'react-router-dom';
|
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import { EventData, Swipeable } from 'react-swipeable';
|
|
||||||
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
|
||||||
import { versionMatch } from '../utils/helpers/version';
|
import { supportsDomainRedirects, supportsDomainVisits, supportsNonOrphanVisits } from '../utils/helpers/features';
|
||||||
import { isReachableServer } from '../servers/data';
|
import { isReachableServer } from '../servers/data';
|
||||||
import NotFound from './NotFound';
|
import { NotFound } from './NotFound';
|
||||||
import { AsideMenuProps } from './AsideMenu';
|
import { AsideMenuProps } from './AsideMenu';
|
||||||
import './MenuLayout.scss';
|
import './MenuLayout.scss';
|
||||||
|
|
||||||
const MenuLayout = (
|
interface MenuLayoutProps {
|
||||||
|
sidebarPresent: Function;
|
||||||
|
sidebarNotPresent: Function;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuLayout = (
|
||||||
TagsList: FC,
|
TagsList: FC,
|
||||||
ShortUrls: FC,
|
ShortUrlsList: FC,
|
||||||
AsideMenu: FC<AsideMenuProps>,
|
AsideMenu: FC<AsideMenuProps>,
|
||||||
CreateShortUrl: FC,
|
CreateShortUrl: FC,
|
||||||
ShortUrlVisits: FC,
|
ShortUrlVisits: FC,
|
||||||
TagVisits: FC,
|
TagVisits: FC,
|
||||||
|
DomainVisits: FC,
|
||||||
|
OrphanVisits: FC,
|
||||||
|
NonOrphanVisits: FC,
|
||||||
ServerError: FC,
|
ServerError: FC,
|
||||||
) => withSelectedServer(({ location, selectedServer }) => {
|
Overview: FC,
|
||||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
EditShortUrl: FC,
|
||||||
|
ManageDomains: FC,
|
||||||
|
) => withSelectedServer<MenuLayoutProps>(({ selectedServer, sidebarNotPresent, sidebarPresent }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const [sidebarVisible, toggleSidebar, showSidebar, hideSidebar] = useToggle();
|
||||||
|
const showContent = isReachableServer(selectedServer);
|
||||||
|
|
||||||
useEffect(() => hideSidebar(), [ location ]);
|
useEffect(() => hideSidebar(), [location]);
|
||||||
|
useEffect(() => {
|
||||||
|
showContent && sidebarPresent();
|
||||||
|
|
||||||
if (!isReachableServer(selectedServer)) {
|
return () => sidebarNotPresent();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!showContent) {
|
||||||
return <ServerError />;
|
return <ServerError />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' });
|
const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer);
|
||||||
const burgerClasses = classNames('menu-layout__burger-icon', {
|
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
|
||||||
'menu-layout__burger-icon--active': sidebarVisible,
|
const addDomainVisitsRoute = supportsDomainVisits(selectedServer);
|
||||||
});
|
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
|
||||||
const swipeMenuIfNoModalExists = (callback: () => void) => (e: EventData) => {
|
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
||||||
const swippedOnVisitsTable = (e.event.composedPath() as HTMLElement[]).some(
|
|
||||||
({ classList }) => classList?.contains('visits-table'),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (swippedOnVisitsTable || document.querySelector('.modal')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
callback();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<>
|
||||||
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
|
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
|
||||||
|
|
||||||
<Swipeable
|
<div {...swipeableProps} className="menu-layout__swipeable">
|
||||||
delta={40}
|
<div className="menu-layout__swipeable-inner">
|
||||||
className="menu-layout__swipeable"
|
<AsideMenu selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
||||||
onSwipedLeft={swipeMenuIfNoModalExists(hideSidebar)}
|
<div className="menu-layout__container" onClick={() => hideSidebar()}>
|
||||||
onSwipedRight={swipeMenuIfNoModalExists(showSidebar)}
|
<div className="container-xl">
|
||||||
>
|
<Routes>
|
||||||
<div className="row menu-layout__swipeable-inner">
|
<Route index element={<Navigate replace to="overview" />} />
|
||||||
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
<Route path="/overview" element={<Overview />} />
|
||||||
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
|
<Route path="/list-short-urls/:page" element={<ShortUrlsList />} />
|
||||||
<div className="menu-layout__container">
|
<Route path="/create-short-url" element={<CreateShortUrl />} />
|
||||||
<Switch>
|
<Route path="/short-code/:shortCode/visits/*" element={<ShortUrlVisits />} />
|
||||||
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
|
<Route path="/short-code/:shortCode/edit" element={<EditShortUrl />} />
|
||||||
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
<Route path="/tag/:tag/visits/*" element={<TagVisits />} />
|
||||||
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
{addDomainVisitsRoute && <Route path="/domain/:domain/visits/*" element={<DomainVisits />} />}
|
||||||
{addTagsVisitsRoute && <Route exact path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
<Route path="/orphan-visits/*" element={<OrphanVisits />} />
|
||||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
{addNonOrphanVisitsRoute && <Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />}
|
||||||
|
<Route path="/manage-tags" element={<TagsList />} />
|
||||||
|
{addManageDomainsRoute && <Route path="/manage-domains" element={<ManageDomains />} />}
|
||||||
<Route
|
<Route
|
||||||
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
path="*"
|
||||||
|
element={<NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||||
/>
|
/>
|
||||||
</Switch>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Swipeable>
|
</div>
|
||||||
</React.Fragment>
|
</>
|
||||||
);
|
);
|
||||||
}, ServerError);
|
}, ServerError);
|
||||||
|
|
||||||
export default MenuLayout;
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
@import '../utils/base';
|
||||||
|
|
||||||
.no-menu-wrapper {
|
.no-menu-wrapper {
|
||||||
padding: 40px 20px 20px;
|
padding: 15px 0 0;
|
||||||
|
|
||||||
|
@media (min-width: $mdMin) {
|
||||||
|
padding: 30px 20px 20px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { FC } from 'react';
|
import { FC, PropsWithChildren } from 'react';
|
||||||
import './NoMenuLayout.scss';
|
import './NoMenuLayout.scss';
|
||||||
|
|
||||||
const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper">{children}</div>;
|
export const NoMenuLayout: FC<PropsWithChildren<unknown>> = ({ children }) => (
|
||||||
|
<div className="no-menu-wrapper container-xl">{children}</div>
|
||||||
export default NoMenuLayout;
|
);
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
import React, { FC } from 'react';
|
import { FC, PropsWithChildren } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
|
||||||
interface NotFoundProps {
|
type NotFoundProps = PropsWithChildren<{ to?: string }>;
|
||||||
to?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
|
export const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
|
||||||
<div className="home">
|
<div className="home">
|
||||||
<h2>Oops! We could not find requested route.</h2>
|
<SimpleCard className="p-4">
|
||||||
<p>
|
<h2>Oops! We could not find requested route.</h2>
|
||||||
Use your browser's back button to navigate to the page you have previously come from, or just press this
|
<p>
|
||||||
button.
|
Use your browser's back button to navigate to the page you have previously come from, or just press this
|
||||||
</p>
|
button.
|
||||||
<br />
|
</p>
|
||||||
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
|
<br />
|
||||||
|
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
|
||||||
|
</SimpleCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default NotFound;
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { PropsWithChildren, useEffect } from 'react';
|
import { FC, PropsWithChildren, useEffect } from 'react';
|
||||||
import { RouteComponentProps } from 'react-router';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const ScrollToTop: FC<PropsWithChildren<unknown>> = ({ children }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const ScrollToTop = () => ({ location, children }: PropsWithChildren<RouteComponentProps>) => {
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollTo(0, 0);
|
scrollTo(0, 0);
|
||||||
}, [ location ]);
|
}, [location]);
|
||||||
|
|
||||||
return <React.Fragment>{children}</React.Fragment>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ScrollToTop;
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { pipe } from 'ramda';
|
import { pipe } from 'ramda';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
||||||
@@ -11,7 +9,6 @@ const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
|
|||||||
export interface ShlinkVersionsProps {
|
export interface ShlinkVersionsProps {
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
clientVersion?: string;
|
clientVersion?: string;
|
||||||
className?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => (
|
const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => (
|
||||||
@@ -20,16 +17,14 @@ const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-cli
|
|||||||
</ExternalLink>
|
</ExternalLink>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ShlinkVersions = (
|
const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps) => {
|
||||||
{ selectedServer, className, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps,
|
|
||||||
) => {
|
|
||||||
const normalizedClientVersion = normalizeVersion(clientVersion);
|
const normalizedClientVersion = normalizeVersion(clientVersion);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<small className={classNames('text-muted', className)}>
|
<small className="text-muted">
|
||||||
{isReachableServer(selectedServer) &&
|
{isReachableServer(selectedServer) && (
|
||||||
<React.Fragment>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </React.Fragment>
|
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
|
||||||
}
|
)}
|
||||||
Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} />
|
Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} />
|
||||||
</small>
|
</small>
|
||||||
);
|
);
|
||||||
|
|||||||
9
src/common/ShlinkVersionsContainer.scss
Normal file
9
src/common/ShlinkVersionsContainer.scss
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
@import '../utils/base';
|
||||||
|
|
||||||
|
.shlink-versions-container--with-sidebar {
|
||||||
|
margin-left: 0;
|
||||||
|
|
||||||
|
@media (min-width: $mdMin) {
|
||||||
|
margin-left: $asideMenuWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/common/ShlinkVersionsContainer.tsx
Normal file
24
src/common/ShlinkVersionsContainer.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import { SelectedServer } from '../servers/data';
|
||||||
|
import ShlinkVersions from './ShlinkVersions';
|
||||||
|
import { Sidebar } from './reducers/sidebar';
|
||||||
|
import './ShlinkVersionsContainer.scss';
|
||||||
|
|
||||||
|
export interface ShlinkVersionsContainerProps {
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
sidebar: Sidebar;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShlinkVersionsContainer = ({ selectedServer, sidebar }: ShlinkVersionsContainerProps) => {
|
||||||
|
const classes = classNames('text-center', {
|
||||||
|
'shlink-versions-container--with-sidebar': sidebar.sidebarPresent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes}>
|
||||||
|
<ShlinkVersions selectedServer={selectedServer} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShlinkVersionsContainer;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +17,9 @@ interface SimplePaginatorProps {
|
|||||||
centered?: boolean;
|
centered?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SimplePaginator: FC<SimplePaginatorProps> = ({ pagesCount, currentPage, setCurrentPage, centered = true }) => {
|
export const SimplePaginator: FC<SimplePaginatorProps> = (
|
||||||
|
{ pagesCount, currentPage, setCurrentPage, centered = true },
|
||||||
|
) => {
|
||||||
if (pagesCount < 2) {
|
if (pagesCount < 2) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -35,7 +37,9 @@ const SimplePaginator: FC<SimplePaginatorProps> = ({ pagesCount, currentPage, se
|
|||||||
disabled={pageIsEllipsis(pageNumber)}
|
disabled={pageIsEllipsis(pageNumber)}
|
||||||
active={currentPage === pageNumber}
|
active={currentPage === pageNumber}
|
||||||
>
|
>
|
||||||
<PaginationLink tag="span" onClick={onClick(pageNumber)}>{prettifyPageNumber(pageNumber)}</PaginationLink>
|
<PaginationLink role="link" tag="span" onClick={onClick(pageNumber)}>
|
||||||
|
{prettifyPageNumber(pageNumber)}
|
||||||
|
</PaginationLink>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
))}
|
))}
|
||||||
<PaginationItem disabled={currentPage >= pagesCount}>
|
<PaginationItem disabled={currentPage >= pagesCount}>
|
||||||
@@ -44,5 +48,3 @@ const SimplePaginator: FC<SimplePaginatorProps> = ({ pagesCount, currentPage, se
|
|||||||
</Pagination>
|
</Pagination>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SimplePaginator;
|
|
||||||
|
|||||||
25
src/common/img/ShlinkLogo.tsx
Normal file
25
src/common/img/ShlinkLogo.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { MAIN_COLOR } from '../../utils/theme';
|
||||||
|
|
||||||
|
export interface ShlinkLogoProps {
|
||||||
|
color?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShlinkLogo = ({ color = MAIN_COLOR, className }: ShlinkLogoProps) => (
|
||||||
|
<svg className={className} viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g fill={color}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
159
src/common/react-tag-autocomplete.scss
Normal file
159
src/common/react-tag-autocomplete.scss
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
@import '../utils/base';
|
||||||
|
|
||||||
|
.react-tags {
|
||||||
|
position: relative;
|
||||||
|
padding: 5px 0 0 6px;
|
||||||
|
border-radius: .3rem;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border: 1px solid var(--input-border-color);
|
||||||
|
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
||||||
|
|
||||||
|
/* shared font styles */
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1.2;
|
||||||
|
|
||||||
|
/* clicking anywhere will focus the input */
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group > .react-tags {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 1%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .react-tags {
|
||||||
|
background-color: var(--input-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tags.is-focused {
|
||||||
|
box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tags__tag {
|
||||||
|
font-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tags__selected {
|
||||||
|
display: inline;
|
||||||
|
vertical-align: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tags__selected-tag {
|
||||||
|
display: inline-block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0 6px 6px 0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--input-border-color);
|
||||||
|
border-radius: .25rem;
|
||||||
|
background: #f1f1f1;
|
||||||
|
|
||||||
|
/* match the font styles */
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tags__selected-tag:after {
|
||||||
|
content: '\2715';
|
||||||
|
color: #aaaaaa;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tags__selected-tag:hover,
|
||||||
|
.react-tags__selected-tag:focus {
|
||||||
|
border-color: var(--input-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tags__search {
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
/* match tag layout */
|
||||||
|
padding: 6px 2px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
|
||||||
|
/* prevent autoresize overflowing the container */
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: $smMin) {
|
||||||
|
.react-tags__search {
|
||||||
|
/* this will become the offsetParent for suggestions */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tags__search-input {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: inherit;
|
||||||
|
color: var(--input-text-color);
|
||||||
|
background-color: inherit;
|
||||||
|
|
||||||
|
/* prevent autoresize overflowing the container */
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
/* remove styles and layout from this element */
|
||||||
|
margin: 0 0 0 7px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tags__search-input::placeholder {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tags__search-input::-ms-clear {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tags__suggestions {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: $smMin) {
|
||||||
|
.react-tags__suggestions {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tags__suggestions ul {
|
||||||
|
margin: 4px -1px;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
background: var(--primary-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: .25rem;
|
||||||
|
box-shadow: 0 2px 6px rgb(0 0 0 / .2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tags__suggestions li {
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tags__suggestions li:not(:last-child) {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tags__suggestions li mark {
|
||||||
|
text-decoration: underline;
|
||||||
|
background: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tags__suggestions li:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--active-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tags__suggestions li.is-active {
|
||||||
|
background-color: var(--active-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-tags__suggestions li.is-disabled {
|
||||||
|
opacity: .5;
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
.react-tagsinput {
|
|
||||||
background-color: #ffffff;
|
|
||||||
border: 1px solid #cccccc;
|
|
||||||
border-radius: .25rem;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 2.6rem;
|
|
||||||
padding: 6px 0 0 6px;
|
|
||||||
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tagsinput--focused {
|
|
||||||
border-color: #80bdff;
|
|
||||||
box-shadow: 0 0 0 .2rem rgba(0, 123, 255, .25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tagsinput-tag {
|
|
||||||
font-size: 1rem;
|
|
||||||
background-color: #f1f1f1;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: inline-block;
|
|
||||||
font-weight: 400;
|
|
||||||
margin: 0 5px 6px 0;
|
|
||||||
padding: 6px 8px;
|
|
||||||
line-height: 1;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tagsinput-remove {
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tagsinput-tag span:before {
|
|
||||||
content: '\2715';
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tagsinput-input {
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
outline: none;
|
|
||||||
padding: 3px 5px;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
25
src/common/reducers/sidebar.ts
Normal file
25
src/common/reducers/sidebar.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Action } from 'redux';
|
||||||
|
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||||
|
|
||||||
|
export const SIDEBAR_PRESENT = 'shlink/common/SIDEBAR_PRESENT';
|
||||||
|
export const SIDEBAR_NOT_PRESENT = 'shlink/common/SIDEBAR_NOT_PRESENT';
|
||||||
|
|
||||||
|
export interface Sidebar {
|
||||||
|
sidebarPresent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SidebarRenderedAction = Action<string>;
|
||||||
|
type SidebarNotRenderedAction = Action<string>;
|
||||||
|
|
||||||
|
const initialState: Sidebar = {
|
||||||
|
sidebarPresent: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default buildReducer<Sidebar, SidebarRenderedAction & SidebarNotRenderedAction>({
|
||||||
|
[SIDEBAR_PRESENT]: () => ({ sidebarPresent: true }),
|
||||||
|
[SIDEBAR_NOT_PRESENT]: () => ({ sidebarPresent: false }),
|
||||||
|
}, initialState);
|
||||||
|
|
||||||
|
export const sidebarPresent = buildActionCreator(SIDEBAR_PRESENT);
|
||||||
|
|
||||||
|
export const sidebarNotPresent = buildActionCreator(SIDEBAR_NOT_PRESENT);
|
||||||
13
src/common/services/ImageDownloader.ts
Normal file
13
src/common/services/ImageDownloader.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { AxiosInstance } from 'axios';
|
||||||
|
import { saveUrl } from '../../utils/helpers/files';
|
||||||
|
|
||||||
|
export class ImageDownloader {
|
||||||
|
public constructor(private readonly axios: AxiosInstance, private readonly window: Window) {}
|
||||||
|
|
||||||
|
public async saveImage(imgUrl: string, filename: string): Promise<void> {
|
||||||
|
const { data } = await this.axios.get(imgUrl, { responseType: 'blob' });
|
||||||
|
const url = URL.createObjectURL(data);
|
||||||
|
|
||||||
|
saveUrl(this.window, url, filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/common/services/ReportExporter.ts
Normal file
30
src/common/services/ReportExporter.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { NormalizedVisit } from '../../visits/types';
|
||||||
|
import { ExportableShortUrl } from '../../short-urls/data';
|
||||||
|
import { saveCsv } from '../../utils/helpers/files';
|
||||||
|
import { JsonToCsv } from '../../utils/helpers/csvjson';
|
||||||
|
|
||||||
|
export class ReportExporter {
|
||||||
|
public constructor(private readonly window: Window, private readonly jsonToCsv: JsonToCsv) {}
|
||||||
|
|
||||||
|
public readonly exportVisits = (filename: string, visits: NormalizedVisit[]) => {
|
||||||
|
if (!visits.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.exportCsv(filename, visits);
|
||||||
|
};
|
||||||
|
|
||||||
|
public readonly exportShortUrls = (shortUrls: ExportableShortUrl[]) => {
|
||||||
|
if (!shortUrls.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.exportCsv('short_urls.csv', shortUrls);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly exportCsv = (filename: string, rows: object[]) => {
|
||||||
|
const csv = this.jsonToCsv(rows);
|
||||||
|
|
||||||
|
saveCsv(this.window, csv, filename);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,48 +1,65 @@
|
|||||||
import Bottle, { Decorator } from 'bottlejs';
|
import axios from 'axios';
|
||||||
import ScrollToTop from '../ScrollToTop';
|
import Bottle from 'bottlejs';
|
||||||
import MainHeader from '../MainHeader';
|
import { ScrollToTop } from '../ScrollToTop';
|
||||||
import Home from '../Home';
|
import { MainHeader } from '../MainHeader';
|
||||||
import MenuLayout from '../MenuLayout';
|
import { Home } from '../Home';
|
||||||
|
import { MenuLayout } from '../MenuLayout';
|
||||||
import AsideMenu from '../AsideMenu';
|
import AsideMenu from '../AsideMenu';
|
||||||
import ErrorHandler from '../ErrorHandler';
|
import { ErrorHandler } from '../ErrorHandler';
|
||||||
import ShlinkVersions from '../ShlinkVersions';
|
import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||||
|
import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar';
|
||||||
|
import { ImageDownloader } from './ImageDownloader';
|
||||||
|
import { ReportExporter } from './ReportExporter';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
|
// Services
|
||||||
bottle.constant('window', (global as any).window);
|
bottle.constant('window', (global as any).window);
|
||||||
bottle.constant('console', global.console);
|
bottle.constant('console', global.console);
|
||||||
|
bottle.constant('axios', axios);
|
||||||
|
|
||||||
bottle.serviceFactory('ScrollToTop', ScrollToTop);
|
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
|
||||||
bottle.decorator('ScrollToTop', withRouter);
|
bottle.service('ReportExporter', ReportExporter, 'window', 'jsonToCsv');
|
||||||
|
|
||||||
|
// Components
|
||||||
|
bottle.serviceFactory('ScrollToTop', () => ScrollToTop);
|
||||||
|
|
||||||
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
|
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
|
||||||
bottle.decorator('MainHeader', withRouter);
|
|
||||||
|
|
||||||
bottle.serviceFactory('Home', () => Home);
|
bottle.serviceFactory('Home', () => Home);
|
||||||
bottle.decorator('Home', withoutSelectedServer);
|
bottle.decorator('Home', withoutSelectedServer);
|
||||||
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
|
bottle.decorator('Home', connect(['servers'], ['resetSelectedServer']));
|
||||||
|
|
||||||
bottle.serviceFactory(
|
bottle.serviceFactory(
|
||||||
'MenuLayout',
|
'MenuLayout',
|
||||||
MenuLayout,
|
MenuLayout,
|
||||||
'TagsList',
|
'TagsList',
|
||||||
'ShortUrls',
|
'ShortUrlsList',
|
||||||
'AsideMenu',
|
'AsideMenu',
|
||||||
'CreateShortUrl',
|
'CreateShortUrl',
|
||||||
'ShortUrlVisits',
|
'ShortUrlVisits',
|
||||||
'TagVisits',
|
'TagVisits',
|
||||||
|
'DomainVisits',
|
||||||
|
'OrphanVisits',
|
||||||
|
'NonOrphanVisits',
|
||||||
'ServerError',
|
'ServerError',
|
||||||
|
'Overview',
|
||||||
|
'EditShortUrl',
|
||||||
|
'ManageDomains',
|
||||||
);
|
);
|
||||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
bottle.decorator('MenuLayout', connect(['selectedServer'], ['selectServer', 'sidebarPresent', 'sidebarNotPresent']));
|
||||||
bottle.decorator('MenuLayout', withRouter);
|
|
||||||
|
|
||||||
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
||||||
|
|
||||||
bottle.serviceFactory('ShlinkVersions', () => ShlinkVersions);
|
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
|
||||||
bottle.decorator('ShlinkVersions', connect([ 'selectedServer' ]));
|
bottle.decorator('ShlinkVersionsContainer', connect(['selectedServer', 'sidebar']));
|
||||||
|
|
||||||
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('sidebarPresent', () => sidebarPresent);
|
||||||
|
bottle.serviceFactory('sidebarNotPresent', () => sidebarNotPresent);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default provideServices;
|
export default provideServices;
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.7 KiB |
@@ -1,8 +1,7 @@
|
|||||||
import Bottle, { IContainer } from 'bottlejs';
|
import Bottle, { IContainer } from 'bottlejs';
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
import { connect as reduxConnect } from 'react-redux';
|
import { connect as reduxConnect } from 'react-redux';
|
||||||
import { pick } from 'ramda';
|
import { pick } from 'ramda';
|
||||||
import App from '../App';
|
import provideApiServices from '../api/services/provideServices';
|
||||||
import provideCommonServices from '../common/services/provideServices';
|
import provideCommonServices from '../common/services/provideServices';
|
||||||
import provideShortUrlsServices from '../short-urls/services/provideServices';
|
import provideShortUrlsServices from '../short-urls/services/provideServices';
|
||||||
import provideServersServices from '../servers/services/provideServices';
|
import provideServersServices from '../servers/services/provideServices';
|
||||||
@@ -11,14 +10,18 @@ import provideTagsServices from '../tags/services/provideServices';
|
|||||||
import provideUtilsServices from '../utils/services/provideServices';
|
import provideUtilsServices from '../utils/services/provideServices';
|
||||||
import provideMercureServices from '../mercure/services/provideServices';
|
import provideMercureServices from '../mercure/services/provideServices';
|
||||||
import provideSettingsServices from '../settings/services/provideServices';
|
import provideSettingsServices from '../settings/services/provideServices';
|
||||||
|
import provideDomainsServices from '../domains/services/provideServices';
|
||||||
|
import provideAppServices from '../app/services/provideServices';
|
||||||
import { ConnectDecorator } from './types';
|
import { ConnectDecorator } from './types';
|
||||||
|
|
||||||
type LazyActionMap = Record<string, Function>;
|
type LazyActionMap = Record<string, Function>;
|
||||||
|
|
||||||
const bottle = new Bottle();
|
const bottle = new Bottle();
|
||||||
const { container } = bottle;
|
|
||||||
|
|
||||||
const lazyService = (container: IContainer, serviceName: string) => (...args: any[]) => container[serviceName](...args);
|
export const { container } = bottle;
|
||||||
|
|
||||||
|
const lazyService = <T extends Function, K>(cont: IContainer, serviceName: string) =>
|
||||||
|
(...args: any[]) => (cont[serviceName] as T)(...args) as K;
|
||||||
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
|
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
|
||||||
...map,
|
...map,
|
||||||
// Wrap actual action service in a function so that it is lazily created the first time it is called
|
// Wrap actual action service in a function so that it is lazily created the first time it is called
|
||||||
@@ -30,16 +33,14 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
|
|||||||
actionServiceNames.reduce(mapActionService, {}),
|
actionServiceNames.reduce(mapActionService, {}),
|
||||||
);
|
);
|
||||||
|
|
||||||
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer', 'Settings', 'ShlinkVersions');
|
provideAppServices(bottle, connect);
|
||||||
bottle.decorator('App', connect([ 'servers' ], [ 'fetchServers' ]));
|
provideCommonServices(bottle, connect);
|
||||||
|
provideApiServices(bottle);
|
||||||
provideCommonServices(bottle, connect, withRouter);
|
|
||||||
provideShortUrlsServices(bottle, connect);
|
provideShortUrlsServices(bottle, connect);
|
||||||
provideServersServices(bottle, connect, withRouter);
|
provideServersServices(bottle, connect);
|
||||||
provideTagsServices(bottle, connect);
|
provideTagsServices(bottle, connect);
|
||||||
provideVisitsServices(bottle, connect);
|
provideVisitsServices(bottle, connect);
|
||||||
provideUtilsServices(bottle);
|
provideUtilsServices(bottle);
|
||||||
provideMercureServices(bottle);
|
provideMercureServices(bottle);
|
||||||
provideSettingsServices(bottle, connect);
|
provideSettingsServices(bottle, connect);
|
||||||
|
provideDomainsServices(bottle, connect);
|
||||||
export default container;
|
|
||||||
|
|||||||
@@ -2,19 +2,21 @@ import ReduxThunk from 'redux-thunk';
|
|||||||
import { applyMiddleware, compose, createStore } from 'redux';
|
import { applyMiddleware, compose, createStore } from 'redux';
|
||||||
import { save, load, RLSOptions } from 'redux-localstorage-simple';
|
import { save, load, RLSOptions } from 'redux-localstorage-simple';
|
||||||
import reducers from '../reducers';
|
import reducers from '../reducers';
|
||||||
|
import { migrateDeprecatedSettings } from '../settings/helpers';
|
||||||
|
import { ShlinkState } from './types';
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV !== 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
// eslint-disable-next-line no-mixed-operators
|
||||||
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||||
|
|
||||||
const localStorageConfig: RLSOptions = {
|
const localStorageConfig: RLSOptions = {
|
||||||
states: [ 'settings', 'servers' ],
|
states: ['settings', 'servers'],
|
||||||
namespace: 'shlink',
|
namespace: 'shlink',
|
||||||
namespaceSeparator: '.',
|
namespaceSeparator: '.',
|
||||||
debounce: 300,
|
debounce: 300,
|
||||||
};
|
};
|
||||||
|
const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState);
|
||||||
|
|
||||||
const store = createStore(reducers, load(localStorageConfig), composeEnhancers(
|
export const store = createStore(reducers, preloadedState, composeEnhancers(
|
||||||
applyMiddleware(save(localStorageConfig), ReduxThunk),
|
applyMiddleware(save(localStorageConfig), ReduxThunk),
|
||||||
));
|
));
|
||||||
|
|
||||||
export default store;
|
|
||||||
|
|||||||
@@ -1,38 +1,44 @@
|
|||||||
import { MercureInfo } from '../mercure/reducers/mercureInfo';
|
import { MercureInfo } from '../mercure/reducers/mercureInfo';
|
||||||
import { SelectedServer, ServersMap } from '../servers/data';
|
import { SelectedServer, ServersMap } from '../servers/data';
|
||||||
import { Settings } from '../settings/reducers/settings';
|
import { Settings } from '../settings/reducers/settings';
|
||||||
import { ShortUrlMetaEdition } from '../short-urls/reducers/shortUrlMeta';
|
|
||||||
import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';
|
import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';
|
||||||
import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion';
|
import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion';
|
||||||
import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition';
|
import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition';
|
||||||
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
|
|
||||||
import { ShortUrlTags } from '../short-urls/reducers/shortUrlTags';
|
|
||||||
import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
|
import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
|
||||||
import { TagDeletion } from '../tags/reducers/tagDelete';
|
import { TagDeletion } from '../tags/reducers/tagDelete';
|
||||||
import { TagEdition } from '../tags/reducers/tagEdit';
|
import { TagEdition } from '../tags/reducers/tagEdit';
|
||||||
import { TagsList } from '../tags/reducers/tagsList';
|
import { TagsList } from '../tags/reducers/tagsList';
|
||||||
import { ShortUrlDetail } from '../visits/reducers/shortUrlDetail';
|
import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
|
||||||
import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
|
import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
|
||||||
import { TagVisits } from '../visits/reducers/tagVisits';
|
import { TagVisits } from '../visits/reducers/tagVisits';
|
||||||
|
import { DomainsList } from '../domains/reducers/domainsList';
|
||||||
|
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||||
|
import { VisitsInfo } from '../visits/types';
|
||||||
|
import { Sidebar } from '../common/reducers/sidebar';
|
||||||
|
import { DomainVisits } from '../visits/reducers/domainVisits';
|
||||||
|
|
||||||
export interface ShlinkState {
|
export interface ShlinkState {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
shortUrlsList: ShortUrlsList;
|
shortUrlsList: ShortUrlsList;
|
||||||
shortUrlsListParams: ShortUrlsListParams;
|
|
||||||
shortUrlCreationResult: ShortUrlCreation;
|
shortUrlCreationResult: ShortUrlCreation;
|
||||||
shortUrlDeletion: ShortUrlDeletion;
|
shortUrlDeletion: ShortUrlDeletion;
|
||||||
shortUrlTags: ShortUrlTags;
|
|
||||||
shortUrlMeta: ShortUrlMetaEdition;
|
|
||||||
shortUrlEdition: ShortUrlEdition;
|
shortUrlEdition: ShortUrlEdition;
|
||||||
shortUrlVisits: ShortUrlVisits;
|
shortUrlVisits: ShortUrlVisits;
|
||||||
tagVisits: TagVisits;
|
tagVisits: TagVisits;
|
||||||
|
domainVisits: DomainVisits;
|
||||||
|
orphanVisits: VisitsInfo;
|
||||||
|
nonOrphanVisits: VisitsInfo;
|
||||||
shortUrlDetail: ShortUrlDetail;
|
shortUrlDetail: ShortUrlDetail;
|
||||||
tagsList: TagsList;
|
tagsList: TagsList;
|
||||||
tagDelete: TagDeletion;
|
tagDelete: TagDeletion;
|
||||||
tagEdit: TagEdition;
|
tagEdit: TagEdition;
|
||||||
mercureInfo: MercureInfo;
|
mercureInfo: MercureInfo;
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
|
domainsList: DomainsList;
|
||||||
|
visitsOverview: VisitsOverview;
|
||||||
|
appUpdated: boolean;
|
||||||
|
sidebar: Sidebar;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
||||||
|
|||||||
63
src/domains/DomainRow.tsx
Normal file
63
src/domains/DomainRow.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { FC, useEffect } from 'react';
|
||||||
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faDotCircle as defaultDomainIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { ShlinkDomainRedirects } from '../api/types';
|
||||||
|
import { OptionalString } from '../utils/utils';
|
||||||
|
import { SelectedServer } from '../servers/data';
|
||||||
|
import { Domain } from './data';
|
||||||
|
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
|
||||||
|
import { DomainDropdown } from './helpers/DomainDropdown';
|
||||||
|
|
||||||
|
interface DomainRowProps {
|
||||||
|
domain: Domain;
|
||||||
|
defaultRedirects?: ShlinkDomainRedirects;
|
||||||
|
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||||
|
checkDomainHealth: (domain: string) => void;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
|
||||||
|
<span className="text-muted">
|
||||||
|
{!fallback && <small>No redirect</small>}
|
||||||
|
{fallback && <>{fallback} <small>(as fallback)</small></>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const DefaultDomain: FC = () => (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon fixedWidth icon={defaultDomainIcon} className="text-primary" id="defaultDomainIcon" />
|
||||||
|
<UncontrolledTooltip target="defaultDomainIcon" placement="right">Default domain</UncontrolledTooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DomainRow: FC<DomainRowProps> = (
|
||||||
|
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer },
|
||||||
|
) => {
|
||||||
|
const { domain: authority, isDefault, redirects, status } = domain;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkDomainHealth(domain.domain);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="responsive-table__row">
|
||||||
|
<td className="responsive-table__cell" data-th="Is default domain">{isDefault && <DefaultDomain />}</td>
|
||||||
|
<th className="responsive-table__cell" data-th="Domain">{authority}</th>
|
||||||
|
<td className="responsive-table__cell" data-th="Base path redirect">
|
||||||
|
{redirects?.baseUrlRedirect ?? <Nr fallback={defaultRedirects?.baseUrlRedirect} />}
|
||||||
|
</td>
|
||||||
|
<td className="responsive-table__cell" data-th="Regular 404 redirect">
|
||||||
|
{redirects?.regular404Redirect ?? <Nr fallback={defaultRedirects?.regular404Redirect} />}
|
||||||
|
</td>
|
||||||
|
<td className="responsive-table__cell" data-th="Invalid short URL redirect">
|
||||||
|
{redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />}
|
||||||
|
</td>
|
||||||
|
<td className="responsive-table__cell text-lg-center" data-th="Status">
|
||||||
|
<DomainStatusIcon status={status} />
|
||||||
|
</td>
|
||||||
|
<td className="responsive-table__cell text-end">
|
||||||
|
<DomainDropdown domain={domain} editDomainRedirects={editDomainRedirects} selectedServer={selectedServer} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
19
src/domains/DomainSelector.scss
Normal file
19
src/domains/DomainSelector.scss
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
@import '../utils/base';
|
||||||
|
@import '../utils/mixins/vertical-align';
|
||||||
|
|
||||||
|
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn,
|
||||||
|
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:hover,
|
||||||
|
.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:active {
|
||||||
|
color: $textPlaceholder !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active,
|
||||||
|
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:hover,
|
||||||
|
.domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:active {
|
||||||
|
color: var(--input-text-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domains-dropdown__back-btn.domains-dropdown__back-btn,
|
||||||
|
.domains-dropdown__back-btn.domains-dropdown__back-btn:hover {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
73
src/domains/DomainSelector.tsx
Normal file
73
src/domains/DomainSelector.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Button, DropdownItem, Input, InputGroup, UncontrolledTooltip, InputProps } from 'reactstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faUndo } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { isEmpty, pipe } from 'ramda';
|
||||||
|
import { DropdownBtn } from '../utils/DropdownBtn';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
|
import { DomainsList } from './reducers/domainsList';
|
||||||
|
import './DomainSelector.scss';
|
||||||
|
|
||||||
|
export interface DomainSelectorProps extends Omit<InputProps, 'onChange'> {
|
||||||
|
value?: string;
|
||||||
|
onChange: (domain: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DomainSelectorConnectProps extends DomainSelectorProps {
|
||||||
|
listDomains: Function;
|
||||||
|
domainsList: DomainsList;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DomainSelector = ({ listDomains, value, domainsList, onChange }: DomainSelectorConnectProps) => {
|
||||||
|
const [inputDisplayed,, showInput, hideInput] = useToggle();
|
||||||
|
const { domains } = domainsList;
|
||||||
|
const valueIsEmpty = isEmpty(value);
|
||||||
|
const unselectDomain = () => onChange('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listDomains();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return inputDisplayed ? (
|
||||||
|
<InputGroup>
|
||||||
|
<Input
|
||||||
|
value={value ?? ''}
|
||||||
|
placeholder="Domain"
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
id="backToDropdown"
|
||||||
|
outline
|
||||||
|
type="button"
|
||||||
|
className="domains-dropdown__back-btn"
|
||||||
|
aria-label="Back to domains list"
|
||||||
|
onClick={pipe(unselectDomain, hideInput)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faUndo} />
|
||||||
|
</Button>
|
||||||
|
<UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
|
||||||
|
Existing domains
|
||||||
|
</UncontrolledTooltip>
|
||||||
|
</InputGroup>
|
||||||
|
) : (
|
||||||
|
<DropdownBtn
|
||||||
|
text={valueIsEmpty ? 'Domain' : `Domain: ${value}`}
|
||||||
|
className={!valueIsEmpty ? 'domains-dropdown__toggle-btn--active' : 'domains-dropdown__toggle-btn'}
|
||||||
|
>
|
||||||
|
{domains.map(({ domain, isDefault }) => (
|
||||||
|
<DropdownItem
|
||||||
|
key={domain}
|
||||||
|
active={(value === domain || isDefault) && valueIsEmpty}
|
||||||
|
onClick={() => onChange(domain)}
|
||||||
|
>
|
||||||
|
{domain}
|
||||||
|
{isDefault && <span className="float-end text-muted">default</span>}
|
||||||
|
</DropdownItem>
|
||||||
|
))}
|
||||||
|
<DropdownItem divider />
|
||||||
|
<DropdownItem onClick={pipe(unselectDomain, showInput)}>
|
||||||
|
<i>New domain</i>
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownBtn>
|
||||||
|
);
|
||||||
|
};
|
||||||
76
src/domains/ManageDomains.tsx
Normal file
76
src/domains/ManageDomains.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { FC, useEffect } from 'react';
|
||||||
|
import Message from '../utils/Message';
|
||||||
|
import { Result } from '../utils/Result';
|
||||||
|
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||||
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
import { SearchField } from '../utils/SearchField';
|
||||||
|
import { ShlinkDomainRedirects } from '../api/types';
|
||||||
|
import { SelectedServer } from '../servers/data';
|
||||||
|
import { DomainsList } from './reducers/domainsList';
|
||||||
|
import { DomainRow } from './DomainRow';
|
||||||
|
|
||||||
|
interface ManageDomainsProps {
|
||||||
|
listDomains: Function;
|
||||||
|
filterDomains: (searchTerm: string) => void;
|
||||||
|
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||||
|
checkDomainHealth: (domain: string) => void;
|
||||||
|
domainsList: DomainsList;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = ['', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', ''];
|
||||||
|
|
||||||
|
export const ManageDomains: FC<ManageDomainsProps> = (
|
||||||
|
{ listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer },
|
||||||
|
) => {
|
||||||
|
const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList;
|
||||||
|
const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listDomains();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Message loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Result type="error">
|
||||||
|
<ShlinkApiError errorData={errorData} fallbackMessage="Error loading domains :(" />
|
||||||
|
</Result>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimpleCard>
|
||||||
|
<table className="table table-hover responsive-table mb-0">
|
||||||
|
<thead className="responsive-table__header">
|
||||||
|
<tr>{headers.map((column, index) => <th key={index}>{column}</th>)}</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{domains.length < 1 && <tr><td colSpan={headers.length} className="text-center">No results found</td></tr>}
|
||||||
|
{domains.map((domain) => (
|
||||||
|
<DomainRow
|
||||||
|
key={domain.domain}
|
||||||
|
domain={domain}
|
||||||
|
editDomainRedirects={editDomainRedirects}
|
||||||
|
checkDomainHealth={checkDomainHealth}
|
||||||
|
defaultRedirects={resolvedDefaultRedirects}
|
||||||
|
selectedServer={selectedServer}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</SimpleCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SearchField className="mb-3" onChange={filterDomains} />
|
||||||
|
{renderContent()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
7
src/domains/data/index.ts
Normal file
7
src/domains/data/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { ShlinkDomain } from '../../api/types';
|
||||||
|
|
||||||
|
export type DomainStatus = 'validating' | 'valid' | 'invalid';
|
||||||
|
|
||||||
|
export interface Domain extends ShlinkDomain {
|
||||||
|
status: DomainStatus;
|
||||||
|
}
|
||||||
51
src/domains/helpers/DomainDropdown.tsx
Normal file
51
src/domains/helpers/DomainDropdown.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { DropdownItem } from 'reactstrap';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { faChartPie as pieChartIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { useToggle } from '../../utils/helpers/hooks';
|
||||||
|
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
||||||
|
import { EditDomainRedirectsModal } from './EditDomainRedirectsModal';
|
||||||
|
import { Domain } from '../data';
|
||||||
|
import { ShlinkDomainRedirects } from '../../api/types';
|
||||||
|
import { supportsDefaultDomainRedirectsEdition, supportsDomainVisits } from '../../utils/helpers/features';
|
||||||
|
import { getServerId, SelectedServer } from '../../servers/data';
|
||||||
|
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
|
||||||
|
|
||||||
|
interface DomainDropdownProps {
|
||||||
|
domain: Domain;
|
||||||
|
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedirects, selectedServer }) => {
|
||||||
|
const [isOpen, toggle] = useToggle();
|
||||||
|
const [isModalOpen, toggleModal] = useToggle();
|
||||||
|
const { isDefault } = domain;
|
||||||
|
const canBeEdited = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer);
|
||||||
|
const withVisits = supportsDomainVisits(selectedServer);
|
||||||
|
const serverId = getServerId(selectedServer);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownBtnMenu isOpen={isOpen} toggle={toggle}>
|
||||||
|
{withVisits && (
|
||||||
|
<DropdownItem
|
||||||
|
tag={Link}
|
||||||
|
to={`/server/${serverId}/domain/${domain.domain}${domain.isDefault ? `_${DEFAULT_DOMAIN}` : ''}/visits`}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
<DropdownItem disabled={!canBeEdited} onClick={!canBeEdited ? undefined : toggleModal}>
|
||||||
|
<FontAwesomeIcon fixedWidth icon={editIcon} /> Edit redirects
|
||||||
|
</DropdownItem>
|
||||||
|
|
||||||
|
<EditDomainRedirectsModal
|
||||||
|
domain={domain}
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
toggle={toggleModal}
|
||||||
|
editDomainRedirects={editDomainRedirects}
|
||||||
|
/>
|
||||||
|
</DropdownBtnMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
62
src/domains/helpers/DomainStatusIcon.tsx
Normal file
62
src/domains/helpers/DomainStatusIcon.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { FC, useEffect, useRef, useState } from 'react';
|
||||||
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import { ExternalLink } from 'react-external-link';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import {
|
||||||
|
faTimes as invalidIcon,
|
||||||
|
faCheck as checkIcon,
|
||||||
|
faCircleNotch as loadingStatusIcon,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { MediaMatcher } from '../../utils/types';
|
||||||
|
import { DomainStatus } from '../data';
|
||||||
|
|
||||||
|
interface DomainStatusIconProps {
|
||||||
|
status: DomainStatus;
|
||||||
|
matchMedia?: MediaMatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia = window.matchMedia }) => {
|
||||||
|
const ref = useRef<HTMLSpanElement>();
|
||||||
|
const matchesMobile = () => matchMedia('(max-width: 991px)').matches;
|
||||||
|
const [isMobile, setIsMobile] = useState<boolean>(matchesMobile());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = () => setIsMobile(matchesMobile());
|
||||||
|
|
||||||
|
window.addEventListener('resize', listener);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('resize', listener);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (status === 'validating') {
|
||||||
|
return <FontAwesomeIcon fixedWidth icon={loadingStatusIcon} spin />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
ref={(el: HTMLSpanElement) => {
|
||||||
|
ref.current = el;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === 'valid'
|
||||||
|
? <FontAwesomeIcon fixedWidth icon={checkIcon} className="text-muted" />
|
||||||
|
: <FontAwesomeIcon fixedWidth icon={invalidIcon} className="text-danger" />}
|
||||||
|
</span>
|
||||||
|
<UncontrolledTooltip
|
||||||
|
target={(() => ref.current) as any}
|
||||||
|
placement={isMobile ? 'top-start' : 'left'}
|
||||||
|
autohide={status === 'valid'}
|
||||||
|
>
|
||||||
|
{status === 'valid' ? 'Congratulations! This domain is properly configured.' : (
|
||||||
|
<span>
|
||||||
|
Oops! There is some missing configuration, and short URLs shared with this domain will not work.
|
||||||
|
<br />
|
||||||
|
Check the <ExternalLink href="https://slnk.to/multi-domain-docs">documentation</ExternalLink> in order to
|
||||||
|
find out what is missing.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</UncontrolledTooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
72
src/domains/helpers/EditDomainRedirectsModal.tsx
Normal file
72
src/domains/helpers/EditDomainRedirectsModal.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { FC, useState } from 'react';
|
||||||
|
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
|
import { ShlinkDomain, ShlinkDomainRedirects } from '../../api/types';
|
||||||
|
import { InputFormGroup, InputFormGroupProps } from '../../utils/forms/InputFormGroup';
|
||||||
|
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
|
||||||
|
import { InfoTooltip } from '../../utils/InfoTooltip';
|
||||||
|
|
||||||
|
interface EditDomainRedirectsModalProps {
|
||||||
|
domain: ShlinkDomain;
|
||||||
|
isOpen: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormGroup: FC<InputFormGroupProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
|
||||||
|
<InputFormGroup
|
||||||
|
{...rest}
|
||||||
|
required={false}
|
||||||
|
type="url"
|
||||||
|
placeholder="No redirect"
|
||||||
|
className={isLast ? 'mb-0' : ''}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
|
||||||
|
{ isOpen, toggle, domain, editDomainRedirects },
|
||||||
|
) => {
|
||||||
|
const [baseUrlRedirect, setBaseUrlRedirect] = useState(domain.redirects?.baseUrlRedirect ?? '');
|
||||||
|
const [regular404Redirect, setRegular404Redirect] = useState(domain.redirects?.regular404Redirect ?? '');
|
||||||
|
const [invalidShortUrlRedirect, setInvalidShortUrlRedirect] = useState(
|
||||||
|
domain.redirects?.invalidShortUrlRedirect ?? '',
|
||||||
|
);
|
||||||
|
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects(domain.domain, {
|
||||||
|
baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect),
|
||||||
|
regular404Redirect: nonEmptyValueOrNull(regular404Redirect),
|
||||||
|
invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect),
|
||||||
|
}).then(toggle));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<ModalHeader toggle={toggle}>Edit redirects for <b>{domain.domain}</b></ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<FormGroup value={baseUrlRedirect} onChange={setBaseUrlRedirect}>
|
||||||
|
<InfoTooltip className="me-2" placement="bottom">
|
||||||
|
Visitors accessing the base url, as in <b>https://{domain.domain}/</b>, will be redirected to this URL.
|
||||||
|
</InfoTooltip>
|
||||||
|
Base URL
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup value={regular404Redirect} onChange={setRegular404Redirect}>
|
||||||
|
<InfoTooltip className="me-2" placement="bottom">
|
||||||
|
Visitors accessing a url not matching a short URL pattern, as in <b>https://{domain.domain}/???/[...]</b>,
|
||||||
|
will be redirected to this URL.
|
||||||
|
</InfoTooltip>
|
||||||
|
Regular 404
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup value={invalidShortUrlRedirect} isLast onChange={setInvalidShortUrlRedirect}>
|
||||||
|
<InfoTooltip className="me-2" placement="bottom">
|
||||||
|
Visitors accessing a url matching a short URL pattern, but not matching an existing short code, will be
|
||||||
|
redirected to this URL.
|
||||||
|
</InfoTooltip>
|
||||||
|
Invalid short URL
|
||||||
|
</FormGroup>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="link" type="button" onClick={toggle}>Cancel</Button>
|
||||||
|
<Button color="primary">Save</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
31
src/domains/reducers/domainRedirects.ts
Normal file
31
src/domains/reducers/domainRedirects.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Action, Dispatch } from 'redux';
|
||||||
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
|
import { ShlinkDomainRedirects } from '../../api/types';
|
||||||
|
import { GetState } from '../../container/types';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
import { parseApiError } from '../../api/utils';
|
||||||
|
|
||||||
|
export const EDIT_DOMAIN_REDIRECTS_START = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_START';
|
||||||
|
export const EDIT_DOMAIN_REDIRECTS_ERROR = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_ERROR';
|
||||||
|
export const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
|
||||||
|
|
||||||
|
export interface EditDomainRedirectsAction extends Action<string> {
|
||||||
|
domain: string;
|
||||||
|
redirects: ShlinkDomainRedirects;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const editDomainRedirects = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||||
|
domain: string,
|
||||||
|
domainRedirects: Partial<ShlinkDomainRedirects>,
|
||||||
|
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||||
|
dispatch({ type: EDIT_DOMAIN_REDIRECTS_START });
|
||||||
|
const { editDomainRedirects: shlinkEditDomainRedirects } = buildShlinkApiClient(getState);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redirects = await shlinkEditDomainRedirects({ domain, ...domainRedirects });
|
||||||
|
|
||||||
|
dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects });
|
||||||
|
} catch (e: any) {
|
||||||
|
dispatch<ApiErrorAction>({ type: EDIT_DOMAIN_REDIRECTS_ERROR, errorData: parseApiError(e) });
|
||||||
|
}
|
||||||
|
};
|
||||||
128
src/domains/reducers/domainsList.ts
Normal file
128
src/domains/reducers/domainsList.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { Action, Dispatch } from 'redux';
|
||||||
|
import { ProblemDetailsError, ShlinkDomainRedirects } from '../../api/types';
|
||||||
|
import { buildReducer } from '../../utils/helpers/redux';
|
||||||
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
|
import { GetState } from '../../container/types';
|
||||||
|
import { parseApiError } from '../../api/utils';
|
||||||
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
import { Domain, DomainStatus } from '../data';
|
||||||
|
import { hasServerData } from '../../servers/data';
|
||||||
|
import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
|
||||||
|
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects';
|
||||||
|
|
||||||
|
export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
|
||||||
|
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
|
||||||
|
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
|
||||||
|
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
|
||||||
|
export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN';
|
||||||
|
|
||||||
|
export interface DomainsList {
|
||||||
|
domains: Domain[];
|
||||||
|
filteredDomains: Domain[];
|
||||||
|
defaultRedirects?: ShlinkDomainRedirects;
|
||||||
|
loading: boolean;
|
||||||
|
error: boolean;
|
||||||
|
errorData?: ProblemDetailsError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListDomainsAction extends Action<string> {
|
||||||
|
domains: Domain[];
|
||||||
|
defaultRedirects?: ShlinkDomainRedirects;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterDomainsAction extends Action<string> {
|
||||||
|
searchTerm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ValidateDomain extends Action<string> {
|
||||||
|
domain: string;
|
||||||
|
status: DomainStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: DomainsList = {
|
||||||
|
domains: [],
|
||||||
|
filteredDomains: [],
|
||||||
|
loading: false,
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DomainsCombinedAction = ListDomainsAction
|
||||||
|
& ApiErrorAction
|
||||||
|
& FilterDomainsAction
|
||||||
|
& EditDomainRedirectsAction
|
||||||
|
& ValidateDomain;
|
||||||
|
|
||||||
|
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
|
||||||
|
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, redirects });
|
||||||
|
|
||||||
|
export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
|
||||||
|
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, status });
|
||||||
|
|
||||||
|
export default buildReducer<DomainsList, DomainsCombinedAction>({
|
||||||
|
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
||||||
|
[LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }),
|
||||||
|
[LIST_DOMAINS]: (_, { domains, defaultRedirects }) =>
|
||||||
|
({ ...initialState, domains, filteredDomains: domains, defaultRedirects }),
|
||||||
|
[FILTER_DOMAINS]: (state, { searchTerm }) => ({
|
||||||
|
...state,
|
||||||
|
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm)),
|
||||||
|
}),
|
||||||
|
[EDIT_DOMAIN_REDIRECTS]: (state, { domain, redirects }) => ({
|
||||||
|
...state,
|
||||||
|
domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||||
|
filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||||
|
}),
|
||||||
|
[VALIDATE_DOMAIN]: (state, { domain, status }) => ({
|
||||||
|
...state,
|
||||||
|
domains: state.domains.map(replaceStatusOnDomain(domain, status)),
|
||||||
|
filteredDomains: state.filteredDomains.map(replaceStatusOnDomain(domain, status)),
|
||||||
|
}),
|
||||||
|
}, initialState);
|
||||||
|
|
||||||
|
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
||||||
|
dispatch: Dispatch,
|
||||||
|
getState: GetState,
|
||||||
|
) => {
|
||||||
|
dispatch({ type: LIST_DOMAINS_START });
|
||||||
|
const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await shlinkListDomains().then(({ data, defaultRedirects }) => ({
|
||||||
|
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
|
||||||
|
defaultRedirects,
|
||||||
|
}));
|
||||||
|
|
||||||
|
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, ...resp });
|
||||||
|
} catch (e: any) {
|
||||||
|
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm });
|
||||||
|
|
||||||
|
export const checkDomainHealth = (buildShlinkApiClient: ShlinkApiClientBuilder) => (domain: string) => async (
|
||||||
|
dispatch: Dispatch,
|
||||||
|
getState: GetState,
|
||||||
|
) => {
|
||||||
|
const { selectedServer } = getState();
|
||||||
|
|
||||||
|
if (!hasServerData(selectedServer)) {
|
||||||
|
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { url, ...rest } = selectedServer;
|
||||||
|
const { health } = buildShlinkApiClient({
|
||||||
|
...rest,
|
||||||
|
url: replaceAuthorityFromUri(url, domain),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { status } = await health();
|
||||||
|
|
||||||
|
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: status === 'pass' ? 'valid' : 'invalid' });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
|
||||||
|
}
|
||||||
|
};
|
||||||
26
src/domains/services/provideServices.ts
Normal file
26
src/domains/services/provideServices.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import Bottle from 'bottlejs';
|
||||||
|
import { ConnectDecorator } from '../../container/types';
|
||||||
|
import { checkDomainHealth, filterDomains, listDomains } from '../reducers/domainsList';
|
||||||
|
import { DomainSelector } from '../DomainSelector';
|
||||||
|
import { ManageDomains } from '../ManageDomains';
|
||||||
|
import { editDomainRedirects } from '../reducers/domainRedirects';
|
||||||
|
|
||||||
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
|
// Components
|
||||||
|
bottle.serviceFactory('DomainSelector', () => DomainSelector);
|
||||||
|
bottle.decorator('DomainSelector', connect(['domainsList'], ['listDomains']));
|
||||||
|
|
||||||
|
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
||||||
|
bottle.decorator('ManageDomains', connect(
|
||||||
|
['domainsList', 'selectedServer'],
|
||||||
|
['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'],
|
||||||
|
));
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
||||||
|
bottle.serviceFactory('filterDomains', () => filterDomains);
|
||||||
|
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
|
||||||
|
bottle.serviceFactory('checkDomainHealth', checkDomainHealth, 'buildShlinkApiClient');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provideServices;
|
||||||
214
src/index.scss
214
src/index.scss
@@ -1,41 +1,205 @@
|
|||||||
|
/* stylelint-disable no-descending-specificity */
|
||||||
|
|
||||||
@import './utils/base';
|
@import './utils/base';
|
||||||
|
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
||||||
|
@import './common/react-tag-autocomplete.scss';
|
||||||
|
@import './theme/theme';
|
||||||
|
@import './utils/table/ResponsiveTable';
|
||||||
|
@import './utils/StickyCardPaginator';
|
||||||
|
|
||||||
|
* {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
background: var(--secondary-color);
|
||||||
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
a,
|
||||||
outline: none !important;
|
.btn-link {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* stylelint-disable-next-line selector-max-pseudo-class */
|
||||||
|
a:not(.nav-link):not(.navbar-brand):not(.page-link):not(.highlight-card):not(.btn):not(.dropdown-item):hover,
|
||||||
|
.btn-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-main {
|
.bg-main {
|
||||||
background-color: $mainColor !important;
|
background-color: $mainColor !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-body,
|
||||||
|
.card-header,
|
||||||
|
.list-group-item {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
background-color: var(--primary-color-alfa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
box-shadow: 0 .125rem .25rem rgb(0 0 0 / .075);
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content,
|
||||||
|
.page-link,
|
||||||
|
.page-item.disabled .page-link,
|
||||||
|
.dropdown-menu {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header,
|
||||||
|
.modal-footer,
|
||||||
|
.card-header,
|
||||||
|
.card-footer,
|
||||||
|
.table thead th,
|
||||||
|
.table th,
|
||||||
|
.table td,
|
||||||
|
.page-link,
|
||||||
|
.page-link:hover,
|
||||||
|
.page-item.disabled .page-link,
|
||||||
|
.dropdown-divider,
|
||||||
|
.dropdown-menu,
|
||||||
|
.list-group-item,
|
||||||
|
.modal-content,
|
||||||
|
hr {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-bordered,
|
||||||
|
.table-bordered thead th,
|
||||||
|
.table-bordered thead td {
|
||||||
|
border-color: var(--table-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-link:hover,
|
||||||
|
.page-link:focus {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-item.active .page-link {
|
||||||
|
background-color: var(--brand-color);
|
||||||
|
border-color: var(--brand-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-link {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-xl {
|
||||||
|
@media (min-width: $xlgMin) {
|
||||||
|
max-width: 1320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $smMax) {
|
||||||
|
padding-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Deprecated. Brought from bootstrap 4 */
|
||||||
|
.btn-block {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-primary:hover,
|
||||||
|
.btn-primary:active,
|
||||||
|
.btn-primary.active,
|
||||||
|
.btn-outline-primary:hover,
|
||||||
|
.btn-outline-primary:active,
|
||||||
|
.btn-outline-primary.active, {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item,
|
||||||
|
.dropdown-item-text {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-item:not(:disabled) {
|
.dropdown-item:not(:disabled) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-item:focus:not(:disabled),
|
||||||
|
.dropdown-item:hover:not(:disabled),
|
||||||
.dropdown-item.active:not(:disabled),
|
.dropdown-item.active:not(:disabled),
|
||||||
.dropdown-item:active:not(:disabled) {
|
.dropdown-item:active:not(:disabled) {
|
||||||
background-color: $lightGrey !important;
|
background-color: var(--active-color) !important;
|
||||||
color: inherit !important;
|
color: var(--text-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item--danger.dropdown-item--danger {
|
||||||
|
color: $dangerColor;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&.active {
|
||||||
|
color: $dangerColor !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-main {
|
.badge-main {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background-color: $mainColor;
|
background-color: var(--brand-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-datepicker__input-container,
|
.close,
|
||||||
.react-datepicker-wrapper {
|
.close:hover,
|
||||||
display: block !important;
|
.table,
|
||||||
|
.table-hover > tbody > tr:hover > *,
|
||||||
|
.table-hover > tbody > tr > * {
|
||||||
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-datepicker-popper {
|
.btn-close {
|
||||||
z-index: 2;
|
filter: var(--btn-close-filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover tbody tr:hover {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
.form-control:focus {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--input-border-color);
|
||||||
|
color: var(--input-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control.disabled,
|
||||||
|
.form-control:disabled {
|
||||||
|
background-color: var(--input-disabled-color);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .form-control:not(:disabled),
|
||||||
|
.card .form-control:not(:disabled):hover {
|
||||||
|
background-color: var(--input-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-active,
|
||||||
|
.table-active > th,
|
||||||
|
.table-active > td {
|
||||||
|
background-color: var(--table-highlight-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
@@ -44,28 +208,34 @@ body,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination .page-link {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.indivisible {
|
.indivisible {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.text-ellipsis {
|
.text-ellipsis {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-datepicker__day--keyboard-selected {
|
|
||||||
background-color: $mainColor;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: darken($mainColor, 12%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
background-color: $mainColor;
|
background-color: $mainColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-xs-block {
|
||||||
|
@media (max-width: $xsMax) {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-md-block {
|
||||||
|
@media (max-width: $mdMax) {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
import React from 'react';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { render } from 'react-dom';
|
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { homepage } from '../package.json';
|
import pack from '../package.json';
|
||||||
import container from './container';
|
import { container } from './container';
|
||||||
import store from './container/store';
|
import { store } from './container/store';
|
||||||
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
||||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
||||||
|
import 'chart.js/auto'; // TODO Import specific ones to reduce bundle size https://react-chartjs-2.js.org/docs/migration-to-v4/#tree-shaking
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import './common/react-tagsinput.scss';
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
|
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
|
||||||
fixLeafletIcons();
|
fixLeafletIcons();
|
||||||
|
|
||||||
const { App, ScrollToTop, ErrorHandler } = container;
|
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
|
||||||
|
|
||||||
render(
|
createRoot(document.getElementById('root')!).render( // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<BrowserRouter basename={homepage}>
|
<BrowserRouter basename={pack.homepage}>
|
||||||
<ErrorHandler>
|
<ErrorHandler>
|
||||||
<ScrollToTop>
|
<ScrollToTop>
|
||||||
<App />
|
<App />
|
||||||
@@ -27,5 +26,11 @@ render(
|
|||||||
</ErrorHandler>
|
</ErrorHandler>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</Provider>,
|
</Provider>,
|
||||||
document.getElementById('root'),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Learn more about service workers: https://cra.link/PWA
|
||||||
|
registerServiceWorker({
|
||||||
|
onUpdate() {
|
||||||
|
store.dispatch(appUpdateAvailable());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
7
src/mercure/helpers/Topics.ts
Normal file
7
src/mercure/helpers/Topics.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export class Topics {
|
||||||
|
public static readonly visits = 'https://shlink.io/new-visit';
|
||||||
|
|
||||||
|
public static readonly orphanVisits = 'https://shlink.io/new-orphan-visit';
|
||||||
|
|
||||||
|
public static readonly shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`;
|
||||||
|
}
|
||||||
@@ -1,40 +1,43 @@
|
|||||||
import React, { FC, useEffect } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { pipe } from 'ramda';
|
import { pipe } from 'ramda';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
import { CreateVisit } from '../../visits/types';
|
import { CreateVisit } from '../../visits/types';
|
||||||
import { MercureInfo } from '../reducers/mercureInfo';
|
import { MercureInfo } from '../reducers/mercureInfo';
|
||||||
import { bindToMercureTopic } from './index';
|
import { bindToMercureTopic } from './index';
|
||||||
|
|
||||||
export interface MercureBoundProps {
|
export interface MercureBoundProps {
|
||||||
createNewVisits: (createdVisits: CreateVisit[]) => void;
|
createNewVisits: (createdVisits: CreateVisit[]) => void;
|
||||||
loadMercureInfo: Function;
|
loadMercureInfo: () => void;
|
||||||
mercureInfo: MercureInfo;
|
mercureInfo: MercureInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function boundToMercureHub<T = {}>(
|
export function boundToMercureHub<T = {}>(
|
||||||
WrappedComponent: FC<MercureBoundProps & T>,
|
WrappedComponent: FC<MercureBoundProps & T>,
|
||||||
getTopicForProps: (props: T) => string,
|
getTopicsForProps: (props: T, routeParams: any) => string[],
|
||||||
) {
|
) {
|
||||||
const pendingUpdates = new Set<CreateVisit>();
|
const pendingUpdates = new Set<CreateVisit>();
|
||||||
|
|
||||||
return (props: MercureBoundProps & T) => {
|
return (props: MercureBoundProps & T) => {
|
||||||
const { createNewVisits, loadMercureInfo, mercureInfo } = props;
|
const { createNewVisits, loadMercureInfo, mercureInfo } = props;
|
||||||
const { interval } = mercureInfo;
|
const { interval } = mercureInfo;
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisits([ visit ]);
|
const onMessage = (visit: CreateVisit) => (interval ? pendingUpdates.add(visit) : createNewVisits([visit]));
|
||||||
const closeEventSource = bindToMercureTopic(mercureInfo, getTopicForProps(props), onMessage, loadMercureInfo);
|
const topics = getTopicsForProps(props, params);
|
||||||
|
const closeEventSource = bindToMercureTopic(mercureInfo, topics, onMessage, loadMercureInfo);
|
||||||
|
|
||||||
if (!interval) {
|
if (!interval) {
|
||||||
return closeEventSource;
|
return closeEventSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
createNewVisits([ ...pendingUpdates ]);
|
createNewVisits([...pendingUpdates]);
|
||||||
pendingUpdates.clear();
|
pendingUpdates.clear();
|
||||||
}, interval * 1000 * 60);
|
}, interval * 1000 * 60);
|
||||||
|
|
||||||
return pipe(() => clearInterval(timer), () => closeEventSource?.());
|
return pipe(() => clearInterval(timer), () => closeEventSource?.());
|
||||||
}, [ mercureInfo ]);
|
}, [mercureInfo]);
|
||||||
|
|
||||||
return <WrappedComponent {...props} />;
|
return <WrappedComponent {...props} />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
|
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
|
||||||
import { MercureInfo } from '../reducers/mercureInfo';
|
import { MercureInfo } from '../reducers/mercureInfo';
|
||||||
|
|
||||||
export const bindToMercureTopic = <T>(mercureInfo: MercureInfo, topic: string, onMessage: (message: T) => void, onTokenExpired: Function) => { // eslint-disable-line max-len
|
export const bindToMercureTopic = <T>(mercureInfo: MercureInfo, topics: string[], onMessage: (message: T) => void, onTokenExpired: () => void) => { // eslint-disable-line max-len
|
||||||
const { mercureHubUrl, token, loading, error } = mercureInfo;
|
const { mercureHubUrl, token, loading, error } = mercureInfo;
|
||||||
|
|
||||||
if (loading || error || !mercureHubUrl) {
|
if (loading || error || !mercureHubUrl) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hubUrl = new URL(mercureHubUrl);
|
const onEventSourceMessage = ({ data }: { data: string }) => onMessage(JSON.parse(data) as T);
|
||||||
|
const onEventSourceError = ({ status }: { status: number }) => status === 401 && onTokenExpired();
|
||||||
|
|
||||||
hubUrl.searchParams.append('topic', topic);
|
const subscriptions = topics.map((topic) => {
|
||||||
const es = new EventSource(hubUrl, {
|
const hubUrl = new URL(mercureHubUrl);
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
hubUrl.searchParams.append('topic', topic);
|
||||||
},
|
const es = new EventSource(hubUrl, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
es.onmessage = onEventSourceMessage;
|
||||||
|
es.onerror = onEventSourceError;
|
||||||
|
|
||||||
|
return es;
|
||||||
});
|
});
|
||||||
|
|
||||||
es.onmessage = ({ data }: { data: string }) => onMessage(JSON.parse(data) as T);
|
return () => subscriptions.forEach((es) => es.close());
|
||||||
es.onerror = ({ status }: { status: number }) => status === 401 && onTokenExpired();
|
|
||||||
|
|
||||||
return () => es.close();
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import { ShlinkMercureInfo } from '../../utils/services/types';
|
import { ShlinkMercureInfo } from '../../api/types';
|
||||||
import { GetState } from '../../container/types';
|
import { GetState } from '../../container/types';
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
import { buildReducer } from '../../utils/helpers/redux';
|
||||||
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
|
||||||
export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START';
|
export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START';
|
||||||
export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR';
|
export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR';
|
||||||
export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO';
|
export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO';
|
||||||
/* eslint-enable padding-line-between-statements */
|
|
||||||
|
|
||||||
export interface MercureInfo {
|
export interface MercureInfo {
|
||||||
token?: string;
|
token?: string;
|
||||||
|
|||||||
@@ -2,38 +2,46 @@ import { combineReducers } from 'redux';
|
|||||||
import serversReducer from '../servers/reducers/servers';
|
import serversReducer from '../servers/reducers/servers';
|
||||||
import selectedServerReducer from '../servers/reducers/selectedServer';
|
import selectedServerReducer from '../servers/reducers/selectedServer';
|
||||||
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
|
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
|
||||||
import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams';
|
|
||||||
import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
|
import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
|
||||||
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
|
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
|
||||||
import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags';
|
|
||||||
import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta';
|
|
||||||
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
||||||
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
||||||
import tagVisitsReducer from '../visits/reducers/tagVisits';
|
import tagVisitsReducer from '../visits/reducers/tagVisits';
|
||||||
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
|
import domainVisitsReducer from '../visits/reducers/domainVisits';
|
||||||
|
import orphanVisitsReducer from '../visits/reducers/orphanVisits';
|
||||||
|
import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits';
|
||||||
|
import shortUrlDetailReducer from '../short-urls/reducers/shortUrlDetail';
|
||||||
import tagsListReducer from '../tags/reducers/tagsList';
|
import tagsListReducer from '../tags/reducers/tagsList';
|
||||||
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
||||||
import tagEditReducer from '../tags/reducers/tagEdit';
|
import tagEditReducer from '../tags/reducers/tagEdit';
|
||||||
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
||||||
import settingsReducer from '../settings/reducers/settings';
|
import settingsReducer from '../settings/reducers/settings';
|
||||||
|
import domainsListReducer from '../domains/reducers/domainsList';
|
||||||
|
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
|
||||||
|
import appUpdatesReducer from '../app/reducers/appUpdates';
|
||||||
|
import sidebarReducer from '../common/reducers/sidebar';
|
||||||
import { ShlinkState } from '../container/types';
|
import { ShlinkState } from '../container/types';
|
||||||
|
|
||||||
export default combineReducers<ShlinkState>({
|
export default combineReducers<ShlinkState>({
|
||||||
servers: serversReducer,
|
servers: serversReducer,
|
||||||
selectedServer: selectedServerReducer,
|
selectedServer: selectedServerReducer,
|
||||||
shortUrlsList: shortUrlsListReducer,
|
shortUrlsList: shortUrlsListReducer,
|
||||||
shortUrlsListParams: shortUrlsListParamsReducer,
|
|
||||||
shortUrlCreationResult: shortUrlCreationReducer,
|
shortUrlCreationResult: shortUrlCreationReducer,
|
||||||
shortUrlDeletion: shortUrlDeletionReducer,
|
shortUrlDeletion: shortUrlDeletionReducer,
|
||||||
shortUrlTags: shortUrlTagsReducer,
|
|
||||||
shortUrlMeta: shortUrlMetaReducer,
|
|
||||||
shortUrlEdition: shortUrlEditionReducer,
|
shortUrlEdition: shortUrlEditionReducer,
|
||||||
shortUrlVisits: shortUrlVisitsReducer,
|
shortUrlVisits: shortUrlVisitsReducer,
|
||||||
tagVisits: tagVisitsReducer,
|
tagVisits: tagVisitsReducer,
|
||||||
|
domainVisits: domainVisitsReducer,
|
||||||
|
orphanVisits: orphanVisitsReducer,
|
||||||
|
nonOrphanVisits: nonOrphanVisitsReducer,
|
||||||
shortUrlDetail: shortUrlDetailReducer,
|
shortUrlDetail: shortUrlDetailReducer,
|
||||||
tagsList: tagsListReducer,
|
tagsList: tagsListReducer,
|
||||||
tagDelete: tagDeleteReducer,
|
tagDelete: tagDeleteReducer,
|
||||||
tagEdit: tagEditReducer,
|
tagEdit: tagEditReducer,
|
||||||
mercureInfo: mercureInfoReducer,
|
mercureInfo: mercureInfoReducer,
|
||||||
settings: settingsReducer,
|
settings: settingsReducer,
|
||||||
|
domainsList: domainsListReducer,
|
||||||
|
visitsOverview: visitsOverviewReducer,
|
||||||
|
appUpdated: appUpdatesReducer,
|
||||||
|
sidebar: sidebarReducer,
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user