mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-02-25 03:06:36 +00:00
Compare commits
398 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
793883148a | ||
|
|
8acb7bea24 | ||
|
|
335cceeb82 | ||
|
|
bf7455ad6e | ||
|
|
421cc5b718 | ||
|
|
78d97a64aa | ||
|
|
749c757cbd | ||
|
|
faf9554286 | ||
|
|
b7a0be3872 | ||
|
|
cff8046ff8 | ||
|
|
af1289752d | ||
|
|
b06d9d3bc7 | ||
|
|
b2904189ef | ||
|
|
5c639d241b | ||
|
|
984e9f32a5 | ||
|
|
59d23b584a | ||
|
|
a7d865228a | ||
|
|
260ff716d7 | ||
|
|
9001a3da37 | ||
|
|
46db1e39f3 | ||
|
|
6bf3fc0fd5 | ||
|
|
a136543551 | ||
|
|
23046c149c | ||
|
|
2951d0d75e | ||
|
|
b52e40edd3 | ||
|
|
51556d76ac | ||
|
|
871868f0a4 | ||
|
|
67495fa302 | ||
|
|
fc9341f631 | ||
|
|
3fea8b5505 | ||
|
|
89e3114ef3 | ||
|
|
4dc5fad8b8 | ||
|
|
2567bdfdf0 | ||
|
|
f36cf1e7b9 | ||
|
|
bd88e56331 | ||
|
|
fe3e08de0f | ||
|
|
cfb165d240 | ||
|
|
fa074f91be | ||
|
|
6fc4963663 | ||
|
|
ad437f655e | ||
|
|
9b45513684 | ||
|
|
5d6d802d64 | ||
|
|
536d49aac9 | ||
|
|
796c9ca140 | ||
|
|
1b1a1f3230 | ||
|
|
98d856700c | ||
|
|
b814f500de | ||
|
|
90abf29db9 | ||
|
|
d064eb5f9e | ||
|
|
58c9ef9b51 | ||
|
|
125b13e059 | ||
|
|
dcdee8b308 | ||
|
|
f33d1fca39 | ||
|
|
7e907ba9b6 | ||
|
|
3cec2efbbd | ||
|
|
d4094e66b3 | ||
|
|
73b854037d | ||
|
|
f2e7a2161d | ||
|
|
260ed3041a | ||
|
|
8a146021dd | ||
|
|
4083592212 | ||
|
|
f9c57ca659 | ||
|
|
d0d664ef79 | ||
|
|
16d96efa4a | ||
|
|
f8ea1ae3d5 | ||
|
|
18883caa6d | ||
|
|
84fc82b74e | ||
|
|
8a9c694fbc | ||
|
|
4b33d39d44 | ||
|
|
c0f5d9c12c | ||
|
|
ef630af154 | ||
|
|
ebd7a76896 | ||
|
|
64a968711c | ||
|
|
aee4c2d02f | ||
|
|
8cc0695ee9 | ||
|
|
f40ad91ea9 | ||
|
|
a96539129d | ||
|
|
dcf72e6818 | ||
|
|
54290d4c9a | ||
|
|
eb3775859a | ||
|
|
83531666de | ||
|
|
f3a2535e2f | ||
|
|
f283dc8569 | ||
|
|
b19bbee7fc | ||
|
|
1b03d04318 | ||
|
|
6696fb13d6 | ||
|
|
f04aece7df | ||
|
|
d8f3952920 | ||
|
|
fefa4e7848 | ||
|
|
0b4a348969 | ||
|
|
3e2fee0df5 | ||
|
|
294888454d | ||
|
|
1b7e1e2b5b | ||
|
|
dc78138066 | ||
|
|
87e64e5899 | ||
|
|
e193a692e8 | ||
|
|
2eba607874 | ||
|
|
62df46d648 | ||
|
|
7c67fa4149 | ||
|
|
2db85c2783 | ||
|
|
39663ba936 | ||
|
|
eefea0c37b | ||
|
|
d65a6ba970 | ||
|
|
524b0a74c6 | ||
|
|
72de9d4ff8 | ||
|
|
a91f1b3bd4 | ||
|
|
343a93b984 | ||
|
|
8be17cce8a | ||
|
|
d2f818c1ea | ||
|
|
a675d60d59 | ||
|
|
2d96c21b50 | ||
|
|
6f6ba9e34d | ||
|
|
e6efda5563 | ||
|
|
b1df1652bf | ||
|
|
474239c151 | ||
|
|
feeb212259 | ||
|
|
90245016a0 | ||
|
|
8c7616c3a7 | ||
|
|
ea84ce9c41 | ||
|
|
c4730ec92d | ||
|
|
76b3d573c0 | ||
|
|
b96f4b7a90 | ||
|
|
2a0def262d | ||
|
|
897e35f0b8 | ||
|
|
1c335506d8 | ||
|
|
d46acdbd70 | ||
|
|
026bb4140e | ||
|
|
d80f3da55d | ||
|
|
f18495a4b1 | ||
|
|
f908da78f6 | ||
|
|
bc16381c90 | ||
|
|
4cb7aa64cf | ||
|
|
da6d7aea8b | ||
|
|
b310d79110 | ||
|
|
e26cdc11c3 | ||
|
|
fa54aa3128 | ||
|
|
e31e70039d | ||
|
|
cb761dea8f | ||
|
|
949e0da105 | ||
|
|
770cc59448 | ||
|
|
72dd2bd0a7 | ||
|
|
54733eaa18 | ||
|
|
52c56f7918 | ||
|
|
c46d5187c1 | ||
|
|
05e3e87653 | ||
|
|
8b9289ff08 | ||
|
|
16ffbcfbc0 | ||
|
|
d825b6e174 | ||
|
|
73e55cc742 | ||
|
|
32cc1cc580 | ||
|
|
e00574553f | ||
|
|
984c1ea716 | ||
|
|
df38cf6ca9 | ||
|
|
1b60b0e2a8 | ||
|
|
11f9c7c507 | ||
|
|
ebe649aaac | ||
|
|
656b68d422 | ||
|
|
cd1f186e28 | ||
|
|
d0b3edaa2f | ||
|
|
2268b85ade | ||
|
|
d7e3b7b912 | ||
|
|
4bd83eecfb | ||
|
|
b7fd2308ad | ||
|
|
a6958941ad | ||
|
|
c98b28ff0f | ||
|
|
6a372badfa | ||
|
|
b6ab9a1bdd | ||
|
|
daf9e7cf64 | ||
|
|
ef42dcd666 | ||
|
|
1b6028ae6d | ||
|
|
9340512980 | ||
|
|
9d0b4cc065 | ||
|
|
c5cb0dcb26 | ||
|
|
a42f5ab13e | ||
|
|
68b0577526 | ||
|
|
61867366e7 | ||
|
|
c670d86955 | ||
|
|
4565a64cd8 | ||
|
|
f36e42d9c1 | ||
|
|
0a3a97242b | ||
|
|
68253c3bc4 | ||
|
|
544384d85e | ||
|
|
91daec852f | ||
|
|
dcc5b9cc8c | ||
|
|
1d26cd93fb | ||
|
|
e47dfaf36f | ||
|
|
09e2c69e46 | ||
|
|
07d3567244 | ||
|
|
9bdbe90716 | ||
|
|
02a4380f7c | ||
|
|
4e483dc5d4 | ||
|
|
52631e629e | ||
|
|
3a53298417 | ||
|
|
fb0f14fc16 | ||
|
|
7a94b1730d | ||
|
|
f856bc218a | ||
|
|
bfbb21e1cc | ||
|
|
18e18f533b | ||
|
|
6eead70511 | ||
|
|
6fd30ed51a | ||
|
|
67c674f073 | ||
|
|
289d8784c0 | ||
|
|
18e026e4ca | ||
|
|
8741f42fe8 | ||
|
|
665d6209d9 | ||
|
|
59fda29894 | ||
|
|
61c027f9a1 | ||
|
|
241c9b73b0 | ||
|
|
85dc1d0825 | ||
|
|
e38887aa26 | ||
|
|
54fec79945 | ||
|
|
fad0bf1c9d | ||
|
|
be2f86050f | ||
|
|
a7f941e8e4 | ||
|
|
b08c6748c7 | ||
|
|
bdd7932e07 | ||
|
|
bcf5dcf180 | ||
|
|
8b2cbf7aea | ||
|
|
277b5e43f8 | ||
|
|
7dd6a31609 | ||
|
|
86bf1515d4 | ||
|
|
bbc47b387e | ||
|
|
3953e98a77 | ||
|
|
09b8bd501d | ||
|
|
6bddaaa055 | ||
|
|
dd728d4d13 | ||
|
|
9ba8bc8f3d | ||
|
|
16dee3664b | ||
|
|
6fcf588bfd | ||
|
|
6a6c427b0e | ||
|
|
41f885d8ec | ||
|
|
7516ca8dd9 | ||
|
|
aa59a95f91 | ||
|
|
8a5161c0e8 | ||
|
|
d8ae69e861 | ||
|
|
a485d0b507 | ||
|
|
ed40b79c8d | ||
|
|
91488ae294 | ||
|
|
a22a1938c1 | ||
|
|
0f73cb9f8c | ||
|
|
f3129399de | ||
|
|
37e6c27461 | ||
|
|
d231ed3ede | ||
|
|
cf6f9028f2 | ||
|
|
7cf49d2c1a | ||
|
|
e37fb1b4bd | ||
|
|
faf5d0bf7b | ||
|
|
6fede88072 | ||
|
|
87ffbefa61 | ||
|
|
f33ae17781 | ||
|
|
2a2bae6d1a | ||
|
|
eb65e99024 | ||
|
|
52dbeb6201 | ||
|
|
fafe920b7b | ||
|
|
9d1e48ee90 | ||
|
|
3851342e1b | ||
|
|
b863c2e19d | ||
|
|
ed584d19e5 | ||
|
|
73256dcf5b | ||
|
|
c67a23c988 | ||
|
|
8f42e65ccd | ||
|
|
05deb1aff0 | ||
|
|
a74b7cdfad | ||
|
|
1c3119ee76 | ||
|
|
ca52911e42 | ||
|
|
9177bc7cef | ||
|
|
310831a26a | ||
|
|
8a486d991b | ||
|
|
b79333393b | ||
|
|
cb7062bb95 | ||
|
|
94c5b2c471 | ||
|
|
66bf26f1dc | ||
|
|
f5cc1abe75 | ||
|
|
bd4255108d | ||
|
|
06b63d1af2 | ||
|
|
2bd70fb9e6 | ||
|
|
e6034dfb14 | ||
|
|
c8ba6764c2 | ||
|
|
19337d6c05 | ||
|
|
a6ad3c2d4d | ||
|
|
b0dd885c09 | ||
|
|
2235592308 | ||
|
|
1219a16261 | ||
|
|
7949e224e0 | ||
|
|
ab2f311bb7 | ||
|
|
a5aab43666 | ||
|
|
74ebd4e572 | ||
|
|
bd29670108 | ||
|
|
9a20b4428d | ||
|
|
d7da8521ce | ||
|
|
bab3b252c1 | ||
|
|
7f05c5c2da | ||
|
|
2d5c2779c3 | ||
|
|
06db4f6556 | ||
|
|
ea5ec63a22 | ||
|
|
f46e737e77 | ||
|
|
6e63bdaafa | ||
|
|
79ccef9f7e | ||
|
|
a9653b3674 | ||
|
|
b5a188e802 | ||
|
|
38fc402b16 | ||
|
|
584d1ec1ce | ||
|
|
2ca7faa457 | ||
|
|
03806abda0 | ||
|
|
18d125430d | ||
|
|
f57f6b7745 | ||
|
|
75ff2b8f40 | ||
|
|
2ec04c0121 | ||
|
|
5145a41dac | ||
|
|
25c67f1c3e | ||
|
|
77b9181150 | ||
|
|
e4f7ded8e2 | ||
|
|
35a62f1fb1 | ||
|
|
24f2deda46 | ||
|
|
5d8af1a0e5 | ||
|
|
6d44ac1e0c | ||
|
|
fb0ebddf28 | ||
|
|
0aebaa4da1 | ||
|
|
f6baedc655 | ||
|
|
7db222664d | ||
|
|
8223f0fd64 | ||
|
|
f44ec42f51 | ||
|
|
dab75ab6a9 | ||
|
|
01672b88e1 | ||
|
|
78dc297022 | ||
|
|
c8cf75fa28 | ||
|
|
b011b4e1d8 | ||
|
|
9804a2d18d | ||
|
|
d1a5ee43e9 | ||
|
|
febecab33c | ||
|
|
99042c0979 | ||
|
|
6395e4e00b | ||
|
|
4a69907ca3 | ||
|
|
c8d682cc98 | ||
|
|
f4cc8d3a0c | ||
|
|
6ac89334fd | ||
|
|
f55d3a66aa | ||
|
|
972eafab34 | ||
|
|
fba156b271 | ||
|
|
96d538db15 | ||
|
|
b89bfa3c1c | ||
|
|
73e3f42614 | ||
|
|
e761f5e1bd | ||
|
|
4a6dd66ecd | ||
|
|
8e1c6908c6 | ||
|
|
f59e569e22 | ||
|
|
be50b24504 | ||
|
|
c181831a37 | ||
|
|
dbee62ac8c | ||
|
|
1e949b3a22 | ||
|
|
b02dcf6c53 | ||
|
|
ab7718e335 | ||
|
|
451c77d47f | ||
|
|
fa0d3d4047 | ||
|
|
397a183f65 | ||
|
|
bc8905ee7f | ||
|
|
853032ac7f | ||
|
|
3b0e282a52 | ||
|
|
bb28cb3862 | ||
|
|
d0f458bece | ||
|
|
da54a72b3e | ||
|
|
86c155d8d1 | ||
|
|
666d2d3065 | ||
|
|
01e69fb6ca | ||
|
|
30e5253acd | ||
|
|
c67ce3918b | ||
|
|
58077f2d86 | ||
|
|
098c94bccf | ||
|
|
861a3c068f | ||
|
|
3b95e8ebc0 | ||
|
|
170e427530 | ||
|
|
707c9f4ce6 | ||
|
|
dc672bf0f0 | ||
|
|
c682737505 | ||
|
|
46fa3d4345 | ||
|
|
9b7bc4b495 | ||
|
|
4385061499 | ||
|
|
e17498e68b | ||
|
|
3e298f010b | ||
|
|
30117bd121 | ||
|
|
93f33b6218 | ||
|
|
535d08a607 | ||
|
|
6ac3a49db2 | ||
|
|
c16f760d79 | ||
|
|
965c2b243f | ||
|
|
703addddb9 | ||
|
|
ab6dff5c31 | ||
|
|
2ef330c62b | ||
|
|
72e71aff40 | ||
|
|
cefd6ec752 | ||
|
|
aec3de18aa | ||
|
|
97620cb583 | ||
|
|
cf4e8190a4 | ||
|
|
8af7436f13 | ||
|
|
c53520ae56 | ||
|
|
3adcaef455 | ||
|
|
43cd9722a9 | ||
|
|
f3154e770e |
@@ -1,6 +1,7 @@
|
||||
./.github
|
||||
./build
|
||||
./coverage
|
||||
./dist
|
||||
./node_modules
|
||||
./test
|
||||
./shlink-web-client.gif
|
||||
./dist
|
||||
|
||||
44
.eslintrc
44
.eslintrc
@@ -1,44 +1,28 @@
|
||||
{
|
||||
"extends": [
|
||||
"adidas-env/browser",
|
||||
"adidas-env/module",
|
||||
"adidas-env/node",
|
||||
"adidas-es6",
|
||||
"adidas-babel",
|
||||
"adidas-react"
|
||||
"@shlinkio/js-coding-standard"
|
||||
],
|
||||
"plugins": ["jest"],
|
||||
"env": {
|
||||
"jest/globals": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"tsconfigRootDir": ".",
|
||||
"createDefaultProgram": true
|
||||
},
|
||||
"globals": {
|
||||
"process": true,
|
||||
"setImmediate": true
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "16.3"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"comma-dangle": ["error", "always-multiline"],
|
||||
"no-invalid-this": "off",
|
||||
"no-console": "warn",
|
||||
"template-curly-spacing": ["error", "never"],
|
||||
"no-warning-comments": "off",
|
||||
"no-magic-numbers": "off",
|
||||
"no-undefined": "off",
|
||||
"no-inline-comments": "off",
|
||||
"indent": ["error", 2, {
|
||||
"SwitchCase": 1
|
||||
}
|
||||
],
|
||||
"react/jsx-curly-spacing": ["error", "never"],
|
||||
"react/jsx-indent-props": ["error", 2],
|
||||
"react/jsx-first-prop-new-line": ["error", "multiline-multiprop"],
|
||||
"react/jsx-closing-bracket-location": ["error", "tag-aligned"],
|
||||
"react/no-array-index-key": "off",
|
||||
"react/no-did-update-set-state": "off",
|
||||
"react/display-name": "off"
|
||||
"max-len": ["error", {
|
||||
"code": 120,
|
||||
"ignoreStrings": true,
|
||||
"ignoreTemplateLiterals": true,
|
||||
"ignoreComments": true
|
||||
}],
|
||||
"no-mixed-operators": "off",
|
||||
"react/display-name": "off",
|
||||
"@typescript-eslint/require-array-sort-compare": "off"
|
||||
}
|
||||
}
|
||||
|
||||
5
.github/ISSUE_TEMPLATE.md
vendored
5
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,6 +1,7 @@
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be expected.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
-->
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/Bug.md
vendored
5
.github/ISSUE_TEMPLATE/Bug.md
vendored
@@ -5,9 +5,10 @@ labels: bug
|
||||
---
|
||||
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be expected.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
|
||||
With that said, please fill in the information requested next. More information might be requested once the issue is open.
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
5
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
@@ -5,9 +5,10 @@ labels: feature
|
||||
---
|
||||
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be expected.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
|
||||
With that said, please fill in the information requested next. More information might be requested once the issue is open.
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
5
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
@@ -5,9 +5,10 @@ labels: question
|
||||
---
|
||||
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be expected.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
|
||||
With that said, please fill in the information requested next. More information might be requested once the issue is open.
|
||||
|
||||
24
.github/workflows/docker-image-build.yml
vendored
Normal file
24
.github/workflows/docker-image-build.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Build docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Install buildx
|
||||
id: buildx
|
||||
uses: crazy-max/ghaction-docker-buildx@v1
|
||||
with:
|
||||
buildx-version: latest
|
||||
- name: Login to docker hub
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
- name: Build the image
|
||||
run: bash ./scripts/docker/build
|
||||
@@ -1,6 +1,6 @@
|
||||
build:
|
||||
environment:
|
||||
node: v12.11.0
|
||||
node: v12.14.1
|
||||
tools:
|
||||
external_code_coverage:
|
||||
timeout: 1200
|
||||
|
||||
66
.travis.yml
66
.travis.yml
@@ -1,40 +1,52 @@
|
||||
dist: bionic
|
||||
|
||||
language: node_js
|
||||
|
||||
node_js:
|
||||
- "12.11.0"
|
||||
branches:
|
||||
only:
|
||||
- /.*/
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- node_modules
|
||||
|
||||
services:
|
||||
- docker
|
||||
node_js:
|
||||
- '12.16.3'
|
||||
|
||||
install:
|
||||
- npm ci
|
||||
jobs:
|
||||
fast_finish: true
|
||||
allow_failures:
|
||||
- name: 'Mutation tests'
|
||||
include:
|
||||
|
||||
before_script:
|
||||
- echo "Building commit range ${TRAVIS_COMMIT_RANGE}"
|
||||
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep -E 'src\/(.*).(js|ts|jsx|tsx)$' | paste -sd ",")
|
||||
- name: 'Lint'
|
||||
install: npm ci
|
||||
script: npm run lint
|
||||
|
||||
script:
|
||||
- npm run lint
|
||||
- npm run test:ci
|
||||
- if [[ -z $TRAVIS_TAG ]]; then docker build -t shlink-web-client:test . ; fi
|
||||
- if [[ -z $TRAVIS_TAG ]]; then npm run mutate:ci ; fi
|
||||
- name: 'Unit tests'
|
||||
install: npm ci
|
||||
script: npm run test:ci
|
||||
after_success:
|
||||
- node_modules/.bin/ocular coverage/clover.xml
|
||||
|
||||
after_success:
|
||||
- node_modules/.bin/ocular coverage/clover.xml
|
||||
- name: 'Mutation tests'
|
||||
install: npm ci
|
||||
before_script:
|
||||
- echo "Building commit range ${TRAVIS_COMMIT_RANGE}"
|
||||
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/main} --name-only | grep -E 'src\/(.*).(ts|tsx)$' | paste -sd ",")
|
||||
script: npm run mutate:ci
|
||||
|
||||
# Before deploying, build dist file for current travis tag
|
||||
before_deploy:
|
||||
- npm run build ${TRAVIS_TAG#?}
|
||||
- name: 'Build docker image'
|
||||
services:
|
||||
- docker
|
||||
script: docker build -t shlink-web-client:test .
|
||||
|
||||
deploy:
|
||||
provider: releases
|
||||
api_key:
|
||||
secure: jBvPwC7EAbViaNR83rwMSt5XQDK0Iu9rgvEMa7GoyShbHcvUCCPd73Tu9quNpKi6NKsDY3INHgtch3vgonjGNGDGJ+yDyIBzXcvsAX2x3UcHpRbgY12uiINVmQxBI1+OVQB016Nm+cKC/i5Z36K4EmDbYfo+MrKndngM6AjcQFTwI8EwniIMaQgg4gNes//K8NhP5u0c3gwG+Q6jEGnq6uH3kcRgh6/epIZYpQyxjqWqKwF77sgcYj+X2Nf6XxtB5neuCi301UKLoLx8G0skh/Lm6KAIO4s9iIhIFa3UpoF21Ka0TxLpd2JxalLryCnFGlWWE6lxC9Htmc0TeRowJQlGdJXCskJ37xT9MljKY0fwNMu06VS/FUgykuCv+jP3zQu51pKu7Ew7+WeNPjautoOTu54VkdGyHcf2ThBNEyJQuiEwAQe4u7yAxY6R5ovEdvHBSIg4w1E5/Mxy5SMTCUlIAv6H7QQ1X9Z/zJm9HH5KeKz5tsHvQ/RIdSpgHXq/tC8o4Yup/LCFucXfrgvy/8pJoO1UpOlmvm62974NFfo0EG5YWwv6brUqz3QXpMjb8sWqgjltYMYJX3J7WZ34rIc+zt4NAmfhqgczaOC4pUGCiJ8jX3rMWIaQRn1AJ+5V337jL9fNDpTHny4phQjHrMJ1e0HZuNp0Xb5Q8wgqDPM=
|
||||
file: "./dist/shlink-web-client_${TRAVIS_TAG#?}_dist.zip"
|
||||
skip_cleanup: true
|
||||
on:
|
||||
tags: true
|
||||
- name: 'Publish release'
|
||||
if: tag IS present
|
||||
before_deploy: npm run build ${TRAVIS_TAG#?}
|
||||
deploy:
|
||||
provider: releases
|
||||
api_key:
|
||||
secure: jBvPwC7EAbViaNR83rwMSt5XQDK0Iu9rgvEMa7GoyShbHcvUCCPd73Tu9quNpKi6NKsDY3INHgtch3vgonjGNGDGJ+yDyIBzXcvsAX2x3UcHpRbgY12uiINVmQxBI1+OVQB016Nm+cKC/i5Z36K4EmDbYfo+MrKndngM6AjcQFTwI8EwniIMaQgg4gNes//K8NhP5u0c3gwG+Q6jEGnq6uH3kcRgh6/epIZYpQyxjqWqKwF77sgcYj+X2Nf6XxtB5neuCi301UKLoLx8G0skh/Lm6KAIO4s9iIhIFa3UpoF21Ka0TxLpd2JxalLryCnFGlWWE6lxC9Htmc0TeRowJQlGdJXCskJ37xT9MljKY0fwNMu06VS/FUgykuCv+jP3zQu51pKu7Ew7+WeNPjautoOTu54VkdGyHcf2ThBNEyJQuiEwAQe4u7yAxY6R5ovEdvHBSIg4w1E5/Mxy5SMTCUlIAv6H7QQ1X9Z/zJm9HH5KeKz5tsHvQ/RIdSpgHXq/tC8o4Yup/LCFucXfrgvy/8pJoO1UpOlmvm62974NFfo0EG5YWwv6brUqz3QXpMjb8sWqgjltYMYJX3J7WZ34rIc+zt4NAmfhqgczaOC4pUGCiJ8jX3rMWIaQRn1AJ+5V337jL9fNDpTHny4phQjHrMJ1e0HZuNp0Xb5Q8wgqDPM=
|
||||
file: "./dist/shlink-web-client_${TRAVIS_TAG#?}_dist.zip"
|
||||
skip_cleanup: true
|
||||
|
||||
168
CHANGELOG.md
168
CHANGELOG.md
@@ -4,6 +4,174 @@ 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).
|
||||
|
||||
## 2.6.0 - 2020-09-20
|
||||
|
||||
#### Added
|
||||
|
||||
* [#289](https://github.com/shlinkio/shlink-web-client/issues/289) Client and server version constraints are now links to the corresponding project release notes.
|
||||
* [#293](https://github.com/shlinkio/shlink-web-client/issues/293) Shlink versions are now always displayed in footer, hiding the server version when there's no connected server.
|
||||
* [#250](https://github.com/shlinkio/shlink-web-client/issues/250) Added support to group real time updates in fixed intervals.
|
||||
|
||||
The settings page now allows to provide the interval in which the UI should get updated, making that happen at once, with all the updates that have happened during that interval.
|
||||
|
||||
By default updates are immediately applied if real-time updates are enabled, to keep the behavior as it was.
|
||||
|
||||
* [#277](https://github.com/shlinkio/shlink-web-client/issues/277) Added highlighting capabilities to the visits line chart.
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#150](https://github.com/shlinkio/shlink-web-client/issues/150) The list of short URLs is now ordered by the creation date, showing newest results first.
|
||||
* [#248](https://github.com/shlinkio/shlink-web-client/issues/248) Numbers displayed application-wide are now prettified.
|
||||
* [#40](https://github.com/shlinkio/shlink-web-client/issues/40) Migrated project to TypeScript.
|
||||
* [#297](https://github.com/shlinkio/shlink-web-client/issues/297) Moved docker image building to github actions.
|
||||
* [#305](https://github.com/shlinkio/shlink-web-client/issues/305) Split travis build so that every step is run in a parallel job.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#295](https://github.com/shlinkio/shlink-web-client/issues/295) Fixed custom slug field not being disabled when selecting a short code length.
|
||||
* [#301](https://github.com/shlinkio/shlink-web-client/issues/301) Fixed tags visits loading not being cancelled when leaving visits page.
|
||||
|
||||
|
||||
## 2.5.1 - 2020-06-06
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#254](https://github.com/shlinkio/shlink-web-client/issues/254) Reduced duplication on code to handle mercure topics binding.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#276](https://github.com/shlinkio/shlink-web-client/issues/276) Fixed default grouping used for visits line chart, making it be dynamic depending on how old the short URL is.
|
||||
* [#280](https://github.com/shlinkio/shlink-web-client/issues/280) Fixed shlink-web-client version not being properly passed when building stable tags of the docker image.
|
||||
* [#269](https://github.com/shlinkio/shlink-web-client/issues/269) Fixed doughnut chart legends getting to big and hiding charts on mobile devices.
|
||||
|
||||
|
||||
## 2.5.0 - 2020-05-31
|
||||
|
||||
#### Added
|
||||
|
||||
* [#148](https://github.com/shlinkio/shlink-web-client/issues/148) Added support for real-time updates when consuming a Shlink version that is integrated with a mercure hub server.
|
||||
|
||||
The integration is transparent. When a server is opened, shlink-web-client will try to get the mercure info from it.
|
||||
|
||||
* If it works, it will setup the necessary `EventSource`s, dispatching redux actions when an event is pushed, which will in turn update the UI.
|
||||
* If it fails, it will assume it is either not configured or not supported by the Shlink version.
|
||||
|
||||
* [#265](https://github.com/shlinkio/shlink-web-client/issues/265) Updated tags section to allow displaying number of short URLs using every tag and number of visits for all short URLs using the tag.
|
||||
|
||||
This will work only when using Shlink v2.2.0 or above. For previous versions, the tags page will continue behaving the same.
|
||||
|
||||
* [#261](https://github.com/shlinkio/shlink-web-client/issues/261) Added new page to show visit stats by tag.
|
||||
|
||||
This new page will return a "not found" error when the server is lower than v2.2.0, as older versions do not support fetching stats by tag.
|
||||
|
||||
* [#253](https://github.com/shlinkio/shlink-web-client/issues/253) Created new settings page that will be used to define customizations in the app.
|
||||
|
||||
* [#149](https://github.com/shlinkio/shlink-web-client/issues/149) and [#198](https://github.com/shlinkio/shlink-web-client/issues/198) Added new line chart to visits and tags stats which displays amount of visits during selected time period, grouped by month, week, day or hour.
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#218](https://github.com/shlinkio/shlink-web-client/issues/218) Added back button to sections not displayed in left menu.
|
||||
* [#255](https://github.com/shlinkio/shlink-web-client/issues/255) Improved how servers and settings are persisted in the local storage.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#262](https://github.com/shlinkio/shlink-web-client/issues/262) Fixed charts displaying decimal numbers, when visits are absolute and that makes no sense.
|
||||
|
||||
|
||||
## 2.4.0 - 2020-04-10
|
||||
|
||||
#### Added
|
||||
|
||||
* [#199](https://github.com/shlinkio/shlink-web-client/issues/199) Added table to visits page which displays the information in a paginated, sortable and filterable list.
|
||||
|
||||
It also supports selecting multiple visits in the table which makes the corresponding data to be highlighted in the visits charts.
|
||||
|
||||
* [#241](https://github.com/shlinkio/shlink-web-client/issues/241) Added support to select charts bars in order to highlight related stats in other charts.
|
||||
|
||||
It also selects the visits in the new table, and you can even combine a selection in the chart and in the table.
|
||||
|
||||
* [#213](https://github.com/shlinkio/shlink-web-client/issues/213) The versions of both shlink-web-client and currently consumed Shlink server are now displayed in the footer.
|
||||
* [#221](https://github.com/shlinkio/shlink-web-client/issues/221) Improved how servers are handled, displaying meaningful errors when a not-found or a not-reachable server is tried to be loaded.
|
||||
* [#226](https://github.com/shlinkio/shlink-web-client/issues/226) Created servers can now be edited.
|
||||
* [#234](https://github.com/shlinkio/shlink-web-client/issues/234) Allowed short code length to be edited on any new short URL when using Shlink 2.1 or higher.
|
||||
* [#235](https://github.com/shlinkio/shlink-web-client/issues/235) Allowed editing the long URL for any existing short URL when suing Shlink 2.1 or higher.
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#205](https://github.com/shlinkio/shlink-web-client/issues/205) Replaced `jest-each` package by jet's native `test.each` function.
|
||||
* [#209](https://github.com/shlinkio/shlink-web-client/issues/209) Replaced `Unknown` by `Direct` for visits from undetermined referrers.
|
||||
* [#212](https://github.com/shlinkio/shlink-web-client/issues/212) Moved copy-to-clipboard next to short URL.
|
||||
* [#208](https://github.com/shlinkio/shlink-web-client/issues/208) Short URLs list paginator is now progressive.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#243](https://github.com/shlinkio/shlink-web-client/issues/243) Fixed loading state and resetting on short URL creation form.
|
||||
* [#239](https://github.com/shlinkio/shlink-web-client/issues/239) Fixed how user agents are parsed, reducing false results.
|
||||
|
||||
|
||||
## 2.3.1 - 2020-02-08
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#191](https://github.com/shlinkio/shlink-web-client/issues/191) Created `ForServerVersion` helper component which dynamically renders children if current server conditions are met.
|
||||
* [#189](https://github.com/shlinkio/shlink-web-client/issues/189) Simplified short url tags and short url deletion components and reducers, by removing redundant actions.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#193](https://github.com/shlinkio/shlink-web-client/issues/193) Fixed `maxVisits` being set to 0 when trying to reset it from having a value to `null`.
|
||||
* [#196](https://github.com/shlinkio/shlink-web-client/issues/196) Included apache `.htaccess` file which takes care of falling back to index.html when reloading the page on a client-side handled route.
|
||||
* [#179](https://github.com/shlinkio/shlink-web-client/issues/179) Ensured domain is provided to Shlink server when editing, deleting or fetching short URLs which do not belong to default domain.
|
||||
* [#202](https://github.com/shlinkio/shlink-web-client/issues/202) Fixed domain not passed when dispatching actions that affect a single short URL (edit tags, edit meta and delete), which cased the list not to be properly updated.
|
||||
|
||||
|
||||
## 2.3.0 - 2020-01-19
|
||||
|
||||
#### Added
|
||||
|
||||
72
CONTRIBUTING.md
Normal file
72
CONTRIBUTING.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Contributing
|
||||
|
||||
This file will guide you through the process of getting to project up and running, in case you want to provide coding contributions.
|
||||
|
||||
You will also see how to ensure the code fulfills the expected code checks, and how to create a pull request.
|
||||
|
||||
## System dependencies
|
||||
|
||||
The project can be run inside a docker container through provided docker-compose configuration.
|
||||
|
||||
Because of this, the only actual dependencies are [docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/).
|
||||
|
||||
## Setting up the project
|
||||
|
||||
The first thing you need to do is fork the repository, and clone it in your local machine.
|
||||
|
||||
Then you will have to follow these steps:
|
||||
|
||||
* Copy the file `docker-compose.override.yml.dist` by also removing the `dist` extension.
|
||||
* Start-up the project by running `docker-compose up`.
|
||||
|
||||
Once this is finished, you will have the project exposed in port `3000` (http://localhost:3000).
|
||||
|
||||
## Project structure
|
||||
|
||||
This project is a [react](https://reactjs.org/) & [redux](https://redux.js.org/) application, built with [typescript](https://www.typescriptlang.org/), which is distributed as a 100% client-side progressive web application.
|
||||
|
||||
This is the basic project structure:
|
||||
|
||||
```
|
||||
shlink-web-client
|
||||
├── config
|
||||
├── public
|
||||
├── scripts
|
||||
├── src
|
||||
├── test
|
||||
├── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
* `config`: It contains some configuration scripts, used during testing, linting and building of the project.
|
||||
* `public`: Will act as the application document root once built, and contains some static assets (favicons, images, etc).
|
||||
* `scripts`: It has some of the CLI scripts used to run tests or building.
|
||||
* `src`: Contains the main source code of the application, including both web components, SASS stylesheets and files with logic.
|
||||
* `test`: Contains the project tests.
|
||||
|
||||
## Running code checks
|
||||
|
||||
> Note: The `indocker` shell script is a helper used to run commands inside the docker container.
|
||||
|
||||
* `./indocker npm run lint`: Checks coding styles are fulfilled, both in JS/TS files as well as in stylesheets.
|
||||
* `./indocker npm run lint:js`: Checks coding styles are fulfilled in JS/TS files.
|
||||
* `./indocker npm run lint:css`: Checks coding styles are fulfilled in stylesheets.
|
||||
* `./indocker npm run lint:js:fix`: Fixes coding styles in JS/TS files.
|
||||
* `./indocker npm run lint:css:fix`: Fixes coding styles in stylesheets.
|
||||
* `./indocker npm run test`: Runs unit tests with Jest.
|
||||
* `./indocker npm run mutate`: Runs mutation tests with StrykerJS (this command can be very slow).
|
||||
|
||||
## Building the project
|
||||
|
||||
The source code in this project cannot be run directly in a web browser, you need to build it first.
|
||||
|
||||
* `./indocker npm run build`: Builds the project using a combination of `webpack`, `babel` and `tsc`, generating the final static files. The content is placed in the `build` folder, which is automatically created if it does not exist.
|
||||
* `./indocker npm run serve:build`: Serves the static files inside the `build` folder in port 5000 (http://localhost:5000). Useful to test the content built with previous command.
|
||||
|
||||
## Pull request process
|
||||
|
||||
In order to provide pull requests to this project, you should always start by creating a new branch, where you will make all desired changes.
|
||||
|
||||
The base branch should always be `main`, and the target branch for the pull request should also be `main`.
|
||||
|
||||
Before your branch can be merged, all the checks described in [Running code checks](#running-code-checks) have to be passing. You can verify that manually, or wait for the build to be run automatically after the pull request is created.
|
||||
@@ -1,8 +1,11 @@
|
||||
FROM node:12.11.1-alpine as node
|
||||
FROM node:12.16.3-alpine as node
|
||||
COPY . /shlink-web-client
|
||||
RUN cd /shlink-web-client && npm install && npm run build
|
||||
ARG VERSION="latest"
|
||||
ENV VERSION ${VERSION}
|
||||
RUN cd /shlink-web-client && \
|
||||
npm install && npm run build -- ${VERSION} --no-dist
|
||||
|
||||
FROM nginx:1.17.7-alpine
|
||||
FROM nginx:1.17.10-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
|
||||
|
||||
47
README.md
47
README.md
@@ -1,36 +1,47 @@
|
||||
# shlink-web-client
|
||||
|
||||
[](https://travis-ci.org/shlinkio/shlink-web-client)
|
||||
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/)
|
||||
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
||||
[](https://github.com/shlinkio/shlink-web-client/blob/master/LICENSE)
|
||||
[](https://acel.me/donate)
|
||||
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||
[](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
||||
[](https://slnk.to/donate)
|
||||
|
||||
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
||||
|
||||

|
||||
|
||||
> If you are trying to find out how to run the project in development mode or how to provide contributions, read the [CONTRIBUTING](CONTRIBUTING.md) doc.
|
||||
|
||||
## Installation
|
||||
|
||||
There are three ways in which you can use this application.
|
||||
|
||||
* The easiest way to use shlink-web-client is by just going to <https://app.shlink.io>.
|
||||
### From app.shlink.io
|
||||
|
||||
The application runs 100% in the browser, so you can safely access any shlink instance from there.
|
||||
The easiest way to use shlink-web-client is by just going to <https://app.shlink.io>.
|
||||
|
||||
* Self hosting the application yourself.
|
||||
The application runs 100% in the browser, so you can safely access any shlink instance from there.
|
||||
|
||||
Get the [latest release](https://github.com/shlinkio/shlink-web-client/releases/latest) and download the distributable zip file attached to it (`shlink-web-client_X.X.X_dist.zip`).
|
||||
### Docker image
|
||||
|
||||
The package contains static files only, so just put it in a folder and serve it with the web server of your choice.
|
||||
If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the [shlinkio/shlink-web-client](https://hub.docker.com/r/shlinkio/shlink-web-client/) image and do it.
|
||||
|
||||
Provided dist files are configured to be served from the root of your domain. If you need to serve shlink-web-client from a subpath, you will have to build it yourself following [these steps](#serve-shlink-in-subpath).
|
||||
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the static app on port 80.
|
||||
|
||||
* Using the official [docker image](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||
### Self-hosted
|
||||
|
||||
If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the `shlinkio/shlink-web-client` image and do it.
|
||||
If you want to self-host it yourself, get the [latest release](https://github.com/shlinkio/shlink-web-client/releases/latest) and download the distributable zip file attached to it (`shlink-web-client_X.X.X_dist.zip`).
|
||||
|
||||
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the static app on port 80.
|
||||
The package contains static files only, so just put it in a folder and serve it with the web server of your choice.
|
||||
|
||||
**Considerations**:
|
||||
|
||||
* Provided dist files are configured to be served from the root of your domain. If you need to serve shlink-web-client from a subpath, you will have to build it yourself following [these steps](#serve-shlink-in-subpath).
|
||||
* The app has a client-side router that handles dynamic paths. Because of that, you need to configure your web server to fall-back to the `index.html` file when requested files do not exist.
|
||||
* If you use Apache, you are covered, since the project includes an `.htaccess` file which already does this.
|
||||
* If you use nginx, you can [see how it's done](config/docker/nginx.conf) for the docker image and do the same.
|
||||
|
||||
## Pre-configuring servers
|
||||
|
||||
@@ -59,6 +70,14 @@ If you are using the shlink-web-client docker image, you can mount the `servers.
|
||||
|
||||
docker run --name shlink-web-client -p 8000:80 -v ${PWD}/servers.json:/usr/share/nginx/html/servers.json shlinkio/shlink-web-client
|
||||
|
||||
> **Be extremely careful when using this feature.**
|
||||
>
|
||||
> Due to shlink-web-client's client-side nature, the file needs to be accessible from the browser.
|
||||
>
|
||||
> Because of that, make sure you use this only when you self-host shlink-web-client, and you know only trusted people will have access to it.
|
||||
>
|
||||
> Failing to do this could cause your API keys to end up being exposed.
|
||||
|
||||
## Serve project in subpath
|
||||
|
||||
Official distributable files have been built so that they are served from the root of a domain.
|
||||
|
||||
@@ -5,7 +5,7 @@ server {
|
||||
index index.html;
|
||||
|
||||
# When requesting static paths with extension, try them, and return a 404 if not found
|
||||
location ~ .+\.(css|js|html|png|jpg|jpeg|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
|
||||
location ~* .+\.(css|js|html|png|jpe?g|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -10,7 +11,7 @@ const { NODE_ENV } = process.env;
|
||||
|
||||
if (!NODE_ENV) {
|
||||
throw new Error(
|
||||
'The NODE_ENV environment variable is required but was not specified.'
|
||||
'The NODE_ENV environment variable is required but was not specified.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +37,7 @@ dotenvFiles.forEach((dotenvFile) => {
|
||||
require('dotenv-expand')(
|
||||
require('dotenv').config({
|
||||
path: dotenvFile,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -82,7 +83,7 @@ function getClientEnvironment(publicUrl) {
|
||||
// This should only be used as an escape hatch. Normally you would put
|
||||
// images into the `src` and `import` them in code to get their paths.
|
||||
PUBLIC_URL: publicUrl,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Stringify all values so we can feed into Webpack DefinePlugin
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -75,7 +75,7 @@ module.exports = (webpackEnv) => {
|
||||
loader: MiniCssExtractPlugin.loader,
|
||||
options: Object.assign(
|
||||
{},
|
||||
shouldUseRelativeAssetPaths ? { publicPath: '../../' } : undefined
|
||||
shouldUseRelativeAssetPaths ? { publicPath: '../../' } : undefined,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -227,7 +227,7 @@ module.exports = (webpackEnv) => {
|
||||
|
||||
// Turned on because emoji and regex is not minified properly using default
|
||||
// https://github.com/facebook/create-react-app/issues/2488
|
||||
ascii_only: true,
|
||||
ascii_only: true, // eslint-disable-line @typescript-eslint/camelcase
|
||||
},
|
||||
},
|
||||
|
||||
@@ -281,7 +281,7 @@ module.exports = (webpackEnv) => {
|
||||
modules: [ 'node_modules' ].concat(
|
||||
|
||||
// It is guaranteed to exist because we tweak it in `env.js`
|
||||
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
|
||||
process.env.NODE_PATH.split(path.delimiter).filter(Boolean),
|
||||
),
|
||||
|
||||
// These are the reasonable defaults supported by the Node ecosystem.
|
||||
@@ -372,7 +372,7 @@ module.exports = (webpackEnv) => {
|
||||
loader: require.resolve('babel-loader'),
|
||||
options: {
|
||||
customize: require.resolve(
|
||||
'babel-preset-react-app/webpack-overrides'
|
||||
'babel-preset-react-app/webpack-overrides',
|
||||
),
|
||||
|
||||
plugins: [
|
||||
@@ -470,7 +470,7 @@ module.exports = (webpackEnv) => {
|
||||
importLoaders: 2,
|
||||
sourceMap: isEnvProduction && shouldUseSourceMap,
|
||||
},
|
||||
'sass-loader'
|
||||
'sass-loader',
|
||||
),
|
||||
|
||||
// Don't consider CSS imports dead code even if the
|
||||
@@ -491,7 +491,7 @@ module.exports = (webpackEnv) => {
|
||||
modules: true,
|
||||
getLocalIdent: getCSSModuleLocalIdent,
|
||||
},
|
||||
'sass-loader'
|
||||
'sass-loader',
|
||||
),
|
||||
},
|
||||
|
||||
@@ -544,8 +544,8 @@ module.exports = (webpackEnv) => {
|
||||
minifyURLs: true,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
|
||||
// Inlines the webpack runtime script. This script is too small to warrant
|
||||
@@ -668,7 +668,7 @@ module.exports = (webpackEnv) => {
|
||||
fs: 'empty',
|
||||
net: 'empty',
|
||||
tls: 'empty',
|
||||
child_process: 'empty',
|
||||
child_process: 'empty', // eslint-disable-line @typescript-eslint/camelcase
|
||||
},
|
||||
|
||||
// Turn off performance processing because we utilize
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
||||
|
||||
const fs = require('fs');
|
||||
const errorOverlayMiddleware = require('react-dev-utils/errorOverlayMiddleware');
|
||||
|
||||
@@ -3,7 +3,7 @@ version: '3'
|
||||
services:
|
||||
shlink_web_client_node:
|
||||
container_name: shlink_web_client_node
|
||||
image: node:12.11.0-alpine
|
||||
image: node:12.16.3-alpine
|
||||
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||
volumes:
|
||||
- ./:/home/shlink/www
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
module.exports = {
|
||||
coverageDirectory: '<rootDir>/coverage',
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.js',
|
||||
'src/**/*.{js,ts,tsx}',
|
||||
'!src/registerServiceWorker.js',
|
||||
'!src/index.js',
|
||||
'!src/reducers/index.js',
|
||||
'!src/**/provideServices.js',
|
||||
'!src/container/*.js',
|
||||
'!src/index.ts',
|
||||
'!src/reducers/index.ts',
|
||||
'!src/**/provideServices.ts',
|
||||
'!src/container/*.ts',
|
||||
],
|
||||
resolver: 'jest-pnp-resolver',
|
||||
setupFiles: [
|
||||
@@ -17,9 +17,9 @@ module.exports = {
|
||||
testEnvironment: 'jsdom',
|
||||
testURL: 'http://localhost',
|
||||
transform: {
|
||||
'^.+\\.(js|jsx|mjs)$': '<rootDir>/node_modules/babel-jest',
|
||||
'^.+\\.(ts|tsx|js|jsx|mjs)$': '<rootDir>/node_modules/babel-jest',
|
||||
'^.+\\.css$': '<rootDir>/config/jest/cssTransform.js',
|
||||
'^(?!.*\\.(js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
|
||||
'^(?!.*\\.(ts|tsx|js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
|
||||
|
||||
11994
package-lock.json
generated
11994
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
140
package.json
140
package.json
@@ -1,14 +1,13 @@
|
||||
{
|
||||
"name": "shlink-web-client",
|
||||
"description": "A React-based progressive web application for shlink",
|
||||
"version": "2.3.0",
|
||||
"private": false,
|
||||
"homepage": "",
|
||||
"repository": "https://github.com/shlinkio/shlink-web-client",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"lint": "npm run lint:js && npm run lint:css",
|
||||
"lint:js": "eslint src test scripts config",
|
||||
"lint:js": "eslint --ext .js,.ts,.tsx src test scripts config",
|
||||
"lint:js:fix": "npm run lint:js -- --fix",
|
||||
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
||||
"lint:css:fix": "npm run lint:css -- --fix",
|
||||
@@ -22,59 +21,81 @@
|
||||
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.11.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.25",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.11.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.11.2",
|
||||
"@fortawesome/react-fontawesome": "^0.1.5",
|
||||
"@fortawesome/fontawesome-free": "^5.14.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.30",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.14.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.14.0",
|
||||
"@fortawesome/react-fontawesome": "^0.1.11",
|
||||
"array-filter": "^1.0.0",
|
||||
"array-map": "^0.0.0",
|
||||
"array-reduce": "^0.0.0",
|
||||
"axios": "^0.19.0",
|
||||
"bootstrap": "^4.3.1",
|
||||
"bottlejs": "^1.7.2",
|
||||
"chart.js": "^2.8.0",
|
||||
"axios": "^0.20.0",
|
||||
"bootstrap": "^4.5.2",
|
||||
"bottlejs": "^2.0.0",
|
||||
"bowser": "^2.10.0",
|
||||
"chart.js": "^2.9.3",
|
||||
"classnames": "^2.2.6",
|
||||
"compare-versions": "^3.5.1",
|
||||
"compare-versions": "^3.6.0",
|
||||
"csvjson": "^5.1.0",
|
||||
"leaflet": "^1.5.1",
|
||||
"moment": "^2.24.0",
|
||||
"event-source-polyfill": "^1.0.17",
|
||||
"leaflet": "^1.7.1",
|
||||
"moment": "^2.27.0",
|
||||
"promise": "^8.0.3",
|
||||
"prop-types": "^15.7.2",
|
||||
"qs": "^6.9.0",
|
||||
"ramda": "^0.26.1",
|
||||
"react": "^16.10.2",
|
||||
"react-autosuggest": "^9.4.3",
|
||||
"react-chartjs-2": "^2.8.0",
|
||||
"react-color": "^2.17.3",
|
||||
"react-copy-to-clipboard": "^5.0.1",
|
||||
"qs": "^6.9.4",
|
||||
"ramda": "^0.27.1",
|
||||
"react": "^16.13.1",
|
||||
"react-autosuggest": "^10.0.2",
|
||||
"react-chartjs-2": "^2.10.0",
|
||||
"react-color": "^2.18.1",
|
||||
"react-copy-to-clipboard": "^5.0.2",
|
||||
"react-datepicker": "~1.5.0",
|
||||
"react-dom": "^16.10.2",
|
||||
"react-external-link": "^1.0.0",
|
||||
"react-leaflet": "^2.4.0",
|
||||
"react-moment": "^0.9.5",
|
||||
"react-redux": "^7.1.1",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"react-swipeable": "^5.4.0",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-external-link": "^1.1.1",
|
||||
"react-leaflet": "^2.7.0",
|
||||
"react-moment": "^0.9.7",
|
||||
"react-redux": "^7.2.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-swipeable": "^5.5.1",
|
||||
"react-tagsinput": "^3.19.0",
|
||||
"reactstrap": "^8.0.1",
|
||||
"redux": "^4.0.4",
|
||||
"redux-actions": "^2.6.5",
|
||||
"redux-localstorage-simple": "^2.2.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"uuid": "^3.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.6.2",
|
||||
"@stryker-mutator/core": "^2.1.0",
|
||||
"@stryker-mutator/html-reporter": "^2.1.0",
|
||||
"@stryker-mutator/javascript-mutator": "^2.1.0",
|
||||
"@stryker-mutator/jest-runner": "^2.1.0",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.4",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.11.0",
|
||||
"@shlinkio/eslint-config-js-coding-standard": "~1.1.0",
|
||||
"@stryker-mutator/core": "^3.2.4",
|
||||
"@stryker-mutator/typescript": "^3.2.4",
|
||||
"@stryker-mutator/jest-runner": "^3.2.4",
|
||||
"@svgr/webpack": "^4.3.3",
|
||||
"@types/chart.js": "^2.9.24",
|
||||
"@types/classnames": "^2.2.10",
|
||||
"@types/enzyme": "^3.10.5",
|
||||
"@types/jest": "^26.0.10",
|
||||
"@types/leaflet": "^1.5.17",
|
||||
"@types/moment": "^2.13.0",
|
||||
"@types/qs": "^6.9.4",
|
||||
"@types/ramda": "^0.27.14",
|
||||
"@types/react": "^16.9.46",
|
||||
"@types/react-autosuggest": "^10.0.0",
|
||||
"@types/react-color": "^2.17.4",
|
||||
"@types/react-copy-to-clipboard": "^4.3.0",
|
||||
"@types/react-datepicker": "~1.8.0",
|
||||
"@types/react-dom": "^16.9.8",
|
||||
"@types/react-leaflet": "^2.5.2",
|
||||
"@types/react-redux": "^7.1.9",
|
||||
"@types/react-router-dom": "^5.1.5",
|
||||
"@types/react-tagsinput": "^3.19.7",
|
||||
"@types/reactstrap": "^8.5.1",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"adm-zip": "^0.4.13",
|
||||
"autoprefixer": "^9.6.3",
|
||||
"babel-core": "7.0.0-bridge.0",
|
||||
"babel-eslint": "^10.0.3",
|
||||
"babel-jest": "^24.9.0",
|
||||
"babel-jest": "^26.3.0",
|
||||
"babel-loader": "^8.0.6",
|
||||
"babel-plugin-named-asset-import": "^0.3.4",
|
||||
"babel-preset-react-app": "^9.0.2",
|
||||
@@ -85,30 +106,20 @@
|
||||
"css-loader": "^3.2.0",
|
||||
"dotenv": "^8.1.0",
|
||||
"dotenv-expand": "^5.1.0",
|
||||
"enzyme": "^3.10.0",
|
||||
"enzyme-adapter-react-16": "^1.14.0",
|
||||
"eslint": "^5.11.1",
|
||||
"eslint-config-adidas-babel": "^1.1.0",
|
||||
"eslint-config-adidas-env": "^1.1.0",
|
||||
"eslint-config-adidas-es6": "^1.2.0",
|
||||
"eslint-config-adidas-react": "^1.1.1",
|
||||
"enzyme": "^3.11.0",
|
||||
"enzyme-adapter-react-16": "^1.15.2",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-loader": "^3.0.2",
|
||||
"eslint-plugin-import": "^2.18.2",
|
||||
"eslint-plugin-jest": "^22.17.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.2.3",
|
||||
"eslint-plugin-promise": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.16.0",
|
||||
"file-loader": "^4.2.0",
|
||||
"fork-ts-checker-webpack-plugin-alt": "^0.4.14",
|
||||
"fs-extra": "^8.1.0",
|
||||
"html-webpack-plugin": "^4.0.0-beta.8",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^24.9.0",
|
||||
"jest-each": "^24.9.0",
|
||||
"jest-pnp-resolver": "^1.2.1",
|
||||
"jest-resolve": "^24.9.0",
|
||||
"jest": "^26.4.2",
|
||||
"jest-pnp-resolver": "^1.2.2",
|
||||
"jest-resolve": "^26.4.0",
|
||||
"mini-css-extract-plugin": "^0.8.0",
|
||||
"node-sass": "^4.12.0",
|
||||
"node-sass": "^4.14.1",
|
||||
"object-assign": "^4.1.1",
|
||||
"ocular.js": "^0.1.0",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.3",
|
||||
@@ -119,20 +130,23 @@
|
||||
"postcss-preset-env": "^6.7.0",
|
||||
"postcss-safe-parser": "^4.0.1",
|
||||
"raf": "^3.4.1",
|
||||
"react-app-polyfill": "^1.0.4",
|
||||
"react-app-polyfill": "^1.0.6",
|
||||
"react-dev-utils": "^9.1.0",
|
||||
"resolve": "^1.12.0",
|
||||
"sass-loader": "^8.0.0",
|
||||
"serve": "^11.2.0",
|
||||
"sass-loader": "^10.0.2",
|
||||
"serve": "^11.3.2",
|
||||
"stryker-cli": "^1.0.0",
|
||||
"style-loader": "^1.0.0",
|
||||
"stylelint": "^9.10.1",
|
||||
"stylelint-config-adidas": "^1.2.1",
|
||||
"style-loader": "^1.2.1",
|
||||
"stylelint": "^13.7.0",
|
||||
"stylelint-config-adidas": "^1.3.0",
|
||||
"stylelint-config-adidas-bem": "^1.2.0",
|
||||
"stylelint-config-recommended-scss": "^4.0.0",
|
||||
"stylelint-scss": "^3.11.1",
|
||||
"stylelint-config-recommended-scss": "^4.2.0",
|
||||
"stylelint-scss": "^3.18.0",
|
||||
"sw-precache-webpack-plugin": "^0.11.5",
|
||||
"terser-webpack-plugin": "^2.1.2",
|
||||
"ts-jest": "^26.3.0",
|
||||
"ts-mockery": "^1.2.0",
|
||||
"typescript": "^3.9.7",
|
||||
"url-loader": "^2.2.0",
|
||||
"webpack": "^4.41.0",
|
||||
"webpack-dev-server": "^3.8.2",
|
||||
@@ -143,6 +157,10 @@
|
||||
"babel": {
|
||||
"presets": [
|
||||
"react-app"
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-optional-chaining",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator"
|
||||
]
|
||||
},
|
||||
"browserslist": [
|
||||
|
||||
16
public/.htaccess
Normal file
16
public/.htaccess
Normal file
@@ -0,0 +1,16 @@
|
||||
RewriteEngine on
|
||||
RewriteBase /
|
||||
|
||||
# do not do anything for already existing files
|
||||
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -l [OR]
|
||||
RewriteCond %{REQUEST_FILENAME} -d
|
||||
RewriteRule (.*) - [L]
|
||||
|
||||
# if request is no valid file NOR directory
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
# if static asset do not do anything
|
||||
RewriteRule (.*)(css|js|html|png|jpe?g|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) - [NC,L,R=404]
|
||||
# everything else should be redirected to /index.html so it can be routed by it
|
||||
RewriteRule (.*) /index.html [L]
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable no-console */
|
||||
/* 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';
|
||||
@@ -14,7 +14,6 @@ process.on('unhandledRejection', (err) => {
|
||||
// Ensure environment variables are read.
|
||||
require('../config/env');
|
||||
|
||||
const path = require('path');
|
||||
const chalk = require('chalk');
|
||||
const fs = require('fs-extra');
|
||||
const webpack = require('webpack');
|
||||
@@ -22,7 +21,6 @@ const bfj = require('bfj');
|
||||
const AdmZip = require('adm-zip');
|
||||
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
|
||||
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
|
||||
const printHostingInstructions = require('react-dev-utils/printHostingInstructions');
|
||||
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
|
||||
const printBuildError = require('react-dev-utils/printBuildError');
|
||||
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
|
||||
@@ -30,7 +28,6 @@ const paths = require('../config/paths');
|
||||
const configFactory = require('../config/webpack.config');
|
||||
|
||||
const { measureFileSizesBeforeBuild, printFileSizesAfterBuild } = FileSizeReporter;
|
||||
const useYarn = fs.existsSync(paths.yarnLockFile);
|
||||
|
||||
// These sizes are pretty large. We'll warn for bundles exceeding them.
|
||||
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; // eslint-disable-line
|
||||
@@ -46,7 +43,9 @@ if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
|
||||
// Process CLI arguments
|
||||
const argvSliceStart = 2;
|
||||
const argv = process.argv.slice(argvSliceStart);
|
||||
const writeStatsJson = argv.indexOf('--stats') !== -1;
|
||||
const writeStatsJson = argv.includes('--stats');
|
||||
const withoutDist = argv.includes('--no-dist');
|
||||
const { version, hasVersion } = getVersionFromArgs(argv);
|
||||
|
||||
// Generate configuration
|
||||
const config = configFactory('production');
|
||||
@@ -76,15 +75,16 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||
console.log(
|
||||
`\nSearch for the ${
|
||||
chalk.underline(chalk.yellow('keywords'))
|
||||
} to learn more about each warning.`
|
||||
} to learn more about each warning.`,
|
||||
);
|
||||
console.log(
|
||||
`To ignore, add ${
|
||||
chalk.cyan('// eslint-disable-next-line')
|
||||
} to the line before.\n`
|
||||
} to the line before.\n`,
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green('Compiled successfully.\n'));
|
||||
hasVersion && replaceVersionPlaceholder(version);
|
||||
}
|
||||
|
||||
console.log('File sizes after gzip:\n');
|
||||
@@ -93,31 +93,17 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||
previousFileSizes,
|
||||
paths.appBuild,
|
||||
WARN_AFTER_BUNDLE_GZIP_SIZE,
|
||||
WARN_AFTER_CHUNK_GZIP_SIZE
|
||||
WARN_AFTER_CHUNK_GZIP_SIZE,
|
||||
);
|
||||
console.log();
|
||||
|
||||
const appPackage = require(paths.appPackageJson);
|
||||
|
||||
const { publicUrl } = paths;
|
||||
const { output: { publicPath } } = config;
|
||||
const buildFolder = path.relative(process.cwd(), paths.appBuild);
|
||||
|
||||
printHostingInstructions(
|
||||
appPackage,
|
||||
publicUrl,
|
||||
publicPath,
|
||||
buildFolder,
|
||||
useYarn
|
||||
);
|
||||
},
|
||||
(err) => {
|
||||
console.log(chalk.red('Failed to compile.\n'));
|
||||
printBuildError(err);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
)
|
||||
.then(zipDist)
|
||||
.then(() => hasVersion && !withoutDist && zipDist(version))
|
||||
.catch((err) => {
|
||||
if (err && err.message) {
|
||||
console.log(err.message);
|
||||
@@ -147,7 +133,7 @@ function build(previousFileSizes) {
|
||||
});
|
||||
} else {
|
||||
messages = formatWebpackMessages(
|
||||
stats.toJson({ all: false, warnings: true, errors: true })
|
||||
stats.toJson({ all: false, warnings: true, errors: true }),
|
||||
);
|
||||
}
|
||||
if (messages.errors.length) {
|
||||
@@ -168,8 +154,8 @@ function build(previousFileSizes) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'\nTreating warnings as errors because process.env.CI = true.\n' +
|
||||
'Most CI servers set it automatically.\n'
|
||||
)
|
||||
'Most CI servers set it automatically.\n',
|
||||
),
|
||||
);
|
||||
|
||||
return reject(new Error(messages.warnings.join('\n\n')));
|
||||
@@ -200,15 +186,7 @@ function copyPublicFolder() {
|
||||
});
|
||||
}
|
||||
|
||||
function zipDist() {
|
||||
const minArgsToContainVersion = 3;
|
||||
|
||||
// If no version was provided, do nothing
|
||||
if (process.argv.length < minArgsToContainVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [ , , version ] = process.argv;
|
||||
function zipDist(version) {
|
||||
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
|
||||
|
||||
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
|
||||
@@ -226,4 +204,24 @@ function zipDist() {
|
||||
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');
|
||||
}
|
||||
|
||||
26
scripts/docker/build
Executable file
26
scripts/docker/build
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -ex
|
||||
|
||||
# PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64"
|
||||
PLATFORMS="linux/amd64"
|
||||
DOCKER_IMAGE="shlinkio/shlink-web-client"
|
||||
|
||||
if [[ "$GITHUB_REF" == *"main"* ]]; 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"
|
||||
else
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
TAGS="-t ${DOCKER_IMAGE}:${VERSION}"
|
||||
|
||||
# Push stable tag only if this is not an alpha or beta release
|
||||
[[ $GITHUB_REF != *"alpha"* && $GITHUB_REF != *"beta"* ]] && TAGS="${TAGS} -t ${DOCKER_IMAGE}:stable"
|
||||
|
||||
docker buildx build --push \
|
||||
--build-arg VERSION=${VERSION} \
|
||||
--platform ${PLATFORMS} \
|
||||
${TAGS} .
|
||||
fi
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable no-console */
|
||||
/* eslint-disable no-console, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
|
||||
|
||||
// Do this as the first thing so that any code reading it knows the right env.
|
||||
process.env.BABEL_ENV = 'development';
|
||||
@@ -49,15 +49,15 @@ if (process.env.HOST) {
|
||||
console.log(
|
||||
chalk.cyan(
|
||||
`Attempting to bind to HOST environment variable: ${chalk.yellow(
|
||||
chalk.bold(process.env.HOST)
|
||||
)}`
|
||||
)
|
||||
chalk.bold(process.env.HOST),
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
'If this was unintentional, check that you haven\'t mistakenly set it in your shell.'
|
||||
'If this was unintentional, check that you haven\'t mistakenly set it in your shell.',
|
||||
);
|
||||
console.log(
|
||||
`Learn more here: ${chalk.yellow('http://bit.ly/CRA-advanced-config')}`
|
||||
`Learn more here: ${chalk.yellow('http://bit.ly/CRA-advanced-config')}`,
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
@@ -91,7 +91,7 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||
// Serve webpack assets generated by the compiler over a web server.
|
||||
const serverConfig = createDevServerConfig(
|
||||
proxyConfig,
|
||||
urls.lanUrlForConfig
|
||||
urls.lanUrlForConfig,
|
||||
);
|
||||
const devServer = new WebpackDevServer(compiler, serverConfig);
|
||||
|
||||
|
||||
12
shlink-web-client.d.ts
vendored
Normal file
12
shlink-web-client.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
declare module 'event-source-polyfill' {
|
||||
export const EventSourcePolyfill: any;
|
||||
}
|
||||
|
||||
declare module 'csvjson' {
|
||||
export declare class CsvJson {
|
||||
public toObject<T>(content: string): T[];
|
||||
public toCSV<T>(data: T[], options: { headers: string }): string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module '*.png'
|
||||
BIN
shlink-web-client.gif
Normal file
BIN
shlink-web-client.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 MiB |
21
src/App.js
21
src/App.js
@@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import './App.scss';
|
||||
import NotFound from './common/NotFound';
|
||||
|
||||
const App = (MainHeader, Home, MenuLayout, CreateServer) => () => (
|
||||
<div className="container-fluid app-container">
|
||||
<MainHeader />
|
||||
|
||||
<div className="app">
|
||||
<Switch>
|
||||
<Route exact path="/server/create" component={CreateServer} />
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="/server/:serverId" component={MenuLayout} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default App;
|
||||
16
src/App.scss
16
src/App.scss
@@ -8,3 +8,19 @@
|
||||
padding-top: $headerHeight;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.shlink-wrapper {
|
||||
min-height: 100%;
|
||||
padding-bottom: $footer-height + $footer-margin;
|
||||
margin-bottom: -($footer-height + $footer-margin);
|
||||
}
|
||||
|
||||
.shlink-footer {
|
||||
height: $footer-height;
|
||||
margin-top: $footer-margin;
|
||||
padding: 0;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 0 15px;
|
||||
}
|
||||
}
|
||||
|
||||
52
src/App.tsx
Normal file
52
src/App.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { useEffect, FC } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import NotFound from './common/NotFound';
|
||||
import { ServersMap } from './servers/data';
|
||||
import './App.scss';
|
||||
|
||||
interface AppProps {
|
||||
fetchServers: Function;
|
||||
servers: ServersMap;
|
||||
}
|
||||
|
||||
const App = (
|
||||
MainHeader: FC,
|
||||
Home: FC,
|
||||
MenuLayout: FC,
|
||||
CreateServer: FC,
|
||||
EditServer: FC,
|
||||
Settings: FC,
|
||||
ShlinkVersions: FC,
|
||||
) => ({ fetchServers, servers }: AppProps) => {
|
||||
// On first load, try to fetch the remote servers if the list is empty
|
||||
useEffect(() => {
|
||||
if (Object.keys(servers).length === 0) {
|
||||
fetchServers();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container-fluid app-container">
|
||||
<MainHeader />
|
||||
|
||||
<div className="app">
|
||||
<div className="shlink-wrapper">
|
||||
<Switch>
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route exact path="/settings" component={Settings} />
|
||||
<Route exact path="/server/create" component={CreateServer} />
|
||||
<Route exact path="/server/:serverId/edit" component={EditServer} />
|
||||
<Route path="/server/:serverId" component={MenuLayout} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<div className="shlink-footer text-center text-md-right">
|
||||
<ShlinkVersions />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -1,73 +0,0 @@
|
||||
import { faList as listIcon, faLink as createIcon, faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import './AsideMenu.scss';
|
||||
|
||||
const defaultProps = {
|
||||
className: '',
|
||||
showOnMobile: false,
|
||||
};
|
||||
const propTypes = {
|
||||
selectedServer: serverType,
|
||||
className: PropTypes.string,
|
||||
showOnMobile: PropTypes.bool,
|
||||
};
|
||||
|
||||
const AsideMenu = (DeleteServerButton) => {
|
||||
const AsideMenu = ({ selectedServer, className, showOnMobile }) => {
|
||||
const serverId = selectedServer ? selectedServer.id : '';
|
||||
const asideClass = classnames('aside-menu', className, {
|
||||
'aside-menu--hidden': !showOnMobile,
|
||||
});
|
||||
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
|
||||
|
||||
return (
|
||||
<aside className={asideClass}>
|
||||
<nav className="nav flex-column aside-menu__nav">
|
||||
<NavLink
|
||||
className="aside-menu__item"
|
||||
activeClassName="aside-menu__item--selected"
|
||||
to={`/server/${serverId}/list-short-urls/1`}
|
||||
isActive={shortUrlsIsActive}
|
||||
>
|
||||
<FontAwesomeIcon icon={listIcon} />
|
||||
<span className="aside-menu__item-text">List short URLs</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
className="aside-menu__item"
|
||||
activeClassName="aside-menu__item--selected"
|
||||
to={`/server/${serverId}/create-short-url`}
|
||||
>
|
||||
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
|
||||
<span className="aside-menu__item-text">Create short URL</span>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className="aside-menu__item"
|
||||
activeClassName="aside-menu__item--selected"
|
||||
to={`/server/${serverId}/manage-tags`}
|
||||
>
|
||||
<FontAwesomeIcon icon={tagsIcon} />
|
||||
<span className="aside-menu__item-text">Manage tags</span>
|
||||
</NavLink>
|
||||
|
||||
<DeleteServerButton
|
||||
className="aside-menu__item aside-menu__item--danger"
|
||||
server={selectedServer}
|
||||
/>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
AsideMenu.defaultProps = defaultProps;
|
||||
AsideMenu.propTypes = propTypes;
|
||||
|
||||
return AsideMenu;
|
||||
};
|
||||
|
||||
export default AsideMenu;
|
||||
@@ -18,7 +18,7 @@ $asideMenuMobileWidth: 280px;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 30px 15px 15px;
|
||||
border-right: 1px solid #eee;
|
||||
border-right: 1px solid #eeeeee;
|
||||
}
|
||||
|
||||
@media (max-width: $smMax) {
|
||||
@@ -51,27 +51,30 @@ $asideMenuMobileWidth: 280px;
|
||||
}
|
||||
|
||||
.aside-menu__item--selected {
|
||||
color: #fff;
|
||||
color: #ffffff;
|
||||
background-color: $mainColor;
|
||||
}
|
||||
|
||||
.aside-menu__item--selected:hover {
|
||||
color: #fff;
|
||||
color: #ffffff;
|
||||
background-color: $mainColor;
|
||||
}
|
||||
|
||||
.aside-menu__item--divider {
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid #eeeeee;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.aside-menu__item--danger {
|
||||
color: $dangerColor;
|
||||
}
|
||||
|
||||
.aside-menu__item--push {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.aside-menu__item--danger:hover {
|
||||
color: #fff;
|
||||
color: #ffffff;
|
||||
background-color: $dangerColor;
|
||||
}
|
||||
|
||||
|
||||
77
src/common/AsideMenu.tsx
Normal file
77
src/common/AsideMenu.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
faList as listIcon,
|
||||
faLink as createIcon,
|
||||
faTags as tagsIcon,
|
||||
faPen as editIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { FC } from 'react';
|
||||
import { NavLink, NavLinkProps } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
import { Location } from 'history';
|
||||
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
||||
import { ServerWithId } from '../servers/data';
|
||||
import './AsideMenu.scss';
|
||||
|
||||
export interface AsideMenuProps {
|
||||
selectedServer: ServerWithId;
|
||||
className?: string;
|
||||
showOnMobile?: boolean;
|
||||
}
|
||||
|
||||
interface AsideMenuItemProps extends NavLinkProps {
|
||||
to: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
|
||||
<NavLink
|
||||
className={classNames('aside-menu__item', className)}
|
||||
activeClassName="aside-menu__item--selected"
|
||||
to={to}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||
{ selectedServer, className, showOnMobile = false }: AsideMenuProps,
|
||||
) => {
|
||||
const serverId = selectedServer ? selectedServer.id : '';
|
||||
const asideClass = classNames('aside-menu', className, {
|
||||
'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('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
||||
<FontAwesomeIcon icon={listIcon} />
|
||||
<span className="aside-menu__item-text">List short URLs</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/create-short-url')}>
|
||||
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
|
||||
<span className="aside-menu__item-text">Create short URL</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/manage-tags')}>
|
||||
<FontAwesomeIcon icon={tagsIcon} />
|
||||
<span className="aside-menu__item-text">Manage tags</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
||||
<FontAwesomeIcon icon={editIcon} />
|
||||
<span className="aside-menu__item-text">Edit this server</span>
|
||||
</AsideMenuItem>
|
||||
<DeleteServerButton
|
||||
className="aside-menu__item aside-menu__item--danger"
|
||||
textClassName="aside-menu__item-text"
|
||||
server={selectedServer}
|
||||
/>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default AsideMenu;
|
||||
@@ -1,30 +1,31 @@
|
||||
import React from 'react';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import './ErrorHandler.scss';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Button } from 'reactstrap';
|
||||
import './ErrorHandler.scss';
|
||||
|
||||
// FIXME Replace with typescript: (window, console)
|
||||
const ErrorHandler = ({ location }, { error }) => class ErrorHandler extends React.Component {
|
||||
static propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
interface ErrorHandlerState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
const ErrorHandler = (
|
||||
{ location }: Window,
|
||||
{ error }: Console,
|
||||
) => class ErrorHandler extends React.Component<any, ErrorHandlerState> {
|
||||
public constructor(props: object) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
public static getDerivedStateFromError(): ErrorHandlerState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(e) {
|
||||
public componentDidCatch(e: Error): void {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
error(e);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
public render(): ReactNode | undefined {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-handler">
|
||||
@@ -1,52 +0,0 @@
|
||||
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { isEmpty, values } from 'ramda';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ListGroup, ListGroupItem } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import './Home.scss';
|
||||
|
||||
export default class Home extends React.Component {
|
||||
static propTypes = {
|
||||
resetSelectedServer: PropTypes.func,
|
||||
servers: PropTypes.object,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.resetSelectedServer();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { servers: { list, loading } } = this.props;
|
||||
const servers = values(list);
|
||||
const hasServers = !isEmpty(servers);
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<h1 className="home__title">Welcome to Shlink</h1>
|
||||
<h5 className="home__intro">
|
||||
{!loading && hasServers && <span>Please, select a server.</span>}
|
||||
{!loading && !hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
|
||||
{loading && <span>Trying to load servers...</span>}
|
||||
</h5>
|
||||
|
||||
{!loading && hasServers && (
|
||||
<ListGroup className="home__servers-list">
|
||||
{servers.map(({ name, id }) => (
|
||||
<ListGroupItem
|
||||
key={id}
|
||||
tag={Link}
|
||||
to={`/server/${id}/list-short-urls/1`}
|
||||
className="home__servers-item"
|
||||
>
|
||||
{name}
|
||||
<FontAwesomeIcon icon={chevronIcon} className="home__servers-item-icon" />
|
||||
</ListGroupItem>
|
||||
))}
|
||||
</ListGroup>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
@import '../utils/base';
|
||||
@import '../utils/mixins/vertical-align';
|
||||
|
||||
.home {
|
||||
text-align: center;
|
||||
height: calc(100vh - #{$headerHeight});
|
||||
height: calc(100vh - #{$headerHeight} - #{($footer-height + $footer-margin)});
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -17,21 +16,3 @@
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.home__servers-list {
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.home__servers-item.home__servers-item {
|
||||
text-align: left;
|
||||
position: relative;
|
||||
padding: .75rem 2.5rem .75rem 1rem;
|
||||
}
|
||||
|
||||
.home__servers-item-icon {
|
||||
@include vertical-align();
|
||||
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
27
src/common/Home.tsx
Normal file
27
src/common/Home.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { isEmpty, values } from 'ramda';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ServersListGroup from '../servers/ServersListGroup';
|
||||
import './Home.scss';
|
||||
import { ServersMap } from '../servers/data';
|
||||
|
||||
export interface HomeProps {
|
||||
servers: ServersMap;
|
||||
}
|
||||
|
||||
const Home = ({ servers }: HomeProps) => {
|
||||
const serversList = values(servers);
|
||||
const hasServers = !isEmpty(serversList);
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<h1 className="home__title">Welcome to Shlink</h1>
|
||||
<ServersListGroup servers={serversList}>
|
||||
{hasServers && <span>Please, select a server.</span>}
|
||||
{!hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
|
||||
</ServersListGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
@@ -1,65 +0,0 @@
|
||||
import { faPlus as plusIcon, faChevronDown as arrowIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
||||
import classnames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import shlinkLogo from './shlink-logo-white.png';
|
||||
import './MainHeader.scss';
|
||||
|
||||
const MainHeader = (ServersDropdown) => class MainHeader extends React.Component {
|
||||
static propTypes = {
|
||||
location: PropTypes.object,
|
||||
};
|
||||
|
||||
state = { isOpen: false };
|
||||
handleToggle = () => {
|
||||
this.setState(({ isOpen }) => ({
|
||||
isOpen: !isOpen,
|
||||
}));
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.location !== prevProps.location) {
|
||||
this.setState({ isOpen: false });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { location } = this.props;
|
||||
const createServerPath = '/server/create';
|
||||
const toggleClass = classnames('main-header__toggle-icon', {
|
||||
'main-header__toggle-icon--opened': this.state.isOpen,
|
||||
});
|
||||
|
||||
return (
|
||||
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
||||
<NavbarBrand tag={Link} to="/">
|
||||
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
|
||||
</NavbarBrand>
|
||||
|
||||
<NavbarToggler onClick={this.handleToggle}>
|
||||
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
||||
</NavbarToggler>
|
||||
|
||||
<Collapse navbar isOpen={this.state.isOpen}>
|
||||
<Nav navbar className="ml-auto">
|
||||
<NavItem>
|
||||
<NavLink
|
||||
tag={Link}
|
||||
to={createServerPath}
|
||||
active={location.pathname === createServerPath}
|
||||
>
|
||||
<FontAwesomeIcon icon={plusIcon} /> Add server
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<ServersDropdown />
|
||||
</Nav>
|
||||
</Collapse>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default MainHeader;
|
||||
51
src/common/MainHeader.tsx
Normal file
51
src/common/MainHeader.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { faPlus as plusIcon, faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import { Link } 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 './shlink-logo-white.png';
|
||||
import './MainHeader.scss';
|
||||
|
||||
const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps) => {
|
||||
const [ isOpen, toggleOpen, , close ] = useToggle();
|
||||
const { pathname } = location;
|
||||
|
||||
useEffect(close, [ location ]);
|
||||
|
||||
const createServerPath = '/server/create';
|
||||
const settingsPath = '/settings';
|
||||
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
|
||||
|
||||
return (
|
||||
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
||||
<NavbarBrand tag={Link} to="/">
|
||||
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
|
||||
</NavbarBrand>
|
||||
|
||||
<NavbarToggler onClick={toggleOpen}>
|
||||
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
||||
</NavbarToggler>
|
||||
|
||||
<Collapse navbar isOpen={isOpen}>
|
||||
<Nav navbar className="ml-auto">
|
||||
<NavItem>
|
||||
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
|
||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<NavLink tag={Link} to={createServerPath} active={pathname === createServerPath}>
|
||||
<FontAwesomeIcon icon={plusIcon} /> Add server
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<ServersDropdown />
|
||||
</Nav>
|
||||
</Collapse>
|
||||
</Navbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainHeader;
|
||||
@@ -1,110 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import { Swipeable } from 'react-swipeable';
|
||||
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import classnames from 'classnames';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import NotFound from './NotFound';
|
||||
import './MenuLayout.scss';
|
||||
|
||||
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits) =>
|
||||
class MenuLayout extends React.Component {
|
||||
static propTypes = {
|
||||
match: PropTypes.object,
|
||||
selectServer: PropTypes.func,
|
||||
location: PropTypes.object,
|
||||
selectedServer: serverType,
|
||||
};
|
||||
|
||||
state = { showSideBar: false };
|
||||
|
||||
componentDidMount() {
|
||||
const { match, selectServer } = this.props;
|
||||
const { params: { serverId } } = match;
|
||||
|
||||
selectServer(serverId);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { location } = this.props;
|
||||
|
||||
// Hide sidebar when location changes
|
||||
if (location !== prevProps.location) {
|
||||
this.setState({ showSideBar: false });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { selectedServer, match } = this.props;
|
||||
const { params: { serverId } } = match;
|
||||
const burgerClasses = classnames('menu-layout__burger-icon', {
|
||||
'menu-layout__burger-icon--active': this.state.showSideBar,
|
||||
});
|
||||
const swipeMenuIfNoModalExists = (showSideBar) => () => {
|
||||
if (document.querySelector('.modal')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ showSideBar });
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<FontAwesomeIcon
|
||||
icon={burgerIcon}
|
||||
className={burgerClasses}
|
||||
onClick={() => this.setState(({ showSideBar }) => ({ showSideBar: !showSideBar }))}
|
||||
/>
|
||||
|
||||
<Swipeable
|
||||
delta={40}
|
||||
className="menu-layout__swipeable"
|
||||
onSwipedLeft={swipeMenuIfNoModalExists(false)}
|
||||
onSwipedRight={swipeMenuIfNoModalExists(true)}
|
||||
>
|
||||
<div className="row menu-layout__swipeable-inner">
|
||||
<AsideMenu
|
||||
className="col-lg-2 col-md-3"
|
||||
selectedServer={selectedServer}
|
||||
showOnMobile={this.state.showSideBar}
|
||||
/>
|
||||
<div
|
||||
className="col-lg-10 offset-lg-2 col-md-9 offset-md-3"
|
||||
onClick={() => this.setState({ showSideBar: false })}
|
||||
>
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path="/server/:serverId/list-short-urls/:page"
|
||||
component={ShortUrls}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/server/:serverId/create-short-url"
|
||||
component={CreateShortUrl}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/server/:serverId/short-code/:shortCode/visits"
|
||||
component={ShortUrlVisits}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/server/:serverId/manage-tags"
|
||||
component={TagsList}
|
||||
/>
|
||||
<Route
|
||||
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`} btnText="List short URLs" />}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</Swipeable>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default MenuLayout;
|
||||
@@ -32,3 +32,12 @@
|
||||
.menu-layout__burger-icon--active {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.menu-layout__container {
|
||||
padding: 20px 0 0;
|
||||
min-height: 100%;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 30px 15px 0;
|
||||
}
|
||||
}
|
||||
|
||||
80
src/common/MenuLayout.tsx
Normal file
80
src/common/MenuLayout.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import { EventData, Swipeable } from 'react-swipeable';
|
||||
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import classNames from 'classnames';
|
||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { versionMatch } from '../utils/helpers/version';
|
||||
import { isReachableServer } from '../servers/data';
|
||||
import NotFound from './NotFound';
|
||||
import { AsideMenuProps } from './AsideMenu';
|
||||
import './MenuLayout.scss';
|
||||
|
||||
const MenuLayout = (
|
||||
TagsList: FC,
|
||||
ShortUrls: FC,
|
||||
AsideMenu: FC<AsideMenuProps>,
|
||||
CreateShortUrl: FC,
|
||||
ShortUrlVisits: FC,
|
||||
TagVisits: FC,
|
||||
ServerError: FC,
|
||||
) => withSelectedServer(({ location, selectedServer }) => {
|
||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
||||
|
||||
useEffect(() => hideSidebar(), [ location ]);
|
||||
|
||||
if (!isReachableServer(selectedServer)) {
|
||||
return <ServerError />;
|
||||
}
|
||||
|
||||
const addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' });
|
||||
const burgerClasses = classNames('menu-layout__burger-icon', {
|
||||
'menu-layout__burger-icon--active': sidebarVisible,
|
||||
});
|
||||
const swipeMenuIfNoModalExists = (callback: () => void) => (e: EventData) => {
|
||||
const swippedOnVisitsTable = (e.event.composedPath() as HTMLElement[]).some(
|
||||
({ classList }) => classList?.contains('visits-table'),
|
||||
);
|
||||
|
||||
if (swippedOnVisitsTable || document.querySelector('.modal')) {
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
|
||||
|
||||
<Swipeable
|
||||
delta={40}
|
||||
className="menu-layout__swipeable"
|
||||
onSwipedLeft={swipeMenuIfNoModalExists(hideSidebar)}
|
||||
onSwipedRight={swipeMenuIfNoModalExists(showSidebar)}
|
||||
>
|
||||
<div className="row menu-layout__swipeable-inner">
|
||||
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
||||
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
|
||||
<div className="menu-layout__container">
|
||||
<Switch>
|
||||
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
|
||||
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
||||
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
||||
{addTagsVisitsRoute && <Route exact path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
||||
<Route
|
||||
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Swipeable>
|
||||
</React.Fragment>
|
||||
);
|
||||
}, ServerError);
|
||||
|
||||
export default MenuLayout;
|
||||
3
src/common/NoMenuLayout.scss
Normal file
3
src/common/NoMenuLayout.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.no-menu-wrapper {
|
||||
padding: 40px 20px 20px;
|
||||
}
|
||||
6
src/common/NoMenuLayout.tsx
Normal file
6
src/common/NoMenuLayout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React, { FC } from 'react';
|
||||
import './NoMenuLayout.scss';
|
||||
|
||||
const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper">{children}</div>;
|
||||
|
||||
export default NoMenuLayout;
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import * as PropTypes from 'prop-types';
|
||||
|
||||
const propTypes = {
|
||||
to: PropTypes.string,
|
||||
btnText: PropTypes.string,
|
||||
};
|
||||
|
||||
const NotFound = ({ to = '/', btnText = 'Home' }) => (
|
||||
<div className="home">
|
||||
<h2>Oops! We could not find requested route.</h2>
|
||||
<p>
|
||||
Use your browser{'\''}s back button to navigate to the page you have previously come from, or just press this button.
|
||||
</p>
|
||||
<br />
|
||||
<Link to={to} className="btn btn-outline-primary btn-lg">{btnText}</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
NotFound.propTypes = propTypes;
|
||||
|
||||
export default NotFound;
|
||||
20
src/common/NotFound.tsx
Normal file
20
src/common/NotFound.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface NotFoundProps {
|
||||
to?: string;
|
||||
}
|
||||
|
||||
const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
|
||||
<div className="home">
|
||||
<h2>Oops! We could not find requested route.</h2>
|
||||
<p>
|
||||
Use your browser's back button to navigate to the page you have previously come from, or just press this
|
||||
button.
|
||||
</p>
|
||||
<br />
|
||||
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default NotFound;
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const ScrollToTop = ({ scrollTo }) => class ScrollToTop extends React.Component {
|
||||
static propTypes = {
|
||||
location: PropTypes.object,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
componentDidUpdate({ location: prevLocation }) {
|
||||
const { location } = this.props;
|
||||
|
||||
if (location !== prevLocation) {
|
||||
scrollTo(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.children;
|
||||
}
|
||||
};
|
||||
|
||||
export default ScrollToTop;
|
||||
12
src/common/ScrollToTop.tsx
Normal file
12
src/common/ScrollToTop.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React, { PropsWithChildren, useEffect } from 'react';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
|
||||
const ScrollToTop = () => ({ location, children }: PropsWithChildren<RouteComponentProps>) => {
|
||||
useEffect(() => {
|
||||
scrollTo(0, 0);
|
||||
}, [ location ]);
|
||||
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
};
|
||||
|
||||
export default ScrollToTop;
|
||||
38
src/common/ShlinkVersions.tsx
Normal file
38
src/common/ShlinkVersions.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { pipe } from 'ramda';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||
|
||||
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
|
||||
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
|
||||
|
||||
export interface ShlinkVersionsProps {
|
||||
selectedServer: SelectedServer;
|
||||
clientVersion?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => (
|
||||
<ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="text-muted">
|
||||
<b>{version}</b>
|
||||
</ExternalLink>
|
||||
);
|
||||
|
||||
const ShlinkVersions = (
|
||||
{ selectedServer, className, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps,
|
||||
) => {
|
||||
const normalizedClientVersion = normalizeVersion(clientVersion);
|
||||
|
||||
return (
|
||||
<small className={classNames('text-muted', className)}>
|
||||
{isReachableServer(selectedServer) &&
|
||||
<React.Fragment>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </React.Fragment>
|
||||
}
|
||||
Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} />
|
||||
</small>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShlinkVersions;
|
||||
@@ -1,65 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||
import { range, max, min } from 'ramda';
|
||||
import './SimplePaginator.scss';
|
||||
|
||||
const propTypes = {
|
||||
pagesCount: PropTypes.number.isRequired,
|
||||
currentPage: PropTypes.number.isRequired,
|
||||
setCurrentPage: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const ellipsis = '...';
|
||||
|
||||
const pagination = (currentPage, pageCount) => {
|
||||
const delta = 2;
|
||||
const pages = range(
|
||||
max(delta, currentPage - delta),
|
||||
min(pageCount - 1, currentPage + delta) + 1
|
||||
);
|
||||
|
||||
if (currentPage - delta > delta) {
|
||||
pages.unshift(ellipsis);
|
||||
}
|
||||
if (currentPage + delta < pageCount - 1) {
|
||||
pages.push(ellipsis);
|
||||
}
|
||||
|
||||
pages.unshift(1);
|
||||
pages.push(pageCount);
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage }) => {
|
||||
if (pagesCount < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onClick = (page) => () => setCurrentPage(page);
|
||||
|
||||
return (
|
||||
<Pagination listClassName="flex-wrap justify-content-center mb-0 simple-paginator">
|
||||
<PaginationItem disabled={currentPage <= 1}>
|
||||
<PaginationLink previous tag="span" onClick={onClick(currentPage - 1)} />
|
||||
</PaginationItem>
|
||||
{pagination(currentPage, pagesCount).map((page, index) => (
|
||||
<PaginationItem
|
||||
key={page !== ellipsis ? page : `${page}_${index}`}
|
||||
active={page === currentPage}
|
||||
disabled={page === ellipsis}
|
||||
>
|
||||
<PaginationLink tag="span" onClick={onClick(page)}>{page}</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem disabled={currentPage >= pagesCount}>
|
||||
<PaginationLink next tag="span" onClick={onClick(currentPage + 1)} />
|
||||
</PaginationItem>
|
||||
</Pagination>
|
||||
);
|
||||
};
|
||||
|
||||
SimplePaginator.propTypes = propTypes;
|
||||
|
||||
export default SimplePaginator;
|
||||
48
src/common/SimplePaginator.tsx
Normal file
48
src/common/SimplePaginator.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React, { FC } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||
import {
|
||||
pageIsEllipsis,
|
||||
keyForPage,
|
||||
NumberOrEllipsis,
|
||||
progressivePagination,
|
||||
prettifyPageNumber,
|
||||
} from '../utils/helpers/pagination';
|
||||
import './SimplePaginator.scss';
|
||||
|
||||
interface SimplePaginatorProps {
|
||||
pagesCount: number;
|
||||
currentPage: number;
|
||||
setCurrentPage: (currentPage: number) => void;
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
const SimplePaginator: FC<SimplePaginatorProps> = ({ pagesCount, currentPage, setCurrentPage, centered = true }) => {
|
||||
if (pagesCount < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onClick = (page: NumberOrEllipsis) => () => !pageIsEllipsis(page) && setCurrentPage(page);
|
||||
|
||||
return (
|
||||
<Pagination listClassName={classNames('flex-wrap mb-0 simple-paginator', { 'justify-content-center': centered })}>
|
||||
<PaginationItem disabled={currentPage <= 1}>
|
||||
<PaginationLink previous tag="span" onClick={onClick(currentPage - 1)} />
|
||||
</PaginationItem>
|
||||
{progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
|
||||
<PaginationItem
|
||||
key={keyForPage(pageNumber, index)}
|
||||
disabled={pageIsEllipsis(pageNumber)}
|
||||
active={currentPage === pageNumber}
|
||||
>
|
||||
<PaginationLink tag="span" onClick={onClick(pageNumber)}>{prettifyPageNumber(pageNumber)}</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem disabled={currentPage >= pagesCount}>
|
||||
<PaginationLink next tag="span" onClick={onClick(currentPage + 1)} />
|
||||
</PaginationItem>
|
||||
</Pagination>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimplePaginator;
|
||||
@@ -1,6 +1,6 @@
|
||||
.react-tagsinput {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #cccccc;
|
||||
border-radius: .25rem;
|
||||
overflow: hidden;
|
||||
min-height: 2.6rem;
|
||||
@@ -22,7 +22,7 @@
|
||||
margin: 0 5px 6px 0;
|
||||
padding: 6px 8px;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.react-tagsinput-remove {
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
.react-tagsinput-tag span:before {
|
||||
content: '\2715';
|
||||
color: #fff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.react-tagsinput-input {
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import Bottle, { Decorator } from 'bottlejs';
|
||||
import ScrollToTop from '../ScrollToTop';
|
||||
import MainHeader from '../MainHeader';
|
||||
import Home from '../Home';
|
||||
import MenuLayout from '../MenuLayout';
|
||||
import AsideMenu from '../AsideMenu';
|
||||
import ErrorHandler from '../ErrorHandler';
|
||||
import ShlinkVersions from '../ShlinkVersions';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||
|
||||
const provideServices = (bottle, connect, withRouter) => {
|
||||
bottle.constant('window', global.window);
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||
bottle.constant('window', (global as any).window);
|
||||
bottle.constant('console', global.console);
|
||||
|
||||
bottle.serviceFactory('ScrollToTop', ScrollToTop, 'window');
|
||||
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);
|
||||
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
|
||||
|
||||
bottle.serviceFactory(
|
||||
@@ -25,13 +30,18 @@ const provideServices = (bottle, connect, withRouter) => {
|
||||
'ShortUrls',
|
||||
'AsideMenu',
|
||||
'CreateShortUrl',
|
||||
'ShortUrlVisits'
|
||||
'ShortUrlVisits',
|
||||
'TagVisits',
|
||||
'ServerError',
|
||||
);
|
||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
||||
bottle.decorator('MenuLayout', withRouter);
|
||||
|
||||
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
||||
|
||||
bottle.serviceFactory('ShlinkVersions', () => ShlinkVersions);
|
||||
bottle.decorator('ShlinkVersions', connect([ 'selectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Bottle from 'bottlejs';
|
||||
import Bottle, { IContainer } from 'bottlejs';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { connect as reduxConnect } from 'react-redux';
|
||||
import { pick } from 'ramda';
|
||||
@@ -9,24 +9,29 @@ import provideServersServices from '../servers/services/provideServices';
|
||||
import provideVisitsServices from '../visits/services/provideServices';
|
||||
import provideTagsServices from '../tags/services/provideServices';
|
||||
import provideUtilsServices from '../utils/services/provideServices';
|
||||
import provideMercureServices from '../mercure/services/provideServices';
|
||||
import provideSettingsServices from '../settings/services/provideServices';
|
||||
import { ConnectDecorator } from './types';
|
||||
|
||||
type LazyActionMap = Record<string, Function>;
|
||||
|
||||
const bottle = new Bottle();
|
||||
const { container } = bottle;
|
||||
|
||||
const lazyService = (container, serviceName) => (...args) => container[serviceName](...args);
|
||||
const mapActionService = (map, actionName) => ({
|
||||
const lazyService = (container: IContainer, serviceName: string) => (...args: any[]) => container[serviceName](...args);
|
||||
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
|
||||
...map,
|
||||
|
||||
// Wrap actual action service in a function so that it is lazily created the first time it is called
|
||||
[actionName]: lazyService(container, actionName),
|
||||
});
|
||||
const connect = (propsFromState, actionServiceNames) =>
|
||||
const connect: ConnectDecorator = (propsFromState: string[] | null, actionServiceNames: string[] = []) =>
|
||||
reduxConnect(
|
||||
propsFromState ? pick(propsFromState) : null,
|
||||
actionServiceNames.reduce(mapActionService, {})
|
||||
actionServiceNames.reduce(mapActionService, {}),
|
||||
);
|
||||
|
||||
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer');
|
||||
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer', 'Settings', 'ShlinkVersions');
|
||||
bottle.decorator('App', connect([ 'servers' ], [ 'fetchServers' ]));
|
||||
|
||||
provideCommonServices(bottle, connect, withRouter);
|
||||
provideShortUrlsServices(bottle, connect);
|
||||
@@ -34,5 +39,7 @@ provideServersServices(bottle, connect, withRouter);
|
||||
provideTagsServices(bottle, connect);
|
||||
provideVisitsServices(bottle, connect);
|
||||
provideUtilsServices(bottle);
|
||||
provideMercureServices(bottle);
|
||||
provideSettingsServices(bottle, connect);
|
||||
|
||||
export default container;
|
||||
@@ -1,13 +0,0 @@
|
||||
import ReduxThunk from 'redux-thunk';
|
||||
import { applyMiddleware, compose, createStore } from 'redux';
|
||||
import reducers from '../reducers';
|
||||
|
||||
const composeEnhancers = process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
||||
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
||||
: compose;
|
||||
|
||||
const store = createStore(reducers, composeEnhancers(
|
||||
applyMiddleware(ReduxThunk)
|
||||
));
|
||||
|
||||
export default store;
|
||||
20
src/container/store.ts
Normal file
20
src/container/store.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import ReduxThunk from 'redux-thunk';
|
||||
import { applyMiddleware, compose, createStore } from 'redux';
|
||||
import { save, load, RLSOptions } from 'redux-localstorage-simple';
|
||||
import reducers from '../reducers';
|
||||
|
||||
const isProduction = process.env.NODE_ENV !== 'production';
|
||||
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||
|
||||
const localStorageConfig: RLSOptions = {
|
||||
states: [ 'settings', 'servers' ],
|
||||
namespace: 'shlink',
|
||||
namespaceSeparator: '.',
|
||||
debounce: 300,
|
||||
};
|
||||
|
||||
const store = createStore(reducers, load(localStorageConfig), composeEnhancers(
|
||||
applyMiddleware(save(localStorageConfig), ReduxThunk),
|
||||
));
|
||||
|
||||
export default store;
|
||||
40
src/container/types.ts
Normal file
40
src/container/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { MercureInfo } from '../mercure/reducers/mercureInfo';
|
||||
import { SelectedServer, ServersMap } from '../servers/data';
|
||||
import { Settings } from '../settings/reducers/settings';
|
||||
import { ShortUrlMetaEdition } from '../short-urls/reducers/shortUrlMeta';
|
||||
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 { ShortUrlTags } from '../short-urls/reducers/shortUrlTags';
|
||||
import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
|
||||
import { TagDeletion } from '../tags/reducers/tagDelete';
|
||||
import { TagEdition } from '../tags/reducers/tagEdit';
|
||||
import { TagsList } from '../tags/reducers/tagsList';
|
||||
import { ShortUrlDetail } from '../visits/reducers/shortUrlDetail';
|
||||
import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
|
||||
import { TagVisits } from '../visits/reducers/tagVisits';
|
||||
|
||||
export interface ShlinkState {
|
||||
servers: ServersMap;
|
||||
selectedServer: SelectedServer;
|
||||
shortUrlsList: ShortUrlsList;
|
||||
shortUrlsListParams: ShortUrlsListParams;
|
||||
shortUrlCreationResult: ShortUrlCreation;
|
||||
shortUrlDeletion: ShortUrlDeletion;
|
||||
shortUrlTags: ShortUrlTags;
|
||||
shortUrlMeta: ShortUrlMetaEdition;
|
||||
shortUrlEdition: ShortUrlEdition;
|
||||
shortUrlVisits: ShortUrlVisits;
|
||||
tagVisits: TagVisits;
|
||||
shortUrlDetail: ShortUrlDetail;
|
||||
tagsList: TagsList;
|
||||
tagDelete: TagDeletion;
|
||||
tagEdit: TagEdition;
|
||||
mercureInfo: MercureInfo;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
||||
|
||||
export type GetState = () => ShlinkState;
|
||||
@@ -10,10 +10,6 @@ body,
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bg-main {
|
||||
background-color: $mainColor !important;
|
||||
}
|
||||
@@ -28,16 +24,8 @@ body,
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.shlink-container {
|
||||
padding: 20px 0;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 30px 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-main {
|
||||
color: #fff;
|
||||
color: #ffffff;
|
||||
background-color: $mainColor;
|
||||
}
|
||||
|
||||
@@ -56,10 +44,24 @@ body,
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.paddingless {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.indivisible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-ellipsis {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.react-datepicker__day--keyboard-selected {
|
||||
background-color: $mainColor;
|
||||
|
||||
&:hover {
|
||||
background-color: darken($mainColor, 12%);
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background-color: $mainColor;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import React from 'react';
|
||||
import { render } from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
@@ -7,7 +6,8 @@ import { homepage } from '../package.json';
|
||||
import registerServiceWorker from './registerServiceWorker';
|
||||
import container from './container';
|
||||
import store from './container/store';
|
||||
import { fixLeafletIcons } from './utils/utils';
|
||||
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import './common/react-tagsinput.scss';
|
||||
@@ -28,6 +28,6 @@ render(
|
||||
</ErrorHandler>
|
||||
</BrowserRouter>
|
||||
</Provider>,
|
||||
document.getElementById('root')
|
||||
document.getElementById('root'),
|
||||
);
|
||||
registerServiceWorker();
|
||||
41
src/mercure/helpers/boundToMercureHub.tsx
Normal file
41
src/mercure/helpers/boundToMercureHub.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import { pipe } from 'ramda';
|
||||
import { CreateVisit } from '../../visits/types';
|
||||
import { MercureInfo } from '../reducers/mercureInfo';
|
||||
import { bindToMercureTopic } from './index';
|
||||
|
||||
export interface MercureBoundProps {
|
||||
createNewVisits: (createdVisits: CreateVisit[]) => void;
|
||||
loadMercureInfo: Function;
|
||||
mercureInfo: MercureInfo;
|
||||
}
|
||||
|
||||
export function boundToMercureHub<T = {}>(
|
||||
WrappedComponent: FC<MercureBoundProps & T>,
|
||||
getTopicForProps: (props: T) => string,
|
||||
) {
|
||||
const pendingUpdates = new Set<CreateVisit>();
|
||||
|
||||
return (props: MercureBoundProps & T) => {
|
||||
const { createNewVisits, loadMercureInfo, mercureInfo } = props;
|
||||
const { interval } = mercureInfo;
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisits([ visit ]);
|
||||
const closeEventSource = bindToMercureTopic(mercureInfo, getTopicForProps(props), onMessage, loadMercureInfo);
|
||||
|
||||
if (!interval) {
|
||||
return closeEventSource;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
createNewVisits([ ...pendingUpdates ]);
|
||||
pendingUpdates.clear();
|
||||
}, interval * 1000 * 60);
|
||||
|
||||
return pipe(() => clearInterval(timer), () => closeEventSource?.());
|
||||
}, [ mercureInfo ]);
|
||||
|
||||
return <WrappedComponent {...props} />;
|
||||
};
|
||||
}
|
||||
24
src/mercure/helpers/index.ts
Normal file
24
src/mercure/helpers/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
|
||||
import { MercureInfo } from '../reducers/mercureInfo';
|
||||
|
||||
export const bindToMercureTopic = <T>(mercureInfo: MercureInfo, topic: string, onMessage: (message: T) => void, onTokenExpired: Function) => { // eslint-disable-line max-len
|
||||
const { mercureHubUrl, token, loading, error } = mercureInfo;
|
||||
|
||||
if (loading || error || !mercureHubUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hubUrl = new URL(mercureHubUrl);
|
||||
|
||||
hubUrl.searchParams.append('topic', topic);
|
||||
const es = new EventSource(hubUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
es.onmessage = ({ data }: { data: string }) => onMessage(JSON.parse(data) as T);
|
||||
es.onerror = ({ status }: { status: number }) => status === 401 && onTokenExpired();
|
||||
|
||||
return () => es.close();
|
||||
};
|
||||
54
src/mercure/reducers/mercureInfo.ts
Normal file
54
src/mercure/reducers/mercureInfo.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { ShlinkMercureInfo } from '../../utils/services/types';
|
||||
import { GetState } from '../../container/types';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START';
|
||||
export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR';
|
||||
export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export interface MercureInfo {
|
||||
token?: string;
|
||||
mercureHubUrl?: string;
|
||||
interval?: number;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
export type GetMercureInfoAction = Action<string> & ShlinkMercureInfo & { interval?: number };
|
||||
|
||||
const initialState: MercureInfo = {
|
||||
loading: true,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<MercureInfo, GetMercureInfoAction>({
|
||||
[GET_MERCURE_INFO_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||
[GET_MERCURE_INFO_ERROR]: (state) => ({ ...state, loading: false, error: true }),
|
||||
[GET_MERCURE_INFO]: (_, action) => ({ ...action, loading: false, error: false }),
|
||||
}, initialState);
|
||||
|
||||
export const loadMercureInfo = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
||||
() => async (dispatch: Dispatch, getState: GetState) => {
|
||||
dispatch({ type: GET_MERCURE_INFO_START });
|
||||
|
||||
const { settings } = getState();
|
||||
const { mercureInfo } = buildShlinkApiClient(getState);
|
||||
|
||||
if (!settings.realTimeUpdates.enabled) {
|
||||
dispatch({ type: GET_MERCURE_INFO_ERROR });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await mercureInfo();
|
||||
|
||||
dispatch<GetMercureInfoAction>({ type: GET_MERCURE_INFO, interval: settings.realTimeUpdates.interval, ...info });
|
||||
} catch (e) {
|
||||
dispatch({ type: GET_MERCURE_INFO_ERROR });
|
||||
}
|
||||
};
|
||||
9
src/mercure/services/provideServices.ts
Normal file
9
src/mercure/services/provideServices.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import Bottle from 'bottlejs';
|
||||
import { loadMercureInfo } from '../reducers/mercureInfo';
|
||||
|
||||
const provideServices = (bottle: Bottle) => {
|
||||
// Actions
|
||||
bottle.serviceFactory('loadMercureInfo', loadMercureInfo, 'buildShlinkApiClient');
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { combineReducers } from 'redux';
|
||||
import serversReducer from '../servers/reducers/server';
|
||||
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';
|
||||
@@ -7,13 +7,18 @@ import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
|
||||
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
|
||||
import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags';
|
||||
import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta';
|
||||
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
||||
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
||||
import tagVisitsReducer from '../visits/reducers/tagVisits';
|
||||
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
|
||||
import tagsListReducer from '../tags/reducers/tagsList';
|
||||
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
||||
import tagEditReducer from '../tags/reducers/tagEdit';
|
||||
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
||||
import settingsReducer from '../settings/reducers/settings';
|
||||
import { ShlinkState } from '../container/types';
|
||||
|
||||
export default combineReducers({
|
||||
export default combineReducers<ShlinkState>({
|
||||
servers: serversReducer,
|
||||
selectedServer: selectedServerReducer,
|
||||
shortUrlsList: shortUrlsListReducer,
|
||||
@@ -22,9 +27,13 @@ export default combineReducers({
|
||||
shortUrlDeletion: shortUrlDeletionReducer,
|
||||
shortUrlTags: shortUrlTagsReducer,
|
||||
shortUrlMeta: shortUrlMetaReducer,
|
||||
shortUrlEdition: shortUrlEditionReducer,
|
||||
shortUrlVisits: shortUrlVisitsReducer,
|
||||
tagVisits: tagVisitsReducer,
|
||||
shortUrlDetail: shortUrlDetailReducer,
|
||||
tagsList: tagsListReducer,
|
||||
tagDelete: tagDeleteReducer,
|
||||
tagEdit: tagEditReducer,
|
||||
mercureInfo: mercureInfoReducer,
|
||||
settings: settingsReducer,
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/promise-function-async, @typescript-eslint/no-misused-promises */
|
||||
|
||||
// In production, we register a service worker to serve assets from local cache.
|
||||
|
||||
// This lets the app load faster on subsequent visits in production, and gives
|
||||
@@ -18,8 +20,8 @@ const isLocalhost = Boolean(
|
||||
|
||||
// 127.0.0.1/8 is considered localhost for IPv4.
|
||||
window.location.hostname.match(
|
||||
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
|
||||
)
|
||||
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/,
|
||||
),
|
||||
);
|
||||
|
||||
export default function register() {
|
||||
@@ -46,7 +48,7 @@ export default function register() {
|
||||
return navigator.serviceWorker.ready.then(() => {
|
||||
console.log(
|
||||
'This web app is being served cache-first by a service ' +
|
||||
'worker. To learn more, visit https://goo.gl/SC7cgQ'
|
||||
'worker. To learn more, visit https://goo.gl/SC7cgQ',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -96,7 +98,7 @@ function checkValidServiceWorker(swUrl) {
|
||||
// Ensure service worker exists, and that we really are getting a JS file.
|
||||
if (
|
||||
response.status === NOT_FOUND_STATUS ||
|
||||
response.headers.get('content-type').indexOf('javascript') === -1
|
||||
response.headers.get('content-type').includes('javascript')
|
||||
) {
|
||||
// No service worker found. Probably a different app. Reload the page.
|
||||
return navigator.serviceWorker.ready.then((registration) =>
|
||||
@@ -110,7 +112,7 @@ function checkValidServiceWorker(swUrl) {
|
||||
})
|
||||
.catch(() => {
|
||||
console.log(
|
||||
'No internet connection found. App is running in offline mode.'
|
||||
'No internet connection found. App is running in offline mode.',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { assoc, dissoc, pipe } from 'ramda';
|
||||
import React from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import PropTypes from 'prop-types';
|
||||
import './CreateServer.scss';
|
||||
|
||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||
|
||||
const CreateServer = (ImportServersBtn, stateFlagTimeout) => class CreateServer extends React.Component {
|
||||
static propTypes = {
|
||||
createServer: PropTypes.func,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
}),
|
||||
resetSelectedServer: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
name: '',
|
||||
url: '',
|
||||
apiKey: '',
|
||||
serversImported: false,
|
||||
};
|
||||
|
||||
handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { createServer, history: { push } } = this.props;
|
||||
const server = pipe(
|
||||
assoc('id', uuid()),
|
||||
dissoc('serversImported')
|
||||
)(this.state);
|
||||
|
||||
createServer(server);
|
||||
push(`/server/${server.id}/list-short-urls/1`);
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.resetSelectedServer();
|
||||
}
|
||||
|
||||
render() {
|
||||
const renderInputGroup = (id, placeholder, type = 'text') => (
|
||||
<div className="form-group row">
|
||||
<label htmlFor={id} className="col-lg-1 col-md-2 col-form-label create-server__label">
|
||||
{placeholder}:
|
||||
</label>
|
||||
<div className="col-lg-11 col-md-10">
|
||||
<input
|
||||
type={type}
|
||||
className="form-control"
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
value={this.state[id]}
|
||||
required
|
||||
onChange={(e) => this.setState({ [id]: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="create-server">
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
{renderInputGroup('name', 'Name')}
|
||||
{renderInputGroup('url', 'URL', 'url')}
|
||||
{renderInputGroup('apiKey', 'API key')}
|
||||
|
||||
<div className="text-right">
|
||||
<ImportServersBtn
|
||||
onImport={() => stateFlagTimeout(this.setState.bind(this), 'serversImported', true, SHOW_IMPORT_MSG_TIME)}
|
||||
/>
|
||||
<button className="btn btn-outline-primary">Create server</button>
|
||||
</div>
|
||||
|
||||
{this.state.serversImported && (
|
||||
<div className="row create-server__import-success-msg">
|
||||
<div className="col-md-10 offset-md-1">
|
||||
<div className="p-2 mt-3 bg-main text-white text-center">
|
||||
Servers properly imported. You can now select one from the list :)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default CreateServer;
|
||||
@@ -1,9 +1,5 @@
|
||||
@import '../utils/base';
|
||||
|
||||
.create-server {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.create-server__label {
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
|
||||
58
src/servers/CreateServer.tsx
Normal file
58
src/servers/CreateServer.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React, { FC } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { RouterProps } from 'react-router';
|
||||
import classNames from 'classnames';
|
||||
import NoMenuLayout from '../common/NoMenuLayout';
|
||||
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||
import { ServerData, ServerWithId } from './data';
|
||||
import './CreateServer.scss';
|
||||
|
||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||
|
||||
interface CreateServerProps extends RouterProps {
|
||||
createServer: (server: ServerWithId) => void;
|
||||
}
|
||||
|
||||
const Result: FC<{ type: 'success' | 'error' }> = ({ children, type }) => (
|
||||
<div className="row">
|
||||
<div className="col-md-10 offset-md-1">
|
||||
<div
|
||||
className={classNames('p-2 mt-3 text-white text-center', {
|
||||
'bg-main': type === 'success',
|
||||
'bg-danger': type === 'error',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
|
||||
{ createServer, history: { push } }: CreateServerProps,
|
||||
) => {
|
||||
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||
const handleSubmit = (serverData: ServerData) => {
|
||||
const id = uuid();
|
||||
|
||||
createServer({ ...serverData, id });
|
||||
push(`/server/${id}/list-short-urls/1`);
|
||||
};
|
||||
|
||||
return (
|
||||
<NoMenuLayout>
|
||||
<ServerForm onSubmit={handleSubmit}>
|
||||
<ImportServersBtn onImport={setServersImported} onImportError={setErrorImporting} />
|
||||
<button className="btn btn-outline-primary">Create server</button>
|
||||
</ServerForm>
|
||||
|
||||
{serversImported && <Result type="success">Servers properly imported. You can now select one from the list :)</Result>}
|
||||
{errorImporting && <Result type="error">The servers could not be imported. Make sure the format is correct.</Result>}
|
||||
</NoMenuLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateServer;
|
||||
@@ -1,40 +0,0 @@
|
||||
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { serverType } from './prop-types';
|
||||
|
||||
const DeleteServerButton = (DeleteServerModal) => class DeleteServerButton extends React.Component {
|
||||
static propTypes = {
|
||||
server: serverType,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
state = { isModalOpen: false };
|
||||
|
||||
render() {
|
||||
const { server, className } = this.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<span
|
||||
className={className}
|
||||
key="deleteServerBtn"
|
||||
onClick={() => this.setState({ isModalOpen: true })}
|
||||
>
|
||||
<FontAwesomeIcon icon={deleteIcon} />
|
||||
<span className="aside-menu__item-text">Delete this server</span>
|
||||
</span>
|
||||
|
||||
<DeleteServerModal
|
||||
isOpen={this.state.isModalOpen}
|
||||
toggle={() => this.setState(({ isModalOpen }) => ({ isModalOpen: !isModalOpen }))}
|
||||
server={server}
|
||||
key="deleteServerModal"
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default DeleteServerButton;
|
||||
31
src/servers/DeleteServerButton.tsx
Normal file
31
src/servers/DeleteServerButton.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React, { FC } from 'react';
|
||||
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { DeleteServerModalProps } from './DeleteServerModal';
|
||||
import { ServerWithId } from './data';
|
||||
|
||||
export interface DeleteServerButtonProps {
|
||||
server: ServerWithId;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
}
|
||||
|
||||
const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<DeleteServerButtonProps> => (
|
||||
{ server, className, children, textClassName },
|
||||
) => {
|
||||
const [ isModalOpen, , showModal, hideModal ] = useToggle();
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<span className={className} onClick={showModal}>
|
||||
{!children && <FontAwesomeIcon icon={deleteIcon} />}
|
||||
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
||||
</span>
|
||||
|
||||
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteServerButton;
|
||||
@@ -1,19 +1,19 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { serverType } from './prop-types';
|
||||
import { RouterProps } from 'react-router';
|
||||
import { ServerWithId } from './data';
|
||||
|
||||
const propTypes = {
|
||||
toggle: PropTypes.func.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
server: serverType,
|
||||
deleteServer: PropTypes.func,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
}),
|
||||
};
|
||||
export interface DeleteServerModalProps {
|
||||
server: ServerWithId;
|
||||
toggle: () => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) => {
|
||||
interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps {
|
||||
deleteServer: (server: ServerWithId) => void;
|
||||
}
|
||||
|
||||
const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }: DeleteServerModalConnectProps) => {
|
||||
const closeModal = () => {
|
||||
deleteServer(server);
|
||||
toggle();
|
||||
@@ -22,12 +22,14 @@ const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) =>
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
<ModalHeader toggle={toggle}><span className="text-danger">Delete server</span></ModalHeader>
|
||||
<ModalHeader toggle={toggle}><span className="text-danger">Remove server</span></ModalHeader>
|
||||
<ModalBody>
|
||||
<p>Are you sure you want to delete server <b>{server ? server.name : ''}</b>?</p>
|
||||
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
|
||||
<p>
|
||||
No data will be deleted, only the access to that server will be removed from this host.
|
||||
You can create it again at any moment.
|
||||
<i>
|
||||
No data will be deleted, only the access to this server will be removed from this host.
|
||||
You can create it again at any moment.
|
||||
</i>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
@@ -38,6 +40,4 @@ const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) =>
|
||||
);
|
||||
};
|
||||
|
||||
DeleteServerModal.propTypes = propTypes;
|
||||
|
||||
export default DeleteServerModal;
|
||||
32
src/servers/EditServer.tsx
Normal file
32
src/servers/EditServer.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Button } from 'reactstrap';
|
||||
import NoMenuLayout from '../common/NoMenuLayout';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
import { withSelectedServer } from './helpers/withSelectedServer';
|
||||
import { isServerWithId, ServerData } from './data';
|
||||
|
||||
interface EditServerProps {
|
||||
editServer: (serverId: string, serverData: ServerData) => void;
|
||||
}
|
||||
|
||||
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
|
||||
{ editServer, selectedServer, history: { push, goBack } },
|
||||
) => {
|
||||
if (!isServerWithId(selectedServer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSubmit = (serverData: ServerData) => {
|
||||
editServer(selectedServer.id, serverData);
|
||||
push(`/server/${selectedServer.id}/list-short-urls/1`);
|
||||
};
|
||||
|
||||
return (
|
||||
<NoMenuLayout>
|
||||
<ServerForm initialValues={selectedServer} onSubmit={handleSubmit}>
|
||||
<Button outline className="mr-2" onClick={goBack}>Cancel</Button>
|
||||
<Button outline color="primary">Save</Button>
|
||||
</ServerForm>
|
||||
</NoMenuLayout>
|
||||
);
|
||||
}, ServerError);
|
||||
@@ -1,64 +0,0 @@
|
||||
import { isEmpty, values } from 'ramda';
|
||||
import React from 'react';
|
||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { serverType } from './prop-types';
|
||||
|
||||
const ServersDropdown = (serversExporter) => class ServersDropdown extends React.Component {
|
||||
static propTypes = {
|
||||
servers: PropTypes.object,
|
||||
selectedServer: serverType,
|
||||
selectServer: PropTypes.func,
|
||||
listServers: PropTypes.func,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
}),
|
||||
};
|
||||
|
||||
renderServers = () => {
|
||||
const { servers: { list, loading }, selectedServer, selectServer } = this.props;
|
||||
const servers = values(list);
|
||||
const { push } = this.props.history;
|
||||
const loadServer = (id) => {
|
||||
selectServer(id)
|
||||
.then(() => push(`/server/${id}/list-short-urls/1`))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <DropdownItem disabled><i>Trying to load servers...</i></DropdownItem>;
|
||||
}
|
||||
|
||||
if (isEmpty(servers)) {
|
||||
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{servers.map(({ name, id }) => (
|
||||
<DropdownItem key={id} active={selectedServer && selectedServer.id === id} onClick={() => loadServer(id)}>
|
||||
{name}
|
||||
</DropdownItem>
|
||||
))}
|
||||
<DropdownItem divider />
|
||||
<DropdownItem
|
||||
className="servers-dropdown__export-item"
|
||||
onClick={() => serversExporter.exportServers()}
|
||||
>
|
||||
Export servers
|
||||
</DropdownItem>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
componentDidMount = this.props.listServers;
|
||||
|
||||
render = () => (
|
||||
<UncontrolledDropdown nav inNavbar>
|
||||
<DropdownToggle nav caret>Servers</DropdownToggle>
|
||||
<DropdownMenu right>{this.renderServers()}</DropdownMenu>
|
||||
</UncontrolledDropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServersDropdown;
|
||||
49
src/servers/ServersDropdown.tsx
Normal file
49
src/servers/ServersDropdown.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { isEmpty, values } from 'ramda';
|
||||
import React from 'react';
|
||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ServersExporter from './services/ServersExporter';
|
||||
import { isServerWithId, SelectedServer, ServersMap } from './data';
|
||||
|
||||
export interface ServersDropdownProps {
|
||||
servers: ServersMap;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, selectedServer }: ServersDropdownProps) => {
|
||||
const serversList = values(servers);
|
||||
|
||||
const renderServers = () => {
|
||||
if (isEmpty(serversList)) {
|
||||
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{serversList.map(({ name, id }) => (
|
||||
<DropdownItem
|
||||
key={id}
|
||||
tag={Link}
|
||||
to={`/server/${id}/list-short-urls/1`}
|
||||
active={isServerWithId(selectedServer) && selectedServer.id === id}
|
||||
>
|
||||
{name}
|
||||
</DropdownItem>
|
||||
))}
|
||||
<DropdownItem divider />
|
||||
<DropdownItem className="servers-dropdown__export-item" onClick={async () => serversExporter.exportServers()}>
|
||||
Export servers
|
||||
</DropdownItem>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<UncontrolledDropdown nav inNavbar>
|
||||
<DropdownToggle nav caret>Servers</DropdownToggle>
|
||||
<DropdownMenu right>{renderServers()}</DropdownMenu>
|
||||
</UncontrolledDropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServersDropdown;
|
||||
18
src/servers/ServersListGroup.scss
Normal file
18
src/servers/ServersListGroup.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
@import '../utils/mixins/vertical-align';
|
||||
|
||||
.servers-list__list-group {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.servers-list__server-item.servers-list__server-item {
|
||||
text-align: left;
|
||||
position: relative;
|
||||
padding: .75rem 2.5rem .75rem 1rem;
|
||||
}
|
||||
|
||||
.servers-list__server-item-icon {
|
||||
@include vertical-align();
|
||||
|
||||
right: 1rem;
|
||||
}
|
||||
33
src/servers/ServersListGroup.tsx
Normal file
33
src/servers/ServersListGroup.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { FC } from 'react';
|
||||
import { ListGroup, ListGroupItem } from 'reactstrap';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import './ServersListGroup.scss';
|
||||
import { ServerWithId } from './data';
|
||||
|
||||
interface ServersListGroup {
|
||||
servers: ServerWithId[];
|
||||
}
|
||||
|
||||
const ServerListItem = ({ id, name }: { id: string; name: string }) => (
|
||||
<ListGroupItem tag={Link} to={`/server/${id}/list-short-urls/1`} className="servers-list__server-item">
|
||||
{name}
|
||||
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
|
||||
</ListGroupItem>
|
||||
);
|
||||
|
||||
const ServersListGroup: FC<ServersListGroup> = ({ servers, children }) => (
|
||||
<React.Fragment>
|
||||
<div className="container">
|
||||
<h5>{children}</h5>
|
||||
</div>
|
||||
{servers.length > 0 && (
|
||||
<ListGroup className="servers-list__list-group mt-md-3">
|
||||
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
|
||||
</ListGroup>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
export default ServersListGroup;
|
||||
40
src/servers/data/index.ts
Normal file
40
src/servers/data/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export interface ServerData {
|
||||
name: string;
|
||||
url: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export interface ServerWithId extends ServerData {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ReachableServer extends ServerWithId {
|
||||
version: string;
|
||||
printableVersion: string;
|
||||
}
|
||||
|
||||
export interface NonReachableServer extends ServerWithId {
|
||||
serverNotReachable: true;
|
||||
}
|
||||
|
||||
export interface NotFoundServer {
|
||||
serverNotFound: true;
|
||||
}
|
||||
|
||||
export type RegularServer = ReachableServer | NonReachableServer;
|
||||
|
||||
export type SelectedServer = RegularServer | NotFoundServer | null;
|
||||
|
||||
export type ServersMap = Record<string, ServerWithId>;
|
||||
|
||||
export const hasServerData = (server: SelectedServer | ServerData): server is ServerData =>
|
||||
!!(server as ServerData)?.url && !!(server as ServerData)?.apiKey;
|
||||
|
||||
export const isServerWithId = (server: SelectedServer | ServerWithId): server is ServerWithId =>
|
||||
!!server?.hasOwnProperty('id');
|
||||
|
||||
export const isReachableServer = (server: SelectedServer): server is ReachableServer =>
|
||||
!!server?.hasOwnProperty('printableVersion');
|
||||
|
||||
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
|
||||
!!server?.hasOwnProperty('serverNotFound');
|
||||
24
src/servers/helpers/ForServerVersion.tsx
Normal file
24
src/servers/helpers/ForServerVersion.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React, { FC } from 'react';
|
||||
import { versionMatch, Versions } from '../../utils/helpers/version';
|
||||
import { isReachableServer, SelectedServer } from '../data';
|
||||
|
||||
interface ForServerVersionProps extends Versions {
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
const ForServerVersion: FC<ForServerVersionProps> = ({ minVersion, maxVersion, selectedServer, children }) => {
|
||||
if (!isReachableServer(selectedServer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { version } = selectedServer;
|
||||
const matchesVersion = versionMatch(version, { maxVersion, minVersion });
|
||||
|
||||
if (!matchesVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
};
|
||||
|
||||
export default ForServerVersion;
|
||||
@@ -1,58 +0,0 @@
|
||||
import React from 'react';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const ImportServersBtn = (serversImporter) => class ImportServersBtn extends React.Component {
|
||||
static defaultProps = {
|
||||
onImport: () => ({}),
|
||||
};
|
||||
static propTypes = {
|
||||
onImport: PropTypes.func,
|
||||
createServers: PropTypes.func,
|
||||
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.fileRef = props.fileRef || React.createRef();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { importServersFromFile } = serversImporter;
|
||||
const { onImport, createServers } = this.props;
|
||||
const onChange = ({ target }) =>
|
||||
importServersFromFile(target.files[0])
|
||||
.then(createServers)
|
||||
.then(onImport)
|
||||
.then(() => {
|
||||
// Reset input after processing file
|
||||
target.value = null;
|
||||
});
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary mr-2"
|
||||
id="importBtn"
|
||||
onClick={() => this.fileRef.current.click()}
|
||||
>
|
||||
Import from file
|
||||
</button>
|
||||
<UncontrolledTooltip placement="top" 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={this.fileRef}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ImportServersBtn;
|
||||
54
src/servers/helpers/ImportServersBtn.tsx
Normal file
54
src/servers/helpers/ImportServersBtn.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useRef, RefObject, ChangeEvent, MutableRefObject } from 'react';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import ServersImporter from '../services/ServersImporter';
|
||||
import { ServerData } from '../data';
|
||||
|
||||
type Ref<T> = RefObject<T> | MutableRefObject<T>;
|
||||
|
||||
export interface ImportServersBtnProps {
|
||||
onImport?: () => void;
|
||||
onImportError?: () => void;
|
||||
}
|
||||
|
||||
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
||||
createServers: (servers: ServerData[]) => void;
|
||||
fileRef: Ref<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
|
||||
createServers,
|
||||
fileRef,
|
||||
onImport = () => {},
|
||||
onImportError = () => {},
|
||||
}: ImportServersBtnConnectProps) => {
|
||||
const ref = fileRef ?? useRef<HTMLInputElement>();
|
||||
const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||
importServersFromFile(target.files?.[0])
|
||||
.then(createServers)
|
||||
.then(onImport)
|
||||
.then(() => {
|
||||
// Reset input after processing file
|
||||
(target as { value: string | null }).value = null;
|
||||
})
|
||||
.catch(onImportError);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<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">
|
||||
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} />
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportServersBtn;
|
||||
17
src/servers/helpers/ServerError.scss
Normal file
17
src/servers/helpers/ServerError.scss
Normal file
@@ -0,0 +1,17 @@
|
||||
@import '../../utils/base';
|
||||
|
||||
.server-error__container {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.server-error__delete-btn {
|
||||
color: $dangerColor;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.server-error__delete-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
45
src/servers/helpers/ServerError.tsx
Normal file
45
src/servers/helpers/ServerError.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Message from '../../utils/Message';
|
||||
import ServersListGroup from '../ServersListGroup';
|
||||
import { DeleteServerButtonProps } from '../DeleteServerButton';
|
||||
import { isServerWithId, SelectedServer, ServersMap } from '../data';
|
||||
import './ServerError.scss';
|
||||
|
||||
interface ServerErrorProps {
|
||||
servers: ServersMap;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
export const ServerError = (DeleteServerButton: FC<DeleteServerButtonProps>): FC<ServerErrorProps> => (
|
||||
{ servers, selectedServer },
|
||||
) => (
|
||||
<div className="server-error__container flex-column">
|
||||
<div className="row w-100 mb-3 mb-md-5">
|
||||
<Message type="error">
|
||||
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
|
||||
{isServerWithId(selectedServer) && (
|
||||
<React.Fragment>
|
||||
<p>Oops! Could not connect to this Shlink server.</p>
|
||||
Make sure you have internet connection, and the server is properly configured and on-line.
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<ServersListGroup servers={Object.values(servers)}>
|
||||
These are the Shlink servers currently configured. Choose one of
|
||||
them or <Link to="/server/create">add a new one</Link>.
|
||||
</ServersListGroup>
|
||||
|
||||
{isServerWithId(selectedServer) && (
|
||||
<div className="container mt-3 mt-md-5">
|
||||
<h5>
|
||||
Alternatively, if you think you may have miss-configured this server, you
|
||||
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or
|
||||
<Link to={`/server/${selectedServer.id}/edit`}>edit it</Link>.
|
||||
</h5>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
32
src/servers/helpers/ServerForm.tsx
Normal file
32
src/servers/helpers/ServerForm.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React, { FC, useEffect, useState } from 'react';
|
||||
import { HorizontalFormGroup } from '../../utils/HorizontalFormGroup';
|
||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||
import { ServerData } from '../data';
|
||||
|
||||
interface ServerFormProps {
|
||||
onSubmit: (server: ServerData) => void;
|
||||
initialValues?: ServerData;
|
||||
}
|
||||
|
||||
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children }) => {
|
||||
const [ name, setName ] = useState('');
|
||||
const [ url, setUrl ] = useState('');
|
||||
const [ apiKey, setApiKey ] = useState('');
|
||||
const handleSubmit = handleEventPreventingDefault(() => onSubmit({ name, url, apiKey }));
|
||||
|
||||
useEffect(() => {
|
||||
initialValues && setName(initialValues.name);
|
||||
initialValues && setUrl(initialValues.url);
|
||||
initialValues && setApiKey(initialValues.apiKey);
|
||||
}, [ initialValues ]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HorizontalFormGroup value={name} onChange={setName}>Name</HorizontalFormGroup>
|
||||
<HorizontalFormGroup type="url" value={url} onChange={setUrl}>URL</HorizontalFormGroup>
|
||||
<HorizontalFormGroup value={apiKey} onChange={setApiKey}>API key</HorizontalFormGroup>
|
||||
|
||||
<div className="text-right">{children}</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
33
src/servers/helpers/withSelectedServer.tsx
Normal file
33
src/servers/helpers/withSelectedServer.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import { RouteComponentProps } from 'react-router';
|
||||
import Message from '../../utils/Message';
|
||||
import { isNotFoundServer, SelectedServer } from '../data';
|
||||
|
||||
interface WithSelectedServerProps extends RouteComponentProps<{ serverId: string }> {
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
selectServer(match.params.serverId);
|
||||
}, [ match.params.serverId ]);
|
||||
|
||||
if (!selectedServer) {
|
||||
return (
|
||||
<div className="row">
|
||||
<Message loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isNotFoundServer(selectedServer)) {
|
||||
return <ServerError />;
|
||||
}
|
||||
|
||||
return <WrappedComponent {...props} />;
|
||||
};
|
||||
}
|
||||
15
src/servers/helpers/withoutSelectedServer.tsx
Normal file
15
src/servers/helpers/withoutSelectedServer.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React, { FC, useEffect } from 'react';
|
||||
|
||||
interface WithoutSelectedServerProps {
|
||||
resetSelectedServer: Function;
|
||||
}
|
||||
|
||||
export function withoutSelectedServer<T = {}>(WrappedComponent: FC<WithoutSelectedServerProps & T>) {
|
||||
return (props: WithoutSelectedServerProps & T) => {
|
||||
useEffect(() => {
|
||||
props.resetSelectedServer();
|
||||
}, []);
|
||||
|
||||
return <WrappedComponent {...props} />;
|
||||
};
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const serverType = PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
apiKey: PropTypes.string,
|
||||
});
|
||||
25
src/servers/reducers/remoteServers.ts
Normal file
25
src/servers/reducers/remoteServers.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { pipe, prop } from 'ramda';
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { Dispatch } from 'redux';
|
||||
import { homepage } from '../../../package.json';
|
||||
import { 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[];
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => {
|
||||
const remoteList = await get(`${homepage}/servers.json`)
|
||||
.then(responseToServersList)
|
||||
.catch(() => []);
|
||||
|
||||
dispatch(createServers(remoteList));
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import { createAction, handleActions } from 'redux-actions';
|
||||
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
|
||||
import { versionIsValidSemVer } from '../../utils/utils';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
|
||||
export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER';
|
||||
|
||||
export const MIN_FALLBACK_VERSION = '1.0.0';
|
||||
export const MAX_FALLBACK_VERSION = '999.999.999';
|
||||
export const LATEST_VERSION_CONSTRAINT = 'latest';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
const initialState = null;
|
||||
|
||||
export const resetSelectedServer = createAction(RESET_SELECTED_SERVER);
|
||||
|
||||
export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serverId) => async (dispatch) => {
|
||||
dispatch(resetShortUrlParams());
|
||||
|
||||
const selectedServer = findServerById(serverId);
|
||||
const { health } = await buildShlinkApiClient(selectedServer);
|
||||
const version = await health()
|
||||
.then(({ version }) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version)
|
||||
.then((version) => !versionIsValidSemVer(version) ? MIN_FALLBACK_VERSION : version)
|
||||
.catch(() => MIN_FALLBACK_VERSION);
|
||||
|
||||
dispatch({
|
||||
type: SELECT_SERVER,
|
||||
selectedServer: {
|
||||
...selectedServer,
|
||||
version,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default handleActions({
|
||||
[RESET_SELECTED_SERVER]: () => initialState,
|
||||
[SELECT_SERVER]: (state, { selectedServer }) => selectedServer,
|
||||
}, initialState);
|
||||
89
src/servers/reducers/selectedServer.ts
Normal file
89
src/servers/reducers/selectedServer.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
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';
|
||||
import { ShlinkHealth } from '../../utils/services/types';
|
||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
|
||||
export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER';
|
||||
|
||||
export const MIN_FALLBACK_VERSION = '1.0.0';
|
||||
export const MAX_FALLBACK_VERSION = '999.999.999';
|
||||
export const LATEST_VERSION_CONSTRAINT = 'latest';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export interface SelectServerAction extends Action<string> {
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
const versionToSemVer = pipe(
|
||||
(version: string) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version,
|
||||
toSemVer(MIN_FALLBACK_VERSION),
|
||||
);
|
||||
|
||||
const getServerVersion = memoizeWith(
|
||||
identity,
|
||||
async (_serverId: string, health: () => Promise<ShlinkHealth>) => health().then(({ version }) => ({
|
||||
version: versionToSemVer(version),
|
||||
printableVersion: versionToPrintable(version),
|
||||
})),
|
||||
);
|
||||
|
||||
const initialState: SelectedServer = null;
|
||||
|
||||
export default buildReducer<SelectedServer, SelectServerAction>({
|
||||
[RESET_SELECTED_SERVER]: () => initialState,
|
||||
[SELECT_SERVER]: (_, { selectedServer }) => selectedServer,
|
||||
}, initialState);
|
||||
|
||||
export const resetSelectedServer = buildActionCreator(RESET_SELECTED_SERVER);
|
||||
|
||||
export const selectServer = (
|
||||
buildShlinkApiClient: ShlinkApiClientBuilder,
|
||||
loadMercureInfo: () => Action,
|
||||
) => (
|
||||
serverId: string,
|
||||
) => async (
|
||||
dispatch: Dispatch,
|
||||
getState: GetState,
|
||||
) => {
|
||||
dispatch(resetSelectedServer());
|
||||
dispatch(resetShortUrlParams());
|
||||
|
||||
const { servers } = getState();
|
||||
const selectedServer = servers[serverId];
|
||||
|
||||
if (!selectedServer) {
|
||||
dispatch<SelectServerAction>({
|
||||
type: SELECT_SERVER,
|
||||
selectedServer: { serverNotFound: true },
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { health } = buildShlinkApiClient(selectedServer);
|
||||
const { version, printableVersion } = await getServerVersion(serverId, health);
|
||||
|
||||
dispatch<SelectServerAction>({
|
||||
type: SELECT_SERVER,
|
||||
selectedServer: {
|
||||
...selectedServer,
|
||||
version,
|
||||
printableVersion,
|
||||
},
|
||||
});
|
||||
dispatch(loadMercureInfo());
|
||||
} catch (e) {
|
||||
dispatch<SelectServerAction>({
|
||||
type: SELECT_SERVER,
|
||||
selectedServer: { ...selectedServer, serverNotReachable: true },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import { handleActions } from 'redux-actions';
|
||||
import { pipe, isEmpty, assoc, map, prop } from 'ramda';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { homepage } from '../../../package.json';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const FETCH_SERVERS_START = 'shlink/servers/FETCH_SERVERS_START';
|
||||
export const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
const initialState = {
|
||||
list: {},
|
||||
loading: false,
|
||||
};
|
||||
|
||||
const assocId = (server) => assoc('id', server.id || uuid(), server);
|
||||
|
||||
export default handleActions({
|
||||
[FETCH_SERVERS_START]: (state) => ({ ...state, loading: true }),
|
||||
[FETCH_SERVERS]: (state, { list }) => ({ list, loading: false }),
|
||||
}, initialState);
|
||||
|
||||
export const listServers = ({ listServers, createServers }, { get }) => () => async (dispatch) => {
|
||||
dispatch({ type: FETCH_SERVERS_START });
|
||||
const localList = listServers();
|
||||
|
||||
if (!isEmpty(localList)) {
|
||||
dispatch({ type: FETCH_SERVERS, list: localList });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If local list is empty, try to fetch it remotely (making sure it's an array) and calculate IDs for every server
|
||||
const getDataAsArrayWithIds = pipe(
|
||||
prop('data'),
|
||||
(value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error('Value is not an array');
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
map(assocId),
|
||||
);
|
||||
const remoteList = await get(`${homepage}/servers.json`)
|
||||
.then(getDataAsArrayWithIds)
|
||||
.catch(() => []);
|
||||
|
||||
createServers(remoteList);
|
||||
dispatch({ type: FETCH_SERVERS, list: remoteList.reduce((map, server) => ({ ...map, [server.id]: server }), {}) });
|
||||
};
|
||||
|
||||
export const createServer = ({ createServer }, listServersAction) => pipe(createServer, listServersAction);
|
||||
|
||||
export const deleteServer = ({ deleteServer }, listServersAction) => pipe(deleteServer, listServersAction);
|
||||
|
||||
export const createServers = ({ createServers }, listServersAction) => pipe(
|
||||
map(assocId),
|
||||
createServers,
|
||||
listServersAction
|
||||
);
|
||||
51
src/servers/reducers/servers.ts
Normal file
51
src/servers/reducers/servers.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { assoc, dissoc, map, pipe, reduce } from 'ramda';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Action } from 'redux';
|
||||
import { ServerData, ServersMap, ServerWithId } from '../data';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
|
||||
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
|
||||
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export interface CreateServersAction extends Action<string> {
|
||||
newServers: ServersMap;
|
||||
}
|
||||
|
||||
const initialState: ServersMap = {};
|
||||
|
||||
const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
||||
if ((server as ServerWithId).id) {
|
||||
return server as ServerWithId;
|
||||
}
|
||||
|
||||
return assoc('id', uuid(), server);
|
||||
};
|
||||
|
||||
export default buildReducer<ServersMap, CreateServersAction>({
|
||||
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
|
||||
[DELETE_SERVER]: (state, { serverId }: any) => dissoc(serverId, state),
|
||||
[EDIT_SERVER]: (state, { serverId, serverData }: any) => !state[serverId]
|
||||
? state
|
||||
: assoc(serverId, { ...state[serverId], ...serverData }, state),
|
||||
}, initialState);
|
||||
|
||||
const serversListToMap = reduce<ServerWithId, ServersMap>((acc, server) => assoc(server.id, server, acc), {});
|
||||
|
||||
export const createServers = pipe(
|
||||
map(serverWithId),
|
||||
serversListToMap,
|
||||
(newServers: ServersMap) => ({ type: CREATE_SERVERS, newServers }),
|
||||
);
|
||||
|
||||
export const createServer = (server: ServerWithId) => createServers([ server ]);
|
||||
|
||||
export const editServer = (serverId: string, serverData: Partial<ServerData>) => ({
|
||||
type: EDIT_SERVER,
|
||||
serverId,
|
||||
serverData,
|
||||
});
|
||||
|
||||
export const deleteServer = ({ id }: ServerWithId) => ({ type: DELETE_SERVER, serverId: id });
|
||||
@@ -1,6 +1,9 @@
|
||||
import { dissoc, head, keys, values } from 'ramda';
|
||||
import { CsvJson } from 'csvjson';
|
||||
import LocalStorage from '../../utils/services/LocalStorage';
|
||||
import { ServersMap } from '../data';
|
||||
|
||||
const saveCsv = (window, csv) => {
|
||||
const saveCsv = (window: Window, csv: string) => {
|
||||
const { navigator, document } = window;
|
||||
const filename = 'shlink-servers.csv';
|
||||
const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
|
||||
@@ -25,14 +28,14 @@ const saveCsv = (window, csv) => {
|
||||
};
|
||||
|
||||
export default class ServersExporter {
|
||||
constructor(serversService, window, csvjson) {
|
||||
this.serversService = serversService;
|
||||
this.window = window;
|
||||
this.csvjson = csvjson;
|
||||
}
|
||||
public constructor(
|
||||
private readonly storage: LocalStorage,
|
||||
private readonly window: Window,
|
||||
private readonly csvjson: CsvJson,
|
||||
) {}
|
||||
|
||||
exportServers = async () => {
|
||||
const servers = values(this.serversService.listServers()).map(dissoc('id'));
|
||||
public readonly exportServers = async () => {
|
||||
const servers = values(this.storage.get<ServersMap>('servers') || {}).map(dissoc('id'));
|
||||
|
||||
try {
|
||||
const csv = this.csvjson.toCSV(servers, {
|
||||
@@ -1,23 +0,0 @@
|
||||
export default class ServersImporter {
|
||||
constructor(csvjson) {
|
||||
this.csvjson = csvjson;
|
||||
}
|
||||
|
||||
importServersFromFile = (file) => {
|
||||
if (!file || file.type !== 'text/csv') {
|
||||
return Promise.reject('No file provided or file is not a CSV');
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
reader.addEventListener('loadend', (e) => {
|
||||
const content = e.target.result;
|
||||
const servers = this.csvjson.toObject(content);
|
||||
|
||||
resolve(servers);
|
||||
});
|
||||
reader.readAsText(file);
|
||||
});
|
||||
};
|
||||
}
|
||||
26
src/servers/services/ServersImporter.ts
Normal file
26
src/servers/services/ServersImporter.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { CsvJson } from 'csvjson';
|
||||
import { ServerData } from '../data';
|
||||
|
||||
const CSV_MIME_TYPE = 'text/csv';
|
||||
|
||||
export default class ServersImporter {
|
||||
public constructor(private readonly csvjson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
|
||||
|
||||
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
|
||||
if (!file || file.type !== CSV_MIME_TYPE) {
|
||||
throw new Error('No file provided or file is not a CSV');
|
||||
}
|
||||
|
||||
const reader = this.fileReaderFactory();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
reader.addEventListener('loadend', (e: ProgressEvent<FileReader>) => {
|
||||
const content = e.target?.result?.toString() ?? '';
|
||||
const servers = this.csvjson.toObject<ServerData>(content);
|
||||
|
||||
resolve(servers);
|
||||
});
|
||||
reader.readAsText(file);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { assoc, dissoc, reduce } from 'ramda';
|
||||
|
||||
const SERVERS_STORAGE_KEY = 'servers';
|
||||
|
||||
export default class ServersService {
|
||||
constructor(storage) {
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
listServers = () => this.storage.get(SERVERS_STORAGE_KEY) || {};
|
||||
|
||||
findServerById = (serverId) => this.listServers()[serverId];
|
||||
|
||||
createServer = (server) => this.createServers([ server ]);
|
||||
|
||||
createServers = (servers) => {
|
||||
const allServers = reduce(
|
||||
(serversObj, server) => assoc(server.id, server, serversObj),
|
||||
this.listServers(),
|
||||
servers
|
||||
);
|
||||
|
||||
this.storage.set(SERVERS_STORAGE_KEY, allServers);
|
||||
};
|
||||
|
||||
deleteServer = ({ id }) =>
|
||||
this.storage.set(SERVERS_STORAGE_KEY, dissoc(id, this.listServers()));
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import csvjson from 'csvjson';
|
||||
import CreateServer from '../CreateServer';
|
||||
import ServersDropdown from '../ServersDropdown';
|
||||
import DeleteServerModal from '../DeleteServerModal';
|
||||
import DeleteServerButton from '../DeleteServerButton';
|
||||
import ImportServersBtn from '../helpers/ImportServersBtn';
|
||||
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
|
||||
import { createServer, createServers, deleteServer, listServers } from '../reducers/server';
|
||||
import ServersImporter from './ServersImporter';
|
||||
import ServersService from './ServersService';
|
||||
import ServersExporter from './ServersExporter';
|
||||
|
||||
const provideServices = (bottle, connect, withRouter) => {
|
||||
// Components
|
||||
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'stateFlagTimeout');
|
||||
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
|
||||
bottle.decorator('ServersDropdown', withRouter);
|
||||
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ], [ 'listServers', 'selectServer' ]));
|
||||
|
||||
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' ]));
|
||||
|
||||
// Services
|
||||
bottle.constant('csvjson', csvjson);
|
||||
bottle.service('ServersImporter', ServersImporter, 'csvjson');
|
||||
bottle.service('ServersService', ServersService, 'Storage');
|
||||
bottle.service('ServersExporter', ServersExporter, 'ServersService', 'window', 'csvjson');
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('selectServer', selectServer, 'ServersService', 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers');
|
||||
bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers');
|
||||
bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers');
|
||||
bottle.serviceFactory('listServers', listServers, 'ServersService', 'axios');
|
||||
|
||||
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
63
src/servers/services/provideServices.ts
Normal file
63
src/servers/services/provideServices.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import csvjson from 'csvjson';
|
||||
import Bottle, { Decorator } from 'bottlejs';
|
||||
import CreateServer from '../CreateServer';
|
||||
import ServersDropdown from '../ServersDropdown';
|
||||
import DeleteServerModal from '../DeleteServerModal';
|
||||
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 { 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 ServersImporter from './ServersImporter';
|
||||
import ServersExporter from './ServersExporter';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||
// Components
|
||||
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
|
||||
bottle.decorator('CreateServer', withoutSelectedServer);
|
||||
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
|
||||
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
|
||||
|
||||
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
|
||||
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.serviceFactory('ForServerVersion', () => ForServerVersion);
|
||||
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton');
|
||||
bottle.decorator('ServerError', connect([ 'servers', 'selectedServer' ]));
|
||||
|
||||
// Services
|
||||
bottle.constant('csvjson', csvjson);
|
||||
bottle.constant('fileReaderFactory', () => new FileReader());
|
||||
bottle.service('ServersImporter', ServersImporter, 'csvjson', 'fileReaderFactory');
|
||||
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'csvjson');
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('selectServer', selectServer, 'buildShlinkApiClient', 'loadMercureInfo');
|
||||
bottle.serviceFactory('createServer', () => createServer);
|
||||
bottle.serviceFactory('createServers', () => createServers);
|
||||
bottle.serviceFactory('deleteServer', () => deleteServer);
|
||||
bottle.serviceFactory('editServer', () => editServer);
|
||||
bottle.serviceFactory('fetchServers', fetchServers, 'axios');
|
||||
|
||||
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
53
src/settings/RealTimeUpdates.tsx
Normal file
53
src/settings/RealTimeUpdates.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { Card, CardBody, CardHeader, FormGroup, Input } from 'reactstrap';
|
||||
import classNames from 'classnames';
|
||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||
import { Settings } from './reducers/settings';
|
||||
|
||||
interface RealTimeUpdatesProps {
|
||||
settings: Settings;
|
||||
toggleRealTimeUpdates: (enabled: boolean) => void;
|
||||
setRealTimeUpdatesInterval: (interval: number) => void;
|
||||
}
|
||||
|
||||
const intervalValue = (interval?: number) => !interval ? '' : `${interval}`;
|
||||
|
||||
const RealTimeUpdates = (
|
||||
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
|
||||
) => (
|
||||
<Card>
|
||||
<CardHeader>Real-time updates</CardHeader>
|
||||
<CardBody>
|
||||
<FormGroup>
|
||||
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
|
||||
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
|
||||
</ToggleSwitch>
|
||||
</FormGroup>
|
||||
<FormGroup className="mb-0">
|
||||
<label className={classNames({ 'text-muted': !realTimeUpdates.enabled })}>
|
||||
Real-time updates frequency (in minutes):
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Immediate"
|
||||
disabled={!realTimeUpdates.enabled}
|
||||
value={intervalValue(realTimeUpdates.interval)}
|
||||
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
|
||||
/>
|
||||
{realTimeUpdates.enabled && (
|
||||
<small className="form-text text-muted">
|
||||
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
|
||||
<span>
|
||||
Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
|
||||
</span>
|
||||
)}
|
||||
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
|
||||
</small>
|
||||
)}
|
||||
</FormGroup>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default RealTimeUpdates;
|
||||
10
src/settings/Settings.tsx
Normal file
10
src/settings/Settings.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React, { FC } from 'react';
|
||||
import NoMenuLayout from '../common/NoMenuLayout';
|
||||
|
||||
const Settings = (RealTimeUpdates: FC) => () => (
|
||||
<NoMenuLayout>
|
||||
<RealTimeUpdates />
|
||||
</NoMenuLayout>
|
||||
);
|
||||
|
||||
export default Settings;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user