mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-02-25 11:16:36 +00:00
Compare commits
486 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,7 +1,9 @@
|
||||
./.github
|
||||
./.stryker-tmp
|
||||
./build
|
||||
./coverage
|
||||
./node_modules
|
||||
./test
|
||||
./shlink-web-client.gif
|
||||
./dist
|
||||
./docs
|
||||
|
||||
@@ -13,5 +13,12 @@
|
||||
"globals": {
|
||||
"process": true,
|
||||
"setImmediate": true
|
||||
},
|
||||
"ignorePatterns": ["src/service*.ts"],
|
||||
"rules": {
|
||||
"complexity": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "off",
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
"@typescript-eslint/no-unsafe-call": "off"
|
||||
}
|
||||
}
|
||||
|
||||
56
.github/workflows/ci.yml
vendored
56
.github/workflows/ci.yml
vendored
@@ -5,54 +5,12 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Use node.js 14.15
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14.15
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
|
||||
unit-tests:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Use node.js 14.15
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14.15
|
||||
- run: npm ci
|
||||
- run: npm run test:ci
|
||||
- name: Publish coverage
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
file: ./coverage/clover.xml
|
||||
|
||||
mutation-tests:
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0 # needed so that the main branch is also fetched
|
||||
- name: Use node.js 14.15
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14.15
|
||||
- run: npm ci
|
||||
- run: npm run mutate -- --mutate=$(git diff origin/main --name-only | grep -E 'src\/(.*).(ts|tsx)$' | paste -sd ",")
|
||||
|
||||
build-docker-image:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- run: docker build -t shlink-web-client:test .
|
||||
ci:
|
||||
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
|
||||
with:
|
||||
node-version: 16.13
|
||||
with-mutation-tests: true
|
||||
publish-coverage: true
|
||||
|
||||
29
.github/workflows/deploy-preview.yml
vendored
Normal file
29
.github/workflows/deploy-preview.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Deploy preview
|
||||
|
||||
on:
|
||||
pull_request_target: null
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-20.04
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
- name: Use node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 16.13
|
||||
- name: Build
|
||||
run: |
|
||||
npm ci && \
|
||||
node ./scripts/set-homepage.js /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \
|
||||
rm src/service-worker.ts && \
|
||||
npm run build
|
||||
- name: Deploy preview
|
||||
uses: shlinkio/deploy-preview-action@v1.0.1
|
||||
with:
|
||||
folder: build
|
||||
2
.github/workflows/docker-image-build.yml
vendored
2
.github/workflows/docker-image-build.yml
vendored
@@ -3,7 +3,7 @@ name: Build docker image
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
|
||||
7
.github/workflows/publish-release.yml
vendored
7
.github/workflows/publish-release.yml
vendored
@@ -11,17 +11,16 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Use node.js 14.15
|
||||
- name: Use node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14.15
|
||||
node-version: 16.13
|
||||
- name: Generate release assets
|
||||
run: npm ci && npm run build ${GITHUB_REF#refs/tags/v}
|
||||
run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
|
||||
- name: Publish release with assets
|
||||
uses: docker://antonyurchenko/git-release:latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ALLOW_TAG_PREFIX: "true"
|
||||
ALLOW_EMPTY_CHANGELOG: "true"
|
||||
with:
|
||||
args: |
|
||||
|
||||
291
CHANGELOG.md
291
CHANGELOG.md
@@ -4,6 +4,297 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## [3.6.0] - 2022-03-17
|
||||
### Added
|
||||
* [#558](https://github.com/shlinkio/shlink-web-client/pull/558) Added dark text for tags where the generated background is too light, improving its legibility.
|
||||
* [#570](https://github.com/shlinkio/shlink-web-client/pull/570) Added new section to load non-orphan visits all together when consuming Shlink 3.0.0.
|
||||
* [#556](https://github.com/shlinkio/shlink-web-client/pull/556) Added support to filter short URLs list by "all" tags when consuming Shlink 3.0.0.
|
||||
* [#549](https://github.com/shlinkio/shlink-web-client/pull/549) Allowed to export the list of short URLs as CSV.
|
||||
|
||||
### Changed
|
||||
* [#543](https://github.com/shlinkio/shlink-web-client/pull/543) Redesigned settings section.
|
||||
* [#567](https://github.com/shlinkio/shlink-web-client/pull/567) Improved Shlink 3.0.0 compatibility by checking the `INVALID_SHORT_URL_DELETION` error code when deleting short URLs.
|
||||
* [#448](https://github.com/shlinkio/shlink-web-client/pull/448) Updated to bootstrap v5.
|
||||
* [#524](https://github.com/shlinkio/shlink-web-client/pull/524) Updated to react-router v6.
|
||||
* [#576](https://github.com/shlinkio/shlink-web-client/pull/576) Updated to fontawesome v6.
|
||||
* [#579](https://github.com/shlinkio/shlink-web-client/pull/579) Replaced react-color with react-colorful.
|
||||
* [#564](https://github.com/shlinkio/shlink-web-client/pull/564) Updated most of the dependencies.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#589](https://github.com/shlinkio/shlink-web-client/pull/589) Fixed alignment of shlink versions footer, by basing the logic on the presence of the sidebar instead of selected server.
|
||||
|
||||
|
||||
## [3.5.1] - 2022-01-08
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#555](https://github.com/shlinkio/shlink-web-client/issues/555) Fixed vertical alignment in welcome screen logo.
|
||||
* [#554](https://github.com/shlinkio/shlink-web-client/issues/554) Fixed behavior in overview page, where items in the list of short URLs were stripped out when creating new ones, even if the amount of short URLs was still not yet big enough.
|
||||
* [#557](https://github.com/shlinkio/shlink-web-client/issues/557) Fixed new tags added to new short URLs, not appearing on tags autosuggest.
|
||||
|
||||
|
||||
## [3.5.0] - 2022-01-01
|
||||
### Added
|
||||
* [#407](https://github.com/shlinkio/shlink-web-client/pull/407) Improved how visits (short URLs, tags and orphan) are loaded, to avoid ending up in a page with "There are no visits matching current filter".
|
||||
|
||||
Now, the app will try to load visits for the configured default interval, and in parallel, it will load the latest visit.
|
||||
|
||||
If the resulting list for that interval is empty, it will try to infer the closest interval with visits, based on the latest visit's date, and reload visits for that interval.
|
||||
|
||||
* [#547](https://github.com/shlinkio/shlink-web-client/pull/547) Improved domains page, to tell which of the domains are not properly configured.
|
||||
|
||||
Now, when this section is loaded, it tries to call the `GET /rest/health` endpoint for each one of the domains, and displays a warning icon on each one that failed.
|
||||
|
||||
The warning includes a link to the documentation, explaining what are the steps to get it fixed.
|
||||
|
||||
* [#506](https://github.com/shlinkio/shlink-web-client/pull/506) Improved how servers are handled, displaying a warning when creating or importing servers that already exist.
|
||||
* [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer.
|
||||
* [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page.
|
||||
* [#537](https://github.com/shlinkio/shlink-web-client/pull/537) Allowed to customize the ordering for every list in the app that supports it, being currently tags and short URLs.
|
||||
* [#542](https://github.com/shlinkio/shlink-web-client/pull/542) Added ordering for short URLs to the query, so that it is consistent with the rest of the filtering params.
|
||||
|
||||
### Changed
|
||||
* [#534](https://github.com/shlinkio/shlink-web-client/pull/534) Updated axios.
|
||||
* [#538](https://github.com/shlinkio/shlink-web-client/pull/538) Switched to the `<field>-<dir>` notation in `orderBy` param for short URLs list, in preparation for Shlink v3.0.0
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## [3.4.2] - 2021-12-07
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#530](https://github.com/shlinkio/shlink-web-client/issues/530) Fixed crash on domains page when default domain has an explicitly set port.
|
||||
|
||||
|
||||
## [3.4.1] - 2021-11-20
|
||||
### Added
|
||||
* [#525](https://github.com/shlinkio/shlink-web-client/issues/525) Added docs section for Architectural Decision Records, including the one for servers "auto-connect".
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#520](https://github.com/shlinkio/shlink-web-client/issues/520) Fixed landing page scroll on mobile devices and improved its design.
|
||||
* [#526](https://github.com/shlinkio/shlink-web-client/issues/526) Ensured exported servers do not include the `autoConnect` prop.
|
||||
|
||||
|
||||
## [3.4.0] - 2021-11-11
|
||||
### Added
|
||||
* [#496](https://github.com/shlinkio/shlink-web-client/issues/496) Allowed to select "all visits" as the default interval for visits.
|
||||
* [#500](https://github.com/shlinkio/shlink-web-client/issues/500) Allowed to set the `forwardQuery` flag when creating/editing short URLs on a Shlink v2.9.0 server.
|
||||
* [#508](https://github.com/shlinkio/shlink-web-client/issues/508) Added new servers management section.
|
||||
* [#490](https://github.com/shlinkio/shlink-web-client/issues/490) Now a server can be marked as auto-connect, skipping home screen when that happens.
|
||||
* [#492](https://github.com/shlinkio/shlink-web-client/issues/492) Improved tags table, by supporting sorting by column and making the header sticky.
|
||||
* [#515](https://github.com/shlinkio/shlink-web-client/issues/515) Allowed to sort tags even when using the cards display mode.
|
||||
* [#518](https://github.com/shlinkio/shlink-web-client/issues/518) Improved short URLs list filtering by moving selected tags, search text and dates to the query string, allowing to navigate back and forth or even bookmark filters.
|
||||
|
||||
### Changed
|
||||
* Moved ci workflow to external repo and reused
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#252](https://github.com/shlinkio/shlink-web-client/issues/252) Fixed visits coming from mercure being added in real time, even when selected date interval does not match tha visit's date.
|
||||
* [#48](https://github.com/shlinkio/shlink-web-client/issues/48) Fixed error when selected page gets out of range after filtering short URLs list by text, tags or dates. Now the page is reset to 1 in any of those cases.
|
||||
|
||||
|
||||
## [3.3.2] - 2021-10-17
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#503](https://github.com/shlinkio/shlink-web-client/issues/503) Fixed short URLs title not being resettable after creation.
|
||||
|
||||
|
||||
## [3.3.1] - 2021-09-27
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#497](https://github.com/shlinkio/shlink-web-client/issues/497) Fixed crash in domains section when one of the domains have more than one dot.
|
||||
|
||||
|
||||
## [3.3.0] - 2021-09-25
|
||||
### Added
|
||||
* [#465](https://github.com/shlinkio/shlink-web-client/issues/465) Added new page to manage domains and their redirects, when consuming Shlink 2.8 or higher.
|
||||
* [#460](https://github.com/shlinkio/shlink-web-client/issues/460) Added dynamic title on hover for tags with a very long title.
|
||||
* [#462](https://github.com/shlinkio/shlink-web-client/issues/462) Now it is possible to paste multiple comma-separated tags in the tags selector, making all of them to be added as individual tags.
|
||||
* [#463](https://github.com/shlinkio/shlink-web-client/issues/463) The strategy to determine which tags to suggest in the TagsSelector during short URL creation, can now be configured:
|
||||
|
||||
* `startsWith`: Suggests tags that start with the input. This is the default behavior for keep it as it was so far.
|
||||
* `includes`: Suggests tags that contain the input.
|
||||
|
||||
* [#464](https://github.com/shlinkio/shlink-web-client/issues/464) Added support to download QR codes. This feature requires an unreleased version of Shlink, so it comes disabled, and will get enabled as soon as Shlink v2.9 is released.
|
||||
* [#469](https://github.com/shlinkio/shlink-web-client/issues/469) Added support `errorCorrection` in QR codes, when consuming Shlink 2.8 or higher.
|
||||
* [#459](https://github.com/shlinkio/shlink-web-client/issues/459) Added new list mode to display tags.
|
||||
|
||||
The mode is optional, and you can toggle between the classic cards mode or the new list mode whenever you want.
|
||||
|
||||
You can also configure the default mode from settings.
|
||||
|
||||
### Changed
|
||||
* [#408](https://github.com/shlinkio/shlink-web-client/issues/408) Updated to Chart.js 3.5
|
||||
* [#486](https://github.com/shlinkio/shlink-web-client/issues/486) Refactored components used to render visits charts, making them easier to maintain and understand.
|
||||
* [#409](https://github.com/shlinkio/shlink-web-client/issues/409) Increased required code coverage and added hard threshold check.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* [#491](https://github.com/shlinkio/shlink-web-client/issues/491) Dropped support for Shlink older than v2.4.0.
|
||||
|
||||
### Fixed
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## [3.2.1] - 2021-09-12
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#478](https://github.com/shlinkio/shlink-web-client/issues/478) Fixed tags including special chars not being properly URL encoded before using them as query params.
|
||||
* [#480](https://github.com/shlinkio/shlink-web-client/issues/480) Fixed servers import on Chromium-based browsers when using windows.
|
||||
* [#482](https://github.com/shlinkio/shlink-web-client/issues/480) Fixed end date not being set to the end of the day when filtering visits using a "smart filter" (last 7 days, last 30 days, etc).
|
||||
|
||||
|
||||
## [3.2.0] - 2021-07-12
|
||||
### Added
|
||||
* [#433](https://github.com/shlinkio/shlink-web-client/pull/433) Added support to provide a default server to connect to via env vars:
|
||||
|
||||
* `SHLINK_SERVER_URL`: The URL of the Shlink server to configure by default.
|
||||
* `SHLINK_SERVER_API_KEY`: The API key of the Shlink server.
|
||||
* `SHLINK_SERVER_NAME`: A name you want to give to this server. Defaults to *Shlink* if not provided.
|
||||
|
||||
* [#432](https://github.com/shlinkio/shlink-web-client/pull/432) Added support to provide the `servers.json` file inside a `conf.d` folder.
|
||||
* [#440](https://github.com/shlinkio/shlink-web-client/issues/440) Added hint of what visits come potentially from a bot, in the visits table, when consuming Shlink >=2.7.
|
||||
* [#431](https://github.com/shlinkio/shlink-web-client/issues/431) Added support to filter out visits from potential bots in visits sections, when consuming Shlink >=2.7.
|
||||
* [#430](https://github.com/shlinkio/shlink-web-client/issues/430) Added support to set new and existing short URLs as crawlable, when consuming Shlink >=2.7.
|
||||
* [#450](https://github.com/shlinkio/shlink-web-client/issues/450) Improved landing page design.
|
||||
* [#449](https://github.com/shlinkio/shlink-web-client/issues/449) Improved PWA update banner, allowing to restart the app directly from it without having to close the tab.
|
||||
|
||||
### Changed
|
||||
* [#442](https://github.com/shlinkio/shlink-web-client/issues/442) Visits filtering now goes through the corresponding reducer.
|
||||
* [#337](https://github.com/shlinkio/shlink-web-client/issues/337) Replaced moment.js with date-fns.
|
||||
* [#360](https://github.com/shlinkio/shlink-web-client/issues/360) Changed component used to generate a tags selector, switching from `react-tagsinput`, which is no longer maintained, to `react-tag-autocomplete`.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#438](https://github.com/shlinkio/shlink-web-client/issues/438) Fixed horizontal scrolling in short URLs list on mobile devices when the long URL didn't have words to break.
|
||||
|
||||
|
||||
## [3.1.2] - 2021-06-06
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* [#428](https://github.com/shlinkio/shlink-web-client/issues/428) Updated to StrykerJS 5.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#371](https://github.com/shlinkio/shlink-web-client/issues/371) Recovered PWA functionality.
|
||||
|
||||
|
||||
## [3.1.1] - 2021-05-08
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#413](https://github.com/shlinkio/shlink-web-client/issues/413) Fixed edit short URL form reflecting outdated info after navigating back from other section.
|
||||
* [#412](https://github.com/shlinkio/shlink-web-client/issues/412) Ensured new visits coming from mercure hub are prepended and not appended, to keep proper sorting.
|
||||
* [#417](https://github.com/shlinkio/shlink-web-client/issues/417) Fixed link spanning out of QR code modal.
|
||||
* [#411](https://github.com/shlinkio/shlink-web-client/issues/411) Added missing feedback when editing a short URL to know if everything went right.
|
||||
|
||||
|
||||
## [3.1.0] - 2021-03-29
|
||||
### Added
|
||||
* [#379](https://github.com/shlinkio/shlink-web-client/issues/379) and [#384](https://github.com/shlinkio/shlink-web-client/issues/384) Improved QR code modal, including controls to customize size, format and margin, as well as a button to copy the link to the clipboard.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
FROM node:14.15-alpine as node
|
||||
FROM node:16.13-alpine as node
|
||||
COPY . /shlink-web-client
|
||||
ARG VERSION="latest"
|
||||
ENV VERSION ${VERSION}
|
||||
RUN cd /shlink-web-client && \
|
||||
npm install && npm run build -- ${VERSION} --no-dist
|
||||
RUN cd /shlink-web-client && npm ci && npm run build
|
||||
|
||||
FROM nginx:1.19.6-alpine
|
||||
FROM nginx:1.21-alpine
|
||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||
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 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
|
||||
|
||||
24
README.md
24
README.md
@@ -1,10 +1,11 @@
|
||||
# shlink-web-client
|
||||
|
||||
[](https://github.com/shlinkio/shlink-web-client/actions?query=workflow%3A%22Continuous+integration%22)
|
||||
[](https://app.codecov.io/gh/shlinkio/shlink-web-client)
|
||||
[](https://github.com/shlinkio/shlink-web-client/actions?query=workflow%3A%22Continuous+integration%22)
|
||||
[](https://app.codecov.io/gh/shlinkio/shlink-web-client)
|
||||
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
||||
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||
[](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
||||
[](https://twitter.com/shlinkio)
|
||||
[](https://slnk.to/donate)
|
||||
|
||||
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
||||
@@ -68,6 +69,25 @@ 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.
|
||||
|
||||
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.**
|
||||
>
|
||||
|
||||
15
babel.config.js
Normal file
15
babel.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
[
|
||||
'react-app',
|
||||
{
|
||||
runtime: 'automatic',
|
||||
typescript: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
plugins: [
|
||||
'@babel/plugin-proposal-optional-chaining',
|
||||
'@babel/plugin-proposal-nullish-coalescing-operator',
|
||||
],
|
||||
};
|
||||
@@ -20,6 +20,11 @@ server {
|
||||
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
|
||||
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;
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
|
||||
// This is a custom Jest transformer turning style imports into empty objects.
|
||||
// http://facebook.github.io/jest/docs/en/webpack.html
|
||||
|
||||
module.exports = {
|
||||
process() {
|
||||
return 'module.exports = {};';
|
||||
},
|
||||
getCacheKey() {
|
||||
// The output is always the same.
|
||||
return 'cssTransform';
|
||||
},
|
||||
};
|
||||
@@ -83,6 +83,7 @@ module.exports = {
|
||||
appNodeModules: resolveApp('node_modules'),
|
||||
publicUrl: getPublicUrl(resolveApp('package.json')),
|
||||
servedPath: getServedPath(resolveApp('package.json')),
|
||||
swSrc: resolveModule(resolveApp, 'src/service-worker'),
|
||||
};
|
||||
|
||||
module.exports.moduleFileExtensions = moduleFileExtensions;
|
||||
|
||||
@@ -13,6 +13,7 @@ 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');
|
||||
@@ -32,6 +33,9 @@ const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false';
|
||||
// Check if TypeScript is setup
|
||||
const useTypeScript = fs.existsSync(paths.appTsConfig);
|
||||
|
||||
// Get the path to the uncompiled service worker (if it exists).
|
||||
const swSrc = paths.swSrc;
|
||||
|
||||
// style files regexes
|
||||
const cssRegex = /\.css$/;
|
||||
const cssModuleRegex = /\.module\.css$/;
|
||||
@@ -610,6 +614,18 @@ module.exports = (webpackEnv) => {
|
||||
// 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 && fs.existsSync(swSrc) && new WorkboxWebpackPlugin.InjectManifest({
|
||||
swSrc,
|
||||
dontCacheBustURLsMatching: /\.[0-9a-f]{8}\./,
|
||||
exclude: [ /\.map$/, /asset-manifest\.json$/, /LICENSE/ ],
|
||||
// Bump up the default maximum size (2mb) that's precached,
|
||||
// to make lazy-loading failure scenarios less likely.
|
||||
// See https://github.com/cra-template/pwa/issues/13#issuecomment-722667270
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||
}),
|
||||
|
||||
// TypeScript type checking
|
||||
useTypeScript &&
|
||||
new ForkTsCheckerWebpackPlugin({
|
||||
|
||||
@@ -3,7 +3,7 @@ version: '3'
|
||||
services:
|
||||
shlink_web_client_node:
|
||||
container_name: shlink_web_client_node
|
||||
image: node:14.15-alpine
|
||||
image: node:16.13-alpine
|
||||
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||
volumes:
|
||||
- ./:/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,37 @@
|
||||
module.exports = {
|
||||
coverageDirectory: '<rootDir>/coverage',
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{js,ts,tsx}',
|
||||
'!src/registerServiceWorker.js',
|
||||
'!src/index.ts',
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/*.{ts,tsx}',
|
||||
'!src/reducers/index.ts',
|
||||
'!src/**/provideServices.ts',
|
||||
'!src/container/*.ts',
|
||||
],
|
||||
resolver: 'jest-pnp-resolver',
|
||||
setupFiles: [
|
||||
'react-app-polyfill/jsdom',
|
||||
'<rootDir>/config/setupEnzyme.js',
|
||||
],
|
||||
testMatch: [ '<rootDir>/test/**/*.test.{js,jsx,ts,tsx}' ],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
statements: 85,
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 85,
|
||||
},
|
||||
},
|
||||
setupFiles: [ '<rootDir>/config/setupEnzyme.js' ],
|
||||
testMatch: [ '<rootDir>/test/**/*.test.{ts,tsx}' ],
|
||||
testEnvironment: 'jsdom',
|
||||
testURL: 'http://localhost',
|
||||
transform: {
|
||||
'^.+\\.(ts|tsx|js|jsx|mjs)$': '<rootDir>/node_modules/babel-jest',
|
||||
'^.+\\.css$': '<rootDir>/config/jest/cssTransform.js',
|
||||
'^(?!.*\\.(ts|tsx|js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
|
||||
'^.+\\.(ts|tsx|js)$': '<rootDir>/node_modules/babel-jest',
|
||||
'^(?!.*\\.(ts|tsx|js|json)$)': '<rootDir>/config/jest/fileTransform.js',
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'<rootDir>/.stryker-tmp',
|
||||
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
|
||||
'^.+\\.module\\.(css|sass|scss)$',
|
||||
'^.+\\.module\\.scss$',
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^react-native$': 'react-native-web',
|
||||
'^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy',
|
||||
'^.+\\.module\\.scss$': 'identity-obj-proxy',
|
||||
// Reactstrap module resolution does not work in jest for some reason. Manually mapping it solves the problem
|
||||
'reactstrap': '<rootDir>/node_modules/reactstrap/dist/reactstrap.umd.js',
|
||||
},
|
||||
moduleFileExtensions: [
|
||||
'web.js',
|
||||
'js',
|
||||
'web.ts',
|
||||
'ts',
|
||||
'web.tsx',
|
||||
'tsx',
|
||||
'json',
|
||||
'web.jsx',
|
||||
'jsx',
|
||||
'node',
|
||||
],
|
||||
moduleFileExtensions: [ 'js', 'ts', 'tsx', 'json' ],
|
||||
};
|
||||
|
||||
45229
package-lock.json
generated
45229
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
188
package.json
188
package.json
@@ -7,98 +7,98 @@
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"lint": "npm run lint:css && npm run lint:js",
|
||||
"lint:js": "eslint --ext .js,.ts,.tsx src test",
|
||||
"lint:js:fix": "npm run lint:js -- --fix",
|
||||
"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:js:fix": "npm run lint:js -- --fix",
|
||||
"start": "node scripts/start.js",
|
||||
"serve:build": "serve ./build",
|
||||
"build": "node scripts/build.js",
|
||||
"build": "node scripts/build.js && node scripts/replace-version.js",
|
||||
"build:dist": "npm run build && node scripts/create-dist-file.js",
|
||||
"test": "node scripts/test.js --env=jsdom --colors --verbose",
|
||||
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
||||
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
|
||||
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
|
||||
"test:ci": "npm run test:coverage -- --coverageReporters=clover",
|
||||
"test:pretty": "npm run test:coverage -- --coverageReporters=html",
|
||||
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.15.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.34",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.15.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.15.2",
|
||||
"@fortawesome/react-fontawesome": "^0.1.14",
|
||||
"axios": "^0.21.1",
|
||||
"bootstrap": "^4.6.0",
|
||||
"@fortawesome/fontawesome-free": "^6.0.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.3.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.0.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.0.0",
|
||||
"@fortawesome/react-fontawesome": "^0.1.17",
|
||||
"axios": "^0.26.0",
|
||||
"bootstrap": "^5.1.3",
|
||||
"bottlejs": "^2.0.0",
|
||||
"bowser": "^2.11.0",
|
||||
"chart.js": "^2.9.4",
|
||||
"classnames": "^2.2.6",
|
||||
"compare-versions": "^3.6.0",
|
||||
"chart.js": "^3.7.1",
|
||||
"classnames": "^2.3.1",
|
||||
"compare-versions": "^4.1.3",
|
||||
"csvjson": "^5.1.0",
|
||||
"event-source-polyfill": "^1.0.22",
|
||||
"date-fns": "^2.28.0",
|
||||
"event-source-polyfill": "^1.0.25",
|
||||
"leaflet": "^1.7.1",
|
||||
"moment": "^2.29.1",
|
||||
"promise": "^8.1.0",
|
||||
"qs": "^6.9.6",
|
||||
"ramda": "^0.27.1",
|
||||
"react": "^17.0.1",
|
||||
"react-autosuggest": "^10.1.0",
|
||||
"react-chartjs-2": "^2.11.1",
|
||||
"react-color": "^2.19.3",
|
||||
"react-copy-to-clipboard": "^5.0.2",
|
||||
"react-datepicker": "^3.6.0",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-external-link": "^1.2.0",
|
||||
"react-leaflet": "^3.1.0",
|
||||
"react-moment": "^1.0.0",
|
||||
"react-redux": "^7.2.2",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-swipeable": "^6.0.1",
|
||||
"react-tagsinput": "^3.19.0",
|
||||
"reactstrap": "^8.9.0",
|
||||
"redux": "^4.0.5",
|
||||
"redux-localstorage-simple": "^2.4.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"uuid": "^8.3.2"
|
||||
"ramda": "^0.27.2",
|
||||
"react": "^17.0.2",
|
||||
"react-chartjs-2": "^3.3.0",
|
||||
"react-colorful": "^5.5.1",
|
||||
"react-copy-to-clipboard": "^5.0.4",
|
||||
"react-datepicker": "^4.7.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-external-link": "^1.2.2",
|
||||
"react-leaflet": "^3.2.5",
|
||||
"react-redux": "^7.2.6",
|
||||
"react-router-dom": "^6.2.2",
|
||||
"react-swipeable": "^6.2.0",
|
||||
"react-tag-autocomplete": "^6.3.0",
|
||||
"reactstrap": "^9.0.1",
|
||||
"redux": "^4.1.2",
|
||||
"redux-localstorage-simple": "^2.4.1",
|
||||
"redux-thunk": "^2.4.1",
|
||||
"uuid": "^8.3.2",
|
||||
"workbox-core": "^6.5.1",
|
||||
"workbox-expiration": "^6.5.1",
|
||||
"workbox-precaching": "^6.5.1",
|
||||
"workbox-routing": "^6.5.1",
|
||||
"workbox-strategies": "^6.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.13.8",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
|
||||
"@babel/core": "^7.17.5",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.16.7",
|
||||
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
|
||||
"@stryker-mutator/core": "^4.4.1",
|
||||
"@stryker-mutator/jest-runner": "^4.4.1",
|
||||
"@stryker-mutator/typescript-checker": "^4.4.1",
|
||||
"@stryker-mutator/core": "^5.6.1",
|
||||
"@stryker-mutator/jest-runner": "^5.6.1",
|
||||
"@stryker-mutator/typescript-checker": "^5.6.1",
|
||||
"@svgr/webpack": "^5.5.0",
|
||||
"@types/chart.js": "^2.9.31",
|
||||
"@types/classnames": "^2.2.11",
|
||||
"@types/enzyme": "^3.10.8",
|
||||
"@types/jest": "^26.0.20",
|
||||
"@types/leaflet": "^1.5.23",
|
||||
"@types/moment": "^2.13.0",
|
||||
"@types/qs": "^6.9.5",
|
||||
"@types/ramda": "^0.27.38",
|
||||
"@types/react": "^17.0.2",
|
||||
"@types/react-autosuggest": "^10.1.2",
|
||||
"@types/react-color": "^3.0.4",
|
||||
"@types/react-copy-to-clipboard": "^5.0.0",
|
||||
"@types/react-datepicker": "^3.1.5",
|
||||
"@types/react-dom": "^17.0.1",
|
||||
"@types/react-leaflet": "^2.5.2",
|
||||
"@types/react-redux": "^7.1.16",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"@types/react-tagsinput": "^3.19.7",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"@wojtekmaj/enzyme-adapter-react-17": "^0.3.1",
|
||||
"adm-zip": "^0.4.16",
|
||||
"autoprefixer": "^10.0.2",
|
||||
"babel-core": "7.0.0-bridge.0",
|
||||
"babel-jest": "^26.6.3",
|
||||
"babel-loader": "^8.2.1",
|
||||
"babel-plugin-named-asset-import": "^0.3.7",
|
||||
"babel-preset-react-app": "^10.0.0",
|
||||
"@types/classnames": "^2.3.1",
|
||||
"@types/enzyme": "^3.10.11",
|
||||
"@types/jest": "^27.4.1",
|
||||
"@types/leaflet": "^1.7.9",
|
||||
"@types/qs": "^6.9.7",
|
||||
"@types/ramda": "0.27.38",
|
||||
"@types/react": "^17.0.39",
|
||||
"@types/react-color": "^3.0.6",
|
||||
"@types/react-copy-to-clipboard": "^5.0.2",
|
||||
"@types/react-datepicker": "^4.3.4",
|
||||
"@types/react-dom": "^17.0.13",
|
||||
"@types/react-leaflet": "^2.8.2",
|
||||
"@types/react-redux": "^7.1.23",
|
||||
"@types/react-tag-autocomplete": "^6.1.1",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@wojtekmaj/enzyme-adapter-react-17": "0.6.5",
|
||||
"adm-zip": "^0.5.9",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"babel-jest": "^27.5.1",
|
||||
"babel-loader": "^8.2.3",
|
||||
"babel-plugin-named-asset-import": "^0.3.8",
|
||||
"babel-preset-react-app": "10.0.0",
|
||||
"babel-runtime": "^6.26.0",
|
||||
"bfj": "^7.0.2",
|
||||
"case-sensitive-paths-webpack-plugin": "^2.3.0",
|
||||
"chalk": "^4.1.0",
|
||||
"case-sensitive-paths-webpack-plugin": "^2.4.0",
|
||||
"chalk": "^4.1.2",
|
||||
"css-loader": "^5.0.1",
|
||||
"dart-sass": "^1.25.0",
|
||||
"dotenv": "^8.2.0",
|
||||
@@ -111,26 +111,22 @@
|
||||
"fs-extra": "^9.0.1",
|
||||
"html-webpack-plugin": "^4.5.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^26.6.3",
|
||||
"jest-pnp-resolver": "^1.2.2",
|
||||
"jest-resolve": "^26.6.2",
|
||||
"jest": "^27.5.1",
|
||||
"mini-css-extract-plugin": "^1.3.1",
|
||||
"object-assign": "^4.1.1",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.4",
|
||||
"pnp-webpack-plugin": "^1.6.4",
|
||||
"postcss": "^8.1.7",
|
||||
"pnp-webpack-plugin": "^1.7.0",
|
||||
"postcss": "^8.4.8",
|
||||
"postcss-flexbugs-fixes": "^4.2.1",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"postcss-preset-env": "^6.7.0",
|
||||
"postcss-safe-parser": "^5.0.2",
|
||||
"raf": "^3.4.1",
|
||||
"react-app-polyfill": "^2.0.0",
|
||||
"react-dev-utils": "^11.0.0",
|
||||
"resolve": "^1.19.0",
|
||||
"sass": "^1.29.0",
|
||||
"resolve": "^1.22.0",
|
||||
"sass": "^1.49.9",
|
||||
"sass-loader": "^10.1.0",
|
||||
"serve": "^11.3.2",
|
||||
"stryker-cli": "^1.0.0",
|
||||
"serve": "^12.0.0",
|
||||
"stryker-cli": "^1.0.2",
|
||||
"style-loader": "^2.0.0",
|
||||
"stylelint": "^13.7.2",
|
||||
"stylelint-config-adidas": "^1.3.0",
|
||||
@@ -139,28 +135,14 @@
|
||||
"stylelint-scss": "^3.18.0",
|
||||
"sw-precache-webpack-plugin": "^1.0.0",
|
||||
"terser-webpack-plugin": "^4.2.3",
|
||||
"ts-jest": "^26.5.2",
|
||||
"ts-mockery": "^1.2.0",
|
||||
"typescript": "^4.2.2",
|
||||
"typescript": "^4.6.2",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^4.44.2",
|
||||
"webpack-dev-server": "^3.11.0",
|
||||
"webpack": "^4.46.0",
|
||||
"webpack-dev-server": "^3.11.3",
|
||||
"webpack-manifest-plugin": "^2.2.0",
|
||||
"whatwg-fetch": "^3.5.0"
|
||||
},
|
||||
"babel": {
|
||||
"presets": [
|
||||
[
|
||||
"react-app",
|
||||
{
|
||||
"runtime": "automatic"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-optional-chaining",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator"
|
||||
]
|
||||
"whatwg-fetch": "^3.6.2",
|
||||
"workbox-webpack-plugin": "^6.5.1"
|
||||
},
|
||||
"browserslist": [
|
||||
">0.2%",
|
||||
|
||||
@@ -18,7 +18,6 @@ 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');
|
||||
@@ -44,8 +43,6 @@ if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
|
||||
const argvSliceStart = 2;
|
||||
const argv = process.argv.slice(argvSliceStart);
|
||||
const writeStatsJson = argv.includes('--stats');
|
||||
const withoutDist = argv.includes('--no-dist');
|
||||
const { version, hasVersion } = getVersionFromArgs(argv);
|
||||
|
||||
// Generate configuration
|
||||
const config = configFactory('production');
|
||||
@@ -84,7 +81,6 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green('Compiled successfully.\n'));
|
||||
hasVersion && replaceVersionPlaceholder(version);
|
||||
}
|
||||
|
||||
console.log('File sizes after gzip:\n');
|
||||
@@ -103,7 +99,6 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||
process.exit(1);
|
||||
},
|
||||
)
|
||||
.then(() => hasVersion && !withoutDist && zipDist(version))
|
||||
.catch((err) => {
|
||||
if (err && err.message) {
|
||||
console.log(err.message);
|
||||
@@ -185,43 +180,3 @@ function copyPublicFolder() {
|
||||
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');
|
||||
}
|
||||
|
||||
36
scripts/create-dist-file.js
Normal file
36
scripts/create-dist-file.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/* eslint-disable no-console, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
|
||||
|
||||
// Do this as the first thing so that any code reading it knows the right env.
|
||||
process.env.BABEL_ENV = 'production';
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const chalk = require('chalk');
|
||||
const AdmZip = require('adm-zip');
|
||||
const fs = require('fs-extra');
|
||||
|
||||
function zipDist(version) {
|
||||
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
|
||||
|
||||
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
|
||||
const zip = new AdmZip();
|
||||
|
||||
try {
|
||||
if (fs.existsSync(versionFileName)) {
|
||||
fs.unlink(versionFileName);
|
||||
}
|
||||
|
||||
zip.addLocalFolder('./build', `shlink-web-client_${version}_dist`);
|
||||
zip.writeZip(versionFileName);
|
||||
console.log(chalk.green('Dist file properly generated'));
|
||||
} catch (e) {
|
||||
console.log(chalk.red('An error occurred while generating dist file'));
|
||||
console.log(e);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
const version = process.env.VERSION;
|
||||
|
||||
if (version) {
|
||||
zipDist(version);
|
||||
}
|
||||
@@ -5,12 +5,12 @@ set -ex
|
||||
PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64"
|
||||
DOCKER_IMAGE="shlinkio/shlink-web-client"
|
||||
|
||||
if [[ "$GITHUB_REF" == *"main"* ]]; then
|
||||
if [[ "$GITHUB_REF" == *"develop"* ]]; then
|
||||
docker buildx build --push \
|
||||
--platform ${PLATFORMS} \
|
||||
-t ${DOCKER_IMAGE}:latest .
|
||||
|
||||
# If ref is not main, then this is a tag. Build that docker tag and also "stable"
|
||||
# If ref is not develop, then this is a tag. Build that docker tag and also "stable"
|
||||
else
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
TAGS="-t ${DOCKER_IMAGE}:${VERSION}"
|
||||
|
||||
16
scripts/docker/servers_from_env.sh
Executable file
16
scripts/docker/servers_from_env.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
ME=$(basename $0)
|
||||
|
||||
setup_single_shlink_server() {
|
||||
[ -n "$SHLINK_SERVER_URL" ] || return 0
|
||||
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0
|
||||
local name="${SHLINK_SERVER_NAME:-Shlink}"
|
||||
echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\"}]" > /usr/share/nginx/html/servers.json
|
||||
}
|
||||
|
||||
setup_single_shlink_server
|
||||
|
||||
exit 0
|
||||
20
scripts/replace-version.js
Normal file
20
scripts/replace-version.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const fs = require('fs-extra');
|
||||
|
||||
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));
|
||||
2
shlink-web-client.d.ts
vendored
2
shlink-web-client.d.ts
vendored
@@ -10,7 +10,7 @@ declare module 'event-source-polyfill' {
|
||||
declare module 'csvjson' {
|
||||
export declare class CsvJson {
|
||||
public toObject<T>(content: string): T[];
|
||||
public toCSV<T>(data: T[], options: { headers: 'full' | 'none' | 'relative' | 'key' }): string;
|
||||
public toCSV<T>(data: T[], options: { headers: 'full' | 'none' | 'relative' | 'key'; wrap?: true }): string;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
57
src/App.tsx
57
src/App.tsx
@@ -1,57 +0,0 @@
|
||||
import { useEffect, FC } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import NotFound from './common/NotFound';
|
||||
import { ServersMap } from './servers/data';
|
||||
import { Settings } from './settings/reducers/settings';
|
||||
import { changeThemeInMarkup } from './utils/theme';
|
||||
import './App.scss';
|
||||
|
||||
interface AppProps {
|
||||
fetchServers: Function;
|
||||
servers: ServersMap;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
const App = (
|
||||
MainHeader: FC,
|
||||
Home: FC,
|
||||
MenuLayout: FC,
|
||||
CreateServer: FC,
|
||||
EditServer: FC,
|
||||
Settings: FC,
|
||||
ShlinkVersionsContainer: FC,
|
||||
) => ({ fetchServers, servers, settings }: AppProps) => {
|
||||
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="shlink-wrapper">
|
||||
<Switch>
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route exact path="/settings" component={Settings} />
|
||||
<Route exact path="/server/create" component={CreateServer} />
|
||||
<Route exact path="/server/:serverId/edit" component={EditServer} />
|
||||
<Route path="/server/:serverId" component={MenuLayout} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<div className="shlink-footer">
|
||||
<ShlinkVersionsContainer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -1,7 +1,5 @@
|
||||
import qs from 'qs';
|
||||
import { isEmpty, isNil, reject } from 'ramda';
|
||||
import { AxiosInstance, AxiosResponse, Method } from 'axios';
|
||||
import { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams';
|
||||
import { ShortUrl, ShortUrlData } from '../../short-urls/data';
|
||||
import { OptionalString } from '../../utils/utils';
|
||||
import {
|
||||
@@ -13,27 +11,34 @@ import {
|
||||
ShlinkVisits,
|
||||
ShlinkVisitsParams,
|
||||
ShlinkShortUrlData,
|
||||
ShlinkDomain,
|
||||
ShlinkDomainsResponse,
|
||||
ShlinkVisitsOverview,
|
||||
ShlinkEditDomainRedirects,
|
||||
ShlinkDomainRedirects,
|
||||
ShlinkShortUrlsListParams,
|
||||
ShlinkShortUrlsListNormalizedParams,
|
||||
} from '../types';
|
||||
import { stringifyQuery } from '../../utils/helpers/query';
|
||||
import { orderToString } from '../../utils/helpers/ordering';
|
||||
|
||||
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
||||
const buildShlinkBaseUrl = (url: string) => url ? `${url}/rest/v2` : '';
|
||||
const rejectNilProps = reject(isNil);
|
||||
const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => {
|
||||
const { orderBy = {}, ...rest } = params;
|
||||
|
||||
return { ...rest, orderBy: orderToString(orderBy) };
|
||||
};
|
||||
|
||||
export default class ShlinkApiClient {
|
||||
private apiVersion: number;
|
||||
|
||||
public constructor(
|
||||
private readonly axios: AxiosInstance,
|
||||
private readonly baseUrl: string,
|
||||
private readonly apiKey: string,
|
||||
) {
|
||||
this.apiVersion = 2;
|
||||
}
|
||||
|
||||
public readonly listShortUrls = async (params: ShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
|
||||
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', params)
|
||||
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> => {
|
||||
@@ -55,6 +60,10 @@ export default class ShlinkApiClient {
|
||||
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);
|
||||
@@ -67,7 +76,10 @@ export default class ShlinkApiClient {
|
||||
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
|
||||
.then(() => {});
|
||||
|
||||
/* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead */
|
||||
// eslint-disable-next-line valid-jsdoc
|
||||
/**
|
||||
* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead
|
||||
*/
|
||||
public readonly updateShortUrlTags = async (
|
||||
shortCode: string,
|
||||
domain: OptionalString,
|
||||
@@ -105,38 +117,21 @@ export default class ShlinkApiClient {
|
||||
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
|
||||
.then((resp) => resp.data);
|
||||
|
||||
public readonly listDomains = async (): Promise<ShlinkDomain[]> =>
|
||||
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data);
|
||||
public readonly listDomains = async (): Promise<ShlinkDomainsResponse> =>
|
||||
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains);
|
||||
|
||||
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
|
||||
try {
|
||||
return await this.axios({
|
||||
method,
|
||||
url: `${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}`,
|
||||
headers: { 'X-Api-Key': this.apiKey },
|
||||
params: rejectNilProps(query),
|
||||
data: body,
|
||||
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
|
||||
});
|
||||
} catch (e) {
|
||||
const { response } = e;
|
||||
public readonly editDomainRedirects = async (
|
||||
domainRedirects: ShlinkEditDomainRedirects,
|
||||
): Promise<ShlinkDomainRedirects> =>
|
||||
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data);
|
||||
|
||||
// Due to a bug on all previous Shlink versions, requests to non-matching URLs will always result on a CORS error
|
||||
// when performed from the browser (due to the preflight request not returning a 2xx status.
|
||||
// See https://github.com/shlinkio/shlink/issues/614), which will make the "response" prop not to be set here.
|
||||
// The bug will be fixed on upcoming Shlink patches, but for other versions, we can consider this situation as
|
||||
// if a request has been performed to a not supported API version.
|
||||
const apiVersionIsNotSupported = !response;
|
||||
|
||||
// When the request is not invalid or we have already tried both API versions, throw the error and let the
|
||||
// caller handle it
|
||||
if (!apiVersionIsNotSupported || this.apiVersion === 2) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
this.apiVersion = this.apiVersion - 1;
|
||||
|
||||
return await this.performRequest(url, method, query, body);
|
||||
}
|
||||
};
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Visit } from '../../visits/types';
|
||||
import { OptionalString } from '../../utils/utils';
|
||||
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
|
||||
import { ShortUrl, ShortUrlMeta, ShortUrlsOrder } from '../../short-urls/data';
|
||||
|
||||
export interface ShlinkShortUrlsResponse {
|
||||
data: ShortUrl[];
|
||||
@@ -25,12 +25,12 @@ interface ShlinkTagsStats {
|
||||
|
||||
export interface ShlinkTags {
|
||||
tags: string[];
|
||||
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2
|
||||
stats: ShlinkTagsStats[];
|
||||
}
|
||||
|
||||
export interface ShlinkTagsResponse {
|
||||
data: string[];
|
||||
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2
|
||||
stats: ShlinkTagsStats[];
|
||||
}
|
||||
|
||||
export interface ShlinkPaginator {
|
||||
@@ -55,6 +55,7 @@ export interface ShlinkVisitsParams {
|
||||
itemsPerPage?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
excludeBots?: boolean;
|
||||
}
|
||||
|
||||
export interface ShlinkShortUrlData extends ShortUrlMeta {
|
||||
@@ -64,13 +65,42 @@ export interface ShlinkShortUrlData extends ShortUrlMeta {
|
||||
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 {
|
||||
@@ -78,7 +108,6 @@ export interface ProblemDetailsError {
|
||||
detail: string;
|
||||
title: string;
|
||||
status: number;
|
||||
|
||||
[extraProps: string]: any;
|
||||
}
|
||||
|
||||
@@ -88,6 +117,6 @@ export interface InvalidArgumentError extends ProblemDetailsError {
|
||||
}
|
||||
|
||||
export interface InvalidShortUrlDeletion extends ProblemDetailsError {
|
||||
type: 'INVALID_SHORTCODE_DELETION';
|
||||
type: 'INVALID_SHORTCODE_DELETION' | 'INVALID_SHORT_URL_DELETION';
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ export const isInvalidArgumentError = (error?: ProblemDetailsError): error is In
|
||||
error?.type === 'INVALID_ARGUMENT';
|
||||
|
||||
export const isInvalidDeletionError = (error?: ProblemDetailsError): error is InvalidShortUrlDeletion =>
|
||||
error?.type === 'INVALID_SHORTCODE_DELETION';
|
||||
error?.type === 'INVALID_SHORTCODE_DELETION' || error?.type === 'INVALID_SHORT_URL_DELETION';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import './utils/base';
|
||||
@import '../utils/base';
|
||||
|
||||
.app-container {
|
||||
height: 100%;
|
||||
69
src/app/App.tsx
Normal file
69
src/app/App.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
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;
|
||||
}
|
||||
|
||||
const App = (
|
||||
MainHeader: FC,
|
||||
Home: FC,
|
||||
MenuLayout: FC,
|
||||
CreateServer: FC,
|
||||
EditServer: FC,
|
||||
Settings: 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={<Settings />} />
|
||||
<Route path="/manage-servers" element={<ManageServers />} />
|
||||
<Route path="/server/create" element={<CreateServer />} />
|
||||
<Route path="/server/:serverId/edit" element={<EditServer />} />
|
||||
<Route path="/server/:serverId/*" element={<MenuLayout />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
<div className="shlink-footer">
|
||||
<ShlinkVersionsContainer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppUpdateBanner isOpen={appUpdated} toggle={resetAppUpdate} forceUpdate={forceUpdate} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
18
src/app/reducers/appUpdates.ts
Normal file
18
src/app/reducers/appUpdates.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Action } from 'redux';
|
||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const APP_UPDATE_AVAILABLE = 'shlink/appUpdates/APP_UPDATE_AVAILABLE';
|
||||
export const RESET_APP_UPDATE = 'shlink/appUpdates/RESET_APP_UPDATE';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
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 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>
|
||||
);
|
||||
};
|
||||
@@ -4,18 +4,19 @@ import {
|
||||
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 } from 'react-router-dom';
|
||||
import { NavLink, NavLinkProps, useLocation } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
import { Location } from 'history';
|
||||
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
||||
import { ServerWithId } from '../servers/data';
|
||||
import { isServerWithId, SelectedServer } from '../servers/data';
|
||||
import { supportsDomainRedirects } from '../utils/helpers/features';
|
||||
import './AsideMenu.scss';
|
||||
|
||||
export interface AsideMenuProps {
|
||||
selectedServer: ServerWithId;
|
||||
selectedServer: SelectedServer;
|
||||
className?: string;
|
||||
showOnMobile?: boolean;
|
||||
}
|
||||
@@ -26,8 +27,7 @@ interface AsideMenuItemProps extends NavLinkProps {
|
||||
|
||||
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
|
||||
<NavLink
|
||||
className={classNames('aside-menu__item', className)}
|
||||
activeClassName="aside-menu__item--selected"
|
||||
className={({ isActive }) => classNames('aside-menu__item', className, { 'aside-menu__item--selected': isActive })}
|
||||
to={to}
|
||||
{...rest}
|
||||
>
|
||||
@@ -38,41 +38,54 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
|
||||
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||
{ selectedServer, showOnMobile = false }: AsideMenuProps,
|
||||
) => {
|
||||
const serverId = selectedServer ? selectedServer.id : '';
|
||||
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 shortUrlsIsActive = (_: null, location: Location) => location.pathname.match('/list-short-urls') !== null;
|
||||
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`;
|
||||
|
||||
return (
|
||||
<aside className={asideClass}>
|
||||
<nav className="nav flex-column aside-menu__nav">
|
||||
<AsideMenuItem to={buildPath('/overview')}>
|
||||
<FontAwesomeIcon icon={overviewIcon} />
|
||||
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
|
||||
<span className="aside-menu__item-text">Overview</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
||||
<FontAwesomeIcon icon={listIcon} />
|
||||
<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 icon={createIcon} flip="horizontal" />
|
||||
<FontAwesomeIcon fixedWidth icon={createIcon} flip="horizontal" />
|
||||
<span className="aside-menu__item-text">Create short URL</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/manage-tags')}>
|
||||
<FontAwesomeIcon icon={tagsIcon} />
|
||||
<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 icon={editIcon} />
|
||||
<FontAwesomeIcon fixedWidth 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}
|
||||
/>
|
||||
{hasId && (
|
||||
<DeleteServerButton
|
||||
className="aside-menu__item aside-menu__item--danger"
|
||||
textClassName="aside-menu__item-text"
|
||||
server={selectedServer}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
@import '../utils/base';
|
||||
@import '../utils/mixins/vertical-align';
|
||||
|
||||
$mainCardWidth: 720px;
|
||||
$fiveColumnsSize: .4167; // 12 / 5 -> Can't use "/" operator in latest dart-sass
|
||||
|
||||
.home {
|
||||
position: relative;
|
||||
padding-top: 15px;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding-top: 0;
|
||||
@@ -11,19 +15,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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: 720px;
|
||||
max-width: $mainCardWidth;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
@include vertical-align();
|
||||
}
|
||||
}
|
||||
|
||||
.home__title-wrapper {
|
||||
padding: 1.5rem !important;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.home__title {
|
||||
text-align: center;
|
||||
font-size: 1.75rem;
|
||||
|
||||
@@ -1,41 +1,61 @@
|
||||
import { useEffect } from 'react';
|
||||
import { isEmpty, values } from 'ramda';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Card, Row } from 'reactstrap';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import ServersListGroup from '../servers/ServersListGroup';
|
||||
import './Home.scss';
|
||||
import { ServersMap } from '../servers/data';
|
||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||
import './Home.scss';
|
||||
|
||||
export interface HomeProps {
|
||||
servers: ServersMap;
|
||||
}
|
||||
|
||||
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 noGutters>
|
||||
<Row className="g-0">
|
||||
<div className="col-md-5 d-none d-md-block">
|
||||
<div className="p-4">
|
||||
<ShlinkLogo />
|
||||
<div className="home__logo-wrapper">
|
||||
<div className="home__logo">
|
||||
<ShlinkLogo />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-7 home__servers-container">
|
||||
<div className="p-4">
|
||||
<div className="home__title-wrapper">
|
||||
<h1 className="home__title">Welcome!</h1>
|
||||
</div>
|
||||
<ServersListGroup embedded servers={serversList}>
|
||||
{!hasServers && (
|
||||
<div className="p-4">
|
||||
<p>This application will help you to manage your Shlink servers.</p>
|
||||
<p>To start, please, <Link to="/server/create">add your first server</Link>.</p>
|
||||
<p className="m-0">
|
||||
You still don‘t have a Shlink server?
|
||||
Learn how to <ExternalLink href="https://shlink.io/documentation">get started</ExternalLink>.
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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 } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
||||
import classNames from 'classnames';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||
import './MainHeader.scss';
|
||||
|
||||
const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps) => {
|
||||
const MainHeader = (ServersDropdown: FC) => () => {
|
||||
const [ isOpen, toggleOpen, , close ] = useToggle();
|
||||
const location = useLocation();
|
||||
const { pathname } = location;
|
||||
|
||||
useEffect(close, [ location ]);
|
||||
@@ -29,9 +29,9 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)
|
||||
</NavbarToggler>
|
||||
|
||||
<Collapse navbar isOpen={isOpen}>
|
||||
<Nav navbar className="ml-auto">
|
||||
<Nav navbar className="ms-auto">
|
||||
<NavItem>
|
||||
<NavLink tag={Link} to={'/settings'} active={pathname === settingsPath}>
|
||||
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
|
||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.menu-layout__swipeable {
|
||||
$offset: 15px;
|
||||
|
||||
height: 100%;
|
||||
margin-right: -$offset;
|
||||
margin-left: -$offset;
|
||||
padding-left: $offset;
|
||||
padding-right: $offset;
|
||||
}
|
||||
|
||||
.menu-layout__swipeable-inner {
|
||||
|
||||
@@ -1,38 +1,53 @@
|
||||
import { FC, useEffect } from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
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 { supportsOrphanVisits, supportsTagVisits } from '../utils/helpers/features';
|
||||
import { supportsDomainRedirects, supportsNonOrphanVisits, supportsOrphanVisits } 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;
|
||||
}
|
||||
|
||||
const MenuLayout = (
|
||||
TagsList: FC,
|
||||
ShortUrls: FC,
|
||||
ShortUrlsList: FC,
|
||||
AsideMenu: FC<AsideMenuProps>,
|
||||
CreateShortUrl: FC,
|
||||
ShortUrlVisits: FC,
|
||||
TagVisits: FC,
|
||||
OrphanVisits: FC,
|
||||
NonOrphanVisits: FC,
|
||||
ServerError: FC,
|
||||
Overview: FC,
|
||||
EditShortUrl: FC,
|
||||
) => withSelectedServer(({ location, selectedServer }) => {
|
||||
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();
|
||||
|
||||
if (!isReachableServer(selectedServer)) {
|
||||
return () => sidebarNotPresent();
|
||||
}, []);
|
||||
|
||||
if (!showContent) {
|
||||
return <ServerError />;
|
||||
}
|
||||
|
||||
const addTagsVisitsRoute = supportsTagVisits(selectedServer);
|
||||
const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
|
||||
const addNonOrphanVisitsRoute = supportsNonOrphanVisits(selectedServer);
|
||||
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
|
||||
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
|
||||
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
||||
|
||||
@@ -45,20 +60,23 @@ const MenuLayout = (
|
||||
<AsideMenu selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
||||
<div className="menu-layout__container" onClick={() => hideSidebar()}>
|
||||
<div className="container-xl">
|
||||
<Switch>
|
||||
<Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
|
||||
<Route exact path="/server/:serverId/overview" component={Overview} />
|
||||
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
|
||||
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
||||
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
||||
<Route path="/server/:serverId/short-code/:shortCode/edit" component={EditShortUrl} />
|
||||
{addTagsVisitsRoute && <Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
||||
{addOrphanVisitsRoute && <Route path="/server/:serverId/orphan-visits" component={OrphanVisits} />}
|
||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
||||
<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 />} />
|
||||
{addOrphanVisitsRoute && <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
|
||||
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||
path="*"
|
||||
element={<NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||
/>
|
||||
</Switch>
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { FC } from 'react';
|
||||
import './NoMenuLayout.scss';
|
||||
|
||||
const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper container-xl">{children}</div>;
|
||||
|
||||
export default NoMenuLayout;
|
||||
export const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper container-xl">{children}</div>;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { PropsWithChildren, useEffect } from 'react';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
import { FC, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
const ScrollToTop = (): FC => ({ children }) => {
|
||||
const location = useLocation();
|
||||
|
||||
const ScrollToTop = () => ({ location, children }: PropsWithChildren<RouteComponentProps>) => {
|
||||
useEffect(() => {
|
||||
scrollTo(0, 0);
|
||||
}, [ location ]);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { pipe } from 'ramda';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
||||
import { isReachableServer } from '../servers/data';
|
||||
import { ShlinkVersionsContainerProps } from './ShlinkVersionsContainer';
|
||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||
|
||||
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
|
||||
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
|
||||
|
||||
export interface ShlinkVersionsProps extends ShlinkVersionsContainerProps {
|
||||
export interface ShlinkVersionsProps {
|
||||
selectedServer: SelectedServer;
|
||||
clientVersion?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.shlink-versions-container--with-server {
|
||||
.shlink-versions-container--with-sidebar {
|
||||
margin-left: 0;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import classNames from 'classnames';
|
||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
import ShlinkVersions from './ShlinkVersions';
|
||||
import { Sidebar } from './reducers/sidebar';
|
||||
import './ShlinkVersionsContainer.scss';
|
||||
|
||||
export interface ShlinkVersionsContainerProps {
|
||||
selectedServer: SelectedServer;
|
||||
sidebar: Sidebar;
|
||||
}
|
||||
|
||||
const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => {
|
||||
const ShlinkVersionsContainer = ({ selectedServer, sidebar }: ShlinkVersionsContainerProps) => {
|
||||
const classes = classNames('text-center', {
|
||||
'shlink-versions-container--with-server': isReachableServer(selectedServer),
|
||||
'shlink-versions-container--with-sidebar': sidebar.sidebarPresent,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
145
src/common/react-tag-autocomplete.scss
Normal file
145
src/common/react-tag-autocomplete.scss
Normal file
@@ -0,0 +1,145 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.react-tags {
|
||||
position: relative;
|
||||
padding: 5px 0 0 6px;
|
||||
border-radius: .3rem;
|
||||
background-color: var(--input-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;
|
||||
}
|
||||
|
||||
.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: var(--input-color);
|
||||
|
||||
/* 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::-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 rgba(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,58 +0,0 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.react-tagsinput {
|
||||
background-color: var(--input-color);
|
||||
border: 1px solid var(--input-border-color);
|
||||
border-radius: .25rem;
|
||||
overflow: hidden;
|
||||
min-height: 2.6rem;
|
||||
padding: .5rem 0 0 1rem;
|
||||
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 rgb(70 150 229 / 25%);
|
||||
}
|
||||
|
||||
.react-tagsinput-tag {
|
||||
font-size: 1rem;
|
||||
background-color: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
font-weight: 400;
|
||||
margin: 0 5px 6px 0;
|
||||
padding: 6px 8px;
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.react-tagsinput-remove {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.react-tagsinput-tag span:before {
|
||||
content: '\2715';
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.react-tagsinput-input {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: none;
|
||||
padding: 1px 0;
|
||||
width: 100%;
|
||||
margin-bottom: 6px;
|
||||
font-size: 1.25rem;
|
||||
color: var(--input-text-color);
|
||||
}
|
||||
|
||||
.react-tagsinput-input::placeholder {
|
||||
color: $textPlaceholder;
|
||||
}
|
||||
|
||||
.react-autosuggest__suggestion--highlighted {
|
||||
background-color: var(--active-color);
|
||||
}
|
||||
27
src/common/reducers/sidebar.ts
Normal file
27
src/common/reducers/sidebar.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Action } from 'redux';
|
||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const SIDEBAR_PRESENT = 'shlink/common/SIDEBAR_PRESENT';
|
||||
export const SIDEBAR_NOT_PRESENT = 'shlink/common/SIDEBAR_NOT_PRESENT';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
33
src/common/services/ReportExporter.ts
Normal file
33
src/common/services/ReportExporter.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { CsvJson } from 'csvjson';
|
||||
import { NormalizedVisit } from '../../visits/types';
|
||||
import { ExportableShortUrl } from '../../short-urls/data';
|
||||
import { saveCsv } from '../../utils/helpers/files';
|
||||
|
||||
export class ReportExporter {
|
||||
public constructor(
|
||||
private readonly window: Window,
|
||||
private readonly csvjson: CsvJson,
|
||||
) {}
|
||||
|
||||
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.csvjson.toCSV(rows, { headers: 'key', wrap: true });
|
||||
|
||||
saveCsv(this.window, csv, filename);
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import Bottle, { Decorator } from 'bottlejs';
|
||||
import Bottle from 'bottlejs';
|
||||
import ScrollToTop from '../ScrollToTop';
|
||||
import MainHeader from '../MainHeader';
|
||||
import Home from '../Home';
|
||||
@@ -9,17 +9,23 @@ 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, withRouter: Decorator) => {
|
||||
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', 'csvjson');
|
||||
|
||||
// Components
|
||||
bottle.serviceFactory('ScrollToTop', ScrollToTop);
|
||||
bottle.decorator('ScrollToTop', withRouter);
|
||||
|
||||
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
|
||||
bottle.decorator('MainHeader', withRouter);
|
||||
|
||||
bottle.serviceFactory('Home', () => Home);
|
||||
bottle.decorator('Home', withoutSelectedServer);
|
||||
@@ -29,25 +35,30 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||
'MenuLayout',
|
||||
MenuLayout,
|
||||
'TagsList',
|
||||
'ShortUrls',
|
||||
'ShortUrlsList',
|
||||
'AsideMenu',
|
||||
'CreateShortUrl',
|
||||
'ShortUrlVisits',
|
||||
'TagVisits',
|
||||
'OrphanVisits',
|
||||
'NonOrphanVisits',
|
||||
'ServerError',
|
||||
'Overview',
|
||||
'EditShortUrl',
|
||||
'ManageDomains',
|
||||
);
|
||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
||||
bottle.decorator('MenuLayout', withRouter);
|
||||
bottle.decorator('MenuLayout', connect([ 'selectedServer' ], [ 'selectServer', 'sidebarPresent', 'sidebarNotPresent' ]));
|
||||
|
||||
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
||||
|
||||
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
|
||||
bottle.decorator('ShlinkVersionsContainer', connect([ 'selectedServer' ]));
|
||||
bottle.decorator('ShlinkVersionsContainer', connect([ 'selectedServer', 'sidebar' ]));
|
||||
|
||||
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('sidebarPresent', () => sidebarPresent);
|
||||
bottle.serviceFactory('sidebarNotPresent', () => sidebarNotPresent);
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import Bottle, { IContainer } from 'bottlejs';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { connect as reduxConnect } from 'react-redux';
|
||||
import { pick } from 'ramda';
|
||||
import App from '../App';
|
||||
import provideApiServices from '../api/services/provideServices';
|
||||
import provideCommonServices from '../common/services/provideServices';
|
||||
import provideShortUrlsServices from '../short-urls/services/provideServices';
|
||||
@@ -13,12 +11,14 @@ 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();
|
||||
const { container } = bottle;
|
||||
|
||||
export const { container } = bottle;
|
||||
|
||||
const lazyService = <T extends Function, K>(container: IContainer, serviceName: string) =>
|
||||
(...args: any[]) => (container[serviceName] as T)(...args) as K;
|
||||
@@ -33,28 +33,14 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
|
||||
actionServiceNames.reduce(mapActionService, {}),
|
||||
);
|
||||
|
||||
bottle.serviceFactory(
|
||||
'App',
|
||||
App,
|
||||
'MainHeader',
|
||||
'Home',
|
||||
'MenuLayout',
|
||||
'CreateServer',
|
||||
'EditServer',
|
||||
'Settings',
|
||||
'ShlinkVersionsContainer',
|
||||
);
|
||||
bottle.decorator('App', connect([ 'servers', 'settings' ], [ 'fetchServers' ]));
|
||||
|
||||
provideCommonServices(bottle, connect, withRouter);
|
||||
provideAppServices(bottle, connect);
|
||||
provideCommonServices(bottle, connect);
|
||||
provideApiServices(bottle);
|
||||
provideShortUrlsServices(bottle, connect);
|
||||
provideServersServices(bottle, connect, withRouter);
|
||||
provideServersServices(bottle, connect);
|
||||
provideTagsServices(bottle, connect);
|
||||
provideVisitsServices(bottle, connect);
|
||||
provideUtilsServices(bottle);
|
||||
provideMercureServices(bottle);
|
||||
provideSettingsServices(bottle, connect);
|
||||
provideDomainsServices(bottle, connect);
|
||||
|
||||
export default container;
|
||||
|
||||
@@ -2,6 +2,8 @@ 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';
|
||||
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||
@@ -12,9 +14,8 @@ const localStorageConfig: RLSOptions = {
|
||||
namespaceSeparator: '.',
|
||||
debounce: 300,
|
||||
};
|
||||
const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState);
|
||||
|
||||
const store = createStore(reducers, load(localStorageConfig), composeEnhancers(
|
||||
export const store = createStore(reducers, preloadedState, composeEnhancers(
|
||||
applyMiddleware(save(localStorageConfig), ReduxThunk),
|
||||
));
|
||||
|
||||
export default store;
|
||||
|
||||
@@ -4,7 +4,6 @@ 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 { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
|
||||
import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
|
||||
import { TagDeletion } from '../tags/reducers/tagDelete';
|
||||
import { TagEdition } from '../tags/reducers/tagEdit';
|
||||
@@ -15,18 +14,19 @@ 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';
|
||||
|
||||
export interface ShlinkState {
|
||||
servers: ServersMap;
|
||||
selectedServer: SelectedServer;
|
||||
shortUrlsList: ShortUrlsList;
|
||||
shortUrlsListParams: ShortUrlsListParams;
|
||||
shortUrlCreationResult: ShortUrlCreation;
|
||||
shortUrlDeletion: ShortUrlDeletion;
|
||||
shortUrlEdition: ShortUrlEdition;
|
||||
shortUrlVisits: ShortUrlVisits;
|
||||
tagVisits: TagVisits;
|
||||
orphanVisits: VisitsInfo;
|
||||
nonOrphanVisits: VisitsInfo;
|
||||
shortUrlDetail: ShortUrlDetail;
|
||||
tagsList: TagsList;
|
||||
tagDelete: TagDeletion;
|
||||
@@ -35,6 +35,8 @@ export interface ShlinkState {
|
||||
settings: Settings;
|
||||
domainsList: DomainsList;
|
||||
visitsOverview: VisitsOverview;
|
||||
appUpdated: boolean;
|
||||
sidebar: Sidebar;
|
||||
}
|
||||
|
||||
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
||||
|
||||
88
src/domains/DomainRow.tsx
Normal file
88
src/domains/DomainRow.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { FC, useEffect } from 'react';
|
||||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBan as forbiddenIcon,
|
||||
faDotCircle as defaultDomainIcon,
|
||||
faEdit as editIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { ShlinkDomainRedirects } from '../api/types';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { OptionalString } from '../utils/utils';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
import { supportsDefaultDomainRedirectsEdition } from '../utils/helpers/features';
|
||||
import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal';
|
||||
import { Domain } from './data';
|
||||
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
|
||||
|
||||
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 [ isOpen, toggle ] = useToggle();
|
||||
const { domain: authority, isDefault, redirects, status } = domain;
|
||||
const canEditDomain = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer);
|
||||
|
||||
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">
|
||||
<span id={!canEditDomain ? 'defaultDomainBtn' : undefined}>
|
||||
<Button outline size="sm" disabled={!canEditDomain} onClick={!canEditDomain ? undefined : toggle}>
|
||||
<FontAwesomeIcon fixedWidth icon={!canEditDomain ? forbiddenIcon : editIcon} />
|
||||
</Button>
|
||||
</span>
|
||||
{!canEditDomain && (
|
||||
<UncontrolledTooltip target="defaultDomainBtn" placement="left">
|
||||
Redirects for default domain cannot be edited here.
|
||||
<br />
|
||||
Use config options or env vars directly on the server.
|
||||
</UncontrolledTooltip>
|
||||
)}
|
||||
</td>
|
||||
<EditDomainRedirectsModal
|
||||
domain={domain}
|
||||
isOpen={isOpen}
|
||||
toggle={toggle}
|
||||
editDomainRedirects={editDomainRedirects}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Button, DropdownItem, Input, InputGroup, InputGroupAddon, UncontrolledTooltip } from 'reactstrap';
|
||||
import { InputProps } from 'reactstrap/lib/Input';
|
||||
import { Button, DropdownItem, Input, InputGroup, UncontrolledTooltip, InputProps } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUndo } from '@fortawesome/free-solid-svg-icons';
|
||||
import { isEmpty, pipe } from 'ramda';
|
||||
@@ -32,24 +31,22 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do
|
||||
return inputDisplayed ? (
|
||||
<InputGroup>
|
||||
<Input
|
||||
value={value}
|
||||
value={value ?? ''}
|
||||
placeholder="Domain"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<InputGroupAddon addonType="append">
|
||||
<Button
|
||||
id="backToDropdown"
|
||||
outline
|
||||
type="button"
|
||||
className="domains-dropdown__back-btn"
|
||||
onClick={pipe(unselectDomain, hideInput)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUndo} />
|
||||
</Button>
|
||||
<UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
|
||||
Existing domains
|
||||
</UncontrolledTooltip>
|
||||
</InputGroupAddon>
|
||||
<Button
|
||||
id="backToDropdown"
|
||||
outline
|
||||
type="button"
|
||||
className="domains-dropdown__back-btn"
|
||||
onClick={pipe(unselectDomain, hideInput)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUndo} />
|
||||
</Button>
|
||||
<UncontrolledTooltip target="backToDropdown" placement="left" trigger="hover">
|
||||
Existing domains
|
||||
</UncontrolledTooltip>
|
||||
</InputGroup>
|
||||
) : (
|
||||
<DropdownBtn
|
||||
@@ -63,7 +60,7 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do
|
||||
onClick={() => onChange(domain)}
|
||||
>
|
||||
{domain}
|
||||
{isDefault && <span className="float-right text-muted">default</span>}
|
||||
{isDefault && <span className="float-end text-muted">default</span>}
|
||||
</DropdownItem>
|
||||
))}
|
||||
<DropdownItem divider />
|
||||
|
||||
76
src/domains/ManageDomains.tsx
Normal file
76
src/domains/ManageDomains.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { FC, useEffect } from 'react';
|
||||
import Message from '../utils/Message';
|
||||
import { Result } from '../utils/Result';
|
||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import { ShlinkDomainRedirects } from '../api/types';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
import { DomainsList } from './reducers/domainsList';
|
||||
import { DomainRow } from './DomainRow';
|
||||
|
||||
interface ManageDomainsProps {
|
||||
listDomains: Function;
|
||||
filterDomains: (searchTerm: string) => void;
|
||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||
checkDomainHealth: (domain: string) => void;
|
||||
domainsList: DomainsList;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', '' ];
|
||||
|
||||
export const ManageDomains: FC<ManageDomainsProps> = (
|
||||
{ listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer },
|
||||
) => {
|
||||
const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList;
|
||||
const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects;
|
||||
|
||||
useEffect(() => {
|
||||
listDomains();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <Message loading />;
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (error) {
|
||||
return (
|
||||
<Result type="error">
|
||||
<ShlinkApiError errorData={errorData} fallbackMessage="Error loading domains :(" />
|
||||
</Result>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleCard>
|
||||
<table className="table table-hover responsive-table mb-0">
|
||||
<thead className="responsive-table__header">
|
||||
<tr>{headers.map((column, index) => <th key={index}>{column}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{domains.length < 1 && <tr><td colSpan={headers.length} className="text-center">No results found</td></tr>}
|
||||
{domains.map((domain) => (
|
||||
<DomainRow
|
||||
key={domain.domain}
|
||||
domain={domain}
|
||||
editDomainRedirects={editDomainRedirects}
|
||||
checkDomainHealth={checkDomainHealth}
|
||||
defaultRedirects={resolvedDefaultRedirects}
|
||||
selectedServer={selectedServer}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</SimpleCard>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchField className="mb-3" onChange={filterDomains} />
|
||||
{renderContent()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
7
src/domains/data/index.ts
Normal file
7
src/domains/data/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { ShlinkDomain } from '../../api/types';
|
||||
|
||||
export type DomainStatus = 'validating' | 'valid' | 'invalid';
|
||||
|
||||
export interface Domain extends ShlinkDomain {
|
||||
status: DomainStatus;
|
||||
}
|
||||
62
src/domains/helpers/DomainStatusIcon.tsx
Normal file
62
src/domains/helpers/DomainStatusIcon.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { FC, useEffect, useRef, useState } from 'react';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faTimes as invalidIcon,
|
||||
faCheck as checkIcon,
|
||||
faCircleNotch as loadingStatusIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { MediaMatcher } from '../../utils/types';
|
||||
import { DomainStatus } from '../data';
|
||||
|
||||
interface DomainStatusIconProps {
|
||||
status: DomainStatus;
|
||||
matchMedia?: MediaMatcher;
|
||||
}
|
||||
|
||||
export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia = window.matchMedia }) => {
|
||||
const ref = useRef<HTMLSpanElement>();
|
||||
const matchesMobile = () => matchMedia('(max-width: 991px)').matches;
|
||||
const [ isMobile, setIsMobile ] = useState<boolean>(matchesMobile());
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => setIsMobile(matchesMobile());
|
||||
|
||||
window.addEventListener('resize', listener);
|
||||
|
||||
return () => window.removeEventListener('resize', listener);
|
||||
}, []);
|
||||
|
||||
if (status === 'validating') {
|
||||
return <FontAwesomeIcon fixedWidth icon={loadingStatusIcon} spin />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={(el: HTMLSpanElement) => {
|
||||
ref.current = el;
|
||||
}}
|
||||
>
|
||||
{status === 'valid'
|
||||
? <FontAwesomeIcon fixedWidth icon={checkIcon} className="text-muted" />
|
||||
: <FontAwesomeIcon fixedWidth icon={invalidIcon} className="text-danger" />}
|
||||
</span>
|
||||
<UncontrolledTooltip
|
||||
target={(() => ref.current) as any}
|
||||
placement={isMobile ? 'top-start' : 'left'}
|
||||
autohide={status === 'valid'}
|
||||
>
|
||||
{status === 'valid' ? 'Congratulations! This domain is properly configured.' : (
|
||||
<span>
|
||||
Oops! There is some missing configuration, and short URLs shared with this domain will not work.
|
||||
<br />
|
||||
Check the <ExternalLink href="https://slnk.to/multi-domain-docs">documentation</ExternalLink> in order to
|
||||
find out what is missing.
|
||||
</span>
|
||||
)}
|
||||
</UncontrolledTooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
72
src/domains/helpers/EditDomainRedirectsModal.tsx
Normal file
72
src/domains/helpers/EditDomainRedirectsModal.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { FC, useState } from 'react';
|
||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { ShlinkDomain, ShlinkDomainRedirects } from '../../api/types';
|
||||
import { InputFormGroup, InputFormGroupProps } from '../../utils/forms/InputFormGroup';
|
||||
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
|
||||
import { InfoTooltip } from '../../utils/InfoTooltip';
|
||||
|
||||
interface EditDomainRedirectsModalProps {
|
||||
domain: ShlinkDomain;
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||
}
|
||||
|
||||
const FormGroup: FC<InputFormGroupProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
|
||||
<InputFormGroup
|
||||
{...rest}
|
||||
required={false}
|
||||
type="url"
|
||||
placeholder="No redirect"
|
||||
className={isLast ? 'mb-0' : ''}
|
||||
/>
|
||||
);
|
||||
|
||||
export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
|
||||
{ isOpen, toggle, domain, editDomainRedirects },
|
||||
) => {
|
||||
const [ baseUrlRedirect, setBaseUrlRedirect ] = useState(domain.redirects?.baseUrlRedirect ?? '');
|
||||
const [ regular404Redirect, setRegular404Redirect ] = useState(domain.redirects?.regular404Redirect ?? '');
|
||||
const [ invalidShortUrlRedirect, setInvalidShortUrlRedirect ] = useState(
|
||||
domain.redirects?.invalidShortUrlRedirect ?? '',
|
||||
);
|
||||
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects(domain.domain, {
|
||||
baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect),
|
||||
regular404Redirect: nonEmptyValueOrNull(regular404Redirect),
|
||||
invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect),
|
||||
}).then(toggle));
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<ModalHeader toggle={toggle}>Edit redirects for <b>{domain.domain}</b></ModalHeader>
|
||||
<ModalBody>
|
||||
<FormGroup value={baseUrlRedirect} onChange={setBaseUrlRedirect}>
|
||||
<InfoTooltip className="me-2" placement="bottom">
|
||||
Visitors accessing the base url, as in <b>https://{domain.domain}/</b>, will be redirected to this URL.
|
||||
</InfoTooltip>
|
||||
Base URL
|
||||
</FormGroup>
|
||||
<FormGroup value={regular404Redirect} onChange={setRegular404Redirect}>
|
||||
<InfoTooltip className="me-2" placement="bottom">
|
||||
Visitors accessing a url not matching a short URL pattern, as in <b>https://{domain.domain}/???/[...]</b>,
|
||||
will be redirected to this URL.
|
||||
</InfoTooltip>
|
||||
Regular 404
|
||||
</FormGroup>
|
||||
<FormGroup value={invalidShortUrlRedirect} isLast onChange={setInvalidShortUrlRedirect}>
|
||||
<InfoTooltip className="me-2" placement="bottom">
|
||||
Visitors accessing a url matching a short URL pattern, but not matching an existing short code, will be
|
||||
redirected to this URL.
|
||||
</InfoTooltip>
|
||||
Invalid short URL
|
||||
</FormGroup>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="link" type="button" onClick={toggle}>Cancel</Button>
|
||||
<Button color="primary">Save</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
33
src/domains/reducers/domainRedirects.ts
Normal file
33
src/domains/reducers/domainRedirects.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ShlinkDomainRedirects } from '../../api/types';
|
||||
import { GetState } from '../../container/types';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const EDIT_DOMAIN_REDIRECTS_START = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_START';
|
||||
export const EDIT_DOMAIN_REDIRECTS_ERROR = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_ERROR';
|
||||
export const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export interface EditDomainRedirectsAction extends Action<string> {
|
||||
domain: string;
|
||||
redirects: ShlinkDomainRedirects;
|
||||
}
|
||||
|
||||
export const editDomainRedirects = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||
domain: string,
|
||||
domainRedirects: Partial<ShlinkDomainRedirects>,
|
||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||
dispatch({ type: EDIT_DOMAIN_REDIRECTS_START });
|
||||
const { editDomainRedirects } = buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
const redirects = await editDomainRedirects({ domain, ...domainRedirects });
|
||||
|
||||
dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects });
|
||||
} catch (e: any) {
|
||||
dispatch<ApiErrorAction>({ type: EDIT_DOMAIN_REDIRECTS_ERROR, errorData: parseApiError(e) });
|
||||
}
|
||||
};
|
||||
@@ -1,35 +1,84 @@
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { ShlinkDomain } from '../../api/types';
|
||||
import { ProblemDetailsError, ShlinkDomainRedirects } from '../../api/types';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { GetState } from '../../container/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { Domain, DomainStatus } from '../data';
|
||||
import { hasServerData } from '../../servers/data';
|
||||
import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
|
||||
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
|
||||
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
|
||||
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
|
||||
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
|
||||
export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export interface DomainsList {
|
||||
domains: ShlinkDomain[];
|
||||
domains: Domain[];
|
||||
filteredDomains: Domain[];
|
||||
defaultRedirects?: ShlinkDomainRedirects;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export interface ListDomainsAction extends Action<string> {
|
||||
domains: ShlinkDomain[];
|
||||
domains: Domain[];
|
||||
defaultRedirects?: ShlinkDomainRedirects;
|
||||
}
|
||||
|
||||
interface FilterDomainsAction extends Action<string> {
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
interface ValidateDomain extends Action<string> {
|
||||
domain: string;
|
||||
status: DomainStatus;
|
||||
}
|
||||
|
||||
const initialState: DomainsList = {
|
||||
domains: [],
|
||||
filteredDomains: [],
|
||||
loading: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<DomainsList, ListDomainsAction>({
|
||||
export type DomainsCombinedAction = ListDomainsAction
|
||||
& ApiErrorAction
|
||||
& FilterDomainsAction
|
||||
& EditDomainRedirectsAction
|
||||
& ValidateDomain;
|
||||
|
||||
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
|
||||
(d: Domain): Domain => d.domain !== domain ? d : { ...d, redirects };
|
||||
|
||||
export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
|
||||
(d: Domain): Domain => d.domain !== domain ? d : { ...d, status };
|
||||
|
||||
export default buildReducer<DomainsList, DomainsCombinedAction>({
|
||||
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
||||
[LIST_DOMAINS_ERROR]: () => ({ ...initialState, error: true }),
|
||||
[LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains }),
|
||||
[LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }),
|
||||
[LIST_DOMAINS]: (_, { domains, defaultRedirects }) =>
|
||||
({ ...initialState, domains, filteredDomains: domains, defaultRedirects }),
|
||||
[FILTER_DOMAINS]: (state, { searchTerm }) => ({
|
||||
...state,
|
||||
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm)),
|
||||
}),
|
||||
[EDIT_DOMAIN_REDIRECTS]: (state, { domain, redirects }) => ({
|
||||
...state,
|
||||
domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||
filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||
}),
|
||||
[VALIDATE_DOMAIN]: (state, { domain, status }) => ({
|
||||
...state,
|
||||
domains: state.domains.map(replaceStatusOnDomain(domain, status)),
|
||||
filteredDomains: state.filteredDomains.map(replaceStatusOnDomain(domain, status)),
|
||||
}),
|
||||
}, initialState);
|
||||
|
||||
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
||||
@@ -40,10 +89,42 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
|
||||
const { listDomains } = buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
const domains = await listDomains();
|
||||
const { domains, defaultRedirects } = await listDomains().then(({ data, defaultRedirects }) => ({
|
||||
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
|
||||
defaultRedirects,
|
||||
}));
|
||||
|
||||
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
|
||||
} catch (e) {
|
||||
dispatch({ type: LIST_DOMAINS_ERROR });
|
||||
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains, defaultRedirects });
|
||||
} catch (e: any) {
|
||||
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
|
||||
}
|
||||
};
|
||||
|
||||
export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm });
|
||||
|
||||
export const checkDomainHealth = (buildShlinkApiClient: ShlinkApiClientBuilder) => (domain: string) => async (
|
||||
dispatch: Dispatch,
|
||||
getState: GetState,
|
||||
) => {
|
||||
const { selectedServer } = getState();
|
||||
|
||||
if (!hasServerData(selectedServer)) {
|
||||
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { url, ...rest } = selectedServer;
|
||||
const { health } = buildShlinkApiClient({
|
||||
...rest,
|
||||
url: replaceAuthorityFromUri(url, domain),
|
||||
});
|
||||
|
||||
const { status } = await health();
|
||||
|
||||
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: status === 'pass' ? 'valid' : 'invalid' });
|
||||
} catch (e) {
|
||||
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import Bottle from 'bottlejs';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { listDomains } from '../reducers/domainsList';
|
||||
import { checkDomainHealth, filterDomains, listDomains } from '../reducers/domainsList';
|
||||
import { DomainSelector } from '../DomainSelector';
|
||||
import { ManageDomains } from '../ManageDomains';
|
||||
import { editDomainRedirects } from '../reducers/domainRedirects';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
// Components
|
||||
bottle.serviceFactory('DomainSelector', () => DomainSelector);
|
||||
bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ]));
|
||||
|
||||
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
||||
bottle.decorator('ManageDomains', connect(
|
||||
[ 'domainsList', 'selectedServer' ],
|
||||
[ 'listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth' ],
|
||||
));
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('filterDomains', () => filterDomains);
|
||||
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('checkDomainHealth', checkDomainHealth, 'buildShlinkApiClient');
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
|
||||
@import './utils/base';
|
||||
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
||||
@import './common/react-tagsinput.scss';
|
||||
@import './common/react-tag-autocomplete.scss';
|
||||
@import './theme/theme';
|
||||
@import './utils/table/ResponsiveTable';
|
||||
@import './utils/StickyCardPaginator';
|
||||
|
||||
* {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
:root {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
@@ -17,6 +23,17 @@ body,
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
a,
|
||||
.btn-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* stylelint-disable-next-line selector-max-pseudo-class */
|
||||
a:not(.nav-link):not(.navbar-brand):not(.page-link):not(.highlight-card):not(.btn):not(.dropdown-item):hover,
|
||||
.btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.bg-main {
|
||||
background-color: $mainColor !important;
|
||||
}
|
||||
@@ -72,7 +89,8 @@ hr {
|
||||
border-color: var(--table-border-color);
|
||||
}
|
||||
|
||||
.page-link:hover {
|
||||
.page-link:hover,
|
||||
.page-link:focus {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
@@ -96,6 +114,22 @@ hr {
|
||||
}
|
||||
}
|
||||
|
||||
/* Deprecated. Brought from bootstrap 4 */
|
||||
.btn-block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-primary:hover,
|
||||
.btn-primary:active,
|
||||
.btn-primary.active,
|
||||
.btn-outline-primary:hover,
|
||||
.btn-outline-primary:active,
|
||||
.btn-outline-primary.active, {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.dropdown-item,
|
||||
.dropdown-item-text {
|
||||
color: var(--text-color);
|
||||
@@ -113,6 +147,16 @@ hr {
|
||||
color: var(--text-color) !important;
|
||||
}
|
||||
|
||||
.dropdown-item--danger.dropdown-item--danger {
|
||||
color: $dangerColor;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: $dangerColor !important;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-main {
|
||||
color: #ffffff;
|
||||
background-color: var(--brand-color);
|
||||
@@ -121,10 +165,15 @@ hr {
|
||||
.close,
|
||||
.close:hover,
|
||||
.table,
|
||||
.table-hover tbody tr:hover {
|
||||
.table-hover > tbody > tr:hover > *,
|
||||
.table-hover > tbody > tr > * {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
filter: var(--btn-close-filter);
|
||||
}
|
||||
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { render } from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { homepage } from '../package.json';
|
||||
import container from './container';
|
||||
import store from './container/store';
|
||||
import { container } from './container';
|
||||
import { store } from './container/store';
|
||||
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
||||
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import './index.scss';
|
||||
@@ -12,7 +13,7 @@ import './index.scss';
|
||||
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
|
||||
fixLeafletIcons();
|
||||
|
||||
const { App, ScrollToTop, ErrorHandler } = container;
|
||||
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
@@ -26,3 +27,12 @@ render(
|
||||
</Provider>,
|
||||
document.getElementById('root'),
|
||||
);
|
||||
|
||||
// If you want your app to work offline and load faster, you can change
|
||||
// unregister() to register() below. Note this comes with some pitfalls.
|
||||
// Learn more about service workers: https://cra.link/PWA
|
||||
registerServiceWorker({
|
||||
onUpdate() {
|
||||
store.dispatch(appUpdateAvailable()); // eslint-disable-line @typescript-eslint/no-unsafe-call
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export class Topics {
|
||||
public static visits = () => 'https://shlink.io/new-visit';
|
||||
public static readonly visits = 'https://shlink.io/new-visit';
|
||||
|
||||
public static shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`;
|
||||
public static readonly orphanVisits = 'https://shlink.io/new-orphan-visit';
|
||||
|
||||
public static orphanVisits = () => 'https://shlink.io/new-orphan-visit';
|
||||
public static readonly shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FC, useEffect } from 'react';
|
||||
import { pipe } from 'ramda';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { CreateVisit } from '../../visits/types';
|
||||
import { MercureInfo } from '../reducers/mercureInfo';
|
||||
import { bindToMercureTopic } from './index';
|
||||
@@ -12,17 +13,19 @@ export interface MercureBoundProps {
|
||||
|
||||
export function boundToMercureHub<T = {}>(
|
||||
WrappedComponent: FC<MercureBoundProps & T>,
|
||||
getTopicsForProps: (props: T) => string[],
|
||||
getTopicsForProps: (props: T, routeParams: any) => string[],
|
||||
) {
|
||||
const pendingUpdates = new Set<CreateVisit>();
|
||||
|
||||
return (props: MercureBoundProps & T) => {
|
||||
const { createNewVisits, loadMercureInfo, mercureInfo } = props;
|
||||
const { interval } = mercureInfo;
|
||||
const params = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisits([ visit ]);
|
||||
const closeEventSource = bindToMercureTopic(mercureInfo, getTopicsForProps(props), onMessage, loadMercureInfo);
|
||||
const topics = getTopicsForProps(props, params);
|
||||
const closeEventSource = bindToMercureTopic(mercureInfo, topics, onMessage, loadMercureInfo);
|
||||
|
||||
if (!interval) {
|
||||
return closeEventSource;
|
||||
|
||||
@@ -2,13 +2,13 @@ import { combineReducers } from 'redux';
|
||||
import serversReducer from '../servers/reducers/servers';
|
||||
import selectedServerReducer from '../servers/reducers/selectedServer';
|
||||
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
|
||||
import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams';
|
||||
import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
|
||||
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
|
||||
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
||||
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
||||
import tagVisitsReducer from '../visits/reducers/tagVisits';
|
||||
import orphanVisitsReducer from '../visits/reducers/orphanVisits';
|
||||
import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits';
|
||||
import shortUrlDetailReducer from '../short-urls/reducers/shortUrlDetail';
|
||||
import tagsListReducer from '../tags/reducers/tagsList';
|
||||
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
||||
@@ -17,19 +17,21 @@ import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
||||
import settingsReducer from '../settings/reducers/settings';
|
||||
import domainsListReducer from '../domains/reducers/domainsList';
|
||||
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
|
||||
import appUpdatesReducer from '../app/reducers/appUpdates';
|
||||
import sidebarReducer from '../common/reducers/sidebar';
|
||||
import { ShlinkState } from '../container/types';
|
||||
|
||||
export default combineReducers<ShlinkState>({
|
||||
servers: serversReducer,
|
||||
selectedServer: selectedServerReducer,
|
||||
shortUrlsList: shortUrlsListReducer,
|
||||
shortUrlsListParams: shortUrlsListParamsReducer,
|
||||
shortUrlCreationResult: shortUrlCreationReducer,
|
||||
shortUrlDeletion: shortUrlDeletionReducer,
|
||||
shortUrlEdition: shortUrlEditionReducer,
|
||||
shortUrlVisits: shortUrlVisitsReducer,
|
||||
tagVisits: tagVisitsReducer,
|
||||
orphanVisits: orphanVisitsReducer,
|
||||
nonOrphanVisits: nonOrphanVisitsReducer,
|
||||
shortUrlDetail: shortUrlDetailReducer,
|
||||
tagsList: tagsListReducer,
|
||||
tagDelete: tagDeleteReducer,
|
||||
@@ -38,4 +40,6 @@ export default combineReducers<ShlinkState>({
|
||||
settings: settingsReducer,
|
||||
domainsList: domainsListReducer,
|
||||
visitsOverview: visitsOverviewReducer,
|
||||
appUpdated: appUpdatesReducer,
|
||||
sidebar: sidebarReducer,
|
||||
});
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.create-server__label {
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.create-server__csv-select {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
}
|
||||
@@ -1,52 +1,78 @@
|
||||
import { FC } from 'react';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { RouterProps } from 'react-router';
|
||||
import { Button } from 'reactstrap';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Result } from '../utils/Result';
|
||||
import NoMenuLayout from '../common/NoMenuLayout';
|
||||
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||
import { StateFlagTimeout, useGoBack, useToggle } from '../utils/helpers/hooks';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||
import { ServerData, ServerWithId } from './data';
|
||||
import './CreateServer.scss';
|
||||
import { ServerData, ServersMap, ServerWithId } from './data';
|
||||
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
|
||||
|
||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||
|
||||
interface CreateServerProps extends RouterProps {
|
||||
interface CreateServerProps {
|
||||
createServer: (server: ServerWithId) => void;
|
||||
servers: ServersMap;
|
||||
}
|
||||
|
||||
const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
|
||||
<Result type={type}>
|
||||
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
||||
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
||||
</Result>
|
||||
<div className="mt-3">
|
||||
<Result type={type}>
|
||||
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
||||
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
||||
</Result>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
|
||||
{ createServer, history: { push } }: CreateServerProps,
|
||||
{ servers, createServer }: CreateServerProps,
|
||||
) => {
|
||||
const navigate = useNavigate();
|
||||
const goBack = useGoBack();
|
||||
const hasServers = !!Object.keys(servers).length;
|
||||
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||
const handleSubmit = (serverData: ServerData) => {
|
||||
const [ isConfirmModalOpen, toggleConfirmModal ] = useToggle();
|
||||
const [ serverData, setServerData ] = useState<ServerData | undefined>();
|
||||
const save = () => {
|
||||
if (!serverData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = uuid();
|
||||
|
||||
createServer({ ...serverData, id });
|
||||
push(`/server/${id}`);
|
||||
navigate(`/server/${id}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const serverExists = Object.values(servers).some(
|
||||
({ url, apiKey }) => serverData?.url === url && serverData?.apiKey === apiKey,
|
||||
);
|
||||
|
||||
serverExists ? toggleConfirmModal() : save();
|
||||
}, [ serverData ]);
|
||||
|
||||
return (
|
||||
<NoMenuLayout>
|
||||
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={handleSubmit}>
|
||||
<ImportServersBtn onImport={setServersImported} onImportError={setErrorImporting} />
|
||||
<button className="btn btn-outline-primary">Create server</button>
|
||||
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={setServerData}>
|
||||
{!hasServers &&
|
||||
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />}
|
||||
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
|
||||
<Button outline color="primary" className="ms-2">Create server</Button>
|
||||
</ServerForm>
|
||||
|
||||
{(serversImported || errorImporting) && (
|
||||
<div className="mt-3">
|
||||
{serversImported && <ImportResult type="success" />}
|
||||
{errorImporting && <ImportResult type="error" />}
|
||||
</div>
|
||||
)}
|
||||
{serversImported && <ImportResult type="success" />}
|
||||
{errorImporting && <ImportResult type="error" />}
|
||||
|
||||
<DuplicatedServersModal
|
||||
isOpen={isConfirmModalOpen}
|
||||
duplicatedServers={serverData ? [ serverData ] : []}
|
||||
onDiscard={goBack}
|
||||
onSave={save}
|
||||
/>
|
||||
</NoMenuLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<D
|
||||
return (
|
||||
<>
|
||||
<span className={className} onClick={showModal}>
|
||||
{!children && <FontAwesomeIcon icon={deleteIcon} />}
|
||||
{!children && <FontAwesomeIcon fixedWidth icon={deleteIcon} />}
|
||||
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { FC } from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { RouterProps } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ServerWithId } from './data';
|
||||
|
||||
export interface DeleteServerModalProps {
|
||||
server: ServerWithId;
|
||||
toggle: () => void;
|
||||
isOpen: boolean;
|
||||
redirectHome?: boolean;
|
||||
}
|
||||
|
||||
interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps {
|
||||
interface DeleteServerModalConnectProps extends DeleteServerModalProps {
|
||||
deleteServer: (server: ServerWithId) => void;
|
||||
}
|
||||
|
||||
const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }: DeleteServerModalConnectProps) => {
|
||||
const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
|
||||
{ server, toggle, isOpen, deleteServer, redirectHome = true },
|
||||
) => {
|
||||
const navigate = useNavigate();
|
||||
const closeModal = () => {
|
||||
deleteServer(server);
|
||||
toggle();
|
||||
history.push('/');
|
||||
redirectHome && navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FC } from 'react';
|
||||
import { Button } from 'reactstrap';
|
||||
import NoMenuLayout from '../common/NoMenuLayout';
|
||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||
import { useGoBack } from '../utils/helpers/hooks';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
import { withSelectedServer } from './helpers/withSelectedServer';
|
||||
import { isServerWithId, ServerData } from './data';
|
||||
@@ -9,16 +10,16 @@ interface EditServerProps {
|
||||
editServer: (serverId: string, serverData: ServerData) => void;
|
||||
}
|
||||
|
||||
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
|
||||
{ editServer, selectedServer, history: { push, goBack } },
|
||||
) => {
|
||||
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>(({ editServer, selectedServer }) => {
|
||||
const goBack = useGoBack();
|
||||
|
||||
if (!isServerWithId(selectedServer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSubmit = (serverData: ServerData) => {
|
||||
editServer(selectedServer.id, serverData);
|
||||
push(`/server/${selectedServer.id}`);
|
||||
goBack();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -28,7 +29,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
|
||||
initialValues={selectedServer}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<Button outline className="mr-2" onClick={goBack}>Cancel</Button>
|
||||
<Button outline className="me-2" onClick={goBack}>Cancel</Button>
|
||||
<Button outline color="primary">Save</Button>
|
||||
</ServerForm>
|
||||
</NoMenuLayout>
|
||||
|
||||
86
src/servers/ManageServers.tsx
Normal file
86
src/servers/ManageServers.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { Button, Row } from 'reactstrap';
|
||||
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import { Result } from '../utils/Result';
|
||||
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
||||
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||
import { ServersMap } from './data';
|
||||
import { ManageServersRowProps } from './ManageServersRow';
|
||||
import ServersExporter from './services/ServersExporter';
|
||||
|
||||
interface ManageServersProps {
|
||||
servers: ServersMap;
|
||||
}
|
||||
|
||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||
|
||||
export const ManageServers = (
|
||||
serversExporter: ServersExporter,
|
||||
ImportServersBtn: FC<ImportServersBtnProps>,
|
||||
useStateFlagTimeout: StateFlagTimeout,
|
||||
ManageServersRow: FC<ManageServersRowProps>,
|
||||
): FC<ManageServersProps> => ({ servers }) => {
|
||||
const allServers = Object.values(servers);
|
||||
const [ serversList, setServersList ] = useState(allServers);
|
||||
const filterServers = (searchTerm: string) => setServersList(
|
||||
allServers.filter(({ name, url }) => `${name} ${url}`.match(searchTerm)),
|
||||
);
|
||||
const hasAutoConnect = serversList.some(({ autoConnect }) => !!autoConnect);
|
||||
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||
|
||||
useEffect(() => {
|
||||
setServersList(Object.values(servers));
|
||||
}, [ servers ]);
|
||||
|
||||
return (
|
||||
<NoMenuLayout>
|
||||
<SearchField className="mb-3" onChange={filterServers} />
|
||||
|
||||
<Row className="mb-3">
|
||||
<div className="col-md-6 d-flex d-md-block mb-2 mb-md-0">
|
||||
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
|
||||
{allServers.length > 0 && (
|
||||
<Button outline className="ms-2 flex-fill" onClick={async () => serversExporter.exportServers()}>
|
||||
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-md-6 text-md-end d-flex d-md-block">
|
||||
<Button outline color="primary" className="flex-fill" tag={Link} to="/server/create">
|
||||
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
|
||||
</Button>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<SimpleCard>
|
||||
<table className="table table-hover responsive-table mb-0">
|
||||
<thead className="responsive-table__header">
|
||||
<tr>
|
||||
{hasAutoConnect && <th style={{ width: '50px' }} />}
|
||||
<th>Name</th>
|
||||
<th>Base URL</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!serversList.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>}
|
||||
{serversList.map((server) =>
|
||||
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />)
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</SimpleCard>
|
||||
|
||||
{errorImporting && (
|
||||
<div className="mt-3">
|
||||
<Result type="error">The servers could not be imported. Make sure the format is correct.</Result>
|
||||
</div>
|
||||
)}
|
||||
</NoMenuLayout>
|
||||
);
|
||||
};
|
||||
38
src/servers/ManageServersRow.tsx
Normal file
38
src/servers/ManageServersRow.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { FC } from 'react';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCheck as checkIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { ServerWithId } from './data';
|
||||
import { ManageServersRowDropdownProps } from './ManageServersRowDropdown';
|
||||
|
||||
export interface ManageServersRowProps {
|
||||
server: ServerWithId;
|
||||
hasAutoConnect: boolean;
|
||||
}
|
||||
|
||||
export const ManageServersRow = (
|
||||
ManageServersRowDropdown: FC<ManageServersRowDropdownProps>,
|
||||
): FC<ManageServersRowProps> => ({ server, hasAutoConnect }) => (
|
||||
<tr className="responsive-table__row">
|
||||
{hasAutoConnect && (
|
||||
<td className="responsive-table__cell" data-th="Auto-connect">
|
||||
{server.autoConnect && (
|
||||
<>
|
||||
<FontAwesomeIcon icon={checkIcon} className="text-primary" id="autoConnectIcon" />
|
||||
<UncontrolledTooltip target="autoConnectIcon" placement="right">
|
||||
Auto-connect to this server
|
||||
</UncontrolledTooltip>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<th className="responsive-table__cell" data-th="Name">
|
||||
<Link to={`/server/${server.id}`}>{server.name}</Link>
|
||||
</th>
|
||||
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td>
|
||||
<td className="responsive-table__cell text-end">
|
||||
<ManageServersRowDropdown server={server} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
53
src/servers/ManageServersRowDropdown.tsx
Normal file
53
src/servers/ManageServersRowDropdown.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { FC } from 'react';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBan as toggleOffIcon,
|
||||
faEdit as editIcon,
|
||||
faMinusCircle as deleteIcon,
|
||||
faPlug as connectIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCircle as toggleOnIcon } from '@fortawesome/free-regular-svg-icons';
|
||||
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { DeleteServerModalProps } from './DeleteServerModal';
|
||||
import { ServerWithId } from './data';
|
||||
|
||||
export interface ManageServersRowDropdownProps {
|
||||
server: ServerWithId;
|
||||
}
|
||||
|
||||
interface ManageServersRowDropdownConnectProps extends ManageServersRowDropdownProps {
|
||||
setAutoConnect: (server: ServerWithId, autoConnect: boolean) => void;
|
||||
}
|
||||
|
||||
export const ManageServersRowDropdown = (
|
||||
DeleteServerModal: FC<DeleteServerModalProps>,
|
||||
): FC<ManageServersRowDropdownConnectProps> => ({ server, setAutoConnect }) => {
|
||||
const [ isMenuOpen, toggleMenu ] = useToggle();
|
||||
const [ isModalOpen,, showModal, hideModal ] = useToggle();
|
||||
const serverUrl = `/server/${server.id}`;
|
||||
const { autoConnect: isAutoConnect } = server;
|
||||
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;
|
||||
|
||||
return (
|
||||
<DropdownBtnMenu isOpen={isMenuOpen} toggle={toggleMenu}>
|
||||
<DropdownItem tag={Link} to={serverUrl}>
|
||||
<FontAwesomeIcon icon={connectIcon} fixedWidth /> Connect
|
||||
</DropdownItem>
|
||||
<DropdownItem tag={Link} to={`${serverUrl}/edit`}>
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit server
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => setAutoConnect(server, !server.autoConnect)}>
|
||||
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
|
||||
</DropdownItem>
|
||||
<DropdownItem divider />
|
||||
<DropdownItem className="dropdown-item--danger" onClick={showModal}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server
|
||||
</DropdownItem>
|
||||
|
||||
<DeleteServerModal redirectHome={false} server={server} isOpen={isModalOpen} toggle={hideModal} />
|
||||
</DropdownBtnMenu>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.overview__card.overview__card {
|
||||
text-align: center;
|
||||
border-top: 3px solid var(--brand-color);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.overview__card-title {
|
||||
text-transform: uppercase;
|
||||
color: $textPlaceholder;
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { FC, useEffect } from 'react';
|
||||
import { Card, CardBody, CardHeader, CardText, CardTitle, Row } from 'reactstrap';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
|
||||
import { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
||||
import { Card, CardBody, CardHeader, Row } from 'reactstrap';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { ITEMS_IN_OVERVIEW_PAGE, ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
||||
import { prettify } from '../utils/helpers/numbers';
|
||||
import { TagsList } from '../tags/reducers/tagsList';
|
||||
import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable';
|
||||
@@ -11,12 +10,14 @@ import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
||||
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||
import { Versions } from '../utils/helpers/version';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import { isServerWithId, SelectedServer } from './data';
|
||||
import './Overview.scss';
|
||||
import { ShlinkShortUrlsListParams } from '../api/types';
|
||||
import { supportsNonOrphanVisits, supportsOrphanVisits } from '../utils/helpers/features';
|
||||
import { getServerId, SelectedServer } from './data';
|
||||
import { HighlightCard } from './helpers/HighlightCard';
|
||||
|
||||
interface OverviewConnectProps {
|
||||
shortUrlsList: ShortUrlsListState;
|
||||
listShortUrls: (params: ShortUrlsListParams) => void;
|
||||
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
|
||||
listTags: Function;
|
||||
tagsList: TagsList;
|
||||
selectedServer: SelectedServer;
|
||||
@@ -40,11 +41,13 @@ export const Overview = (
|
||||
const { loading, shortUrls } = shortUrlsList;
|
||||
const { loading: loadingTags } = tagsList;
|
||||
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
|
||||
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
||||
const history = useHistory();
|
||||
const serverId = getServerId(selectedServer);
|
||||
const linkToOrphanVisits = supportsOrphanVisits(selectedServer);
|
||||
const linkToNonOrphanVisits = supportsNonOrphanVisits(selectedServer);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
listShortUrls({ itemsPerPage: 5, orderBy: { dateCreated: 'DESC' } });
|
||||
listShortUrls({ itemsPerPage: ITEMS_IN_OVERVIEW_PAGE, orderBy: { field: 'dateCreated', dir: 'DESC' } });
|
||||
listTags();
|
||||
loadVisitsOverview();
|
||||
}, []);
|
||||
@@ -52,52 +55,38 @@ export const Overview = (
|
||||
return (
|
||||
<>
|
||||
<Row>
|
||||
<div className="col-md-6 col-xl-3">
|
||||
<Card className="overview__card mb-3" body>
|
||||
<CardTitle tag="h5" className="overview__card-title">Visits</CardTitle>
|
||||
<CardText tag="h2">
|
||||
<ForServerVersion minVersion="2.2.0">
|
||||
{loadingVisits ? 'Loading...' : prettify(visitsCount)}
|
||||
</ForServerVersion>
|
||||
<ForServerVersion maxVersion="2.1.*">
|
||||
<small className="text-muted"><i>Shlink 2.2 is needed</i></small>
|
||||
</ForServerVersion>
|
||||
</CardText>
|
||||
</Card>
|
||||
<div className="col-lg-6 col-xl-3 mb-3">
|
||||
<HighlightCard title="Visits" link={linkToNonOrphanVisits && `/server/${serverId}/non-orphan-visits`}>
|
||||
{loadingVisits ? 'Loading...' : prettify(visitsCount)}
|
||||
</HighlightCard>
|
||||
</div>
|
||||
<div className="col-md-6 col-xl-3">
|
||||
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/orphan-visits`}>
|
||||
<CardTitle tag="h5" className="overview__card-title">Orphan visits</CardTitle>
|
||||
<CardText tag="h2">
|
||||
<ForServerVersion minVersion="2.6.0">
|
||||
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)}
|
||||
</ForServerVersion>
|
||||
<ForServerVersion maxVersion="2.5.*">
|
||||
<small className="text-muted"><i>Shlink 2.6 is needed</i></small>
|
||||
</ForServerVersion>
|
||||
</CardText>
|
||||
</Card>
|
||||
<div className="col-lg-6 col-xl-3 mb-3">
|
||||
<HighlightCard title="Orphan visits" link={linkToOrphanVisits && `/server/${serverId}/orphan-visits`}>
|
||||
<ForServerVersion minVersion="2.6.0">
|
||||
{loadingVisits ? 'Loading...' : prettify(orphanVisitsCount ?? 0)}
|
||||
</ForServerVersion>
|
||||
<ForServerVersion maxVersion="2.5.*">
|
||||
<small className="text-muted"><i>Shlink 2.6 is needed</i></small>
|
||||
</ForServerVersion>
|
||||
</HighlightCard>
|
||||
</div>
|
||||
<div className="col-md-6 col-xl-3">
|
||||
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/list-short-urls/1`}>
|
||||
<CardTitle tag="h5" className="overview__card-title">Short URLs</CardTitle>
|
||||
<CardText tag="h2">
|
||||
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
|
||||
</CardText>
|
||||
</Card>
|
||||
<div className="col-lg-6 col-xl-3 mb-3">
|
||||
<HighlightCard title="Short URLs" link={`/server/${serverId}/list-short-urls/1`}>
|
||||
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
|
||||
</HighlightCard>
|
||||
</div>
|
||||
<div className="col-md-6 col-xl-3">
|
||||
<Card className="overview__card mb-3" body tag={Link} to={`/server/${serverId}/manage-tags`}>
|
||||
<CardTitle tag="h5" className="overview__card-title">Tags</CardTitle>
|
||||
<CardText tag="h2">{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}</CardText>
|
||||
</Card>
|
||||
<div className="col-lg-6 col-xl-3 mb-3">
|
||||
<HighlightCard title="Tags" link={`/server/${serverId}/manage-tags`}>
|
||||
{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}
|
||||
</HighlightCard>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<Card className="mb-3">
|
||||
<CardHeader>
|
||||
<span className="d-sm-none">Create a short URL</span>
|
||||
<h5 className="d-none d-sm-inline">Create a short URL</h5>
|
||||
<Link className="float-right" to={`/server/${serverId}/create-short-url`}>Advanced options »</Link>
|
||||
<Link className="float-end" to={`/server/${serverId}/create-short-url`}>Advanced options »</Link>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<CreateShortUrl basicMode />
|
||||
@@ -107,17 +96,17 @@ export const Overview = (
|
||||
<CardHeader>
|
||||
<span className="d-sm-none">Recently created URLs</span>
|
||||
<h5 className="d-none d-sm-inline">Recently created URLs</h5>
|
||||
<Link className="float-right" to={`/server/${serverId}/list-short-urls/1`}>See all »</Link>
|
||||
<Link className="float-end" to={`/server/${serverId}/list-short-urls/1`}>See all »</Link>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<ShortUrlsTable
|
||||
shortUrlsList={shortUrlsList}
|
||||
selectedServer={selectedServer}
|
||||
className="mb-0"
|
||||
onTagClick={(tag) => history.push(`/server/${serverId}/list-short-urls/1?tag=${tag}`)}
|
||||
onTagClick={(tag) => navigate(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}, () => [ Topics.visits(), Topics.orphanVisits() ]);
|
||||
}, () => [ Topics.visits, Topics.orphanVisits ]);
|
||||
|
||||
@@ -1,45 +1,37 @@
|
||||
import { isEmpty, values } from 'ramda';
|
||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { faPlus as plusIcon, faFileDownload as exportIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import ServersExporter from './services/ServersExporter';
|
||||
import { isServerWithId, SelectedServer, ServersMap } from './data';
|
||||
import { getServerId, SelectedServer, ServersMap } from './data';
|
||||
|
||||
export interface ServersDropdownProps {
|
||||
servers: ServersMap;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, selectedServer }: ServersDropdownProps) => {
|
||||
const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
|
||||
const serversList = values(servers);
|
||||
const createServerItem = (
|
||||
<DropdownItem tag={Link} to="/server/create">
|
||||
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add server</span>
|
||||
</DropdownItem>
|
||||
);
|
||||
|
||||
const renderServers = () => {
|
||||
if (isEmpty(serversList)) {
|
||||
return createServerItem;
|
||||
return (
|
||||
<DropdownItem tag={Link} to="/server/create">
|
||||
<FontAwesomeIcon icon={plusIcon} /> <span className="ms-1">Add a server</span>
|
||||
</DropdownItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{serversList.map(({ name, id }) => (
|
||||
<DropdownItem
|
||||
key={id}
|
||||
tag={Link}
|
||||
to={`/server/${id}`}
|
||||
active={isServerWithId(selectedServer) && selectedServer.id === id}
|
||||
>
|
||||
<DropdownItem key={id} tag={Link} to={`/server/${id}`} active={getServerId(selectedServer) === id}>
|
||||
{name}
|
||||
</DropdownItem>
|
||||
))}
|
||||
<DropdownItem divider />
|
||||
{createServerItem}
|
||||
<DropdownItem className="servers-dropdown__export-item" onClick={async () => serversExporter.exportServers()}>
|
||||
<FontAwesomeIcon icon={exportIcon} /> <span className="ml-1">Export servers</span>
|
||||
<DropdownItem tag={Link} to="/manage-servers">
|
||||
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Manage servers</span>
|
||||
</DropdownItem>
|
||||
</>
|
||||
);
|
||||
@@ -48,9 +40,9 @@ const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, select
|
||||
return (
|
||||
<UncontrolledDropdown nav inNavbar>
|
||||
<DropdownToggle nav caret>
|
||||
<FontAwesomeIcon icon={serverIcon} /> <span className="ml-1">Servers</span>
|
||||
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Servers</span>
|
||||
</DropdownToggle>
|
||||
<DropdownMenu right>{renderServers()}</DropdownMenu>
|
||||
<DropdownMenu end style={{ right: 0 }}>{renderServers()}</DropdownMenu>
|
||||
</UncontrolledDropdown>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
padding: .75rem 2.5rem .75rem 1rem;
|
||||
}
|
||||
|
||||
.servers-list__server-item:not(:hover) {
|
||||
color: $mainColor;
|
||||
}
|
||||
|
||||
.servers-list__server-item:hover {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { omit } from 'ramda';
|
||||
import { SemVer } from '../../utils/helpers/version';
|
||||
|
||||
export interface ServerData {
|
||||
@@ -8,6 +9,7 @@ export interface ServerData {
|
||||
|
||||
export interface ServerWithId extends ServerData {
|
||||
id: string;
|
||||
autoConnect?: boolean;
|
||||
}
|
||||
|
||||
export interface ReachableServer extends ServerWithId {
|
||||
@@ -40,3 +42,8 @@ export const isReachableServer = (server: SelectedServer): server is ReachableSe
|
||||
|
||||
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
|
||||
!!server?.hasOwnProperty('serverNotFound');
|
||||
|
||||
export const getServerId = (server: SelectedServer) => isServerWithId(server) ? server.id : '';
|
||||
|
||||
export const serverWithIdToServerData = (server: ServerWithId): ServerData =>
|
||||
omit<ServerWithId, 'id' | 'autoConnect'>([ 'id', 'autoConnect' ], server);
|
||||
|
||||
40
src/servers/helpers/DuplicatedServersModal.tsx
Normal file
40
src/servers/helpers/DuplicatedServersModal.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { FC, Fragment } from 'react';
|
||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { ServerData } from '../data';
|
||||
|
||||
interface DuplicatedServersModalProps {
|
||||
duplicatedServers: ServerData[];
|
||||
isOpen: boolean;
|
||||
onDiscard: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
|
||||
{ isOpen, duplicatedServers, onDiscard, onSave },
|
||||
) => {
|
||||
const hasMultipleServers = duplicatedServers.length > 1;
|
||||
|
||||
return (
|
||||
<Modal centered isOpen={isOpen}>
|
||||
<ModalHeader>Duplicated server{hasMultipleServers && 's'}</ModalHeader>
|
||||
<ModalBody>
|
||||
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
|
||||
<ul>
|
||||
{duplicatedServers.map(({ url, apiKey }, index) => !hasMultipleServers ? (
|
||||
<Fragment key={index}>
|
||||
<li>URL: <b>{url}</b></li>
|
||||
<li>API key: <b>{apiKey}</b></li>
|
||||
</Fragment>
|
||||
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>)}
|
||||
</ul>
|
||||
<span>
|
||||
{hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}?
|
||||
</span>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="link" onClick={onDiscard}>{hasMultipleServers ? 'Ignore duplicated' : 'Discard'}</Button>
|
||||
<Button color="primary" onClick={onSave}>Save anyway</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
21
src/servers/helpers/HighlightCard.scss
Normal file
21
src/servers/helpers/HighlightCard.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
@import '../../utils/base';
|
||||
|
||||
.highlight-card.highlight-card {
|
||||
text-align: center;
|
||||
border-top: 3px solid var(--brand-color);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.highlight-card__link-icon {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
opacity: 0.1;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.highlight-card__title {
|
||||
text-transform: uppercase;
|
||||
color: $textPlaceholder;
|
||||
}
|
||||
21
src/servers/helpers/HighlightCard.tsx
Normal file
21
src/servers/helpers/HighlightCard.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { FC } from 'react';
|
||||
import { Card, CardText, CardTitle } from 'reactstrap';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { faArrowAltCircleRight as linkIcon } from '@fortawesome/free-regular-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import './HighlightCard.scss';
|
||||
|
||||
export interface HighlightCardProps {
|
||||
title: string;
|
||||
link?: string | false;
|
||||
}
|
||||
|
||||
const buildExtraProps = (link?: string | false) => !link ? {} : { tag: Link, to: link };
|
||||
|
||||
export const HighlightCard: FC<HighlightCardProps> = ({ children, title, link }) => (
|
||||
<Card className="highlight-card" body {...buildExtraProps(link)}>
|
||||
{link && <FontAwesomeIcon size="3x" className="highlight-card__link-icon" icon={linkIcon} />}
|
||||
<CardTitle tag="h5" className="highlight-card__title">{title}</CardTitle>
|
||||
<CardText tag="h2">{children}</CardText>
|
||||
</Card>
|
||||
);
|
||||
5
src/servers/helpers/ImportServersBtn.scss
Normal file
5
src/servers/helpers/ImportServersBtn.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.import-servers-btn__csv-select {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
}
|
||||
@@ -1,52 +1,91 @@
|
||||
import { useRef, RefObject, ChangeEvent, MutableRefObject } from 'react';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import ServersImporter from '../services/ServersImporter';
|
||||
import { ServerData } from '../data';
|
||||
import { useRef, RefObject, ChangeEvent, MutableRefObject, FC, useState, useEffect } from 'react';
|
||||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||
import { complement, pipe } from 'ramda';
|
||||
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import { ServersImporter } from '../services/ServersImporter';
|
||||
import { ServerData, ServersMap } from '../data';
|
||||
import { DuplicatedServersModal } from './DuplicatedServersModal';
|
||||
import './ImportServersBtn.scss';
|
||||
|
||||
type Ref<T> = RefObject<T> | MutableRefObject<T>;
|
||||
|
||||
export interface ImportServersBtnProps {
|
||||
onImport?: () => void;
|
||||
onImportError?: () => void;
|
||||
onImportError?: (error: Error) => void;
|
||||
tooltipPlacement?: 'top' | 'bottom';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
||||
createServers: (servers: ServerData[]) => void;
|
||||
servers: ServersMap;
|
||||
fileRef: Ref<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
|
||||
const serversFiltering = (servers: ServerData[]) =>
|
||||
({ url, apiKey }: ServerData) => servers.some((server) => server.url === url && server.apiKey === apiKey);
|
||||
|
||||
const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<ImportServersBtnConnectProps> => ({
|
||||
createServers,
|
||||
servers,
|
||||
fileRef,
|
||||
children,
|
||||
onImport = () => {},
|
||||
onImportError = () => {},
|
||||
}: ImportServersBtnConnectProps) => {
|
||||
tooltipPlacement = 'bottom',
|
||||
className = '',
|
||||
}) => {
|
||||
const ref = fileRef ?? useRef<HTMLInputElement>();
|
||||
const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||
const [ serversToCreate, setServersToCreate ] = useState<ServerData[] | undefined>();
|
||||
const [ duplicatedServers, setDuplicatedServers ] = useState<ServerData[]>([]);
|
||||
const [ isModalOpen,, showModal, hideModal ] = useToggle();
|
||||
const create = pipe(createServers, onImport);
|
||||
const createAllServers = pipe(() => create(serversToCreate ?? []), hideModal);
|
||||
const createNonDuplicatedServers = pipe(
|
||||
() => create((serversToCreate ?? []).filter(complement(serversFiltering(duplicatedServers)))),
|
||||
hideModal,
|
||||
);
|
||||
const onFile = async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||
importServersFromFile(target.files?.[0])
|
||||
.then(createServers)
|
||||
.then(onImport)
|
||||
.then(setServersToCreate)
|
||||
.then(() => {
|
||||
// Reset input after processing file
|
||||
(target as { value: string | null }).value = null;
|
||||
})
|
||||
.catch(onImportError);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serversToCreate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingServers = Object.values(servers);
|
||||
const duplicatedServers = serversToCreate.filter(serversFiltering(existingServers));
|
||||
const hasDuplicatedServers = !!duplicatedServers.length;
|
||||
|
||||
!hasDuplicatedServers ? create(serversToCreate) : setDuplicatedServers(duplicatedServers);
|
||||
hasDuplicatedServers && showModal();
|
||||
}, [ serversToCreate ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary mr-2"
|
||||
id="importBtn"
|
||||
onClick={() => ref.current?.click()}
|
||||
>
|
||||
Import from file
|
||||
</button>
|
||||
<UncontrolledTooltip placement="top" target="importBtn">
|
||||
<Button outline id="importBtn" className={className} onClick={() => ref.current?.click()}>
|
||||
<FontAwesomeIcon icon={importIcon} fixedWidth /> {children ?? 'Import from file'}
|
||||
</Button>
|
||||
<UncontrolledTooltip placement={tooltipPlacement} target="importBtn">
|
||||
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
|
||||
</UncontrolledTooltip>
|
||||
|
||||
<input type="file" accept="text/csv" className="create-server__csv-select" ref={ref} onChange={onChange} />
|
||||
<input type="file" accept="text/csv" className="import-servers-btn__csv-select" ref={ref} onChange={onFile} />
|
||||
|
||||
<DuplicatedServersModal
|
||||
isOpen={isModalOpen}
|
||||
duplicatedServers={duplicatedServers}
|
||||
onDiscard={createNonDuplicatedServers}
|
||||
onSave={createAllServers}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import Message from '../../utils/Message';
|
||||
import ServersListGroup from '../ServersListGroup';
|
||||
import { DeleteServerButtonProps } from '../DeleteServerButton';
|
||||
import { isServerWithId, SelectedServer, ServersMap } from '../data';
|
||||
import NoMenuLayout from '../../common/NoMenuLayout';
|
||||
import { NoMenuLayout } from '../../common/NoMenuLayout';
|
||||
import './ServerError.scss';
|
||||
|
||||
interface ServerErrorProps {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.server-form .form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { FC, ReactNode, useEffect, useState } from 'react';
|
||||
import { FormGroupContainer } from '../../utils/FormGroupContainer';
|
||||
import { InputFormGroup } from '../../utils/forms/InputFormGroup';
|
||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||
import { ServerData } from '../data';
|
||||
import { SimpleCard } from '../../utils/SimpleCard';
|
||||
import './ServerForm.scss';
|
||||
|
||||
interface ServerFormProps {
|
||||
onSubmit: (server: ServerData) => void;
|
||||
@@ -26,12 +25,12 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
|
||||
return (
|
||||
<form className="server-form" onSubmit={handleSubmit}>
|
||||
<SimpleCard className="mb-3" title={title}>
|
||||
<FormGroupContainer value={name} onChange={setName}>Name</FormGroupContainer>
|
||||
<FormGroupContainer type="url" value={url} onChange={setUrl}>URL</FormGroupContainer>
|
||||
<FormGroupContainer value={apiKey} onChange={setApiKey}>API key</FormGroupContainer>
|
||||
<InputFormGroup value={name} onChange={setName}>Name</InputFormGroup>
|
||||
<InputFormGroup type="url" value={url} onChange={setUrl}>URL</InputFormGroup>
|
||||
<InputFormGroup value={apiKey} onChange={setApiKey}>API key</InputFormGroup>
|
||||
</SimpleCard>
|
||||
|
||||
<div className="text-right">{children}</div>
|
||||
<div className="text-end">{children}</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { FC, useEffect } from 'react';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import Message from '../../utils/Message';
|
||||
import { isNotFoundServer, SelectedServer } from '../data';
|
||||
import NoMenuLayout from '../../common/NoMenuLayout';
|
||||
import { NoMenuLayout } from '../../common/NoMenuLayout';
|
||||
|
||||
interface WithSelectedServerProps extends RouteComponentProps<{ serverId: string }> {
|
||||
interface WithSelectedServerProps {
|
||||
selectServer: (serverId: string) => void;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
export function withSelectedServer<T = {}>(WrappedComponent: FC<WithSelectedServerProps & T>, ServerError: FC) {
|
||||
return (props: WithSelectedServerProps & T) => {
|
||||
const { selectServer, selectedServer, match } = props;
|
||||
const params = useParams<{ serverId: string }>();
|
||||
const { selectServer, selectedServer } = props;
|
||||
|
||||
useEffect(() => {
|
||||
selectServer(match.params.serverId);
|
||||
}, [ match.params.serverId ]);
|
||||
params.serverId && selectServer(params.serverId);
|
||||
}, [ params.serverId ]);
|
||||
|
||||
if (!selectedServer) {
|
||||
return (
|
||||
|
||||
@@ -2,24 +2,17 @@ import { pipe, prop } from 'ramda';
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { Dispatch } from 'redux';
|
||||
import { homepage } from '../../../package.json';
|
||||
import { ServerData } from '../data';
|
||||
import { hasServerData, ServerData } from '../data';
|
||||
import { createServers } from './servers';
|
||||
|
||||
const responseToServersList = pipe(
|
||||
prop<any, any>('data'),
|
||||
(data: any): ServerData[] => {
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Value is not an array');
|
||||
}
|
||||
|
||||
return data as ServerData[];
|
||||
},
|
||||
(data: any): ServerData[] => Array.isArray(data) ? data.filter(hasServerData) : [],
|
||||
);
|
||||
|
||||
export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => {
|
||||
const remoteList = await get(`${homepage}/servers.json`)
|
||||
.then(responseToServersList)
|
||||
.catch(() => []);
|
||||
const resp = await get(`${homepage}/servers.json`);
|
||||
const remoteList = responseToServersList(resp);
|
||||
|
||||
dispatch(createServers(remoteList));
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { identity, memoizeWith, pipe } from 'ramda';
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
|
||||
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
||||
import { SelectedServer } from '../data';
|
||||
import { GetState } from '../../container/types';
|
||||
@@ -53,7 +52,6 @@ export const selectServer = (
|
||||
getState: GetState,
|
||||
) => {
|
||||
dispatch(resetSelectedServer());
|
||||
dispatch(resetShortUrlParams());
|
||||
|
||||
const { servers } = getState();
|
||||
const selectedServer = servers[serverId];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { assoc, dissoc, map, pipe, reduce } from 'ramda';
|
||||
import { assoc, dissoc, fromPairs, map, pipe, reduce, toPairs } from 'ramda';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Action } from 'redux';
|
||||
import { ServerData, ServersMap, ServerWithId } from '../data';
|
||||
@@ -8,12 +8,22 @@ import { buildReducer } from '../../utils/helpers/redux';
|
||||
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
|
||||
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
|
||||
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
|
||||
export const SET_AUTO_CONNECT = 'shlink/servers/SET_AUTO_CONNECT';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export interface CreateServersAction extends Action<string> {
|
||||
newServers: ServersMap;
|
||||
}
|
||||
|
||||
interface DeleteServerAction extends Action<string> {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
interface SetAutoConnectAction extends Action<string> {
|
||||
serverId: string;
|
||||
autoConnect: boolean;
|
||||
}
|
||||
|
||||
const initialState: ServersMap = {};
|
||||
|
||||
const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
||||
@@ -24,12 +34,28 @@ const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
||||
return assoc('id', uuid(), server);
|
||||
};
|
||||
|
||||
export default buildReducer<ServersMap, CreateServersAction>({
|
||||
export default buildReducer<ServersMap, CreateServersAction & DeleteServerAction & SetAutoConnectAction>({
|
||||
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
|
||||
[DELETE_SERVER]: (state, { serverId }: any) => dissoc(serverId, state),
|
||||
[DELETE_SERVER]: (state, { serverId }) => dissoc(serverId, state),
|
||||
[EDIT_SERVER]: (state, { serverId, serverData }: any) => !state[serverId]
|
||||
? state
|
||||
: assoc(serverId, { ...state[serverId], ...serverData }, state),
|
||||
[SET_AUTO_CONNECT]: (state, { serverId, autoConnect }) => {
|
||||
if (!state[serverId]) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (!autoConnect) {
|
||||
return assoc(serverId, { ...state[serverId], autoConnect }, state);
|
||||
}
|
||||
|
||||
return fromPairs(
|
||||
toPairs(state).map(([ evaluatedServerId, server ]) => [
|
||||
evaluatedServerId,
|
||||
{ ...server, autoConnect: evaluatedServerId === serverId },
|
||||
]),
|
||||
);
|
||||
},
|
||||
}, initialState);
|
||||
|
||||
const serversListToMap = reduce<ServerWithId, ServersMap>((acc, server) => assoc(server.id, server, acc), {});
|
||||
@@ -48,4 +74,10 @@ export const editServer = (serverId: string, serverData: Partial<ServerData>) =>
|
||||
serverData,
|
||||
});
|
||||
|
||||
export const deleteServer = ({ id }: ServerWithId) => ({ type: DELETE_SERVER, serverId: id });
|
||||
export const deleteServer = ({ id }: ServerWithId): DeleteServerAction => ({ type: DELETE_SERVER, serverId: id });
|
||||
|
||||
export const setAutoConnect = ({ id }: ServerWithId, autoConnect: boolean): SetAutoConnectAction => ({
|
||||
type: SET_AUTO_CONNECT,
|
||||
serverId: id,
|
||||
autoConnect,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { dissoc, values } from 'ramda';
|
||||
import { values } from 'ramda';
|
||||
import { CsvJson } from 'csvjson';
|
||||
import LocalStorage from '../../utils/services/LocalStorage';
|
||||
import { ServersMap } from '../data';
|
||||
import { saveCsv } from '../../utils/helpers/csv';
|
||||
import { ServersMap, serverWithIdToServerData } from '../data';
|
||||
import { saveCsv } from '../../utils/helpers/files';
|
||||
|
||||
const SERVERS_FILENAME = 'shlink-servers.csv';
|
||||
|
||||
@@ -14,7 +14,7 @@ export default class ServersExporter {
|
||||
) {}
|
||||
|
||||
public readonly exportServers = async () => {
|
||||
const servers = values(this.storage.get<ServersMap>('servers') ?? {}).map(dissoc('id'));
|
||||
const servers = values(this.storage.get<ServersMap>('servers') ?? {}).map(serverWithIdToServerData);
|
||||
|
||||
try {
|
||||
const csv = this.csvjson.toCSV(servers, { headers: 'key' });
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
import { CsvJson } from 'csvjson';
|
||||
import { ServerData } from '../data';
|
||||
|
||||
interface CsvFile extends File {
|
||||
type: 'text/csv' | 'text/comma-separated-values' | 'application/csv';
|
||||
}
|
||||
const validateServer = (server: any): server is ServerData =>
|
||||
typeof server.url === 'string' && typeof server.apiKey === 'string' && typeof server.name === 'string';
|
||||
|
||||
const CSV_MIME_TYPES = [ 'text/csv', 'text/comma-separated-values', 'application/csv' ];
|
||||
const isCsv = (file?: File | null): file is CsvFile => !!file && CSV_MIME_TYPES.includes(file.type);
|
||||
const validateServers = (servers: any): servers is ServerData[] =>
|
||||
Array.isArray(servers) && servers.every(validateServer);
|
||||
|
||||
export default class ServersImporter {
|
||||
public constructor(private readonly csvjson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
|
||||
export class ServersImporter {
|
||||
public constructor(private readonly csvJson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
|
||||
|
||||
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
|
||||
if (!isCsv(file)) {
|
||||
throw new Error('No file provided or file is not a CSV');
|
||||
if (!file) {
|
||||
throw new Error('No file provided');
|
||||
}
|
||||
|
||||
const reader = this.fileReaderFactory();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
reader.addEventListener('loadend', (e: ProgressEvent<FileReader>) => {
|
||||
const content = e.target?.result?.toString() ?? '';
|
||||
const servers = this.csvjson.toObject<ServerData>(content);
|
||||
try {
|
||||
// TODO Read as stream, otherwise, if the file is too big, this will block the browser tab
|
||||
const content = e.target?.result?.toString() ?? '';
|
||||
const servers = this.csvJson.toObject(content);
|
||||
|
||||
resolve(servers);
|
||||
if (!validateServers(servers)) {
|
||||
throw new Error('Provided file does not have the right format.');
|
||||
}
|
||||
|
||||
resolve(servers);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import csvjson from 'csvjson';
|
||||
import Bottle, { Decorator } from 'bottlejs';
|
||||
import Bottle from 'bottlejs';
|
||||
import CreateServer from '../CreateServer';
|
||||
import ServersDropdown from '../ServersDropdown';
|
||||
import DeleteServerModal from '../DeleteServerModal';
|
||||
@@ -7,36 +7,54 @@ import DeleteServerButton from '../DeleteServerButton';
|
||||
import { EditServer } from '../EditServer';
|
||||
import ImportServersBtn from '../helpers/ImportServersBtn';
|
||||
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
|
||||
import { createServer, createServers, deleteServer, editServer } from '../reducers/servers';
|
||||
import { createServer, createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
|
||||
import { fetchServers } from '../reducers/remoteServers';
|
||||
import ForServerVersion from '../helpers/ForServerVersion';
|
||||
import { ServerError } from '../helpers/ServerError';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
|
||||
import { Overview } from '../Overview';
|
||||
import ServersImporter from './ServersImporter';
|
||||
import { ManageServers } from '../ManageServers';
|
||||
import { ManageServersRow } from '../ManageServersRow';
|
||||
import { ManageServersRowDropdown } from '../ManageServersRowDropdown';
|
||||
import { ServersImporter } from './ServersImporter';
|
||||
import ServersExporter from './ServersExporter';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
// Components
|
||||
bottle.serviceFactory(
|
||||
'ManageServers',
|
||||
ManageServers,
|
||||
'ServersExporter',
|
||||
'ImportServersBtn',
|
||||
'useStateFlagTimeout',
|
||||
'ManageServersRow',
|
||||
);
|
||||
bottle.decorator('ManageServers', withoutSelectedServer);
|
||||
bottle.decorator('ManageServers', connect([ 'selectedServer', 'servers' ], [ 'resetSelectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('ManageServersRow', ManageServersRow, 'ManageServersRowDropdown');
|
||||
|
||||
bottle.serviceFactory('ManageServersRowDropdown', ManageServersRowDropdown, 'DeleteServerModal');
|
||||
bottle.decorator('ManageServersRowDropdown', connect(null, [ 'setAutoConnect' ]));
|
||||
|
||||
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
|
||||
bottle.decorator('CreateServer', withoutSelectedServer);
|
||||
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
|
||||
bottle.decorator('CreateServer', connect([ 'selectedServer', 'servers' ], [ 'createServer', 'resetSelectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
|
||||
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
|
||||
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer', 'resetSelectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
|
||||
bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
|
||||
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
|
||||
bottle.decorator('DeleteServerModal', withRouter);
|
||||
bottle.decorator('DeleteServerModal', connect(null, [ 'deleteServer' ]));
|
||||
|
||||
bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal');
|
||||
|
||||
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
|
||||
bottle.decorator('ImportServersBtn', connect(null, [ 'createServers' ]));
|
||||
bottle.decorator('ImportServersBtn', connect([ 'servers' ], [ 'createServers' ]));
|
||||
|
||||
bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
|
||||
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));
|
||||
@@ -62,6 +80,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||
bottle.serviceFactory('createServers', () => createServers);
|
||||
bottle.serviceFactory('deleteServer', () => deleteServer);
|
||||
bottle.serviceFactory('editServer', () => editServer);
|
||||
bottle.serviceFactory('setAutoConnect', () => setAutoConnect);
|
||||
bottle.serviceFactory('fetchServers', fetchServers, 'axios');
|
||||
|
||||
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user