mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-02 13:51:48 +00:00
Compare commits
247 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
984c1ea716 | ||
|
|
df38cf6ca9 | ||
|
|
1b60b0e2a8 | ||
|
|
11f9c7c507 | ||
|
|
ebe649aaac | ||
|
|
656b68d422 | ||
|
|
cd1f186e28 | ||
|
|
d0b3edaa2f | ||
|
|
2268b85ade | ||
|
|
d7e3b7b912 | ||
|
|
4bd83eecfb | ||
|
|
b7fd2308ad | ||
|
|
a6958941ad | ||
|
|
c98b28ff0f | ||
|
|
6a372badfa | ||
|
|
b6ab9a1bdd | ||
|
|
daf9e7cf64 | ||
|
|
ef42dcd666 | ||
|
|
1b6028ae6d | ||
|
|
9340512980 | ||
|
|
9d0b4cc065 | ||
|
|
c5cb0dcb26 | ||
|
|
a42f5ab13e | ||
|
|
68b0577526 | ||
|
|
61867366e7 | ||
|
|
c670d86955 | ||
|
|
4565a64cd8 | ||
|
|
f36e42d9c1 | ||
|
|
0a3a97242b | ||
|
|
68253c3bc4 | ||
|
|
544384d85e | ||
|
|
91daec852f | ||
|
|
dcc5b9cc8c | ||
|
|
1d26cd93fb | ||
|
|
e47dfaf36f | ||
|
|
09e2c69e46 | ||
|
|
07d3567244 | ||
|
|
9bdbe90716 | ||
|
|
02a4380f7c | ||
|
|
4e483dc5d4 | ||
|
|
52631e629e | ||
|
|
3a53298417 | ||
|
|
fb0f14fc16 | ||
|
|
7a94b1730d | ||
|
|
f856bc218a | ||
|
|
bfbb21e1cc | ||
|
|
18e18f533b | ||
|
|
6eead70511 | ||
|
|
6fd30ed51a | ||
|
|
67c674f073 | ||
|
|
289d8784c0 | ||
|
|
18e026e4ca | ||
|
|
8741f42fe8 | ||
|
|
665d6209d9 | ||
|
|
59fda29894 | ||
|
|
61c027f9a1 | ||
|
|
241c9b73b0 | ||
|
|
85dc1d0825 | ||
|
|
e38887aa26 | ||
|
|
54fec79945 | ||
|
|
fad0bf1c9d | ||
|
|
be2f86050f | ||
|
|
a7f941e8e4 | ||
|
|
b08c6748c7 | ||
|
|
bdd7932e07 | ||
|
|
bcf5dcf180 | ||
|
|
8b2cbf7aea | ||
|
|
277b5e43f8 | ||
|
|
7dd6a31609 | ||
|
|
86bf1515d4 | ||
|
|
bbc47b387e | ||
|
|
3953e98a77 | ||
|
|
09b8bd501d | ||
|
|
6bddaaa055 | ||
|
|
dd728d4d13 | ||
|
|
9ba8bc8f3d | ||
|
|
16dee3664b | ||
|
|
6fcf588bfd | ||
|
|
6a6c427b0e | ||
|
|
41f885d8ec | ||
|
|
7516ca8dd9 | ||
|
|
aa59a95f91 | ||
|
|
8a5161c0e8 | ||
|
|
d8ae69e861 | ||
|
|
a485d0b507 | ||
|
|
ed40b79c8d | ||
|
|
91488ae294 | ||
|
|
a22a1938c1 | ||
|
|
0f73cb9f8c | ||
|
|
f3129399de | ||
|
|
37e6c27461 | ||
|
|
d231ed3ede | ||
|
|
cf6f9028f2 | ||
|
|
7cf49d2c1a | ||
|
|
e37fb1b4bd | ||
|
|
faf5d0bf7b | ||
|
|
6fede88072 | ||
|
|
87ffbefa61 | ||
|
|
f33ae17781 | ||
|
|
2a2bae6d1a | ||
|
|
eb65e99024 | ||
|
|
52dbeb6201 | ||
|
|
fafe920b7b | ||
|
|
9d1e48ee90 | ||
|
|
3851342e1b | ||
|
|
b863c2e19d | ||
|
|
ed584d19e5 | ||
|
|
73256dcf5b | ||
|
|
c67a23c988 | ||
|
|
8f42e65ccd | ||
|
|
05deb1aff0 | ||
|
|
a74b7cdfad | ||
|
|
1c3119ee76 | ||
|
|
ca52911e42 | ||
|
|
9177bc7cef | ||
|
|
310831a26a | ||
|
|
8a486d991b | ||
|
|
b79333393b | ||
|
|
cb7062bb95 | ||
|
|
94c5b2c471 | ||
|
|
66bf26f1dc | ||
|
|
f5cc1abe75 | ||
|
|
bd4255108d | ||
|
|
06b63d1af2 | ||
|
|
2bd70fb9e6 | ||
|
|
e6034dfb14 | ||
|
|
c8ba6764c2 | ||
|
|
19337d6c05 | ||
|
|
a6ad3c2d4d | ||
|
|
b0dd885c09 | ||
|
|
2235592308 | ||
|
|
1219a16261 | ||
|
|
7949e224e0 | ||
|
|
ab2f311bb7 | ||
|
|
a5aab43666 | ||
|
|
74ebd4e572 | ||
|
|
bd29670108 | ||
|
|
9a20b4428d | ||
|
|
d7da8521ce | ||
|
|
bab3b252c1 | ||
|
|
7f05c5c2da | ||
|
|
2d5c2779c3 | ||
|
|
06db4f6556 | ||
|
|
ea5ec63a22 | ||
|
|
f46e737e77 | ||
|
|
6e63bdaafa | ||
|
|
79ccef9f7e | ||
|
|
a9653b3674 | ||
|
|
b5a188e802 | ||
|
|
38fc402b16 | ||
|
|
584d1ec1ce | ||
|
|
2ca7faa457 | ||
|
|
03806abda0 | ||
|
|
18d125430d | ||
|
|
f57f6b7745 | ||
|
|
75ff2b8f40 | ||
|
|
2ec04c0121 | ||
|
|
5145a41dac | ||
|
|
25c67f1c3e | ||
|
|
77b9181150 | ||
|
|
e4f7ded8e2 | ||
|
|
35a62f1fb1 | ||
|
|
24f2deda46 | ||
|
|
5d8af1a0e5 | ||
|
|
6d44ac1e0c | ||
|
|
fb0ebddf28 | ||
|
|
0aebaa4da1 | ||
|
|
f6baedc655 | ||
|
|
7db222664d | ||
|
|
8223f0fd64 | ||
|
|
f44ec42f51 | ||
|
|
dab75ab6a9 | ||
|
|
01672b88e1 | ||
|
|
78dc297022 | ||
|
|
c8cf75fa28 | ||
|
|
b011b4e1d8 | ||
|
|
9804a2d18d | ||
|
|
d1a5ee43e9 | ||
|
|
febecab33c | ||
|
|
99042c0979 | ||
|
|
6395e4e00b | ||
|
|
4a69907ca3 | ||
|
|
c8d682cc98 | ||
|
|
f4cc8d3a0c | ||
|
|
6ac89334fd | ||
|
|
f55d3a66aa | ||
|
|
972eafab34 | ||
|
|
fba156b271 | ||
|
|
96d538db15 | ||
|
|
b89bfa3c1c | ||
|
|
73e3f42614 | ||
|
|
e761f5e1bd | ||
|
|
4a6dd66ecd | ||
|
|
8e1c6908c6 | ||
|
|
f59e569e22 | ||
|
|
be50b24504 | ||
|
|
c181831a37 | ||
|
|
dbee62ac8c | ||
|
|
1e949b3a22 | ||
|
|
b02dcf6c53 | ||
|
|
ab7718e335 | ||
|
|
451c77d47f | ||
|
|
fa0d3d4047 | ||
|
|
397a183f65 | ||
|
|
bc8905ee7f | ||
|
|
853032ac7f | ||
|
|
3b0e282a52 | ||
|
|
bb28cb3862 | ||
|
|
d0f458bece | ||
|
|
da54a72b3e | ||
|
|
86c155d8d1 | ||
|
|
666d2d3065 | ||
|
|
01e69fb6ca | ||
|
|
30e5253acd | ||
|
|
c67ce3918b | ||
|
|
58077f2d86 | ||
|
|
098c94bccf | ||
|
|
861a3c068f | ||
|
|
3b95e8ebc0 | ||
|
|
170e427530 | ||
|
|
707c9f4ce6 | ||
|
|
dc672bf0f0 | ||
|
|
c682737505 | ||
|
|
46fa3d4345 | ||
|
|
9b7bc4b495 | ||
|
|
4385061499 | ||
|
|
e17498e68b | ||
|
|
3e298f010b | ||
|
|
30117bd121 | ||
|
|
93f33b6218 | ||
|
|
535d08a607 | ||
|
|
6ac3a49db2 | ||
|
|
c16f760d79 | ||
|
|
965c2b243f | ||
|
|
703addddb9 | ||
|
|
ab6dff5c31 | ||
|
|
2ef330c62b | ||
|
|
72e71aff40 | ||
|
|
cefd6ec752 | ||
|
|
aec3de18aa | ||
|
|
97620cb583 | ||
|
|
cf4e8190a4 | ||
|
|
8af7436f13 | ||
|
|
c53520ae56 | ||
|
|
3adcaef455 | ||
|
|
43cd9722a9 | ||
|
|
f3154e770e |
@@ -1,6 +1,7 @@
|
|||||||
./.github
|
./.github
|
||||||
./build
|
./build
|
||||||
./coverage
|
./coverage
|
||||||
./dist
|
|
||||||
./node_modules
|
./node_modules
|
||||||
./test
|
./test
|
||||||
|
./shlink-web-client.gif
|
||||||
|
./dist
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"no-magic-numbers": "off",
|
"no-magic-numbers": "off",
|
||||||
"no-undefined": "off",
|
"no-undefined": "off",
|
||||||
"no-inline-comments": "off",
|
"no-inline-comments": "off",
|
||||||
|
"lines-around-comment": "off",
|
||||||
"indent": ["error", 2, {
|
"indent": ["error", 2, {
|
||||||
"SwitchCase": 1
|
"SwitchCase": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
build:
|
build:
|
||||||
environment:
|
environment:
|
||||||
node: v12.11.0
|
node: v12.14.1
|
||||||
tools:
|
tools:
|
||||||
external_code_coverage:
|
external_code_coverage:
|
||||||
timeout: 1200
|
timeout: 1200
|
||||||
|
|||||||
54
.travis.yml
54
.travis.yml
@@ -1,7 +1,21 @@
|
|||||||
|
dist: bionic
|
||||||
|
|
||||||
language: node_js
|
language: node_js
|
||||||
|
|
||||||
node_js:
|
jobs:
|
||||||
- "12.11.0"
|
fast_finish: true
|
||||||
|
include:
|
||||||
|
- name: "Docker publish"
|
||||||
|
node_js: '12.16.3'
|
||||||
|
if: NOT type = pull_request
|
||||||
|
env:
|
||||||
|
- DOCKER_PUBLISH="true"
|
||||||
|
- name: "CI"
|
||||||
|
node_js: '12.16.3'
|
||||||
|
env:
|
||||||
|
- DOCKER_PUBLISH="false"
|
||||||
|
allow_failures:
|
||||||
|
- name: "Docker publish"
|
||||||
|
|
||||||
cache:
|
cache:
|
||||||
directories:
|
directories:
|
||||||
@@ -11,30 +25,34 @@ services:
|
|||||||
- docker
|
- docker
|
||||||
|
|
||||||
install:
|
install:
|
||||||
- npm ci
|
- if [[ ${DOCKER_PUBLISH} == 'true' ]]; then sudo bash ./scripts/docker/install-docker ; fi
|
||||||
|
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then npm ci ; fi
|
||||||
|
|
||||||
before_script:
|
before_script:
|
||||||
- echo "Building commit range ${TRAVIS_COMMIT_RANGE}"
|
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then echo "Building commit range ${TRAVIS_COMMIT_RANGE}" ; fi
|
||||||
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep -E 'src\/(.*).(js|ts|jsx|tsx)$' | paste -sd ",")
|
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep -E 'src\/(.*).(js|ts|jsx|tsx)$' | paste -sd ",") ; fi
|
||||||
|
|
||||||
script:
|
script:
|
||||||
- npm run lint
|
- if [[ ${DOCKER_PUBLISH} == 'true' ]]; then bash ./scripts/docker/build ; fi
|
||||||
- npm run test:ci
|
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then npm run lint ; fi
|
||||||
- if [[ -z $TRAVIS_TAG ]]; then docker build -t shlink-web-client:test . ; fi
|
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then npm run test:ci ; fi
|
||||||
- if [[ -z $TRAVIS_TAG ]]; then npm run mutate:ci ; fi
|
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then docker build -t shlink-web-client:test . ; fi
|
||||||
|
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then npm run mutate:ci ; fi
|
||||||
|
|
||||||
after_success:
|
after_success:
|
||||||
- node_modules/.bin/ocular coverage/clover.xml
|
- if [[ ${DOCKER_PUBLISH} == 'false' ]]; then node_modules/.bin/ocular coverage/clover.xml ; fi
|
||||||
|
|
||||||
# Before deploying, build dist file for current travis tag
|
# Before deploying, build dist file for current travis tag
|
||||||
before_deploy:
|
before_deploy:
|
||||||
- npm run build ${TRAVIS_TAG#?}
|
- if [[ ! -z $TRAVIS_TAG && ${DOCKER_PUBLISH} == 'false' ]]; then npm run build ${TRAVIS_TAG#?} ; fi
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
provider: releases
|
- provider: releases
|
||||||
api_key:
|
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=
|
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"
|
file: "./dist/shlink-web-client_${TRAVIS_TAG#?}_dist.zip"
|
||||||
skip_cleanup: true
|
skip_cleanup: true
|
||||||
on:
|
on:
|
||||||
tags: true
|
all_branches: true
|
||||||
|
condition: ${DOCKER_PUBLISH} == 'false'
|
||||||
|
tags: true
|
||||||
|
|||||||
107
CHANGELOG.md
107
CHANGELOG.md
@@ -4,6 +4,113 @@ 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).
|
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.5.0 - 2020-05-31
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
* [#148](https://github.com/shlinkio/shlink-web-client/issues/148) Added support for real-time updates when consuming a Shlink version that is integrated with a mercure hub server.
|
||||||
|
|
||||||
|
The integration is transparent. When a server is opened, shlink-web-client will try to get the mercure info from it.
|
||||||
|
|
||||||
|
* If it works, it will setup the necessary `EventSource`s, dispatching redux actions when an event is pushed, which will in turn update the UI.
|
||||||
|
* If it fails, it will assume it is either not configured or not supported by the Shlink version.
|
||||||
|
|
||||||
|
* [#265](https://github.com/shlinkio/shlink-web-client/issues/265) Updated tags section to allow displaying number of short URLs using every tag and number of visits for all short URLs using the tag.
|
||||||
|
|
||||||
|
This will work only when using Shlink v2.2.0 or above. For previous versions, the tags page will continue behaving the same.
|
||||||
|
|
||||||
|
* [#261](https://github.com/shlinkio/shlink-web-client/issues/261) Added new page to show visit stats by tag.
|
||||||
|
|
||||||
|
This new page will return a "not found" error when the server is lower than v2.2.0, as older versions do not support fetching stats by tag.
|
||||||
|
|
||||||
|
* [#253](https://github.com/shlinkio/shlink-web-client/issues/253) Created new settings page that will be used to define customizations in the app.
|
||||||
|
|
||||||
|
* [#149](https://github.com/shlinkio/shlink-web-client/issues/149) and [#198](https://github.com/shlinkio/shlink-web-client/issues/198) Added new line chart to visits and tags stats which displays amount of visits during selected time period, grouped by month, week, day or hour.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
* [#218](https://github.com/shlinkio/shlink-web-client/issues/218) Added back button to sections not displayed in left menu.
|
||||||
|
* [#255](https://github.com/shlinkio/shlink-web-client/issues/255) Improved how servers and settings are persisted in the local storage.
|
||||||
|
|
||||||
|
#### Deprecated
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
* [#262](https://github.com/shlinkio/shlink-web-client/issues/262) Fixed charts displaying decimal numbers, when visits are absolute and that makes no sense.
|
||||||
|
|
||||||
|
|
||||||
|
## 2.4.0 - 2020-04-10
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
* [#199](https://github.com/shlinkio/shlink-web-client/issues/199) Added table to visits page which displays the information in a paginated, sortable and filterable list.
|
||||||
|
|
||||||
|
It also supports selecting multiple visits in the table which makes the corresponding data to be highlighted in the visits charts.
|
||||||
|
|
||||||
|
* [#241](https://github.com/shlinkio/shlink-web-client/issues/241) Added support to select charts bars in order to highlight related stats in other charts.
|
||||||
|
|
||||||
|
It also selects the visits in the new table, and you can even combine a selection in the chart and in the table.
|
||||||
|
|
||||||
|
* [#213](https://github.com/shlinkio/shlink-web-client/issues/213) The versions of both shlink-web-client and currently consumed Shlink server are now displayed in the footer.
|
||||||
|
* [#221](https://github.com/shlinkio/shlink-web-client/issues/221) Improved how servers are handled, displaying meaningful errors when a not-found or a not-reachable server is tried to be loaded.
|
||||||
|
* [#226](https://github.com/shlinkio/shlink-web-client/issues/226) Created servers can now be edited.
|
||||||
|
* [#234](https://github.com/shlinkio/shlink-web-client/issues/234) Allowed short code length to be edited on any new short URL when using Shlink 2.1 or higher.
|
||||||
|
* [#235](https://github.com/shlinkio/shlink-web-client/issues/235) Allowed editing the long URL for any existing short URL when suing Shlink 2.1 or higher.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
* [#205](https://github.com/shlinkio/shlink-web-client/issues/205) Replaced `jest-each` package by jet's native `test.each` function.
|
||||||
|
* [#209](https://github.com/shlinkio/shlink-web-client/issues/209) Replaced `Unknown` by `Direct` for visits from undetermined referrers.
|
||||||
|
* [#212](https://github.com/shlinkio/shlink-web-client/issues/212) Moved copy-to-clipboard next to short URL.
|
||||||
|
* [#208](https://github.com/shlinkio/shlink-web-client/issues/208) Short URLs list paginator is now progressive.
|
||||||
|
|
||||||
|
#### Deprecated
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
* [#243](https://github.com/shlinkio/shlink-web-client/issues/243) Fixed loading state and resetting on short URL creation form.
|
||||||
|
* [#239](https://github.com/shlinkio/shlink-web-client/issues/239) Fixed how user agents are parsed, reducing false results.
|
||||||
|
|
||||||
|
|
||||||
|
## 2.3.1 - 2020-02-08
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
* [#191](https://github.com/shlinkio/shlink-web-client/issues/191) Created `ForServerVersion` helper component which dynamically renders children if current server conditions are met.
|
||||||
|
* [#189](https://github.com/shlinkio/shlink-web-client/issues/189) Simplified short url tags and short url deletion components and reducers, by removing redundant actions.
|
||||||
|
|
||||||
|
#### Deprecated
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
* [#193](https://github.com/shlinkio/shlink-web-client/issues/193) Fixed `maxVisits` being set to 0 when trying to reset it from having a value to `null`.
|
||||||
|
* [#196](https://github.com/shlinkio/shlink-web-client/issues/196) Included apache `.htaccess` file which takes care of falling back to index.html when reloading the page on a client-side handled route.
|
||||||
|
* [#179](https://github.com/shlinkio/shlink-web-client/issues/179) Ensured domain is provided to Shlink server when editing, deleting or fetching short URLs which do not belong to default domain.
|
||||||
|
* [#202](https://github.com/shlinkio/shlink-web-client/issues/202) Fixed domain not passed when dispatching actions that affect a single short URL (edit tags, edit meta and delete), which cased the list not to be properly updated.
|
||||||
|
|
||||||
|
|
||||||
## 2.3.0 - 2020-01-19
|
## 2.3.0 - 2020-01-19
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
FROM node:12.11.1-alpine as node
|
FROM node:12.16.3-alpine as node
|
||||||
COPY . /shlink-web-client
|
COPY . /shlink-web-client
|
||||||
RUN cd /shlink-web-client && npm install && npm run build
|
ARG VERSION="latest"
|
||||||
|
ENV VERSION ${VERSION}
|
||||||
|
RUN cd /shlink-web-client && \
|
||||||
|
npm install && npm run build -- ${VERSION} --no-dist
|
||||||
|
|
||||||
FROM nginx:1.17.7-alpine
|
FROM nginx:1.17.10-alpine
|
||||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||||
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
|
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
|
||||||
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -1,36 +1,45 @@
|
|||||||
# shlink-web-client
|
# shlink-web-client
|
||||||
|
|
||||||
[](https://travis-ci.org/shlinkio/shlink-web-client)
|
[](https://travis-ci.org/shlinkio/shlink-web-client)
|
||||||
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
|
||||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
|
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
|
||||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
|
[](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
|
||||||
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
[](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
||||||
|
[](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||||
[](https://github.com/shlinkio/shlink-web-client/blob/master/LICENSE)
|
[](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).
|
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
There are three ways in which you can use this application.
|
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
|
## Pre-configuring servers
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# When requesting static paths with extension, try them, and return a 404 if not found
|
# When requesting static paths with extension, try them, and return a 404 if not found
|
||||||
location ~ .+\.(css|js|html|png|jpg|jpeg|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
|
location ~* .+\.(css|js|html|png|jpe?g|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
|
||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ version: '3'
|
|||||||
services:
|
services:
|
||||||
shlink_web_client_node:
|
shlink_web_client_node:
|
||||||
container_name: shlink_web_client_node
|
container_name: shlink_web_client_node
|
||||||
image: node:12.11.0-alpine
|
image: node:12.16.3-alpine
|
||||||
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/home/shlink/www
|
- ./:/home/shlink/www
|
||||||
|
|||||||
2337
package-lock.json
generated
2337
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "shlink-web-client",
|
"name": "shlink-web-client",
|
||||||
"description": "A React-based progressive web application for shlink",
|
"description": "A React-based progressive web application for shlink",
|
||||||
"version": "2.3.0",
|
|
||||||
"private": false,
|
"private": false,
|
||||||
"homepage": "",
|
"homepage": "",
|
||||||
"repository": "https://github.com/shlinkio/shlink-web-client",
|
"repository": "https://github.com/shlinkio/shlink-web-client",
|
||||||
@@ -19,7 +18,8 @@
|
|||||||
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
||||||
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
|
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
|
||||||
"mutate": "./node_modules/.bin/stryker run",
|
"mutate": "./node_modules/.bin/stryker run",
|
||||||
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES"
|
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES",
|
||||||
|
"check": "npm run test & npm run lint & wait"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^5.11.2",
|
"@fortawesome/fontawesome-free": "^5.11.2",
|
||||||
@@ -33,23 +33,25 @@
|
|||||||
"axios": "^0.19.0",
|
"axios": "^0.19.0",
|
||||||
"bootstrap": "^4.3.1",
|
"bootstrap": "^4.3.1",
|
||||||
"bottlejs": "^1.7.2",
|
"bottlejs": "^1.7.2",
|
||||||
|
"bowser": "^2.9.0",
|
||||||
"chart.js": "^2.8.0",
|
"chart.js": "^2.8.0",
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6",
|
||||||
"compare-versions": "^3.5.1",
|
"compare-versions": "^3.5.1",
|
||||||
"csvjson": "^5.1.0",
|
"csvjson": "^5.1.0",
|
||||||
|
"event-source-polyfill": "^1.0.12",
|
||||||
"leaflet": "^1.5.1",
|
"leaflet": "^1.5.1",
|
||||||
"moment": "^2.24.0",
|
"moment": "^2.24.0",
|
||||||
"promise": "^8.0.3",
|
"promise": "^8.0.3",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"qs": "^6.9.0",
|
"qs": "^6.9.0",
|
||||||
"ramda": "^0.26.1",
|
"ramda": "^0.26.1",
|
||||||
"react": "^16.10.2",
|
"react": "^16.13.1",
|
||||||
"react-autosuggest": "^9.4.3",
|
"react-autosuggest": "^9.4.3",
|
||||||
"react-chartjs-2": "^2.8.0",
|
"react-chartjs-2": "^2.8.0",
|
||||||
"react-color": "^2.17.3",
|
"react-color": "^2.17.3",
|
||||||
"react-copy-to-clipboard": "^5.0.1",
|
"react-copy-to-clipboard": "^5.0.1",
|
||||||
"react-datepicker": "~1.5.0",
|
"react-datepicker": "~1.5.0",
|
||||||
"react-dom": "^16.10.2",
|
"react-dom": "^16.13.1",
|
||||||
"react-external-link": "^1.0.0",
|
"react-external-link": "^1.0.0",
|
||||||
"react-leaflet": "^2.4.0",
|
"react-leaflet": "^2.4.0",
|
||||||
"react-moment": "^0.9.5",
|
"react-moment": "^0.9.5",
|
||||||
@@ -60,15 +62,15 @@
|
|||||||
"reactstrap": "^8.0.1",
|
"reactstrap": "^8.0.1",
|
||||||
"redux": "^4.0.4",
|
"redux": "^4.0.4",
|
||||||
"redux-actions": "^2.6.5",
|
"redux-actions": "^2.6.5",
|
||||||
|
"redux-localstorage-simple": "^2.2.0",
|
||||||
"redux-thunk": "^2.3.0",
|
"redux-thunk": "^2.3.0",
|
||||||
"uuid": "^3.3.3"
|
"uuid": "^3.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.6.2",
|
"@babel/core": "^7.6.2",
|
||||||
"@stryker-mutator/core": "^2.1.0",
|
"@stryker-mutator/core": "^3.2.4",
|
||||||
"@stryker-mutator/html-reporter": "^2.1.0",
|
"@stryker-mutator/javascript-mutator": "^3.2.4",
|
||||||
"@stryker-mutator/javascript-mutator": "^2.1.0",
|
"@stryker-mutator/jest-runner": "^3.2.4",
|
||||||
"@stryker-mutator/jest-runner": "^2.1.0",
|
|
||||||
"@svgr/webpack": "^4.3.3",
|
"@svgr/webpack": "^4.3.3",
|
||||||
"adm-zip": "^0.4.13",
|
"adm-zip": "^0.4.13",
|
||||||
"autoprefixer": "^9.6.3",
|
"autoprefixer": "^9.6.3",
|
||||||
@@ -85,8 +87,8 @@
|
|||||||
"css-loader": "^3.2.0",
|
"css-loader": "^3.2.0",
|
||||||
"dotenv": "^8.1.0",
|
"dotenv": "^8.1.0",
|
||||||
"dotenv-expand": "^5.1.0",
|
"dotenv-expand": "^5.1.0",
|
||||||
"enzyme": "^3.10.0",
|
"enzyme": "^3.11.0",
|
||||||
"enzyme-adapter-react-16": "^1.14.0",
|
"enzyme-adapter-react-16": "^1.15.2",
|
||||||
"eslint": "^5.11.1",
|
"eslint": "^5.11.1",
|
||||||
"eslint-config-adidas-babel": "^1.1.0",
|
"eslint-config-adidas-babel": "^1.1.0",
|
||||||
"eslint-config-adidas-env": "^1.1.0",
|
"eslint-config-adidas-env": "^1.1.0",
|
||||||
@@ -104,7 +106,6 @@
|
|||||||
"html-webpack-plugin": "^4.0.0-beta.8",
|
"html-webpack-plugin": "^4.0.0-beta.8",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^24.9.0",
|
"jest": "^24.9.0",
|
||||||
"jest-each": "^24.9.0",
|
|
||||||
"jest-pnp-resolver": "^1.2.1",
|
"jest-pnp-resolver": "^1.2.1",
|
||||||
"jest-resolve": "^24.9.0",
|
"jest-resolve": "^24.9.0",
|
||||||
"mini-css-extract-plugin": "^0.8.0",
|
"mini-css-extract-plugin": "^0.8.0",
|
||||||
|
|||||||
16
public/.htaccess
Normal file
16
public/.htaccess
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
RewriteEngine on
|
||||||
|
RewriteBase /
|
||||||
|
|
||||||
|
# do not do anything for already existing files
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -l [OR]
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -d
|
||||||
|
RewriteRule (.*) - [L]
|
||||||
|
|
||||||
|
# if request is no valid file NOR directory
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
# if static asset do not do anything
|
||||||
|
RewriteRule (.*)(css|js|html|png|jpe?g|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) - [NC,L,R=404]
|
||||||
|
# everything else should be redirected to /index.html so it can be routed by it
|
||||||
|
RewriteRule (.*) /index.html [L]
|
||||||
@@ -14,7 +14,6 @@ process.on('unhandledRejection', (err) => {
|
|||||||
// Ensure environment variables are read.
|
// Ensure environment variables are read.
|
||||||
require('../config/env');
|
require('../config/env');
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const chalk = require('chalk');
|
const chalk = require('chalk');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const webpack = require('webpack');
|
const webpack = require('webpack');
|
||||||
@@ -22,7 +21,6 @@ const bfj = require('bfj');
|
|||||||
const AdmZip = require('adm-zip');
|
const AdmZip = require('adm-zip');
|
||||||
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
|
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
|
||||||
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
|
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
|
||||||
const printHostingInstructions = require('react-dev-utils/printHostingInstructions');
|
|
||||||
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
|
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
|
||||||
const printBuildError = require('react-dev-utils/printBuildError');
|
const printBuildError = require('react-dev-utils/printBuildError');
|
||||||
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
|
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
|
||||||
@@ -30,7 +28,6 @@ const paths = require('../config/paths');
|
|||||||
const configFactory = require('../config/webpack.config');
|
const configFactory = require('../config/webpack.config');
|
||||||
|
|
||||||
const { measureFileSizesBeforeBuild, printFileSizesAfterBuild } = FileSizeReporter;
|
const { measureFileSizesBeforeBuild, printFileSizesAfterBuild } = FileSizeReporter;
|
||||||
const useYarn = fs.existsSync(paths.yarnLockFile);
|
|
||||||
|
|
||||||
// These sizes are pretty large. We'll warn for bundles exceeding them.
|
// These sizes are pretty large. We'll warn for bundles exceeding them.
|
||||||
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; // eslint-disable-line
|
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; // eslint-disable-line
|
||||||
@@ -47,6 +44,8 @@ if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
|
|||||||
const argvSliceStart = 2;
|
const argvSliceStart = 2;
|
||||||
const argv = process.argv.slice(argvSliceStart);
|
const argv = process.argv.slice(argvSliceStart);
|
||||||
const writeStatsJson = argv.indexOf('--stats') !== -1;
|
const writeStatsJson = argv.indexOf('--stats') !== -1;
|
||||||
|
const withoutDist = argv.indexOf('--no-dist') !== -1;
|
||||||
|
const { version, hasVersion } = getVersionFromArgs(argv);
|
||||||
|
|
||||||
// Generate configuration
|
// Generate configuration
|
||||||
const config = configFactory('production');
|
const config = configFactory('production');
|
||||||
@@ -85,6 +84,7 @@ checkBrowsers(paths.appPath, isInteractive)
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log(chalk.green('Compiled successfully.\n'));
|
console.log(chalk.green('Compiled successfully.\n'));
|
||||||
|
hasVersion && replaceVersionPlaceholder(version);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('File sizes after gzip:\n');
|
console.log('File sizes after gzip:\n');
|
||||||
@@ -96,20 +96,6 @@ checkBrowsers(paths.appPath, isInteractive)
|
|||||||
WARN_AFTER_CHUNK_GZIP_SIZE
|
WARN_AFTER_CHUNK_GZIP_SIZE
|
||||||
);
|
);
|
||||||
console.log();
|
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) => {
|
(err) => {
|
||||||
console.log(chalk.red('Failed to compile.\n'));
|
console.log(chalk.red('Failed to compile.\n'));
|
||||||
@@ -117,7 +103,7 @@ checkBrowsers(paths.appPath, isInteractive)
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.then(zipDist)
|
.then(() => hasVersion && !withoutDist && zipDist(version))
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (err && err.message) {
|
if (err && err.message) {
|
||||||
console.log(err.message);
|
console.log(err.message);
|
||||||
@@ -200,15 +186,7 @@ function copyPublicFolder() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function zipDist() {
|
function zipDist(version) {
|
||||||
const minArgsToContainVersion = 3;
|
|
||||||
|
|
||||||
// If no version was provided, do nothing
|
|
||||||
if (process.argv.length < minArgsToContainVersion) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [ , , version ] = process.argv;
|
|
||||||
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
|
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
|
||||||
|
|
||||||
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
|
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(chalk.red('An error occurred while generating dist file'));
|
||||||
console.log(e);
|
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');
|
||||||
}
|
}
|
||||||
|
|||||||
33
scripts/docker/build
Executable file
33
scripts/docker/build
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
#PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64"
|
||||||
|
PLATFORMS="linux/amd64"
|
||||||
|
DOCKER_IMAGE="shlinkio/shlink-web-client"
|
||||||
|
BUILDX_VER=v0.4.1
|
||||||
|
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||||
|
|
||||||
|
mkdir -vp ~/.docker/cli-plugins/ ~/dockercache
|
||||||
|
curl --silent -L "https://github.com/docker/buildx/releases/download/${BUILDX_VER}/buildx-${BUILDX_VER}.linux-amd64" > ~/.docker/cli-plugins/docker-buildx
|
||||||
|
chmod a+x ~/.docker/cli-plugins/docker-buildx
|
||||||
|
|
||||||
|
docker buildx create --use
|
||||||
|
|
||||||
|
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
|
||||||
|
|
||||||
|
if [[ -z $TRAVIS_TAG ]]; then
|
||||||
|
docker buildx build --push \
|
||||||
|
--platform ${PLATFORMS} \
|
||||||
|
-t ${DOCKER_IMAGE}:latest .
|
||||||
|
|
||||||
|
else
|
||||||
|
TAGS="-t ${DOCKER_IMAGE}:${TRAVIS_TAG#?}"
|
||||||
|
# Push stable tag only if this is not an alpha or beta release
|
||||||
|
[[ $TRAVIS_TAG != *"alpha"* && $TRAVIS_TAG != *"beta"* ]] && TAGS="${TAGS} -t ${DOCKER_IMAGE}:stable"
|
||||||
|
|
||||||
|
docker buildx build --push \
|
||||||
|
--build-arg SHLINK_VERSION=${TRAVIS_TAG#?} \
|
||||||
|
--platform ${PLATFORMS} \
|
||||||
|
${TAGS} .
|
||||||
|
fi
|
||||||
12
scripts/docker/install-docker
Executable file
12
scripts/docker/install-docker
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -ex
|
||||||
|
|
||||||
|
# install latest docker version
|
||||||
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
|
||||||
|
add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
|
||||||
|
apt-get update
|
||||||
|
apt-get -y -o Dpkg::Options::="--force-confnew" install docker-ce
|
||||||
|
|
||||||
|
# enable multiarch execution
|
||||||
|
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
||||||
BIN
shlink-web-client.gif
Normal file
BIN
shlink-web-client.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 MiB |
47
src/App.js
47
src/App.js
@@ -1,21 +1,40 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
import { Route, Switch } from 'react-router-dom';
|
import { Route, Switch } from 'react-router-dom';
|
||||||
import './App.scss';
|
|
||||||
import NotFound from './common/NotFound';
|
import NotFound from './common/NotFound';
|
||||||
|
import './App.scss';
|
||||||
|
|
||||||
const App = (MainHeader, Home, MenuLayout, CreateServer) => () => (
|
const propTypes = {
|
||||||
<div className="container-fluid app-container">
|
fetchServers: PropTypes.func,
|
||||||
<MainHeader />
|
servers: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
<div className="app">
|
const App = (MainHeader, Home, MenuLayout, CreateServer, EditServer, Settings) => ({ fetchServers, servers }) => {
|
||||||
<Switch>
|
// On first load, try to fetch the remote servers if the list is empty
|
||||||
<Route exact path="/server/create" component={CreateServer} />
|
useEffect(() => {
|
||||||
<Route exact path="/" component={Home} />
|
if (Object.keys(servers).length === 0) {
|
||||||
<Route path="/server/:serverId" component={MenuLayout} />
|
fetchServers();
|
||||||
<Route component={NotFound} />
|
}
|
||||||
</Switch>
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid app-container">
|
||||||
|
<MainHeader />
|
||||||
|
|
||||||
|
<div className="app">
|
||||||
|
<Switch>
|
||||||
|
<Route exact path="/" component={Home} />
|
||||||
|
<Route exact path="/settings" component={Settings} />
|
||||||
|
<Route exact path="/server/create" component={CreateServer} />
|
||||||
|
<Route exact path="/server/:serverId/edit" component={EditServer} />
|
||||||
|
<Route path="/server/:serverId" component={MenuLayout} />
|
||||||
|
<Route component={NotFound} />
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
|
App.propTypes = propTypes;
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import classnames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { serverType } from '../servers/prop-types';
|
import { serverType } from '../servers/prop-types';
|
||||||
import './AsideMenu.scss';
|
import './AsideMenu.scss';
|
||||||
|
|
||||||
const defaultProps = {
|
const AsideMenuItem = ({ children, to, className, ...rest }) => (
|
||||||
className: '',
|
<NavLink
|
||||||
showOnMobile: false,
|
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 = {
|
const propTypes = {
|
||||||
selectedServer: serverType,
|
selectedServer: serverType,
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
@@ -20,43 +38,34 @@ const propTypes = {
|
|||||||
const AsideMenu = (DeleteServerButton) => {
|
const AsideMenu = (DeleteServerButton) => {
|
||||||
const AsideMenu = ({ selectedServer, className, showOnMobile }) => {
|
const AsideMenu = ({ selectedServer, className, showOnMobile }) => {
|
||||||
const serverId = selectedServer ? selectedServer.id : '';
|
const serverId = selectedServer ? selectedServer.id : '';
|
||||||
const asideClass = classnames('aside-menu', className, {
|
const asideClass = classNames('aside-menu', className, {
|
||||||
'aside-menu--hidden': !showOnMobile,
|
'aside-menu--hidden': !showOnMobile,
|
||||||
});
|
});
|
||||||
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
|
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
|
||||||
|
const buildPath = (suffix) => `/server/${serverId}${suffix}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={asideClass}>
|
<aside className={asideClass}>
|
||||||
<nav className="nav flex-column aside-menu__nav">
|
<nav className="nav flex-column aside-menu__nav">
|
||||||
<NavLink
|
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
||||||
className="aside-menu__item"
|
|
||||||
activeClassName="aside-menu__item--selected"
|
|
||||||
to={`/server/${serverId}/list-short-urls/1`}
|
|
||||||
isActive={shortUrlsIsActive}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={listIcon} />
|
<FontAwesomeIcon icon={listIcon} />
|
||||||
<span className="aside-menu__item-text">List short URLs</span>
|
<span className="aside-menu__item-text">List short URLs</span>
|
||||||
</NavLink>
|
</AsideMenuItem>
|
||||||
<NavLink
|
<AsideMenuItem to={buildPath('/create-short-url')}>
|
||||||
className="aside-menu__item"
|
|
||||||
activeClassName="aside-menu__item--selected"
|
|
||||||
to={`/server/${serverId}/create-short-url`}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
|
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
|
||||||
<span className="aside-menu__item-text">Create short URL</span>
|
<span className="aside-menu__item-text">Create short URL</span>
|
||||||
</NavLink>
|
</AsideMenuItem>
|
||||||
|
<AsideMenuItem to={buildPath('/manage-tags')}>
|
||||||
<NavLink
|
|
||||||
className="aside-menu__item"
|
|
||||||
activeClassName="aside-menu__item--selected"
|
|
||||||
to={`/server/${serverId}/manage-tags`}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={tagsIcon} />
|
<FontAwesomeIcon icon={tagsIcon} />
|
||||||
<span className="aside-menu__item-text">Manage tags</span>
|
<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
|
<DeleteServerButton
|
||||||
className="aside-menu__item aside-menu__item--danger"
|
className="aside-menu__item aside-menu__item--danger"
|
||||||
|
textClassName="aside-menu__item-text"
|
||||||
server={selectedServer}
|
server={selectedServer}
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -64,7 +73,6 @@ const AsideMenu = (DeleteServerButton) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
AsideMenu.defaultProps = defaultProps;
|
|
||||||
AsideMenu.propTypes = propTypes;
|
AsideMenu.propTypes = propTypes;
|
||||||
|
|
||||||
return AsideMenu;
|
return AsideMenu;
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ $asideMenuMobileWidth: 280px;
|
|||||||
|
|
||||||
.aside-menu__item--danger {
|
.aside-menu__item--danger {
|
||||||
color: $dangerColor;
|
color: $dangerColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aside-menu__item--push {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +1,34 @@
|
|||||||
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
|
import React, { useEffect } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { isEmpty, values } from 'ramda';
|
import { isEmpty, values } from 'ramda';
|
||||||
import React from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ListGroup, ListGroupItem } from 'reactstrap';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import './Home.scss';
|
import './Home.scss';
|
||||||
|
import ServersListGroup from '../servers/ServersListGroup';
|
||||||
|
|
||||||
export default class Home extends React.Component {
|
const propTypes = {
|
||||||
static propTypes = {
|
resetSelectedServer: PropTypes.func,
|
||||||
resetSelectedServer: PropTypes.func,
|
servers: PropTypes.object,
|
||||||
servers: PropTypes.object,
|
};
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount() {
|
const Home = ({ resetSelectedServer, servers }) => {
|
||||||
this.props.resetSelectedServer();
|
const serversList = values(servers);
|
||||||
}
|
const hasServers = !isEmpty(serversList);
|
||||||
|
|
||||||
render() {
|
useEffect(() => {
|
||||||
const { servers: { list, loading } } = this.props;
|
resetSelectedServer();
|
||||||
const servers = values(list);
|
}, []);
|
||||||
const hasServers = !isEmpty(servers);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="home">
|
<div className="home">
|
||||||
<h1 className="home__title">Welcome to Shlink</h1>
|
<h1 className="home__title">Welcome to Shlink</h1>
|
||||||
<h5 className="home__intro">
|
<ServersListGroup servers={serversList}>
|
||||||
{!loading && hasServers && <span>Please, select a server.</span>}
|
{hasServers && <span>Please, select a server.</span>}
|
||||||
{!loading && !hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
|
{!hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
|
||||||
{loading && <span>Trying to load servers...</span>}
|
</ServersListGroup>
|
||||||
</h5>
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
{!loading && hasServers && (
|
Home.propTypes = propTypes;
|
||||||
<ListGroup className="home__servers-list">
|
|
||||||
{servers.map(({ name, id }) => (
|
export default Home;
|
||||||
<ListGroupItem
|
|
||||||
key={id}
|
|
||||||
tag={Link}
|
|
||||||
to={`/server/${id}/list-short-urls/1`}
|
|
||||||
className="home__servers-item"
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
<FontAwesomeIcon icon={chevronIcon} className="home__servers-item-icon" />
|
|
||||||
</ListGroupItem>
|
|
||||||
))}
|
|
||||||
</ListGroup>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
@import '../utils/base';
|
@import '../utils/base';
|
||||||
@import '../utils/mixins/vertical-align';
|
|
||||||
|
|
||||||
.home {
|
.home {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -17,21 +16,3 @@
|
|||||||
font-size: 2.2rem;
|
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,37 +1,28 @@
|
|||||||
import { faPlus as plusIcon, faChevronDown as arrowIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faPlus as plusIcon, faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
||||||
import classnames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
import shlinkLogo from './shlink-logo-white.png';
|
import shlinkLogo from './shlink-logo-white.png';
|
||||||
import './MainHeader.scss';
|
import './MainHeader.scss';
|
||||||
|
|
||||||
const MainHeader = (ServersDropdown) => class MainHeader extends React.Component {
|
const propTypes = {
|
||||||
static propTypes = {
|
location: PropTypes.object,
|
||||||
location: PropTypes.object,
|
};
|
||||||
};
|
|
||||||
|
|
||||||
state = { isOpen: false };
|
const MainHeader = (ServersDropdown) => {
|
||||||
handleToggle = () => {
|
const MainHeaderComp = ({ location }) => {
|
||||||
this.setState(({ isOpen }) => ({
|
const [ isOpen, toggleOpen, , close ] = useToggle();
|
||||||
isOpen: !isOpen,
|
const { pathname } = location;
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
useEffect(close, [ location ]);
|
||||||
if (this.props.location !== prevProps.location) {
|
|
||||||
this.setState({ isOpen: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { location } = this.props;
|
|
||||||
const createServerPath = '/server/create';
|
const createServerPath = '/server/create';
|
||||||
const toggleClass = classnames('main-header__toggle-icon', {
|
const settingsPath = '/settings';
|
||||||
'main-header__toggle-icon--opened': this.state.isOpen,
|
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
||||||
@@ -39,18 +30,19 @@ const MainHeader = (ServersDropdown) => class MainHeader extends React.Component
|
|||||||
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
|
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
|
||||||
</NavbarBrand>
|
</NavbarBrand>
|
||||||
|
|
||||||
<NavbarToggler onClick={this.handleToggle}>
|
<NavbarToggler onClick={toggleOpen}>
|
||||||
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
||||||
</NavbarToggler>
|
</NavbarToggler>
|
||||||
|
|
||||||
<Collapse navbar isOpen={this.state.isOpen}>
|
<Collapse navbar isOpen={isOpen}>
|
||||||
<Nav navbar className="ml-auto">
|
<Nav navbar className="ml-auto">
|
||||||
<NavItem>
|
<NavItem>
|
||||||
<NavLink
|
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
|
||||||
tag={Link}
|
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||||
to={createServerPath}
|
</NavLink>
|
||||||
active={location.pathname === createServerPath}
|
</NavItem>
|
||||||
>
|
<NavItem>
|
||||||
|
<NavLink tag={Link} to={createServerPath} active={pathname === createServerPath}>
|
||||||
<FontAwesomeIcon icon={plusIcon} /> Add server
|
<FontAwesomeIcon icon={plusIcon} /> Add server
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
@@ -59,7 +51,11 @@ const MainHeader = (ServersDropdown) => class MainHeader extends React.Component
|
|||||||
</Collapse>
|
</Collapse>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
MainHeaderComp.propTypes = propTypes;
|
||||||
|
|
||||||
|
return MainHeaderComp;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MainHeader;
|
export default MainHeader;
|
||||||
|
|||||||
@@ -1,110 +1,98 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Route, Switch } from 'react-router-dom';
|
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 { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import classnames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import * as PropTypes from 'prop-types';
|
import * as PropTypes from 'prop-types';
|
||||||
import { serverType } from '../servers/prop-types';
|
import { serverType } from '../servers/prop-types';
|
||||||
|
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
|
import { versionMatch } from '../utils/helpers/version';
|
||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
import './MenuLayout.scss';
|
import './MenuLayout.scss';
|
||||||
|
|
||||||
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits) =>
|
const propTypes = {
|
||||||
class MenuLayout extends React.Component {
|
match: PropTypes.object,
|
||||||
static propTypes = {
|
location: PropTypes.object,
|
||||||
match: PropTypes.object,
|
selectedServer: serverType,
|
||||||
selectServer: PropTypes.func,
|
};
|
||||||
location: PropTypes.object,
|
|
||||||
selectedServer: serverType,
|
const MenuLayout = (
|
||||||
|
TagsList,
|
||||||
|
ShortUrls,
|
||||||
|
AsideMenu,
|
||||||
|
CreateShortUrl,
|
||||||
|
ShortUrlVisits,
|
||||||
|
TagVisits,
|
||||||
|
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 addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' });
|
||||||
|
const burgerClasses = classNames('menu-layout__burger-icon', {
|
||||||
|
'menu-layout__burger-icon--active': sidebarVisible,
|
||||||
|
});
|
||||||
|
const swipeMenuIfNoModalExists = (callback) => (e) => {
|
||||||
|
const swippedOnVisitsTable = e.event.path.some(
|
||||||
|
({ classList }) => classList && classList.contains('visits-table')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (swippedOnVisitsTable || document.querySelector('.modal')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback();
|
||||||
};
|
};
|
||||||
|
|
||||||
state = { showSideBar: false };
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
|
||||||
|
|
||||||
componentDidMount() {
|
<Swipeable
|
||||||
const { match, selectServer } = this.props;
|
delta={40}
|
||||||
const { params: { serverId } } = match;
|
className="menu-layout__swipeable"
|
||||||
|
onSwipedLeft={swipeMenuIfNoModalExists(hideSidebar)}
|
||||||
selectServer(serverId);
|
onSwipedRight={swipeMenuIfNoModalExists(showSidebar)}
|
||||||
}
|
>
|
||||||
|
<div className="row menu-layout__swipeable-inner">
|
||||||
componentDidUpdate(prevProps) {
|
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={sidebarVisible} />
|
||||||
const { location } = this.props;
|
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
|
||||||
|
<div className="menu-layout__container">
|
||||||
// Hide sidebar when location changes
|
|
||||||
if (location !== prevProps.location) {
|
|
||||||
this.setState({ showSideBar: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { selectedServer, match } = this.props;
|
|
||||||
const { params: { serverId } } = match;
|
|
||||||
const burgerClasses = classnames('menu-layout__burger-icon', {
|
|
||||||
'menu-layout__burger-icon--active': this.state.showSideBar,
|
|
||||||
});
|
|
||||||
const swipeMenuIfNoModalExists = (showSideBar) => () => {
|
|
||||||
if (document.querySelector('.modal')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({ showSideBar });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={burgerIcon}
|
|
||||||
className={burgerClasses}
|
|
||||||
onClick={() => this.setState(({ showSideBar }) => ({ showSideBar: !showSideBar }))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Swipeable
|
|
||||||
delta={40}
|
|
||||||
className="menu-layout__swipeable"
|
|
||||||
onSwipedLeft={swipeMenuIfNoModalExists(false)}
|
|
||||||
onSwipedRight={swipeMenuIfNoModalExists(true)}
|
|
||||||
>
|
|
||||||
<div className="row menu-layout__swipeable-inner">
|
|
||||||
<AsideMenu
|
|
||||||
className="col-lg-2 col-md-3"
|
|
||||||
selectedServer={selectedServer}
|
|
||||||
showOnMobile={this.state.showSideBar}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="col-lg-10 offset-lg-2 col-md-9 offset-md-3"
|
|
||||||
onClick={() => this.setState({ showSideBar: false })}
|
|
||||||
>
|
|
||||||
<Switch>
|
<Switch>
|
||||||
|
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
|
||||||
|
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
||||||
|
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
||||||
|
{addTagsVisitsRoute && <Route exact path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
||||||
|
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
||||||
<Route
|
<Route
|
||||||
exact
|
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||||
path="/server/:serverId/list-short-urls/:page"
|
|
||||||
component={ShortUrls}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
exact
|
|
||||||
path="/server/:serverId/create-short-url"
|
|
||||||
component={CreateShortUrl}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
exact
|
|
||||||
path="/server/:serverId/short-code/:shortCode/visits"
|
|
||||||
component={ShortUrlVisits}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
exact
|
|
||||||
path="/server/:serverId/manage-tags"
|
|
||||||
component={TagsList}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`} btnText="List short URLs" />}
|
|
||||||
/>
|
/>
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="menu-layout__footer text-center text-md-right">
|
||||||
|
<ShlinkVersions />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Swipeable>
|
</div>
|
||||||
</React.Fragment>
|
</Swipeable>
|
||||||
);
|
</React.Fragment>
|
||||||
}
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
MenuLayoutComp.propTypes = propTypes;
|
||||||
|
|
||||||
|
return withSelectedServer(MenuLayoutComp, ServerError);
|
||||||
|
};
|
||||||
|
|
||||||
export default MenuLayout;
|
export default MenuLayout;
|
||||||
|
|||||||
@@ -32,3 +32,26 @@
|
|||||||
.menu-layout__burger-icon--active {
|
.menu-layout__burger-icon--active {
|
||||||
color: white;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
13
src/common/NoMenuLayout.js
Normal file
13
src/common/NoMenuLayout.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import './NoMenuLayout.scss';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
children: PropTypes.node,
|
||||||
|
};
|
||||||
|
|
||||||
|
const NoMenuLayout = ({ children }) => <div className="no-menu-wrapper">{children}</div>;
|
||||||
|
|
||||||
|
NoMenuLayout.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default NoMenuLayout;
|
||||||
3
src/common/NoMenuLayout.scss
Normal file
3
src/common/NoMenuLayout.scss
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.no-menu-wrapper {
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
@@ -4,17 +4,18 @@ import * as PropTypes from 'prop-types';
|
|||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
to: PropTypes.string,
|
to: PropTypes.string,
|
||||||
btnText: PropTypes.string,
|
children: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
const NotFound = ({ to = '/', btnText = 'Home' }) => (
|
const NotFound = ({ to = '/', children = 'Home' }) => (
|
||||||
<div className="home">
|
<div className="home">
|
||||||
<h2>Oops! We could not find requested route.</h2>
|
<h2>Oops! We could not find requested route.</h2>
|
||||||
<p>
|
<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>
|
</p>
|
||||||
<br />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import React from 'react';
|
import { useEffect } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
const ScrollToTop = ({ scrollTo }) => class ScrollToTop extends React.Component {
|
const propTypes = {
|
||||||
static propTypes = {
|
location: PropTypes.object,
|
||||||
location: PropTypes.object,
|
children: PropTypes.node,
|
||||||
children: PropTypes.node,
|
};
|
||||||
|
|
||||||
|
const ScrollToTop = () => {
|
||||||
|
const ScrollToTopComp = ({ location, children }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
scrollTo(0, 0);
|
||||||
|
}, [ location ]);
|
||||||
|
|
||||||
|
return children;
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidUpdate({ location: prevLocation }) {
|
ScrollToTopComp.propTypes = propTypes;
|
||||||
const { location } = this.props;
|
|
||||||
|
|
||||||
if (location !== prevLocation) {
|
return ScrollToTopComp;
|
||||||
scrollTo(0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ScrollToTop;
|
export default ScrollToTop;
|
||||||
|
|||||||
29
src/common/ShlinkVersions.js
Normal file
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;
|
||||||
@@ -1,38 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'classnames';
|
||||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||||
import { range, max, min } from 'ramda';
|
import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
|
||||||
import './SimplePaginator.scss';
|
import './SimplePaginator.scss';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
pagesCount: PropTypes.number.isRequired,
|
pagesCount: PropTypes.number.isRequired,
|
||||||
currentPage: PropTypes.number.isRequired,
|
currentPage: PropTypes.number.isRequired,
|
||||||
setCurrentPage: PropTypes.func.isRequired,
|
setCurrentPage: PropTypes.func.isRequired,
|
||||||
|
centered: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ellipsis = '...';
|
const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage, centered = true }) => {
|
||||||
|
|
||||||
const pagination = (currentPage, pageCount) => {
|
|
||||||
const delta = 2;
|
|
||||||
const pages = range(
|
|
||||||
max(delta, currentPage - delta),
|
|
||||||
min(pageCount - 1, currentPage + delta) + 1
|
|
||||||
);
|
|
||||||
|
|
||||||
if (currentPage - delta > delta) {
|
|
||||||
pages.unshift(ellipsis);
|
|
||||||
}
|
|
||||||
if (currentPage + delta < pageCount - 1) {
|
|
||||||
pages.push(ellipsis);
|
|
||||||
}
|
|
||||||
|
|
||||||
pages.unshift(1);
|
|
||||||
pages.push(pageCount);
|
|
||||||
|
|
||||||
return pages;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage }) => {
|
|
||||||
if (pagesCount < 2) {
|
if (pagesCount < 2) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -40,17 +20,17 @@ const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage }) => {
|
|||||||
const onClick = (page) => () => setCurrentPage(page);
|
const onClick = (page) => () => setCurrentPage(page);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pagination listClassName="flex-wrap justify-content-center mb-0 simple-paginator">
|
<Pagination listClassName={classNames('flex-wrap mb-0 simple-paginator', { 'justify-content-center': centered })}>
|
||||||
<PaginationItem disabled={currentPage <= 1}>
|
<PaginationItem disabled={currentPage <= 1}>
|
||||||
<PaginationLink previous tag="span" onClick={onClick(currentPage - 1)} />
|
<PaginationLink previous tag="span" onClick={onClick(currentPage - 1)} />
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
{pagination(currentPage, pagesCount).map((page, index) => (
|
{progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
|
||||||
<PaginationItem
|
<PaginationItem
|
||||||
key={page !== ellipsis ? page : `${page}_${index}`}
|
key={keyForPage(pageNumber, index)}
|
||||||
active={page === currentPage}
|
disabled={isPageDisabled(pageNumber)}
|
||||||
disabled={page === ellipsis}
|
active={currentPage === pageNumber}
|
||||||
>
|
>
|
||||||
<PaginationLink tag="span" onClick={onClick(page)}>{page}</PaginationLink>
|
<PaginationLink tag="span" onClick={onClick(pageNumber)}>{pageNumber}</PaginationLink>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
))}
|
))}
|
||||||
<PaginationItem disabled={currentPage >= pagesCount}>
|
<PaginationItem disabled={currentPage >= pagesCount}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Home from '../Home';
|
|||||||
import MenuLayout from '../MenuLayout';
|
import MenuLayout from '../MenuLayout';
|
||||||
import AsideMenu from '../AsideMenu';
|
import AsideMenu from '../AsideMenu';
|
||||||
import ErrorHandler from '../ErrorHandler';
|
import ErrorHandler from '../ErrorHandler';
|
||||||
|
import ShlinkVersions from '../ShlinkVersions';
|
||||||
|
|
||||||
const provideServices = (bottle, connect, withRouter) => {
|
const provideServices = (bottle, connect, withRouter) => {
|
||||||
bottle.constant('window', global.window);
|
bottle.constant('window', global.window);
|
||||||
@@ -25,13 +26,19 @@ const provideServices = (bottle, connect, withRouter) => {
|
|||||||
'ShortUrls',
|
'ShortUrls',
|
||||||
'AsideMenu',
|
'AsideMenu',
|
||||||
'CreateShortUrl',
|
'CreateShortUrl',
|
||||||
'ShortUrlVisits'
|
'ShortUrlVisits',
|
||||||
|
'TagVisits',
|
||||||
|
'ShlinkVersions',
|
||||||
|
'ServerError'
|
||||||
);
|
);
|
||||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
||||||
bottle.decorator('MenuLayout', withRouter);
|
bottle.decorator('MenuLayout', withRouter);
|
||||||
|
|
||||||
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
||||||
|
|
||||||
|
bottle.serviceFactory('ShlinkVersions', () => ShlinkVersions);
|
||||||
|
bottle.decorator('ShlinkVersions', connect([ 'selectedServer' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import provideServersServices from '../servers/services/provideServices';
|
|||||||
import provideVisitsServices from '../visits/services/provideServices';
|
import provideVisitsServices from '../visits/services/provideServices';
|
||||||
import provideTagsServices from '../tags/services/provideServices';
|
import provideTagsServices from '../tags/services/provideServices';
|
||||||
import provideUtilsServices from '../utils/services/provideServices';
|
import provideUtilsServices from '../utils/services/provideServices';
|
||||||
|
import provideMercureServices from '../mercure/services/provideServices';
|
||||||
|
import provideSettingsServices from '../settings/services/provideServices';
|
||||||
|
|
||||||
const bottle = new Bottle();
|
const bottle = new Bottle();
|
||||||
const { container } = bottle;
|
const { container } = bottle;
|
||||||
@@ -20,13 +22,14 @@ const mapActionService = (map, actionName) => ({
|
|||||||
// Wrap actual action service in a function so that it is lazily created the first time it is called
|
// Wrap actual action service in a function so that it is lazily created the first time it is called
|
||||||
[actionName]: lazyService(container, actionName),
|
[actionName]: lazyService(container, actionName),
|
||||||
});
|
});
|
||||||
const connect = (propsFromState, actionServiceNames) =>
|
const connect = (propsFromState, actionServiceNames = []) =>
|
||||||
reduxConnect(
|
reduxConnect(
|
||||||
propsFromState ? pick(propsFromState) : null,
|
propsFromState ? pick(propsFromState) : null,
|
||||||
actionServiceNames.reduce(mapActionService, {})
|
actionServiceNames.reduce(mapActionService, {})
|
||||||
);
|
);
|
||||||
|
|
||||||
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer');
|
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer', 'Settings');
|
||||||
|
bottle.decorator('App', connect([ 'servers' ], [ 'fetchServers' ]));
|
||||||
|
|
||||||
provideCommonServices(bottle, connect, withRouter);
|
provideCommonServices(bottle, connect, withRouter);
|
||||||
provideShortUrlsServices(bottle, connect);
|
provideShortUrlsServices(bottle, connect);
|
||||||
@@ -34,5 +37,7 @@ provideServersServices(bottle, connect, withRouter);
|
|||||||
provideTagsServices(bottle, connect);
|
provideTagsServices(bottle, connect);
|
||||||
provideVisitsServices(bottle, connect);
|
provideVisitsServices(bottle, connect);
|
||||||
provideUtilsServices(bottle);
|
provideUtilsServices(bottle);
|
||||||
|
provideMercureServices(bottle);
|
||||||
|
provideSettingsServices(bottle, connect);
|
||||||
|
|
||||||
export default container;
|
export default container;
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import ReduxThunk from 'redux-thunk';
|
import ReduxThunk from 'redux-thunk';
|
||||||
import { applyMiddleware, compose, createStore } from 'redux';
|
import { applyMiddleware, compose, createStore } from 'redux';
|
||||||
|
import { save, load } from 'redux-localstorage-simple';
|
||||||
import reducers from '../reducers';
|
import reducers from '../reducers';
|
||||||
|
|
||||||
const composeEnhancers = process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
const composeEnhancers = process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
||||||
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
||||||
: compose;
|
: compose;
|
||||||
|
|
||||||
const store = createStore(reducers, composeEnhancers(
|
const localStorageConfig = {
|
||||||
applyMiddleware(ReduxThunk)
|
states: [ 'settings', 'servers' ],
|
||||||
|
namespace: 'shlink',
|
||||||
|
namespaceSeparator: '.',
|
||||||
|
debounce: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = createStore(reducers, load(localStorageConfig), composeEnhancers(
|
||||||
|
applyMiddleware(save(localStorageConfig), ReduxThunk)
|
||||||
));
|
));
|
||||||
|
|
||||||
export default store;
|
export default store;
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ body,
|
|||||||
outline: none !important;
|
outline: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nowrap {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-main {
|
.bg-main {
|
||||||
background-color: $mainColor !important;
|
background-color: $mainColor !important;
|
||||||
}
|
}
|
||||||
@@ -28,14 +24,6 @@ body,
|
|||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shlink-container {
|
|
||||||
padding: 20px 0;
|
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
|
||||||
padding: 30px 15px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-main {
|
.badge-main {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background-color: $mainColor;
|
background-color: $mainColor;
|
||||||
@@ -56,10 +44,24 @@ body,
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paddingless {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.indivisible {
|
.indivisible {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-ellipsis {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-datepicker__day--keyboard-selected {
|
||||||
|
background-color: $mainColor;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: darken($mainColor, 12%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
background-color: $mainColor;
|
||||||
|
}
|
||||||
|
|||||||
24
src/mercure/helpers/index.js
Normal file
24
src/mercure/helpers/index.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
|
||||||
|
|
||||||
|
export const bindToMercureTopic = (mercureInfo, realTimeUpdates, topic, onMessage, onTokenExpired) => () => {
|
||||||
|
const { enabled } = realTimeUpdates;
|
||||||
|
const { mercureHubUrl, token, loading, error } = mercureInfo;
|
||||||
|
|
||||||
|
if (!enabled || loading || error) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hubUrl = new URL(mercureHubUrl);
|
||||||
|
|
||||||
|
hubUrl.searchParams.append('topic', topic);
|
||||||
|
const es = new EventSource(hubUrl, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
es.onmessage = ({ data }) => onMessage(JSON.parse(data));
|
||||||
|
es.onerror = ({ status }) => status === 401 && onTokenExpired();
|
||||||
|
|
||||||
|
return () => es.close();
|
||||||
|
};
|
||||||
41
src/mercure/reducers/mercureInfo.js
Normal file
41
src/mercure/reducers/mercureInfo.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { handleActions } from 'redux-actions';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
/* eslint-disable padding-line-between-statements */
|
||||||
|
export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START';
|
||||||
|
export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR';
|
||||||
|
export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO';
|
||||||
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
|
export const MercureInfoType = PropTypes.shape({
|
||||||
|
token: PropTypes.string,
|
||||||
|
mercureHubUrl: PropTypes.string,
|
||||||
|
loading: PropTypes.bool,
|
||||||
|
error: PropTypes.bool,
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
token: undefined,
|
||||||
|
mercureHubUrl: undefined,
|
||||||
|
loading: true,
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handleActions({
|
||||||
|
[GET_MERCURE_INFO_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||||
|
[GET_MERCURE_INFO_ERROR]: (state) => ({ ...state, loading: false, error: true }),
|
||||||
|
[GET_MERCURE_INFO]: (state, { token, mercureHubUrl }) => ({ token, mercureHubUrl, loading: false, error: false }),
|
||||||
|
}, initialState);
|
||||||
|
|
||||||
|
export const loadMercureInfo = (buildShlinkApiClient) => () => async (dispatch, getState) => {
|
||||||
|
dispatch({ type: GET_MERCURE_INFO_START });
|
||||||
|
const { mercureInfo } = buildShlinkApiClient(getState);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await mercureInfo();
|
||||||
|
|
||||||
|
dispatch({ type: GET_MERCURE_INFO, ...result });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: GET_MERCURE_INFO_ERROR });
|
||||||
|
}
|
||||||
|
};
|
||||||
8
src/mercure/services/provideServices.js
Normal file
8
src/mercure/services/provideServices.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { loadMercureInfo } from '../reducers/mercureInfo';
|
||||||
|
|
||||||
|
const provideServices = (bottle) => {
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('loadMercureInfo', loadMercureInfo, 'buildShlinkApiClient');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provideServices;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { combineReducers } from 'redux';
|
import { combineReducers } from 'redux';
|
||||||
import serversReducer from '../servers/reducers/server';
|
import serversReducer from '../servers/reducers/servers';
|
||||||
import selectedServerReducer from '../servers/reducers/selectedServer';
|
import selectedServerReducer from '../servers/reducers/selectedServer';
|
||||||
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
|
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
|
||||||
import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams';
|
import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams';
|
||||||
@@ -7,11 +7,15 @@ import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
|
|||||||
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
|
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
|
||||||
import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags';
|
import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags';
|
||||||
import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta';
|
import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta';
|
||||||
|
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
||||||
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
||||||
|
import tagVisitsReducer from '../visits/reducers/tagVisits';
|
||||||
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
|
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
|
||||||
import tagsListReducer from '../tags/reducers/tagsList';
|
import tagsListReducer from '../tags/reducers/tagsList';
|
||||||
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
||||||
import tagEditReducer from '../tags/reducers/tagEdit';
|
import tagEditReducer from '../tags/reducers/tagEdit';
|
||||||
|
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
||||||
|
import settingsReducer from '../settings/reducers/settings';
|
||||||
|
|
||||||
export default combineReducers({
|
export default combineReducers({
|
||||||
servers: serversReducer,
|
servers: serversReducer,
|
||||||
@@ -22,9 +26,13 @@ export default combineReducers({
|
|||||||
shortUrlDeletion: shortUrlDeletionReducer,
|
shortUrlDeletion: shortUrlDeletionReducer,
|
||||||
shortUrlTags: shortUrlTagsReducer,
|
shortUrlTags: shortUrlTagsReducer,
|
||||||
shortUrlMeta: shortUrlMetaReducer,
|
shortUrlMeta: shortUrlMetaReducer,
|
||||||
|
shortUrlEdition: shortUrlEditionReducer,
|
||||||
shortUrlVisits: shortUrlVisitsReducer,
|
shortUrlVisits: shortUrlVisitsReducer,
|
||||||
|
tagVisits: tagVisitsReducer,
|
||||||
shortUrlDetail: shortUrlDetailReducer,
|
shortUrlDetail: shortUrlDetailReducer,
|
||||||
tagsList: tagsListReducer,
|
tagsList: tagsListReducer,
|
||||||
tagDelete: tagDeleteReducer,
|
tagDelete: tagDeleteReducer,
|
||||||
tagEdit: tagEditReducer,
|
tagEdit: tagEditReducer,
|
||||||
|
mercureInfo: mercureInfoReducer,
|
||||||
|
settings: settingsReducer,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,91 +1,57 @@
|
|||||||
import { assoc, dissoc, pipe } from 'ramda';
|
import React, { useEffect } from 'react';
|
||||||
import React from 'react';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import NoMenuLayout from '../common/NoMenuLayout';
|
||||||
|
import { ServerForm } from './helpers/ServerForm';
|
||||||
import './CreateServer.scss';
|
import './CreateServer.scss';
|
||||||
|
|
||||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
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 {
|
const CreateServer = (ImportServersBtn, useStateFlagTimeout) => {
|
||||||
static propTypes = {
|
const CreateServerComp = ({ createServer, history: { push }, resetSelectedServer }) => {
|
||||||
createServer: PropTypes.func,
|
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||||
history: PropTypes.shape({
|
const handleSubmit = (serverData) => {
|
||||||
push: PropTypes.func,
|
const id = uuid();
|
||||||
}),
|
const server = { id, ...serverData };
|
||||||
resetSelectedServer: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
createServer(server);
|
||||||
name: '',
|
push(`/server/${id}/list-short-urls/1`);
|
||||||
url: '',
|
};
|
||||||
apiKey: '',
|
|
||||||
serversImported: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleSubmit = (e) => {
|
useEffect(() => {
|
||||||
e.preventDefault();
|
resetSelectedServer();
|
||||||
|
}, []);
|
||||||
const { createServer, history: { push } } = this.props;
|
|
||||||
const server = pipe(
|
|
||||||
assoc('id', uuid()),
|
|
||||||
dissoc('serversImported')
|
|
||||||
)(this.state);
|
|
||||||
|
|
||||||
createServer(server);
|
|
||||||
push(`/server/${server.id}/list-short-urls/1`);
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.props.resetSelectedServer();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const renderInputGroup = (id, placeholder, type = 'text') => (
|
|
||||||
<div className="form-group row">
|
|
||||||
<label htmlFor={id} className="col-lg-1 col-md-2 col-form-label create-server__label">
|
|
||||||
{placeholder}:
|
|
||||||
</label>
|
|
||||||
<div className="col-lg-11 col-md-10">
|
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
className="form-control"
|
|
||||||
id={id}
|
|
||||||
placeholder={placeholder}
|
|
||||||
value={this.state[id]}
|
|
||||||
required
|
|
||||||
onChange={(e) => this.setState({ [id]: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="create-server">
|
<NoMenuLayout>
|
||||||
<form onSubmit={this.handleSubmit}>
|
<ServerForm onSubmit={handleSubmit}>
|
||||||
{renderInputGroup('name', 'Name')}
|
<ImportServersBtn onImport={setServersImported} />
|
||||||
{renderInputGroup('url', 'URL', 'url')}
|
<button className="btn btn-outline-primary">Create server</button>
|
||||||
{renderInputGroup('apiKey', 'API key')}
|
</ServerForm>
|
||||||
|
|
||||||
<div className="text-right">
|
{serversImported && (
|
||||||
<ImportServersBtn
|
<div className="row create-server__import-success-msg">
|
||||||
onImport={() => stateFlagTimeout(this.setState.bind(this), 'serversImported', true, SHOW_IMPORT_MSG_TIME)}
|
<div className="col-md-10 offset-md-1">
|
||||||
/>
|
<div className="p-2 mt-3 bg-main text-white text-center">
|
||||||
<button className="btn btn-outline-primary">Create server</button>
|
Servers properly imported. You can now select one from the list :)
|
||||||
</div>
|
|
||||||
|
|
||||||
{this.state.serversImported && (
|
|
||||||
<div className="row create-server__import-success-msg">
|
|
||||||
<div className="col-md-10 offset-md-1">
|
|
||||||
<div className="p-2 mt-3 bg-main text-white text-center">
|
|
||||||
Servers properly imported. You can now select one from the list :)
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</form>
|
)}
|
||||||
</div>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
CreateServerComp.propTypes = propTypes;
|
||||||
|
|
||||||
|
return CreateServerComp;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CreateServer;
|
export default CreateServer;
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
@import '../utils/base';
|
@import '../utils/base';
|
||||||
|
|
||||||
.create-server {
|
|
||||||
padding: 40px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-server__label {
|
.create-server__label {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -1,40 +1,36 @@
|
|||||||
|
import React from 'react';
|
||||||
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
import { serverType } from './prop-types';
|
import { serverType } from './prop-types';
|
||||||
|
|
||||||
const DeleteServerButton = (DeleteServerModal) => class DeleteServerButton extends React.Component {
|
const propTypes = {
|
||||||
static propTypes = {
|
server: serverType,
|
||||||
server: serverType,
|
className: PropTypes.string,
|
||||||
className: PropTypes.string,
|
textClassName: PropTypes.string,
|
||||||
};
|
children: PropTypes.node,
|
||||||
|
};
|
||||||
|
|
||||||
state = { isModalOpen: false };
|
const DeleteServerButton = (DeleteServerModal) => {
|
||||||
|
const DeleteServerButtonComp = ({ server, className, children, textClassName }) => {
|
||||||
render() {
|
const [ isModalOpen, , showModal, hideModal ] = useToggle();
|
||||||
const { server, className } = this.props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<span
|
<span className={className} onClick={showModal}>
|
||||||
className={className}
|
{!children && <FontAwesomeIcon icon={deleteIcon} />}
|
||||||
key="deleteServerBtn"
|
<span className={textClassName}>{children || 'Remove this server'}</span>
|
||||||
onClick={() => this.setState({ isModalOpen: true })}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={deleteIcon} />
|
|
||||||
<span className="aside-menu__item-text">Delete this server</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<DeleteServerModal
|
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
|
||||||
isOpen={this.state.isModalOpen}
|
|
||||||
toggle={() => this.setState(({ isModalOpen }) => ({ isModalOpen: !isModalOpen }))}
|
|
||||||
server={server}
|
|
||||||
key="deleteServerModal"
|
|
||||||
/>
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
DeleteServerButtonComp.propTypes = propTypes;
|
||||||
|
|
||||||
|
return DeleteServerButtonComp;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DeleteServerButton;
|
export default DeleteServerButton;
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) =>
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
<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>
|
<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>
|
<p>
|
||||||
No data will be deleted, only the access to that server will be removed from this host.
|
<i>
|
||||||
You can create it again at any moment.
|
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>
|
</p>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
38
src/servers/EditServer.js
Normal file
38
src/servers/EditServer.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Button } from 'reactstrap';
|
||||||
|
import NoMenuLayout from '../common/NoMenuLayout';
|
||||||
|
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,
|
||||||
|
goBack: PropTypes.func,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditServer = (ServerError) => {
|
||||||
|
const EditServerComp = ({ editServer, selectedServer, history: { push, goBack } }) => {
|
||||||
|
const handleSubmit = (serverData) => {
|
||||||
|
editServer(selectedServer.id, serverData);
|
||||||
|
push(`/server/${selectedServer.id}/list-short-urls/1`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NoMenuLayout>
|
||||||
|
<ServerForm initialValues={selectedServer} onSubmit={handleSubmit}>
|
||||||
|
<Button outline className="mr-2" onClick={goBack}>Cancel</Button>
|
||||||
|
<Button outline color="primary">Save</Button>
|
||||||
|
</ServerForm>
|
||||||
|
</NoMenuLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
EditServerComp.propTypes = propTypes;
|
||||||
|
|
||||||
|
return withSelectedServer(EditServerComp, ServerError);
|
||||||
|
};
|
||||||
@@ -2,63 +2,54 @@ import { isEmpty, values } from 'ramda';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { serverType } from './prop-types';
|
import { serverType } from './prop-types';
|
||||||
|
|
||||||
const ServersDropdown = (serversExporter) => class ServersDropdown extends React.Component {
|
const propTypes = {
|
||||||
static propTypes = {
|
servers: PropTypes.object,
|
||||||
servers: PropTypes.object,
|
selectedServer: serverType,
|
||||||
selectedServer: serverType,
|
};
|
||||||
selectServer: PropTypes.func,
|
|
||||||
listServers: PropTypes.func,
|
|
||||||
history: PropTypes.shape({
|
|
||||||
push: PropTypes.func,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
renderServers = () => {
|
const ServersDropdown = (serversExporter) => {
|
||||||
const { servers: { list, loading }, selectedServer, selectServer } = this.props;
|
const ServersDropdownComp = ({ servers, selectedServer }) => {
|
||||||
const servers = values(list);
|
const serversList = values(servers);
|
||||||
const { push } = this.props.history;
|
|
||||||
const loadServer = (id) => {
|
const renderServers = () => {
|
||||||
selectServer(id)
|
if (isEmpty(serversList)) {
|
||||||
.then(() => push(`/server/${id}/list-short-urls/1`))
|
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
|
||||||
.catch(() => {});
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
{serversList.map(({ name, id }) => (
|
||||||
|
<DropdownItem
|
||||||
|
key={id}
|
||||||
|
tag={Link}
|
||||||
|
to={`/server/${id}/list-short-urls/1`}
|
||||||
|
active={selectedServer && selectedServer.id === id}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</DropdownItem>
|
||||||
|
))}
|
||||||
|
<DropdownItem divider />
|
||||||
|
<DropdownItem className="servers-dropdown__export-item" onClick={() => serversExporter.exportServers()}>
|
||||||
|
Export servers
|
||||||
|
</DropdownItem>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <DropdownItem disabled><i>Trying to load servers...</i></DropdownItem>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEmpty(servers)) {
|
|
||||||
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<UncontrolledDropdown nav inNavbar>
|
||||||
{servers.map(({ name, id }) => (
|
<DropdownToggle nav caret>Servers</DropdownToggle>
|
||||||
<DropdownItem key={id} active={selectedServer && selectedServer.id === id} onClick={() => loadServer(id)}>
|
<DropdownMenu right>{renderServers()}</DropdownMenu>
|
||||||
{name}
|
</UncontrolledDropdown>
|
||||||
</DropdownItem>
|
|
||||||
))}
|
|
||||||
<DropdownItem divider />
|
|
||||||
<DropdownItem
|
|
||||||
className="servers-dropdown__export-item"
|
|
||||||
onClick={() => serversExporter.exportServers()}
|
|
||||||
>
|
|
||||||
Export servers
|
|
||||||
</DropdownItem>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount = this.props.listServers;
|
ServersDropdownComp.propTypes = propTypes;
|
||||||
|
|
||||||
render = () => (
|
return ServersDropdownComp;
|
||||||
<UncontrolledDropdown nav inNavbar>
|
|
||||||
<DropdownToggle nav caret>Servers</DropdownToggle>
|
|
||||||
<DropdownMenu right>{this.renderServers()}</DropdownMenu>
|
|
||||||
</UncontrolledDropdown>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ServersDropdown;
|
export default ServersDropdown;
|
||||||
|
|||||||
42
src/servers/ServersListGroup.js
Normal file
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
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
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;
|
||||||
@@ -1,25 +1,17 @@
|
|||||||
import React from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
const ImportServersBtn = (serversImporter) => class ImportServersBtn extends React.Component {
|
const propTypes = {
|
||||||
static defaultProps = {
|
onImport: PropTypes.func,
|
||||||
onImport: () => ({}),
|
createServers: PropTypes.func,
|
||||||
};
|
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
|
||||||
static propTypes = {
|
};
|
||||||
onImport: PropTypes.func,
|
|
||||||
createServers: PropTypes.func,
|
|
||||||
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
// FIXME Replace with typescript: (ServersImporter)
|
||||||
super(props);
|
const ImportServersBtn = ({ importServersFromFile }) => {
|
||||||
this.fileRef = props.fileRef || React.createRef();
|
const ImportServersBtnComp = ({ createServers, fileRef, onImport = () => {} }) => {
|
||||||
}
|
const ref = fileRef || useRef();
|
||||||
|
|
||||||
render() {
|
|
||||||
const { importServersFromFile } = serversImporter;
|
|
||||||
const { onImport, createServers } = this.props;
|
|
||||||
const onChange = ({ target }) =>
|
const onChange = ({ target }) =>
|
||||||
importServersFromFile(target.files[0])
|
importServersFromFile(target.files[0])
|
||||||
.then(createServers)
|
.then(createServers)
|
||||||
@@ -35,24 +27,22 @@ const ImportServersBtn = (serversImporter) => class ImportServersBtn extends Rea
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-outline-secondary mr-2"
|
className="btn btn-outline-secondary mr-2"
|
||||||
id="importBtn"
|
id="importBtn"
|
||||||
onClick={() => this.fileRef.current.click()}
|
onClick={() => ref.current.click()}
|
||||||
>
|
>
|
||||||
Import from file
|
Import from file
|
||||||
</button>
|
</button>
|
||||||
<UncontrolledTooltip placement="top" target="importBtn">
|
<UncontrolledTooltip placement="top" target="importBtn">
|
||||||
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>
|
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
|
||||||
</UncontrolledTooltip>
|
</UncontrolledTooltip>
|
||||||
|
|
||||||
<input
|
<input type="file" accept="text/csv" className="create-server__csv-select" ref={ref} onChange={onChange} />
|
||||||
type="file"
|
|
||||||
accept="text/csv"
|
|
||||||
className="create-server__csv-select"
|
|
||||||
ref={this.fileRef}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
ImportServersBtnComp.propTypes = propTypes;
|
||||||
|
|
||||||
|
return ImportServersBtnComp;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ImportServersBtn;
|
export default ImportServersBtn;
|
||||||
|
|||||||
50
src/servers/helpers/ServerError.js
Normal file
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, 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(servers)}>
|
||||||
|
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
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
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
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';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
export const serverType = PropTypes.shape({
|
const regularServerType = PropTypes.shape({
|
||||||
id: PropTypes.string,
|
id: PropTypes.string,
|
||||||
name: PropTypes.string,
|
name: PropTypes.string,
|
||||||
url: PropTypes.string,
|
url: PropTypes.string,
|
||||||
apiKey: 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,
|
||||||
|
]);
|
||||||
|
|||||||
22
src/servers/reducers/remoteServers.js
Normal file
22
src/servers/reducers/remoteServers.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { pipe, prop } from 'ramda';
|
||||||
|
import { homepage } from '../../../package.json';
|
||||||
|
import { createServers } from './servers';
|
||||||
|
|
||||||
|
const responseToServersList = pipe(
|
||||||
|
prop('data'),
|
||||||
|
(value) => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new Error('Value is not an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchServers = ({ get }) => () => async (dispatch) => {
|
||||||
|
const remoteList = await get(`${homepage}/servers.json`)
|
||||||
|
.then(responseToServersList)
|
||||||
|
.catch(() => []);
|
||||||
|
|
||||||
|
dispatch(createServers(remoteList));
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createAction, handleActions } from 'redux-actions';
|
import { createAction, handleActions } from 'redux-actions';
|
||||||
|
import { identity, memoizeWith, pipe } from 'ramda';
|
||||||
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
|
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
|
||||||
import { versionIsValidSemVer } from '../../utils/utils';
|
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
|
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
|
||||||
@@ -12,26 +13,56 @@ export const LATEST_VERSION_CONSTRAINT = 'latest';
|
|||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
const initialState = null;
|
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 resetSelectedServer = createAction(RESET_SELECTED_SERVER);
|
||||||
|
|
||||||
export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serverId) => async (dispatch) => {
|
export const selectServer = (buildShlinkApiClient, loadMercureInfo) => (serverId) => async (
|
||||||
|
dispatch,
|
||||||
|
getState
|
||||||
|
) => {
|
||||||
|
dispatch(resetSelectedServer());
|
||||||
dispatch(resetShortUrlParams());
|
dispatch(resetShortUrlParams());
|
||||||
|
|
||||||
const selectedServer = findServerById(serverId);
|
const { servers } = getState();
|
||||||
const { health } = await buildShlinkApiClient(selectedServer);
|
const selectedServer = servers[serverId];
|
||||||
const version = await health()
|
|
||||||
.then(({ version }) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version)
|
|
||||||
.then((version) => !versionIsValidSemVer(version) ? MIN_FALLBACK_VERSION : version)
|
|
||||||
.catch(() => MIN_FALLBACK_VERSION);
|
|
||||||
|
|
||||||
dispatch({
|
if (!selectedServer) {
|
||||||
type: SELECT_SERVER,
|
dispatch({
|
||||||
selectedServer: {
|
type: SELECT_SERVER,
|
||||||
...selectedServer,
|
selectedServer: { serverNotFound: true },
|
||||||
version,
|
});
|
||||||
},
|
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { health } = buildShlinkApiClient(selectedServer);
|
||||||
|
const { version, printableVersion } = await getServerVersion(serverId, health);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: SELECT_SERVER,
|
||||||
|
selectedServer: {
|
||||||
|
...selectedServer,
|
||||||
|
version,
|
||||||
|
printableVersion,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatch(loadMercureInfo());
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({
|
||||||
|
type: SELECT_SERVER,
|
||||||
|
selectedServer: { ...selectedServer, serverNotReachable: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default handleActions({
|
export default handleActions({
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { handleActions } from 'redux-actions';
|
|
||||||
import { pipe, isEmpty, assoc, map, prop } from 'ramda';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
import { homepage } from '../../../package.json';
|
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
|
||||||
export const FETCH_SERVERS_START = 'shlink/servers/FETCH_SERVERS_START';
|
|
||||||
export const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS';
|
|
||||||
/* eslint-enable padding-line-between-statements */
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
list: {},
|
|
||||||
loading: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const assocId = (server) => assoc('id', server.id || uuid(), server);
|
|
||||||
|
|
||||||
export default handleActions({
|
|
||||||
[FETCH_SERVERS_START]: (state) => ({ ...state, loading: true }),
|
|
||||||
[FETCH_SERVERS]: (state, { list }) => ({ list, loading: false }),
|
|
||||||
}, initialState);
|
|
||||||
|
|
||||||
export const listServers = ({ listServers, createServers }, { get }) => () => async (dispatch) => {
|
|
||||||
dispatch({ type: FETCH_SERVERS_START });
|
|
||||||
const localList = listServers();
|
|
||||||
|
|
||||||
if (!isEmpty(localList)) {
|
|
||||||
dispatch({ type: FETCH_SERVERS, list: localList });
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If local list is empty, try to fetch it remotely (making sure it's an array) and calculate IDs for every server
|
|
||||||
const getDataAsArrayWithIds = pipe(
|
|
||||||
prop('data'),
|
|
||||||
(value) => {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
throw new Error('Value is not an array');
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
map(assocId),
|
|
||||||
);
|
|
||||||
const remoteList = await get(`${homepage}/servers.json`)
|
|
||||||
.then(getDataAsArrayWithIds)
|
|
||||||
.catch(() => []);
|
|
||||||
|
|
||||||
createServers(remoteList);
|
|
||||||
dispatch({ type: FETCH_SERVERS, list: remoteList.reduce((map, server) => ({ ...map, [server.id]: server }), {}) });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createServer = ({ createServer }, listServersAction) => pipe(createServer, listServersAction);
|
|
||||||
|
|
||||||
export const deleteServer = ({ deleteServer }, listServersAction) => pipe(deleteServer, listServersAction);
|
|
||||||
|
|
||||||
export const createServers = ({ createServers }, listServersAction) => pipe(
|
|
||||||
map(assocId),
|
|
||||||
createServers,
|
|
||||||
listServersAction
|
|
||||||
);
|
|
||||||
35
src/servers/reducers/servers.js
Normal file
35
src/servers/reducers/servers.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { handleActions } from 'redux-actions';
|
||||||
|
import { pipe, assoc, map, reduce, dissoc } from 'ramda';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
/* eslint-disable padding-line-between-statements */
|
||||||
|
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
|
||||||
|
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
|
||||||
|
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
|
||||||
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
|
const initialState = {};
|
||||||
|
|
||||||
|
const assocId = (server) => assoc('id', server.id || uuid(), server);
|
||||||
|
|
||||||
|
export default handleActions({
|
||||||
|
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
|
||||||
|
[DELETE_SERVER]: (state, { serverId }) => dissoc(serverId, state),
|
||||||
|
[EDIT_SERVER]: (state, { serverId, serverData }) => !state[serverId]
|
||||||
|
? state
|
||||||
|
: assoc(serverId, { ...state[serverId], ...serverData }, state),
|
||||||
|
}, initialState);
|
||||||
|
|
||||||
|
export const createServer = (server) => createServers([ server ]);
|
||||||
|
|
||||||
|
const serversListToMap = reduce((acc, server) => assoc(server.id, server, acc), {});
|
||||||
|
|
||||||
|
export const createServers = pipe(
|
||||||
|
map(assocId),
|
||||||
|
serversListToMap,
|
||||||
|
(newServers) => ({ type: CREATE_SERVERS, newServers })
|
||||||
|
);
|
||||||
|
|
||||||
|
export const editServer = (serverId, serverData) => ({ type: EDIT_SERVER, serverId, serverData });
|
||||||
|
|
||||||
|
export const deleteServer = ({ id }) => ({ type: DELETE_SERVER, serverId: id });
|
||||||
@@ -25,14 +25,14 @@ const saveCsv = (window, csv) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default class ServersExporter {
|
export default class ServersExporter {
|
||||||
constructor(serversService, window, csvjson) {
|
constructor(storage, window, csvjson) {
|
||||||
this.serversService = serversService;
|
this.storage = storage;
|
||||||
this.window = window;
|
this.window = window;
|
||||||
this.csvjson = csvjson;
|
this.csvjson = csvjson;
|
||||||
}
|
}
|
||||||
|
|
||||||
exportServers = async () => {
|
exportServers = async () => {
|
||||||
const servers = values(this.serversService.listServers()).map(dissoc('id'));
|
const servers = values(this.storage.get('servers') || {}).map(dissoc('id'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const csv = this.csvjson.toCSV(servers, {
|
const csv = this.csvjson.toCSV(servers, {
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { assoc, dissoc, reduce } from 'ramda';
|
|
||||||
|
|
||||||
const SERVERS_STORAGE_KEY = 'servers';
|
|
||||||
|
|
||||||
export default class ServersService {
|
|
||||||
constructor(storage) {
|
|
||||||
this.storage = storage;
|
|
||||||
}
|
|
||||||
|
|
||||||
listServers = () => this.storage.get(SERVERS_STORAGE_KEY) || {};
|
|
||||||
|
|
||||||
findServerById = (serverId) => this.listServers()[serverId];
|
|
||||||
|
|
||||||
createServer = (server) => this.createServers([ server ]);
|
|
||||||
|
|
||||||
createServers = (servers) => {
|
|
||||||
const allServers = reduce(
|
|
||||||
(serversObj, server) => assoc(server.id, server, serversObj),
|
|
||||||
this.listServers(),
|
|
||||||
servers
|
|
||||||
);
|
|
||||||
|
|
||||||
this.storage.set(SERVERS_STORAGE_KEY, allServers);
|
|
||||||
};
|
|
||||||
|
|
||||||
deleteServer = ({ id }) =>
|
|
||||||
this.storage.set(SERVERS_STORAGE_KEY, dissoc(id, this.listServers()));
|
|
||||||
}
|
|
||||||
@@ -3,21 +3,26 @@ import CreateServer from '../CreateServer';
|
|||||||
import ServersDropdown from '../ServersDropdown';
|
import ServersDropdown from '../ServersDropdown';
|
||||||
import DeleteServerModal from '../DeleteServerModal';
|
import DeleteServerModal from '../DeleteServerModal';
|
||||||
import DeleteServerButton from '../DeleteServerButton';
|
import DeleteServerButton from '../DeleteServerButton';
|
||||||
|
import { EditServer } from '../EditServer';
|
||||||
import ImportServersBtn from '../helpers/ImportServersBtn';
|
import ImportServersBtn from '../helpers/ImportServersBtn';
|
||||||
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
|
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
|
||||||
import { createServer, createServers, deleteServer, listServers } from '../reducers/server';
|
import { createServer, createServers, deleteServer, editServer } from '../reducers/servers';
|
||||||
|
import { fetchServers } from '../reducers/remoteServers';
|
||||||
|
import ForServerVersion from '../helpers/ForServerVersion';
|
||||||
|
import { ServerError } from '../helpers/ServerError';
|
||||||
import ServersImporter from './ServersImporter';
|
import ServersImporter from './ServersImporter';
|
||||||
import ServersService from './ServersService';
|
|
||||||
import ServersExporter from './ServersExporter';
|
import ServersExporter from './ServersExporter';
|
||||||
|
|
||||||
const provideServices = (bottle, connect, withRouter) => {
|
const provideServices = (bottle, connect, withRouter) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'stateFlagTimeout');
|
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
|
||||||
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
|
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.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
|
||||||
bottle.decorator('ServersDropdown', withRouter);
|
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ]));
|
||||||
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ], [ 'listServers', 'selectServer' ]));
|
|
||||||
|
|
||||||
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
|
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
|
||||||
bottle.decorator('DeleteServerModal', withRouter);
|
bottle.decorator('DeleteServerModal', withRouter);
|
||||||
@@ -28,18 +33,24 @@ const provideServices = (bottle, connect, withRouter) => {
|
|||||||
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
|
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
|
||||||
bottle.decorator('ImportServersBtn', connect(null, [ 'createServers' ]));
|
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
|
// Services
|
||||||
bottle.constant('csvjson', csvjson);
|
bottle.constant('csvjson', csvjson);
|
||||||
bottle.service('ServersImporter', ServersImporter, 'csvjson');
|
bottle.service('ServersImporter', ServersImporter, 'csvjson');
|
||||||
bottle.service('ServersService', ServersService, 'Storage');
|
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'csvjson');
|
||||||
bottle.service('ServersExporter', ServersExporter, 'ServersService', 'window', 'csvjson');
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('selectServer', selectServer, 'ServersService', 'buildShlinkApiClient');
|
bottle.serviceFactory('selectServer', selectServer, 'buildShlinkApiClient', 'loadMercureInfo');
|
||||||
bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers');
|
bottle.serviceFactory('createServer', () => createServer);
|
||||||
bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers');
|
bottle.serviceFactory('createServers', () => createServers);
|
||||||
bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers');
|
bottle.serviceFactory('deleteServer', () => deleteServer);
|
||||||
bottle.serviceFactory('listServers', listServers, 'ServersService', 'axios');
|
bottle.serviceFactory('editServer', () => editServer);
|
||||||
|
bottle.serviceFactory('fetchServers', fetchServers, 'axios');
|
||||||
|
|
||||||
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
||||||
};
|
};
|
||||||
|
|||||||
25
src/settings/RealTimeUpdates.js
Normal file
25
src/settings/RealTimeUpdates.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, CardBody, CardHeader } from 'reactstrap';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import Checkbox from '../utils/Checkbox';
|
||||||
|
import { SettingsType } from './reducers/settings';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
settings: SettingsType,
|
||||||
|
setRealTimeUpdates: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
const RealTimeUpdates = ({ settings: { realTimeUpdates }, setRealTimeUpdates }) => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>Real-time updates</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<Checkbox checked={realTimeUpdates.enabled} onChange={setRealTimeUpdates}>
|
||||||
|
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
|
||||||
|
</Checkbox>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
RealTimeUpdates.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default RealTimeUpdates;
|
||||||
10
src/settings/Settings.js
Normal file
10
src/settings/Settings.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import NoMenuLayout from '../common/NoMenuLayout';
|
||||||
|
|
||||||
|
const Settings = (RealTimeUpdates) => () => (
|
||||||
|
<NoMenuLayout>
|
||||||
|
<RealTimeUpdates />
|
||||||
|
</NoMenuLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
25
src/settings/reducers/settings.js
Normal file
25
src/settings/reducers/settings.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { handleActions } from 'redux-actions';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
export const SET_REAL_TIME_UPDATES = 'shlink/realTimeUpdates/SET_REAL_TIME_UPDATES';
|
||||||
|
|
||||||
|
export const SettingsType = PropTypes.shape({
|
||||||
|
realTimeUpdates: PropTypes.shape({
|
||||||
|
enabled: PropTypes.bool.isRequired,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
realTimeUpdates: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handleActions({
|
||||||
|
[SET_REAL_TIME_UPDATES]: (state, { realTimeUpdates }) => ({ ...state, realTimeUpdates }),
|
||||||
|
}, initialState);
|
||||||
|
|
||||||
|
export const setRealTimeUpdates = (enabled) => ({
|
||||||
|
type: SET_REAL_TIME_UPDATES,
|
||||||
|
realTimeUpdates: { enabled },
|
||||||
|
});
|
||||||
16
src/settings/services/provideServices.js
Normal file
16
src/settings/services/provideServices.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import RealTimeUpdates from '../RealTimeUpdates';
|
||||||
|
import Settings from '../Settings';
|
||||||
|
import { setRealTimeUpdates } from '../reducers/settings';
|
||||||
|
|
||||||
|
const provideServices = (bottle, connect) => {
|
||||||
|
// Components
|
||||||
|
bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates');
|
||||||
|
|
||||||
|
bottle.serviceFactory('RealTimeUpdates', () => RealTimeUpdates);
|
||||||
|
bottle.decorator('RealTimeUpdates', connect([ 'settings' ], [ 'setRealTimeUpdates' ]));
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('setRealTimeUpdates', () => setRealTimeUpdates);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provideServices;
|
||||||
@@ -1,52 +1,66 @@
|
|||||||
import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { assoc, dissoc, isEmpty, isNil, pipe, replace, trim } from 'ramda';
|
import { isEmpty, isNil, pipe, replace, trim } from 'ramda';
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Collapse, FormGroup, Input } from 'reactstrap';
|
import { Collapse, FormGroup, Input } from 'reactstrap';
|
||||||
import * as PropTypes from 'prop-types';
|
import * as PropTypes from 'prop-types';
|
||||||
import DateInput from '../utils/DateInput';
|
import DateInput from '../utils/DateInput';
|
||||||
import Checkbox from '../utils/Checkbox';
|
import Checkbox from '../utils/Checkbox';
|
||||||
import ForVersion from '../utils/ForVersion';
|
|
||||||
import { serverType } from '../servers/prop-types';
|
import { serverType } from '../servers/prop-types';
|
||||||
import { compareVersions } from '../utils/utils';
|
import { versionMatch } from '../utils/helpers/version';
|
||||||
|
import { hasValue } from '../utils/utils';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
import { createShortUrlResultType } from './reducers/shortUrlCreation';
|
import { createShortUrlResultType } from './reducers/shortUrlCreation';
|
||||||
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
|
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
|
||||||
|
|
||||||
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||||
const formatDate = (date) => isNil(date) ? date : date.format();
|
const formatDate = (date) => isNil(date) ? date : date.format();
|
||||||
|
|
||||||
const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShortUrl extends React.Component {
|
const propTypes = {
|
||||||
static propTypes = {
|
createShortUrl: PropTypes.func,
|
||||||
createShortUrl: PropTypes.func,
|
shortUrlCreationResult: createShortUrlResultType,
|
||||||
shortUrlCreationResult: createShortUrlResultType,
|
resetCreateShortUrl: PropTypes.func,
|
||||||
resetCreateShortUrl: PropTypes.func,
|
selectedServer: serverType,
|
||||||
selectedServer: serverType,
|
};
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
const initialState = {
|
||||||
longUrl: '',
|
longUrl: '',
|
||||||
tags: [],
|
tags: [],
|
||||||
customSlug: undefined,
|
customSlug: '',
|
||||||
domain: undefined,
|
shortCodeLength: '',
|
||||||
validSince: undefined,
|
domain: '',
|
||||||
validUntil: undefined,
|
validSince: undefined,
|
||||||
maxVisits: undefined,
|
validUntil: undefined,
|
||||||
findIfExists: false,
|
maxVisits: '',
|
||||||
moreOptionsVisible: false,
|
findIfExists: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
const CreateShortUrl = (TagsSelector, CreateShortUrlResult, ForServerVersion) => {
|
||||||
const { createShortUrl, shortUrlCreationResult, resetCreateShortUrl } = this.props;
|
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 = {}) => (
|
const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => (
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
type={type}
|
type={type}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={this.state[id]}
|
value={shortUrlCreation[id]}
|
||||||
onChange={(e) => this.setState({ [id]: e.target.value })}
|
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, [id]: e.target.value })}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
@@ -54,105 +68,106 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort
|
|||||||
const renderDateInput = (id, placeholder, props = {}) => (
|
const renderDateInput = (id, placeholder, props = {}) => (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<DateInput
|
<DateInput
|
||||||
selected={this.state[id]}
|
selected={shortUrlCreation[id]}
|
||||||
placeholderText={placeholder}
|
placeholderText={placeholder}
|
||||||
isClearable
|
isClearable
|
||||||
onChange={(date) => this.setState({ [id]: date })}
|
onChange={(date) => setShortUrlCreation({ ...shortUrlCreation, [id]: date })}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const save = (e) => {
|
|
||||||
e.preventDefault();
|
const currentServerVersion = selectedServer && selectedServer.version;
|
||||||
createShortUrl(pipe(
|
const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' });
|
||||||
dissoc('moreOptionsVisible'),
|
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
|
||||||
assoc('validSince', formatDate(this.state.validSince)),
|
|
||||||
assoc('validUntil', formatDate(this.state.validUntil))
|
|
||||||
)(this.state));
|
|
||||||
};
|
|
||||||
const currentServerVersion = this.props.selectedServer ? this.props.selectedServer.version : '';
|
|
||||||
const disableDomain = isEmpty(currentServerVersion) || compareVersions(currentServerVersion, '<', '1.19.0-beta.1');
|
|
||||||
|
|
||||||
return (
|
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">
|
<div className="form-group">
|
||||||
<input
|
<TagsSelector tags={shortUrlCreation.tags} onChange={changeTags} />
|
||||||
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 })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Collapse isOpen={this.state.moreOptionsVisible}>
|
<div className="row">
|
||||||
<div className="form-group">
|
<div className="col-sm-4">
|
||||||
<TagsSelector tags={this.state.tags} onChange={changeTags} />
|
{renderOptionalInput('customSlug', 'Custom slug')}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-sm-4">
|
||||||
<div className="row">
|
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
|
||||||
<div className="col-sm-6">
|
min: 4,
|
||||||
{renderOptionalInput('customSlug', 'Custom slug')}
|
disabled: disableShortCodeLength || hasValue(shortUrlCreation.customSlug),
|
||||||
</div>
|
...disableShortCodeLength && {
|
||||||
<div className="col-sm-6">
|
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
|
||||||
{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>
|
||||||
|
<div className="col-sm-4">
|
||||||
<div className="row">
|
{renderOptionalInput('domain', 'Domain', 'text', {
|
||||||
<div className="col-sm-6">
|
disabled: disableDomain,
|
||||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
|
||||||
</div>
|
})}
|
||||||
<div className="col-sm-3">
|
|
||||||
{renderDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })}
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-3">
|
|
||||||
{renderDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ForVersion minVersion="1.16.0" currentServerVersion={currentServerVersion}>
|
|
||||||
<div className="mb-4 text-right">
|
|
||||||
<Checkbox
|
|
||||||
className="mr-2"
|
|
||||||
checked={this.state.findIfExists}
|
|
||||||
onChange={(findIfExists) => this.setState({ findIfExists })}
|
|
||||||
>
|
|
||||||
Use existing URL if found
|
|
||||||
</Checkbox>
|
|
||||||
<UseExistingIfFoundInfoIcon />
|
|
||||||
</div>
|
|
||||||
</ForVersion>
|
|
||||||
</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 || isEmpty(this.state.longUrl)}
|
|
||||||
>
|
|
||||||
{shortUrlCreationResult.loading ? 'Creating...' : 'Create'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />
|
<div className="row">
|
||||||
</form>
|
<div className="col-sm-4">
|
||||||
</div>
|
{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={shortUrlCreation.findIfExists}
|
||||||
|
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
|
||||||
|
>
|
||||||
|
Use existing URL if found
|
||||||
|
</Checkbox>
|
||||||
|
<UseExistingIfFoundInfoIcon />
|
||||||
|
</div>
|
||||||
|
</ForServerVersion>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
CreateShortUrlComp.propTypes = propTypes;
|
||||||
|
|
||||||
|
return CreateShortUrlComp;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CreateShortUrl;
|
export default CreateShortUrl;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import React from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { rangeOf } from '../utils/utils';
|
import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
|
||||||
|
import './Paginator.scss';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
serverId: PropTypes.string.isRequired,
|
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;
|
const { currentPage, pagesCount = 0 } = paginator;
|
||||||
|
|
||||||
if (pagesCount <= 1) {
|
if (pagesCount <= 1) {
|
||||||
@@ -20,8 +21,12 @@ export default function Paginator({ paginator = {}, serverId }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderPages = () =>
|
const renderPages = () =>
|
||||||
rangeOf(pagesCount, (pageNumber) => (
|
progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
|
||||||
<PaginationItem key={pageNumber} active={currentPage === pageNumber}>
|
<PaginationItem
|
||||||
|
key={keyForPage(pageNumber, index)}
|
||||||
|
disabled={isPageDisabled(pageNumber)}
|
||||||
|
active={currentPage === pageNumber}
|
||||||
|
>
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
tag={Link}
|
tag={Link}
|
||||||
to={`/server/${serverId}/list-short-urls/${pageNumber}`}
|
to={`/server/${serverId}/list-short-urls/${pageNumber}`}
|
||||||
@@ -32,7 +37,7 @@ export default function Paginator({ paginator = {}, serverId }) {
|
|||||||
));
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pagination listClassName="flex-wrap">
|
<Pagination className="short-urls-paginator" listClassName="flex-wrap justify-content-center mb-0">
|
||||||
<PaginationItem disabled={currentPage === 1}>
|
<PaginationItem disabled={currentPage === 1}>
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
previous
|
previous
|
||||||
@@ -50,6 +55,8 @@ export default function Paginator({ paginator = {}, serverId }) {
|
|||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
</Pagination>
|
</Pagination>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
Paginator.propTypes = propTypes;
|
Paginator.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default Paginator;
|
||||||
|
|||||||
7
src/short-urls/Paginator.scss
Normal file
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);
|
||||||
|
}
|
||||||
@@ -7,23 +7,19 @@ import moment from 'moment';
|
|||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
import Tag from '../tags/helpers/Tag';
|
import Tag from '../tags/helpers/Tag';
|
||||||
import DateRangeRow from '../utils/DateRangeRow';
|
import DateRangeRow from '../utils/DateRangeRow';
|
||||||
import { compareVersions, formatDate } from '../utils/utils';
|
import { formatDate } from '../utils/helpers/date';
|
||||||
import { serverType } from '../servers/prop-types';
|
|
||||||
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
||||||
import './SearchBar.scss';
|
import './SearchBar.scss';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
listShortUrls: PropTypes.func,
|
listShortUrls: PropTypes.func,
|
||||||
shortUrlsListParams: shortUrlsListParamsType,
|
shortUrlsListParams: shortUrlsListParamsType,
|
||||||
selectedServer: serverType,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const dateOrUndefined = (date) => date ? moment(date) : undefined;
|
const dateOrUndefined = (date) => date ? moment(date) : undefined;
|
||||||
|
|
||||||
const SearchBar = (colorGenerator) => {
|
const SearchBar = (colorGenerator, ForServerVersion) => {
|
||||||
const SearchBar = ({ listShortUrls, shortUrlsListParams, selectedServer }) => {
|
const SearchBar = ({ listShortUrls, shortUrlsListParams }) => {
|
||||||
const currentServerVersion = selectedServer ? selectedServer.version : '';
|
|
||||||
const enableDateFiltering = !isEmpty(currentServerVersion) && compareVersions(currentServerVersion, '>=', '1.21.0');
|
|
||||||
const selectedTags = shortUrlsListParams.tags || [];
|
const selectedTags = shortUrlsListParams.tags || [];
|
||||||
const setDate = (dateName) => pipe(
|
const setDate = (dateName) => pipe(
|
||||||
formatDate(),
|
formatDate(),
|
||||||
@@ -38,16 +34,20 @@ const SearchBar = (colorGenerator) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{enableDateFiltering && (
|
<ForServerVersion minVersion="1.21.0">
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<DateRangeRow
|
<div className="row">
|
||||||
startDate={dateOrUndefined(shortUrlsListParams.startDate)}
|
<div className="col-lg-8 offset-lg-4 col-xl-6 offset-xl-6">
|
||||||
endDate={dateOrUndefined(shortUrlsListParams.endDate)}
|
<DateRangeRow
|
||||||
onStartDateChange={setDate('startDate')}
|
startDate={dateOrUndefined(shortUrlsListParams.startDate)}
|
||||||
onEndDateChange={setDate('endDate')}
|
endDate={dateOrUndefined(shortUrlsListParams.endDate)}
|
||||||
/>
|
onStartDateChange={setDate('startDate')}
|
||||||
|
onEndDateChange={setDate('endDate')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</ForServerVersion>
|
||||||
|
|
||||||
{!isEmpty(selectedTags) && (
|
{!isEmpty(selectedTags) && (
|
||||||
<h4 className="search-bar__selected-tag mt-3">
|
<h4 className="search-bar__selected-tag mt-3">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import Paginator from './Paginator';
|
import Paginator from './Paginator';
|
||||||
|
|
||||||
@@ -14,16 +14,22 @@ const ShortUrls = (SearchBar, ShortUrlsList) => {
|
|||||||
const { match: { params }, shortUrlsList } = props;
|
const { match: { params }, shortUrlsList } = props;
|
||||||
const { page, serverId } = params;
|
const { page, serverId } = params;
|
||||||
const { data = [], pagination } = shortUrlsList;
|
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
|
// 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 (
|
return (
|
||||||
<div className="shlink-container">
|
<React.Fragment>
|
||||||
<div className="form-group"><SearchBar /></div>
|
<div className="form-group"><SearchBar /></div>
|
||||||
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
|
<div>
|
||||||
<Paginator paginator={pagination} serverId={serverId} />
|
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
|
||||||
</div>
|
<Paginator paginator={pagination} serverId={serverId} />
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { head, isEmpty, keys, values } from 'ramda';
|
import { head, isEmpty, keys, values } from 'ramda';
|
||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { serverType } from '../servers/prop-types';
|
import { serverType } from '../servers/prop-types';
|
||||||
import SortingDropdown from '../utils/SortingDropdown';
|
import SortingDropdown from '../utils/SortingDropdown';
|
||||||
import { determineOrderDir } from '../utils/utils';
|
import { determineOrderDir } from '../utils/utils';
|
||||||
|
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
|
||||||
|
import { bindToMercureTopic } from '../mercure/helpers';
|
||||||
|
import { SettingsType } from '../settings/reducers/settings';
|
||||||
import { shortUrlType } from './reducers/shortUrlsList';
|
import { shortUrlType } from './reducers/shortUrlsList';
|
||||||
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
||||||
import './ShortUrlsList.scss';
|
import './ShortUrlsList.scss';
|
||||||
@@ -18,118 +21,117 @@ export const SORTABLE_FIELDS = {
|
|||||||
visits: 'Visits',
|
visits: 'Visits',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
listShortUrls: PropTypes.func,
|
||||||
|
resetShortUrlParams: PropTypes.func,
|
||||||
|
shortUrlsListParams: shortUrlsListParamsType,
|
||||||
|
match: PropTypes.object,
|
||||||
|
location: PropTypes.object,
|
||||||
|
loading: PropTypes.bool,
|
||||||
|
error: PropTypes.bool,
|
||||||
|
shortUrlsList: PropTypes.arrayOf(shortUrlType),
|
||||||
|
selectedServer: serverType,
|
||||||
|
createNewVisit: PropTypes.func,
|
||||||
|
loadMercureInfo: PropTypes.func,
|
||||||
|
mercureInfo: MercureInfoType,
|
||||||
|
settings: SettingsType,
|
||||||
|
};
|
||||||
|
|
||||||
// FIXME Replace with typescript: (ShortUrlsRow component)
|
// FIXME Replace with typescript: (ShortUrlsRow component)
|
||||||
const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Component {
|
const ShortUrlsList = (ShortUrlsRow) => {
|
||||||
static propTypes = {
|
const ShortUrlsListComp = ({
|
||||||
listShortUrls: PropTypes.func,
|
listShortUrls,
|
||||||
resetShortUrlParams: PropTypes.func,
|
resetShortUrlParams,
|
||||||
shortUrlsListParams: shortUrlsListParamsType,
|
shortUrlsListParams,
|
||||||
match: PropTypes.object,
|
match,
|
||||||
location: PropTypes.object,
|
location,
|
||||||
loading: PropTypes.bool,
|
loading,
|
||||||
error: PropTypes.bool,
|
error,
|
||||||
shortUrlsList: PropTypes.arrayOf(shortUrlType),
|
shortUrlsList,
|
||||||
selectedServer: serverType,
|
selectedServer,
|
||||||
};
|
createNewVisit,
|
||||||
|
loadMercureInfo,
|
||||||
refreshList = (extraParams) => {
|
mercureInfo,
|
||||||
const { listShortUrls, shortUrlsListParams } = this.props;
|
settings: { realTimeUpdates },
|
||||||
|
}) => {
|
||||||
listShortUrls({
|
const { orderBy } = shortUrlsListParams;
|
||||||
...shortUrlsListParams,
|
const [ order, setOrder ] = useState({
|
||||||
...extraParams,
|
orderField: orderBy && head(keys(orderBy)),
|
||||||
|
orderDir: orderBy && head(values(orderBy)),
|
||||||
});
|
});
|
||||||
};
|
const refreshList = (extraParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
|
||||||
|
const handleOrderBy = (orderField, orderDir) => {
|
||||||
handleOrderBy = (orderField, orderDir) => {
|
setOrder({ orderField, orderDir });
|
||||||
this.setState({ orderField, orderDir });
|
refreshList({ orderBy: { [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}
|
|
||||||
className="short-urls-list__header-icon"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
const { orderBy } = props.shortUrlsListParams;
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
orderField: orderBy ? head(keys(orderBy)) : undefined,
|
|
||||||
orderDir: orderBy ? head(values(orderBy)) : undefined,
|
|
||||||
};
|
};
|
||||||
}
|
const orderByColumn = (columnName) => () =>
|
||||||
|
handleOrderBy(columnName, determineOrderDir(columnName, order.orderField, order.orderDir));
|
||||||
|
const renderOrderIcon = (field) => {
|
||||||
|
if (order.orderField !== field) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
if (!order.orderDir) {
|
||||||
const { match: { params }, location, shortUrlsListParams } = this.props;
|
return null;
|
||||||
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
|
}
|
||||||
const tags = query.tag ? [ query.tag ] : shortUrlsListParams.tags;
|
|
||||||
|
|
||||||
this.refreshList({ page: params.page, tags });
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
const { resetShortUrlParams } = this.props;
|
|
||||||
|
|
||||||
resetShortUrlParams();
|
|
||||||
}
|
|
||||||
|
|
||||||
renderShortUrls() {
|
|
||||||
const { shortUrlsList, selectedServer, loading, error, shortUrlsListParams } = this.props;
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
return (
|
||||||
<tr>
|
<FontAwesomeIcon
|
||||||
<td colSpan="6" className="text-center table-danger">Something went wrong while loading short URLs :(</td>
|
icon={order.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
|
||||||
</tr>
|
className="short-urls-list__header-icon"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
const renderShortUrls = () => {
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="6" className="text-center table-danger">Something went wrong while loading short URLs :(</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <tr><td colSpan="6" className="text-center">Loading...</td></tr>;
|
return <tr><td colSpan="6" className="text-center">Loading...</td></tr>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!loading && isEmpty(shortUrlsList)) {
|
if (!loading && isEmpty(shortUrlsList)) {
|
||||||
return <tr><td colSpan="6" className="text-center">No results found</td></tr>;
|
return <tr><td colSpan="6" className="text-center">No results found</td></tr>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return shortUrlsList.map((shortUrl) => (
|
return shortUrlsList.map((shortUrl) => (
|
||||||
<ShortUrlsRow
|
<ShortUrlsRow
|
||||||
shortUrl={shortUrl}
|
key={shortUrl.shortUrl}
|
||||||
selectedServer={selectedServer}
|
shortUrl={shortUrl}
|
||||||
key={shortUrl.shortCode}
|
selectedServer={selectedServer}
|
||||||
refreshList={this.refreshList}
|
refreshList={refreshList}
|
||||||
shortUrlsListParams={shortUrlsListParams}
|
shortUrlsListParams={shortUrlsListParams}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
}
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { params } = match;
|
||||||
|
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
|
||||||
|
const tags = query.tag ? [ query.tag ] : shortUrlsListParams.tags;
|
||||||
|
|
||||||
|
refreshList({ page: params.page, tags });
|
||||||
|
|
||||||
|
return resetShortUrlParams;
|
||||||
|
}, []);
|
||||||
|
useEffect(
|
||||||
|
bindToMercureTopic(mercureInfo, realTimeUpdates, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo),
|
||||||
|
[ mercureInfo ]
|
||||||
|
);
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<div className="d-block d-md-none mb-3">
|
<div className="d-block d-md-none mb-3">
|
||||||
<SortingDropdown
|
<SortingDropdown
|
||||||
items={SORTABLE_FIELDS}
|
items={SORTABLE_FIELDS}
|
||||||
orderField={this.state.orderField}
|
orderField={order.orderField}
|
||||||
orderDir={this.state.orderDir}
|
orderDir={order.orderDir}
|
||||||
onChange={this.handleOrderBy}
|
onChange={handleOrderBy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<table className="table table-striped table-hover">
|
<table className="table table-striped table-hover">
|
||||||
@@ -137,42 +139,46 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
|
|||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
||||||
onClick={this.orderByColumn('dateCreated')}
|
onClick={orderByColumn('dateCreated')}
|
||||||
>
|
>
|
||||||
{this.renderOrderIcon('dateCreated')}
|
{renderOrderIcon('dateCreated')}
|
||||||
Created at
|
Created at
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
||||||
onClick={this.orderByColumn('shortCode')}
|
onClick={orderByColumn('shortCode')}
|
||||||
>
|
>
|
||||||
{this.renderOrderIcon('shortCode')}
|
{renderOrderIcon('shortCode')}
|
||||||
Short URL
|
Short URL
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
||||||
onClick={this.orderByColumn('longUrl')}
|
onClick={orderByColumn('longUrl')}
|
||||||
>
|
>
|
||||||
{this.renderOrderIcon('longUrl')}
|
{renderOrderIcon('longUrl')}
|
||||||
Long URL
|
Long URL
|
||||||
</th>
|
</th>
|
||||||
<th className="short-urls-list__header-cell">Tags</th>
|
<th className="short-urls-list__header-cell">Tags</th>
|
||||||
<th
|
<th
|
||||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
||||||
onClick={this.orderByColumn('visits')}
|
onClick={orderByColumn('visits')}
|
||||||
>
|
>
|
||||||
<span className="nowrap">{this.renderOrderIcon('visits')} Visits</span>
|
<span className="indivisible">{renderOrderIcon('visits')} Visits</span>
|
||||||
</th>
|
</th>
|
||||||
<th className="short-urls-list__header-cell"> </th>
|
<th className="short-urls-list__header-cell"> </th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{this.renderShortUrls()}
|
{renderShortUrls()}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
ShortUrlsListComp.propTypes = propTypes;
|
||||||
|
|
||||||
|
return ShortUrlsListComp;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ShortUrlsList;
|
export default ShortUrlsList;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
|
||||||
import './UseExistingIfFoundInfoIcon.scss';
|
import './UseExistingIfFoundInfoIcon.scss';
|
||||||
import { useToggle } from '../utils/utils';
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
|
|
||||||
const renderInfoModal = (isOpen, toggle) => (
|
const renderInfoModal = (isOpen, toggle) => (
|
||||||
<Modal isOpen={isOpen} toggle={toggle} centered size="lg">
|
<Modal isOpen={isOpen} toggle={toggle} centered size="lg">
|
||||||
@@ -38,7 +38,7 @@ const renderInfoModal = (isOpen, toggle) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const UseExistingIfFoundInfoIcon = () => {
|
const UseExistingIfFoundInfoIcon = () => {
|
||||||
const [ isModalOpen, toggleModal ] = useToggle(false);
|
const [ isModalOpen, toggleModal ] = useToggle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
|
|||||||
@@ -1,28 +1,26 @@
|
|||||||
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { isNil } from 'ramda';
|
import { isNil } from 'ramda';
|
||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||||
import { Card, CardBody, Tooltip } from 'reactstrap';
|
import { Card, CardBody, Tooltip } from 'reactstrap';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { createShortUrlResultType } from '../reducers/shortUrlCreation';
|
import { createShortUrlResultType } from '../reducers/shortUrlCreation';
|
||||||
import './CreateShortUrlResult.scss';
|
import './CreateShortUrlResult.scss';
|
||||||
|
|
||||||
const CreateShortUrlResult = (stateFlagTimeout) => class CreateShortUrlResult extends React.Component {
|
const propTypes = {
|
||||||
static propTypes = {
|
resetCreateShortUrl: PropTypes.func,
|
||||||
resetCreateShortUrl: PropTypes.func,
|
error: PropTypes.bool,
|
||||||
error: PropTypes.bool,
|
result: createShortUrlResultType,
|
||||||
result: createShortUrlResultType,
|
};
|
||||||
};
|
|
||||||
|
|
||||||
state = { showCopyTooltip: false };
|
const CreateShortUrlResult = (useStateFlagTimeout) => {
|
||||||
|
const CreateShortUrlResultComp = ({ error, result, resetCreateShortUrl }) => {
|
||||||
|
const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout();
|
||||||
|
|
||||||
componentDidMount() {
|
useEffect(() => {
|
||||||
this.props.resetCreateShortUrl();
|
resetCreateShortUrl();
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
render() {
|
|
||||||
const { error, result } = this.props;
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
@@ -31,19 +29,19 @@ const CreateShortUrlResult = (stateFlagTimeout) => class CreateShortUrlResult ex
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNil(result)) {
|
if (isNil(result)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { shortUrl } = result;
|
const { shortUrl } = result;
|
||||||
const onCopy = () => stateFlagTimeout(this.setState.bind(this), 'showCopyTooltip');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card inverse className="bg-main mt-3">
|
<Card inverse className="bg-main mt-3">
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<b>Great!</b> The short URL is <b>{shortUrl}</b>
|
<b>Great!</b> The short URL is <b>{shortUrl}</b>
|
||||||
|
|
||||||
<CopyToClipboard text={shortUrl} onCopy={onCopy}>
|
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-light btn-sm create-short-url-result__copy-btn"
|
className="btn btn-light btn-sm create-short-url-result__copy-btn"
|
||||||
id="copyBtn"
|
id="copyBtn"
|
||||||
@@ -53,13 +51,17 @@ const CreateShortUrlResult = (stateFlagTimeout) => class CreateShortUrlResult ex
|
|||||||
</button>
|
</button>
|
||||||
</CopyToClipboard>
|
</CopyToClipboard>
|
||||||
|
|
||||||
<Tooltip placement="left" isOpen={this.state.showCopyTooltip} target="copyBtn">
|
<Tooltip placement="left" isOpen={showCopyTooltip} target="copyBtn">
|
||||||
Copied!
|
Copied!
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
CreateShortUrlResultComp.propTypes = propTypes;
|
||||||
|
|
||||||
|
return CreateShortUrlResultComp;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CreateShortUrlResult;
|
export default CreateShortUrlResult;
|
||||||
|
|||||||
@@ -1,93 +1,86 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { identity } from 'ramda';
|
import { identity, pipe } from 'ramda';
|
||||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||||
import { shortUrlDeletionType } from '../reducers/shortUrlDeletion';
|
import { shortUrlDeletionType } from '../reducers/shortUrlDeletion';
|
||||||
|
|
||||||
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
|
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
|
||||||
|
|
||||||
export default class DeleteShortUrlModal extends React.Component {
|
const propTypes = {
|
||||||
static propTypes = {
|
shortUrl: shortUrlType,
|
||||||
shortUrl: shortUrlType,
|
toggle: PropTypes.func,
|
||||||
toggle: PropTypes.func,
|
isOpen: PropTypes.bool,
|
||||||
isOpen: PropTypes.bool,
|
shortUrlDeletion: shortUrlDeletionType,
|
||||||
shortUrlDeletion: shortUrlDeletionType,
|
deleteShortUrl: PropTypes.func,
|
||||||
deleteShortUrl: PropTypes.func,
|
resetDeleteShortUrl: PropTypes.func,
|
||||||
resetDeleteShortUrl: PropTypes.func,
|
};
|
||||||
shortUrlDeleted: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = { inputValue: '' };
|
const DeleteShortUrlModal = ({ shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl, deleteShortUrl }) => {
|
||||||
handleDeleteUrl = (e) => {
|
const [ inputValue, setInputValue ] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => resetDeleteShortUrl, []);
|
||||||
|
|
||||||
|
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);
|
||||||
|
const handleDeleteUrl = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const { deleteShortUrl, shortUrl, toggle, shortUrlDeleted } = this.props;
|
const { shortCode, domain } = shortUrl;
|
||||||
const { shortCode } = shortUrl;
|
|
||||||
|
|
||||||
deleteShortUrl(shortCode)
|
deleteShortUrl(shortCode, domain)
|
||||||
.then(() => {
|
.then(toggle)
|
||||||
shortUrlDeleted(shortCode);
|
|
||||||
toggle();
|
|
||||||
})
|
|
||||||
.catch(identity);
|
.catch(identity);
|
||||||
};
|
};
|
||||||
|
|
||||||
componentWillUnmount() {
|
return (
|
||||||
const { resetDeleteShortUrl } = this.props;
|
<Modal isOpen={isOpen} toggle={close} centered>
|
||||||
|
<form onSubmit={handleDeleteUrl}>
|
||||||
|
<ModalHeader toggle={close}>
|
||||||
|
<span className="text-danger">Delete short URL</span>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
|
||||||
|
<p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
|
||||||
|
|
||||||
resetDeleteShortUrl();
|
<input
|
||||||
}
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Insert the short code of the URL"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
render() {
|
{hasThresholdError && (
|
||||||
const { shortUrl, toggle, isOpen, shortUrlDeletion } = this.props;
|
<div className="p-2 mt-2 bg-warning text-center">
|
||||||
const { error, errorData } = shortUrlDeletion;
|
{errorData.threshold && `This short URL has received more than ${errorData.threshold} visits, and therefore, it cannot be deleted.`}
|
||||||
const errorCode = error && (errorData.type || errorData.error);
|
{!errorData.threshold && 'This short URL has received too many visits, and therefore, it cannot be deleted.'}
|
||||||
const hasThresholdError = errorCode === THRESHOLD_REACHED;
|
</div>
|
||||||
const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED;
|
)}
|
||||||
|
{hasErrorOtherThanThreshold && (
|
||||||
|
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||||
|
Something went wrong while deleting the URL :(
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<button type="button" className="btn btn-link" onClick={close}>Cancel</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-danger"
|
||||||
|
disabled={inputValue !== shortUrl.shortCode || shortUrlDeletion.loading}
|
||||||
|
>
|
||||||
|
{shortUrlDeletion.loading ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
DeleteShortUrlModal.propTypes = propTypes;
|
||||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
|
||||||
<form onSubmit={this.handleDeleteUrl}>
|
|
||||||
<ModalHeader toggle={toggle}>
|
|
||||||
<span className="text-danger">Delete short URL</span>
|
|
||||||
</ModalHeader>
|
|
||||||
<ModalBody>
|
|
||||||
<p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
|
|
||||||
<p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
|
|
||||||
|
|
||||||
<input
|
export default DeleteShortUrlModal;
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
placeholder="Insert the short code of the URL"
|
|
||||||
value={this.state.inputValue}
|
|
||||||
onChange={(e) => this.setState({ inputValue: e.target.value })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{hasThresholdError && (
|
|
||||||
<div className="p-2 mt-2 bg-warning text-center">
|
|
||||||
{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 && (
|
|
||||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
|
||||||
Something went wrong while deleting the URL :(
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter>
|
|
||||||
<button type="button" className="btn btn-link" onClick={toggle}>Cancel</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-danger"
|
|
||||||
disabled={this.state.inputValue !== shortUrl.shortCode || shortUrlDeletion.loading}
|
|
||||||
>
|
|
||||||
{shortUrlDeletion.loading ? 'Deleting...' : 'Delete'}
|
|
||||||
</button>
|
|
||||||
</ModalFooter>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { pipe } from 'ramda';
|
import { isEmpty, pipe } from 'ramda';
|
||||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||||
import { shortUrlEditMetaType } from '../reducers/shortUrlMeta';
|
import { shortUrlEditMetaType } from '../reducers/shortUrlMeta';
|
||||||
import DateInput from '../../utils/DateInput';
|
import DateInput from '../../utils/DateInput';
|
||||||
import { formatIsoDate } from '../../utils/utils';
|
import { formatIsoDate } from '../../utils/helpers/date';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
isOpen: PropTypes.bool.isRequired,
|
isOpen: PropTypes.bool.isRequired,
|
||||||
@@ -26,9 +26,7 @@ const dateOrUndefined = (shortUrl, dateName) => {
|
|||||||
return date && moment(date);
|
return date && moment(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditMetaModal = (
|
const EditMetaModal = ({ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }) => {
|
||||||
{ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }
|
|
||||||
) => {
|
|
||||||
const { saving, error } = shortUrlMeta;
|
const { saving, error } = shortUrlMeta;
|
||||||
const url = shortUrl && (shortUrl.shortUrl || '');
|
const url = shortUrl && (shortUrl.shortUrl || '');
|
||||||
const [ validSince, setValidSince ] = useState(dateOrUndefined(shortUrl, 'validSince'));
|
const [ validSince, setValidSince ] = useState(dateOrUndefined(shortUrl, 'validSince'));
|
||||||
@@ -36,8 +34,8 @@ const EditMetaModal = (
|
|||||||
const [ maxVisits, setMaxVisits ] = useState(shortUrl && shortUrl.meta && shortUrl.meta.maxVisits);
|
const [ maxVisits, setMaxVisits ] = useState(shortUrl && shortUrl.meta && shortUrl.meta.maxVisits);
|
||||||
|
|
||||||
const close = pipe(resetShortUrlMeta, toggle);
|
const close = pipe(resetShortUrlMeta, toggle);
|
||||||
const doEdit = () => editShortUrlMeta(shortUrl.shortCode, {
|
const doEdit = () => editShortUrlMeta(shortUrl.shortCode, shortUrl.domain, {
|
||||||
maxVisits: maxVisits && parseInt(maxVisits),
|
maxVisits: maxVisits && !isEmpty(maxVisits) ? parseInt(maxVisits) : null,
|
||||||
validSince: validSince && formatIsoDate(validSince),
|
validSince: validSince && formatIsoDate(validSince),
|
||||||
validUntil: validUntil && formatIsoDate(validUntil),
|
validUntil: validUntil && formatIsoDate(validUntil),
|
||||||
}).then(close);
|
}).then(close);
|
||||||
|
|||||||
57
src/short-urls/helpers/EditShortUrlModal.js
Normal file
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,65 +1,37 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { shortUrlTagsType } from '../reducers/shortUrlTags';
|
import { shortUrlTagsType } from '../reducers/shortUrlTags';
|
||||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||||
|
|
||||||
const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Component {
|
const propTypes = {
|
||||||
static propTypes = {
|
isOpen: PropTypes.bool.isRequired,
|
||||||
isOpen: PropTypes.bool.isRequired,
|
toggle: PropTypes.func.isRequired,
|
||||||
toggle: PropTypes.func.isRequired,
|
shortUrl: shortUrlType.isRequired,
|
||||||
shortUrl: shortUrlType.isRequired,
|
shortUrlTags: shortUrlTagsType,
|
||||||
shortUrlTags: shortUrlTagsType,
|
editShortUrlTags: PropTypes.func,
|
||||||
editShortUrlTags: PropTypes.func,
|
resetShortUrlsTags: PropTypes.func,
|
||||||
shortUrlTagsEdited: PropTypes.func,
|
};
|
||||||
resetShortUrlsTags: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
saveTags = () => {
|
const EditTagsModal = (TagsSelector) => {
|
||||||
const { editShortUrlTags, shortUrl, toggle } = this.props;
|
const EditTagsModalComp = ({ isOpen, toggle, shortUrl, shortUrlTags, editShortUrlTags, resetShortUrlsTags }) => {
|
||||||
|
const [ selectedTags, setSelectedTags ] = useState(shortUrl.tags || []);
|
||||||
|
|
||||||
editShortUrlTags(shortUrl.shortCode, this.state.tags)
|
useEffect(() => resetShortUrlsTags, []);
|
||||||
.then(() => {
|
|
||||||
this.tagsSaved = true;
|
|
||||||
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) {
|
|
||||||
super(props);
|
|
||||||
this.state = { tags: props.shortUrl.tags };
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { isOpen, toggle, shortUrl, shortUrlTags } = this.props;
|
|
||||||
const url = shortUrl && (shortUrl.shortUrl || '');
|
const url = shortUrl && (shortUrl.shortUrl || '');
|
||||||
|
const saveTags = () => editShortUrlTags(shortUrl.shortCode, shortUrl.domain, selectedTags)
|
||||||
|
.then(toggle)
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={() => this.refreshShortUrls()}>
|
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||||
<ModalHeader toggle={toggle}>
|
<ModalHeader toggle={toggle}>
|
||||||
Edit tags for <ExternalLink href={url} />
|
Edit tags for <ExternalLink href={url} />
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<TagsSelector tags={this.state.tags} onChange={(tags) => this.setState({ tags })} />
|
<TagsSelector tags={selectedTags} onChange={(tags) => setSelectedTags(tags)} />
|
||||||
{shortUrlTags.error && (
|
{shortUrlTags.error && (
|
||||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||||
Something went wrong while saving the tags :(
|
Something went wrong while saving the tags :(
|
||||||
@@ -68,18 +40,17 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
|
|||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<button className="btn btn-link" onClick={toggle}>Cancel</button>
|
<button className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||||
<button
|
<button className="btn btn-primary" type="button" disabled={shortUrlTags.saving} onClick={saveTags}>
|
||||||
className="btn btn-primary"
|
|
||||||
type="button"
|
|
||||||
disabled={shortUrlTags.saving}
|
|
||||||
onClick={() => this.saveTags()}
|
|
||||||
>
|
|
||||||
{shortUrlTags.saving ? 'Saving tags...' : 'Save tags'}
|
{shortUrlTags.saving ? 'Saving tags...' : 'Save tags'}
|
||||||
</button>
|
</button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
EditTagsModalComp.propTypes = propTypes;
|
||||||
|
|
||||||
|
return EditTagsModalComp;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EditTagsModal;
|
export default EditTagsModal;
|
||||||
|
|||||||
@@ -1,36 +1,59 @@
|
|||||||
import React from 'react';
|
import React, { useRef } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
import { shortUrlMetaType } from '../reducers/shortUrlMeta';
|
import classNames from 'classnames';
|
||||||
|
import { serverType } from '../../servers/prop-types';
|
||||||
|
import { prettify } from '../../utils/helpers/numbers';
|
||||||
|
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||||
|
import VisitStatsLink from './VisitStatsLink';
|
||||||
import './ShortUrlVisitsCount.scss';
|
import './ShortUrlVisitsCount.scss';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
visitsCount: PropTypes.number.isRequired,
|
visitsCount: PropTypes.number.isRequired,
|
||||||
meta: shortUrlMetaType,
|
shortUrl: shortUrlType,
|
||||||
|
selectedServer: serverType,
|
||||||
|
active: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ShortUrlVisitsCount = ({ visitsCount, meta }) => {
|
const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = false }) => {
|
||||||
const maxVisits = meta && meta.maxVisits;
|
const maxVisits = shortUrl && shortUrl.meta && shortUrl.meta.maxVisits;
|
||||||
|
const visitsLink = (
|
||||||
|
<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>
|
||||||
|
<strong
|
||||||
|
className={classNames('short-url-visits-count__amount', { 'short-url-visits-count__amount--big': active })}
|
||||||
|
>
|
||||||
|
{prettify(visitsCount)}
|
||||||
|
</strong>
|
||||||
|
</VisitStatsLink>
|
||||||
|
);
|
||||||
|
|
||||||
if (!maxVisits) {
|
if (!maxVisits) {
|
||||||
return <span>{visitsCount}</span>;
|
return visitsLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prettifiedMaxVisits = prettify(maxVisits);
|
||||||
|
const tooltipRef = useRef();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<span className="indivisible">
|
<span className="indivisible">
|
||||||
{visitsCount}
|
{visitsLink}
|
||||||
<small id="maxVisitsControl" className="short-urls-visits-count__max-visits-control">
|
<small
|
||||||
{' '}/ {maxVisits}{' '}
|
className="short-urls-visits-count__max-visits-control"
|
||||||
|
ref={(el) => {
|
||||||
|
tooltipRef.current = el;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{' '}/ {prettifiedMaxVisits}{' '}
|
||||||
<sup>
|
<sup>
|
||||||
<FontAwesomeIcon icon={infoIcon} />
|
<FontAwesomeIcon icon={infoIcon} />
|
||||||
</sup>
|
</sup>
|
||||||
</small>
|
</small>
|
||||||
</span>
|
</span>
|
||||||
<UncontrolledTooltip target="maxVisitsControl" placement="bottom">
|
<UncontrolledTooltip target={() => tooltipRef.current} placement="bottom">
|
||||||
This short URL will not accept more than <b>{maxVisits}</b> visits.
|
This short URL will not accept more than <b>{prettifiedMaxVisits}</b> visits.
|
||||||
</UncontrolledTooltip>
|
</UncontrolledTooltip>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
.short-urls-visits-count__max-visits-control {
|
.short-urls-visits-count__max-visits-control {
|
||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.short-url-visits-count__amount {
|
||||||
|
transition: transform .3s ease;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-url-visits-count__amount--big {
|
||||||
|
transform: scale(1.5);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { isEmpty } from 'ramda';
|
import { isEmpty } from 'ramda';
|
||||||
import React from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import Moment from 'react-moment';
|
import Moment from 'react-moment';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
||||||
|
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||||
import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams';
|
import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams';
|
||||||
import { serverType } from '../../servers/prop-types';
|
import { serverType } from '../../servers/prop-types';
|
||||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||||
@@ -10,72 +13,86 @@ import Tag from '../../tags/helpers/Tag';
|
|||||||
import ShortUrlVisitsCount from './ShortUrlVisitsCount';
|
import ShortUrlVisitsCount from './ShortUrlVisitsCount';
|
||||||
import './ShortUrlsRow.scss';
|
import './ShortUrlsRow.scss';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
refreshList: PropTypes.func,
|
||||||
|
shortUrlsListParams: shortUrlsListParamsType,
|
||||||
|
selectedServer: serverType,
|
||||||
|
shortUrl: shortUrlType,
|
||||||
|
};
|
||||||
|
|
||||||
const ShortUrlsRow = (
|
const ShortUrlsRow = (
|
||||||
ShortUrlsRowMenu,
|
ShortUrlsRowMenu,
|
||||||
colorGenerator,
|
colorGenerator,
|
||||||
stateFlagTimeout
|
useStateFlagTimeout
|
||||||
) => class ShortUrlsRow extends React.Component {
|
) => {
|
||||||
static propTypes = {
|
const ShortUrlsRowComp = ({ shortUrl, selectedServer, refreshList, shortUrlsListParams }) => {
|
||||||
refreshList: PropTypes.func,
|
const [ copiedToClipboard, setCopiedToClipboard ] = useStateFlagTimeout();
|
||||||
shortUrlsListParams: shortUrlsListParamsType,
|
const [ active, setActive ] = useStateFlagTimeout(false, 500);
|
||||||
selectedServer: serverType,
|
const isFirstRun = useRef(true);
|
||||||
shortUrl: shortUrlType,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = { copiedToClipboard: false };
|
const renderTags = (tags) => {
|
||||||
|
if (isEmpty(tags)) {
|
||||||
|
return <i className="indivisible"><small>No tags</small></i>;
|
||||||
|
}
|
||||||
|
|
||||||
renderTags(tags) {
|
const selectedTags = shortUrlsListParams.tags || [];
|
||||||
if (isEmpty(tags)) {
|
|
||||||
return <i className="nowrap"><small>No tags</small></i>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { refreshList, shortUrlsListParams } = this.props;
|
return tags.map((tag) => (
|
||||||
const selectedTags = shortUrlsListParams.tags || [];
|
<Tag
|
||||||
|
colorGenerator={colorGenerator}
|
||||||
|
key={tag}
|
||||||
|
text={tag}
|
||||||
|
onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
return tags.map((tag) => (
|
useEffect(() => {
|
||||||
<Tag
|
if (isFirstRun.current) {
|
||||||
colorGenerator={colorGenerator}
|
isFirstRun.current = false;
|
||||||
key={tag}
|
} else {
|
||||||
text={tag}
|
setActive(true);
|
||||||
onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })}
|
}
|
||||||
/>
|
}, [ shortUrl.visitsCount ]);
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { shortUrl, selectedServer } = this.props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="short-urls-row">
|
<tr className="short-urls-row">
|
||||||
<td className="nowrap short-urls-row__cell" data-th="Created at: ">
|
<td className="indivisible short-urls-row__cell" data-th="Created at: ">
|
||||||
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
|
<Moment format="YYYY-MM-DD HH:mm">{shortUrl.dateCreated}</Moment>
|
||||||
</td>
|
</td>
|
||||||
<td className="short-urls-row__cell" data-th="Short URL: ">
|
<td className="short-urls-row__cell" data-th="Short URL: ">
|
||||||
<ExternalLink href={shortUrl.shortUrl} />
|
<span className="indivisible short-urls-row__cell--relative">
|
||||||
|
<ExternalLink href={shortUrl.shortUrl} />
|
||||||
|
<CopyToClipboard text={shortUrl.shortUrl} onCopy={setCopiedToClipboard}>
|
||||||
|
<FontAwesomeIcon icon={copyIcon} className="ml-2 short-urls-row__copy-btn" />
|
||||||
|
</CopyToClipboard>
|
||||||
|
<span className="badge badge-warning short-urls-row__copy-hint" hidden={!copiedToClipboard}>
|
||||||
|
Copied short URL!
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="short-urls-row__cell short-urls-row__cell--break" data-th="Long URL: ">
|
<td className="short-urls-row__cell short-urls-row__cell--break" data-th="Long URL: ">
|
||||||
<ExternalLink href={shortUrl.longUrl} />
|
<ExternalLink href={shortUrl.longUrl} />
|
||||||
</td>
|
</td>
|
||||||
<td className="short-urls-row__cell" data-th="Tags: ">{this.renderTags(shortUrl.tags)}</td>
|
<td className="short-urls-row__cell" data-th="Tags: ">{renderTags(shortUrl.tags)}</td>
|
||||||
<td className="short-urls-row__cell text-md-right" data-th="Visits: ">
|
<td className="short-urls-row__cell text-md-right" data-th="Visits: ">
|
||||||
<ShortUrlVisitsCount visitsCount={shortUrl.visitsCount} meta={shortUrl.meta} />
|
<ShortUrlVisitsCount
|
||||||
</td>
|
visitsCount={shortUrl.visitsCount}
|
||||||
<td className="short-urls-row__cell short-urls-row__cell--relative">
|
|
||||||
<small
|
|
||||||
className="badge badge-warning short-urls-row__copy-hint"
|
|
||||||
hidden={!this.state.copiedToClipboard}
|
|
||||||
>
|
|
||||||
Copied short URL!
|
|
||||||
</small>
|
|
||||||
<ShortUrlsRowMenu
|
|
||||||
selectedServer={selectedServer}
|
|
||||||
shortUrl={shortUrl}
|
shortUrl={shortUrl}
|
||||||
onCopyToClipboard={() => stateFlagTimeout(this.setState.bind(this), 'copiedToClipboard')}
|
selectedServer={selectedServer}
|
||||||
|
active={active}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="short-urls-row__cell">
|
||||||
|
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
ShortUrlsRowComp.propTypes = propTypes;
|
||||||
|
|
||||||
|
return ShortUrlsRowComp;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ShortUrlsRow;
|
export default ShortUrlsRow;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.short-urls-row__cell--break {
|
.short-urls-row__cell--break {
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
@@ -43,11 +44,20 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.short-urls-row__cell--big {
|
||||||
|
transform: scale(1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-urls-row__copy-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.short-urls-row__copy-hint {
|
.short-urls-row__copy-hint {
|
||||||
@include vertical-align();
|
@include vertical-align(translateX(10px));
|
||||||
right: 100%;
|
box-shadow: 0 3px 15px rgba(0, 0, 0, .25);
|
||||||
|
|
||||||
@media (max-width: $smMax) {
|
@media (max-width: $smMax) {
|
||||||
right: calc(100% + 10px);
|
@include vertical-align(translateX(calc(-100% - 20px)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { faCopy as copyIcon, faImage as pictureIcon } from '@fortawesome/free-regular-svg-icons';
|
import { faImage as pictureIcon } from '@fortawesome/free-regular-svg-icons';
|
||||||
import {
|
import {
|
||||||
faTags as tagsIcon,
|
faTags as tagsIcon,
|
||||||
faChartPie as pieChartIcon,
|
faChartPie as pieChartIcon,
|
||||||
@@ -6,111 +6,90 @@ import {
|
|||||||
faQrcode as qrIcon,
|
faQrcode as qrIcon,
|
||||||
faMinusCircle as deleteIcon,
|
faMinusCircle as deleteIcon,
|
||||||
faEdit as editIcon,
|
faEdit as editIcon,
|
||||||
|
faLink as linkIcon,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
|
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { isEmpty } from 'ramda';
|
|
||||||
import { serverType } from '../../servers/prop-types';
|
import { serverType } from '../../servers/prop-types';
|
||||||
import { compareVersions } from '../../utils/utils';
|
|
||||||
import { shortUrlType } from '../reducers/shortUrlsList';
|
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||||
|
import { useToggle } from '../../utils/helpers/hooks';
|
||||||
import PreviewModal from './PreviewModal';
|
import PreviewModal from './PreviewModal';
|
||||||
import QrCodeModal from './QrCodeModal';
|
import QrCodeModal from './QrCodeModal';
|
||||||
|
import VisitStatsLink from './VisitStatsLink';
|
||||||
import './ShortUrlsRowMenu.scss';
|
import './ShortUrlsRowMenu.scss';
|
||||||
|
|
||||||
const ShortUrlsRowMenu = (
|
const propTypes = {
|
||||||
DeleteShortUrlModal,
|
selectedServer: serverType,
|
||||||
EditTagsModal,
|
shortUrl: shortUrlType,
|
||||||
EditMetaModal
|
};
|
||||||
) => class ShortUrlsRowMenu extends React.Component {
|
|
||||||
static propTypes = {
|
|
||||||
onCopyToClipboard: PropTypes.func,
|
|
||||||
selectedServer: serverType,
|
|
||||||
shortUrl: shortUrlType,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal, EditMetaModal, EditShortUrlModal, ForServerVersion) => {
|
||||||
isOpen: false,
|
const ShortUrlsRowMenuComp = ({ shortUrl, selectedServer }) => {
|
||||||
isQrModalOpen: false,
|
const [ isOpen, toggle ] = useToggle();
|
||||||
isPreviewModalOpen: false,
|
const [ isQrModalOpen, toggleQrCode ] = useToggle();
|
||||||
isTagsModalOpen: false,
|
const [ isPreviewModalOpen, togglePreview ] = useToggle();
|
||||||
isMetaModalOpen: false,
|
const [ isTagsModalOpen, toggleTags ] = useToggle();
|
||||||
isDeleteModalOpen: false,
|
const [ isMetaModalOpen, toggleMeta ] = useToggle();
|
||||||
};
|
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||||
toggle = () => this.setState(({ isOpen }) => ({ isOpen: !isOpen }));
|
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
||||||
|
|
||||||
render() {
|
|
||||||
const { onCopyToClipboard, shortUrl, selectedServer } = this.props;
|
|
||||||
const completeShortUrl = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
|
const completeShortUrl = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : '';
|
||||||
const currentServerVersion = selectedServer ? selectedServer.version : '';
|
|
||||||
const showEditMetaBtn = !isEmpty(currentServerVersion) && compareVersions(currentServerVersion, '>=', '1.18.0');
|
|
||||||
const showPreviewBtn = !isEmpty(currentServerVersion) && compareVersions(currentServerVersion, '<', '2.0.0');
|
|
||||||
const toggleModal = (prop) => () => this.setState((prevState) => ({ [prop]: !prevState[prop] }));
|
|
||||||
const toggleQrCode = toggleModal('isQrModalOpen');
|
|
||||||
const togglePreview = toggleModal('isPreviewModalOpen');
|
|
||||||
const toggleTags = toggleModal('isTagsModalOpen');
|
|
||||||
const toggleMeta = toggleModal('isMetaModalOpen');
|
|
||||||
const toggleDelete = toggleModal('isDeleteModalOpen');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ButtonDropdown toggle={this.toggle} isOpen={this.state.isOpen}>
|
<ButtonDropdown toggle={toggle} isOpen={isOpen}>
|
||||||
<DropdownToggle size="sm" caret outline className="short-urls-row-menu__dropdown-toggle">
|
<DropdownToggle size="sm" caret outline className="short-urls-row-menu__dropdown-toggle">
|
||||||
<FontAwesomeIcon icon={menuIcon} />
|
<FontAwesomeIcon icon={menuIcon} />
|
||||||
</DropdownToggle>
|
</DropdownToggle>
|
||||||
<DropdownMenu right>
|
<DropdownMenu right>
|
||||||
<DropdownItem tag={Link} to={`/server/${selectedServer ? selectedServer.id : ''}/short-code/${shortUrl.shortCode}/visits`}>
|
<DropdownItem tag={VisitStatsLink} selectedServer={selectedServer} shortUrl={shortUrl}>
|
||||||
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
|
||||||
<DropdownItem onClick={toggleTags}>
|
<DropdownItem onClick={toggleTags}>
|
||||||
<FontAwesomeIcon icon={tagsIcon} fixedWidth /> Edit tags
|
<FontAwesomeIcon icon={tagsIcon} fixedWidth /> Edit tags
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<EditTagsModal shortUrl={shortUrl} isOpen={this.state.isTagsModalOpen} toggle={toggleTags} />
|
<EditTagsModal shortUrl={shortUrl} isOpen={isTagsModalOpen} toggle={toggleTags} />
|
||||||
|
|
||||||
{showEditMetaBtn && (
|
<ForServerVersion minVersion="1.18.0">
|
||||||
<React.Fragment>
|
<DropdownItem onClick={toggleMeta}>
|
||||||
<DropdownItem onClick={toggleMeta}>
|
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata
|
||||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata
|
</DropdownItem>
|
||||||
</DropdownItem>
|
<EditMetaModal shortUrl={shortUrl} isOpen={isMetaModalOpen} toggle={toggleMeta} />
|
||||||
<EditMetaModal shortUrl={shortUrl} isOpen={this.state.isMetaModalOpen} toggle={toggleMeta} />
|
</ForServerVersion>
|
||||||
</React.Fragment>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
|
<ForServerVersion minVersion="2.1.0">
|
||||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
<DropdownItem onClick={toggleEdit}>
|
||||||
</DropdownItem>
|
<FontAwesomeIcon icon={linkIcon} fixedWidth /> Edit long URL
|
||||||
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={this.state.isDeleteModalOpen} toggle={toggleDelete} />
|
</DropdownItem>
|
||||||
|
<EditShortUrlModal shortUrl={shortUrl} isOpen={isEditModalOpen} toggle={toggleEdit} />
|
||||||
<DropdownItem divider />
|
</ForServerVersion>
|
||||||
|
|
||||||
{showPreviewBtn && (
|
|
||||||
<React.Fragment>
|
|
||||||
<DropdownItem onClick={togglePreview}>
|
|
||||||
<FontAwesomeIcon icon={pictureIcon} fixedWidth /> Preview
|
|
||||||
</DropdownItem>
|
|
||||||
<PreviewModal url={completeShortUrl} isOpen={this.state.isPreviewModalOpen} toggle={togglePreview} />
|
|
||||||
</React.Fragment>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownItem onClick={toggleQrCode}>
|
<DropdownItem onClick={toggleQrCode}>
|
||||||
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<QrCodeModal url={completeShortUrl} isOpen={this.state.isQrModalOpen} toggle={toggleQrCode} />
|
<QrCodeModal url={completeShortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
|
||||||
|
|
||||||
{showPreviewBtn && <DropdownItem divider />}
|
<ForServerVersion maxVersion="1.x">
|
||||||
|
<DropdownItem onClick={togglePreview}>
|
||||||
<CopyToClipboard text={completeShortUrl} onCopy={onCopyToClipboard}>
|
<FontAwesomeIcon icon={pictureIcon} fixedWidth /> Preview
|
||||||
<DropdownItem>
|
|
||||||
<FontAwesomeIcon icon={copyIcon} fixedWidth /> Copy to clipboard
|
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</CopyToClipboard>
|
<PreviewModal url={completeShortUrl} isOpen={isPreviewModalOpen} toggle={togglePreview} />
|
||||||
|
</ForServerVersion>
|
||||||
|
|
||||||
|
<DropdownItem divider />
|
||||||
|
|
||||||
|
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
|
||||||
|
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
||||||
|
</DropdownItem>
|
||||||
|
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</ButtonDropdown>
|
</ButtonDropdown>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
ShortUrlsRowMenuComp.propTypes = propTypes;
|
||||||
|
|
||||||
|
return ShortUrlsRowMenuComp;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ShortUrlsRowMenu;
|
export default ShortUrlsRowMenu;
|
||||||
|
|||||||
29
src/short-urls/helpers/VisitStatsLink.js
Normal file
29
src/short-urls/helpers/VisitStatsLink.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { serverType } from '../../servers/prop-types';
|
||||||
|
import { shortUrlType } from '../reducers/shortUrlsList';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
shortUrl: shortUrlType,
|
||||||
|
selectedServer: serverType,
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildVisitsUrl = ({ id }, { shortCode, domain }) => {
|
||||||
|
const query = domain ? `?domain=${domain}` : '';
|
||||||
|
|
||||||
|
return `/server/${id}/short-code/${shortCode}/visits${query}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VisitStatsLink = ({ selectedServer, shortUrl, children, ...rest }) => {
|
||||||
|
if (!selectedServer || !shortUrl) {
|
||||||
|
return <span {...rest}>{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Link to={buildVisitsUrl(selectedServer, shortUrl)} {...rest}>{children}</Link>;
|
||||||
|
};
|
||||||
|
|
||||||
|
VisitStatsLink.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default VisitStatsLink;
|
||||||
9
src/short-urls/helpers/index.js
Normal file
9
src/short-urls/helpers/index.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { isNil } from 'ramda';
|
||||||
|
|
||||||
|
export const shortUrlMatches = (shortUrl, shortCode, domain) => {
|
||||||
|
if (isNil(domain)) {
|
||||||
|
return shortUrl.shortCode === shortCode && !shortUrl.domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shortUrl.shortCode === shortCode && shortUrl.domain === domain;
|
||||||
|
};
|
||||||
@@ -31,8 +31,7 @@ export default handleActions({
|
|||||||
|
|
||||||
export const createShortUrl = (buildShlinkApiClient) => (data) => async (dispatch, getState) => {
|
export const createShortUrl = (buildShlinkApiClient) => (data) => async (dispatch, getState) => {
|
||||||
dispatch({ type: CREATE_SHORT_URL_START });
|
dispatch({ type: CREATE_SHORT_URL_START });
|
||||||
|
const { createShortUrl } = buildShlinkApiClient(getState);
|
||||||
const { createShortUrl } = await buildShlinkApiClient(getState);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await createShortUrl(data);
|
const result = await createShortUrl(data);
|
||||||
@@ -40,6 +39,8 @@ export const createShortUrl = (buildShlinkApiClient) => (data) => async (dispatc
|
|||||||
dispatch({ type: CREATE_SHORT_URL, result });
|
dispatch({ type: CREATE_SHORT_URL, result });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: CREATE_SHORT_URL_ERROR });
|
dispatch({ type: CREATE_SHORT_URL_ERROR });
|
||||||
|
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import { apiErrorType } from '../../utils/services/ShlinkApiClient';
|
|||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
|
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
|
||||||
export const DELETE_SHORT_URL_ERROR = 'shlink/deleteShortUrl/DELETE_SHORT_URL_ERROR';
|
export const DELETE_SHORT_URL_ERROR = 'shlink/deleteShortUrl/DELETE_SHORT_URL_ERROR';
|
||||||
export const DELETE_SHORT_URL = 'shlink/deleteShortUrl/DELETE_SHORT_URL';
|
|
||||||
export const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
|
|
||||||
export const SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED';
|
export const SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED';
|
||||||
|
export const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
|
||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
export const shortUrlDeletionType = PropTypes.shape({
|
export const shortUrlDeletionType = PropTypes.shape({
|
||||||
@@ -27,18 +26,17 @@ const initialState = {
|
|||||||
export default handleActions({
|
export default handleActions({
|
||||||
[DELETE_SHORT_URL_START]: (state) => ({ ...state, loading: true, error: false }),
|
[DELETE_SHORT_URL_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||||
[DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }),
|
[DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }),
|
||||||
[DELETE_SHORT_URL]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
|
[SHORT_URL_DELETED]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
|
||||||
[RESET_DELETE_SHORT_URL]: () => initialState,
|
[RESET_DELETE_SHORT_URL]: () => initialState,
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
export const deleteShortUrl = (buildShlinkApiClient) => (shortCode) => async (dispatch, getState) => {
|
export const deleteShortUrl = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => {
|
||||||
dispatch({ type: DELETE_SHORT_URL_START });
|
dispatch({ type: DELETE_SHORT_URL_START });
|
||||||
|
const { deleteShortUrl } = buildShlinkApiClient(getState);
|
||||||
const { deleteShortUrl } = await buildShlinkApiClient(getState);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteShortUrl(shortCode);
|
await deleteShortUrl(shortCode, domain);
|
||||||
dispatch({ type: DELETE_SHORT_URL, shortCode });
|
dispatch({ type: SHORT_URL_DELETED, shortCode, domain });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: DELETE_SHORT_URL_ERROR, errorData: e.response.data });
|
dispatch({ type: DELETE_SHORT_URL_ERROR, errorData: e.response.data });
|
||||||
|
|
||||||
@@ -47,5 +45,3 @@ export const deleteShortUrl = (buildShlinkApiClient) => (shortCode) => async (di
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const resetDeleteShortUrl = createAction(RESET_DELETE_SHORT_URL);
|
export const resetDeleteShortUrl = createAction(RESET_DELETE_SHORT_URL);
|
||||||
|
|
||||||
export const shortUrlDeleted = (shortCode) => ({ type: SHORT_URL_DELETED, shortCode });
|
|
||||||
|
|||||||
42
src/short-urls/reducers/shortUrlEdition.js
Normal file
42
src/short-urls/reducers/shortUrlEdition.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { handleActions } from 'redux-actions';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
/* eslint-disable padding-line-between-statements */
|
||||||
|
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
|
||||||
|
export const EDIT_SHORT_URL_ERROR = 'shlink/shortUrlEdition/EDIT_SHORT_URL_ERROR';
|
||||||
|
export const SHORT_URL_EDITED = 'shlink/shortUrlEdition/SHORT_URL_EDITED';
|
||||||
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
|
export const ShortUrlEditionType = PropTypes.shape({
|
||||||
|
shortCode: PropTypes.string,
|
||||||
|
longUrl: PropTypes.string,
|
||||||
|
saving: PropTypes.bool.isRequired,
|
||||||
|
error: PropTypes.bool.isRequired,
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
shortCode: null,
|
||||||
|
longUrl: null,
|
||||||
|
saving: false,
|
||||||
|
error: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handleActions({
|
||||||
|
[EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||||
|
[EDIT_SHORT_URL_ERROR]: (state) => ({ ...state, saving: false, error: true }),
|
||||||
|
[SHORT_URL_EDITED]: (state, { shortCode, longUrl }) => ({ shortCode, longUrl, saving: false, error: false }),
|
||||||
|
}, initialState);
|
||||||
|
|
||||||
|
export const editShortUrl = (buildShlinkApiClient) => (shortCode, domain, longUrl) => async (dispatch, getState) => {
|
||||||
|
dispatch({ type: EDIT_SHORT_URL_START });
|
||||||
|
const { updateShortUrlMeta } = buildShlinkApiClient(getState);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateShortUrlMeta(shortCode, domain, { longUrl });
|
||||||
|
dispatch({ shortCode, longUrl, domain, type: SHORT_URL_EDITED });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: EDIT_SHORT_URL_ERROR });
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -35,13 +35,13 @@ export default handleActions({
|
|||||||
[RESET_EDIT_SHORT_URL_META]: () => initialState,
|
[RESET_EDIT_SHORT_URL_META]: () => initialState,
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
export const editShortUrlMeta = (buildShlinkApiClient) => (shortCode, meta) => async (dispatch, getState) => {
|
export const editShortUrlMeta = (buildShlinkApiClient) => (shortCode, domain, meta) => async (dispatch, getState) => {
|
||||||
dispatch({ type: EDIT_SHORT_URL_META_START });
|
dispatch({ type: EDIT_SHORT_URL_META_START });
|
||||||
const { updateShortUrlMeta } = await buildShlinkApiClient(getState);
|
const { updateShortUrlMeta } = buildShlinkApiClient(getState);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateShortUrlMeta(shortCode, meta);
|
await updateShortUrlMeta(shortCode, domain, meta);
|
||||||
dispatch({ shortCode, meta, type: SHORT_URL_META_EDITED });
|
dispatch({ shortCode, meta, domain, type: SHORT_URL_META_EDITED });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: EDIT_SHORT_URL_META_ERROR });
|
dispatch({ type: EDIT_SHORT_URL_META_ERROR });
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import PropTypes from 'prop-types';
|
|||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START';
|
export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START';
|
||||||
export const EDIT_SHORT_URL_TAGS_ERROR = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_ERROR';
|
export const EDIT_SHORT_URL_TAGS_ERROR = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_ERROR';
|
||||||
export const EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS';
|
|
||||||
export const RESET_EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/RESET_EDIT_SHORT_URL_TAGS';
|
|
||||||
export const SHORT_URL_TAGS_EDITED = 'shlink/shortUrlTags/SHORT_URL_TAGS_EDITED';
|
export const SHORT_URL_TAGS_EDITED = 'shlink/shortUrlTags/SHORT_URL_TAGS_EDITED';
|
||||||
|
export const RESET_EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/RESET_EDIT_SHORT_URL_TAGS';
|
||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
export const shortUrlTagsType = PropTypes.shape({
|
export const shortUrlTagsType = PropTypes.shape({
|
||||||
@@ -26,18 +25,18 @@ const initialState = {
|
|||||||
export default handleActions({
|
export default handleActions({
|
||||||
[EDIT_SHORT_URL_TAGS_START]: (state) => ({ ...state, saving: true, error: false }),
|
[EDIT_SHORT_URL_TAGS_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||||
[EDIT_SHORT_URL_TAGS_ERROR]: (state) => ({ ...state, saving: false, error: true }),
|
[EDIT_SHORT_URL_TAGS_ERROR]: (state) => ({ ...state, saving: false, error: true }),
|
||||||
[EDIT_SHORT_URL_TAGS]: (state, { shortCode, tags }) => ({ shortCode, tags, saving: false, error: false }),
|
[SHORT_URL_TAGS_EDITED]: (state, { shortCode, tags }) => ({ shortCode, tags, saving: false, error: false }),
|
||||||
[RESET_EDIT_SHORT_URL_TAGS]: () => initialState,
|
[RESET_EDIT_SHORT_URL_TAGS]: () => initialState,
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, tags) => async (dispatch, getState) => {
|
export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, domain, tags) => async (dispatch, getState) => {
|
||||||
dispatch({ type: EDIT_SHORT_URL_TAGS_START });
|
dispatch({ type: EDIT_SHORT_URL_TAGS_START });
|
||||||
const { updateShortUrlTags } = await buildShlinkApiClient(getState);
|
const { updateShortUrlTags } = buildShlinkApiClient(getState);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const normalizedTags = await updateShortUrlTags(shortCode, tags);
|
const normalizedTags = await updateShortUrlTags(shortCode, domain, tags);
|
||||||
|
|
||||||
dispatch({ tags: normalizedTags, shortCode, type: EDIT_SHORT_URL_TAGS });
|
dispatch({ tags: normalizedTags, shortCode, domain, type: SHORT_URL_TAGS_EDITED });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: EDIT_SHORT_URL_TAGS_ERROR });
|
dispatch({ type: EDIT_SHORT_URL_TAGS_ERROR });
|
||||||
|
|
||||||
@@ -46,9 +45,3 @@ export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, tags) => a
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const resetShortUrlsTags = createAction(RESET_EDIT_SHORT_URL_TAGS);
|
export const resetShortUrlsTags = createAction(RESET_EDIT_SHORT_URL_TAGS);
|
||||||
|
|
||||||
export const shortUrlTagsEdited = (shortCode, tags) => ({
|
|
||||||
tags,
|
|
||||||
shortCode,
|
|
||||||
type: SHORT_URL_TAGS_EDITED,
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { handleActions } from 'redux-actions';
|
import { handleActions } from 'redux-actions';
|
||||||
import { assoc, assocPath, propEq, reject } from 'ramda';
|
import { assoc, assocPath, reject } from 'ramda';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { shortUrlMatches } from '../helpers';
|
||||||
|
import { CREATE_VISIT } from '../../visits/reducers/visitCreation';
|
||||||
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
|
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
|
||||||
import { SHORT_URL_DELETED } from './shortUrlDeletion';
|
import { SHORT_URL_DELETED } from './shortUrlDeletion';
|
||||||
import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta';
|
import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta';
|
||||||
|
import { SHORT_URL_EDITED } from './shortUrlEdition';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
|
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
|
||||||
@@ -18,6 +21,7 @@ export const shortUrlType = PropTypes.shape({
|
|||||||
visitsCount: PropTypes.number,
|
visitsCount: PropTypes.number,
|
||||||
meta: shortUrlMetaType,
|
meta: shortUrlMetaType,
|
||||||
tags: PropTypes.arrayOf(PropTypes.string),
|
tags: PropTypes.arrayOf(PropTypes.string),
|
||||||
|
domain: PropTypes.string,
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
@@ -26,10 +30,10 @@ const initialState = {
|
|||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const setPropFromActionOnMatchingShortUrl = (prop) => (state, { shortCode, [prop]: propValue }) => assocPath(
|
const setPropFromActionOnMatchingShortUrl = (prop) => (state, { shortCode, domain, [prop]: propValue }) => assocPath(
|
||||||
[ 'shortUrls', 'data' ],
|
[ 'shortUrls', 'data' ],
|
||||||
state.shortUrls.data.map(
|
state.shortUrls.data.map(
|
||||||
(shortUrl) => shortUrl.shortCode === shortCode ? assoc(prop, propValue, shortUrl) : shortUrl
|
(shortUrl) => shortUrlMatches(shortUrl, shortCode, domain) ? assoc(prop, propValue, shortUrl) : shortUrl
|
||||||
),
|
),
|
||||||
state
|
state
|
||||||
);
|
);
|
||||||
@@ -38,19 +42,28 @@ export default handleActions({
|
|||||||
[LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }),
|
[LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||||
[LIST_SHORT_URLS]: (state, { shortUrls }) => ({ loading: false, error: false, shortUrls }),
|
[LIST_SHORT_URLS]: (state, { shortUrls }) => ({ loading: false, error: false, shortUrls }),
|
||||||
[LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true, shortUrls: {} }),
|
[LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true, shortUrls: {} }),
|
||||||
[SHORT_URL_DELETED]: (state, { shortCode }) => assocPath(
|
[SHORT_URL_DELETED]: (state, { shortCode, domain }) => assocPath(
|
||||||
[ 'shortUrls', 'data' ],
|
[ 'shortUrls', 'data' ],
|
||||||
reject(propEq('shortCode', shortCode), state.shortUrls.data),
|
reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data),
|
||||||
state,
|
state,
|
||||||
),
|
),
|
||||||
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'),
|
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'),
|
||||||
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'),
|
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'),
|
||||||
|
[SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'),
|
||||||
|
[CREATE_VISIT]: (state, { shortUrl: { shortCode, domain, visitsCount } }) => assocPath(
|
||||||
|
[ 'shortUrls', 'data' ],
|
||||||
|
state.shortUrls && state.shortUrls.data && state.shortUrls.data.map(
|
||||||
|
(shortUrl) => shortUrlMatches(shortUrl, shortCode, domain)
|
||||||
|
? assoc('visitsCount', visitsCount, shortUrl)
|
||||||
|
: shortUrl
|
||||||
|
),
|
||||||
|
state
|
||||||
|
),
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => {
|
export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => {
|
||||||
dispatch({ type: LIST_SHORT_URLS_START });
|
dispatch({ type: LIST_SHORT_URLS_START });
|
||||||
|
const { listShortUrls } = buildShlinkApiClient(getState);
|
||||||
const { listShortUrls } = await buildShlinkApiClient(getState);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const shortUrls = await listShortUrls(params);
|
const shortUrls = await listShortUrls(params);
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ import CreateShortUrl from '../CreateShortUrl';
|
|||||||
import DeleteShortUrlModal from '../helpers/DeleteShortUrlModal';
|
import DeleteShortUrlModal from '../helpers/DeleteShortUrlModal';
|
||||||
import EditTagsModal from '../helpers/EditTagsModal';
|
import EditTagsModal from '../helpers/EditTagsModal';
|
||||||
import EditMetaModal from '../helpers/EditMetaModal';
|
import EditMetaModal from '../helpers/EditMetaModal';
|
||||||
|
import EditShortUrlModal from '../helpers/EditShortUrlModal';
|
||||||
import CreateShortUrlResult from '../helpers/CreateShortUrlResult';
|
import CreateShortUrlResult from '../helpers/CreateShortUrlResult';
|
||||||
import { listShortUrls } from '../reducers/shortUrlsList';
|
import { listShortUrls } from '../reducers/shortUrlsList';
|
||||||
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
|
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
|
||||||
import { deleteShortUrl, resetDeleteShortUrl, shortUrlDeleted } from '../reducers/shortUrlDeletion';
|
import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion';
|
||||||
import { editShortUrlTags, resetShortUrlsTags, shortUrlTagsEdited } from '../reducers/shortUrlTags';
|
import { editShortUrlTags, resetShortUrlsTags } from '../reducers/shortUrlTags';
|
||||||
import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta';
|
import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta';
|
||||||
import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
|
import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
|
||||||
|
import { editShortUrl } from '../reducers/shortUrlEdition';
|
||||||
|
|
||||||
const provideServices = (bottle, connect) => {
|
const provideServices = (bottle, connect) => {
|
||||||
// Components
|
// Components
|
||||||
@@ -24,45 +26,49 @@ const provideServices = (bottle, connect) => {
|
|||||||
(state) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList)
|
(state) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList)
|
||||||
));
|
));
|
||||||
|
|
||||||
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
|
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator', 'ForServerVersion');
|
||||||
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams', 'selectedServer' ], [ 'listShortUrls' ]));
|
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
|
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
|
||||||
bottle.decorator('ShortUrlsList', connect(
|
bottle.decorator('ShortUrlsList', connect(
|
||||||
[ 'selectedServer', 'shortUrlsListParams' ],
|
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo', 'settings' ],
|
||||||
[ 'listShortUrls', 'resetShortUrlParams' ]
|
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisit', 'loadMercureInfo' ]
|
||||||
));
|
));
|
||||||
|
|
||||||
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'stateFlagTimeout');
|
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout');
|
||||||
|
|
||||||
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'EditTagsModal', 'EditMetaModal');
|
bottle.serviceFactory(
|
||||||
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'stateFlagTimeout');
|
'ShortUrlsRowMenu',
|
||||||
|
ShortUrlsRowMenu,
|
||||||
|
'DeleteShortUrlModal',
|
||||||
|
'EditTagsModal',
|
||||||
|
'EditMetaModal',
|
||||||
|
'EditShortUrlModal',
|
||||||
|
'ForServerVersion'
|
||||||
|
);
|
||||||
|
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useStateFlagTimeout');
|
||||||
|
|
||||||
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult');
|
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult', 'ForServerVersion');
|
||||||
bottle.decorator(
|
bottle.decorator(
|
||||||
'CreateShortUrl',
|
'CreateShortUrl',
|
||||||
connect([ 'shortUrlCreationResult', 'selectedServer' ], [ 'createShortUrl', 'resetCreateShortUrl' ])
|
connect([ 'shortUrlCreationResult', 'selectedServer' ], [ 'createShortUrl', 'resetCreateShortUrl' ])
|
||||||
);
|
);
|
||||||
|
|
||||||
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
|
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
|
||||||
bottle.decorator('DeleteShortUrlModal', connect(
|
bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ]));
|
||||||
[ 'shortUrlDeletion' ],
|
|
||||||
[ 'deleteShortUrl', 'resetDeleteShortUrl', 'shortUrlDeleted' ]
|
|
||||||
));
|
|
||||||
|
|
||||||
bottle.serviceFactory('EditTagsModal', EditTagsModal, 'TagsSelector');
|
bottle.serviceFactory('EditTagsModal', EditTagsModal, 'TagsSelector');
|
||||||
bottle.decorator('EditTagsModal', connect(
|
bottle.decorator('EditTagsModal', connect([ 'shortUrlTags' ], [ 'editShortUrlTags', 'resetShortUrlsTags' ]));
|
||||||
[ 'shortUrlTags' ],
|
|
||||||
[ 'editShortUrlTags', 'resetShortUrlsTags', 'shortUrlTagsEdited' ]
|
|
||||||
));
|
|
||||||
|
|
||||||
bottle.serviceFactory('EditMetaModal', () => EditMetaModal);
|
bottle.serviceFactory('EditMetaModal', () => EditMetaModal);
|
||||||
bottle.decorator('EditMetaModal', connect([ 'shortUrlMeta' ], [ 'editShortUrlMeta', 'resetShortUrlMeta' ]));
|
bottle.decorator('EditMetaModal', connect([ 'shortUrlMeta' ], [ 'editShortUrlMeta', 'resetShortUrlMeta' ]));
|
||||||
|
|
||||||
|
bottle.serviceFactory('EditShortUrlModal', () => EditShortUrlModal);
|
||||||
|
bottle.decorator('EditShortUrlModal', connect([ 'shortUrlEdition' ], [ 'editShortUrl' ]));
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
|
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
|
||||||
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);
|
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);
|
||||||
bottle.serviceFactory('shortUrlTagsEdited', () => shortUrlTagsEdited);
|
|
||||||
|
|
||||||
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
||||||
bottle.serviceFactory('resetShortUrlParams', () => resetShortUrlParams);
|
bottle.serviceFactory('resetShortUrlParams', () => resetShortUrlParams);
|
||||||
@@ -72,10 +78,11 @@ const provideServices = (bottle, connect) => {
|
|||||||
|
|
||||||
bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient');
|
bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient');
|
||||||
bottle.serviceFactory('resetDeleteShortUrl', () => resetDeleteShortUrl);
|
bottle.serviceFactory('resetDeleteShortUrl', () => resetDeleteShortUrl);
|
||||||
bottle.serviceFactory('shortUrlDeleted', () => shortUrlDeleted);
|
|
||||||
|
|
||||||
bottle.serviceFactory('editShortUrlMeta', editShortUrlMeta, 'buildShlinkApiClient');
|
bottle.serviceFactory('editShortUrlMeta', editShortUrlMeta, 'buildShlinkApiClient');
|
||||||
bottle.serviceFactory('resetShortUrlMeta', () => resetShortUrlMeta);
|
bottle.serviceFactory('resetShortUrlMeta', () => resetShortUrlMeta);
|
||||||
|
|
||||||
|
bottle.serviceFactory('editShortUrl', editShortUrl, 'buildShlinkApiClient');
|
||||||
};
|
};
|
||||||
|
|
||||||
export default provideServices;
|
export default provideServices;
|
||||||
|
|||||||
@@ -1,47 +1,84 @@
|
|||||||
import { Card, CardBody } from 'reactstrap';
|
import { Card, CardHeader, CardBody, Button, Collapse } from 'reactstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faTrash as deleteIcon, faPencilAlt as editIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faTrash as deleteIcon, faPencilAlt as editIcon, faLink, faEye } from '@fortawesome/free-solid-svg-icons';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { serverType } from '../servers/prop-types';
|
||||||
|
import { prettify } from '../utils/helpers/numbers';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
import TagBullet from './helpers/TagBullet';
|
import TagBullet from './helpers/TagBullet';
|
||||||
import './TagCard.scss';
|
import './TagCard.scss';
|
||||||
|
|
||||||
const TagCard = (DeleteTagConfirmModal, EditTagModal, colorGenerator) => class TagCard extends React.Component {
|
const propTypes = {
|
||||||
static propTypes = {
|
tag: PropTypes.string,
|
||||||
tag: PropTypes.string,
|
tagStats: PropTypes.shape({
|
||||||
currentServerId: PropTypes.string,
|
shortUrlsCount: PropTypes.number,
|
||||||
};
|
visitsCount: PropTypes.number,
|
||||||
|
}),
|
||||||
|
selectedServer: serverType,
|
||||||
|
displayed: PropTypes.bool,
|
||||||
|
toggle: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
state = { isDeleteModalOpen: false, isEditModalOpen: false };
|
const TagCard = (DeleteTagConfirmModal, EditTagModal, ForServerVersion, colorGenerator) => {
|
||||||
|
const TagCardComp = ({ tag, tagStats, selectedServer, displayed, toggle }) => {
|
||||||
|
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||||
|
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
||||||
|
|
||||||
render() {
|
const { id } = selectedServer;
|
||||||
const { tag, currentServerId } = this.props;
|
const shortUrlsLink = `/server/${id}/list-short-urls/1?tag=${tag}`;
|
||||||
const toggleDelete = () =>
|
|
||||||
this.setState(({ isDeleteModalOpen }) => ({ isDeleteModalOpen: !isDeleteModalOpen }));
|
|
||||||
const toggleEdit = () =>
|
|
||||||
this.setState(({ isEditModalOpen }) => ({ isEditModalOpen: !isEditModalOpen }));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="tag-card">
|
<Card className="tag-card">
|
||||||
<CardBody className="tag-card__body">
|
<CardHeader className="tag-card__header">
|
||||||
<button className="btn btn-light btn-sm tag-card__btn tag-card__btn--last" onClick={toggleDelete}>
|
<Button color="light" size="sm" className="tag-card__btn tag-card__btn--last" onClick={toggleDelete}>
|
||||||
<FontAwesomeIcon icon={deleteIcon} />
|
<FontAwesomeIcon icon={deleteIcon} />
|
||||||
</button>
|
</Button>
|
||||||
<button className="btn btn-light btn-sm tag-card__btn" onClick={toggleEdit}>
|
<Button color="light" size="sm" className="tag-card__btn" onClick={toggleEdit}>
|
||||||
<FontAwesomeIcon icon={editIcon} />
|
<FontAwesomeIcon icon={editIcon} />
|
||||||
</button>
|
</Button>
|
||||||
<h5 className="tag-card__tag-title">
|
<h5 className="tag-card__tag-title text-ellipsis">
|
||||||
<TagBullet tag={tag} colorGenerator={colorGenerator} />
|
<TagBullet tag={tag} colorGenerator={colorGenerator} />
|
||||||
<Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>{tag}</Link>
|
<ForServerVersion minVersion="2.2.0">
|
||||||
|
<span className="tag-card__tag-name" onClick={toggle}>{tag}</span>
|
||||||
|
</ForServerVersion>
|
||||||
|
<ForServerVersion maxVersion="2.1.*">
|
||||||
|
<Link to={shortUrlsLink}>{tag}</Link>
|
||||||
|
</ForServerVersion>
|
||||||
</h5>
|
</h5>
|
||||||
</CardBody>
|
</CardHeader>
|
||||||
|
|
||||||
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={this.state.isDeleteModalOpen} />
|
{tagStats && (
|
||||||
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={this.state.isEditModalOpen} />
|
<Collapse isOpen={displayed}>
|
||||||
|
<CardBody className="tag-card__body">
|
||||||
|
<Link
|
||||||
|
to={shortUrlsLink}
|
||||||
|
className="btn btn-light btn-block d-flex justify-content-between align-items-center mb-1"
|
||||||
|
>
|
||||||
|
<span className="text-ellipsis"><FontAwesomeIcon icon={faLink} className="mr-2" />Short URLs</span>
|
||||||
|
<b>{prettify(tagStats.shortUrlsCount)}</b>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to={`/server/${id}/tag/${tag}/visits`}
|
||||||
|
className="btn btn-light btn-block d-flex justify-content-between align-items-center"
|
||||||
|
>
|
||||||
|
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
|
||||||
|
<b>{prettify(tagStats.visitsCount)}</b>
|
||||||
|
</Link>
|
||||||
|
</CardBody>
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
||||||
|
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
TagCardComp.propTypes = propTypes;
|
||||||
|
|
||||||
|
return TagCardComp;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TagCard;
|
export default TagCard;
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
.tag-card.tag-card {
|
.tag-card.tag-card {
|
||||||
background-color: #eee;
|
|
||||||
margin-bottom: .5rem;
|
margin-bottom: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-card__header.tag-card__header {
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-card__header.tag-card__header,
|
||||||
.tag-card__body.tag-card__body {
|
.tag-card__body.tag-card__body {
|
||||||
padding: .75rem;
|
padding: .75rem;
|
||||||
}
|
}
|
||||||
@@ -10,9 +14,6 @@
|
|||||||
.tag-card__tag-title {
|
.tag-card__tag-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 31px;
|
line-height: 31px;
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
padding-right: 5px;
|
padding-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,3 +24,17 @@
|
|||||||
.tag-card__btn--last {
|
.tag-card__btn--last {
|
||||||
margin-left: 3px;
|
margin-left: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-card__table-cell.tag-card__table-cell {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-card__tag-name {
|
||||||
|
color: #007bff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-card__tag-name:hover {
|
||||||
|
color: #0056b3;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,84 +1,97 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { splitEvery } from 'ramda';
|
import { splitEvery } from 'ramda';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import MuttedMessage from '../utils/MuttedMessage';
|
import Message from '../utils/Message';
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
|
import { serverType } from '../servers/prop-types';
|
||||||
|
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
|
||||||
|
import { SettingsType } from '../settings/reducers/settings';
|
||||||
|
import { bindToMercureTopic } from '../mercure/helpers';
|
||||||
|
import { TagsListType } from './reducers/tagsList';
|
||||||
|
|
||||||
const { ceil } = Math;
|
const { ceil } = Math;
|
||||||
const TAGS_GROUPS_AMOUNT = 4;
|
const TAGS_GROUPS_AMOUNT = 4;
|
||||||
|
|
||||||
const TagsList = (TagCard) => class TagsList extends React.Component {
|
const propTypes = {
|
||||||
static propTypes = {
|
filterTags: PropTypes.func,
|
||||||
filterTags: PropTypes.func,
|
forceListTags: PropTypes.func,
|
||||||
forceListTags: PropTypes.func,
|
tagsList: TagsListType,
|
||||||
tagsList: PropTypes.shape({
|
selectedServer: serverType,
|
||||||
loading: PropTypes.bool,
|
createNewVisit: PropTypes.func,
|
||||||
error: PropTypes.bool,
|
loadMercureInfo: PropTypes.func,
|
||||||
filteredTags: PropTypes.arrayOf(PropTypes.string),
|
mercureInfo: MercureInfoType,
|
||||||
}),
|
settings: SettingsType,
|
||||||
match: PropTypes.object,
|
};
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount() {
|
const TagsList = (TagCard) => {
|
||||||
const { forceListTags } = this.props;
|
const TagListComp = (
|
||||||
|
{ filterTags, forceListTags, tagsList, selectedServer, createNewVisit, loadMercureInfo, mercureInfo, settings }
|
||||||
|
) => {
|
||||||
|
const { realTimeUpdates } = settings;
|
||||||
|
const [ displayedTag, setDisplayedTag ] = useState();
|
||||||
|
|
||||||
forceListTags();
|
useEffect(() => {
|
||||||
}
|
forceListTags();
|
||||||
|
}, []);
|
||||||
|
useEffect(
|
||||||
|
bindToMercureTopic(mercureInfo, realTimeUpdates, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo),
|
||||||
|
[ mercureInfo ]
|
||||||
|
);
|
||||||
|
|
||||||
renderContent() {
|
const renderContent = () => {
|
||||||
const { tagsList, match } = this.props;
|
if (tagsList.loading) {
|
||||||
|
return <Message noMargin loading />;
|
||||||
|
}
|
||||||
|
|
||||||
if (tagsList.loading) {
|
if (tagsList.error) {
|
||||||
return <MuttedMessage marginSize={0}>Loading...</MuttedMessage>;
|
return (
|
||||||
}
|
<div className="col-12">
|
||||||
|
<div className="bg-danger p-2 text-white text-center">Error loading tags :(</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagsCount = tagsList.filteredTags.length;
|
||||||
|
|
||||||
|
if (tagsCount < 1) {
|
||||||
|
return <Message>No tags found</Message>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
||||||
|
|
||||||
if (tagsList.error) {
|
|
||||||
return (
|
return (
|
||||||
<div className="col-12">
|
<React.Fragment>
|
||||||
<div className="bg-danger p-2 text-white text-center">Error loading tags :(</div>
|
{tagsGroups.map((group, index) => (
|
||||||
</div>
|
<div key={index} className="col-md-6 col-xl-3">
|
||||||
|
{group.map((tag) => (
|
||||||
|
<TagCard
|
||||||
|
key={tag}
|
||||||
|
tag={tag}
|
||||||
|
tagStats={tagsList.stats[tag]}
|
||||||
|
selectedServer={selectedServer}
|
||||||
|
displayed={displayedTag === tag}
|
||||||
|
toggle={() => setDisplayedTag(displayedTag !== tag ? tag : undefined)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const tagsCount = tagsList.filteredTags.length;
|
|
||||||
|
|
||||||
if (tagsCount < 1) {
|
|
||||||
return <MuttedMessage>No tags found</MuttedMessage>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
{tagsGroups.map((group, index) => (
|
{!tagsList.loading && <SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />}
|
||||||
<div key={index} className="col-md-6 col-xl-3">
|
<div className="row">
|
||||||
{group.map((tag) => (
|
{renderContent()}
|
||||||
<TagCard
|
</div>
|
||||||
key={tag}
|
|
||||||
tag={tag}
|
|
||||||
currentServerId={match.params.serverId}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
TagListComp.propTypes = propTypes;
|
||||||
const { filterTags } = this.props;
|
|
||||||
|
|
||||||
return (
|
return TagListComp;
|
||||||
<div className="shlink-container">
|
|
||||||
{!this.props.tagsList.loading &&
|
|
||||||
<SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />
|
|
||||||
}
|
|
||||||
<div className="row">
|
|
||||||
{this.renderContent()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TagsList;
|
export default TagsList;
|
||||||
|
|||||||
@@ -3,64 +3,45 @@ import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { tagDeleteType } from '../reducers/tagDelete';
|
import { tagDeleteType } from '../reducers/tagDelete';
|
||||||
|
|
||||||
export default class DeleteTagConfirmModal extends React.Component {
|
const propTypes = {
|
||||||
static propTypes = {
|
tag: PropTypes.string.isRequired,
|
||||||
tag: PropTypes.string.isRequired,
|
toggle: PropTypes.func.isRequired,
|
||||||
toggle: PropTypes.func.isRequired,
|
isOpen: PropTypes.bool.isRequired,
|
||||||
isOpen: PropTypes.bool.isRequired,
|
deleteTag: PropTypes.func,
|
||||||
deleteTag: PropTypes.func,
|
tagDelete: tagDeleteType,
|
||||||
tagDelete: tagDeleteType,
|
tagDeleted: PropTypes.func,
|
||||||
tagDeleted: PropTypes.func,
|
};
|
||||||
};
|
|
||||||
|
|
||||||
doDelete = async () => {
|
|
||||||
const { tag, toggle, deleteTag } = this.props;
|
|
||||||
|
|
||||||
|
const DeleteTagConfirmModal = ({ tag, toggle, isOpen, deleteTag, tagDelete, tagDeleted }) => {
|
||||||
|
const doDelete = async () => {
|
||||||
await deleteTag(tag);
|
await deleteTag(tag);
|
||||||
this.tagWasDeleted = true;
|
tagDeleted(tag);
|
||||||
toggle();
|
toggle();
|
||||||
};
|
};
|
||||||
handleOnClosed = () => {
|
|
||||||
if (!this.tagWasDeleted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { tagDeleted, tag } = this.props;
|
return (
|
||||||
|
<Modal toggle={toggle} isOpen={isOpen} centered>
|
||||||
|
<ModalHeader toggle={toggle}>
|
||||||
|
<span className="text-danger">Delete tag</span>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
Are you sure you want to delete tag <b>{tag}</b>?
|
||||||
|
{tagDelete.error && (
|
||||||
|
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||||
|
Something went wrong while deleting the tag :(
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<button className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||||
|
<button className="btn btn-danger" disabled={tagDelete.deleting} onClick={doDelete}>
|
||||||
|
{tagDelete.deleting ? 'Deleting tag...' : 'Delete tag'}
|
||||||
|
</button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
tagDeleted(tag);
|
DeleteTagConfirmModal.propTypes = propTypes;
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount() {
|
export default DeleteTagConfirmModal;
|
||||||
this.tagWasDeleted = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { tag, toggle, isOpen, tagDelete } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal toggle={toggle} isOpen={isOpen} centered onClosed={this.handleOnClosed}>
|
|
||||||
<ModalHeader toggle={toggle}>
|
|
||||||
<span className="text-danger">Delete tag</span>
|
|
||||||
</ModalHeader>
|
|
||||||
<ModalBody>
|
|
||||||
Are you sure you want to delete tag <b>{tag}</b>?
|
|
||||||
{tagDelete.error && (
|
|
||||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
|
||||||
Something went wrong while deleting the tag :(
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter>
|
|
||||||
<button className="btn btn-link" onClick={toggle}>Cancel</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-danger"
|
|
||||||
disabled={tagDelete.deleting}
|
|
||||||
onClick={() => this.doDelete()}
|
|
||||||
>
|
|
||||||
{tagDelete.deleting ? 'Deleting tag...' : 'Delete tag'}
|
|
||||||
</button>
|
|
||||||
</ModalFooter>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,109 +1,62 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
||||||
import { ChromePicker } from 'react-color';
|
import { ChromePicker } from 'react-color';
|
||||||
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import './EditTagModal.scss';
|
import './EditTagModal.scss';
|
||||||
|
import { useToggle } from '../../utils/helpers/hooks';
|
||||||
|
|
||||||
const EditTagModal = ({ getColorForKey }) => class EditTagModal extends React.Component {
|
const propTypes = {
|
||||||
static propTypes = {
|
tag: PropTypes.string,
|
||||||
tag: PropTypes.string,
|
editTag: PropTypes.func,
|
||||||
editTag: PropTypes.func,
|
toggle: PropTypes.func,
|
||||||
toggle: PropTypes.func,
|
tagEdited: PropTypes.func,
|
||||||
tagEdited: PropTypes.func,
|
isOpen: PropTypes.bool,
|
||||||
isOpen: PropTypes.bool,
|
tagEdit: PropTypes.shape({
|
||||||
tagEdit: PropTypes.shape({
|
error: PropTypes.bool,
|
||||||
error: PropTypes.bool,
|
editing: PropTypes.bool,
|
||||||
editing: PropTypes.bool,
|
}),
|
||||||
}),
|
};
|
||||||
};
|
|
||||||
|
|
||||||
saveTag = (e) => {
|
const EditTagModal = ({ getColorForKey }) => {
|
||||||
e.preventDefault();
|
const EditTagModalComp = ({ tag, editTag, toggle, tagEdited, isOpen, tagEdit }) => {
|
||||||
const { tag: oldName, editTag, toggle } = this.props;
|
const [ newTagName, setNewTagName ] = useState(tag);
|
||||||
const { tag: newName, color } = this.state;
|
const [ color, setColor ] = useState(getColorForKey(tag));
|
||||||
|
const [ showColorPicker, toggleColorPicker ] = useToggle();
|
||||||
|
const saveTag = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
editTag(oldName, newName, color)
|
editTag(tag, newTagName, color)
|
||||||
.then(() => {
|
.then(() => tagEdited(tag, newTagName, color))
|
||||||
this.tagWasEdited = true;
|
.then(toggle)
|
||||||
toggle();
|
.catch(() => {});
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
};
|
|
||||||
handleOnClosed = () => {
|
|
||||||
if (!this.tagWasEdited) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { tag: oldName, tagEdited } = this.props;
|
|
||||||
const { tag: newName, color } = this.state;
|
|
||||||
|
|
||||||
tagEdited(oldName, newName, color);
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
const { tag } = props;
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
showColorPicker: false,
|
|
||||||
tag,
|
|
||||||
color: getColorForKey(tag),
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.tagWasEdited = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { isOpen, toggle, tagEdit } = this.props;
|
|
||||||
const { tag, color } = this.state;
|
|
||||||
const toggleColorPicker = () =>
|
|
||||||
this.setState(({ showColorPicker }) => ({ showColorPicker: !showColorPicker }));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={this.handleOnClosed}>
|
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||||
<form onSubmit={(e) => this.saveTag(e)}>
|
<form onSubmit={saveTag}>
|
||||||
<ModalHeader toggle={toggle}>Edit tag</ModalHeader>
|
<ModalHeader toggle={toggle}>Edit tag</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<div
|
<div className="input-group-prepend" id="colorPickerBtn" onClick={toggleColorPicker}>
|
||||||
className="input-group-prepend"
|
|
||||||
id="colorPickerBtn"
|
|
||||||
onClick={toggleColorPicker}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="input-group-text edit-tag-modal__color-picker-toggle"
|
className="input-group-text edit-tag-modal__color-picker-toggle"
|
||||||
style={{
|
style={{ backgroundColor: color, borderColor: color }}
|
||||||
backgroundColor: color,
|
|
||||||
borderColor: color,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={colorIcon} className="edit-tag-modal__color-icon" />
|
<FontAwesomeIcon icon={colorIcon} className="edit-tag-modal__color-icon" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Popover
|
<Popover isOpen={showColorPicker} toggle={toggleColorPicker} target="colorPickerBtn" placement="right">
|
||||||
isOpen={this.state.showColorPicker}
|
<ChromePicker color={color} disableAlpha onChange={({ hex }) => setColor(hex)} />
|
||||||
toggle={toggleColorPicker}
|
|
||||||
target="colorPickerBtn"
|
|
||||||
placement="right"
|
|
||||||
>
|
|
||||||
<ChromePicker
|
|
||||||
color={color}
|
|
||||||
disableAlpha
|
|
||||||
onChange={(color) => this.setState({ color: color.hex })}
|
|
||||||
/>
|
|
||||||
</Popover>
|
</Popover>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={tag}
|
value={newTagName}
|
||||||
placeholder="Tag"
|
placeholder="Tag"
|
||||||
required
|
required
|
||||||
className="form-control"
|
className="form-control"
|
||||||
onChange={(e) => this.setState({ tag: e.target.value })}
|
onChange={(e) => setNewTagName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,7 +75,11 @@ const EditTagModal = ({ getColorForKey }) => class EditTagModal extends React.Co
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
EditTagModalComp.propTypes = propTypes;
|
||||||
|
|
||||||
|
return EditTagModalComp;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EditTagModal;
|
export default EditTagModal;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import './Tag.scss';
|
|
||||||
import { colorGeneratorType } from '../../utils/services/ColorGenerator';
|
import { colorGeneratorType } from '../../utils/services/ColorGenerator';
|
||||||
|
import './Tag.scss';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
text: PropTypes.string,
|
text: PropTypes.string,
|
||||||
@@ -17,12 +17,12 @@ const Tag = ({
|
|||||||
children,
|
children,
|
||||||
clearable,
|
clearable,
|
||||||
colorGenerator,
|
colorGenerator,
|
||||||
onClick = () => {},
|
onClick,
|
||||||
onClose = () => {},
|
onClose,
|
||||||
}) => (
|
}) => (
|
||||||
<span
|
<span
|
||||||
className="badge tag"
|
className="badge tag"
|
||||||
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable ? 'auto' : 'pointer' }}
|
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable || !onClick ? 'auto' : 'pointer' }}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{children || text}
|
{children || text}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
.tag {
|
.tag {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag:not(:last-child) {
|
.tag:not(:last-child) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import TagsInput from 'react-tagsinput';
|
import TagsInput from 'react-tagsinput';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import Autosuggest from 'react-autosuggest';
|
import Autosuggest from 'react-autosuggest';
|
||||||
@@ -6,28 +6,23 @@ import { identity } from 'ramda';
|
|||||||
import TagBullet from './TagBullet';
|
import TagBullet from './TagBullet';
|
||||||
import './TagsSelector.scss';
|
import './TagsSelector.scss';
|
||||||
|
|
||||||
const TagsSelector = (colorGenerator) => class TagsSelector extends React.Component {
|
const propTypes = {
|
||||||
static propTypes = {
|
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||||
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
onChange: PropTypes.func.isRequired,
|
listTags: PropTypes.func,
|
||||||
listTags: PropTypes.func,
|
placeholder: PropTypes.string,
|
||||||
placeholder: PropTypes.string,
|
tagsList: PropTypes.shape({
|
||||||
tagsList: PropTypes.shape({
|
tags: PropTypes.arrayOf(PropTypes.string),
|
||||||
tags: PropTypes.arrayOf(PropTypes.string),
|
}),
|
||||||
}),
|
};
|
||||||
};
|
|
||||||
static defaultProps = {
|
|
||||||
placeholder: 'Add tags to the URL',
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount() {
|
const TagsSelector = (colorGenerator) => {
|
||||||
const { listTags } = this.props;
|
const TagsSelectorComp = ({ tags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
listTags();
|
||||||
|
}, []);
|
||||||
|
|
||||||
listTags();
|
// eslint-disable-next-line
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { tags, onChange, placeholder, tagsList } = this.props;
|
|
||||||
const renderTag = ({ tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other }) => (
|
const renderTag = ({ tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other }) => (
|
||||||
<span key={key} style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} {...other}>
|
<span key={key} style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} {...other}>
|
||||||
{getTagDisplayValue(tag)}
|
{getTagDisplayValue(tag)}
|
||||||
@@ -40,7 +35,6 @@ const TagsSelector = (colorGenerator) => class TagsSelector extends React.Compon
|
|||||||
method === 'enter' ? e.preventDefault() : otherProps.onChange(e);
|
method === 'enter' ? e.preventDefault() : otherProps.onChange(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line no-extra-parens
|
|
||||||
const inputValue = (otherProps.value && otherProps.value.trim().toLowerCase()) || '';
|
const inputValue = (otherProps.value && otherProps.value.trim().toLowerCase()) || '';
|
||||||
const inputLength = inputValue.length;
|
const inputLength = inputValue.length;
|
||||||
const suggestions = tagsList.tags.filter((state) => state.toLowerCase().slice(0, inputLength) === inputValue);
|
const suggestions = tagsList.tags.filter((state) => state.toLowerCase().slice(0, inputLength) === inputValue);
|
||||||
@@ -75,13 +69,16 @@ const TagsSelector = (colorGenerator) => class TagsSelector extends React.Compon
|
|||||||
onlyUnique
|
onlyUnique
|
||||||
renderTag={renderTag}
|
renderTag={renderTag}
|
||||||
renderInput={renderAutocompleteInput}
|
renderInput={renderAutocompleteInput}
|
||||||
|
|
||||||
// FIXME Workaround to be able to add tags on Android
|
// FIXME Workaround to be able to add tags on Android
|
||||||
addOnBlur
|
addOnBlur
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
TagsSelectorComp.propTypes = propTypes;
|
||||||
|
|
||||||
|
return TagsSelectorComp;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TagsSelector;
|
export default TagsSelector;
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ export default handleActions({
|
|||||||
|
|
||||||
export const deleteTag = (buildShlinkApiClient) => (tag) => async (dispatch, getState) => {
|
export const deleteTag = (buildShlinkApiClient) => (tag) => async (dispatch, getState) => {
|
||||||
dispatch({ type: DELETE_TAG_START });
|
dispatch({ type: DELETE_TAG_START });
|
||||||
|
const { deleteTags } = buildShlinkApiClient(getState);
|
||||||
const { deleteTags } = await buildShlinkApiClient(getState);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteTags([ tag ]);
|
await deleteTags([ tag ]);
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ export const editTag = (buildShlinkApiClient, colorGenerator) => (oldName, newNa
|
|||||||
getState
|
getState
|
||||||
) => {
|
) => {
|
||||||
dispatch({ type: EDIT_TAG_START });
|
dispatch({ type: EDIT_TAG_START });
|
||||||
|
const { editTag } = buildShlinkApiClient(getState);
|
||||||
const { editTag } = await buildShlinkApiClient(getState);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await editTag(oldName, newName);
|
await editTag(oldName, newName);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user