Compare commits
256 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
44aca4aeda | ||
|
|
5762342d6c | ||
|
|
2236ed467e | ||
|
|
d244b830ac | ||
|
|
e89b68fe1e | ||
|
|
1f588c5b13 | ||
|
|
38cad143a0 | ||
|
|
f52bcc5389 | ||
|
|
caa6f7bcd8 | ||
|
|
207a8cef20 | ||
|
|
d44a4b260e | ||
|
|
80a8e0b55c | ||
|
|
2d60f830f7 | ||
|
|
90751a09f7 | ||
|
|
301da4bb2a | ||
|
|
c90cd46095 | ||
|
|
7826000384 | ||
|
|
b48dcdd5e1 | ||
|
|
4f6326b139 | ||
|
|
cff96eeccc | ||
|
|
5eb4a3adec | ||
|
|
b60908a5e9 | ||
|
|
124441238b | ||
|
|
4ec0287a74 | ||
|
|
05c67a5c99 | ||
|
|
f507a3628c | ||
|
|
89e9d2b2d1 | ||
|
|
595858ac4b | ||
|
|
3f2162fe62 | ||
|
|
f2cb30409a | ||
|
|
5c4fec5a2f | ||
|
|
e96c119432 | ||
|
|
0920962d72 | ||
|
|
aaeb0fff78 | ||
|
|
de41f50945 | ||
|
|
0f51bf95e3 | ||
|
|
ba8cade6fc | ||
|
|
dbefae5a01 | ||
|
|
727b219742 | ||
|
|
fb25e44b58 | ||
|
|
fe2d394831 | ||
|
|
efd08ff1d6 | ||
|
|
4b861a5376 | ||
|
|
2076e7d5e8 | ||
|
|
37f6f1f90c | ||
|
|
81f76e0bd6 | ||
|
|
69b305cd8a | ||
|
|
45742a066e | ||
|
|
86fb8b3f7c | ||
|
|
9c0fc8e1d2 | ||
|
|
10d6302180 | ||
|
|
da7ed6992f | ||
|
|
32c9375ac8 | ||
|
|
7ed1334a51 | ||
|
|
d9097896f6 | ||
|
|
359b16e700 | ||
|
|
0237af771d | ||
|
|
86cce5b205 | ||
|
|
fc7a2e0c6d | ||
|
|
f74d135922 | ||
|
|
66124370a6 | ||
|
|
e9fc2bb73a | ||
|
|
12f6b94ece | ||
|
|
d9a8243d36 | ||
|
|
232c54885e | ||
|
|
42c43f6c78 | ||
|
|
9d2494834c | ||
|
|
a7613435ea | ||
|
|
c9df044e1a | ||
|
|
5a37787042 | ||
|
|
923cc3ba01 | ||
|
|
8fcf72f564 | ||
|
|
a7f7666ccd | ||
|
|
c181948afe | ||
|
|
ce9ecd7b93 | ||
|
|
354d19af1b | ||
|
|
6d996baf5d | ||
|
|
4120d09220 | ||
|
|
67a23bfe33 | ||
|
|
08b710930d | ||
|
|
7ec3b332ed | ||
|
|
722eb060f0 | ||
|
|
ce740aed68 | ||
|
|
09f582daa1 | ||
|
|
1b5f7b0d76 | ||
|
|
2c93e9a587 | ||
|
|
ab0976981b | ||
|
|
959ce42137 | ||
|
|
1c25db9179 | ||
|
|
810ddd7717 | ||
|
|
7bbff114a4 | ||
|
|
99475fc311 | ||
|
|
df121eb294 | ||
|
|
138194a149 | ||
|
|
ab99213d8c | ||
|
|
2fe923678e | ||
|
|
34f194c714 | ||
|
|
2bef398d4c | ||
|
|
404b5c45dd | ||
|
|
f607ade508 |
@@ -1,6 +1,6 @@
|
||||
./.github
|
||||
./build
|
||||
./coverage
|
||||
./dist
|
||||
./node_modules
|
||||
./test
|
||||
./shlink-web-client.gif
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"no-warning-comments": "off",
|
||||
"no-magic-numbers": "off",
|
||||
"no-undefined": "off",
|
||||
"no-inline-comments": "off",
|
||||
"lines-around-comment": "off",
|
||||
"indent": ["error", 2, {
|
||||
"SwitchCase": 1
|
||||
}
|
||||
|
||||
2
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
github: ['acelaya']
|
||||
custom: ['https://acel.me/donate']
|
||||
2
.github/ISSUE_TEMPLATE.md
vendored
@@ -2,5 +2,5 @@
|
||||
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.
|
||||
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.
|
||||
Try to be polite, and understand it is impossible for a project to cover all use cases.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
-->
|
||||
|
||||
35
.github/ISSUE_TEMPLATE/Bug.md
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Something on shlink is broken or not working as documented?
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
-->
|
||||
|
||||
#### Shlink web client version
|
||||
|
||||
* Version: x.y.z
|
||||
* How do you use shlink-web-client: app.shlink.io|Docker image|self-hosted
|
||||
|
||||
#### Summary
|
||||
|
||||
<!-- Provide a summary describing the problem you are experiencing. -->
|
||||
|
||||
#### Current behavior
|
||||
|
||||
<!-- How is it actually behaving (and it shouldn't)? -->
|
||||
|
||||
#### Expected behavior
|
||||
|
||||
<!-- How did you expected to behave? -->
|
||||
|
||||
#### How to reproduce
|
||||
|
||||
<!-- Provide steps to reproduce the bug. -->
|
||||
18
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Do you find shlink is missing some important feature that would make it more useful?
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
-->
|
||||
|
||||
#### Summary
|
||||
|
||||
<!-- Describe the new feature you would like to request. -->
|
||||
23
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: Question - Support
|
||||
about: Do you have a problem setting up or using shlink?
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
-->
|
||||
|
||||
#### Shlink web client version
|
||||
|
||||
* Version: x.y.z
|
||||
* How do you use shlink-web-client: app.shlink.io|Docker image|self-hosted
|
||||
|
||||
#### Summary
|
||||
|
||||
<!-- Describe the issue you are facing here. -->
|
||||
@@ -1,6 +1,6 @@
|
||||
build:
|
||||
environment:
|
||||
node: v10.15.3
|
||||
node: v12.14.1
|
||||
tools:
|
||||
external_code_coverage:
|
||||
timeout: 1200
|
||||
|
||||
29
.travis.yml
@@ -1,7 +1,7 @@
|
||||
language: node_js
|
||||
|
||||
node_js:
|
||||
- "10.15.3"
|
||||
- "12.14.1"
|
||||
|
||||
cache:
|
||||
directories:
|
||||
@@ -15,26 +15,31 @@ install:
|
||||
|
||||
before_script:
|
||||
- echo "Building commit range ${TRAVIS_COMMIT_RANGE}"
|
||||
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep src/ | paste -sd ",")
|
||||
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep -E 'src\/(.*).(js|ts|jsx|tsx)$' | paste -sd ",")
|
||||
|
||||
script:
|
||||
- npm run lint
|
||||
- npm run test:ci
|
||||
- if [[ -z $TRAVIS_TAG ]]; then docker build -t shlink-web-client:test . ; fi
|
||||
- if [[ -z $TRAVIS_TAG ]]; then npm run mutate:ci ; fi
|
||||
- if [[ $TRAVIS_PULL_REQUEST != 'false' ]]; then docker build -t shlink-web-client:test . ; fi
|
||||
- if [[ $TRAVIS_PULL_REQUEST != 'false' ]]; then npm run mutate:ci ; fi
|
||||
|
||||
after_success:
|
||||
- node_modules/.bin/ocular coverage/clover.xml
|
||||
|
||||
# Before deploying, build dist file for current travis tag
|
||||
before_deploy:
|
||||
- npm run build ${TRAVIS_TAG#?}
|
||||
- if [[ ! -z $TRAVIS_TAG ]]; then npm run build ${TRAVIS_TAG#?} ; fi
|
||||
|
||||
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
|
||||
- provider: script
|
||||
script: bash ./scripts/docker/build
|
||||
on:
|
||||
all_branches: true
|
||||
condition: $TRAVIS_PULL_REQUEST == 'false'
|
||||
- provider: releases
|
||||
api_key:
|
||||
secure: jBvPwC7EAbViaNR83rwMSt5XQDK0Iu9rgvEMa7GoyShbHcvUCCPd73Tu9quNpKi6NKsDY3INHgtch3vgonjGNGDGJ+yDyIBzXcvsAX2x3UcHpRbgY12uiINVmQxBI1+OVQB016Nm+cKC/i5Z36K4EmDbYfo+MrKndngM6AjcQFTwI8EwniIMaQgg4gNes//K8NhP5u0c3gwG+Q6jEGnq6uH3kcRgh6/epIZYpQyxjqWqKwF77sgcYj+X2Nf6XxtB5neuCi301UKLoLx8G0skh/Lm6KAIO4s9iIhIFa3UpoF21Ka0TxLpd2JxalLryCnFGlWWE6lxC9Htmc0TeRowJQlGdJXCskJ37xT9MljKY0fwNMu06VS/FUgykuCv+jP3zQu51pKu7Ew7+WeNPjautoOTu54VkdGyHcf2ThBNEyJQuiEwAQe4u7yAxY6R5ovEdvHBSIg4w1E5/Mxy5SMTCUlIAv6H7QQ1X9Z/zJm9HH5KeKz5tsHvQ/RIdSpgHXq/tC8o4Yup/LCFucXfrgvy/8pJoO1UpOlmvm62974NFfo0EG5YWwv6brUqz3QXpMjb8sWqgjltYMYJX3J7WZ34rIc+zt4NAmfhqgczaOC4pUGCiJ8jX3rMWIaQRn1AJ+5V337jL9fNDpTHny4phQjHrMJ1e0HZuNp0Xb5Q8wgqDPM=
|
||||
file: "./dist/shlink-web-client_${TRAVIS_TAG#?}_dist.zip"
|
||||
skip_cleanup: true
|
||||
on:
|
||||
tags: true
|
||||
|
||||
186
CHANGELOG.md
@@ -4,6 +4,192 @@ 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.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
|
||||
|
||||
* [#174](https://github.com/shlinkio/shlink-web-client/issues/174) Added complete support for Shlink v2.x together with currently supported Shlink versions.
|
||||
* [#164](https://github.com/shlinkio/shlink-web-client/issues/164) Added max visits control on those URLs which have `maxVisits`.
|
||||
* [#178](https://github.com/shlinkio/shlink-web-client/issues/178) Short URLs list can now be filtered by date range.
|
||||
* [#46](https://github.com/shlinkio/shlink-web-client/issues/46) Allowed short URL's metadata to be edited (`maxVisits`, `validSince` and `validUntil`).
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#170](https://github.com/shlinkio/shlink-web-client/issues/170) Fixed apple icon referencing to incorrect file names.
|
||||
|
||||
|
||||
## 2.2.2 - 2019-10-21
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#167](https://github.com/shlinkio/shlink-web-client/issues/167) Fixed `/servers.json` path not being ignored when returning something other than an array.
|
||||
|
||||
|
||||
## 2.2.1 - 2019-10-18
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#165](https://github.com/shlinkio/shlink-web-client/issues/165) Fixed error thrown when opening "create" page while using a Shlink version which does not return a valid SemVer version (like `latest` docker image, or any development instance).
|
||||
|
||||
|
||||
## 2.2.0 - 2019-10-05
|
||||
|
||||
#### Added
|
||||
|
||||
* [#144](https://github.com/shlinkio/shlink-web-client/issues/144) Added domain input to create domain page.
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#140](https://github.com/shlinkio/shlink-web-client/issues/140) Updated project dependencies.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## 2.1.1 - 2019-09-22
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#142](https://github.com/shlinkio/shlink-web-client/issues/142) Updated to newer versions of base docker images for dev and production.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#151](https://github.com/shlinkio/shlink-web-client/issues/151) Fixed "order by" indicator (caret) still indicate ASC on column header when no order is specified.
|
||||
* [#157](https://github.com/shlinkio/shlink-web-client/issues/157) Fixed pagination control on graphs expanding too much when lots of pages need to be rendered.
|
||||
* [#155](https://github.com/shlinkio/shlink-web-client/issues/155) Fixed client-side paths resolve to 404 when served from nginx in docker image instead of falling back to `index.html`.
|
||||
|
||||
|
||||
## 2.1.0 - 2019-05-19
|
||||
|
||||
#### Added
|
||||
|
||||
17
Dockerfile
@@ -1,8 +1,17 @@
|
||||
FROM node:10.15.3-alpine as node
|
||||
FROM node:12.14.1-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 && \
|
||||
UNCOMPRESSED="shlink-web-client_${VERSION}_dist" && \
|
||||
DIST_FILE="./dist/${UNCOMPRESSED}.zip" && \
|
||||
# If a dist file already exists, just unzip it
|
||||
if [[ -f ${DIST_FILE} ]]; then unzip ${DIST_FILE} && mv ./${UNCOMPRESSED} ./build ; fi && \
|
||||
# If no dist file exsts, build from scratch
|
||||
if [[ ! -f ${DIST_FILE} ]]; then npm install && npm run build -- ${VERSION} --no-dist ; fi
|
||||
|
||||
FROM nginx:1.15.9-alpine
|
||||
FROM nginx:1.17.7-alpine
|
||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||
RUN rm -r /usr/share/nginx/html
|
||||
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
|
||||
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=node /shlink-web-client/build /usr/share/nginx/html
|
||||
|
||||
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018-2019 shlinkio
|
||||
Copyright (c) 2018-2020 shlinkio
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
31
README.md
@@ -1,36 +1,45 @@
|
||||
# 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://github.com/shlinkio/shlink-web-client/releases/latest)
|
||||
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||
[](https://github.com/shlinkio/shlink-web-client/blob/master/LICENSE)
|
||||
[](https://acel.me/donate)
|
||||
[](https://slnk.to/donate)
|
||||
|
||||
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
|
||||
17
config/docker/nginx.conf
Normal file
@@ -0,0 +1,17 @@
|
||||
server {
|
||||
listen 80 default_server;
|
||||
charset utf-8;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# When requesting static paths with extension, try them, and return a 404 if not found
|
||||
location ~* .+\.(css|js|html|png|jpe?g|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# When requesting a path without extension, try it, and return the index if not found
|
||||
# This allows HTML5 history paths to be handled by the client application
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html$is_args$args;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ version: '3'
|
||||
services:
|
||||
shlink_web_client_node:
|
||||
container_name: shlink_web_client_node
|
||||
image: node:10.15.3-alpine
|
||||
image: node:12.14.1-alpine
|
||||
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||
volumes:
|
||||
- ./:/home/shlink/www
|
||||
|
||||
16482
package-lock.json
generated
175
package.json
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"name": "shlink-web-client-react",
|
||||
"name": "shlink-web-client",
|
||||
"description": "A React-based progressive web application for shlink",
|
||||
"version": "1.0.0",
|
||||
"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",
|
||||
@@ -20,120 +22,123 @@
|
||||
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.6.3",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.6.3",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.6.3",
|
||||
"@fortawesome/react-fontawesome": "^0.1.3",
|
||||
"@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",
|
||||
"array-filter": "^1.0.0",
|
||||
"array-map": "^0.0.0",
|
||||
"array-reduce": "^0.0.0",
|
||||
"axios": "^0.18.0",
|
||||
"axios": "^0.19.0",
|
||||
"bootstrap": "^4.3.1",
|
||||
"bottlejs": "^1.7.1",
|
||||
"chart.js": "^2.7.2",
|
||||
"bottlejs": "^1.7.2",
|
||||
"bowser": "^2.9.0",
|
||||
"chart.js": "^2.8.0",
|
||||
"classnames": "^2.2.6",
|
||||
"compare-versions": "^3.5.1",
|
||||
"csvjson": "^5.1.0",
|
||||
"leaflet": "^1.4.0",
|
||||
"moment": "^2.22.2",
|
||||
"promise": "^8.0.1",
|
||||
"prop-types": "^15.6.2",
|
||||
"qs": "^6.5.2",
|
||||
"leaflet": "^1.5.1",
|
||||
"moment": "^2.24.0",
|
||||
"promise": "^8.0.3",
|
||||
"prop-types": "^15.7.2",
|
||||
"qs": "^6.9.0",
|
||||
"ramda": "^0.26.1",
|
||||
"react": "^16.8.0",
|
||||
"react-autosuggest": "^9.4.0",
|
||||
"react-chartjs-2": "^2.7.4",
|
||||
"react-color": "^2.14.1",
|
||||
"react": "^16.13.1",
|
||||
"react-autosuggest": "^9.4.3",
|
||||
"react-chartjs-2": "^2.8.0",
|
||||
"react-color": "^2.17.3",
|
||||
"react-copy-to-clipboard": "^5.0.1",
|
||||
"react-datepicker": "~1.5.0",
|
||||
"react-dom": "^16.8.0",
|
||||
"react-leaflet": "^2.2.1",
|
||||
"react-moment": "^0.7.6",
|
||||
"react-redux": "^5.0.7",
|
||||
"react-router-dom": "^4.2.2",
|
||||
"react-swipeable": "^4.3.0",
|
||||
"react-dom": "^16.13.1",
|
||||
"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-tagsinput": "^3.19.0",
|
||||
"reactstrap": "^7.1.0",
|
||||
"redux": "^4.0.0",
|
||||
"reactstrap": "^8.0.1",
|
||||
"redux": "^4.0.4",
|
||||
"redux-actions": "^2.6.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"uuid": "^3.3.2"
|
||||
"uuid": "^3.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.1.6",
|
||||
"@stryker-mutator/core": "^1.2.0",
|
||||
"@stryker-mutator/html-reporter": "^1.2.0",
|
||||
"@stryker-mutator/javascript-mutator": "^1.2.0",
|
||||
"@stryker-mutator/jest-runner": "^1.2.0",
|
||||
"@svgr/webpack": "^2.4.1",
|
||||
"adm-zip": "0.4.11",
|
||||
"autoprefixer": "^7.1.6",
|
||||
"@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",
|
||||
"@svgr/webpack": "^4.3.3",
|
||||
"adm-zip": "^0.4.13",
|
||||
"autoprefixer": "^9.6.3",
|
||||
"babel-core": "7.0.0-bridge.0",
|
||||
"babel-eslint": "^10.0.1",
|
||||
"babel-jest": "^23.6.0",
|
||||
"babel-loader": "^8.0.4",
|
||||
"babel-plugin-named-asset-import": "^0.3.0",
|
||||
"babel-preset-react-app": "^7.0.0",
|
||||
"babel-eslint": "^10.0.3",
|
||||
"babel-jest": "^24.9.0",
|
||||
"babel-loader": "^8.0.6",
|
||||
"babel-plugin-named-asset-import": "^0.3.4",
|
||||
"babel-preset-react-app": "^9.0.2",
|
||||
"babel-runtime": "^6.26.0",
|
||||
"bfj": "^6.1.1",
|
||||
"case-sensitive-paths-webpack-plugin": "^2.1.2",
|
||||
"chalk": "^2.4.1",
|
||||
"css-loader": "^1.0.0",
|
||||
"dotenv": "^6.0.0",
|
||||
"dotenv-expand": "^4.2.0",
|
||||
"enzyme": "^3.3.0",
|
||||
"enzyme-adapter-react-16": "^1.1.1",
|
||||
"bfj": "^7.0.1",
|
||||
"case-sensitive-paths-webpack-plugin": "^2.2.0",
|
||||
"chalk": "^2.4.2",
|
||||
"css-loader": "^3.2.0",
|
||||
"dotenv": "^8.1.0",
|
||||
"dotenv-expand": "^5.1.0",
|
||||
"enzyme": "^3.11.0",
|
||||
"enzyme-adapter-react-16": "^1.15.2",
|
||||
"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",
|
||||
"eslint-loader": "^2.1.1",
|
||||
"eslint-plugin-import": "^2.14.0",
|
||||
"eslint-plugin-jest": "^21.22.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.1.2",
|
||||
"eslint-plugin-promise": "^4.0.1",
|
||||
"eslint-plugin-react": "^7.11.1",
|
||||
"file-loader": "^2.0.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": "^7.0.0",
|
||||
"html-webpack-plugin": "^4.0.0-alpha.2",
|
||||
"fs-extra": "^8.1.0",
|
||||
"html-webpack-plugin": "^4.0.0-beta.8",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^23.6.0",
|
||||
"jest-pnp-resolver": "^1.0.1",
|
||||
"jest-resolve": "^23.6.0",
|
||||
"mini-css-extract-plugin": "^0.4.3",
|
||||
"node-sass": "^4.9.0",
|
||||
"jest": "^24.9.0",
|
||||
"jest-pnp-resolver": "^1.2.1",
|
||||
"jest-resolve": "^24.9.0",
|
||||
"mini-css-extract-plugin": "^0.8.0",
|
||||
"node-sass": "^4.12.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"ocular.js": "^0.1.0",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.1",
|
||||
"pnp-webpack-plugin": "^1.1.0",
|
||||
"postcss": "^7.0.7",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.3",
|
||||
"pnp-webpack-plugin": "^1.5.0",
|
||||
"postcss": "^7.0.18",
|
||||
"postcss-flexbugs-fixes": "^4.1.0",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"postcss-preset-env": "^6.3.1",
|
||||
"postcss-preset-env": "^6.7.0",
|
||||
"postcss-safe-parser": "^4.0.1",
|
||||
"raf": "^3.4.0",
|
||||
"react-app-polyfill": "^0.2.0",
|
||||
"react-dev-utils": "^7.0.1",
|
||||
"resolve": "^1.8.1",
|
||||
"sass-loader": "^7.1.0",
|
||||
"serve": "^10.0.0",
|
||||
"raf": "^3.4.1",
|
||||
"react-app-polyfill": "^1.0.4",
|
||||
"react-dev-utils": "^9.1.0",
|
||||
"resolve": "^1.12.0",
|
||||
"sass-loader": "^8.0.0",
|
||||
"serve": "^11.2.0",
|
||||
"stryker-cli": "^1.0.0",
|
||||
"style-loader": "^0.23.0",
|
||||
"stylelint": "^9.9.0",
|
||||
"style-loader": "^1.0.0",
|
||||
"stylelint": "^9.10.1",
|
||||
"stylelint-config-adidas": "^1.2.1",
|
||||
"stylelint-config-adidas-bem": "^1.2.0",
|
||||
"stylelint-config-recommended-scss": "^3.2.0",
|
||||
"stylelint-scss": "^3.3.0",
|
||||
"sw-precache-webpack-plugin": "^0.11.4",
|
||||
"terser-webpack-plugin": "^1.1.0",
|
||||
"url-loader": "^1.1.1",
|
||||
"webpack": "^4.19.1",
|
||||
"webpack-dev-server": "^3.1.14",
|
||||
"webpack-manifest-plugin": "^2.0.4",
|
||||
"whatwg-fetch": "^2.0.3",
|
||||
"workbox-webpack-plugin": "^3.6.3"
|
||||
"stylelint-config-recommended-scss": "^4.0.0",
|
||||
"stylelint-scss": "^3.11.1",
|
||||
"sw-precache-webpack-plugin": "^0.11.5",
|
||||
"terser-webpack-plugin": "^2.1.2",
|
||||
"url-loader": "^2.2.0",
|
||||
"webpack": "^4.41.0",
|
||||
"webpack-dev-server": "^3.8.2",
|
||||
"webpack-manifest-plugin": "^2.2.0",
|
||||
"whatwg-fetch": "^3.0.0",
|
||||
"workbox-webpack-plugin": "^4.3.1"
|
||||
},
|
||||
"babel": {
|
||||
"presets": [
|
||||
|
||||
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]
|
||||
BIN
public/favicon.gif
Normal file
|
After Width: | Height: | Size: 642 B |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 1.1 KiB |
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
1
public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="512pt" height="512pt" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg"><g fill="#4595e3"><path d=" M 23.71 85.08 C 17.22 49.81 49.44 14.86 85.08 18.12 C 118.83 19.21 145.72 53.33 139.45 86.37 C 155.64 102.30 171.32 118.83 187.87 134.36 C 198.32 111.73 208.84 89.12 219.57 66.62 C 226.05 53.84 243.47 48.74 255.73 56.27 C 263.76 62.10 270.34 69.69 277.25 76.75 C 286.28 86.61 285.72 102.89 276.31 112.31 C 223.38 165.37 170.38 218.37 117.35 271.34 C 107.72 280.99 91.01 281.25 81.11 271.86 C 74.39 264.94 66.82 258.69 61.24 250.77 C 53.72 238.52 58.85 221.07 71.64 214.62 C 94.11 203.87 116.72 193.38 139.33 182.91 C 123.81 166.36 107.30 150.68 91.37 134.49 C 60.20 140.28 27.37 116.78 23.71 85.08 Z" /><path d=" M 205.21 201.23 C 225.32 181.36 260.88 181.11 281.14 200.86 C 299.25 218.75 317.37 236.65 335.10 254.93 C 356.73 278.01 352.01 318.70 326.03 336.56 C 320.07 330.47 313.73 324.65 308.12 318.28 C 323.86 309.39 328.76 286.18 316.63 272.39 C 301.73 256.95 286.30 242.03 271.24 226.75 C 264.49 219.65 256.80 212.00 246.37 211.52 C 224.65 208.64 205.52 233.36 214.49 253.58 C 221.09 266.81 234.22 275.12 243.62 286.24 C 240.43 295.96 238.09 306.13 238.29 316.46 C 225.55 304.29 213.16 291.73 200.89 279.09 C 180.97 257.57 183.10 220.45 205.21 201.23 Z" /><path d=" M 273.90 352.07 C 252.28 328.99 256.98 288.31 282.96 270.46 C 288.93 276.54 295.26 282.36 300.88 288.72 C 285.14 297.62 280.23 320.82 292.38 334.61 C 307.27 350.05 322.70 364.96 337.75 380.25 C 344.51 387.35 352.20 395.00 362.64 395.48 C 384.35 398.37 403.49 373.64 394.51 353.42 C 387.92 340.18 374.78 331.88 365.38 320.76 C 368.56 311.04 370.91 300.86 370.71 290.54 C 383.45 302.70 395.84 315.27 408.11 327.91 C 428.03 349.43 425.90 386.55 403.78 405.77 C 383.68 425.64 348.13 425.89 327.86 406.14 C 309.75 388.25 291.60 370.37 273.90 352.07 Z" /><path d=" M 422.11 403.83 C 431.96 394.07 441.60 384.06 451.66 374.51 C 460.90 383.74 471.89 392.70 474.89 406.11 C 480.16 429.97 484.08 454.13 488.76 478.12 C 490.00 483.41 484.47 488.29 479.35 486.63 C 454.66 481.52 429.55 478.12 405.14 471.84 C 393.17 467.97 385.20 457.75 376.55 449.27 C 386.39 439.49 396.13 429.60 406.06 419.91 C 416.37 433.45 435.74 414.00 422.11 403.83 Z" /></g></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/icons/icon-1024x1024.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
public/icons/icon-114x114.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/icons/icon-120x120.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 1.4 KiB |
BIN
public/icons/icon-150x150.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 1.3 KiB |
BIN
public/icons/icon-160x160.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/icons/icon-167x167.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/icons/icon-16x16.png
Normal file
|
After Width: | Height: | Size: 287 B |
BIN
public/icons/icon-180x180.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 1.7 KiB |
BIN
public/icons/icon-196x196.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/icons/icon-228x228.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/icons/icon-24x24.png
Normal file
|
After Width: | Height: | Size: 381 B |
BIN
public/icons/icon-256x256.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/icons/icon-310x310.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/icons/icon-32x32.png
Normal file
|
After Width: | Height: | Size: 437 B |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 3.2 KiB |
BIN
public/icons/icon-40x40.png
Normal file
|
After Width: | Height: | Size: 466 B |
BIN
public/icons/icon-48x48.png
Normal file
|
After Width: | Height: | Size: 551 B |
BIN
public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/icons/icon-60x60.png
Normal file
|
After Width: | Height: | Size: 638 B |
BIN
public/icons/icon-64x64.png
Normal file
|
After Width: | Height: | Size: 684 B |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 750 B |
BIN
public/icons/icon-76x76.png
Normal file
|
After Width: | Height: | Size: 783 B |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 984 B |
@@ -11,12 +11,74 @@
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
||||
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/icons/shlink-128.png">
|
||||
<link rel="apple-touch-icon" sizes="64x64" href="%PUBLIC_URL%/icons/shlink-64.png">
|
||||
<link rel="apple-touch-icon" sizes="32x32" href="%PUBLIC_URL%/icons/shlink-32.png">
|
||||
<link rel="apple-touch-icon" sizes="24x24" href="%PUBLIC_URL%/icons/shlink-24.png">
|
||||
<link rel="apple-touch-icon" sizes="16x16" href="%PUBLIC_URL%/icons/shlink-16.png">
|
||||
<!-- FavIcon itself -->
|
||||
<link rel="icon" type="image/x-icon" href="%PUBLIC_URL%/favicon.ico">
|
||||
<link rel="icon" type="image/svg+xml" href="%PUBLIC_URL%/favicon.svg" sizes="any">
|
||||
<link rel="icon" type="image/png" href="%PUBLIC_URL%/favicon.png">
|
||||
<link rel="icon" type="image/gif" href="%PUBLIC_URL%/favicon.gif">
|
||||
<!-- Apple Touch -->
|
||||
<link rel="apple-touch-icon" sizes="16x16" href="%PUBLIC_URL%/icons/icon-16x16.png">
|
||||
<link rel="apple-touch-icon" sizes="24x24" href="%PUBLIC_URL%/icons/icon-24x24.png">
|
||||
<link rel="apple-touch-icon" sizes="32x32" href="%PUBLIC_URL%/icons/icon-32x32.png">
|
||||
<link rel="apple-touch-icon" sizes="40x40" href="%PUBLIC_URL%/icons/icon-40x40.png">
|
||||
<link rel="apple-touch-icon" sizes="48x48" href="%PUBLIC_URL%/icons/icon-48x48.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="%PUBLIC_URL%/icons/icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="64x64" href="%PUBLIC_URL%/icons/icon-64x64.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="%PUBLIC_URL%/icons/icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="%PUBLIC_URL%/icons/icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="96x96" href="%PUBLIC_URL%/icons/icon-96x96.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="%PUBLIC_URL%/icons/icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="%PUBLIC_URL%/icons/icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="128x128" href="%PUBLIC_URL%/icons/icon-128x128.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="%PUBLIC_URL%/icons/icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="150x150" href="%PUBLIC_URL%/icons/icon-150x150.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="%PUBLIC_URL%/icons/icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="160x160" href="%PUBLIC_URL%/icons/icon-160x160.png">
|
||||
<link rel="apple-touch-icon" sizes="167x167" href="%PUBLIC_URL%/icons/icon-167x167.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/icons/icon-180x180.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="%PUBLIC_URL%/icons/icon-192x192.png">
|
||||
<link rel="apple-touch-icon" sizes="196x196" href="%PUBLIC_URL%/icons/icon-196x196.png">
|
||||
<link rel="apple-touch-icon" sizes="228x228" href="%PUBLIC_URL%/icons/icon-228x228.png">
|
||||
<link rel="apple-touch-icon" sizes="256x256" href="%PUBLIC_URL%/icons/icon-256x256.png">
|
||||
<link rel="apple-touch-icon" sizes="310x310" href="%PUBLIC_URL%/icons/icon-310x310.png">
|
||||
<link rel="apple-touch-icon" sizes="384x384" href="%PUBLIC_URL%/icons/icon-384x384.png">
|
||||
<link rel="apple-touch-icon" sizes="512x512" href="%PUBLIC_URL%/icons/icon-512x512.png">
|
||||
<link rel="apple-touch-icon" sizes="1024x1024" href="%PUBLIC_URL%/icons/icon-1024x1024.png">
|
||||
<!-- Normal -->
|
||||
<link rel="icon" type="image/png" sizes="1024x1024" href="%PUBLIC_URL%/icons/icon-1024x1024.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="%PUBLIC_URL%/icons/icon-512x512.png">
|
||||
<link rel="icon" type="image/png" sizes="384x384" href="%PUBLIC_URL%/icons/icon-384x384.png">
|
||||
<link rel="icon" type="image/png" sizes="310x310" href="%PUBLIC_URL%/icons/icon-310x310.png">
|
||||
<link rel="icon" type="image/png" sizes="256x256" href="%PUBLIC_URL%/icons/icon-256x256.png">
|
||||
<link rel="icon" type="image/png" sizes="228x228" href="%PUBLIC_URL%/icons/icon-228x228.png">
|
||||
<link rel="icon" type="image/png" sizes="196x196" href="%PUBLIC_URL%/icons/icon-196x196.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="%PUBLIC_URL%/icons/icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="180x180" href="%PUBLIC_URL%/icons/icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="167x167" href="%PUBLIC_URL%/icons/icon-167x167.png">
|
||||
<link rel="icon" type="image/png" sizes="160x160" href="%PUBLIC_URL%/icons/icon-160x160.png">
|
||||
<link rel="icon" type="image/png" sizes="152x152" href="%PUBLIC_URL%/icons/icon-152x152.png">
|
||||
<link rel="icon" type="image/png" sizes="150x150" href="%PUBLIC_URL%/icons/icon-150x150.png">
|
||||
<link rel="icon" type="image/png" sizes="144x144" href="%PUBLIC_URL%/icons/icon-144x144.png">
|
||||
<link rel="icon" type="image/png" sizes="128x128" href="%PUBLIC_URL%/icons/icon-128x128.png">
|
||||
<link rel="icon" type="image/png" sizes="120x120" href="%PUBLIC_URL%/icons/icon-120x120.png">
|
||||
<link rel="icon" type="image/png" sizes="114x114" href="%PUBLIC_URL%/icons/icon-114x114.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="%PUBLIC_URL%/icons/icon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="76x76" href="%PUBLIC_URL%/icons/icon-76x76.png">
|
||||
<link rel="icon" type="image/png" sizes="72x72" href="%PUBLIC_URL%/icons/icon-72x72.png">
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="%PUBLIC_URL%/icons/icon-64x64.png">
|
||||
<link rel="icon" type="image/png" sizes="60x60" href="%PUBLIC_URL%/icons/icon-60x60.png">
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="%PUBLIC_URL%/icons/icon-48x48.png">
|
||||
<link rel="icon" type="image/png" sizes="40x40" href="%PUBLIC_URL%/icons/icon-40x40.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/icons/icon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="24x24" href="%PUBLIC_URL%/icons/icon-24x24.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/icons/icon-16x16.png">
|
||||
<!-- MS -->
|
||||
<meta name="msapplication-TileImage" content="%PUBLIC_URL%/icons/icon-144x144.png">
|
||||
<meta name="msapplication-square70x70logo" content="%PUBLIC_URL%/icons/icon-70x70.png">
|
||||
<meta name="msapplication-square144x144logo" content="%PUBLIC_URL%/icons/icon-144x144.png">
|
||||
<meta name="msapplication-square150x150logo" content="%PUBLIC_URL%/icons/icon-150x150.png">
|
||||
<meta name="msapplication-square310x310logo" content="%PUBLIC_URL%/icons/icon-310x310.png">
|
||||
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
|
||||
@@ -6,16 +6,66 @@
|
||||
"theme_color": "#4696e5",
|
||||
"background_color": "#4696e5",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./icons/icon-16x16.png",
|
||||
"type": "image/png",
|
||||
"sizes": "16x16"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-24x24.png",
|
||||
"type": "image/png",
|
||||
"sizes": "24x24"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-32x32.png",
|
||||
"type": "image/png",
|
||||
"sizes": "32x32"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-40x40.png",
|
||||
"type": "image/png",
|
||||
"sizes": "40x40"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-48x48.png",
|
||||
"type": "image/png",
|
||||
"sizes": "48x48"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-60x60.png",
|
||||
"type": "image/png",
|
||||
"sizes": "60x60"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-64x64.png",
|
||||
"type": "image/png",
|
||||
"sizes": "64x64"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-72x72.png",
|
||||
"type": "image/png",
|
||||
"sizes": "72x72"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-76x76.png",
|
||||
"type": "image/png",
|
||||
"sizes": "76x76"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-96x96.png",
|
||||
"type": "image/png",
|
||||
"sizes": "96x96"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-114x114.png",
|
||||
"type": "image/png",
|
||||
"sizes": "114x114"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-120x120.png",
|
||||
"type": "image/png",
|
||||
"sizes": "120x120"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-128x128.png",
|
||||
"type": "image/png",
|
||||
@@ -26,20 +76,70 @@
|
||||
"type": "image/png",
|
||||
"sizes": "144x144"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-150x150.png",
|
||||
"type": "image/png",
|
||||
"sizes": "150x150"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-152x152.png",
|
||||
"type": "image/png",
|
||||
"sizes": "152x152"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-160x160.png",
|
||||
"type": "image/png",
|
||||
"sizes": "160x160"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-167x167.png",
|
||||
"type": "image/png",
|
||||
"sizes": "167x167"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-180x180.png",
|
||||
"type": "image/png",
|
||||
"sizes": "180x180"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-192x192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-196x196.png",
|
||||
"type": "image/png",
|
||||
"sizes": "196x196"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-228x228.png",
|
||||
"type": "image/png",
|
||||
"sizes": "228x228"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-256x256.png",
|
||||
"type": "image/png",
|
||||
"sizes": "256x256"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-310x310.png",
|
||||
"type": "image/png",
|
||||
"sizes": "310x310"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-384x384.png",
|
||||
"type": "image/png",
|
||||
"sizes": "384x384"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-512x512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-1024x1024.png",
|
||||
"type": "image/png",
|
||||
"sizes": "1024x1024"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -47,6 +44,8 @@ if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
|
||||
const argvSliceStart = 2;
|
||||
const argv = process.argv.slice(argvSliceStart);
|
||||
const writeStatsJson = argv.indexOf('--stats') !== -1;
|
||||
const withoutDist = argv.indexOf('--no-dist') !== -1;
|
||||
const { version, hasVersion } = getVersionFromArgs(argv);
|
||||
|
||||
// Generate configuration
|
||||
const config = configFactory('production');
|
||||
@@ -85,6 +84,7 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green('Compiled successfully.\n'));
|
||||
hasVersion && replaceVersionPlaceholder(version);
|
||||
}
|
||||
|
||||
console.log('File sizes after gzip:\n');
|
||||
@@ -96,20 +96,6 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||
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'));
|
||||
@@ -117,7 +103,7 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||
process.exit(1);
|
||||
}
|
||||
)
|
||||
.then(zipDist)
|
||||
.then(() => hasVersion && !withoutDist && zipDist(version))
|
||||
.catch((err) => {
|
||||
if (err && err.message) {
|
||||
console.log(err.message);
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
13
scripts/docker/build
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
|
||||
|
||||
if [[ -z $TRAVIS_TAG ]]; then
|
||||
docker build -t shlinkio/shlink-web-client:latest .
|
||||
docker push shlinkio/shlink-web-client:latest
|
||||
else
|
||||
docker build --build-arg VERSION=${TRAVIS_TAG#?} -t shlinkio/shlink-web-client:${TRAVIS_TAG#?} -t shlinkio/shlink-web-client:stable .
|
||||
docker push shlinkio/shlink-web-client:${TRAVIS_TAG#?}
|
||||
docker push shlinkio/shlink-web-client:stable
|
||||
fi
|
||||
@@ -81,7 +81,7 @@ checkBrowsers(paths.appPath, isInteractive)
|
||||
const urls = prepareUrls(protocol, HOST, port);
|
||||
|
||||
// Create a webpack compiler that is configured with custom messages.
|
||||
const compiler = createCompiler(webpack, config, appName, urls, useYarn);
|
||||
const compiler = createCompiler({ webpack, config, appName, urls, useYarn });
|
||||
|
||||
// Load proxy config
|
||||
const proxySetting = require(paths.appPackageJson).proxy;
|
||||
|
||||
BIN
shlink-web-client.gif
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
@@ -3,14 +3,15 @@ import { Route, Switch } from 'react-router-dom';
|
||||
import './App.scss';
|
||||
import NotFound from './common/NotFound';
|
||||
|
||||
const App = (MainHeader, Home, MenuLayout, CreateServer) => () => (
|
||||
const App = (MainHeader, Home, MenuLayout, CreateServer, EditServer) => () => (
|
||||
<div className="container-fluid app-container">
|
||||
<MainHeader />
|
||||
|
||||
<div className="app">
|
||||
<Switch>
|
||||
<Route exact path="/server/create" component={CreateServer} />
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route exact path="/server/create" component={CreateServer} />
|
||||
<Route exact path="/server/:serverId/edit" component={EditServer} />
|
||||
<Route path="/server/:serverId" component={MenuLayout} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
import { faList as listIcon, faLink as createIcon, faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import {
|
||||
faList as listIcon,
|
||||
faLink as createIcon,
|
||||
faTags as tagsIcon,
|
||||
faPen as editIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import './AsideMenu.scss';
|
||||
|
||||
const defaultProps = {
|
||||
className: '',
|
||||
showOnMobile: false,
|
||||
const AsideMenuItem = ({ children, to, className, ...rest }) => (
|
||||
<NavLink
|
||||
className={classNames('aside-menu__item', className)}
|
||||
activeClassName="aside-menu__item--selected"
|
||||
to={to}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
AsideMenuItem.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
to: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
const propTypes = {
|
||||
selectedServer: serverType,
|
||||
className: PropTypes.string,
|
||||
@@ -20,43 +38,34 @@ const propTypes = {
|
||||
const AsideMenu = (DeleteServerButton) => {
|
||||
const AsideMenu = ({ selectedServer, className, showOnMobile }) => {
|
||||
const serverId = selectedServer ? selectedServer.id : '';
|
||||
const asideClass = classnames('aside-menu', className, {
|
||||
const asideClass = classNames('aside-menu', className, {
|
||||
'aside-menu--hidden': !showOnMobile,
|
||||
});
|
||||
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
|
||||
const buildPath = (suffix) => `/server/${serverId}${suffix}`;
|
||||
|
||||
return (
|
||||
<aside className={asideClass}>
|
||||
<nav className="nav flex-column aside-menu__nav">
|
||||
<NavLink
|
||||
className="aside-menu__item"
|
||||
activeClassName="aside-menu__item--selected"
|
||||
to={`/server/${serverId}/list-short-urls/1`}
|
||||
isActive={shortUrlsIsActive}
|
||||
>
|
||||
<AsideMenuItem to={buildPath('/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`}
|
||||
>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/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`}
|
||||
>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/manage-tags')}>
|
||||
<FontAwesomeIcon icon={tagsIcon} />
|
||||
<span className="aside-menu__item-text">Manage tags</span>
|
||||
</NavLink>
|
||||
|
||||
</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>
|
||||
@@ -64,7 +73,6 @@ const AsideMenu = (DeleteServerButton) => {
|
||||
);
|
||||
};
|
||||
|
||||
AsideMenu.defaultProps = defaultProps;
|
||||
AsideMenu.propTypes = propTypes;
|
||||
|
||||
return AsideMenu;
|
||||
|
||||
@@ -67,6 +67,9 @@ $asideMenuMobileWidth: 280px;
|
||||
|
||||
.aside-menu__item--danger {
|
||||
color: $dangerColor;
|
||||
}
|
||||
|
||||
.aside-menu__item--push {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as PropTypes from 'prop-types';
|
||||
import './ErrorHandler.scss';
|
||||
import { Button } from 'reactstrap';
|
||||
|
||||
// FIXME Replace with typescript: (window, console)
|
||||
const ErrorHandler = ({ location }, { error }) => class ErrorHandler extends React.Component {
|
||||
static propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
|
||||
@@ -1,52 +1,35 @@
|
||||
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { useEffect } from 'react';
|
||||
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';
|
||||
import ServersListGroup from '../servers/ServersListGroup';
|
||||
|
||||
export default class Home extends React.Component {
|
||||
static propTypes = {
|
||||
resetSelectedServer: PropTypes.func,
|
||||
servers: PropTypes.object,
|
||||
};
|
||||
const propTypes = {
|
||||
resetSelectedServer: PropTypes.func,
|
||||
servers: PropTypes.object,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.resetSelectedServer();
|
||||
}
|
||||
const Home = ({ resetSelectedServer, servers: { list, loading } }) => {
|
||||
const servers = values(list);
|
||||
const hasServers = !isEmpty(servers);
|
||||
|
||||
render() {
|
||||
const { servers: { list, loading } } = this.props;
|
||||
const servers = values(list);
|
||||
const hasServers = !isEmpty(servers);
|
||||
useEffect(() => {
|
||||
resetSelectedServer();
|
||||
}, []);
|
||||
|
||||
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>
|
||||
return (
|
||||
<div className="home">
|
||||
<h1 className="home__title">Welcome to Shlink</h1>
|
||||
<ServersListGroup servers={servers}>
|
||||
{!loading && hasServers && <span>Please, select a server.</span>}
|
||||
{!loading && !hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
|
||||
{loading && <span>Trying to load servers...</span>}
|
||||
</ServersListGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Home.propTypes = propTypes;
|
||||
|
||||
export default Home;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@import '../utils/base';
|
||||
@import '../utils/mixins/vertical-align';
|
||||
|
||||
.home {
|
||||
text-align: 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;
|
||||
}
|
||||
|
||||
@@ -1,110 +1,82 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import Swipeable from 'react-swipeable';
|
||||
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 classNames from 'classnames';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import NotFound from './NotFound';
|
||||
import './MenuLayout.scss';
|
||||
|
||||
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits) =>
|
||||
class MenuLayout extends React.Component {
|
||||
static propTypes = {
|
||||
match: PropTypes.object,
|
||||
selectServer: PropTypes.func,
|
||||
location: PropTypes.object,
|
||||
selectedServer: serverType,
|
||||
const propTypes = {
|
||||
match: PropTypes.object,
|
||||
location: PropTypes.object,
|
||||
selectedServer: serverType,
|
||||
};
|
||||
|
||||
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits, ShlinkVersions, ServerError) => {
|
||||
const MenuLayoutComp = ({ match, location, selectedServer }) => {
|
||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
||||
const { params: { serverId } } = match;
|
||||
|
||||
useEffect(() => hideSidebar(), [ location ]);
|
||||
|
||||
if (selectedServer.serverNotReachable) {
|
||||
return <ServerError type="not-reachable" />;
|
||||
}
|
||||
|
||||
const burgerClasses = classNames('menu-layout__burger-icon', {
|
||||
'menu-layout__burger-icon--active': sidebarVisible,
|
||||
});
|
||||
const swipeMenuIfNoModalExists = (callback) => () => {
|
||||
if (document.querySelector('.modal')) {
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
};
|
||||
|
||||
state = { showSideBar: false };
|
||||
return (
|
||||
<React.Fragment>
|
||||
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
|
||||
|
||||
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 })}
|
||||
>
|
||||
<Swipeable
|
||||
delta={40}
|
||||
className="menu-layout__swipeable"
|
||||
onSwipedLeft={swipeMenuIfNoModalExists(hideSidebar)}
|
||||
onSwipedRight={swipeMenuIfNoModalExists(showSidebar)}
|
||||
>
|
||||
<div className="row menu-layout__swipeable-inner">
|
||||
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
||||
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
|
||||
<div className="menu-layout__container">
|
||||
<Switch>
|
||||
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
|
||||
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
||||
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
||||
<Route
|
||||
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" />}
|
||||
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<div className="menu-layout__footer text-center text-md-right">
|
||||
<ShlinkVersions />
|
||||
</div>
|
||||
</div>
|
||||
</Swipeable>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
</Swipeable>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
MenuLayoutComp.propTypes = propTypes;
|
||||
|
||||
return withSelectedServer(MenuLayoutComp, ServerError);
|
||||
};
|
||||
|
||||
export default MenuLayout;
|
||||
|
||||
@@ -32,3 +32,26 @@
|
||||
.menu-layout__burger-icon--active {
|
||||
color: white;
|
||||
}
|
||||
|
||||
$footer-height: 2.3rem;
|
||||
$footer-margin: .8rem;
|
||||
|
||||
.menu-layout__container {
|
||||
padding: 20px 0 ($footer-height + $footer-margin);
|
||||
min-height: 100%;
|
||||
margin-bottom: -($footer-height + $footer-margin);
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 30px 15px ($footer-height + $footer-margin);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-layout__footer {
|
||||
height: $footer-height;
|
||||
margin-top: $footer-margin;
|
||||
padding: 0;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 0 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,18 @@ import * as PropTypes from 'prop-types';
|
||||
|
||||
const propTypes = {
|
||||
to: PropTypes.string,
|
||||
btnText: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
const NotFound = ({ to = '/', btnText = 'Home' }) => (
|
||||
const NotFound = ({ to = '/', children = 'Home' }) => (
|
||||
<div className="home">
|
||||
<h2>Oops! We could not find requested route.</h2>
|
||||
<p>
|
||||
Use your browser{'\''}s back button to navigate to the page you have previously come from, or just press this button.
|
||||
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>
|
||||
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
29
src/common/ShlinkVersions.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { pipe } from 'ramda';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
|
||||
|
||||
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
|
||||
|
||||
const propTypes = {
|
||||
selectedServer: serverType,
|
||||
className: PropTypes.string,
|
||||
clientVersion: PropTypes.string,
|
||||
};
|
||||
|
||||
const ShlinkVersions = ({ selectedServer, className, clientVersion = SHLINK_WEB_CLIENT_VERSION }) => {
|
||||
const { printableVersion: serverVersion } = selectedServer;
|
||||
const normalizedClientVersion = pipe(versionToSemVer(), versionToPrintable)(clientVersion);
|
||||
|
||||
return (
|
||||
<small className={classNames('text-muted', className)}>
|
||||
Client: <b>{normalizedClientVersion}</b> - Server: <b>{serverVersion}</b>
|
||||
</small>
|
||||
);
|
||||
};
|
||||
|
||||
ShlinkVersions.propTypes = propTypes;
|
||||
|
||||
export default ShlinkVersions;
|
||||
45
src/common/SimplePaginator.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||
import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
|
||||
import './SimplePaginator.scss';
|
||||
|
||||
const propTypes = {
|
||||
pagesCount: PropTypes.number.isRequired,
|
||||
currentPage: PropTypes.number.isRequired,
|
||||
setCurrentPage: PropTypes.func.isRequired,
|
||||
centered: PropTypes.bool,
|
||||
};
|
||||
|
||||
const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage, centered = true }) => {
|
||||
if (pagesCount < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onClick = (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={isPageDisabled(pageNumber)}
|
||||
active={currentPage === pageNumber}
|
||||
>
|
||||
<PaginationLink tag="span" onClick={onClick(pageNumber)}>{pageNumber}</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem disabled={currentPage >= pagesCount}>
|
||||
<PaginationLink next tag="span" onClick={onClick(currentPage + 1)} />
|
||||
</PaginationItem>
|
||||
</Pagination>
|
||||
);
|
||||
};
|
||||
|
||||
SimplePaginator.propTypes = propTypes;
|
||||
|
||||
export default SimplePaginator;
|
||||
3
src/common/SimplePaginator.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.simple-paginator {
|
||||
user-select: none;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import Home from '../Home';
|
||||
import MenuLayout from '../MenuLayout';
|
||||
import AsideMenu from '../AsideMenu';
|
||||
import ErrorHandler from '../ErrorHandler';
|
||||
import ShlinkVersions from '../ShlinkVersions';
|
||||
|
||||
const provideServices = (bottle, connect, withRouter) => {
|
||||
bottle.constant('window', global.window);
|
||||
@@ -25,13 +26,18 @@ const provideServices = (bottle, connect, withRouter) => {
|
||||
'ShortUrls',
|
||||
'AsideMenu',
|
||||
'CreateShortUrl',
|
||||
'ShortUrlVisits'
|
||||
'ShortUrlVisits',
|
||||
'ShlinkVersions',
|
||||
'ServerError'
|
||||
);
|
||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
||||
bottle.decorator('MenuLayout', withRouter);
|
||||
|
||||
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
||||
|
||||
bottle.serviceFactory('ShlinkVersions', () => ShlinkVersions);
|
||||
bottle.decorator('ShlinkVersions', connect([ 'selectedServer' ]));
|
||||
|
||||
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
||||
};
|
||||
|
||||
|
||||
@@ -20,13 +20,13 @@ const mapActionService = (map, actionName) => ({
|
||||
// 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 = (propsFromState, actionServiceNames = []) =>
|
||||
reduxConnect(
|
||||
propsFromState ? pick(propsFromState) : null,
|
||||
actionServiceNames.reduce(mapActionService, {})
|
||||
);
|
||||
|
||||
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer');
|
||||
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer');
|
||||
|
||||
provideCommonServices(bottle, connect, withRouter);
|
||||
provideShortUrlsServices(bottle, connect);
|
||||
|
||||
@@ -10,10 +10,6 @@ body,
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bg-main {
|
||||
background-color: $mainColor !important;
|
||||
}
|
||||
@@ -28,14 +24,6 @@ body,
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.shlink-container {
|
||||
padding: 20px 0;
|
||||
|
||||
@media (min-width: $mdMin) {
|
||||
padding: 30px 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-main {
|
||||
color: #fff;
|
||||
background-color: $mainColor;
|
||||
@@ -59,3 +47,15 @@ body,
|
||||
.paddingless {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.indivisible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.react-datepicker__day--keyboard-selected {
|
||||
background-color: $mainColor;
|
||||
|
||||
&:hover {
|
||||
background-color: darken($mainColor, 12%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListPara
|
||||
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 shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
|
||||
import tagsListReducer from '../tags/reducers/tagsList';
|
||||
@@ -20,6 +22,8 @@ export default combineReducers({
|
||||
shortUrlCreationResult: shortUrlCreationReducer,
|
||||
shortUrlDeletion: shortUrlDeletionReducer,
|
||||
shortUrlTags: shortUrlTagsReducer,
|
||||
shortUrlMeta: shortUrlMetaReducer,
|
||||
shortUrlEdition: shortUrlEditionReducer,
|
||||
shortUrlVisits: shortUrlVisitsReducer,
|
||||
shortUrlDetail: shortUrlDetailReducer,
|
||||
tagsList: tagsListReducer,
|
||||
|
||||
@@ -1,91 +1,56 @@
|
||||
import { assoc, dissoc, pipe } from 'ramda';
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import PropTypes from 'prop-types';
|
||||
import './CreateServer.scss';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
|
||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||
const propTypes = {
|
||||
createServer: PropTypes.func,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
}),
|
||||
resetSelectedServer: PropTypes.func,
|
||||
};
|
||||
|
||||
const CreateServer = (ImportServersBtn, stateFlagTimeout) => class CreateServer extends React.Component {
|
||||
static propTypes = {
|
||||
createServer: PropTypes.func,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
}),
|
||||
resetSelectedServer: PropTypes.func,
|
||||
};
|
||||
const CreateServer = (ImportServersBtn, useStateFlagTimeout) => {
|
||||
const CreateServerComp = ({ createServer, history: { push }, resetSelectedServer }) => {
|
||||
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||
const handleSubmit = (serverData) => {
|
||||
const id = uuid();
|
||||
const server = { id, ...serverData };
|
||||
|
||||
state = {
|
||||
name: '',
|
||||
url: '',
|
||||
apiKey: '',
|
||||
serversImported: false,
|
||||
};
|
||||
createServer(server);
|
||||
push(`/server/${id}/list-short-urls/1`);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
useEffect(() => {
|
||||
resetSelectedServer();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="create-server">
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
{renderInputGroup('name', 'Name')}
|
||||
{renderInputGroup('url', 'URL', 'url')}
|
||||
{renderInputGroup('apiKey', 'API key')}
|
||||
<ServerForm onSubmit={handleSubmit}>
|
||||
<ImportServersBtn onImport={setServersImported} />
|
||||
<button className="btn btn-outline-primary">Create server</button>
|
||||
</ServerForm>
|
||||
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
CreateServerComp.propTypes = propTypes;
|
||||
|
||||
return CreateServerComp;
|
||||
};
|
||||
|
||||
export default CreateServer;
|
||||
|
||||
@@ -1,40 +1,36 @@
|
||||
import React from 'react';
|
||||
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 { useToggle } from '../utils/helpers/hooks';
|
||||
import { serverType } from './prop-types';
|
||||
|
||||
const DeleteServerButton = (DeleteServerModal) => class DeleteServerButton extends React.Component {
|
||||
static propTypes = {
|
||||
server: serverType,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
const propTypes = {
|
||||
server: serverType,
|
||||
className: PropTypes.string,
|
||||
textClassName: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
state = { isModalOpen: false };
|
||||
|
||||
render() {
|
||||
const { server, className } = this.props;
|
||||
const DeleteServerButton = (DeleteServerModal) => {
|
||||
const DeleteServerButtonComp = ({ server, className, children, textClassName }) => {
|
||||
const [ isModalOpen, , showModal, hideModal ] = useToggle();
|
||||
|
||||
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 className={className} onClick={showModal}>
|
||||
{!children && <FontAwesomeIcon icon={deleteIcon} />}
|
||||
<span className={textClassName}>{children || 'Remove this server'}</span>
|
||||
</span>
|
||||
|
||||
<DeleteServerModal
|
||||
isOpen={this.state.isModalOpen}
|
||||
toggle={() => this.setState(({ isModalOpen }) => ({ isModalOpen: !isModalOpen }))}
|
||||
server={server}
|
||||
key="deleteServerModal"
|
||||
/>
|
||||
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
DeleteServerButtonComp.propTypes = propTypes;
|
||||
|
||||
return DeleteServerButtonComp;
|
||||
};
|
||||
|
||||
export default DeleteServerButton;
|
||||
|
||||
@@ -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>
|
||||
|
||||
34
src/servers/EditServer.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ServerForm } from './helpers/ServerForm';
|
||||
import { withSelectedServer } from './helpers/withSelectedServer';
|
||||
import { serverType } from './prop-types';
|
||||
|
||||
const propTypes = {
|
||||
editServer: PropTypes.func,
|
||||
selectedServer: serverType,
|
||||
history: PropTypes.shape({
|
||||
push: PropTypes.func,
|
||||
}),
|
||||
};
|
||||
|
||||
export const EditServer = (ServerError) => {
|
||||
const EditServerComp = ({ editServer, selectedServer, history: { push } }) => {
|
||||
const handleSubmit = (serverData) => {
|
||||
editServer(selectedServer.id, serverData);
|
||||
push(`/server/${selectedServer.id}/list-short-urls/1`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="create-server">
|
||||
<ServerForm initialValues={selectedServer} onSubmit={handleSubmit}>
|
||||
<button className="btn btn-outline-primary">Save</button>
|
||||
</ServerForm>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
EditServerComp.propTypes = propTypes;
|
||||
|
||||
return withSelectedServer(EditServerComp, ServerError);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { isEmpty, values } from 'ramda';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { serverType } from './prop-types';
|
||||
@@ -9,13 +8,17 @@ const ServersDropdown = (serversExporter) => class ServersDropdown extends React
|
||||
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: { list, loading }, selectedServer } = this.props;
|
||||
const servers = values(list);
|
||||
const { push } = this.props.history;
|
||||
const loadServer = (id) => push(`/server/${id}/list-short-urls/1`);
|
||||
|
||||
if (loading) {
|
||||
return <DropdownItem disabled><i>Trying to load servers...</i></DropdownItem>;
|
||||
@@ -28,23 +31,12 @@ const ServersDropdown = (serversExporter) => class ServersDropdown extends React
|
||||
return (
|
||||
<React.Fragment>
|
||||
{servers.map(({ name, id }) => (
|
||||
<DropdownItem
|
||||
key={id}
|
||||
tag={Link}
|
||||
to={`/server/${id}/list-short-urls/1`}
|
||||
active={selectedServer && selectedServer.id === id}
|
||||
|
||||
// FIXME This should be implicit
|
||||
onClick={() => selectServer(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()}
|
||||
>
|
||||
<DropdownItem className="servers-dropdown__export-item" onClick={() => serversExporter.exportServers()}>
|
||||
Export servers
|
||||
</DropdownItem>
|
||||
</React.Fragment>
|
||||
|
||||
42
src/servers/ServersListGroup.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
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 { serverType } from './prop-types';
|
||||
import './ServersListGroup.scss';
|
||||
|
||||
const propTypes = {
|
||||
servers: PropTypes.arrayOf(serverType).isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
const ServerListItem = ({ id, name }) => (
|
||||
<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>
|
||||
);
|
||||
|
||||
ServerListItem.propTypes = {
|
||||
id: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
};
|
||||
|
||||
const 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>
|
||||
);
|
||||
|
||||
ServersListGroup.propTypes = propTypes;
|
||||
|
||||
export default ServersListGroup;
|
||||
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;
|
||||
}
|
||||
30
src/servers/helpers/ForServerVersion.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { serverType } from '../prop-types';
|
||||
import { versionMatch } from '../../utils/helpers/version';
|
||||
|
||||
const propTypes = {
|
||||
minVersion: PropTypes.string,
|
||||
maxVersion: PropTypes.string,
|
||||
selectedServer: serverType,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
const ForServerVersion = ({ minVersion, maxVersion, selectedServer, children }) => {
|
||||
if (!selectedServer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { version } = selectedServer;
|
||||
const matchesVersion = versionMatch(version, { maxVersion, minVersion });
|
||||
|
||||
if (!matchesVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
};
|
||||
|
||||
ForServerVersion.propTypes = propTypes;
|
||||
|
||||
export default ForServerVersion;
|
||||
50
src/servers/helpers/ServerError.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Message from '../../utils/Message';
|
||||
import ServersListGroup from '../ServersListGroup';
|
||||
import { serverType } from '../prop-types';
|
||||
import './ServerError.scss';
|
||||
|
||||
const propTypes = {
|
||||
servers: PropTypes.object,
|
||||
selectedServer: serverType,
|
||||
type: PropTypes.oneOf([ 'not-found', 'not-reachable' ]).isRequired,
|
||||
};
|
||||
|
||||
export const ServerError = (DeleteServerButton) => {
|
||||
const ServerErrorComp = ({ type, servers: { list }, selectedServer }) => (
|
||||
<div className="server-error__container flex-column">
|
||||
<div className="row w-100 mb-3 mb-md-5">
|
||||
<Message type="error">
|
||||
{type === 'not-found' && 'Could not find this Shlink server.'}
|
||||
{type === 'not-reachable' && (
|
||||
<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(list)}>
|
||||
These are the Shlink servers currently configured. Choose one of
|
||||
them or <Link to="/server/create">add a new one</Link>.
|
||||
</ServersListGroup>
|
||||
|
||||
{type === 'not-reachable' && (
|
||||
<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>
|
||||
);
|
||||
|
||||
ServerErrorComp.propTypes = propTypes;
|
||||
|
||||
return ServerErrorComp;
|
||||
};
|
||||
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;
|
||||
}
|
||||
41
src/servers/helpers/ServerForm.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { HorizontalFormGroup } from '../../utils/HorizontalFormGroup';
|
||||
|
||||
const propTypes = {
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
initialValues: PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
apiKey: PropTypes.string.isRequired,
|
||||
}),
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export const ServerForm = ({ onSubmit, initialValues, children }) => {
|
||||
const [ name, setName ] = useState('');
|
||||
const [ url, setUrl ] = useState('');
|
||||
const [ apiKey, setApiKey ] = useState('');
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
ServerForm.propTypes = propTypes;
|
||||
35
src/servers/helpers/withSelectedServer.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Message from '../../utils/Message';
|
||||
import { serverType } from '../prop-types';
|
||||
|
||||
const propTypes = {
|
||||
selectServer: PropTypes.func,
|
||||
selectedServer: serverType,
|
||||
match: PropTypes.object,
|
||||
};
|
||||
|
||||
export const withSelectedServer = (WrappedComponent, ServerError) => {
|
||||
const Component = (props) => {
|
||||
const { selectServer, selectedServer, match } = props;
|
||||
const { params: { serverId } } = match;
|
||||
|
||||
useEffect(() => {
|
||||
selectServer(serverId);
|
||||
}, [ serverId ]);
|
||||
|
||||
if (!selectedServer) {
|
||||
return <Message loading />;
|
||||
}
|
||||
|
||||
if (selectedServer.serverNotFound) {
|
||||
return <ServerError type="not-found" />;
|
||||
}
|
||||
|
||||
return <WrappedComponent {...props} />;
|
||||
};
|
||||
|
||||
Component.propTypes = propTypes;
|
||||
|
||||
return Component;
|
||||
};
|
||||
@@ -1,8 +1,20 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const serverType = PropTypes.shape({
|
||||
const regularServerType = PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
apiKey: PropTypes.string,
|
||||
version: PropTypes.string,
|
||||
printableVersion: PropTypes.string,
|
||||
serverNotReachable: PropTypes.bool,
|
||||
});
|
||||
|
||||
const notFoundServerType = PropTypes.shape({
|
||||
serverNotFound: PropTypes.bool.isRequired,
|
||||
});
|
||||
|
||||
export const serverType = PropTypes.oneOfType([
|
||||
regularServerType,
|
||||
notFoundServerType,
|
||||
]);
|
||||
|
||||
@@ -1,24 +1,62 @@
|
||||
import { createAction, handleActions } from 'redux-actions';
|
||||
import { identity, memoizeWith, pipe } from 'ramda';
|
||||
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
|
||||
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
||||
|
||||
/* 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;
|
||||
const versionToSemVer = pipe(
|
||||
(version) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version,
|
||||
toSemVer(MIN_FALLBACK_VERSION)
|
||||
);
|
||||
|
||||
const getServerVersion = memoizeWith(identity, (serverId, health) => health().then(({ version }) => ({
|
||||
version: versionToSemVer(version),
|
||||
printableVersion: versionToPrintable(version),
|
||||
})));
|
||||
|
||||
export const resetSelectedServer = createAction(RESET_SELECTED_SERVER);
|
||||
|
||||
export const selectServer = ({ findServerById }) => (serverId) => (dispatch) => {
|
||||
export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serverId) => async (dispatch) => {
|
||||
dispatch(resetSelectedServer());
|
||||
dispatch(resetShortUrlParams());
|
||||
|
||||
const selectedServer = findServerById(serverId);
|
||||
|
||||
dispatch({
|
||||
type: SELECT_SERVER,
|
||||
selectedServer,
|
||||
});
|
||||
if (!selectedServer) {
|
||||
dispatch({
|
||||
type: SELECT_SERVER,
|
||||
selectedServer: { serverNotFound: true },
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { health } = buildShlinkApiClient(selectedServer);
|
||||
const { version, printableVersion } = await getServerVersion(serverId, health);
|
||||
|
||||
dispatch({
|
||||
type: SELECT_SERVER,
|
||||
selectedServer: {
|
||||
...selectedServer,
|
||||
version,
|
||||
printableVersion,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
dispatch({
|
||||
type: SELECT_SERVER,
|
||||
selectedServer: { ...selectedServer, serverNotReachable: true },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default handleActions({
|
||||
|
||||
@@ -30,10 +30,20 @@ export const listServers = ({ listServers, createServers }, { get }) => () => as
|
||||
return;
|
||||
}
|
||||
|
||||
// If local list is empty, try to fetch it remotely and calculate IDs for every server
|
||||
// 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(prop('data'))
|
||||
.then(map(assocId))
|
||||
.then(getDataAsArrayWithIds)
|
||||
.catch(() => []);
|
||||
|
||||
createServers(remoteList);
|
||||
@@ -42,6 +52,8 @@ export const listServers = ({ listServers, createServers }, { get }) => () => as
|
||||
|
||||
export const createServer = ({ createServer }, listServersAction) => pipe(createServer, listServersAction);
|
||||
|
||||
export const editServer = ({ editServer }, listServersAction) => pipe(editServer, listServersAction);
|
||||
|
||||
export const deleteServer = ({ deleteServer }, listServersAction) => pipe(deleteServer, listServersAction);
|
||||
|
||||
export const createServers = ({ createServers }, listServersAction) => pipe(
|
||||
|
||||
@@ -25,4 +25,14 @@ export default class ServersService {
|
||||
|
||||
deleteServer = ({ id }) =>
|
||||
this.storage.set(SERVERS_STORAGE_KEY, dissoc(id, this.listServers()));
|
||||
|
||||
editServer = (id, serverData) => {
|
||||
const allServers = this.listServers();
|
||||
|
||||
if (!allServers[id]) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.storage.set(SERVERS_STORAGE_KEY, assoc(id, { ...allServers[id], ...serverData }, allServers));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,27 @@ 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, listServers } from '../reducers/server';
|
||||
import { createServer, createServers, deleteServer, editServer, listServers } from '../reducers/server';
|
||||
import ForServerVersion from '../helpers/ForServerVersion';
|
||||
import { ServerError } from '../helpers/ServerError';
|
||||
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.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
|
||||
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' ], [ 'listServers', 'selectServer' ]));
|
||||
bottle.decorator('ServersDropdown', withRouter);
|
||||
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ], [ 'listServers' ]));
|
||||
|
||||
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
|
||||
bottle.decorator('DeleteServerModal', withRouter);
|
||||
@@ -27,6 +34,12 @@ const provideServices = (bottle, connect, withRouter) => {
|
||||
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.service('ServersImporter', ServersImporter, 'csvjson');
|
||||
@@ -34,10 +47,11 @@ const provideServices = (bottle, connect, withRouter) => {
|
||||
bottle.service('ServersExporter', ServersExporter, 'ServersService', 'window', 'csvjson');
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('selectServer', selectServer, 'ServersService');
|
||||
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('editServer', editServer, 'ServersService', 'listServers');
|
||||
bottle.serviceFactory('listServers', listServers, 'ServersService', 'axios');
|
||||
|
||||
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
||||
|
||||
@@ -1,134 +1,173 @@
|
||||
import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { assoc, dissoc, isNil, pipe, replace, trim } from 'ramda';
|
||||
import React from 'react';
|
||||
import { Collapse } from 'reactstrap';
|
||||
import { isEmpty, isNil, pipe, replace, trim } from 'ramda';
|
||||
import React, { useState } from 'react';
|
||||
import { Collapse, FormGroup, Input } from 'reactstrap';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import DateInput from '../utils/DateInput';
|
||||
import Checkbox from '../utils/Checkbox';
|
||||
import { serverType } from '../servers/prop-types';
|
||||
import { versionMatch } from '../utils/helpers/version';
|
||||
import { hasValue } from '../utils/utils';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { createShortUrlResultType } from './reducers/shortUrlCreation';
|
||||
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
|
||||
|
||||
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||
const formatDate = (date) => isNil(date) ? date : date.format();
|
||||
|
||||
const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShortUrl extends React.Component {
|
||||
static propTypes = {
|
||||
createShortUrl: PropTypes.func,
|
||||
shortUrlCreationResult: createShortUrlResultType,
|
||||
resetCreateShortUrl: PropTypes.func,
|
||||
};
|
||||
const propTypes = {
|
||||
createShortUrl: PropTypes.func,
|
||||
shortUrlCreationResult: createShortUrlResultType,
|
||||
resetCreateShortUrl: PropTypes.func,
|
||||
selectedServer: serverType,
|
||||
};
|
||||
|
||||
state = {
|
||||
longUrl: '',
|
||||
tags: [],
|
||||
customSlug: undefined,
|
||||
validSince: undefined,
|
||||
validUntil: undefined,
|
||||
maxVisits: undefined,
|
||||
findIfExists: false,
|
||||
moreOptionsVisible: false,
|
||||
};
|
||||
const initialState = {
|
||||
longUrl: '',
|
||||
tags: [],
|
||||
customSlug: '',
|
||||
shortCodeLength: '',
|
||||
domain: '',
|
||||
validSince: undefined,
|
||||
validUntil: undefined,
|
||||
maxVisits: '',
|
||||
findIfExists: false,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { createShortUrl, shortUrlCreationResult, resetCreateShortUrl } = this.props;
|
||||
const CreateShortUrl = (TagsSelector, CreateShortUrlResult, ForServerVersion) => {
|
||||
const CreateShortUrlComp = ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }) => {
|
||||
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
|
||||
const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle();
|
||||
|
||||
const changeTags = (tags) => this.setState({ tags: tags.map(normalizeTag) });
|
||||
const changeTags = (tags) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
|
||||
const reset = () => setShortUrlCreation(initialState);
|
||||
const save = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const shortUrlData = {
|
||||
...shortUrlCreation,
|
||||
validSince: formatDate(shortUrlCreation.validSince),
|
||||
validUntil: formatDate(shortUrlCreation.validUntil),
|
||||
};
|
||||
|
||||
createShortUrl(shortUrlData).then(reset).catch(() => {});
|
||||
};
|
||||
const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => (
|
||||
<div className="form-group">
|
||||
<input
|
||||
className="form-control"
|
||||
<FormGroup>
|
||||
<Input
|
||||
id={id}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
value={this.state[id]}
|
||||
onChange={(e) => this.setState({ [id]: e.target.value })}
|
||||
value={shortUrlCreation[id]}
|
||||
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, [id]: e.target.value })}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</FormGroup>
|
||||
);
|
||||
const renderDateInput = (id, placeholder, props = {}) => (
|
||||
<div className="form-group">
|
||||
<DateInput
|
||||
selected={this.state[id]}
|
||||
selected={shortUrlCreation[id]}
|
||||
placeholderText={placeholder}
|
||||
isClearable
|
||||
onChange={(date) => this.setState({ [id]: date })}
|
||||
onChange={(date) => setShortUrlCreation({ ...shortUrlCreation, [id]: date })}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const save = (e) => {
|
||||
e.preventDefault();
|
||||
createShortUrl(pipe(
|
||||
dissoc('moreOptionsVisible'),
|
||||
assoc('validSince', formatDate(this.state.validSince)),
|
||||
assoc('validUntil', formatDate(this.state.validUntil))
|
||||
)(this.state));
|
||||
};
|
||||
|
||||
const currentServerVersion = selectedServer && selectedServer.version;
|
||||
const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' });
|
||||
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
|
||||
|
||||
return (
|
||||
<div className="shlink-container">
|
||||
<form onSubmit={save}>
|
||||
<form onSubmit={save}>
|
||||
<div className="form-group">
|
||||
<input
|
||||
className="form-control form-control-lg"
|
||||
type="url"
|
||||
placeholder="Insert the URL to be shortened"
|
||||
required
|
||||
value={shortUrlCreation.longUrl}
|
||||
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, longUrl: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Collapse isOpen={moreOptionsVisible}>
|
||||
<div className="form-group">
|
||||
<input
|
||||
className="form-control form-control-lg"
|
||||
type="url"
|
||||
placeholder="Insert the URL to be shortened"
|
||||
required
|
||||
value={this.state.longUrl}
|
||||
onChange={(e) => this.setState({ longUrl: e.target.value })}
|
||||
/>
|
||||
<TagsSelector tags={shortUrlCreation.tags} onChange={changeTags} />
|
||||
</div>
|
||||
|
||||
<Collapse isOpen={this.state.moreOptionsVisible}>
|
||||
<div className="form-group">
|
||||
<TagsSelector tags={this.state.tags} onChange={changeTags} />
|
||||
<div className="row">
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('customSlug', 'Custom slug')}
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-6">
|
||||
{renderOptionalInput('customSlug', 'Custom slug')}
|
||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||
</div>
|
||||
<div className="col-sm-6">
|
||||
{renderDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })}
|
||||
{renderDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
|
||||
min: 4,
|
||||
disabled: disableShortCodeLength || hasValue(shortUrlCreation.customSlug),
|
||||
...disableShortCodeLength && {
|
||||
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('domain', 'Domain', 'text', {
|
||||
disabled: disableDomain,
|
||||
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 text-right">
|
||||
<div className="row">
|
||||
<div className="col-sm-4">
|
||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil })}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ForServerVersion minVersion="1.16.0">
|
||||
<div className="mb-4 text-right">
|
||||
<Checkbox
|
||||
className="mr-2"
|
||||
checked={this.state.findIfExists}
|
||||
onChange={(findIfExists) => this.setState({ findIfExists })}
|
||||
checked={shortUrlCreation.findIfExists}
|
||||
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
|
||||
>
|
||||
Use existing URL if found
|
||||
</Checkbox>
|
||||
<UseExistingIfFoundInfoIcon />
|
||||
</div>
|
||||
</Collapse>
|
||||
</ForServerVersion>
|
||||
</Collapse>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={() => this.setState(({ moreOptionsVisible }) => ({ moreOptionsVisible: !moreOptionsVisible }))}
|
||||
>
|
||||
<FontAwesomeIcon icon={this.state.moreOptionsVisible ? upIcon : downIcon} />
|
||||
|
||||
{this.state.moreOptionsVisible ? 'Less' : 'More'} options
|
||||
</button>
|
||||
<button className="btn btn-outline-primary float-right" disabled={shortUrlCreationResult.loading}>
|
||||
{shortUrlCreationResult.loading ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" className="btn btn-outline-secondary" onClick={toggleMoreOptionsVisible}>
|
||||
<FontAwesomeIcon icon={moreOptionsVisible ? upIcon : downIcon} />
|
||||
|
||||
{moreOptionsVisible ? 'Less' : 'More'} options
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-primary float-right"
|
||||
disabled={shortUrlCreationResult.saving || isEmpty(shortUrlCreation.longUrl)}
|
||||
>
|
||||
{shortUrlCreationResult.saving ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />
|
||||
</form>
|
||||
</div>
|
||||
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
CreateShortUrlComp.propTypes = propTypes;
|
||||
|
||||
return CreateShortUrlComp;
|
||||
};
|
||||
|
||||
export default CreateShortUrl;
|
||||
|
||||
@@ -2,7 +2,8 @@ import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { rangeOf } from '../utils/utils';
|
||||
import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
|
||||
import './Paginator.scss';
|
||||
|
||||
const propTypes = {
|
||||
serverId: PropTypes.string.isRequired,
|
||||
@@ -12,7 +13,7 @@ const propTypes = {
|
||||
}),
|
||||
};
|
||||
|
||||
export default function Paginator({ paginator = {}, serverId }) {
|
||||
const Paginator = ({ paginator = {}, serverId }) => {
|
||||
const { currentPage, pagesCount = 0 } = paginator;
|
||||
|
||||
if (pagesCount <= 1) {
|
||||
@@ -20,8 +21,12 @@ export default function Paginator({ paginator = {}, serverId }) {
|
||||
}
|
||||
|
||||
const renderPages = () =>
|
||||
rangeOf(pagesCount, (pageNumber) => (
|
||||
<PaginationItem key={pageNumber} active={currentPage === pageNumber}>
|
||||
progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
|
||||
<PaginationItem
|
||||
key={keyForPage(pageNumber, index)}
|
||||
disabled={isPageDisabled(pageNumber)}
|
||||
active={currentPage === pageNumber}
|
||||
>
|
||||
<PaginationLink
|
||||
tag={Link}
|
||||
to={`/server/${serverId}/list-short-urls/${pageNumber}`}
|
||||
@@ -32,7 +37,7 @@ export default function Paginator({ paginator = {}, serverId }) {
|
||||
));
|
||||
|
||||
return (
|
||||
<Pagination listClassName="flex-wrap">
|
||||
<Pagination className="short-urls-paginator" listClassName="flex-wrap justify-content-center mb-0">
|
||||
<PaginationItem disabled={currentPage === 1}>
|
||||
<PaginationLink
|
||||
previous
|
||||
@@ -50,6 +55,8 @@ export default function Paginator({ paginator = {}, serverId }) {
|
||||
</PaginationItem>
|
||||
</Pagination>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Paginator.propTypes = propTypes;
|
||||
|
||||
export default Paginator;
|
||||
|
||||
7
src/short-urls/Paginator.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
.short-urls-paginator {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background-color: rgba(white, .8);
|
||||
padding: .75rem 0;
|
||||
border-top: 1px solid rgba(black, .125);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
import { isEmpty } from 'ramda';
|
||||
import { isEmpty, pipe } from 'ramda';
|
||||
import PropTypes from 'prop-types';
|
||||
import moment from 'moment';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import Tag from '../tags/helpers/Tag';
|
||||
import DateRangeRow from '../utils/DateRangeRow';
|
||||
import { formatDate } from '../utils/helpers/date';
|
||||
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
||||
import './SearchBar.scss';
|
||||
|
||||
@@ -13,19 +16,41 @@ const propTypes = {
|
||||
shortUrlsListParams: shortUrlsListParamsType,
|
||||
};
|
||||
|
||||
const SearchBar = (colorGenerator) => {
|
||||
const dateOrUndefined = (date) => date ? moment(date) : undefined;
|
||||
|
||||
const SearchBar = (colorGenerator, ForServerVersion) => {
|
||||
const SearchBar = ({ listShortUrls, shortUrlsListParams }) => {
|
||||
const selectedTags = shortUrlsListParams.tags || [];
|
||||
const setDate = (dateName) => pipe(
|
||||
formatDate(),
|
||||
(date) => listShortUrls({ ...shortUrlsListParams, [dateName]: date })
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="serach-bar-container">
|
||||
<SearchField onChange={
|
||||
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
|
||||
}
|
||||
<div className="search-bar-container">
|
||||
<SearchField
|
||||
onChange={
|
||||
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
|
||||
}
|
||||
/>
|
||||
|
||||
<ForServerVersion minVersion="1.21.0">
|
||||
<div className="mt-3">
|
||||
<div className="row">
|
||||
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
|
||||
<DateRangeRow
|
||||
startDate={dateOrUndefined(shortUrlsListParams.startDate)}
|
||||
endDate={dateOrUndefined(shortUrlsListParams.endDate)}
|
||||
onStartDateChange={setDate('startDate')}
|
||||
onEndDateChange={setDate('endDate')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ForServerVersion>
|
||||
|
||||
{!isEmpty(selectedTags) && (
|
||||
<h4 className="search-bar__selected-tag mt-2">
|
||||
<h4 className="search-bar__selected-tag mt-3">
|
||||
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
|
||||
|
||||
{selectedTags.map((tag) => (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Paginator from './Paginator';
|
||||
|
||||
@@ -14,16 +14,22 @@ const ShortUrls = (SearchBar, ShortUrlsList) => {
|
||||
const { match: { params }, shortUrlsList } = props;
|
||||
const { page, serverId } = params;
|
||||
const { data = [], pagination } = shortUrlsList;
|
||||
const [ urlsListKey, setUrlsListKey ] = useState(`${serverId}_${page}`);
|
||||
|
||||
// Using a key on a component makes react to create a new instance every time the key changes
|
||||
const urlsListKey = `${serverId}_${page}`;
|
||||
// Without it, pagination on the URL will not make the component to be refreshed
|
||||
useEffect(() => {
|
||||
setUrlsListKey(`${serverId}_${page}`);
|
||||
}, [ serverId, page ]);
|
||||
|
||||
return (
|
||||
<div className="shlink-container">
|
||||
<React.Fragment>
|
||||
<div className="form-group"><SearchBar /></div>
|
||||
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
|
||||
<Paginator paginator={pagination} serverId={serverId} />
|
||||
</div>
|
||||
<div>
|
||||
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
|
||||
<Paginator paginator={pagination} serverId={serverId} />
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,13 +11,14 @@ import { shortUrlType } from './reducers/shortUrlsList';
|
||||
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
||||
import './ShortUrlsList.scss';
|
||||
|
||||
const SORTABLE_FIELDS = {
|
||||
export const SORTABLE_FIELDS = {
|
||||
dateCreated: 'Created at',
|
||||
shortCode: 'Short URL',
|
||||
longUrl: 'Long URL',
|
||||
visits: 'Visits',
|
||||
};
|
||||
|
||||
// FIXME Replace with typescript: (ShortUrlsRow component)
|
||||
const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Component {
|
||||
static propTypes = {
|
||||
listShortUrls: PropTypes.func,
|
||||
@@ -39,17 +40,24 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
|
||||
...extraParams,
|
||||
});
|
||||
};
|
||||
|
||||
handleOrderBy = (orderField, orderDir) => {
|
||||
this.setState({ orderField, orderDir });
|
||||
this.refreshList({ orderBy: { [orderField]: orderDir } });
|
||||
};
|
||||
|
||||
orderByColumn = (columnName) => () =>
|
||||
this.handleOrderBy(columnName, determineOrderDir(columnName, this.state.orderField, this.state.orderDir));
|
||||
|
||||
renderOrderIcon = (field) => {
|
||||
if (this.state.orderField !== field) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.state.orderDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FontAwesomeIcon
|
||||
icon={this.state.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
|
||||
@@ -72,8 +80,9 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
|
||||
componentDidMount() {
|
||||
const { match: { params }, location, shortUrlsListParams } = this.props;
|
||||
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
|
||||
const tags = query.tag ? [ query.tag ] : shortUrlsListParams.tags;
|
||||
|
||||
this.refreshList({ page: params.page, tags: query.tag ? [ query.tag ] : shortUrlsListParams.tags });
|
||||
this.refreshList({ page: params.page, tags });
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -103,9 +112,9 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
|
||||
|
||||
return shortUrlsList.map((shortUrl) => (
|
||||
<ShortUrlsRow
|
||||
key={shortUrl.shortUrl}
|
||||
shortUrl={shortUrl}
|
||||
selectedServer={selectedServer}
|
||||
key={shortUrl.shortCode}
|
||||
refreshList={this.refreshList}
|
||||
shortUrlsListParams={shortUrlsListParams}
|
||||
/>
|
||||
@@ -152,7 +161,7 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
|
||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
||||
onClick={this.orderByColumn('visits')}
|
||||
>
|
||||
<span className="nowrap">{this.renderOrderIcon('visits')} Visits</span>
|
||||
<span className="indivisible">{this.renderOrderIcon('visits')} Visits</span>
|
||||
</th>
|
||||
<th className="short-urls-list__header-cell"> </th>
|
||||
</tr>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
||||
import './UseExistingIfFoundInfoIcon.scss';
|
||||
import { useToggle } from '../utils/utils';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
|
||||
const renderInfoModal = (isOpen, toggle) => (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered size="lg">
|
||||
@@ -20,11 +20,11 @@ const renderInfoModal = (isOpen, toggle) => (
|
||||
<ul>
|
||||
<li>
|
||||
When only the long URL is provided: The most recent match will be returned, or a new short URL will be created
|
||||
if none is found
|
||||
if none is found.
|
||||
</li>
|
||||
<li>
|
||||
When long URL and custom slug are provided: Same as in previous case, but it will try to match the short URL
|
||||
using both the long URL and the slug.
|
||||
When long URL and custom slug and/or domain are provided: Same as in previous case, but it will try to match
|
||||
the short URL using both the long URL and the slug, the long URL and the domain, or the three of them.
|
||||
<br />
|
||||
If the slug is being used by another long URL, an error will be returned.
|
||||
</li>
|
||||
@@ -33,15 +33,12 @@ const renderInfoModal = (isOpen, toggle) => (
|
||||
all provided data. If any of them does not match, a new short URL will be created
|
||||
</li>
|
||||
</ul>
|
||||
<blockquote className="use-existing-if-found-info-icon__modal-quote">
|
||||
<b>Important:</b> This feature will be ignored while using a Shlink version older than v1.16.0.
|
||||
</blockquote>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const UseExistingIfFoundInfoIcon = () => {
|
||||
const [ isModalOpen, toggleModal ] = useToggle(false);
|
||||
const [ isModalOpen, toggleModal ] = useToggle();
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { identity } from 'ramda';
|
||||
import { identity, pipe } from 'ramda';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
import { shortUrlDeletionType } from '../reducers/shortUrlDeletion';
|
||||
|
||||
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
|
||||
|
||||
export default class DeleteShortUrlModal extends React.Component {
|
||||
static propTypes = {
|
||||
shortUrl: shortUrlType,
|
||||
@@ -13,21 +15,17 @@ export default class DeleteShortUrlModal extends React.Component {
|
||||
shortUrlDeletion: shortUrlDeletionType,
|
||||
deleteShortUrl: PropTypes.func,
|
||||
resetDeleteShortUrl: PropTypes.func,
|
||||
shortUrlDeleted: PropTypes.func,
|
||||
};
|
||||
|
||||
state = { inputValue: '' };
|
||||
handleDeleteUrl = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { deleteShortUrl, shortUrl, toggle, shortUrlDeleted } = this.props;
|
||||
const { shortCode } = shortUrl;
|
||||
const { deleteShortUrl, shortUrl, toggle } = this.props;
|
||||
const { shortCode, domain } = shortUrl;
|
||||
|
||||
deleteShortUrl(shortCode)
|
||||
.then(() => {
|
||||
shortUrlDeleted(shortCode);
|
||||
toggle();
|
||||
})
|
||||
deleteShortUrl(shortCode, domain)
|
||||
.then(toggle)
|
||||
.catch(identity);
|
||||
};
|
||||
|
||||
@@ -38,15 +36,17 @@ export default class DeleteShortUrlModal extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { shortUrl, toggle, isOpen, shortUrlDeletion } = this.props;
|
||||
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
|
||||
const hasThresholdError = shortUrlDeletion.error && shortUrlDeletion.errorData.error === THRESHOLD_REACHED;
|
||||
const hasErrorOtherThanThreshold = shortUrlDeletion.error && shortUrlDeletion.errorData.error !== THRESHOLD_REACHED;
|
||||
const { shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl } = this.props;
|
||||
const { error, errorData } = shortUrlDeletion;
|
||||
const errorCode = error && (errorData.type || errorData.error);
|
||||
const hasThresholdError = errorCode === THRESHOLD_REACHED;
|
||||
const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED;
|
||||
const close = pipe(resetDeleteShortUrl, toggle);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
<Modal isOpen={isOpen} toggle={close} centered>
|
||||
<form onSubmit={this.handleDeleteUrl}>
|
||||
<ModalHeader toggle={toggle}>
|
||||
<ModalHeader toggle={close}>
|
||||
<span className="text-danger">Delete short URL</span>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
@@ -63,7 +63,8 @@ export default class DeleteShortUrlModal extends React.Component {
|
||||
|
||||
{hasThresholdError && (
|
||||
<div className="p-2 mt-2 bg-warning text-center">
|
||||
This short URL has received too many visits and therefore, it cannot be deleted
|
||||
{errorData.threshold && `This short URL has received more than ${errorData.threshold} visits, and therefore, it cannot be deleted.`}
|
||||
{!errorData.threshold && 'This short URL has received too many visits, and therefore, it cannot be deleted.'}
|
||||
</div>
|
||||
)}
|
||||
{hasErrorOtherThanThreshold && (
|
||||
@@ -73,7 +74,7 @@ export default class DeleteShortUrlModal extends React.Component {
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button type="button" className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||
<button type="button" className="btn btn-link" onClick={close}>Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-danger"
|
||||
|
||||
98
src/short-urls/helpers/EditMetaModal.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, UncontrolledTooltip } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import moment from 'moment';
|
||||
import { isEmpty, pipe } from 'ramda';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
import { shortUrlEditMetaType } from '../reducers/shortUrlMeta';
|
||||
import DateInput from '../../utils/DateInput';
|
||||
import { formatIsoDate } from '../../utils/helpers/date';
|
||||
|
||||
const propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
toggle: PropTypes.func.isRequired,
|
||||
shortUrl: shortUrlType.isRequired,
|
||||
shortUrlMeta: shortUrlEditMetaType,
|
||||
editShortUrlMeta: PropTypes.func,
|
||||
resetShortUrlMeta: PropTypes.func,
|
||||
};
|
||||
|
||||
const dateOrUndefined = (shortUrl, dateName) => {
|
||||
const date = shortUrl && shortUrl.meta && shortUrl.meta[dateName];
|
||||
|
||||
return date && moment(date);
|
||||
};
|
||||
|
||||
const EditMetaModal = ({ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }) => {
|
||||
const { saving, error } = shortUrlMeta;
|
||||
const url = shortUrl && (shortUrl.shortUrl || '');
|
||||
const [ validSince, setValidSince ] = useState(dateOrUndefined(shortUrl, 'validSince'));
|
||||
const [ validUntil, setValidUntil ] = useState(dateOrUndefined(shortUrl, 'validUntil'));
|
||||
const [ maxVisits, setMaxVisits ] = useState(shortUrl && shortUrl.meta && shortUrl.meta.maxVisits);
|
||||
|
||||
const close = pipe(resetShortUrlMeta, toggle);
|
||||
const doEdit = () => editShortUrlMeta(shortUrl.shortCode, shortUrl.domain, {
|
||||
maxVisits: maxVisits && !isEmpty(maxVisits) ? parseInt(maxVisits) : null,
|
||||
validSince: validSince && formatIsoDate(validSince),
|
||||
validUntil: validUntil && formatIsoDate(validUntil),
|
||||
}).then(close);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={close} centered>
|
||||
<ModalHeader toggle={close}>
|
||||
<FontAwesomeIcon icon={infoIcon} id="metaTitleInfo" /> Edit metadata for <ExternalLink href={url} />
|
||||
<UncontrolledTooltip target="metaTitleInfo" placement="bottom">
|
||||
<p>Using these metadata properties, you can limit when and how many times your short URL can be visited.</p>
|
||||
<p>If any of the params is not met, the URL will behave as if it was an invalid short URL.</p>
|
||||
</UncontrolledTooltip>
|
||||
</ModalHeader>
|
||||
<form onSubmit={(e) => e.preventDefault() || doEdit()}>
|
||||
<ModalBody>
|
||||
<FormGroup>
|
||||
<DateInput
|
||||
placeholderText="Enabled since..."
|
||||
selected={validSince}
|
||||
maxDate={validUntil}
|
||||
isClearable
|
||||
onChange={setValidSince}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<DateInput
|
||||
placeholderText="Enabled until..."
|
||||
selected={validUntil}
|
||||
minDate={validSince}
|
||||
isClearable
|
||||
onChange={setValidUntil}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup className="mb-0">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Maximum number of visits allowed"
|
||||
min={1}
|
||||
value={maxVisits || ''}
|
||||
onChange={(e) => setMaxVisits(e.target.value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
{error && (
|
||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||
Something went wrong while saving the metadata :(
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button className="btn btn-link" type="button" onClick={close}>Cancel</button>
|
||||
<button className="btn btn-primary" type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
EditMetaModal.propTypes = propTypes;
|
||||
|
||||
export default EditMetaModal;
|
||||
57
src/short-urls/helpers/EditShortUrlModal.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, Button } from 'reactstrap';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
import { ShortUrlEditionType } from '../reducers/shortUrlEdition';
|
||||
import { hasValue } from '../../utils/utils';
|
||||
|
||||
const propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
toggle: PropTypes.func.isRequired,
|
||||
shortUrl: shortUrlType.isRequired,
|
||||
shortUrlEdition: ShortUrlEditionType,
|
||||
editShortUrl: PropTypes.func,
|
||||
};
|
||||
|
||||
const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShortUrl }) => {
|
||||
const { saving, error } = shortUrlEdition;
|
||||
const url = shortUrl && (shortUrl.shortUrl || '');
|
||||
const [ longUrl, setLongUrl ] = useState(shortUrl.longUrl);
|
||||
|
||||
const doEdit = () => editShortUrl(shortUrl.shortCode, shortUrl.domain, longUrl).then(toggle);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
<ModalHeader toggle={toggle}>
|
||||
Edit long URL for <ExternalLink href={url} />
|
||||
</ModalHeader>
|
||||
<form onSubmit={(e) => e.preventDefault() || doEdit()}>
|
||||
<ModalBody>
|
||||
<FormGroup className="mb-0">
|
||||
<Input
|
||||
type="url"
|
||||
required
|
||||
placeholder="Long URL"
|
||||
value={longUrl}
|
||||
onChange={(e) => setLongUrl(e.target.value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
{error && (
|
||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||
Something went wrong while saving the long URL :(
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="link" onClick={toggle}>Cancel</Button>
|
||||
<Button color="primary" disabled={saving || !hasValue(longUrl)}>{saving ? 'Saving...' : 'Save'}</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
EditShortUrlModal.propTypes = propTypes;
|
||||
|
||||
export default EditShortUrlModal;
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import ExternalLink from '../../utils/ExternalLink';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { pipe } from 'ramda';
|
||||
import { shortUrlTagsType } from '../reducers/shortUrlTags';
|
||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||
|
||||
@@ -9,40 +10,24 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
|
||||
static propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
toggle: PropTypes.func.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
shortUrl: shortUrlType.isRequired,
|
||||
shortUrlTags: shortUrlTagsType,
|
||||
editShortUrlTags: PropTypes.func,
|
||||
shortUrlTagsEdited: PropTypes.func,
|
||||
resetShortUrlsTags: PropTypes.func,
|
||||
};
|
||||
|
||||
saveTags = () => {
|
||||
const { editShortUrlTags, shortUrl, toggle } = this.props;
|
||||
|
||||
editShortUrlTags(shortUrl.shortCode, this.state.tags)
|
||||
.then(() => {
|
||||
this.tagsSaved = true;
|
||||
toggle();
|
||||
})
|
||||
editShortUrlTags(shortUrl.shortCode, shortUrl.domain, this.state.tags)
|
||||
.then(toggle)
|
||||
.catch(() => {});
|
||||
};
|
||||
refreshShortUrls = () => {
|
||||
if (!this.tagsSaved) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { shortUrlTagsEdited, shortUrl, shortUrlTags } = this.props;
|
||||
const { tags } = shortUrlTags;
|
||||
|
||||
shortUrlTagsEdited(shortUrl.shortCode, tags);
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { resetShortUrlsTags } = this.props;
|
||||
|
||||
resetShortUrlsTags();
|
||||
this.tagsSaved = false;
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
@@ -51,12 +36,14 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isOpen, toggle, url, shortUrlTags } = this.props;
|
||||
const { isOpen, toggle, shortUrl, shortUrlTags, resetShortUrlsTags } = this.props;
|
||||
const url = shortUrl && (shortUrl.shortUrl || '');
|
||||
const close = pipe(resetShortUrlsTags, toggle);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={() => this.refreshShortUrls()}>
|
||||
<ModalHeader toggle={toggle}>
|
||||
Edit tags for <ExternalLink href={url}>{url}</ExternalLink>
|
||||
<Modal isOpen={isOpen} toggle={close} centered>
|
||||
<ModalHeader toggle={close}>
|
||||
Edit tags for <ExternalLink href={url} />
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<TagsSelector tags={this.state.tags} onChange={(tags) => this.setState({ tags })} />
|
||||
@@ -67,7 +54,7 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||
<button className="btn btn-link" onClick={close}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="button"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import './PreviewModal.scss';
|
||||
import ExternalLink from '../../utils/ExternalLink';
|
||||
|
||||
const propTypes = {
|
||||
url: PropTypes.string,
|
||||
|
||||