mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-02-27 04:06:39 +00:00
Compare commits
1305 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a46116d936 | ||
|
|
8fd419dc72 | ||
|
|
18b27dbd0c | ||
|
|
0c17818a24 | ||
|
|
b1749ee2ef | ||
|
|
27b82c56b1 | ||
|
|
f69bda351d | ||
|
|
4a92d0ff11 | ||
|
|
b37a983bde | ||
|
|
97cf3b26b0 | ||
|
|
c490835f9b | ||
|
|
a3ab2c6e1b | ||
|
|
ce5108937d | ||
|
|
9164db181c | ||
|
|
aa14a17ad6 | ||
|
|
d67b8c0530 | ||
|
|
d41c1a2a52 | ||
|
|
3b938251d9 | ||
|
|
b8aa068876 | ||
|
|
5d288de390 | ||
|
|
af851e708b | ||
|
|
72399e7ccd | ||
|
|
1ffd71e81f | ||
|
|
d627de8e83 | ||
|
|
fc4fdb4fc7 | ||
|
|
126537185b | ||
|
|
24de0773d8 | ||
|
|
cc77af6142 | ||
|
|
c16460af82 | ||
|
|
4c7bed90a3 | ||
|
|
491b2f2c07 | ||
|
|
a038f5e618 | ||
|
|
9ba74328ff | ||
|
|
90a643761a | ||
|
|
6236d36372 | ||
|
|
065c908153 | ||
|
|
a14e612a38 | ||
|
|
0b155b1d20 | ||
|
|
9b9cfd0543 | ||
|
|
3d067371d3 | ||
|
|
381eb5a502 | ||
|
|
4a88f30d13 | ||
|
|
bdf181adec | ||
|
|
f97fce873b | ||
|
|
879017ecca | ||
|
|
83150331e5 | ||
|
|
7249ec4968 | ||
|
|
3ac148f7cd | ||
|
|
a73472f7e5 | ||
|
|
08ca59f990 | ||
|
|
d07f7e757e | ||
|
|
cb13e82b9c | ||
|
|
fd7aa570ed | ||
|
|
c00053f6e1 | ||
|
|
2e0e7f361c | ||
|
|
21101d4da8 | ||
|
|
65f739499f | ||
|
|
91ee4a32cd | ||
|
|
498668929f | ||
|
|
935b12763b | ||
|
|
28b15e4a85 | ||
|
|
6af49a9945 | ||
|
|
c80ad70e3b | ||
|
|
1a20065053 | ||
|
|
edef36bae8 | ||
|
|
3cd25dc2df | ||
|
|
43840d7656 | ||
|
|
a1bdb75036 | ||
|
|
ac0107d450 | ||
|
|
d3d2cf72b9 | ||
|
|
eb9ec4ec31 | ||
|
|
3201830b27 | ||
|
|
58ddec6aff | ||
|
|
59fd58b824 | ||
|
|
efa07f0368 | ||
|
|
1dd6a8e2e4 | ||
|
|
bcd3fa8ce4 | ||
|
|
6ff3cf544b | ||
|
|
ab21f923c6 | ||
|
|
b75fd2e03a | ||
|
|
ec7c7d521f | ||
|
|
f9909713d9 | ||
|
|
59087ced8a | ||
|
|
84435714f5 | ||
|
|
6bd628712e | ||
|
|
997f4a6bdc | ||
|
|
b1fec831c5 | ||
|
|
54fe849efd | ||
|
|
8bf1a9d023 | ||
|
|
07cedd0bdb | ||
|
|
44a93ae556 | ||
|
|
72f790b28c | ||
|
|
a63f7e741a | ||
|
|
58f952df8a | ||
|
|
2acd0ec95d | ||
|
|
105254d053 | ||
|
|
e538f2a3bb | ||
|
|
98ea491469 | ||
|
|
10e50efb33 | ||
|
|
d60023f585 | ||
|
|
c0d5feb433 | ||
|
|
2451167296 | ||
|
|
4a70e4ecd3 | ||
|
|
a90c3da7b6 | ||
|
|
53e15b041d | ||
|
|
7669254a0c | ||
|
|
b450e4093e | ||
|
|
a012d6206f | ||
|
|
30f502a51b | ||
|
|
4defeaf017 | ||
|
|
7f35fb0ada | ||
|
|
cd1a926292 | ||
|
|
e46506b264 | ||
|
|
807c5c3fb4 | ||
|
|
64efb1d43d | ||
|
|
49e1f82b03 | ||
|
|
1bd8636c19 | ||
|
|
cfe84e1275 | ||
|
|
5dda4731a0 | ||
|
|
ce830ea6d3 | ||
|
|
b217b70dfe | ||
|
|
ceee26ad25 | ||
|
|
876018390d | ||
|
|
0366f3544b | ||
|
|
b964ba5317 | ||
|
|
494e36c842 | ||
|
|
9c611a5b13 | ||
|
|
357c478640 | ||
|
|
89f830d9bb | ||
|
|
56150e8707 | ||
|
|
1d60db25bd | ||
|
|
2cac1d9fd2 | ||
|
|
e70724f058 | ||
|
|
27a05e55c9 | ||
|
|
0a0de86ecd | ||
|
|
ec025b7d0f | ||
|
|
744cea1f11 | ||
|
|
073617b6d3 | ||
|
|
8d69945e8e | ||
|
|
3f34a1fb87 | ||
|
|
7bbc7250dd | ||
|
|
63433864d3 | ||
|
|
e53f90fc5c | ||
|
|
33adb08105 | ||
|
|
4a610d182c | ||
|
|
8655d9be87 | ||
|
|
9962ddcd36 | ||
|
|
e117429373 | ||
|
|
dd0f5f961c | ||
|
|
f0b5505770 | ||
|
|
c63b4f9f21 | ||
|
|
6ed19af295 | ||
|
|
46f7a67b14 | ||
|
|
6f2639fd1f | ||
|
|
f1568eb43b | ||
|
|
aefc632ed7 | ||
|
|
bd3555db94 | ||
|
|
ed366fa4cc | ||
|
|
30aeba0af2 | ||
|
|
b4c3bd16b1 | ||
|
|
4b97abaf72 | ||
|
|
e387706a7b | ||
|
|
a7cc8786c3 | ||
|
|
5c0573deb7 | ||
|
|
bc8956db4a | ||
|
|
d44be88b3f | ||
|
|
64ee9a39cc | ||
|
|
4999f982e4 | ||
|
|
f3f0dbac19 | ||
|
|
c2818645c4 | ||
|
|
b0a4aee175 | ||
|
|
67c45f444b | ||
|
|
a8c6e916cf | ||
|
|
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 | ||
|
|
14e31ed2c3 | ||
|
|
ff1fb0dd12 | ||
|
|
2e6a35181d | ||
|
|
22cca598ca | ||
|
|
de906bf370 | ||
|
|
498594c31b | ||
|
|
cfbd246cfc | ||
|
|
3f91c556e4 | ||
|
|
4d1622607c | ||
|
|
eacdee293c | ||
|
|
f4b115cffd | ||
|
|
7dcd623513 | ||
|
|
8b00d1aaae | ||
|
|
facfd33e96 | ||
|
|
a841dc7531 | ||
|
|
28ebd55b69 | ||
|
|
3eade5a0c0 | ||
|
|
caf74cd87b | ||
|
|
049510f513 | ||
|
|
b151b7eedb | ||
|
|
4e22e9c092 | ||
|
|
793883148a | ||
|
|
8acb7bea24 | ||
|
|
335cceeb82 | ||
|
|
bf7455ad6e | ||
|
|
421cc5b718 | ||
|
|
78d97a64aa | ||
|
|
749c757cbd | ||
|
|
faf9554286 | ||
|
|
b7a0be3872 | ||
|
|
cff8046ff8 | ||
|
|
af1289752d | ||
|
|
b06d9d3bc7 | ||
|
|
b2904189ef | ||
|
|
5c639d241b | ||
|
|
984e9f32a5 | ||
|
|
59d23b584a | ||
|
|
a7d865228a | ||
|
|
260ff716d7 | ||
|
|
9001a3da37 | ||
|
|
46db1e39f3 | ||
|
|
6bf3fc0fd5 | ||
|
|
a136543551 | ||
|
|
23046c149c | ||
|
|
2951d0d75e | ||
|
|
b52e40edd3 | ||
|
|
51556d76ac | ||
|
|
871868f0a4 | ||
|
|
67495fa302 | ||
|
|
fc9341f631 | ||
|
|
3fea8b5505 | ||
|
|
89e3114ef3 | ||
|
|
4dc5fad8b8 | ||
|
|
2567bdfdf0 | ||
|
|
f36cf1e7b9 | ||
|
|
bd88e56331 | ||
|
|
fe3e08de0f | ||
|
|
cfb165d240 | ||
|
|
fa074f91be | ||
|
|
6fc4963663 | ||
|
|
ad437f655e | ||
|
|
9b45513684 | ||
|
|
5d6d802d64 | ||
|
|
536d49aac9 | ||
|
|
796c9ca140 | ||
|
|
1b1a1f3230 | ||
|
|
98d856700c | ||
|
|
b814f500de | ||
|
|
90abf29db9 | ||
|
|
d064eb5f9e | ||
|
|
58c9ef9b51 | ||
|
|
125b13e059 | ||
|
|
dcdee8b308 | ||
|
|
f33d1fca39 | ||
|
|
7e907ba9b6 | ||
|
|
3cec2efbbd | ||
|
|
d4094e66b3 | ||
|
|
73b854037d | ||
|
|
f2e7a2161d | ||
|
|
260ed3041a | ||
|
|
8a146021dd | ||
|
|
4083592212 | ||
|
|
f9c57ca659 | ||
|
|
d0d664ef79 | ||
|
|
16d96efa4a | ||
|
|
f8ea1ae3d5 | ||
|
|
18883caa6d | ||
|
|
84fc82b74e | ||
|
|
8a9c694fbc | ||
|
|
4b33d39d44 | ||
|
|
c0f5d9c12c | ||
|
|
ef630af154 | ||
|
|
ebd7a76896 | ||
|
|
64a968711c | ||
|
|
aee4c2d02f | ||
|
|
8cc0695ee9 | ||
|
|
f40ad91ea9 | ||
|
|
a96539129d | ||
|
|
dcf72e6818 | ||
|
|
54290d4c9a | ||
|
|
eb3775859a | ||
|
|
83531666de | ||
|
|
f3a2535e2f | ||
|
|
f283dc8569 | ||
|
|
b19bbee7fc | ||
|
|
1b03d04318 | ||
|
|
6696fb13d6 | ||
|
|
f04aece7df | ||
|
|
d8f3952920 | ||
|
|
fefa4e7848 | ||
|
|
0b4a348969 | ||
|
|
3e2fee0df5 | ||
|
|
294888454d | ||
|
|
1b7e1e2b5b | ||
|
|
dc78138066 | ||
|
|
87e64e5899 | ||
|
|
e193a692e8 | ||
|
|
2eba607874 | ||
|
|
62df46d648 | ||
|
|
7c67fa4149 | ||
|
|
2db85c2783 | ||
|
|
39663ba936 | ||
|
|
eefea0c37b | ||
|
|
d65a6ba970 | ||
|
|
524b0a74c6 | ||
|
|
72de9d4ff8 | ||
|
|
a91f1b3bd4 | ||
|
|
343a93b984 | ||
|
|
8be17cce8a | ||
|
|
d2f818c1ea | ||
|
|
a675d60d59 | ||
|
|
2d96c21b50 | ||
|
|
6f6ba9e34d | ||
|
|
e6efda5563 | ||
|
|
b1df1652bf | ||
|
|
474239c151 | ||
|
|
feeb212259 | ||
|
|
90245016a0 | ||
|
|
8c7616c3a7 | ||
|
|
ea84ce9c41 | ||
|
|
c4730ec92d | ||
|
|
76b3d573c0 | ||
|
|
b96f4b7a90 | ||
|
|
2a0def262d | ||
|
|
897e35f0b8 | ||
|
|
1c335506d8 | ||
|
|
d46acdbd70 | ||
|
|
026bb4140e | ||
|
|
d80f3da55d | ||
|
|
f18495a4b1 | ||
|
|
f908da78f6 | ||
|
|
bc16381c90 | ||
|
|
4cb7aa64cf | ||
|
|
da6d7aea8b | ||
|
|
b310d79110 | ||
|
|
e26cdc11c3 | ||
|
|
fa54aa3128 | ||
|
|
e31e70039d | ||
|
|
cb761dea8f | ||
|
|
949e0da105 | ||
|
|
770cc59448 | ||
|
|
72dd2bd0a7 | ||
|
|
54733eaa18 | ||
|
|
52c56f7918 | ||
|
|
c46d5187c1 | ||
|
|
05e3e87653 | ||
|
|
8b9289ff08 | ||
|
|
16ffbcfbc0 | ||
|
|
d825b6e174 | ||
|
|
73e55cc742 | ||
|
|
32cc1cc580 | ||
|
|
e00574553f | ||
|
|
984c1ea716 | ||
|
|
df38cf6ca9 | ||
|
|
1b60b0e2a8 | ||
|
|
11f9c7c507 | ||
|
|
ebe649aaac | ||
|
|
656b68d422 | ||
|
|
cd1f186e28 | ||
|
|
d0b3edaa2f | ||
|
|
2268b85ade | ||
|
|
d7e3b7b912 | ||
|
|
4bd83eecfb | ||
|
|
b7fd2308ad | ||
|
|
a6958941ad | ||
|
|
c98b28ff0f | ||
|
|
6a372badfa | ||
|
|
b6ab9a1bdd | ||
|
|
daf9e7cf64 | ||
|
|
ef42dcd666 | ||
|
|
1b6028ae6d | ||
|
|
9340512980 | ||
|
|
9d0b4cc065 | ||
|
|
c5cb0dcb26 | ||
|
|
a42f5ab13e | ||
|
|
68b0577526 | ||
|
|
61867366e7 | ||
|
|
c670d86955 | ||
|
|
4565a64cd8 | ||
|
|
f36e42d9c1 | ||
|
|
0a3a97242b | ||
|
|
68253c3bc4 | ||
|
|
544384d85e | ||
|
|
91daec852f | ||
|
|
dcc5b9cc8c | ||
|
|
1d26cd93fb | ||
|
|
e47dfaf36f | ||
|
|
09e2c69e46 | ||
|
|
07d3567244 | ||
|
|
9bdbe90716 | ||
|
|
02a4380f7c | ||
|
|
4e483dc5d4 | ||
|
|
52631e629e | ||
|
|
3a53298417 | ||
|
|
fb0f14fc16 | ||
|
|
7a94b1730d | ||
|
|
f856bc218a | ||
|
|
bfbb21e1cc | ||
|
|
18e18f533b | ||
|
|
6eead70511 | ||
|
|
6fd30ed51a | ||
|
|
67c674f073 | ||
|
|
289d8784c0 | ||
|
|
18e026e4ca | ||
|
|
8741f42fe8 | ||
|
|
665d6209d9 | ||
|
|
59fda29894 | ||
|
|
61c027f9a1 | ||
|
|
241c9b73b0 | ||
|
|
85dc1d0825 | ||
|
|
e38887aa26 | ||
|
|
54fec79945 | ||
|
|
fad0bf1c9d | ||
|
|
be2f86050f | ||
|
|
a7f941e8e4 | ||
|
|
b08c6748c7 | ||
|
|
bdd7932e07 | ||
|
|
bcf5dcf180 | ||
|
|
8b2cbf7aea | ||
|
|
277b5e43f8 | ||
|
|
7dd6a31609 | ||
|
|
86bf1515d4 | ||
|
|
bbc47b387e | ||
|
|
3953e98a77 | ||
|
|
09b8bd501d | ||
|
|
6bddaaa055 | ||
|
|
dd728d4d13 | ||
|
|
9ba8bc8f3d | ||
|
|
16dee3664b | ||
|
|
6fcf588bfd | ||
|
|
6a6c427b0e | ||
|
|
41f885d8ec | ||
|
|
7516ca8dd9 | ||
|
|
aa59a95f91 | ||
|
|
8a5161c0e8 | ||
|
|
d8ae69e861 | ||
|
|
a485d0b507 | ||
|
|
ed40b79c8d | ||
|
|
91488ae294 | ||
|
|
a22a1938c1 | ||
|
|
0f73cb9f8c | ||
|
|
f3129399de | ||
|
|
37e6c27461 |
@@ -1,6 +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
|
||||||
|
./docs
|
||||||
|
|||||||
47
.eslintrc
47
.eslintrc
@@ -1,45 +1,16 @@
|
|||||||
{
|
{
|
||||||
|
"root": true,
|
||||||
"extends": [
|
"extends": [
|
||||||
"adidas-env/browser",
|
"@shlinkio/js-coding-standard"
|
||||||
"adidas-env/module",
|
|
||||||
"adidas-env/node",
|
|
||||||
"adidas-es6",
|
|
||||||
"adidas-babel",
|
|
||||||
"adidas-react"
|
|
||||||
],
|
],
|
||||||
"plugins": ["jest"],
|
"parserOptions": {
|
||||||
"env": {
|
"project": "./tsconfig.json"
|
||||||
"jest/globals": true
|
|
||||||
},
|
|
||||||
"globals": {
|
|
||||||
"process": true,
|
|
||||||
"setImmediate": true
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"react": {
|
|
||||||
"version": "16.3"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
"ignorePatterns": ["src/service*.ts"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"comma-dangle": ["error", "always-multiline"],
|
"jsx-a11y/control-has-associated-label": "off",
|
||||||
"no-invalid-this": "off",
|
"jsx-a11y/label-has-associated-control": "off",
|
||||||
"no-console": "warn",
|
"jsx-a11y/click-events-have-key-events": "off",
|
||||||
"template-curly-spacing": ["error", "never"],
|
"jsx-a11y/no-static-element-interactions": "off"
|
||||||
"no-warning-comments": "off",
|
|
||||||
"no-magic-numbers": "off",
|
|
||||||
"no-undefined": "off",
|
|
||||||
"no-inline-comments": "off",
|
|
||||||
"lines-around-comment": "off",
|
|
||||||
"indent": ["error", 2, {
|
|
||||||
"SwitchCase": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"react/jsx-curly-spacing": ["error", "never"],
|
|
||||||
"react/jsx-indent-props": ["error", 2],
|
|
||||||
"react/jsx-first-prop-new-line": ["error", "multiline-multiprop"],
|
|
||||||
"react/jsx-closing-bracket-location": ["error", "tag-aligned"],
|
|
||||||
"react/no-array-index-key": "off",
|
|
||||||
"react/no-did-update-set-state": "off",
|
|
||||||
"react/display-name": "off"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
.github/ISSUE_TEMPLATE.md
vendored
5
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,6 +1,7 @@
|
|||||||
<!--
|
<!--
|
||||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||||
I'm always happy to help and provide support, but some understanding will be required.
|
I'm always happy to help and provide support, but some understanding will be expected.
|
||||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||||
|
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||||
-->
|
-->
|
||||||
|
|||||||
5
.github/ISSUE_TEMPLATE/Bug.md
vendored
5
.github/ISSUE_TEMPLATE/Bug.md
vendored
@@ -5,9 +5,10 @@ labels: bug
|
|||||||
---
|
---
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||||
I'm always happy to help and provide support, but some understanding will be required.
|
I'm always happy to help and provide support, but some understanding will be expected.
|
||||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||||
|
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||||
|
|
||||||
With that said, please fill in the information requested next. More information might be requested once the issue is open.
|
With that said, please fill in the information requested next. More information might be requested once the issue is open.
|
||||||
|
|||||||
5
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
5
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
@@ -5,9 +5,10 @@ labels: feature
|
|||||||
---
|
---
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||||
I'm always happy to help and provide support, but some understanding will be required.
|
I'm always happy to help and provide support, but some understanding will be expected.
|
||||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||||
|
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||||
|
|
||||||
With that said, please fill in the information requested next. More information might be requested once the issue is open.
|
With that said, please fill in the information requested next. More information might be requested once the issue is open.
|
||||||
|
|||||||
5
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
5
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
@@ -5,9 +5,10 @@ labels: question
|
|||||||
---
|
---
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||||
I'm always happy to help and provide support, but some understanding will be required.
|
I'm always happy to help and provide support, but some understanding will be expected.
|
||||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||||
|
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||||
|
|
||||||
With that said, please fill in the information requested next. More information might be requested once the issue is open.
|
With that said, please fill in the information requested next. More information might be requested once the issue is open.
|
||||||
|
|||||||
17
.github/workflows/ci.yml
vendored
Normal file
17
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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.15
|
||||||
|
with-mutation-tests: true
|
||||||
|
publish-coverage: true
|
||||||
|
force-install: 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.15
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
npm ci --force && \
|
||||||
|
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
|
||||||
28
.github/workflows/docker-image-build.yml
vendored
Normal file
28
.github/workflows/docker-image-build.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
name: Build docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
- name: Login to docker hub
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||||
|
- name: Build the image
|
||||||
|
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.15
|
||||||
|
- name: Generate release assets
|
||||||
|
run: npm ci --force && 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,6 +0,0 @@
|
|||||||
build:
|
|
||||||
environment:
|
|
||||||
node: v12.14.1
|
|
||||||
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"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
45
.travis.yml
45
.travis.yml
@@ -1,45 +0,0 @@
|
|||||||
language: node_js
|
|
||||||
|
|
||||||
node_js:
|
|
||||||
- "12.14.1"
|
|
||||||
|
|
||||||
cache:
|
|
||||||
directories:
|
|
||||||
- node_modules
|
|
||||||
|
|
||||||
services:
|
|
||||||
- docker
|
|
||||||
|
|
||||||
install:
|
|
||||||
- npm ci
|
|
||||||
|
|
||||||
before_script:
|
|
||||||
- echo "Building commit range ${TRAVIS_COMMIT_RANGE}"
|
|
||||||
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep -E 'src\/(.*).(js|ts|jsx|tsx)$' | paste -sd ",")
|
|
||||||
|
|
||||||
script:
|
|
||||||
- npm run lint
|
|
||||||
- npm run test:ci
|
|
||||||
- if [[ $TRAVIS_PULL_REQUEST != 'false' ]]; then docker build -t shlink-web-client:test . ; fi
|
|
||||||
- if [[ $TRAVIS_PULL_REQUEST != 'false' ]]; then npm run mutate:ci ; fi
|
|
||||||
|
|
||||||
after_success:
|
|
||||||
- node_modules/.bin/ocular coverage/clover.xml
|
|
||||||
|
|
||||||
# Before deploying, build dist file for current travis tag
|
|
||||||
before_deploy:
|
|
||||||
- if [[ ! -z $TRAVIS_TAG ]]; then npm run build ${TRAVIS_TAG#?} ; fi
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
- provider: script
|
|
||||||
script: bash ./scripts/docker/build
|
|
||||||
on:
|
|
||||||
all_branches: true
|
|
||||||
condition: $TRAVIS_PULL_REQUEST == 'false'
|
|
||||||
- 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
|
|
||||||
913
CHANGELOG.md
913
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
72
CONTRIBUTING.md
Normal file
72
CONTRIBUTING.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
This file will guide you through the process of getting to project up and running, in case you want to provide coding contributions.
|
||||||
|
|
||||||
|
You will also see how to ensure the code fulfills the expected code checks, and how to create a pull request.
|
||||||
|
|
||||||
|
## System dependencies
|
||||||
|
|
||||||
|
The project can be run inside a docker container through provided docker-compose configuration.
|
||||||
|
|
||||||
|
Because of this, the only actual dependencies are [docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/).
|
||||||
|
|
||||||
|
## Setting up the project
|
||||||
|
|
||||||
|
The first thing you need to do is fork the repository, and clone it in your local machine.
|
||||||
|
|
||||||
|
Then you will have to follow these steps:
|
||||||
|
|
||||||
|
* Copy the file `docker-compose.override.yml.dist` by also removing the `dist` extension.
|
||||||
|
* Start-up the project by running `docker-compose up`.
|
||||||
|
|
||||||
|
Once this is finished, you will have the project exposed in port `3000` (http://localhost:3000).
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
This project is a [react](https://reactjs.org/) & [redux](https://redux.js.org/) application, built with [typescript](https://www.typescriptlang.org/), which is distributed as a 100% client-side progressive web application.
|
||||||
|
|
||||||
|
This is the basic project structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
shlink-web-client
|
||||||
|
├── config
|
||||||
|
├── public
|
||||||
|
├── scripts
|
||||||
|
├── src
|
||||||
|
├── test
|
||||||
|
├── package.json
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
* `config`: It contains some configuration scripts, used during testing, linting and building of the project.
|
||||||
|
* `public`: Will act as the application document root once built, and contains some static assets (favicons, images, etc).
|
||||||
|
* `scripts`: It has some of the CLI scripts used to run tests or building.
|
||||||
|
* `src`: Contains the main source code of the application, including both web components, SASS stylesheets and files with logic.
|
||||||
|
* `test`: Contains the project tests.
|
||||||
|
|
||||||
|
## Running code checks
|
||||||
|
|
||||||
|
> Note: The `indocker` shell script is a helper used to run commands inside the docker container.
|
||||||
|
|
||||||
|
* `./indocker npm run lint`: Checks coding styles are fulfilled, both in JS/TS files as well as in stylesheets.
|
||||||
|
* `./indocker npm run lint:js`: Checks coding styles are fulfilled in JS/TS files.
|
||||||
|
* `./indocker npm run lint:css`: Checks coding styles are fulfilled in stylesheets.
|
||||||
|
* `./indocker npm run lint:js:fix`: Fixes coding styles in JS/TS files.
|
||||||
|
* `./indocker npm run lint:css:fix`: Fixes coding styles in stylesheets.
|
||||||
|
* `./indocker npm run test`: Runs unit tests with Jest.
|
||||||
|
* `./indocker npm run mutate`: Runs mutation tests with StrykerJS (this command can be very slow).
|
||||||
|
|
||||||
|
## Building the project
|
||||||
|
|
||||||
|
The source code in this project cannot be run directly in a web browser, you need to build it first.
|
||||||
|
|
||||||
|
* `./indocker npm run build`: Builds the project using a combination of `webpack`, `babel` and `tsc`, generating the final static files. The content is placed in the `build` folder, which is automatically created if it does not exist.
|
||||||
|
* `./indocker npm run serve:build`: Serves the static files inside the `build` folder in port 5000 (http://localhost:5000). Useful to test the content built with previous command.
|
||||||
|
|
||||||
|
## Pull request process
|
||||||
|
|
||||||
|
In order to provide pull requests to this project, you should always start by creating a new branch, where you will make all desired changes.
|
||||||
|
|
||||||
|
The base branch should always be `main`, and the target branch for the pull request should also be `main`.
|
||||||
|
|
||||||
|
Before your branch can be merged, all the checks described in [Running code checks](#running-code-checks) have to be passing. You can verify that manually, or wait for the build to be run automatically after the pull request is created.
|
||||||
13
Dockerfile
13
Dockerfile
@@ -1,17 +1,12 @@
|
|||||||
FROM node:12.14.1-alpine as node
|
FROM node:16.15-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 --force && NODE_ENV=production npm run build
|
||||||
UNCOMPRESSED="shlink-web-client_${VERSION}_dist" && \
|
|
||||||
DIST_FILE="./dist/${UNCOMPRESSED}.zip" && \
|
|
||||||
# If a dist file already exists, just unzip it
|
|
||||||
if [[ -f ${DIST_FILE} ]]; then unzip ${DIST_FILE} && mv ./${UNCOMPRESSED} ./build ; fi && \
|
|
||||||
# If no dist file exsts, build from scratch
|
|
||||||
if [[ ! -f ${DIST_FILE} ]]; then npm install && npm run build -- ${VERSION} --no-dist ; fi
|
|
||||||
|
|
||||||
FROM nginx:1.17.7-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
|
||||||
|
|||||||
39
README.md
39
README.md
@@ -1,17 +1,19 @@
|
|||||||
# shlink-web-client
|
# shlink-web-client
|
||||||
|
|
||||||
[](https://travis-ci.org/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/?branch=master)
|
[](https://app.codecov.io/gh/shlinkio/shlink-web-client)
|
||||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
|
|
||||||
[](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/master/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).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
> If you are trying to find out how to run the project in development mode or how to provide contributions, read the [CONTRIBUTING](CONTRIBUTING.md) doc.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
There are three ways in which you can use this application.
|
There are three ways in which you can use this application.
|
||||||
@@ -67,6 +69,33 @@ Those servers can be exported and imported in other browsers, but if for some re
|
|||||||
If you are using the shlink-web-client docker image, you can mount the `servers.json` file in a volume inside `/usr/share/nginx/html`, which is the app's document root inside the container.
|
If you are using the shlink-web-client docker image, you can mount the `servers.json` file in a volume inside `/usr/share/nginx/html`, which is the app's document root inside the container.
|
||||||
|
|
||||||
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.**
|
||||||
|
>
|
||||||
|
> Due to shlink-web-client's client-side nature, the file needs to be accessible from the browser.
|
||||||
|
>
|
||||||
|
> Because of that, make sure you use this only when you self-host shlink-web-client, and you know only trusted people will have access to it.
|
||||||
|
>
|
||||||
|
> Failing to do this could cause your API keys to end up being exposed.
|
||||||
|
|
||||||
## Serve project in subpath
|
## Serve project in subpath
|
||||||
|
|
||||||
|
|||||||
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -4,11 +4,31 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Expire rules for static content
|
||||||
|
# HTML files should never be cached. There's only one here, which is the entry point (index.html)
|
||||||
|
location ~* \.(?:manifest|appcache|html?|xml|json)$ {
|
||||||
|
expires -1;
|
||||||
|
}
|
||||||
|
# Images and other binary assets can be saved for a month
|
||||||
|
location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ {
|
||||||
|
expires 1M;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
# JS and CSS files can be saved for a year, as they are always hashed. New versions will include a new hash anyway, forcing the download
|
||||||
|
location ~* \.(?:css|js)$ {
|
||||||
|
expires 1y;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
# When requesting a path without extension, try it, and return the index if not found
|
# When requesting a path without extension, try it, and return the index if not found
|
||||||
# This allows HTML5 history paths to be handled by the client application
|
# This allows HTML5 history paths to be handled by the client application
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
100
config/env.js
100
config/env.js
@@ -1,100 +0,0 @@
|
|||||||
|
|
||||||
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};`
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
8
config/jest/setupTests.ts
Normal file
8
config/jest/setupTests.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
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).prompt = () => {};
|
||||||
|
(global as any).matchMedia = (media: string) => ({ matches: false, media });
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
|
|
||||||
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,4 +0,0 @@
|
|||||||
import Enzyme from 'enzyme';
|
|
||||||
import Adapter from 'enzyme-adapter-react-16';
|
|
||||||
|
|
||||||
Enzyme.configure({ adapter: new Adapter() });
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// 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',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Turn off performance processing because we utilize
|
|
||||||
// our own hints via the FileSizeReporter
|
|
||||||
performance: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
|
|
||||||
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:12.14.1-alpine
|
image: node:16.15-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,40 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
coverageDirectory: '<rootDir>/coverage',
|
coverageDirectory: '<rootDir>/coverage',
|
||||||
collectCoverageFrom: [
|
collectCoverageFrom: [
|
||||||
'src/**/*.js',
|
'src/**/*.{ts,tsx}',
|
||||||
'!src/registerServiceWorker.js',
|
'!src/*.{ts,tsx}',
|
||||||
'!src/index.js',
|
'!src/reducers/index.ts',
|
||||||
'!src/reducers/index.js',
|
'!src/**/provideServices.ts',
|
||||||
'!src/**/provideServices.js',
|
'!src/container/*.ts',
|
||||||
'!src/container/*.js',
|
|
||||||
],
|
],
|
||||||
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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: {
|
||||||
'^.+\\.(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',
|
||||||
'^(?!.*\\.(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',
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
55463
package-lock.json
generated
55463
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
223
package.json
223
package.json
@@ -1,149 +1,110 @@
|
|||||||
{
|
{
|
||||||
"name": "shlink-web-client",
|
"name": "shlink-web-client",
|
||||||
"description": "A React-based progressive web application for shlink",
|
"description": "A React-based progressive web application for shlink",
|
||||||
"version": "2.3.0",
|
|
||||||
"private": false,
|
"private": false,
|
||||||
"homepage": "",
|
"homepage": "",
|
||||||
"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 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.mjs",
|
||||||
"test": "node scripts/test.js --env=jsdom --colors",
|
"build:dist": "npm run build && node scripts/create-dist-file.mjs",
|
||||||
"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",
|
||||||
"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",
|
||||||
|
"test:verbose": "npm run test -- --verbose",
|
||||||
|
"mutate": "./node_modules/.bin/stryker run --concurrency 4 --ignoreStatic"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^5.11.2",
|
"@fortawesome/fontawesome-free": "^6.0.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^1.2.25",
|
"@fortawesome/fontawesome-svg-core": "^1.3.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^5.11.2",
|
"@fortawesome/free-regular-svg-icons": "^6.0.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.11.2",
|
"@fortawesome/free-solid-svg-icons": "^6.0.0",
|
||||||
"@fortawesome/react-fontawesome": "^0.1.5",
|
"@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",
|
"bottlejs": "^2.0.0",
|
||||||
"axios": "^0.19.0",
|
"bowser": "^2.11.0",
|
||||||
"bootstrap": "^4.3.1",
|
"chart.js": "^3.7.1",
|
||||||
"bottlejs": "^1.7.2",
|
"classnames": "^2.3.1",
|
||||||
"bowser": "^2.9.0",
|
"compare-versions": "^4.1.3",
|
||||||
"chart.js": "^2.8.0",
|
"csvtojson": "^2.0.10",
|
||||||
"classnames": "^2.2.6",
|
"date-fns": "^2.28.0",
|
||||||
"compare-versions": "^3.5.1",
|
"event-source-polyfill": "^1.0.25",
|
||||||
"csvjson": "^5.1.0",
|
"json2csv": "^5.0.7",
|
||||||
"leaflet": "^1.5.1",
|
"leaflet": "^1.7.1",
|
||||||
"moment": "^2.24.0",
|
"qs": "^6.9.6",
|
||||||
"promise": "^8.0.3",
|
"ramda": "^0.27.2",
|
||||||
"prop-types": "^15.7.2",
|
"react": "^18.1.0",
|
||||||
"qs": "^6.9.0",
|
"react-chartjs-2": "^4.1.0",
|
||||||
"ramda": "^0.26.1",
|
"react-colorful": "^5.5.1",
|
||||||
"react": "^16.13.1",
|
"react-copy-to-clipboard": "^5.1.0",
|
||||||
"react-autosuggest": "^9.4.3",
|
"react-datepicker": "^4.8.0",
|
||||||
"react-chartjs-2": "^2.8.0",
|
"react-dom": "^18.1.0",
|
||||||
"react-color": "^2.17.3",
|
"react-external-link": "^2.0.0",
|
||||||
"react-copy-to-clipboard": "^5.0.1",
|
"react-leaflet": "^4.0.0",
|
||||||
"react-datepicker": "~1.5.0",
|
"react-redux": "^8.0.0",
|
||||||
"react-dom": "^16.13.1",
|
"react-router-dom": "^6.3.0",
|
||||||
"react-external-link": "^1.0.0",
|
"react-swipeable": "^7.0.0",
|
||||||
"react-leaflet": "^2.4.0",
|
"react-tag-autocomplete": "^6.3.0",
|
||||||
"react-moment": "^0.9.5",
|
"reactstrap": "^9.0.1",
|
||||||
"react-redux": "^7.1.1",
|
"redux": "^4.2.0",
|
||||||
"react-router-dom": "^5.1.2",
|
"redux-localstorage-simple": "^2.4.1",
|
||||||
"react-swipeable": "^5.4.0",
|
"redux-thunk": "^2.4.1",
|
||||||
"react-tagsinput": "^3.19.0",
|
"stream": "^0.0.2",
|
||||||
"reactstrap": "^8.0.1",
|
"uuid": "^8.3.2",
|
||||||
"redux": "^4.0.4",
|
"workbox-core": "^6.5.1",
|
||||||
"redux-actions": "^2.6.5",
|
"workbox-expiration": "^6.5.1",
|
||||||
"redux-thunk": "^2.3.0",
|
"workbox-precaching": "^6.5.1",
|
||||||
"uuid": "^3.3.3"
|
"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",
|
||||||
"@stryker-mutator/core": "^2.1.0",
|
"@shlinkio/stylelint-config-css-coding-standard": "~1.0.1",
|
||||||
"@stryker-mutator/html-reporter": "^2.1.0",
|
"@stryker-mutator/core": "^6.0.2",
|
||||||
"@stryker-mutator/javascript-mutator": "^2.1.0",
|
"@stryker-mutator/jest-runner": "^6.0.2",
|
||||||
"@stryker-mutator/jest-runner": "^2.1.0",
|
"@stryker-mutator/typescript-checker": "^6.0.2",
|
||||||
"@svgr/webpack": "^4.3.3",
|
"@testing-library/jest-dom": "^5.16.4",
|
||||||
"adm-zip": "^0.4.13",
|
"@testing-library/react": "^13.1.1",
|
||||||
"autoprefixer": "^9.6.3",
|
"@testing-library/user-event": "^14.1.1",
|
||||||
"babel-core": "7.0.0-bridge.0",
|
"@types/jest": "^27.4.1",
|
||||||
"babel-eslint": "^10.0.3",
|
"@types/json2csv": "^5.0.3",
|
||||||
"babel-jest": "^24.9.0",
|
"@types/leaflet": "^1.7.9",
|
||||||
"babel-loader": "^8.0.6",
|
"@types/qs": "^6.9.7",
|
||||||
"babel-plugin-named-asset-import": "^0.3.4",
|
"@types/ramda": "0.27.38",
|
||||||
"babel-preset-react-app": "^9.0.2",
|
"@types/react": "^18.0.8",
|
||||||
"babel-runtime": "^6.26.0",
|
"@types/react-color": "^3.0.6",
|
||||||
"bfj": "^7.0.1",
|
"@types/react-copy-to-clipboard": "^5.0.2",
|
||||||
"case-sensitive-paths-webpack-plugin": "^2.2.0",
|
"@types/react-datepicker": "^4.3.4",
|
||||||
"chalk": "^2.4.2",
|
"@types/react-dom": "^18.0.3",
|
||||||
"css-loader": "^3.2.0",
|
"@types/react-tag-autocomplete": "^6.1.1",
|
||||||
"dotenv": "^8.1.0",
|
"@types/uuid": "^8.3.4",
|
||||||
"dotenv-expand": "^5.1.0",
|
"adm-zip": "^0.5.9",
|
||||||
"enzyme": "^3.11.0",
|
"babel-jest": "^28.0.3",
|
||||||
"enzyme-adapter-react-16": "^1.15.2",
|
"chalk": "^5.0.1",
|
||||||
"eslint": "^5.11.1",
|
"eslint": "^8.12.0",
|
||||||
"eslint-config-adidas-babel": "^1.1.0",
|
|
||||||
"eslint-config-adidas-env": "^1.1.0",
|
|
||||||
"eslint-config-adidas-es6": "^1.2.0",
|
|
||||||
"eslint-config-adidas-react": "^1.1.1",
|
|
||||||
"eslint-loader": "^3.0.2",
|
|
||||||
"eslint-plugin-import": "^2.18.2",
|
|
||||||
"eslint-plugin-jest": "^22.17.0",
|
|
||||||
"eslint-plugin-jsx-a11y": "^6.2.3",
|
|
||||||
"eslint-plugin-promise": "^4.2.1",
|
|
||||||
"eslint-plugin-react": "^7.16.0",
|
|
||||||
"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": "^24.9.0",
|
"jest": "^28.0.3",
|
||||||
"jest-pnp-resolver": "^1.2.1",
|
"jest-canvas-mock": "^2.4.0",
|
||||||
"jest-resolve": "^24.9.0",
|
"jest-environment-jsdom": "^28.0.2",
|
||||||
"mini-css-extract-plugin": "^0.8.0",
|
"react-scripts": "^5.0.1",
|
||||||
"node-sass": "^4.12.0",
|
"resize-observer-polyfill": "^1.5.1",
|
||||||
"object-assign": "^4.1.1",
|
"sass": "^1.49.9",
|
||||||
"ocular.js": "^0.1.0",
|
"serve": "^13.0.2",
|
||||||
"optimize-css-assets-webpack-plugin": "^5.0.3",
|
"stryker-cli": "^1.0.2",
|
||||||
"pnp-webpack-plugin": "^1.5.0",
|
"stylelint": "^14.8.2",
|
||||||
"postcss": "^7.0.18",
|
"ts-mockery": "^1.2.0",
|
||||||
"postcss-flexbugs-fixes": "^4.1.0",
|
"typescript": "^4.6.2",
|
||||||
"postcss-loader": "^3.0.0",
|
"webpack": "^5.70.0"
|
||||||
"postcss-preset-env": "^6.7.0",
|
|
||||||
"postcss-safe-parser": "^4.0.1",
|
|
||||||
"raf": "^3.4.1",
|
|
||||||
"react-app-polyfill": "^1.0.4",
|
|
||||||
"react-dev-utils": "^9.1.0",
|
|
||||||
"resolve": "^1.12.0",
|
|
||||||
"sass-loader": "^8.0.0",
|
|
||||||
"serve": "^11.2.0",
|
|
||||||
"stryker-cli": "^1.0.0",
|
|
||||||
"style-loader": "^1.0.0",
|
|
||||||
"stylelint": "^9.10.1",
|
|
||||||
"stylelint-config-adidas": "^1.2.1",
|
|
||||||
"stylelint-config-adidas-bem": "^1.2.0",
|
|
||||||
"stylelint-config-recommended-scss": "^4.0.0",
|
|
||||||
"stylelint-scss": "^3.11.1",
|
|
||||||
"sw-precache-webpack-plugin": "^0.11.5",
|
|
||||||
"terser-webpack-plugin": "^2.1.2",
|
|
||||||
"url-loader": "^2.2.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"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
">0.2%",
|
">0.2%",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
manifest.json provides metadata used when your web app is added to the
|
manifest.json provides metadata used when your web app is added to the
|
||||||
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
|
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
|
||||||
-->
|
-->
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" crossorigin="use-credentials">
|
||||||
|
|
||||||
<!-- FavIcon itself -->
|
<!-- FavIcon itself -->
|
||||||
<link rel="icon" type="image/x-icon" href="%PUBLIC_URL%/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="%PUBLIC_URL%/favicon.ico">
|
||||||
|
|||||||
227
scripts/build.js
227
scripts/build.js
@@ -1,227 +0,0 @@
|
|||||||
/* eslint-disable no-console */
|
|
||||||
|
|
||||||
// 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.indexOf('--stats') !== -1;
|
|
||||||
const withoutDist = argv.indexOf('--no-dist') !== -1;
|
|
||||||
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.mjs
Normal file
34
scripts/create-dist-file.mjs
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';
|
||||||
|
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import AdmZip from 'adm-zip';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
function zipDist(version) {
|
||||||
|
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
|
set -ex
|
||||||
|
|
||||||
if [[ -z $TRAVIS_TAG ]]; then
|
PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64"
|
||||||
docker build -t shlinkio/shlink-web-client:latest .
|
DOCKER_IMAGE="shlinkio/shlink-web-client"
|
||||||
docker push shlinkio/shlink-web-client:latest
|
|
||||||
|
if [[ "$GITHUB_REF" == *"develop"* ]]; then
|
||||||
|
docker buildx build --push \
|
||||||
|
--platform ${PLATFORMS} \
|
||||||
|
-t ${DOCKER_IMAGE}:latest .
|
||||||
|
|
||||||
|
# If ref is not develop, then this is a tag. Build that docker tag and also "stable"
|
||||||
else
|
else
|
||||||
docker build --build-arg VERSION=${TRAVIS_TAG#?} -t shlinkio/shlink-web-client:${TRAVIS_TAG#?} -t shlinkio/shlink-web-client:stable .
|
VERSION=${GITHUB_REF#refs/tags/v}
|
||||||
docker push shlinkio/shlink-web-client:${TRAVIS_TAG#?}
|
TAGS="-t ${DOCKER_IMAGE}:${VERSION}"
|
||||||
docker push shlinkio/shlink-web-client:stable
|
|
||||||
|
# Push stable tag only if this is not an alpha or beta release
|
||||||
|
[[ $GITHUB_REF != *"alpha"* && $GITHUB_REF != *"beta"* ]] && TAGS="${TAGS} -t ${DOCKER_IMAGE}:stable"
|
||||||
|
|
||||||
|
docker buildx build --push \
|
||||||
|
--build-arg VERSION=${VERSION} \
|
||||||
|
--platform ${PLATFORMS} \
|
||||||
|
${TAGS} .
|
||||||
fi
|
fi
|
||||||
|
|||||||
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.mjs
Normal file
20
scripts/replace-version.mjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import fs from '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 */
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
10
shlink-web-client.d.ts
vendored
Normal file
10
shlink-web-client.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
declare module 'event-source-polyfill' {
|
||||||
|
declare class EventSourcePolyfill {
|
||||||
|
public onmessage?: ({ data }: { data: string }) => void;
|
||||||
|
public onerror?: ({ status }: { status: number }) => void;
|
||||||
|
public close: () => void;
|
||||||
|
public constructor(hubUrl: URL, options?: any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.png'
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 MiB After Width: | Height: | Size: 2.3 MiB |
22
src/App.js
22
src/App.js
@@ -1,22 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Route, Switch } from 'react-router-dom';
|
|
||||||
import './App.scss';
|
|
||||||
import NotFound from './common/NotFound';
|
|
||||||
|
|
||||||
const App = (MainHeader, Home, MenuLayout, CreateServer, EditServer) => () => (
|
|
||||||
<div className="container-fluid app-container">
|
|
||||||
<MainHeader />
|
|
||||||
|
|
||||||
<div className="app">
|
|
||||||
<Switch>
|
|
||||||
<Route exact path="/" component={Home} />
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
10
src/App.scss
10
src/App.scss
@@ -1,10 +0,0 @@
|
|||||||
@import './utils/base';
|
|
||||||
|
|
||||||
.app-container {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app {
|
|
||||||
padding-top: $headerHeight;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
128
src/api/services/ShlinkApiClient.ts
Normal file
128
src/api/services/ShlinkApiClient.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { isEmpty, isNil, reject } from 'ramda';
|
||||||
|
import { AxiosInstance, AxiosResponse, Method } from 'axios';
|
||||||
|
import { ShortUrl, ShortUrlData } from '../../short-urls/data';
|
||||||
|
import { OptionalString } from '../../utils/utils';
|
||||||
|
import {
|
||||||
|
ShlinkHealth,
|
||||||
|
ShlinkMercureInfo,
|
||||||
|
ShlinkShortUrlsResponse,
|
||||||
|
ShlinkTags,
|
||||||
|
ShlinkTagsResponse,
|
||||||
|
ShlinkVisits,
|
||||||
|
ShlinkVisitsParams,
|
||||||
|
ShlinkShortUrlData,
|
||||||
|
ShlinkDomainsResponse,
|
||||||
|
ShlinkVisitsOverview,
|
||||||
|
ShlinkEditDomainRedirects,
|
||||||
|
ShlinkDomainRedirects,
|
||||||
|
ShlinkShortUrlsListParams,
|
||||||
|
ShlinkShortUrlsListNormalizedParams,
|
||||||
|
} from '../types';
|
||||||
|
import { stringifyQuery } from '../../utils/helpers/query';
|
||||||
|
import { orderToString } from '../../utils/helpers/ordering';
|
||||||
|
|
||||||
|
const buildShlinkBaseUrl = (url: string) => (url ? `${url}/rest/v2` : '');
|
||||||
|
const rejectNilProps = reject(isNil);
|
||||||
|
const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => {
|
||||||
|
const { orderBy = {}, ...rest } = params;
|
||||||
|
|
||||||
|
return { ...rest, orderBy: orderToString(orderBy) };
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ShlinkApiClient {
|
||||||
|
public constructor(
|
||||||
|
private readonly axios: AxiosInstance,
|
||||||
|
private readonly baseUrl: string,
|
||||||
|
private readonly apiKey: string,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
|
||||||
|
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', normalizeOrderByInParams(params))
|
||||||
|
.then(({ data }) => data.shortUrls);
|
||||||
|
|
||||||
|
public readonly createShortUrl = async (options: ShortUrlData): Promise<ShortUrl> => {
|
||||||
|
const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options as any);
|
||||||
|
|
||||||
|
return this.performRequest<ShortUrl>('/short-urls', 'POST', {}, filteredOptions)
|
||||||
|
.then((resp) => resp.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
public readonly getShortUrlVisits = async (shortCode: string, query?: ShlinkVisitsParams): Promise<ShlinkVisits> =>
|
||||||
|
this.performRequest<{ visits: ShlinkVisits }>(`/short-urls/${shortCode}/visits`, 'GET', query)
|
||||||
|
.then(({ data }) => data.visits);
|
||||||
|
|
||||||
|
public readonly getTagVisits = async (tag: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
||||||
|
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query)
|
||||||
|
.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> =>
|
||||||
|
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'GET', { domain })
|
||||||
|
.then(({ data }) => data);
|
||||||
|
|
||||||
|
public readonly deleteShortUrl = async (shortCode: string, domain?: OptionalString): Promise<void> =>
|
||||||
|
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
|
||||||
|
.then(() => {});
|
||||||
|
|
||||||
|
public readonly updateShortUrl = async (
|
||||||
|
shortCode: string,
|
||||||
|
domain: OptionalString,
|
||||||
|
edit: ShlinkShortUrlData,
|
||||||
|
): Promise<ShortUrl> =>
|
||||||
|
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, edit).then(({ data }) => data);
|
||||||
|
|
||||||
|
public readonly listTags = async (): Promise<ShlinkTags> =>
|
||||||
|
this.performRequest<{ tags: ShlinkTagsResponse }>('/tags', 'GET', { withStats: 'true' })
|
||||||
|
.then((resp) => resp.data.tags)
|
||||||
|
.then(({ data, stats }) => ({ tags: data, stats }));
|
||||||
|
|
||||||
|
public readonly deleteTags = async (tags: string[]): Promise<{ tags: string[] }> =>
|
||||||
|
this.performRequest('/tags', 'DELETE', { tags })
|
||||||
|
.then(() => ({ tags }));
|
||||||
|
|
||||||
|
public readonly editTag = async (oldName: string, newName: string): Promise<{ oldName: string; newName: string }> =>
|
||||||
|
this.performRequest('/tags', 'PUT', {}, { oldName, newName })
|
||||||
|
.then(() => ({ oldName, newName }));
|
||||||
|
|
||||||
|
public readonly health = async (): Promise<ShlinkHealth> =>
|
||||||
|
this.performRequest<ShlinkHealth>('/health', 'GET')
|
||||||
|
.then((resp) => resp.data);
|
||||||
|
|
||||||
|
public readonly mercureInfo = async (): Promise<ShlinkMercureInfo> =>
|
||||||
|
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
|
||||||
|
.then((resp) => resp.data);
|
||||||
|
|
||||||
|
public readonly listDomains = async (): Promise<ShlinkDomainsResponse> =>
|
||||||
|
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains);
|
||||||
|
|
||||||
|
public readonly editDomainRedirects = async (
|
||||||
|
domainRedirects: ShlinkEditDomainRedirects,
|
||||||
|
): Promise<ShlinkDomainRedirects> =>
|
||||||
|
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data);
|
||||||
|
|
||||||
|
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> =>
|
||||||
|
this.axios({
|
||||||
|
method,
|
||||||
|
url: `${buildShlinkBaseUrl(this.baseUrl)}${url}`,
|
||||||
|
headers: { 'X-Api-Key': this.apiKey },
|
||||||
|
params: rejectNilProps(query),
|
||||||
|
data: body,
|
||||||
|
paramsSerializer: stringifyQuery,
|
||||||
|
});
|
||||||
|
}
|
||||||
34
src/api/services/ShlinkApiClientBuilder.ts
Normal file
34
src/api/services/ShlinkApiClientBuilder.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { AxiosInstance } from 'axios';
|
||||||
|
import { prop } from 'ramda';
|
||||||
|
import { hasServerData, SelectedServer, ServerWithId } from '../../servers/data';
|
||||||
|
import { GetState } from '../../container/types';
|
||||||
|
import { ShlinkApiClient } from './ShlinkApiClient';
|
||||||
|
|
||||||
|
const apiClients: Record<string, ShlinkApiClient> = {};
|
||||||
|
|
||||||
|
const isGetState = (getStateOrSelectedServer: GetState | ServerWithId): getStateOrSelectedServer is GetState =>
|
||||||
|
typeof getStateOrSelectedServer === 'function';
|
||||||
|
const getSelectedServerFromState = (getState: GetState): SelectedServer => prop('selectedServer', getState());
|
||||||
|
|
||||||
|
export type ShlinkApiClientBuilder = (getStateOrSelectedServer: GetState | ServerWithId) => ShlinkApiClient;
|
||||||
|
|
||||||
|
export const buildShlinkApiClient = (axios: AxiosInstance): ShlinkApiClientBuilder => (
|
||||||
|
getStateOrSelectedServer: GetState | ServerWithId,
|
||||||
|
) => {
|
||||||
|
const server = isGetState(getStateOrSelectedServer)
|
||||||
|
? getSelectedServerFromState(getStateOrSelectedServer)
|
||||||
|
: getStateOrSelectedServer;
|
||||||
|
|
||||||
|
if (!hasServerData(server)) {
|
||||||
|
throw new Error('There\'s no selected server or it is not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url, apiKey } = server;
|
||||||
|
const clientKey = `${url}_${apiKey}`;
|
||||||
|
|
||||||
|
if (!apiClients[clientKey]) {
|
||||||
|
apiClients[clientKey] = new ShlinkApiClient(axios, url, apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiClients[clientKey];
|
||||||
|
};
|
||||||
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';
|
||||||
26
src/app/App.scss
Normal file
26
src/app/App.scss
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
@import '../utils/base';
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
padding-top: $headerHeight;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shlink-wrapper {
|
||||||
|
min-height: 100%;
|
||||||
|
padding-bottom: $footer-height + $footer-margin;
|
||||||
|
margin-bottom: -($footer-height + $footer-margin);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shlink-footer {
|
||||||
|
height: $footer-height;
|
||||||
|
margin-top: $footer-margin;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
@media (min-width: $mdMin) {
|
||||||
|
padding: 0 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
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,81 +0,0 @@
|
|||||||
import {
|
|
||||||
faList as listIcon,
|
|
||||||
faLink as createIcon,
|
|
||||||
faTags as tagsIcon,
|
|
||||||
faPen as editIcon,
|
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import React from 'react';
|
|
||||||
import { NavLink } from 'react-router-dom';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { serverType } from '../servers/prop-types';
|
|
||||||
import './AsideMenu.scss';
|
|
||||||
|
|
||||||
const AsideMenuItem = ({ children, to, className, ...rest }) => (
|
|
||||||
<NavLink
|
|
||||||
className={classNames('aside-menu__item', className)}
|
|
||||||
activeClassName="aside-menu__item--selected"
|
|
||||||
to={to}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
|
|
||||||
AsideMenuItem.propTypes = {
|
|
||||||
children: PropTypes.node.isRequired,
|
|
||||||
to: PropTypes.string.isRequired,
|
|
||||||
className: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
selectedServer: serverType,
|
|
||||||
className: PropTypes.string,
|
|
||||||
showOnMobile: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
const AsideMenu = (DeleteServerButton) => {
|
|
||||||
const AsideMenu = ({ selectedServer, className, showOnMobile }) => {
|
|
||||||
const serverId = selectedServer ? selectedServer.id : '';
|
|
||||||
const asideClass = classNames('aside-menu', className, {
|
|
||||||
'aside-menu--hidden': !showOnMobile,
|
|
||||||
});
|
|
||||||
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
|
|
||||||
const buildPath = (suffix) => `/server/${serverId}${suffix}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className={asideClass}>
|
|
||||||
<nav className="nav flex-column aside-menu__nav">
|
|
||||||
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
|
||||||
<FontAwesomeIcon icon={listIcon} />
|
|
||||||
<span className="aside-menu__item-text">List short URLs</span>
|
|
||||||
</AsideMenuItem>
|
|
||||||
<AsideMenuItem to={buildPath('/create-short-url')}>
|
|
||||||
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
|
|
||||||
<span className="aside-menu__item-text">Create short URL</span>
|
|
||||||
</AsideMenuItem>
|
|
||||||
<AsideMenuItem to={buildPath('/manage-tags')}>
|
|
||||||
<FontAwesomeIcon icon={tagsIcon} />
|
|
||||||
<span className="aside-menu__item-text">Manage tags</span>
|
|
||||||
</AsideMenuItem>
|
|
||||||
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
|
||||||
<FontAwesomeIcon icon={editIcon} />
|
|
||||||
<span className="aside-menu__item-text">Edit this server</span>
|
|
||||||
</AsideMenuItem>
|
|
||||||
<DeleteServerButton
|
|
||||||
className="aside-menu__item aside-menu__item--danger"
|
|
||||||
textClassName="aside-menu__item-text"
|
|
||||||
server={selectedServer}
|
|
||||||
/>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
AsideMenu.propTypes = propTypes;
|
|
||||||
|
|
||||||
return AsideMenu;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AsideMenu;
|
|
||||||
@@ -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 #eee;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@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,24 +42,24 @@ $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: #fff;
|
|
||||||
background-color: $mainColor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aside-menu__item--selected,
|
||||||
.aside-menu__item--selected:hover {
|
.aside-menu__item--selected:hover {
|
||||||
color: #fff;
|
color: #ffffff;
|
||||||
background-color: $mainColor;
|
background-color: var(--brand-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aside-menu__item--divider {
|
.aside-menu__item--divider {
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid #eeeeee;
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +72,7 @@ $asideMenuMobileWidth: 280px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.aside-menu__item--danger:hover {
|
.aside-menu__item--danger:hover {
|
||||||
color: #fff;
|
color: #ffffff;
|
||||||
background-color: $dangerColor;
|
background-color: $dangerColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
91
src/common/AsideMenu.tsx
Normal file
91
src/common/AsideMenu.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
faList as listIcon,
|
||||||
|
faLink as createIcon,
|
||||||
|
faTags as tagsIcon,
|
||||||
|
faPen as editIcon,
|
||||||
|
faHome as overviewIcon,
|
||||||
|
faGlobe as domainsIcon,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { FC } from 'react';
|
||||||
|
import { NavLink, NavLinkProps, useLocation } from 'react-router-dom';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
||||||
|
import { isServerWithId, SelectedServer } from '../servers/data';
|
||||||
|
import { supportsDomainRedirects } from '../utils/helpers/features';
|
||||||
|
import './AsideMenu.scss';
|
||||||
|
|
||||||
|
export interface AsideMenuProps {
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
showOnMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AsideMenuItemProps extends NavLinkProps {
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
|
||||||
|
<NavLink
|
||||||
|
className={({ isActive }) => classNames('aside-menu__item', className, { 'aside-menu__item--selected': isActive })}
|
||||||
|
to={to}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||||
|
{ selectedServer, showOnMobile = false }: AsideMenuProps,
|
||||||
|
) => {
|
||||||
|
const hasId = isServerWithId(selectedServer);
|
||||||
|
const serverId = hasId ? selectedServer.id : '';
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
|
||||||
|
const asideClass = classNames('aside-menu', {
|
||||||
|
'aside-menu--hidden': !showOnMobile,
|
||||||
|
});
|
||||||
|
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className={asideClass}>
|
||||||
|
<nav className="nav flex-column aside-menu__nav">
|
||||||
|
<AsideMenuItem to={buildPath('/overview')}>
|
||||||
|
<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>
|
||||||
|
</AsideMenuItem>
|
||||||
|
<AsideMenuItem to={buildPath('/create-short-url')}>
|
||||||
|
<FontAwesomeIcon fixedWidth icon={createIcon} flip="horizontal" />
|
||||||
|
<span className="aside-menu__item-text">Create short URL</span>
|
||||||
|
</AsideMenuItem>
|
||||||
|
<AsideMenuItem to={buildPath('/manage-tags')}>
|
||||||
|
<FontAwesomeIcon fixedWidth icon={tagsIcon} />
|
||||||
|
<span className="aside-menu__item-text">Manage tags</span>
|
||||||
|
</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">
|
||||||
|
<FontAwesomeIcon fixedWidth icon={editIcon} />
|
||||||
|
<span className="aside-menu__item-text">Edit this server</span>
|
||||||
|
</AsideMenuItem>
|
||||||
|
{hasId && (
|
||||||
|
<DeleteServerButton
|
||||||
|
className="aside-menu__item aside-menu__item--danger"
|
||||||
|
textClassName="aside-menu__item-text"
|
||||||
|
server={selectedServer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import * as PropTypes from 'prop-types';
|
|
||||||
import './ErrorHandler.scss';
|
|
||||||
import { Button } from 'reactstrap';
|
|
||||||
|
|
||||||
// FIXME Replace with typescript: (window, console)
|
|
||||||
const ErrorHandler = ({ location }, { error }) => class ErrorHandler extends React.Component {
|
|
||||||
static propTypes = {
|
|
||||||
children: PropTypes.node.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.state = { hasError: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError() {
|
|
||||||
return { hasError: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(e) {
|
|
||||||
if (process.env.NODE_ENV !== 'development') {
|
|
||||||
error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return (
|
|
||||||
<div className="error-handler">
|
|
||||||
<h1>Oops! This is awkward :S</h1>
|
|
||||||
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
|
|
||||||
<br />
|
|
||||||
<Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ErrorHandler;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
@import '../utils/mixins/vertical-align.scss';
|
|
||||||
|
|
||||||
.error-handler {
|
|
||||||
@include vertical-align();
|
|
||||||
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
46
src/common/ErrorHandler.tsx
Normal file
46
src/common/ErrorHandler.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Component, ReactNode } from 'react';
|
||||||
|
import { Button } from 'reactstrap';
|
||||||
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
|
||||||
|
interface ErrorHandlerState {
|
||||||
|
hasError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorHandler = (
|
||||||
|
{ location }: Window,
|
||||||
|
{ error }: Console,
|
||||||
|
) => class extends Component<any, ErrorHandlerState> {
|
||||||
|
public constructor(props: object) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(): ErrorHandlerState {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(e: Error): void {
|
||||||
|
if (process.env.NODE_ENV !== 'development') {
|
||||||
|
error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): ReactNode {
|
||||||
|
const { hasError } = this.state;
|
||||||
|
if (hasError) {
|
||||||
|
return (
|
||||||
|
<div className="home">
|
||||||
|
<SimpleCard className="p-4">
|
||||||
|
<h1>Oops! This is awkward :S</h1>
|
||||||
|
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
|
||||||
|
<br />
|
||||||
|
<Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
|
||||||
|
</SimpleCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { children } = this.props;
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
import { isEmpty, values } from 'ramda';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './Home.scss';
|
|
||||||
import ServersListGroup from '../servers/ServersListGroup';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
resetSelectedServer: PropTypes.func,
|
|
||||||
servers: PropTypes.object,
|
|
||||||
};
|
|
||||||
|
|
||||||
const Home = ({ resetSelectedServer, servers: { list, loading } }) => {
|
|
||||||
const servers = values(list);
|
|
||||||
const hasServers = !isEmpty(servers);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
resetSelectedServer();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="home">
|
|
||||||
<h1 className="home__title">Welcome to Shlink</h1>
|
|
||||||
<ServersListGroup servers={servers}>
|
|
||||||
{!loading && hasServers && <span>Please, select a server.</span>}
|
|
||||||
{!loading && !hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
|
|
||||||
{loading && <span>Trying to load servers...</span>}
|
|
||||||
</ServersListGroup>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Home.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default Home;
|
|
||||||
@@ -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});
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
67
src/common/Home.tsx
Normal file
67
src/common/Home.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { isEmpty, values } from 'ramda';
|
||||||
|
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 { ServersMap } from '../servers/data';
|
||||||
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
|
import './Home.scss';
|
||||||
|
|
||||||
|
interface HomeProps {
|
||||||
|
servers: ServersMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Home = ({ servers }: HomeProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const serversList = values(servers);
|
||||||
|
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 (
|
||||||
|
<div className="home">
|
||||||
|
<Card className="home__main-card">
|
||||||
|
<Row className="g-0">
|
||||||
|
<div className="col-md-5 d-none d-md-block">
|
||||||
|
<div className="home__logo-wrapper">
|
||||||
|
<div className="home__logo">
|
||||||
|
<ShlinkLogo />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { faPlus as plusIcon, faChevronDown as arrowIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import React from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
|
||||||
import classnames from 'classnames';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import shlinkLogo from './shlink-logo-white.png';
|
|
||||||
import './MainHeader.scss';
|
|
||||||
|
|
||||||
const MainHeader = (ServersDropdown) => class MainHeader extends React.Component {
|
|
||||||
static propTypes = {
|
|
||||||
location: PropTypes.object,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = { isOpen: false };
|
|
||||||
handleToggle = () => {
|
|
||||||
this.setState(({ isOpen }) => ({
|
|
||||||
isOpen: !isOpen,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
if (this.props.location !== prevProps.location) {
|
|
||||||
this.setState({ isOpen: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { location } = this.props;
|
|
||||||
const createServerPath = '/server/create';
|
|
||||||
const toggleClass = classnames('main-header__toggle-icon', {
|
|
||||||
'main-header__toggle-icon--opened': this.state.isOpen,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
|
||||||
<NavbarBrand tag={Link} to="/">
|
|
||||||
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
|
|
||||||
</NavbarBrand>
|
|
||||||
|
|
||||||
<NavbarToggler onClick={this.handleToggle}>
|
|
||||||
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
|
||||||
</NavbarToggler>
|
|
||||||
|
|
||||||
<Collapse navbar isOpen={this.state.isOpen}>
|
|
||||||
<Nav navbar className="ml-auto">
|
|
||||||
<NavItem>
|
|
||||||
<NavLink
|
|
||||||
tag={Link}
|
|
||||||
to={createServerPath}
|
|
||||||
active={location.pathname === createServerPath}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={plusIcon} /> Add server
|
|
||||||
</NavLink>
|
|
||||||
</NavItem>
|
|
||||||
<ServersDropdown />
|
|
||||||
</Nav>
|
|
||||||
</Collapse>
|
|
||||||
</Navbar>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MainHeader;
|
|
||||||
@@ -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;
|
||||||
|
|||||||
43
src/common/MainHeader.tsx
Normal file
43
src/common/MainHeader.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { FC, useEffect } from 'react';
|
||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
|
import './MainHeader.scss';
|
||||||
|
|
||||||
|
export const MainHeader = (ServersDropdown: FC) => () => {
|
||||||
|
const [isOpen, toggleOpen, , close] = useToggle();
|
||||||
|
const location = useLocation();
|
||||||
|
const { pathname } = location;
|
||||||
|
|
||||||
|
useEffect(close, [location]);
|
||||||
|
|
||||||
|
const settingsPath = '/settings';
|
||||||
|
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
||||||
|
<NavbarBrand tag={Link} to="/">
|
||||||
|
<ShlinkLogo className="main-header__brand-logo" color="white" /> Shlink
|
||||||
|
</NavbarBrand>
|
||||||
|
|
||||||
|
<NavbarToggler onClick={toggleOpen}>
|
||||||
|
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
||||||
|
</NavbarToggler>
|
||||||
|
|
||||||
|
<Collapse navbar isOpen={isOpen}>
|
||||||
|
<Nav navbar className="ms-auto">
|
||||||
|
<NavItem>
|
||||||
|
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
|
||||||
|
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||||
|
</NavLink>
|
||||||
|
</NavItem>
|
||||||
|
<ServersDropdown />
|
||||||
|
</Nav>
|
||||||
|
</Collapse>
|
||||||
|
</Navbar>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
import { Route, Switch } from 'react-router-dom';
|
|
||||||
import { Swipeable } from 'react-swipeable';
|
|
||||||
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import * as PropTypes from 'prop-types';
|
|
||||||
import { serverType } from '../servers/prop-types';
|
|
||||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
|
||||||
import NotFound from './NotFound';
|
|
||||||
import './MenuLayout.scss';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
match: PropTypes.object,
|
|
||||||
location: PropTypes.object,
|
|
||||||
selectedServer: serverType,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits, ShlinkVersions, ServerError) => {
|
|
||||||
const MenuLayoutComp = ({ match, location, selectedServer }) => {
|
|
||||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
|
||||||
const { params: { serverId } } = match;
|
|
||||||
|
|
||||||
useEffect(() => hideSidebar(), [ location ]);
|
|
||||||
|
|
||||||
if (selectedServer.serverNotReachable) {
|
|
||||||
return <ServerError type="not-reachable" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const burgerClasses = classNames('menu-layout__burger-icon', {
|
|
||||||
'menu-layout__burger-icon--active': sidebarVisible,
|
|
||||||
});
|
|
||||||
const swipeMenuIfNoModalExists = (callback) => () => {
|
|
||||||
if (document.querySelector('.modal')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
callback();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
|
|
||||||
|
|
||||||
<Swipeable
|
|
||||||
delta={40}
|
|
||||||
className="menu-layout__swipeable"
|
|
||||||
onSwipedLeft={swipeMenuIfNoModalExists(hideSidebar)}
|
|
||||||
onSwipedRight={swipeMenuIfNoModalExists(showSidebar)}
|
|
||||||
>
|
|
||||||
<div className="row menu-layout__swipeable-inner">
|
|
||||||
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
|
||||||
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
|
|
||||||
<div className="menu-layout__container">
|
|
||||||
<Switch>
|
|
||||||
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
|
|
||||||
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
|
||||||
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
|
||||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
|
||||||
<Route
|
|
||||||
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`}>List short URLs</NotFound>}
|
|
||||||
/>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="menu-layout__footer text-center text-md-right">
|
|
||||||
<ShlinkVersions />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Swipeable>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
MenuLayoutComp.propTypes = propTypes;
|
|
||||||
|
|
||||||
return withSelectedServer(MenuLayoutComp, ServerError);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MenuLayout;
|
|
||||||
@@ -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,25 +27,11 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
$footer-height: 2.3rem;
|
.menu-layout__container.menu-layout__container {
|
||||||
$footer-margin: .8rem;
|
padding: 20px 0 0;
|
||||||
|
|
||||||
.menu-layout__container {
|
|
||||||
padding: 20px 0 ($footer-height + $footer-margin);
|
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
margin-bottom: -($footer-height + $footer-margin);
|
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
@media (min-width: $mdMin) {
|
||||||
padding: 30px 15px ($footer-height + $footer-margin);
|
padding: 30px 0 0 $asideMenuWidth;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-layout__footer {
|
|
||||||
height: $footer-height;
|
|
||||||
margin-top: $footer-margin;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
|
||||||
padding: 0 15px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
88
src/common/MenuLayout.tsx
Normal file
88
src/common/MenuLayout.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { FC, useEffect } from 'react';
|
||||||
|
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
|
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||||
|
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
|
||||||
|
import { supportsDomainRedirects, supportsDomainVisits, supportsNonOrphanVisits } from '../utils/helpers/features';
|
||||||
|
import { isReachableServer } from '../servers/data';
|
||||||
|
import { NotFound } from './NotFound';
|
||||||
|
import { AsideMenuProps } from './AsideMenu';
|
||||||
|
import './MenuLayout.scss';
|
||||||
|
|
||||||
|
interface MenuLayoutProps {
|
||||||
|
sidebarPresent: Function;
|
||||||
|
sidebarNotPresent: Function;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuLayout = (
|
||||||
|
TagsList: FC,
|
||||||
|
ShortUrlsList: FC,
|
||||||
|
AsideMenu: FC<AsideMenuProps>,
|
||||||
|
CreateShortUrl: FC,
|
||||||
|
ShortUrlVisits: FC,
|
||||||
|
TagVisits: FC,
|
||||||
|
DomainVisits: FC,
|
||||||
|
OrphanVisits: FC,
|
||||||
|
NonOrphanVisits: FC,
|
||||||
|
ServerError: FC,
|
||||||
|
Overview: FC,
|
||||||
|
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(() => {
|
||||||
|
showContent && sidebarPresent();
|
||||||
|
|
||||||
|
return () => sidebarNotPresent();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!showContent) {
|
||||||
|
return <ServerError />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer);
|
||||||
|
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
|
||||||
|
const addDomainVisitsRoute = supportsDomainVisits(selectedServer);
|
||||||
|
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
|
||||||
|
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
|
||||||
|
|
||||||
|
<div {...swipeableProps} className="menu-layout__swipeable">
|
||||||
|
<div className="menu-layout__swipeable-inner">
|
||||||
|
<AsideMenu selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
||||||
|
<div className="menu-layout__container" onClick={() => hideSidebar()}>
|
||||||
|
<div className="container-xl">
|
||||||
|
<Routes>
|
||||||
|
<Route index element={<Navigate replace to="overview" />} />
|
||||||
|
<Route path="/overview" element={<Overview />} />
|
||||||
|
<Route path="/list-short-urls/:page" element={<ShortUrlsList />} />
|
||||||
|
<Route path="/create-short-url" element={<CreateShortUrl />} />
|
||||||
|
<Route path="/short-code/:shortCode/visits/*" element={<ShortUrlVisits />} />
|
||||||
|
<Route path="/short-code/:shortCode/edit" element={<EditShortUrl />} />
|
||||||
|
<Route path="/tag/:tag/visits/*" element={<TagVisits />} />
|
||||||
|
{addDomainVisitsRoute && <Route path="/domain/:domain/visits/*" element={<DomainVisits />} />}
|
||||||
|
<Route path="/orphan-visits/*" element={<OrphanVisits />} />
|
||||||
|
{addNonOrphanVisitsRoute && <Route path="/non-orphan-visits/*" element={<NonOrphanVisits />} />}
|
||||||
|
<Route path="/manage-tags" element={<TagsList />} />
|
||||||
|
{addManageDomainsRoute && <Route path="/manage-domains" element={<ManageDomains />} />}
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={<NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}, ServerError);
|
||||||
9
src/common/NoMenuLayout.scss
Normal file
9
src/common/NoMenuLayout.scss
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
@import '../utils/base';
|
||||||
|
|
||||||
|
.no-menu-wrapper {
|
||||||
|
padding: 15px 0 0;
|
||||||
|
|
||||||
|
@media (min-width: $mdMin) {
|
||||||
|
padding: 30px 20px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/common/NoMenuLayout.tsx
Normal file
6
src/common/NoMenuLayout.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { FC, PropsWithChildren } from 'react';
|
||||||
|
import './NoMenuLayout.scss';
|
||||||
|
|
||||||
|
export const NoMenuLayout: FC<PropsWithChildren<unknown>> = ({ children }) => (
|
||||||
|
<div className="no-menu-wrapper container-xl">{children}</div>
|
||||||
|
);
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import * as PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
to: PropTypes.string,
|
|
||||||
children: PropTypes.node,
|
|
||||||
};
|
|
||||||
|
|
||||||
const NotFound = ({ to = '/', children = 'Home' }) => (
|
|
||||||
<div className="home">
|
|
||||||
<h2>Oops! We could not find requested route.</h2>
|
|
||||||
<p>
|
|
||||||
Use your browser's back button to navigate to the page you have previously come from, or just press this
|
|
||||||
button.
|
|
||||||
</p>
|
|
||||||
<br />
|
|
||||||
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
NotFound.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default NotFound;
|
|
||||||
19
src/common/NotFound.tsx
Normal file
19
src/common/NotFound.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { FC, PropsWithChildren } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
|
||||||
|
type NotFoundProps = PropsWithChildren<{ to?: string }>;
|
||||||
|
|
||||||
|
export const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
|
||||||
|
<div className="home">
|
||||||
|
<SimpleCard className="p-4">
|
||||||
|
<h2>Oops! We could not find requested route.</h2>
|
||||||
|
<p>
|
||||||
|
Use your browser's back button to navigate to the page you have previously come from, or just press this
|
||||||
|
button.
|
||||||
|
</p>
|
||||||
|
<br />
|
||||||
|
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
|
||||||
|
</SimpleCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
const ScrollToTop = ({ scrollTo }) => class ScrollToTop extends React.Component {
|
|
||||||
static propTypes = {
|
|
||||||
location: PropTypes.object,
|
|
||||||
children: PropTypes.node,
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidUpdate({ location: prevLocation }) {
|
|
||||||
const { location } = this.props;
|
|
||||||
|
|
||||||
if (location !== prevLocation) {
|
|
||||||
scrollTo(0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ScrollToTop;
|
|
||||||
12
src/common/ScrollToTop.tsx
Normal file
12
src/common/ScrollToTop.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { FC, PropsWithChildren, useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const ScrollToTop: FC<PropsWithChildren<unknown>> = ({ children }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollTo(0, 0);
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { pipe } from 'ramda';
|
|
||||||
import { serverType } from '../servers/prop-types';
|
|
||||||
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
|
||||||
|
|
||||||
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
selectedServer: serverType,
|
|
||||||
className: PropTypes.string,
|
|
||||||
clientVersion: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ShlinkVersions = ({ selectedServer, className, clientVersion = SHLINK_WEB_CLIENT_VERSION }) => {
|
|
||||||
const { printableVersion: serverVersion } = selectedServer;
|
|
||||||
const normalizedClientVersion = pipe(versionToSemVer(), versionToPrintable)(clientVersion);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<small className={classNames('text-muted', className)}>
|
|
||||||
Client: <b>{normalizedClientVersion}</b> - Server: <b>{serverVersion}</b>
|
|
||||||
</small>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ShlinkVersions.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default ShlinkVersions;
|
|
||||||
31
src/common/ShlinkVersions.tsx
Normal file
31
src/common/ShlinkVersions.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { pipe } from 'ramda';
|
||||||
|
import { ExternalLink } from 'react-external-link';
|
||||||
|
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
||||||
|
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||||
|
|
||||||
|
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
|
||||||
|
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
|
||||||
|
|
||||||
|
export interface ShlinkVersionsProps {
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
clientVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => (
|
||||||
|
<ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="text-muted">
|
||||||
|
<b>{version}</b>
|
||||||
|
</ExternalLink>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps) => {
|
||||||
|
const normalizedClientVersion = normalizeVersion(clientVersion);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<small className="text-muted">
|
||||||
|
{isReachableServer(selectedServer) && (
|
||||||
|
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
|
||||||
|
)}
|
||||||
|
Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} />
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/common/ShlinkVersionsContainer.tsx
Normal file
22
src/common/ShlinkVersionsContainer.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,23 +1,30 @@
|
|||||||
import React from 'react';
|
import { FC } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||||
import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
|
import {
|
||||||
|
pageIsEllipsis,
|
||||||
|
keyForPage,
|
||||||
|
NumberOrEllipsis,
|
||||||
|
progressivePagination,
|
||||||
|
prettifyPageNumber,
|
||||||
|
} from '../utils/helpers/pagination';
|
||||||
import './SimplePaginator.scss';
|
import './SimplePaginator.scss';
|
||||||
|
|
||||||
const propTypes = {
|
interface SimplePaginatorProps {
|
||||||
pagesCount: PropTypes.number.isRequired,
|
pagesCount: number;
|
||||||
currentPage: PropTypes.number.isRequired,
|
currentPage: number;
|
||||||
setCurrentPage: PropTypes.func.isRequired,
|
setCurrentPage: (currentPage: number) => void;
|
||||||
centered: PropTypes.bool,
|
centered?: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
const SimplePaginator = ({ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClick = (page) => () => setCurrentPage(page);
|
const onClick = (page: NumberOrEllipsis) => () => !pageIsEllipsis(page) && setCurrentPage(page);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pagination listClassName={classNames('flex-wrap mb-0 simple-paginator', { 'justify-content-center': centered })}>
|
<Pagination listClassName={classNames('flex-wrap mb-0 simple-paginator', { 'justify-content-center': centered })}>
|
||||||
@@ -27,10 +34,12 @@ const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage, centered = t
|
|||||||
{progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
|
{progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
|
||||||
<PaginationItem
|
<PaginationItem
|
||||||
key={keyForPage(pageNumber, index)}
|
key={keyForPage(pageNumber, index)}
|
||||||
disabled={isPageDisabled(pageNumber)}
|
disabled={pageIsEllipsis(pageNumber)}
|
||||||
active={currentPage === pageNumber}
|
active={currentPage === pageNumber}
|
||||||
>
|
>
|
||||||
<PaginationLink tag="span" onClick={onClick(pageNumber)}>{pageNumber}</PaginationLink>
|
<PaginationLink role="link" tag="span" onClick={onClick(pageNumber)}>
|
||||||
|
{prettifyPageNumber(pageNumber)}
|
||||||
|
</PaginationLink>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
))}
|
))}
|
||||||
<PaginationItem disabled={currentPage >= pagesCount}>
|
<PaginationItem disabled={currentPage >= pagesCount}>
|
||||||
@@ -39,7 +48,3 @@ const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage, centered = t
|
|||||||
</Pagination>
|
</Pagination>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
SimplePaginator.propTypes = propTypes;
|
|
||||||
|
|
||||||
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: #fff;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
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: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tagsinput-remove {
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tagsinput-tag span:before {
|
|
||||||
content: '\2715';
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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,44 +0,0 @@
|
|||||||
import ScrollToTop from '../ScrollToTop';
|
|
||||||
import MainHeader from '../MainHeader';
|
|
||||||
import Home from '../Home';
|
|
||||||
import MenuLayout from '../MenuLayout';
|
|
||||||
import AsideMenu from '../AsideMenu';
|
|
||||||
import ErrorHandler from '../ErrorHandler';
|
|
||||||
import ShlinkVersions from '../ShlinkVersions';
|
|
||||||
|
|
||||||
const provideServices = (bottle, connect, withRouter) => {
|
|
||||||
bottle.constant('window', global.window);
|
|
||||||
bottle.constant('console', global.console);
|
|
||||||
|
|
||||||
bottle.serviceFactory('ScrollToTop', ScrollToTop, 'window');
|
|
||||||
bottle.decorator('ScrollToTop', withRouter);
|
|
||||||
|
|
||||||
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
|
|
||||||
bottle.decorator('MainHeader', withRouter);
|
|
||||||
|
|
||||||
bottle.serviceFactory('Home', () => Home);
|
|
||||||
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
|
|
||||||
|
|
||||||
bottle.serviceFactory(
|
|
||||||
'MenuLayout',
|
|
||||||
MenuLayout,
|
|
||||||
'TagsList',
|
|
||||||
'ShortUrls',
|
|
||||||
'AsideMenu',
|
|
||||||
'CreateShortUrl',
|
|
||||||
'ShortUrlVisits',
|
|
||||||
'ShlinkVersions',
|
|
||||||
'ServerError'
|
|
||||||
);
|
|
||||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
|
||||||
bottle.decorator('MenuLayout', withRouter);
|
|
||||||
|
|
||||||
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
|
||||||
|
|
||||||
bottle.serviceFactory('ShlinkVersions', () => ShlinkVersions);
|
|
||||||
bottle.decorator('ShlinkVersions', connect([ 'selectedServer' ]));
|
|
||||||
|
|
||||||
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
|
||||||
};
|
|
||||||
|
|
||||||
export default provideServices;
|
|
||||||
65
src/common/services/provideServices.ts
Normal file
65
src/common/services/provideServices.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import Bottle from 'bottlejs';
|
||||||
|
import { ScrollToTop } from '../ScrollToTop';
|
||||||
|
import { MainHeader } from '../MainHeader';
|
||||||
|
import { Home } from '../Home';
|
||||||
|
import { MenuLayout } from '../MenuLayout';
|
||||||
|
import { AsideMenu } from '../AsideMenu';
|
||||||
|
import { ErrorHandler } from '../ErrorHandler';
|
||||||
|
import { ShlinkVersionsContainer } from '../ShlinkVersionsContainer';
|
||||||
|
import { ConnectDecorator } from '../../container/types';
|
||||||
|
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) => {
|
||||||
|
// Services
|
||||||
|
bottle.constant('window', (global as any).window);
|
||||||
|
bottle.constant('console', global.console);
|
||||||
|
bottle.constant('axios', axios);
|
||||||
|
|
||||||
|
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
|
||||||
|
bottle.service('ReportExporter', ReportExporter, 'window', 'jsonToCsv');
|
||||||
|
|
||||||
|
// Components
|
||||||
|
bottle.serviceFactory('ScrollToTop', () => ScrollToTop);
|
||||||
|
|
||||||
|
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
|
||||||
|
|
||||||
|
bottle.serviceFactory('Home', () => Home);
|
||||||
|
bottle.decorator('Home', withoutSelectedServer);
|
||||||
|
bottle.decorator('Home', connect(['servers'], ['resetSelectedServer']));
|
||||||
|
|
||||||
|
bottle.serviceFactory(
|
||||||
|
'MenuLayout',
|
||||||
|
MenuLayout,
|
||||||
|
'TagsList',
|
||||||
|
'ShortUrlsList',
|
||||||
|
'AsideMenu',
|
||||||
|
'CreateShortUrl',
|
||||||
|
'ShortUrlVisits',
|
||||||
|
'TagVisits',
|
||||||
|
'DomainVisits',
|
||||||
|
'OrphanVisits',
|
||||||
|
'NonOrphanVisits',
|
||||||
|
'ServerError',
|
||||||
|
'Overview',
|
||||||
|
'EditShortUrl',
|
||||||
|
'ManageDomains',
|
||||||
|
);
|
||||||
|
bottle.decorator('MenuLayout', connect(['selectedServer'], ['selectServer', 'sidebarPresent', 'sidebarNotPresent']));
|
||||||
|
|
||||||
|
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
||||||
|
|
||||||
|
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
|
||||||
|
bottle.decorator('ShlinkVersionsContainer', connect(['selectedServer', 'sidebar']));
|
||||||
|
|
||||||
|
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('sidebarPresent', () => sidebarPresent);
|
||||||
|
bottle.serviceFactory('sidebarNotPresent', () => sidebarNotPresent);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provideServices;
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.7 KiB |
@@ -1,38 +0,0 @@
|
|||||||
import Bottle from 'bottlejs';
|
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
import { connect as reduxConnect } from 'react-redux';
|
|
||||||
import { pick } from 'ramda';
|
|
||||||
import App from '../App';
|
|
||||||
import provideCommonServices from '../common/services/provideServices';
|
|
||||||
import provideShortUrlsServices from '../short-urls/services/provideServices';
|
|
||||||
import provideServersServices from '../servers/services/provideServices';
|
|
||||||
import provideVisitsServices from '../visits/services/provideServices';
|
|
||||||
import provideTagsServices from '../tags/services/provideServices';
|
|
||||||
import provideUtilsServices from '../utils/services/provideServices';
|
|
||||||
|
|
||||||
const bottle = new Bottle();
|
|
||||||
const { container } = bottle;
|
|
||||||
|
|
||||||
const lazyService = (container, serviceName) => (...args) => container[serviceName](...args);
|
|
||||||
const mapActionService = (map, actionName) => ({
|
|
||||||
...map,
|
|
||||||
|
|
||||||
// Wrap actual action service in a function so that it is lazily created the first time it is called
|
|
||||||
[actionName]: lazyService(container, actionName),
|
|
||||||
});
|
|
||||||
const connect = (propsFromState, actionServiceNames = []) =>
|
|
||||||
reduxConnect(
|
|
||||||
propsFromState ? pick(propsFromState) : null,
|
|
||||||
actionServiceNames.reduce(mapActionService, {})
|
|
||||||
);
|
|
||||||
|
|
||||||
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer');
|
|
||||||
|
|
||||||
provideCommonServices(bottle, connect, withRouter);
|
|
||||||
provideShortUrlsServices(bottle, connect);
|
|
||||||
provideServersServices(bottle, connect, withRouter);
|
|
||||||
provideTagsServices(bottle, connect);
|
|
||||||
provideVisitsServices(bottle, connect);
|
|
||||||
provideUtilsServices(bottle);
|
|
||||||
|
|
||||||
export default container;
|
|
||||||
46
src/container/index.ts
Normal file
46
src/container/index.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import Bottle, { IContainer } from 'bottlejs';
|
||||||
|
import { connect as reduxConnect } from 'react-redux';
|
||||||
|
import { pick } from 'ramda';
|
||||||
|
import provideApiServices from '../api/services/provideServices';
|
||||||
|
import provideCommonServices from '../common/services/provideServices';
|
||||||
|
import provideShortUrlsServices from '../short-urls/services/provideServices';
|
||||||
|
import provideServersServices from '../servers/services/provideServices';
|
||||||
|
import provideVisitsServices from '../visits/services/provideServices';
|
||||||
|
import provideTagsServices from '../tags/services/provideServices';
|
||||||
|
import provideUtilsServices from '../utils/services/provideServices';
|
||||||
|
import provideMercureServices from '../mercure/services/provideServices';
|
||||||
|
import provideSettingsServices from '../settings/services/provideServices';
|
||||||
|
import provideDomainsServices from '../domains/services/provideServices';
|
||||||
|
import provideAppServices from '../app/services/provideServices';
|
||||||
|
import { ConnectDecorator } from './types';
|
||||||
|
|
||||||
|
type LazyActionMap = Record<string, Function>;
|
||||||
|
|
||||||
|
const bottle = new Bottle();
|
||||||
|
|
||||||
|
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 => ({
|
||||||
|
...map,
|
||||||
|
// Wrap actual action service in a function so that it is lazily created the first time it is called
|
||||||
|
[actionName]: lazyService(container, actionName),
|
||||||
|
});
|
||||||
|
const connect: ConnectDecorator = (propsFromState: string[] | null, actionServiceNames: string[] = []) =>
|
||||||
|
reduxConnect(
|
||||||
|
propsFromState ? pick(propsFromState) : null,
|
||||||
|
actionServiceNames.reduce(mapActionService, {}),
|
||||||
|
);
|
||||||
|
|
||||||
|
provideAppServices(bottle, connect);
|
||||||
|
provideCommonServices(bottle, connect);
|
||||||
|
provideApiServices(bottle);
|
||||||
|
provideShortUrlsServices(bottle, connect);
|
||||||
|
provideServersServices(bottle, connect);
|
||||||
|
provideTagsServices(bottle, connect);
|
||||||
|
provideVisitsServices(bottle, connect);
|
||||||
|
provideUtilsServices(bottle);
|
||||||
|
provideMercureServices(bottle);
|
||||||
|
provideSettingsServices(bottle, connect);
|
||||||
|
provideDomainsServices(bottle, connect);
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import ReduxThunk from 'redux-thunk';
|
|
||||||
import { applyMiddleware, compose, createStore } from 'redux';
|
|
||||||
import reducers from '../reducers';
|
|
||||||
|
|
||||||
const composeEnhancers = process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
|
||||||
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
|
||||||
: compose;
|
|
||||||
|
|
||||||
const store = createStore(reducers, composeEnhancers(
|
|
||||||
applyMiddleware(ReduxThunk)
|
|
||||||
));
|
|
||||||
|
|
||||||
export default store;
|
|
||||||
22
src/container/store.ts
Normal file
22
src/container/store.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import ReduxThunk from 'redux-thunk';
|
||||||
|
import { applyMiddleware, compose, createStore } from 'redux';
|
||||||
|
import { save, load, RLSOptions } from 'redux-localstorage-simple';
|
||||||
|
import reducers from '../reducers';
|
||||||
|
import { migrateDeprecatedSettings } from '../settings/helpers';
|
||||||
|
import { ShlinkState } from './types';
|
||||||
|
|
||||||
|
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 localStorageConfig: RLSOptions = {
|
||||||
|
states: ['settings', 'servers'],
|
||||||
|
namespace: 'shlink',
|
||||||
|
namespaceSeparator: '.',
|
||||||
|
debounce: 300,
|
||||||
|
};
|
||||||
|
const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState);
|
||||||
|
|
||||||
|
export const store = createStore(reducers, preloadedState, composeEnhancers(
|
||||||
|
applyMiddleware(save(localStorageConfig), ReduxThunk),
|
||||||
|
));
|
||||||
46
src/container/types.ts
Normal file
46
src/container/types.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { MercureInfo } from '../mercure/reducers/mercureInfo';
|
||||||
|
import { SelectedServer, ServersMap } from '../servers/data';
|
||||||
|
import { Settings } from '../settings/reducers/settings';
|
||||||
|
import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';
|
||||||
|
import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion';
|
||||||
|
import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition';
|
||||||
|
import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
|
||||||
|
import { TagDeletion } from '../tags/reducers/tagDelete';
|
||||||
|
import { TagEdition } from '../tags/reducers/tagEdit';
|
||||||
|
import { TagsList } from '../tags/reducers/tagsList';
|
||||||
|
import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
|
||||||
|
import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
|
||||||
|
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 {
|
||||||
|
servers: ServersMap;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
shortUrlsList: ShortUrlsList;
|
||||||
|
shortUrlCreationResult: ShortUrlCreation;
|
||||||
|
shortUrlDeletion: ShortUrlDeletion;
|
||||||
|
shortUrlEdition: ShortUrlEdition;
|
||||||
|
shortUrlVisits: ShortUrlVisits;
|
||||||
|
tagVisits: TagVisits;
|
||||||
|
domainVisits: DomainVisits;
|
||||||
|
orphanVisits: VisitsInfo;
|
||||||
|
nonOrphanVisits: VisitsInfo;
|
||||||
|
shortUrlDetail: ShortUrlDetail;
|
||||||
|
tagsList: TagsList;
|
||||||
|
tagDelete: TagDeletion;
|
||||||
|
tagEdit: TagEdition;
|
||||||
|
mercureInfo: MercureInfo;
|
||||||
|
settings: Settings;
|
||||||
|
domainsList: DomainsList;
|
||||||
|
visitsOverview: VisitsOverview;
|
||||||
|
appUpdated: boolean;
|
||||||
|
sidebar: Sidebar;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
||||||
|
|
||||||
|
export type GetState = () => ShlinkState;
|
||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user